Files
tooll3--t3/Core/Audio/AudioMixerManager.cs
2026-07-13 13:13:17 +08:00

669 lines
27 KiB
C#

#nullable enable
using System;
using System.Collections.Generic;
using System.Threading;
using ManagedBass;
using ManagedBass.Mix;
using ManagedBass.Wasapi;
using T3.Core.Logging;
using T3.Core.Settings;
namespace T3.Core.Audio;
/// <summary>
/// Manages the audio mixer architecture with separate paths for operator clips and soundtrack clips.
///
/// Architecture:
/// Live Playback:
/// Operator Clip(s) > Operator Mixer (decode) > Global Mixer > Soundcard
/// Soundtrack Clip(s) > Soundtrack Mixer (decode) > Global Mixer > Soundcard
///
/// Export:
/// GlobalMixer is PAUSED during export, so we can read directly from:
/// OperatorMixer and SoundtrackMixer using Bass.ChannelGetData()
///
/// Offline Analysis (waveform images, FFT):
/// Standalone decode streams via CreateOfflineAnalysisStream() - no mixer needed.
/// Each analysis stream is independent and does not interfere with playback.
/// </summary>
public static class AudioMixerManager
{
private static int _globalMixerHandle;
private static int _operatorMixerHandle;
private static int _soundtrackMixerHandle;
private static bool _initialized;
private static bool _initializationFailed;
private static int _flacPluginHandle;
private static readonly Lock _offlineStreamLock = new();
private static readonly Lock _initLock = new();
private static float _globalMixerVolume = 1.0f;
internal static int GlobalMixerHandle => _globalMixerHandle;
public static int OperatorMixerHandle => _operatorMixerHandle;
internal static int SoundtrackMixerHandle => _soundtrackMixerHandle;
public static bool IsInitialized => _initialized;
public static void Initialize()
{
lock (_initLock)
{
if (_initialized)
{
Log.Gated.Audio("[AudioMixer] Already initialized, skipping.");
return;
}
if (_initializationFailed)
{
// Don't spam logs with repeated init attempts after a failure
return;
}
Log.Gated.Audio("[AudioMixer] Starting initialization...");
// Check if BASS is already initialized by checking the default output device
// Note: Bass.CurrentDevice returns -1 when not initialized, so we check device 1 (default output)
bool bassIsInitialized = false;
try
{
// Device 1 is typically the default output device
if (Bass.GetDeviceInfo(1, out var deviceInfo))
{
bassIsInitialized = deviceInfo.IsInitialized;
}
}
catch
{
// GetDeviceInfo can fail if BASS DLL is not loaded yet
bassIsInitialized = false;
}
if (bassIsInitialized)
{
Log.Warning("[AudioMixer] BASS was already initialized by something else - our low-latency config may not apply!");
Log.Warning("[AudioMixer] To fix this, ensure AudioMixerManager.Initialize() is called BEFORE any Bass.Init() calls.");
Bass.GetInfo(out var info);
// Set the mixer frequency to the device's actual sample rate
AudioConfig.MixerFrequency = info.SampleRate;
Log.Gated.Audio($"[AudioMixer] Existing BASS - SampleRate: {info.SampleRate}Hz, MinBuffer: {info.MinBufferLength}ms, Latency: {info.Latency}ms");
}
else
{
Log.Gated.Audio("[AudioMixer] BASS not initialized, configuring for low latency...");
// Query the default output device's sample rate from WASAPI before BASS init
// WASAPI loopback devices represent the output and have the correct MixFrequency
int deviceSampleRate = GetDefaultOutputSampleRate();
// Configure BASS for low latency BEFORE initialization
Bass.Configure(Configuration.UpdatePeriod, AudioConfig.UpdatePeriodMs);
Bass.Configure(Configuration.UpdateThreads, AudioConfig.UpdateThreads);
Bass.Configure(Configuration.PlaybackBufferLength, AudioConfig.PlaybackBufferLengthMs);
Bass.Configure(Configuration.DeviceBufferLength, AudioConfig.DeviceBufferLengthMs);
Log.Gated.Audio($"[AudioMixer] Config - UpdatePeriod: {AudioConfig.UpdatePeriodMs}ms, UpdateThreads: {AudioConfig.UpdateThreads}, PlaybackBuffer: {AudioConfig.PlaybackBufferLengthMs}ms, DeviceBuffer: {AudioConfig.DeviceBufferLengthMs}ms");
// Try to initialize BASS with the device's actual sample rate first,
// then fall back to common sample rates if that fails
// Enable 3D audio support along with latency optimization
var initFlags = DeviceInitFlags.Latency | DeviceInitFlags.Stereo | DeviceInitFlags.Device3D;
// Build frequency list: device rate first (if known), then common fallbacks
var frequenciesToTry = new List<int>();
if (deviceSampleRate > 0)
{
frequenciesToTry.Add(deviceSampleRate);
}
// Add common fallbacks that aren't already in the list
if (deviceSampleRate != 48000) frequenciesToTry.Add(48000);
if (deviceSampleRate != 44100) frequenciesToTry.Add(44100);
bool initialized = false;
int usedFrequency = 0;
bool usedDeviceDefault = false;
string initMethod = "LATENCY";
foreach (var freq in frequenciesToTry)
{
bool isDeviceRate = (freq == deviceSampleRate && deviceSampleRate > 0);
var freqDesc = isDeviceRate ? $"{freq}Hz (device)" : $"{freq}Hz (fallback)";
Log.Gated.Audio($"[AudioMixer] Attempting BASS.Init with Latency+Stereo at {freqDesc}...");
if (Bass.Init(-1, freq, initFlags, IntPtr.Zero))
{
Log.Gated.Audio($"[AudioMixer] BASS initialized with LATENCY flag at {freqDesc}");
initialized = true;
usedFrequency = freq;
usedDeviceDefault = isDeviceRate;
initMethod = "LATENCY";
break;
}
var error1 = Bass.LastError;
// If already initialized, that's fine - continue with existing init
if (error1 == Errors.Already)
{
Log.Gated.Audio("[AudioMixer] BASS already initialized");
initialized = true;
usedDeviceDefault = true; // Assume existing init used device default
initMethod = "EXISTING";
break;
}
Log.Gated.Audio($"{error1} [AudioMixer] Init at {freqDesc} failed, trying next...");
}
// If all frequencies failed with Latency flag, try without it
if (!initialized)
{
foreach (var freq in frequenciesToTry)
{
bool isDeviceRate = (freq == deviceSampleRate && deviceSampleRate > 0);
var freqDesc = isDeviceRate ? $"{freq}Hz (device)" : $"{freq}Hz (fallback)";
if (Bass.Init(-1, freq, DeviceInitFlags.Stereo | DeviceInitFlags.Device3D, IntPtr.Zero))
{
Log.Warning($"[AudioMixer] BASS initialized with STEREO+3D flag at {freqDesc} (no latency optimization)");
initialized = true;
usedFrequency = freq;
usedDeviceDefault = isDeviceRate;
initMethod = "STEREO+3D";
break;
}
if (Bass.LastError == Errors.Already)
{
initialized = true;
usedDeviceDefault = true;
initMethod = "EXISTING";
break;
}
}
}
// Last resort - basic init with 3D
if (!initialized)
{
foreach (var freq in frequenciesToTry)
{
bool isDeviceRate = (freq == deviceSampleRate && deviceSampleRate > 0);
var freqDesc = isDeviceRate ? $"{freq}Hz (device)" : $"{freq}Hz (fallback)";
if (Bass.Init(-1, freq, DeviceInitFlags.Default | DeviceInitFlags.Device3D, IntPtr.Zero))
{
Log.Warning($"[AudioMixer] BASS initialized with DEFAULT+3D flags at {freqDesc}");
initialized = true;
usedFrequency = freq;
usedDeviceDefault = isDeviceRate;
initMethod = "DEFAULT+3D";
break;
}
if (Bass.LastError == Errors.Already)
{
initialized = true;
usedDeviceDefault = true;
initMethod = "EXISTING";
break;
}
}
}
if (!initialized)
{
var lastError = Bass.LastError;
Log.Error($"[AudioMixer] Failed to initialize BASS with all methods: {lastError}");
LogEnvironmentInfo();
_initializationFailed = true;
return;
}
// Get actual device info after init
Bass.GetInfo(out var info);
// Set the mixer frequency to the device's actual sample rate
AudioConfig.MixerFrequency = info.SampleRate;
var freqSource = usedDeviceDefault ? "device default" : $"fallback ({usedFrequency}Hz requested)";
Log.Debug($"[AudioMixer] BASS initialized - SampleRate: {info.SampleRate}Hz ({freqSource}), Device: {Bass.CurrentDevice}, Method: {initMethod}, Latency: {info.Latency}ms");
}
// Load BASS FLAC plugin for native FLAC support (better than Media Foundation)
_flacPluginHandle = Bass.PluginLoad("bassflac.dll");
if (_flacPluginHandle == 0)
{
Log.Warning($"[AudioMixer] Failed to load BASS FLAC plugin: {Bass.LastError}. FLAC files will use Media Foundation fallback.");
}
else
{
Log.Gated.Audio($"[AudioMixer] BASS FLAC plugin loaded successfully: Handle={_flacPluginHandle}");
}
// Create global mixer (stereo output to soundcard)
Log.Gated.Audio("[AudioMixer] Creating global mixer stream...");
_globalMixerHandle = BassMix.CreateMixerStream(AudioConfig.MixerFrequency, 2, BassFlags.Float | BassFlags.MixerNonStop);
if (_globalMixerHandle == 0)
{
Log.Error($"[AudioMixer] Failed to create global mixer: {Bass.LastError}");
_initializationFailed = true;
return;
}
Log.Gated.Audio($"[AudioMixer] Global mixer created: Handle={_globalMixerHandle}");
// Create operator mixer (decode stream that feeds into global mixer)
Log.Gated.Audio("[AudioMixer] Creating operator mixer stream...");
_operatorMixerHandle = BassMix.CreateMixerStream(AudioConfig.MixerFrequency, 2, BassFlags.MixerNonStop | BassFlags.Decode | BassFlags.Float);
if (_operatorMixerHandle == 0)
{
Log.Error($"[AudioMixer] Failed to create operator mixer: {Bass.LastError}");
_initializationFailed = true;
return;
}
Log.Gated.Audio($"[AudioMixer] Operator mixer created: Handle={_operatorMixerHandle}");
// Create soundtrack mixer (decode stream that feeds into global mixer)
Log.Gated.Audio("[AudioMixer] Creating soundtrack mixer stream...");
_soundtrackMixerHandle = BassMix.CreateMixerStream(AudioConfig.MixerFrequency, 2, BassFlags.MixerNonStop | BassFlags.Decode | BassFlags.Float);
if (_soundtrackMixerHandle == 0)
{
Log.Error($"[AudioMixer] Failed to create soundtrack mixer: {Bass.LastError}");
_initializationFailed = true;
return;
}
Log.Gated.Audio($"[AudioMixer] Soundtrack mixer created: Handle={_soundtrackMixerHandle}");
// Add operator mixer to global mixer with buffer flag for smooth mixing
Log.Gated.Audio("[AudioMixer] Adding operator mixer to global mixer...");
if (!BassMix.MixerAddChannel(_globalMixerHandle, _operatorMixerHandle, BassFlags.MixerChanBuffer))
{
Log.Error($"[AudioMixer] Failed to add operator mixer to global mixer: {Bass.LastError}");
}
else
{
Log.Gated.Audio("[AudioMixer] Operator mixer added to global mixer successfully");
}
// Add soundtrack mixer to global mixer
// Use MixerChanBuffer to enable level metering via BassMix.ChannelGetLevel
Log.Gated.Audio("[AudioMixer] Adding soundtrack mixer to global mixer...");
if (!BassMix.MixerAddChannel(_globalMixerHandle, _soundtrackMixerHandle, BassFlags.MixerChanBuffer))
{
Log.Error($"[AudioMixer] Failed to add soundtrack mixer to global mixer: {Bass.LastError}");
}
else
{
Log.Gated.Audio("[AudioMixer] Soundtrack mixer added to global mixer successfully");
}
// Note: Offline mixer is NOT added to global mixer - it's completely isolated
// Start the global mixer playing (outputs to soundcard)
Log.Gated.Audio("[AudioMixer] Starting global mixer playback...");
if (!Bass.ChannelPlay(_globalMixerHandle))
{
Log.Error($"[AudioMixer] Failed to start global mixer: {Bass.LastError}");
}
else
{
var playbackState = Bass.ChannelIsActive(_globalMixerHandle);
Log.Gated.Audio($"[AudioMixer] Global mixer started, State: {playbackState}");
}
_initialized = true;
Log.Gated.Audio("[AudioMixer] ✓ Audio mixer system initialized successfully with low-latency settings.");
} // end lock
}
internal static void Shutdown()
{
lock (_initLock)
{
if (!_initialized && !_initializationFailed)
return;
Log.Gated.Audio("[AudioMixer] Shutting down...");
Bass.StreamFree(_operatorMixerHandle);
Bass.StreamFree(_soundtrackMixerHandle);
Bass.StreamFree(_globalMixerHandle);
_operatorMixerHandle = 0;
_soundtrackMixerHandle = 0;
_globalMixerHandle = 0;
// Unload FLAC plugin
if (_flacPluginHandle != 0)
{
Bass.PluginFree(_flacPluginHandle);
_flacPluginHandle = 0;
}
Bass.Free();
_initialized = false;
_initializationFailed = false; // Reset so initialization can be retried after device change
Log.Gated.Audio("[AudioMixer] Audio mixer system shut down.");
}
}
public static void SetOperatorMixerVolume(float volume)
{
if (!_initialized) return;
Bass.ChannelSetAttribute(_operatorMixerHandle, ChannelAttribute.Volume, volume);
}
public static void SetSoundtrackMixerVolume(float volume)
{
if (!_initialized) return;
Bass.ChannelSetAttribute(_soundtrackMixerHandle, ChannelAttribute.Volume, volume);
}
internal static void SetGlobalVolume(float volume)
{
if (!_initialized) return;
Bass.ChannelSetAttribute(_globalMixerHandle, ChannelAttribute.Volume, volume);
}
internal static void SetGlobalMute(bool mute)
{
if (!_initialized) return;
if (mute)
{
// Store the current volume before muting, but only if not already muted
Bass.ChannelGetAttribute(_globalMixerHandle, ChannelAttribute.Volume, out var currentVolume);
if (currentVolume > 0.001f)
{
_globalMixerVolume = currentVolume;
}
Bass.ChannelSetAttribute(_globalMixerHandle, ChannelAttribute.Volume, 0f);
}
else
{
// Always restore the current CoreSettings volume (user may have changed it while muted)
float definedVolume = 1.0f;
try
{
definedVolume = IO.CoreSettings.Config.AppVolume;
}
catch
{
// ignored
}
Bass.ChannelSetAttribute(_globalMixerHandle, ChannelAttribute.Volume, definedVolume);
}
}
private static float _operatorMixerVolume = 1.0f;
internal static void SetOperatorMute(bool mute)
{
if (!_initialized) return;
if (mute)
{
// Store the current volume before muting, but only if not already muted
Bass.ChannelGetAttribute(_operatorMixerHandle, ChannelAttribute.Volume, out var currentVolume);
if (currentVolume > 0.001f)
{
_operatorMixerVolume = currentVolume;
}
Bass.ChannelSetAttribute(_operatorMixerHandle, ChannelAttribute.Volume, 0f);
}
else
{
// Always restore the current project settings volume (user may have changed it while muted)
float definedVolume = 1.0f;
try
{
definedVolume = CompositionSettings.Current.Audio.OperatorVolume;
}
catch
{
// ignored
}
Bass.ChannelSetAttribute(_operatorMixerHandle, ChannelAttribute.Volume, definedVolume);
}
}
/// <summary>
/// Creates a decode-only stream for offline analysis (waveform image generation, FFT, etc.)
/// This stream is NOT connected to any output and will not interfere with playback.
/// The caller is responsible for freeing the stream with Bass.StreamFree() when done.
/// </summary>
/// <param name="filePath">Absolute path to the audio file</param>
/// <returns>Stream handle, or 0 if creation failed</returns>
public static int CreateOfflineAnalysisStream(string filePath)
{
// Ensure BASS is initialized
if (!_initialized)
{
Initialize();
}
lock (_offlineStreamLock)
{
// Create a decode-only stream (no output to soundcard)
var stream = Bass.CreateStream(filePath, 0, 0, BassFlags.Decode | BassFlags.Prescan | BassFlags.Float);
if (stream == 0)
{
var error = Bass.LastError;
Log.Warning($"[AudioMixer] Failed to create offline analysis stream for '{filePath}': {error}");
return 0;
}
Log.Gated.Audio($"[AudioMixer] Created offline analysis stream: Handle={stream} for '{filePath}'");
return stream;
}
}
/// <summary>
/// Reads the duration of an audio file in seconds by opening a temporary decode stream.
/// Returns 0 on failure. Used by the timeline-drop handler to size a new clip's TimeRange
/// at the moment of import, without going through the full playback path.
/// </summary>
public static double TryProbeAudioDurationSecs(string filePath)
{
var stream = CreateOfflineAnalysisStream(filePath);
if (stream == 0)
return 0;
try
{
var bytes = Bass.ChannelGetLength(stream);
if (bytes < 0)
return 0;
return Bass.ChannelBytes2Seconds(stream, bytes);
}
finally
{
FreeOfflineAnalysisStream(stream);
}
}
/// <summary>
/// Frees an offline analysis stream created by CreateOfflineAnalysisStream.
/// </summary>
public static void FreeOfflineAnalysisStream(int streamHandle)
{
if (streamHandle == 0)
return;
lock (_offlineStreamLock)
{
Bass.StreamFree(streamHandle);
Log.Gated.Audio($"[AudioMixer] Freed offline analysis stream: Handle={streamHandle}");
}
}
/// <summary>
/// Gets the current audio level from the global mixer (0.0 to 1.0 normalized).
/// Returns the maximum of left and right channels.
/// </summary>
/// <remarks>
/// Uses the level-ex variant of Bass.ChannelGetLevel with a configurable time window
/// (see <see cref="AudioConfig.LevelMeteringWindowSeconds"/>). This provides windowed RMS-style
/// metering rather than instantaneous peak levels, which is better for visual meter displays.
/// This method does not consume audio data from the stream.
/// </remarks>
public static float GetGlobalMixerLevel()
{
if (!_initialized || _globalMixerHandle == 0)
return 0f;
// Use ChannelGetLevel (level-ex variant) with configurable time window for responsive metering.
// The float[] overload returns RMS levels over the specified window, normalized to 0.0-1.0.
float[] levels = new float[2];
if (!Bass.ChannelGetLevel(_globalMixerHandle, levels, AudioConfig.LevelMeteringWindowSeconds, LevelRetrievalFlags.Stereo))
return 0f;
return Math.Max(levels[0], levels[1]);
}
/// <summary>
/// Gets the current audio level from the operator mixer (0.0 to 1.0 normalized).
/// Returns the maximum of left and right channels.
/// </summary>
/// <remarks>
/// Uses BassMix.ChannelGetLevel for decode streams added to a mixer with MixerChanBuffer flag.
/// This reads from the mixer's internal buffer without consuming audio data.
/// Note: Decode streams require BassMix.ChannelGetLevel, not Bass.ChannelGetLevel.
/// </remarks>
public static float GetOperatorMixerLevel()
{
if (!_initialized || _operatorMixerHandle == 0)
return 0f;
// For decode streams with MixerChanBuffer, use BassMix.ChannelGetLevel (integer variant)
// The float[] level-ex variant doesn't work correctly for decode streams
var level = BassMix.ChannelGetLevel(_operatorMixerHandle);
if (level == -1)
return 0f;
// Low 16 bits = left channel, high 16 bits = right channel (0-32768 range)
var left = (level & 0xFFFF) / 32768f;
var right = ((level >> 16) & 0xFFFF) / 32768f;
return Math.Max(left, right);
}
/// <summary>
/// Gets the current audio level from the soundtrack mixer (0.0 to 1.0 normalized).
/// Returns the maximum of left and right channels.
/// </summary>
/// <remarks>
/// Uses BassMix.ChannelGetLevel for decode streams added to a mixer with MixerChanBuffer flag.
/// This reads from the mixer's internal buffer without consuming audio data.
/// Note: Decode streams require BassMix.ChannelGetLevel, not Bass.ChannelGetLevel.
/// </remarks>
public static float GetSoundtrackMixerLevel()
{
if (!_initialized || _soundtrackMixerHandle == 0)
return 0f;
// For decode streams with MixerChanBuffer, use BassMix.ChannelGetLevel (integer variant)
// The float[] level-ex variant doesn't work correctly for decode streams
var level = BassMix.ChannelGetLevel(_soundtrackMixerHandle);
if (level == -1)
return 0f;
// Low 16 bits = left channel, high 16 bits = right channel (0-32768 range)
var left = (level & 0xFFFF) / 32768f;
var right = ((level >> 16) & 0xFFFF) / 32768f;
return Math.Max(left, right);
}
/// <summary>
/// Queries WASAPI to get the default output device's configured sample rate.
/// This works before BASS is initialized.
/// </summary>
/// <returns>The device sample rate in Hz, or 0 if it couldn't be determined.</returns>
private static int GetDefaultOutputSampleRate()
{
try
{
// Enumerate WASAPI devices to find the default output's loopback
// Loopback devices represent the output and have the correct MixFrequency
var deviceCount = BassWasapi.DeviceCount;
for (var i = 0; i < deviceCount; i++)
{
var info = BassWasapi.GetDeviceInfo(i);
// Look for enabled loopback device (represents system output)
if (info.IsEnabled && info.IsLoopback && !info.IsInput)
{
var sampleRate = info.MixFrequency;
Log.Debug($"[AudioMixer] Found default output device: '{info.Name}' at {sampleRate}Hz");
return sampleRate;
}
}
// Fallback: try to find any enabled loopback device
for (var i = 0; i < deviceCount; i++)
{
var info = BassWasapi.GetDeviceInfo(i);
if (info.IsEnabled && info.IsLoopback)
{
var sampleRate = info.MixFrequency;
Log.Debug($"[AudioMixer] Found loopback device: '{info.Name}' at {sampleRate}Hz");
return sampleRate;
}
}
Log.Debug("[AudioMixer] Could not find default output device sample rate from WASAPI");
}
catch (Exception ex)
{
Log.Debug($"[AudioMixer] Failed to query WASAPI device sample rate: {ex.Message}");
}
return 0; // Couldn't determine
}
/// <summary>
/// Logs environment info to help diagnose BASS initialization failures.
/// </summary>
private static void LogEnvironmentInfo()
{
Log.Error("[AudioMixer] Environment info for diagnosis:");
Log.Error($" OS: {Environment.OSVersion}");
Log.Error($" 64-bit OS: {Environment.Is64BitOperatingSystem}, 64-bit Process: {Environment.Is64BitProcess}");
Log.Error($" Current Directory: {Environment.CurrentDirectory}");
try
{
// Log available audio devices
int deviceCount = Bass.DeviceCount;
Log.Error($" BASS Device Count: {deviceCount}");
for (int i = 0; i < deviceCount; i++)
{
if (Bass.GetDeviceInfo(i, out var deviceInfo))
{
Log.Error($" Device[{i}]: '{deviceInfo.Name}' Type={deviceInfo.Type} Enabled={deviceInfo.IsEnabled} Default={deviceInfo.IsDefault}");
}
}
}
catch (Exception ex)
{
Log.Error($" Failed to enumerate BASS devices: {ex.Message}");
}
try
{
// Check for bass.dll
var bassDllPath = System.IO.Path.Combine(Environment.CurrentDirectory, "bass.dll");
var bassDllExists = System.IO.File.Exists(bassDllPath);
Log.Error($" bass.dll exists in current dir: {bassDllExists}");
var bassMixDllPath = System.IO.Path.Combine(Environment.CurrentDirectory, "bassmix.dll");
var bassMixDllExists = System.IO.File.Exists(bassMixDllPath);
Log.Error($" bassmix.dll exists in current dir: {bassMixDllExists}");
}
catch (Exception ex)
{
Log.Error($" Failed to check DLL existence: {ex.Message}");
}
}
}