#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; /// /// Controls loading, playback, and discarding of audio clips. /// public static class AudioEngine { // --- Soundtrack (Timeline) Audio --- internal static readonly Dictionary SoundtrackClipStreams = new(); private static readonly Dictionary _updatedSoundtrackClipTimes = new(); private static readonly List _obsoleteSoundtrackHandles = new(); // --- Operator Audio --- private static readonly Dictionary> _stereoOperatorStates = new(); private static readonly Dictionary _spatialOperatorStates = new(); /// /// Internal monotonic frame token for stale detection. /// Incremented at the start of each new frame (detected via Playback.FrameCount change). /// private static long _audioFrameToken; /// /// Tracks the last seen Playback.FrameCount to detect when a new frame has started. /// When this differs from current Playback.FrameCount, we increment _audioFrameToken. /// private static int _lastSeenPlaybackFrame = -1; /// /// Tracks whether stale check has been performed for the current frame token. /// private static long _lastStaleCheckFrameToken = -1; // Export state /// /// Gets whether the audio engine is currently in export mode. /// During export, streams should not be paused when marked stale. /// 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; /// /// Common state for operator audio streams using mixer (stereo). /// private sealed class OperatorAudioState where T : OperatorAudioStreamBase { public T? Stream; public string CurrentFilePath = string.Empty; public bool IsPaused; public bool PreviousPlay; public bool PreviousStop; public bool IsStale; /// /// 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. /// public float PendingSeek; /// /// 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. /// public long LastUpdatedFrameId = -1; /// /// The file path that previously failed to load, if any. /// Used to avoid repeated load attempts and error logging for the same invalid file. /// public string? FailedFilePath; /// /// The error message from the last failed load attempt. /// Cleared when a new file path is set or when explicitly cleared. /// public string? LastLoadError; } /// /// State for spatial operator audio streams (native 3D, not using mixer). /// private sealed class SpatialOperatorState { public SpatialOperatorAudioStream? Stream; public string CurrentFilePath = string.Empty; public bool IsPaused; public bool PreviousPlay; public bool PreviousStop; public bool IsStale; /// /// 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. /// public float PendingSeek; /// /// 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. /// public long LastUpdatedFrameId = -1; /// /// The file path that previously failed to load, if any. /// Used to avoid repeated load attempts and error logging for the same invalid file. /// public string? FailedFilePath; /// /// The error message from the last failed load attempt. /// Cleared when a new file path is set or when explicitly cleared. /// public string? LastLoadError; } #region Soundtrack Management /// /// Registers a soundtrack clip to be used at the specified time during the current frame. /// /// The audio clip resource handle. /// The playback time in seconds. public static void UseSoundtrackClip(AudioClipResourceHandle handle, double time) { _updatedSoundtrackClipTimes[handle] = time; } /// /// Reloads a soundtrack clip by freeing the existing stream and re-registering it. /// /// The audio clip resource handle to reload. 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); } /// /// Completes the audio processing for the current frame, handling soundtrack clips, /// FFT analysis, and stale operator detection. /// /// The current playback state. /// The duration of the current frame in seconds. 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); } } /// /// Sets the mute state for the soundtrack audio. /// /// True to mute the soundtrack, false to unmute. public static void SetSoundtrackMute(bool configSoundtrackMute) => IsSoundtrackMuted = configSoundtrackMute; /// /// Gets a value indicating whether the soundtrack is currently muted. /// public static bool IsSoundtrackMuted { get; private set; } /// /// Gets a value indicating whether global audio is currently muted. /// public static bool IsGlobalMuted => CoreSettings.Config.AppMute; internal static void UpdateFftBufferFromSoundtrack(Playback playback) { UpdateFftBufferFromSoundtrack(playback, AudioAnalysisContext.Default); } /// /// Updates FFT and waveform buffers from the soundtrack mixer. /// /// The current playback state /// The analysis context to write data into 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); } /// /// Gets the number of audio channels for a soundtrack clip. /// /// The audio clip resource handle. /// The number of channels, or 2 (stereo) if the clip is not found. 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; } /// /// Gets the sample rate for a soundtrack clip. /// /// The audio clip resource handle. /// The sample rate in Hz, or 48000 if the clip is not found. 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 /// /// Converts a System.Numerics.Vector3 to a ManagedBass.Vector3D. /// private static Vector3D ToBassVector(Vector3 v) => new(v.X, v.Y, v.Z); /// /// Sets the 3D listener position and orientation for spatial audio. /// /// The world position of the listener. /// The forward direction vector of the listener. /// The up direction vector of the listener. 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}"); } } /// /// Initializes BASS 3D audio with configured factors. /// private static void Initialize3DAudio() { // Set 3D factors for proper world scaling Bass.Set3DFactors( AudioConfig.DistanceFactor, AudioConfig.RolloffFactor, AudioConfig.DopplerFactor); // Apply initial listener position ApplyListenerPosition(); } /// /// Applies the current listener position to BASS. /// 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; } /// /// Marks that a 3D apply is needed at the end of the frame. /// Called by spatial streams when their positions are updated. /// internal static void Mark3DApplyNeeded() => _3dApplyNeeded = true; /// /// Applies all pending 3D changes (called once per frame in CompleteFrame). /// private static void Apply3DChanges() { if (_3dListenerDirty) { ApplyListenerPosition(); } if (_3dApplyNeeded || _3dListenerDirty) { Bass.Apply3D(); _3dApplyNeeded = false; } } /// /// Gets the current 3D listener position. /// /// The world position of the listener. public static Vector3 Get3DListenerPosition() => _listenerPosition; /// /// Gets the current 3D listener forward direction. /// /// The forward direction vector of the listener. public static Vector3 Get3DListenerForward() => _listenerForward; /// /// Gets the current 3D listener up direction. /// /// The up direction vector of the listener. public static Vector3 Get3DListenerUp() => _listenerUp; #endregion #region Stereo Operator Playback /// /// Updates the playback state of a stereo audio stream for an operator. /// /// The unique identifier of the operator. /// The file path of the audio file to play. /// True to trigger playback on rising edge. /// True to trigger stop on rising edge. /// The volume level (0.0 to 1.0). /// True to mute the stream. /// The stereo panning value (-1.0 left to 1.0 right). /// The playback speed multiplier (default 1.0). /// /// The normalized seek position (0.0 to 1.0). This value is stored and only applied when /// playback is triggered via . 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. /// 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); } } /// Pauses the audio stream for the specified stereo operator. /// The unique identifier of the operator. public static void PauseOperator(Guid operatorId) => PauseOperatorInternal(_stereoOperatorStates, operatorId); /// Resumes the audio stream for the specified stereo operator. /// The unique identifier of the operator. public static void ResumeOperator(Guid operatorId) => ResumeOperatorInternal(_stereoOperatorStates, operatorId); /// Checks if the audio stream is currently playing for the specified stereo operator. /// The unique identifier of the operator. /// True if the stream is playing and not paused; otherwise, false. public static bool IsOperatorStreamPlaying(Guid operatorId) => IsOperatorPlaying(_stereoOperatorStates, operatorId); /// Checks if the audio stream is currently paused for the specified stereo operator. /// The unique identifier of the operator. /// True if the stream is paused; otherwise, false. public static bool IsOperatorPaused(Guid operatorId) => IsOperatorPausedInternal(_stereoOperatorStates, operatorId); /// Gets the current audio level for the specified stereo operator. /// The unique identifier of the operator. /// The current audio level, or 0 if the stream is not found. public static float GetOperatorLevel(Guid operatorId) => GetOperatorLevelInternal(_stereoOperatorStates, operatorId); /// /// Attempts to retrieve the stereo audio stream for the specified operator. /// /// The unique identifier of the operator. /// When this method returns, contains the stream if found; otherwise, null. /// True if the stream was found; otherwise, false. 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 /// /// 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. /// /// The unique identifier of the operator. /// The file path of the audio file to play. /// True to trigger playback on rising edge. /// True to trigger stop on rising edge. /// The volume level (0.0 to 1.0). /// True to mute the stream. /// The 3D world position of the audio source. /// The distance at which the volume starts to attenuate. /// The distance at which the volume reaches minimum. /// The playback speed multiplier (default 1.0). /// /// The normalized seek position (0.0 to 1.0). This value is stored and only applied when /// playback is triggered via . 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. /// /// The orientation vector of the sound source for directional audio. /// The inner cone angle in degrees for directional audio. /// The outer cone angle in degrees for directional audio. /// The volume level outside the outer cone. /// The 3D processing mode. 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); } } /// Pauses the audio stream for the specified spatial operator. /// The unique identifier of the operator. public static void PauseSpatialOperator(Guid operatorId) { if (_spatialOperatorStates.TryGetValue(operatorId, out var state) && state.Stream != null) { state.Stream.Pause(); state.IsPaused = true; } } /// Resumes the audio stream for the specified spatial operator. /// The unique identifier of the operator. public static void ResumeSpatialOperator(Guid operatorId) { if (_spatialOperatorStates.TryGetValue(operatorId, out var state) && state.Stream != null) { state.Stream.Resume(); state.IsPaused = false; } } /// Checks if the audio stream is currently playing for the specified spatial operator. /// The unique identifier of the operator. /// True if the stream is playing and not paused; otherwise, false. public static bool IsSpatialOperatorStreamPlaying(Guid operatorId) { return _spatialOperatorStates.TryGetValue(operatorId, out var state) && state.Stream != null && state.Stream.IsPlaying && !state.Stream.IsPaused; } /// Checks if the audio stream is currently paused for the specified spatial operator. /// The unique identifier of the operator. /// True if the stream is paused; otherwise, false. public static bool IsSpatialOperatorPaused(Guid operatorId) { return _spatialOperatorStates.TryGetValue(operatorId, out var state) && state.Stream != null && state.IsPaused; } /// Gets the current audio level for the specified spatial operator. /// The unique identifier of the operator. /// The current audio level, or 0 if the stream is not found. public static float GetSpatialOperatorLevel(Guid operatorId) { return _spatialOperatorStates.TryGetValue(operatorId, out var state) && state.Stream != null ? state.Stream.GetLevel() : 0f; } /// /// Attempts to retrieve the spatial audio stream for the specified operator. /// /// The unique identifier of the operator. /// When this method returns, contains the stream if found; otherwise, null. /// True if the stream was found; otherwise, false. 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 GetOrCreateState(Dictionary> states, Guid operatorId) where T : OperatorAudioStreamBase { if (!states.TryGetValue(operatorId, out var state)) { state = new OperatorAudioState(); 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(OperatorAudioState state, string? resolvedPath, Guid operatorId, Func 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(OperatorAudioState 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(Dictionary> 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(Dictionary> 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(Dictionary> 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(Dictionary> states, Guid operatorId) where T : OperatorAudioStreamBase { return states.TryGetValue(operatorId, out var state) && state.Stream != null && state.IsPaused; } private static float GetOperatorLevelInternal(Dictionary> 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 /// /// Unregisters an operator from audio playback and disposes its associated streams. /// /// The unique identifier of the operator to unregister. 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); } } /// /// 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. /// /// The unique identifier of the operator. 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; } } /// /// Gets the last load error for an operator, if any. /// /// The unique identifier of the operator. /// The error message if a load failed, null otherwise. 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 /// /// 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. /// 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(Dictionary> 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; } } } /// /// Resets all operator audio streams in preparation for audio export. /// internal static void ResetAllOperatorStreamsForExport() { IsExporting = true; ResetOperatorStreamsForExport(_stereoOperatorStates); ResetSpatialOperatorStreamsForExport(); } private static void ResetOperatorStreamsForExport(Dictionary> 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; } } /// /// 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. /// 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++; } /// /// Restores all operator audio streams after export has completed. /// 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(Dictionary> 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 /// /// Handles audio device changes by reinitializing all audio streams. /// /// /// Handles audio device changes by disposing all audio streams and reinitializing. /// This ensures no stale BASS handles remain after Bass.Free() is called. /// 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."); } /// /// Disposes all audio streams (stereo operators, spatial operators, and soundtracks). /// Called during device changes to prevent invalid BASS handle errors. /// 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)."); } /// /// Sets the global audio volume level. /// /// The volume level (0.0 to 1.0). public static void SetGlobalVolume(float volume) { CoreSettings.Config.AppVolume = volume; AudioMixerManager.SetGlobalVolume(volume); } /// /// Initializes the global volume from the stored project settings. /// public static void InitializeGlobalVolumeFromSettings() { AudioMixerManager.SetGlobalVolume(CoreSettings.Config.AppVolume); } /// /// Sets the global audio mute state. /// /// True to mute all audio, false to unmute. public static void SetGlobalMute(bool mute) { AudioMixerManager.SetGlobalMute(mute); CoreSettings.Config.AppMute = mute; } /// /// Sets the mute state for all operator audio streams. /// /// True to mute operator audio, false to unmute. public static void SetOperatorMute(bool mute) { AudioMixerManager.SetOperatorMute(mute); CompositionSettings.Current.Audio.OperatorMute = mute; } #endregion #region Export Metering Accessors /// /// Gets all stereo operator states for export metering purposes. /// /// Enumerable of operator ID and stream state pairs. public static IEnumerable> GetAllStereoOperatorStates() { foreach (var kvp in _stereoOperatorStates) yield return new KeyValuePair(kvp.Key, (kvp.Value.Stream, kvp.Value.IsStale)); } /// /// Gets all spatial operator states for export metering purposes. /// /// Enumerable of operator ID and stream state pairs. public static IEnumerable> GetAllSpatialOperatorStates() { foreach (var kvp in _spatialOperatorStates) yield return new KeyValuePair(kvp.Key, (kvp.Value.Stream, kvp.Value.IsStale)); } #endregion }