#nullable enable using System; using System.IO; using System.Linq; using ManagedBass; using ManagedBass.Mix; using T3.Core.Logging; // ReSharper disable UnusedAutoPropertyAccessor.Global // ReSharper disable UnusedAutoPropertyAccessor.Local // ReSharper disable UnusedParameter.Local // ReSharper disable InconsistentNaming namespace T3.Core.Audio; /// /// Base class for operator audio streams (stereo and spatial). /// Contains common playback, metering, and export functionality. /// public abstract class OperatorAudioStreamBase { /// /// Gets the duration of the audio stream in seconds. /// internal double Duration { get; init; } /// /// Gets the BASS stream handle for this audio stream. /// internal int StreamHandle { get; init; } /// /// Gets the BASS mixer stream handle that this stream is connected to. /// internal int MixerStreamHandle { get; init; } /// /// Gets or sets whether the stream is currently paused. /// internal bool IsPaused { get; set; } /// /// Gets or sets whether the stream is currently playing. /// internal bool IsPlaying { get; set; } /// /// Gets the file path of the loaded audio file. /// public string FilePath { get; protected set; } = string.Empty; /// /// Gets or sets the default playback frequency of the stream. /// protected float DefaultPlaybackFrequency { get; set; } /// /// The current volume level (0.0 to 1.0). /// private float CurrentVolume = 1.0f; /// /// The current playback speed multiplier. /// private float CurrentSpeed = 1.0f; /// /// Cached number of channels in the audio stream. /// protected int CachedChannels; /// /// Cached sample frequency of the audio stream. /// protected int CachedFrequency; /// /// Indicates whether the stream has been stopped and reset due to being stale (not actively updated). /// internal bool IsStaleStopped; /// /// Indicates whether the stream is muted by user request. /// private bool IsUserMuted; /// /// The audio level during export, if available. /// private float? ExportLevel; /// /// Attempts to load an audio stream from a file and add it to the mixer. /// /// The path to the audio file to load. /// The BASS mixer handle to add the stream to. /// Additional BASS flags to apply when creating the stream. /// When successful, contains the created stream handle. /// When successful, contains the default frequency of the stream. /// When successful, contains the channel information. /// When successful, contains the duration of the stream in seconds. /// true if the stream was successfully loaded and added to the mixer; otherwise, false. protected static bool TryLoadStreamCore( string filePath, int mixerHandle, BassFlags additionalFlags, out int streamHandle, out float defaultFreq, out ChannelInfo info, out double duration) { streamHandle = 0; defaultFreq = 0; info = default; duration = 0; if (string.IsNullOrEmpty(filePath) || !File.Exists(filePath)) return false; streamHandle = Bass.CreateStream(filePath, 0, 0, BassFlags.Decode | BassFlags.Float | BassFlags.AsyncFile | additionalFlags); if (streamHandle == 0) { Log.Error($"[Audio] Error loading '{Path.GetFileName(filePath)}': {Bass.LastError}"); return false; } Bass.ChannelGetAttribute(streamHandle, ChannelAttribute.Frequency, out defaultFreq); info = Bass.ChannelGetInfo(streamHandle); var bytes = Bass.ChannelGetLength(streamHandle); if (bytes <= 0) { Bass.StreamFree(streamHandle); streamHandle = 0; return false; } duration = Bass.ChannelBytes2Seconds(streamHandle, bytes); if (duration <= 0 || duration > 36000) { Bass.StreamFree(streamHandle); streamHandle = 0; return false; } if (!BassMix.MixerAddChannel(mixerHandle, streamHandle, BassFlags.MixerChanBuffer | BassFlags.MixerChanPause)) { Log.Error($"[Audio] Failed to add stream to mixer: {Bass.LastError}"); Bass.StreamFree(streamHandle); streamHandle = 0; return false; } // Start with volume at 0 - stream will be unmuted when Play() is triggered Bass.ChannelSetAttribute(streamHandle, ChannelAttribute.Volume, 0.0f); return true; } /// /// Starts playback of the audio stream. /// internal virtual void Play() { IsStaleStopped = false; BassMix.ChannelFlags(StreamHandle, 0, BassFlags.MixerChanPause); IsPlaying = true; IsPaused = false; // Restore volume (PrepareForExport sets it to 0, and SetStale needs IsPlaying=true) if (!IsUserMuted) { Bass.ChannelSetAttribute(StreamHandle, ChannelAttribute.Volume, CurrentVolume); } Bass.ChannelUpdate(MixerStreamHandle, 0); } /// /// Pauses playback of the audio stream. /// internal void Pause() { if (!IsPlaying || IsPaused) return; BassMix.ChannelFlags(StreamHandle, BassFlags.MixerChanPause, BassFlags.MixerChanPause); IsPaused = true; } /// /// Resumes playback of a paused audio stream. /// internal virtual void Resume() { if (!IsPaused) return; BassMix.ChannelFlags(StreamHandle, 0, BassFlags.MixerChanPause); IsPaused = false; Bass.ChannelUpdate(MixerStreamHandle, 0); } /// /// Stops playback and resets the stream position to the beginning. /// internal void Stop() { IsPlaying = false; IsPaused = false; IsStaleStopped = false; BassMix.ChannelFlags(StreamHandle, BassFlags.MixerChanPause, BassFlags.MixerChanPause); var position = Bass.ChannelSeconds2Bytes(StreamHandle, 0); BassMix.ChannelSetPosition(StreamHandle, position, PositionFlags.Bytes | PositionFlags.MixerReset); } /// /// Sets the stale state of the stream. Stale streams are paused to prevent them from contributing to the mix. /// During export mode, we pause but don't reset position (to preserve playback continuity when stream becomes active again). /// /// Whether the stream should be stopped due to being stale. /// Optional reason for the stale state change (for debugging). internal void SetStale(bool stale, string reason = "") { if (IsStaleStopped == stale) return; IsStaleStopped = stale; if (stale) { // Pause the stream so it doesn't contribute to the mixer output BassMix.ChannelFlags(StreamHandle, BassFlags.MixerChanPause, BassFlags.MixerChanPause); // During normal playback, also reset position and state // During export, preserve position for continuity when stream becomes active again if (!AudioEngine.IsExporting) { var position = Bass.ChannelSeconds2Bytes(StreamHandle, 0); BassMix.ChannelSetPosition(StreamHandle, position, PositionFlags.Bytes | PositionFlags.MixerReset); IsPlaying = false; IsPaused = false; } } else { // Un-pause the stream when it becomes non-stale BassMix.ChannelFlags(StreamHandle, 0, BassFlags.MixerChanPause); if (IsPlaying && !IsPaused && !IsUserMuted) { Bass.ChannelSetAttribute(StreamHandle, ChannelAttribute.Volume, CurrentVolume); } } } /// /// Sets the volume and mute state of the stream. /// /// The volume level (0.0 to 1.0). /// Whether the stream should be muted. internal void SetVolume(float volume, bool mute) { CurrentVolume = volume; IsUserMuted = mute; if (!IsPlaying) return; float finalVolume = (!mute && !IsStaleStopped) ? volume : 0.0f; Bass.ChannelSetAttribute(StreamHandle, ChannelAttribute.Volume, finalVolume); } /// /// Sets the playback speed of the stream. /// /// The playback speed multiplier (clamped between 0.1 and 4.0). internal void SetSpeed(float speed) { if (Math.Abs(speed - CurrentSpeed) < 0.001f) return; var clampedSpeed = Math.Clamp(speed, 0.1f, 4f); Bass.ChannelGetAttribute(StreamHandle, ChannelAttribute.Frequency, out var currentFreq); var newFreq = (currentFreq / CurrentSpeed) * clampedSpeed; Bass.ChannelSetAttribute(StreamHandle, ChannelAttribute.Frequency, newFreq); CurrentSpeed = clampedSpeed; } /// /// Seeks to a specific position in the audio stream. /// /// The position to seek to, in seconds. internal void Seek(float timeInSeconds) { var position = Bass.ChannelSeconds2Bytes(StreamHandle, timeInSeconds); BassMix.ChannelSetPosition(StreamHandle, position, PositionFlags.Bytes | PositionFlags.MixerReset); } /// /// Restarts the stream after an export operation, resetting position and restoring playback state. /// internal virtual void RestartAfterExport() { IsStaleStopped = false; var resetPosition = Bass.ChannelSeconds2Bytes(StreamHandle, 0); BassMix.ChannelSetPosition(StreamHandle, resetPosition, PositionFlags.Bytes | PositionFlags.MixerReset); BassMix.ChannelFlags(StreamHandle, 0, BassFlags.MixerChanPause); if (!IsUserMuted) Bass.ChannelSetAttribute(StreamHandle, ChannelAttribute.Volume, CurrentVolume); IsPlaying = true; IsPaused = false; Bass.ChannelUpdate(MixerStreamHandle, 0); } /// /// Updates the export level metering from an audio buffer. /// /// The audio buffer containing sample data. internal void UpdateFromBuffer(float[] buffer) { float peak = buffer.Select(Math.Abs).Prepend(0f).Max(); ExportLevel = Math.Min(peak, 1f); } /// /// Clears the export metering level. /// internal void ClearExportMetering() { ExportLevel = null; } /// /// Gets the current audio level of the stream for metering purposes. /// /// The peak audio level (0.0 to 1.0). internal float GetLevel() { if (ExportLevel.HasValue) return ExportLevel.Value; if (!IsPlaying || (IsPaused && !IsStaleStopped)) return 0f; var level = BassMix.ChannelGetLevel(StreamHandle); if (level == -1) return 0f; var left = (level & 0xFFFF) / 32768f; var right = ((level >> 16) & 0xFFFF) / 32768f; return Math.Min(Math.Max(left, right), 1f); } /// /// Renders audio data from this stream into an output buffer with resampling support. /// /// The start time in seconds. /// The duration to render in seconds. /// The buffer to write the rendered audio data to. /// The target sample rate for the output. /// The target number of channels for the output. /// The number of samples written to the output buffer. public int RenderAudio(double startTime, double duration, float[] outputBuffer, int targetSampleRate, int targetChannels) { int nativeSampleRate = CachedFrequency > 0 ? CachedFrequency : 44100; int nativeChannels = GetNativeChannelCount(); OperatorAudioUtils.FillAndResample( RenderNativeAudio, startTime, duration, outputBuffer, nativeSampleRate, nativeChannels, targetSampleRate, targetChannels); return outputBuffer.Length; } /// /// Gets the native channel count of the audio stream. /// /// The number of channels in the native audio stream. protected virtual int GetNativeChannelCount() => CachedChannels > 0 ? CachedChannels : 2; /// /// Renders audio data in the native format of the stream. /// /// The start time in seconds. /// The duration to render in seconds. /// The buffer to write the audio data to. /// The number of samples read. private int RenderNativeAudio(double startTime, double duration, float[] buffer) { int bytesToRead = buffer.Length * sizeof(float); int bytesRead = Bass.ChannelGetData(StreamHandle, buffer, bytesToRead); return bytesRead > 0 ? bytesRead / sizeof(float) : 0; } /// /// Gets the current playback position of the stream. /// /// The current position in seconds. public double GetCurrentPosition() { long positionBytes = BassMix.ChannelGetPosition(StreamHandle); if (positionBytes < 0) positionBytes = 0; return Bass.ChannelBytes2Seconds(StreamHandle, positionBytes); } /// /// Disposes of the audio stream, releasing all BASS resources. /// internal void Dispose() { Bass.ChannelStop(StreamHandle); BassMix.MixerRemoveChannel(StreamHandle); Bass.StreamFree(StreamHandle); } /// /// Prepares the stream for export by resetting position and playback state. /// The stream will require an explicit play trigger to start during export. /// internal virtual void PrepareForExport() { // Reset position to beginning for consistent export var resetPosition = Bass.ChannelSeconds2Bytes(StreamHandle, 0); BassMix.ChannelSetPosition(StreamHandle, resetPosition, PositionFlags.Bytes | PositionFlags.MixerReset); // Reset playback state so streams don't auto-play when un-staled during export. // Audio should only play when explicitly triggered during the export. IsPlaying = false; IsPaused = false; ClearExportMetering(); } }