Files
2026-07-13 13:13:17 +08:00

1374 lines
54 KiB
C#

#nullable enable
using System;
using System.Collections.Generic;
using ManagedBass;
using T3.Core.Animation;
using T3.Core.IO;
using T3.Core.Logging;
using T3.Core.Settings;
using T3.Core.Resource.Assets;
// ReSharper disable MergeIntoPattern
namespace T3.Core.Audio;
/// <summary>
/// Controls loading, playback, and discarding of audio clips.
/// </summary>
public static class AudioEngine
{
// --- Soundtrack (Timeline) Audio ---
internal static readonly Dictionary<AudioClipResourceHandle, SoundtrackClipStream> SoundtrackClipStreams = new();
private static readonly Dictionary<AudioClipResourceHandle, double> _updatedSoundtrackClipTimes = new();
private static readonly List<AudioClipResourceHandle> _obsoleteSoundtrackHandles = new();
// --- Operator Audio ---
private static readonly Dictionary<Guid, OperatorAudioState<StereoOperatorAudioStream>> _stereoOperatorStates = new();
private static readonly Dictionary<Guid, SpatialOperatorState> _spatialOperatorStates = new();
/// <summary>
/// Internal monotonic frame token for stale detection.
/// Incremented at the start of each new frame (detected via Playback.FrameCount change).
/// </summary>
private static long _audioFrameToken;
/// <summary>
/// Tracks the last seen Playback.FrameCount to detect when a new frame has started.
/// When this differs from current Playback.FrameCount, we increment _audioFrameToken.
/// </summary>
private static int _lastSeenPlaybackFrame = -1;
/// <summary>
/// Tracks whether stale check has been performed for the current frame token.
/// </summary>
private static long _lastStaleCheckFrameToken = -1;
// Export state
/// <summary>
/// Gets whether the audio engine is currently in export mode.
/// During export, streams should not be paused when marked stale.
/// </summary>
internal static bool IsExporting { get; private set; }
// 3D Listener
private static Vector3 _listenerPosition = Vector3.Zero;
private static Vector3 _listenerVelocity = Vector3.Zero;
private static Vector3 _listenerForward = new(0, 0, 1);
private static Vector3 _listenerUp = new(0, 1, 0);
private static bool _3dInitialized;
private static bool _3dListenerDirty;
private static bool _3dApplyNeeded;
private static double _lastPlaybackSpeed = 1;
private static bool _bassInitialized;
private static bool _bassInitFailed;
/// <summary>
/// Common state for operator audio streams using mixer (stereo).
/// </summary>
private sealed class OperatorAudioState<T> where T : OperatorAudioStreamBase
{
public T? Stream;
public string CurrentFilePath = string.Empty;
public bool IsPaused;
public bool PreviousPlay;
public bool PreviousStop;
public bool IsStale;
/// <summary>
/// The pending normalized seek position (0.0 to 1.0) to apply on the next play trigger.
/// Seek changes are stored here but not applied until playback is triggered.
/// </summary>
public float PendingSeek;
/// <summary>
/// The frame token when this operator was last updated.
/// Used for stale detection - if this doesn't match the current _audioFrameToken,
/// the operator is considered stale.
/// </summary>
public long LastUpdatedFrameId = -1;
/// <summary>
/// The file path that previously failed to load, if any.
/// Used to avoid repeated load attempts and error logging for the same invalid file.
/// </summary>
public string? FailedFilePath;
/// <summary>
/// The error message from the last failed load attempt.
/// Cleared when a new file path is set or when explicitly cleared.
/// </summary>
public string? LastLoadError;
}
/// <summary>
/// State for spatial operator audio streams (native 3D, not using mixer).
/// </summary>
private sealed class SpatialOperatorState
{
public SpatialOperatorAudioStream? Stream;
public string CurrentFilePath = string.Empty;
public bool IsPaused;
public bool PreviousPlay;
public bool PreviousStop;
public bool IsStale;
/// <summary>
/// The pending normalized seek position (0.0 to 1.0) to apply on the next play trigger.
/// Seek changes are stored here but not applied until playback is triggered.
/// </summary>
public float PendingSeek;
/// <summary>
/// The frame token when this operator was last updated.
/// Used for stale detection - if this doesn't match the current _audioFrameToken,
/// the operator is considered stale.
/// </summary>
public long LastUpdatedFrameId = -1;
/// <summary>
/// The file path that previously failed to load, if any.
/// Used to avoid repeated load attempts and error logging for the same invalid file.
/// </summary>
public string? FailedFilePath;
/// <summary>
/// The error message from the last failed load attempt.
/// Cleared when a new file path is set or when explicitly cleared.
/// </summary>
public string? LastLoadError;
}
#region Soundtrack Management
/// <summary>
/// Registers a soundtrack clip to be used at the specified time during the current frame.
/// </summary>
/// <param name="handle">The audio clip resource handle.</param>
/// <param name="time">The playback time in seconds.</param>
public static void UseSoundtrackClip(AudioClipResourceHandle handle, double time)
{
_updatedSoundtrackClipTimes[handle] = time;
}
/// <summary>
/// Reloads a soundtrack clip by freeing the existing stream and re-registering it.
/// </summary>
/// <param name="handle">The audio clip resource handle to reload.</param>
public static void ReloadSoundtrackClip(AudioClipResourceHandle handle)
{
if (SoundtrackClipStreams.TryGetValue(handle, out var stream))
{
Bass.StreamFree(stream.StreamHandle);
SoundtrackClipStreams.Remove(handle);
}
// Handles are now cached per clip, so a prior failed attempt would otherwise be sticky.
// An explicit reload should always re-attempt (e.g. after the file path was fixed).
handle.LoadingAttemptFailed = false;
UseSoundtrackClip(handle, 0);
}
/// <summary>
/// Completes the audio processing for the current frame, handling soundtrack clips,
/// FFT analysis, and stale operator detection.
/// </summary>
/// <param name="playback">The current playback state.</param>
/// <param name="frameDurationInSeconds">The duration of the current frame in seconds.</param>
public static void CompleteFrame(Playback playback, double frameDurationInSeconds)
{
EnsureBassInitialized();
ProcessSoundtrackClips(playback, frameDurationInSeconds);
// Process FFT data after filling the buffer from soundtrack
// Skip during export - GetFullMixDownBuffer handles FFT processing during export
// to ensure consistent behavior between soundtrack and external audio modes
if (!playback.IsRenderingToFile &&
playback.Settings is { Enabled: true, Playback.AudioSource: CompositionSettings.AudioSources.ProjectSoundTrack })
AudioAnalysis.ProcessUpdate(playback.Settings.Playback.AudioGainFactor, playback.Settings.Playback.AudioDecayFactor);
StopStaleOperators();
// Ensure the frame token is incremented even when no audio operators update.
// This must be called AFTER StopStaleOperators() so stale detection compares
// against the previous frame's token, not the current one.
EnsureFrameTokenCurrent();
// Apply all 3D audio changes once per frame (batched for performance)
Apply3DChanges();
_obsoleteSoundtrackHandles.Clear();
_updatedSoundtrackClipTimes.Clear();
}
private static void EnsureBassInitialized()
{
if (_bassInitialized || _bassInitFailed) return;
AudioMixerManager.Initialize();
if (AudioMixerManager.OperatorMixerHandle == 0)
{
Log.Error("[AudioEngine] Failed to initialize AudioMixerManager; audio disabled.");
_bassInitFailed = true;
return;
}
_bassInitialized = true;
InitializeGlobalVolumeFromSettings();
}
private static void ProcessSoundtrackClips(Playback playback, double frameDurationInSeconds)
{
// Note: During export, both soundtrack and external audio modes follow the same code path here.
// The only difference is whether soundtrack audio is mixed in GetFullMixDownBuffer.
// This ensures consistent behavior for FFT/metering between modes.
foreach (var (handle, time) in _updatedSoundtrackClipTimes)
{
if (SoundtrackClipStreams.TryGetValue(handle, out var clip))
{
clip.TargetTime = time;
}
else if (!string.IsNullOrEmpty(handle.Clip.AssetPath) &&
SoundtrackClipStream.TryLoadSoundtrackClip(handle, out var soundtrackClipStream))
{
SoundtrackClipStreams[handle] = soundtrackClipStream;
}
}
var playbackSpeedChanged = Math.Abs(_lastPlaybackSpeed - playback.PlaybackSpeed) > 0.001f;
_lastPlaybackSpeed = playback.PlaybackSpeed;
var handledMainSoundtrack = false;
foreach (var (handle, clipStream) in SoundtrackClipStreams)
{
clipStream.IsInUse = _updatedSoundtrackClipTimes.ContainsKey(clipStream.ResourceHandle);
// Free streams that weren't touched this frame. Otherwise a stream whose owner stopped
// calling UseSoundtrackClip stays alive and unpaused, and worse, can win the
// handledMainSoundtrack guard below — starving the active soundtrack of seek/pause
// updates.
if (!clipStream.IsInUse)
{
_obsoleteSoundtrackHandles.Add(handle);
continue;
}
if (!playback.IsRenderingToFile && playbackSpeedChanged)
clipStream.UpdateSoundtrackPlaybackSpeed(playback.PlaybackSpeed);
// Export path: per-clip positioning is done separately in
// AudioRendering.MixSoundtracksFromExportMixer. Here we only route the main
// clip into ExportAudioFrame (which drives FFT for the rendered frames).
if (playback.IsRenderingToFile)
{
if (!handledMainSoundtrack && clipStream.ResourceHandle.Clip.IsMainSoundtrack)
{
handledMainSoundtrack = true;
AudioRendering.ExportAudioFrame(playback, frameDurationInSeconds, clipStream);
}
continue;
}
// Live playback: every clip gets seek/pause/volume sync so multiple clips
// can play simultaneously.
clipStream.UpdateSoundtrackTime(playback);
// FFT routing is still bound to a single "main" clip. The IsMainSoundtrack
// flag bundles waveform + FFT + export concerns; unbundling those is a
// separate later refactor.
if (handledMainSoundtrack || !clipStream.ResourceHandle.Clip.IsMainSoundtrack)
continue;
handledMainSoundtrack = true;
UpdateFftBufferFromSoundtrack(playback);
}
foreach (var handle in _obsoleteSoundtrackHandles)
{
SoundtrackClipStreams[handle].DisableSoundtrackStream();
SoundtrackClipStreams.Remove(handle);
}
// Always update FFT buffer from mixer when in soundtrack mode, even if no soundtrack is loaded.
// This ensures audio metering operators (AudioWaveform, PlaybackFFT, AudioReaction, etc.)
// continue to work with operator-generated audio when no soundtrack is loaded.
if (!handledMainSoundtrack && !playback.IsRenderingToFile)
{
UpdateFftBufferFromSoundtrack(playback);
}
}
/// <summary>
/// Sets the mute state for the soundtrack audio.
/// </summary>
/// <param name="configSoundtrackMute">True to mute the soundtrack, false to unmute.</param>
public static void SetSoundtrackMute(bool configSoundtrackMute) => IsSoundtrackMuted = configSoundtrackMute;
/// <summary>
/// Gets a value indicating whether the soundtrack is currently muted.
/// </summary>
public static bool IsSoundtrackMuted { get; private set; }
/// <summary>
/// Gets a value indicating whether global audio is currently muted.
/// </summary>
public static bool IsGlobalMuted => CoreSettings.Config.AppMute;
internal static void UpdateFftBufferFromSoundtrack(Playback playback)
{
UpdateFftBufferFromSoundtrack(playback, AudioAnalysisContext.Default);
}
/// <summary>
/// Updates FFT and waveform buffers from the soundtrack mixer.
/// </summary>
/// <param name="playback">The current playback state</param>
/// <param name="context">The analysis context to write data into</param>
internal static void UpdateFftBufferFromSoundtrack(Playback playback, AudioAnalysisContext context)
{
if (playback.Settings is not { Playback.AudioSource: CompositionSettings.AudioSources.ProjectSoundTrack })
return;
// During export, the GlobalMixer is paused and empty, so FFT/waveform data
// is populated via WaveFormProcessing.PopulateFromExportBuffer() in AudioRendering.GetFullMixDownBuffer()
if (playback.IsRenderingToFile)
return;
// Get FFT data from the GlobalMixer to include both soundtrack AND operator audio
// This ensures all audio metering (AudioWaveform, AudioFrequencies, AudioReaction, etc.)
// monitors the complete audio output including operator-generated sounds
var mixerHandle = AudioMixerManager.GlobalMixerHandle;
if (mixerHandle == 0)
return;
const int dataFlags = (int)DataFlags.FFT2048;
_ = Bass.ChannelGetData(mixerHandle, context.FftGainBuffer, dataFlags);
if (!context.WaveformRequested)
return;
int lengthInBytes = AudioConfig.WaveformSampleCount << 2 << 1;
context.LastWaveformFetchResult = Bass.ChannelGetData(mixerHandle,
context.InterleavedSampleBuffer, lengthInBytes);
}
/// <summary>
/// Gets the number of audio channels for a soundtrack clip.
/// </summary>
/// <param name="handle">The audio clip resource handle.</param>
/// <returns>The number of channels, or 2 (stereo) if the clip is not found.</returns>
public static int GetClipChannelCount(AudioClipResourceHandle? handle)
{
if (handle == null || !SoundtrackClipStreams.TryGetValue(handle, out var clipStream))
return 2;
Bass.ChannelGetInfo(clipStream.StreamHandle, out var info);
return info.Channels;
}
/// <summary>
/// Gets the sample rate for a soundtrack clip.
/// </summary>
/// <param name="clip">The audio clip resource handle.</param>
/// <returns>The sample rate in Hz, or 48000 if the clip is not found.</returns>
public static int GetClipSampleRate(AudioClipResourceHandle? clip)
{
if (clip == null || !SoundtrackClipStreams.TryGetValue(clip, out var stream))
return 48000;
Bass.ChannelGetInfo(stream.StreamHandle, out var info);
return info.Frequency;
}
#endregion
#region 3D Listener
/// <summary>
/// Converts a System.Numerics.Vector3 to a ManagedBass.Vector3D.
/// </summary>
private static Vector3D ToBassVector(Vector3 v) => new(v.X, v.Y, v.Z);
/// <summary>
/// Sets the 3D listener position and orientation for spatial audio.
/// </summary>
/// <param name="position">The world position of the listener.</param>
/// <param name="forward">The forward direction vector of the listener.</param>
/// <param name="up">The up direction vector of the listener.</param>
public static void Set3DListenerPosition(Vector3 position, Vector3 forward, Vector3 up)
{
// Compute velocity from position delta (assumes ~60fps, will be refined by actual frame time)
var deltaPos = position - _listenerPosition;
_listenerVelocity = deltaPos * 60.0f;
_listenerPosition = position;
_listenerForward = forward;
_listenerUp = up;
_3dListenerDirty = true;
if (!_3dInitialized)
{
Initialize3DAudio();
_3dInitialized = true;
Log.Gated.Audio($"[AudioEngine] 3D audio initialized | Pos: {position}");
}
}
/// <summary>
/// Initializes BASS 3D audio with configured factors.
/// </summary>
private static void Initialize3DAudio()
{
// Set 3D factors for proper world scaling
Bass.Set3DFactors(
AudioConfig.DistanceFactor,
AudioConfig.RolloffFactor,
AudioConfig.DopplerFactor);
// Apply initial listener position
ApplyListenerPosition();
}
/// <summary>
/// Applies the current listener position to BASS.
/// </summary>
private static void ApplyListenerPosition()
{
var pos = ToBassVector(_listenerPosition);
var vel = ToBassVector(_listenerVelocity);
var front = ToBassVector(_listenerForward);
var top = ToBassVector(_listenerUp);
Bass.Set3DPosition(pos, vel, front, top);
_3dListenerDirty = false;
}
/// <summary>
/// Marks that a 3D apply is needed at the end of the frame.
/// Called by spatial streams when their positions are updated.
/// </summary>
internal static void Mark3DApplyNeeded() => _3dApplyNeeded = true;
/// <summary>
/// Applies all pending 3D changes (called once per frame in CompleteFrame).
/// </summary>
private static void Apply3DChanges()
{
if (_3dListenerDirty)
{
ApplyListenerPosition();
}
if (_3dApplyNeeded || _3dListenerDirty)
{
Bass.Apply3D();
_3dApplyNeeded = false;
}
}
/// <summary>
/// Gets the current 3D listener position.
/// </summary>
/// <returns>The world position of the listener.</returns>
public static Vector3 Get3DListenerPosition() => _listenerPosition;
/// <summary>
/// Gets the current 3D listener forward direction.
/// </summary>
/// <returns>The forward direction vector of the listener.</returns>
public static Vector3 Get3DListenerForward() => _listenerForward;
/// <summary>
/// Gets the current 3D listener up direction.
/// </summary>
/// <returns>The up direction vector of the listener.</returns>
public static Vector3 Get3DListenerUp() => _listenerUp;
#endregion
#region Stereo Operator Playback
/// <summary>
/// Updates the playback state of a stereo audio stream for an operator.
/// </summary>
/// <param name="operatorId">The unique identifier of the operator.</param>
/// <param name="filePath">The file path of the audio file to play.</param>
/// <param name="shouldPlay">True to trigger playback on rising edge.</param>
/// <param name="shouldStop">True to trigger stop on rising edge.</param>
/// <param name="volume">The volume level (0.0 to 1.0).</param>
/// <param name="mute">True to mute the stream.</param>
/// <param name="panning">The stereo panning value (-1.0 left to 1.0 right).</param>
/// <param name="speed">The playback speed multiplier (default 1.0).</param>
/// <param name="seek">
/// The normalized seek position (0.0 to 1.0). This value is stored and only applied when
/// playback is triggered via <paramref name="shouldPlay"/>. Changing the seek value during
/// playback has no effect until the next play trigger. This allows setting the seek position
/// and triggering play in the same frame for predictable behavior.
/// </param>
public static void UpdateStereoOperatorPlayback(
Guid operatorId, string filePath, bool shouldPlay, bool shouldStop,
float volume, bool mute, float panning, float speed = 1.0f, float seek = 0f)
{
EnsureFrameTokenCurrent();
if (!EnsureMixerInitialized()) return;
var state = GetOrCreateState(_stereoOperatorStates, operatorId);
// Mark this operator as updated for this frame (used for stale detection)
state.LastUpdatedFrameId = _audioFrameToken;
var resolvedPath = ResolveFilePath(filePath);
if (!HandleFileChange(state, resolvedPath, operatorId,
path => StereoOperatorAudioStream.TryLoadStream(path, AudioMixerManager.OperatorMixerHandle, out var s) ? s : null))
return;
if (state.Stream == null) return;
// Store the pending seek position - will be applied on next play trigger
if (seek >= 0f && seek <= 1f)
state.PendingSeek = seek;
if (HandlePlaybackTriggers(state, shouldPlay, shouldStop, operatorId))
return;
if (state.Stream.IsPlaying)
{
state.Stream.SetVolume(volume, mute);
state.Stream.SetPanning(panning);
state.Stream.SetSpeed(speed);
}
}
/// <summary>Pauses the audio stream for the specified stereo operator.</summary>
/// <param name="operatorId">The unique identifier of the operator.</param>
public static void PauseOperator(Guid operatorId) => PauseOperatorInternal(_stereoOperatorStates, operatorId);
/// <summary>Resumes the audio stream for the specified stereo operator.</summary>
/// <param name="operatorId">The unique identifier of the operator.</param>
public static void ResumeOperator(Guid operatorId) => ResumeOperatorInternal(_stereoOperatorStates, operatorId);
/// <summary>Checks if the audio stream is currently playing for the specified stereo operator.</summary>
/// <param name="operatorId">The unique identifier of the operator.</param>
/// <returns>True if the stream is playing and not paused; otherwise, false.</returns>
public static bool IsOperatorStreamPlaying(Guid operatorId) => IsOperatorPlaying(_stereoOperatorStates, operatorId);
/// <summary>Checks if the audio stream is currently paused for the specified stereo operator.</summary>
/// <param name="operatorId">The unique identifier of the operator.</param>
/// <returns>True if the stream is paused; otherwise, false.</returns>
public static bool IsOperatorPaused(Guid operatorId) => IsOperatorPausedInternal(_stereoOperatorStates, operatorId);
/// <summary>Gets the current audio level for the specified stereo operator.</summary>
/// <param name="operatorId">The unique identifier of the operator.</param>
/// <returns>The current audio level, or 0 if the stream is not found.</returns>
public static float GetOperatorLevel(Guid operatorId) => GetOperatorLevelInternal(_stereoOperatorStates, operatorId);
/// <summary>
/// Attempts to retrieve the stereo audio stream for the specified operator.
/// </summary>
/// <param name="operatorId">The unique identifier of the operator.</param>
/// <param name="stream">When this method returns, contains the stream if found; otherwise, null.</param>
/// <returns>True if the stream was found; otherwise, false.</returns>
public static bool TryGetStereoOperatorStream(Guid operatorId, out StereoOperatorAudioStream? stream)
{
stream = null;
if (_stereoOperatorStates.TryGetValue(operatorId, out var state) && state.Stream != null)
{
stream = state.Stream;
return true;
}
return false;
}
#endregion
#region Spatial Operator Playback
/// <summary>
/// Updates the playback state of a spatial (3D) audio stream for an operator.
/// Spatial streams play directly to BASS output for hardware-accelerated 3D audio.
/// </summary>
/// <param name="operatorId">The unique identifier of the operator.</param>
/// <param name="filePath">The file path of the audio file to play.</param>
/// <param name="shouldPlay">True to trigger playback on rising edge.</param>
/// <param name="shouldStop">True to trigger stop on rising edge.</param>
/// <param name="volume">The volume level (0.0 to 1.0).</param>
/// <param name="mute">True to mute the stream.</param>
/// <param name="position">The 3D world position of the audio source.</param>
/// <param name="minDistance">The distance at which the volume starts to attenuate.</param>
/// <param name="maxDistance">The distance at which the volume reaches minimum.</param>
/// <param name="speed">The playback speed multiplier (default 1.0).</param>
/// <param name="seek">
/// The normalized seek position (0.0 to 1.0). This value is stored and only applied when
/// playback is triggered via <paramref name="shouldPlay"/>. Changing the seek value during
/// playback has no effect until the next play trigger. This allows setting the seek position
/// and triggering play in the same frame for predictable behavior.
/// </param>
/// <param name="orientation">The orientation vector of the sound source for directional audio.</param>
/// <param name="innerConeAngle">The inner cone angle in degrees for directional audio.</param>
/// <param name="outerConeAngle">The outer cone angle in degrees for directional audio.</param>
/// <param name="outerConeVolume">The volume level outside the outer cone.</param>
/// <param name="mode3D">The 3D processing mode.</param>
public static void UpdateSpatialOperatorPlayback(
Guid operatorId, string? filePath, bool shouldPlay, bool shouldStop,
float volume, bool mute, Vector3 position, float minDistance, float maxDistance,
float speed = 1.0f, float seek = 0f, Vector3? orientation = null,
float innerConeAngle = 360f, float outerConeAngle = 360f, float outerConeVolume = 1.0f, int mode3D = 0)
{
EnsureFrameTokenCurrent();
if (!EnsureMixerInitialized()) return;
if (!_3dInitialized)
Set3DListenerPosition(Vector3.Zero, new Vector3(0, 0, 1), new Vector3(0, 1, 0));
var state = GetOrCreateSpatialState(operatorId);
// Mark this operator as updated for this frame (used for stale detection)
state.LastUpdatedFrameId = _audioFrameToken;
var resolvedPath = !string.IsNullOrEmpty(filePath) ? ResolveFilePath(filePath) : string.Empty;
if (!HandleSpatialFileChange(state, resolvedPath, operatorId))
return;
if (state.Stream == null) return;
// Always update 3D position
state.Stream.Update3DPosition(position, minDistance, maxDistance);
// Store the pending seek position - will be applied on next play trigger
if (seek >= 0f && seek <= 1f)
state.PendingSeek = seek;
if (HandleSpatialPlaybackTriggers(state, shouldPlay, shouldStop, operatorId))
return;
if (state.Stream.IsPlaying)
{
state.Stream.SetVolume(volume, mute);
state.Stream.SetSpeed(speed);
if (orientation.HasValue && orientation.Value.Length() > 0.001f)
state.Stream.Set3DOrientation(orientation.Value);
if (Math.Abs(innerConeAngle - 360f) > 0.1f || Math.Abs(outerConeAngle - 360f) > 0.1f || Math.Abs(outerConeVolume - 1.0f) > 0.001f)
state.Stream.Set3DCone(innerConeAngle, outerConeAngle, outerConeVolume);
if (mode3D != 0)
state.Stream.Set3DMode((Mode3D)mode3D);
}
}
/// <summary>Pauses the audio stream for the specified spatial operator.</summary>
/// <param name="operatorId">The unique identifier of the operator.</param>
public static void PauseSpatialOperator(Guid operatorId)
{
if (_spatialOperatorStates.TryGetValue(operatorId, out var state) && state.Stream != null)
{
state.Stream.Pause();
state.IsPaused = true;
}
}
/// <summary>Resumes the audio stream for the specified spatial operator.</summary>
/// <param name="operatorId">The unique identifier of the operator.</param>
public static void ResumeSpatialOperator(Guid operatorId)
{
if (_spatialOperatorStates.TryGetValue(operatorId, out var state) && state.Stream != null)
{
state.Stream.Resume();
state.IsPaused = false;
}
}
/// <summary>Checks if the audio stream is currently playing for the specified spatial operator.</summary>
/// <param name="operatorId">The unique identifier of the operator.</param>
/// <returns>True if the stream is playing and not paused; otherwise, false.</returns>
public static bool IsSpatialOperatorStreamPlaying(Guid operatorId)
{
return _spatialOperatorStates.TryGetValue(operatorId, out var state)
&& state.Stream != null
&& state.Stream.IsPlaying
&& !state.Stream.IsPaused;
}
/// <summary>Checks if the audio stream is currently paused for the specified spatial operator.</summary>
/// <param name="operatorId">The unique identifier of the operator.</param>
/// <returns>True if the stream is paused; otherwise, false.</returns>
public static bool IsSpatialOperatorPaused(Guid operatorId)
{
return _spatialOperatorStates.TryGetValue(operatorId, out var state)
&& state.Stream != null
&& state.IsPaused;
}
/// <summary>Gets the current audio level for the specified spatial operator.</summary>
/// <param name="operatorId">The unique identifier of the operator.</param>
/// <returns>The current audio level, or 0 if the stream is not found.</returns>
public static float GetSpatialOperatorLevel(Guid operatorId)
{
return _spatialOperatorStates.TryGetValue(operatorId, out var state) && state.Stream != null
? state.Stream.GetLevel()
: 0f;
}
/// <summary>
/// Attempts to retrieve the spatial audio stream for the specified operator.
/// </summary>
/// <param name="operatorId">The unique identifier of the operator.</param>
/// <param name="stream">When this method returns, contains the stream if found; otherwise, null.</param>
/// <returns>True if the stream was found; otherwise, false.</returns>
public static bool TryGetSpatialOperatorStream(Guid operatorId, out SpatialOperatorAudioStream? stream)
{
stream = null;
if (_spatialOperatorStates.TryGetValue(operatorId, out var state) && state.Stream != null)
{
stream = state.Stream;
return true;
}
return false;
}
#endregion
#region Common Operator Helpers
private static bool EnsureMixerInitialized()
{
if (AudioMixerManager.IsInitialized) return true;
Log.Gated.Audio("[AudioEngine] Mixer not initialized, initializing...");
AudioMixerManager.Initialize();
if (AudioMixerManager.OperatorMixerHandle == 0)
{
// Don't log every time - Initialize() already logs the failure once
return false;
}
return true;
}
private static OperatorAudioState<T> GetOrCreateState<T>(Dictionary<Guid, OperatorAudioState<T>> states, Guid operatorId)
where T : OperatorAudioStreamBase
{
if (!states.TryGetValue(operatorId, out var state))
{
state = new OperatorAudioState<T>();
states[operatorId] = state;
Log.Gated.Audio($"[AudioEngine] Created audio state for operator: {operatorId}");
}
return state;
}
private static string ResolveFilePath(string filePath)
{
if (string.IsNullOrEmpty(filePath) || System.IO.File.Exists(filePath))
return filePath;
if (AssetRegistry.TryResolveAddress(filePath, null, out var absolutePath, out _))
{
Log.Gated.Audio($"[AudioEngine] Resolved: {filePath} → {absolutePath}");
return absolutePath;
}
// Return original path - HandleFileChange will log an error once if the file doesn't exist
// and cache the failure to avoid repeated logging
return filePath;
}
private static bool HandleFileChange<T>(OperatorAudioState<T> state, string? resolvedPath, Guid operatorId,
Func<string, T?> loadFunc) where T : OperatorAudioStreamBase
{
// If the path hasn't changed, check if this is a previously failed path
if (state.CurrentFilePath == resolvedPath)
{
// If we have a cached error for this path, skip loading without logging again
return state.FailedFilePath == resolvedPath && state.LastLoadError != null || true;
}
Log.Gated.Audio($"[AudioEngine] File changed for {operatorId}: '{state.CurrentFilePath}' → '{resolvedPath}'");
state.Stream?.Dispose();
state.Stream = null;
state.CurrentFilePath = resolvedPath ?? string.Empty;
state.PreviousPlay = false;
state.PreviousStop = false;
// Clear previous load error when path changes
state.FailedFilePath = null;
state.LastLoadError = null;
if (!string.IsNullOrEmpty(resolvedPath))
{
// Check file existence before attempting to load
if (!System.IO.File.Exists(resolvedPath))
{
var error = $"File does not exist: {resolvedPath}";
Log.Error($"[AudioEngine] Failed to load stream for {operatorId}: {error}");
state.FailedFilePath = resolvedPath;
state.LastLoadError = error;
}
else
{
state.Stream = loadFunc(resolvedPath);
if (state.Stream == null)
{
var bassError = Bass.LastError;
var error = $"{resolvedPath} (BASS error: {bassError})";
Log.Error($"[AudioEngine] Failed to load stream for {operatorId}: {error}");
state.FailedFilePath = resolvedPath;
state.LastLoadError = error;
}
}
}
// During export, mark new streams as stale
if (state.Stream == null || !Playback.Current.IsRenderingToFile)
return true;
state.IsStale = true;
state.Stream.SetStale(true);
Log.Gated.Audio($"[AudioEngine] New stream during export - marking stale: {resolvedPath}");
return false;
}
private static bool HandlePlaybackTriggers<T>(OperatorAudioState<T> state, bool shouldPlay, bool shouldStop, Guid operatorId)
where T : OperatorAudioStreamBase
{
var playTrigger = shouldPlay && !state.PreviousPlay;
var stopTrigger = shouldStop && !state.PreviousStop;
state.PreviousPlay = shouldPlay;
state.PreviousStop = shouldStop;
if (stopTrigger)
{
state.Stream!.Stop();
state.IsPaused = false;
state.PendingSeek = 0f;
return true;
}
if (playTrigger)
{
state.Stream!.Stop();
// Apply pending seek position before starting playback
if (state.PendingSeek > 0f && state.PendingSeek <= 1f)
{
var seekTime = (float)(state.PendingSeek * state.Stream.Duration);
state.Stream.Seek(seekTime);
Log.Gated.Audio($"[AudioEngine] Applied pending seek {state.PendingSeek:F3} ({seekTime:F3}s) for {operatorId}");
}
state.Stream.Play();
state.IsPaused = false;
state.IsStale = false;
}
return false;
}
private static void PauseOperatorInternal<T>(Dictionary<Guid, OperatorAudioState<T>> states, Guid operatorId)
where T : OperatorAudioStreamBase
{
if (states.TryGetValue(operatorId, out var state) && state.Stream != null)
{
state.Stream.Pause();
state.IsPaused = true;
}
}
private static void ResumeOperatorInternal<T>(Dictionary<Guid, OperatorAudioState<T>> states, Guid operatorId)
where T : OperatorAudioStreamBase
{
if (states.TryGetValue(operatorId, out var state) && state.Stream != null)
{
state.Stream.Resume();
state.IsPaused = false;
}
}
private static bool IsOperatorPlaying<T>(Dictionary<Guid, OperatorAudioState<T>> states, Guid operatorId)
where T : OperatorAudioStreamBase
{
return states.TryGetValue(operatorId, out var state)
&& state.Stream != null
&& state.Stream.IsPlaying
&& !state.Stream.IsPaused;
}
private static bool IsOperatorPausedInternal<T>(Dictionary<Guid, OperatorAudioState<T>> states, Guid operatorId)
where T : OperatorAudioStreamBase
{
return states.TryGetValue(operatorId, out var state)
&& state.Stream != null
&& state.IsPaused;
}
private static float GetOperatorLevelInternal<T>(Dictionary<Guid, OperatorAudioState<T>> states, Guid operatorId)
where T : OperatorAudioStreamBase
{
return states.TryGetValue(operatorId, out var state) && state.Stream != null
? state.Stream.GetLevel()
: 0f;
}
#endregion
#region Spatial Operator Helpers (Non-Generic)
private static SpatialOperatorState GetOrCreateSpatialState(Guid operatorId)
{
if (!_spatialOperatorStates.TryGetValue(operatorId, out var state))
{
state = new SpatialOperatorState();
_spatialOperatorStates[operatorId] = state;
Log.Gated.Audio($"[AudioEngine] Created spatial audio state for operator: {operatorId}");
}
return state;
}
private static bool HandleSpatialFileChange(SpatialOperatorState state, string? resolvedPath, Guid operatorId)
{
// If the path hasn't changed, check if this is a previously failed path
if (state.CurrentFilePath == resolvedPath)
{
// If we have a cached error for this path, skip loading without logging again
return state.FailedFilePath == resolvedPath && state.LastLoadError != null || true;
}
Log.Gated.Audio($"[AudioEngine] File changed for spatial {operatorId}: '{state.CurrentFilePath}' → '{resolvedPath}'");
state.Stream?.Dispose();
state.Stream = null;
state.CurrentFilePath = resolvedPath ?? string.Empty;
state.PreviousPlay = false;
state.PreviousStop = false;
// Clear previous load error when path changes
state.FailedFilePath = null;
state.LastLoadError = null;
if (!string.IsNullOrEmpty(resolvedPath))
{
// Check file existence before attempting to load
if (!System.IO.File.Exists(resolvedPath))
{
var error = $"File does not exist: {resolvedPath}";
Log.Error($"[AudioEngine] Failed to load spatial stream for {operatorId}: {error}");
state.FailedFilePath = resolvedPath;
state.LastLoadError = error;
}
// Note: mixerHandle parameter is ignored for spatial streams - they play directly to BASS
else if (!SpatialOperatorAudioStream.TryLoadStream(resolvedPath, 0, out var stream))
{
var bassError = Bass.LastError;
var error = $"{resolvedPath} (BASS error: {bassError})";
Log.Error($"[AudioEngine] Failed to load spatial stream for {operatorId}: {error}");
state.FailedFilePath = resolvedPath;
state.LastLoadError = error;
}
else
{
state.Stream = stream;
}
}
// During export, mark new streams as stale
if (state.Stream == null || !Playback.Current.IsRenderingToFile)
return true;
state.IsStale = true;
state.Stream.SetStale(true);
Log.Gated.Audio($"[AudioEngine] New spatial stream during export - marking stale: {resolvedPath}");
return false;
}
private static bool HandleSpatialPlaybackTriggers(SpatialOperatorState state, bool shouldPlay, bool shouldStop, Guid operatorId)
{
var playTrigger = shouldPlay && !state.PreviousPlay;
var stopTrigger = shouldStop && !state.PreviousStop;
state.PreviousPlay = shouldPlay;
state.PreviousStop = shouldStop;
if (stopTrigger)
{
state.Stream!.Stop();
state.IsPaused = false;
state.PendingSeek = 0f;
return true;
}
if (playTrigger)
{
state.Stream!.Stop();
// Apply pending seek position before starting playback
if (state.PendingSeek > 0f && state.PendingSeek <= 1f)
{
var seekTime = (float)(state.PendingSeek * state.Stream.Duration);
state.Stream.Seek(seekTime);
Log.Gated.Audio($"[AudioEngine] Applied pending seek {state.PendingSeek:F3} ({seekTime:F3}s) for spatial {operatorId}");
}
state.Stream.Play();
state.IsPaused = false;
state.IsStale = false;
}
return false;
}
#endregion
/// <summary>
/// Unregisters an operator from audio playback and disposes its associated streams.
/// </summary>
/// <param name="operatorId">The unique identifier of the operator to unregister.</param>
public static void UnregisterOperator(Guid operatorId)
{
if (_stereoOperatorStates.TryGetValue(operatorId, out var stereoState))
{
stereoState.Stream?.Dispose();
_stereoOperatorStates.Remove(operatorId);
}
if (_spatialOperatorStates.TryGetValue(operatorId, out var spatialState))
{
spatialState.Stream?.Dispose();
_spatialOperatorStates.Remove(operatorId);
}
}
/// <summary>
/// Clears the cached load error for an operator, allowing it to retry loading the same file.
/// Call this when the user explicitly requests a reload or when the underlying file may have changed.
/// </summary>
/// <param name="operatorId">The unique identifier of the operator.</param>
public static void ClearOperatorLoadError(Guid operatorId)
{
if (_stereoOperatorStates.TryGetValue(operatorId, out var stereoState))
{
stereoState.FailedFilePath = null;
stereoState.LastLoadError = null;
// Also clear the current file path to force a reload attempt
stereoState.CurrentFilePath = string.Empty;
}
if (_spatialOperatorStates.TryGetValue(operatorId, out var spatialState))
{
spatialState.FailedFilePath = null;
spatialState.LastLoadError = null;
// Also clear the current file path to force a reload attempt
spatialState.CurrentFilePath = string.Empty;
}
}
/// <summary>
/// Gets the last load error for an operator, if any.
/// </summary>
/// <param name="operatorId">The unique identifier of the operator.</param>
/// <returns>The error message if a load failed, null otherwise.</returns>
public static string? GetOperatorLoadError(Guid operatorId)
{
if (_stereoOperatorStates.TryGetValue(operatorId, out var stereoState) && stereoState.LastLoadError != null)
return stereoState.LastLoadError;
if (_spatialOperatorStates.TryGetValue(operatorId, out var spatialState) && spatialState.LastLoadError != null)
return spatialState.LastLoadError;
return null;
}
#region Stale Detection & Export
/// <summary>
/// Checks if a new frame has started and increments the internal frame token if so.
/// Called automatically by operator update methods to ensure frame token is current
/// before recording operator updates.
/// </summary>
private static void EnsureFrameTokenCurrent()
{
if (Playback.Current.IsRenderingToFile) return;
var currentPlaybackFrame = Playback.FrameCount;
if (_lastSeenPlaybackFrame != currentPlaybackFrame)
{
_lastSeenPlaybackFrame = currentPlaybackFrame;
_audioFrameToken++;
}
}
private static void StopStaleOperators()
{
if (Playback.Current.IsRenderingToFile) return;
// Only run stale check once per frame token
if (_lastStaleCheckFrameToken == _audioFrameToken) return;
_lastStaleCheckFrameToken = _audioFrameToken;
UpdateStaleStates(_stereoOperatorStates);
UpdateSpatialStaleStates();
}
private static void UpdateStaleStates<T>(Dictionary<Guid, OperatorAudioState<T>> states)
where T : OperatorAudioStreamBase
{
foreach (var (_, state) in states)
{
if (state.Stream == null) continue;
// An operator is stale if it wasn't updated this frame
bool isStale = (state.LastUpdatedFrameId != _audioFrameToken);
if (state.IsStale != isStale)
{
state.Stream.SetStale(isStale);
state.IsStale = isStale;
}
}
}
private static void UpdateSpatialStaleStates()
{
foreach (var (_, state) in _spatialOperatorStates)
{
if (state.Stream == null) continue;
// An operator is stale if it wasn't updated this frame
bool isStale = (state.LastUpdatedFrameId != _audioFrameToken);
if (state.IsStale != isStale)
{
state.Stream.SetStale(isStale);
state.IsStale = isStale;
}
}
}
/// <summary>
/// Resets all operator audio streams in preparation for audio export.
/// </summary>
internal static void ResetAllOperatorStreamsForExport()
{
IsExporting = true;
ResetOperatorStreamsForExport(_stereoOperatorStates);
ResetSpatialOperatorStreamsForExport();
}
private static void ResetOperatorStreamsForExport<T>(Dictionary<Guid, OperatorAudioState<T>> states)
where T : OperatorAudioStreamBase
{
foreach (var (_, state) in states)
{
if (state.Stream == null) continue;
state.Stream.PrepareForExport();
// Mark as stale and pause - the stale detection will un-pause if operator updates
state.Stream.SetStale(true);
state.IsStale = true;
}
}
private static void ResetSpatialOperatorStreamsForExport()
{
foreach (var (_, state) in _spatialOperatorStates)
{
if (state.Stream == null) continue;
state.Stream.PrepareForExport();
// Mark as stale - spatial streams handle stale differently
state.IsStale = true;
}
}
/// <summary>
/// Updates the stale states for all operator streams during export.
/// Checks stale state against current token (operators that updated last frame),
/// THEN increments the token for the next frame.
/// </summary>
internal static void UpdateStaleStatesForExport()
{
// First, check stale states against the CURRENT token
// This identifies which operators were updated in the previous frame
// (i.e., which operators should be producing audio for this export frame)
UpdateStaleStates(_stereoOperatorStates);
UpdateSpatialStaleStates();
// Then increment the frame token for the next frame's operator updates
_audioFrameToken++;
}
/// <summary>
/// Restores all operator audio streams after export has completed.
/// </summary>
internal static void RestoreOperatorAudioStreams()
{
IsExporting = false;
if (AudioMixerManager.GlobalMixerHandle != 0)
{
if (Bass.ChannelIsActive(AudioMixerManager.GlobalMixerHandle) != PlaybackState.Playing)
Bass.ChannelPlay(AudioMixerManager.GlobalMixerHandle);
Bass.ChannelUpdate(AudioMixerManager.GlobalMixerHandle, 0);
}
RestoreOperatorStreams(_stereoOperatorStates);
RestoreSpatialOperatorStreams();
if (AudioMixerManager.GlobalMixerHandle != 0)
Bass.ChannelUpdate(AudioMixerManager.GlobalMixerHandle, 0);
}
private static void RestoreOperatorStreams<T>(Dictionary<Guid, OperatorAudioState<T>> states)
where T : OperatorAudioStreamBase
{
foreach (var state in states.Values)
{
if (state.Stream != null)
{
state.Stream.ClearExportMetering();
state.Stream.RestartAfterExport();
state.Stream.SetStale(false);
state.IsStale = false;
}
}
}
private static void RestoreSpatialOperatorStreams()
{
foreach (var state in _spatialOperatorStates.Values)
{
if (state.Stream != null)
{
state.Stream.ClearExportMetering();
state.Stream.RestartAfterExport();
state.Stream.SetStale(false);
state.IsStale = false;
}
}
}
#endregion
#region Device & Volume Management
/// <summary>
/// Handles audio device changes by reinitializing all audio streams.
/// </summary>
/// <summary>
/// Handles audio device changes by disposing all audio streams and reinitializing.
/// This ensures no stale BASS handles remain after Bass.Free() is called.
/// </summary>
public static void OnAudioDeviceChanged()
{
// Dispose ALL streams before calling AudioMixerManager.Shutdown() / Bass.Free()
// This prevents invalid handle errors when streams are accessed after device change
DisposeAllAudioStreams();
AudioMixerManager.Shutdown();
_bassInitialized = false; // Reset flag to allow proper reinitialization
_bassInitFailed = false; // Reset failure flag to allow retry
AudioMixerManager.Initialize();
Log.Gated.Audio("[AudioEngine] Audio device changed: all streams disposed and reinitialized.");
}
/// <summary>
/// Disposes all audio streams (stereo operators, spatial operators, and soundtracks).
/// Called during device changes to prevent invalid BASS handle errors.
/// </summary>
private static void DisposeAllAudioStreams()
{
// Dispose stereo operator streams
foreach (var state in _stereoOperatorStates.Values)
state.Stream?.Dispose();
_stereoOperatorStates.Clear();
// Dispose spatial operator streams
foreach (var state in _spatialOperatorStates.Values)
state.Stream?.Dispose();
_spatialOperatorStates.Clear();
// Dispose soundtrack streams
foreach (var (_, clipStream) in SoundtrackClipStreams)
clipStream.DisableSoundtrackStream();
SoundtrackClipStreams.Clear();
_updatedSoundtrackClipTimes.Clear();
_obsoleteSoundtrackHandles.Clear();
Log.Gated.Audio("[AudioEngine] Disposed all audio streams (stereo, spatial, soundtrack).");
}
/// <summary>
/// Sets the global audio volume level.
/// </summary>
/// <param name="volume">The volume level (0.0 to 1.0).</param>
public static void SetGlobalVolume(float volume)
{
CoreSettings.Config.AppVolume = volume;
AudioMixerManager.SetGlobalVolume(volume);
}
/// <summary>
/// Initializes the global volume from the stored project settings.
/// </summary>
public static void InitializeGlobalVolumeFromSettings()
{
AudioMixerManager.SetGlobalVolume(CoreSettings.Config.AppVolume);
}
/// <summary>
/// Sets the global audio mute state.
/// </summary>
/// <param name="mute">True to mute all audio, false to unmute.</param>
public static void SetGlobalMute(bool mute)
{
AudioMixerManager.SetGlobalMute(mute);
CoreSettings.Config.AppMute = mute;
}
/// <summary>
/// Sets the mute state for all operator audio streams.
/// </summary>
/// <param name="mute">True to mute operator audio, false to unmute.</param>
public static void SetOperatorMute(bool mute)
{
AudioMixerManager.SetOperatorMute(mute);
CompositionSettings.Current.Audio.OperatorMute = mute;
}
#endregion
#region Export Metering Accessors
/// <summary>
/// Gets all stereo operator states for export metering purposes.
/// </summary>
/// <returns>Enumerable of operator ID and stream state pairs.</returns>
public static IEnumerable<KeyValuePair<Guid, (StereoOperatorAudioStream? Stream, bool IsStale)>> GetAllStereoOperatorStates()
{
foreach (var kvp in _stereoOperatorStates)
yield return new KeyValuePair<Guid, (StereoOperatorAudioStream?, bool)>(kvp.Key, (kvp.Value.Stream, kvp.Value.IsStale));
}
/// <summary>
/// Gets all spatial operator states for export metering purposes.
/// </summary>
/// <returns>Enumerable of operator ID and stream state pairs.</returns>
public static IEnumerable<KeyValuePair<Guid, (SpatialOperatorAudioStream? Stream, bool IsStale)>> GetAllSpatialOperatorStates()
{
foreach (var kvp in _spatialOperatorStates)
yield return new KeyValuePair<Guid, (SpatialOperatorAudioStream?, bool)>(kvp.Key, (kvp.Value.Stream, kvp.Value.IsStale));
}
#endregion
}