Files
osu-framework/osu.Framework/Threading/AudioThread.cs
2026-01-16 12:28:33 +08:00

867 lines
34 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence.
// See the LICENCE file in the repository root for full licence text.
using osu.Framework.Statistics;
using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.IO;
using System.Linq;
using System.Reflection;
using System.Runtime.InteropServices;
using System.Threading;
using ManagedBass;
using ManagedBass.Asio;
using ManagedBass.Mix;
using ManagedBass.Wasapi;
using osu.Framework.Audio;
using osu.Framework.Audio.Asio;
using osu.Framework.Audio.EzLatency;
using osu.Framework.Bindables;
using osu.Framework.Development;
using osu.Framework.Logging;
using osu.Framework.Platform.Linux.Native;
namespace osu.Framework.Threading
{
public class AudioThread : GameThread
{
private static int wasapiNativeUnavailableLogged;
private static int asioResolverRegistered;
// EzOsuLatency 模块
internal readonly EzLatencyTestModule LatencyTestModule;
internal readonly EzLogModule LogModule;
internal readonly EzDriverModule DriverModule;
internal readonly EzHardwareModule HardwareModule;
// EzOsuLatency 数据结构体
// NOTE: Previously these were present as placeholders; we now pass local structs from the playback path.
// Keep no instance fields to avoid misleading shared mutable state.
public AudioThread()
: base(name: "Audio")
{
// Ensure this AudioThread instance is always reachable from native WASAPI callbacks.
wasapiUserHandle = GCHandle.Alloc(this, GCHandleType.Normal);
wasapiUserPtr = GCHandle.ToIntPtr(wasapiUserHandle);
OnNewFrame += onNewFrame;
PreloadBass();
// 初始化EzOsuLatency模块
LatencyTestModule = new EzLatencyTestModule();
LogModule = new EzLogModule();
DriverModule = new EzDriverModule();
HardwareModule = new EzHardwareModule();
}
public override bool IsCurrent => ThreadSafety.IsAudioThread;
internal sealed override void MakeCurrent()
{
base.MakeCurrent();
ThreadSafety.IsAudioThread = true;
}
internal override IEnumerable<StatisticsCounterType> StatisticsCounters => new[]
{
StatisticsCounterType.TasksRun,
StatisticsCounterType.Tracks,
StatisticsCounterType.Samples,
StatisticsCounterType.SChannels,
StatisticsCounterType.Components,
StatisticsCounterType.MixChannels,
};
private readonly List<AudioManager> managers = new List<AudioManager>();
private static readonly HashSet<int> initialised_devices = new HashSet<int>();
private static readonly GlobalStatistic<double> cpu_usage = GlobalStatistics.Get<double>("Audio", "Bass CPU%");
private long frameCount;
private void onNewFrame()
{
if (frameCount++ % 1000 == 0)
cpu_usage.Value = Bass.CPUUsage;
lock (managers)
{
for (int i = 0; i < managers.Count; i++)
{
var m = managers[i];
m.Update();
}
}
// EzOsuLatency: 定期运行延迟测试
LatencyTestModule.RunPeriodicTest();
}
internal void RegisterManager(AudioManager manager)
{
lock (managers)
{
if (managers.Contains(manager))
throw new InvalidOperationException($"{manager} was already registered");
managers.Add(manager);
}
// Set the manager reference for event triggering
Manager ??= manager;
manager.GlobalMixerHandle.BindTo(globalMixerHandle);
}
internal void UnregisterManager(AudioManager manager)
{
lock (managers)
managers.Remove(manager);
manager.GlobalMixerHandle.UnbindFrom(globalMixerHandle);
}
protected override void OnExit()
{
base.OnExit();
lock (managers)
{
// AudioManagers are iterated over backwards since disposal will unregister and remove them from the list.
for (int i = managers.Count - 1; i >= 0; i--)
{
var m = managers[i];
m.Dispose();
// Audio component disposal (including the AudioManager itself) is scheduled and only runs when the AudioThread updates.
// But the AudioThread won't run another update since it's exiting, so an update must be performed manually in order to finish the disposal.
m.Update();
}
managers.Clear();
}
// Safety net to ensure we have freed all devices before exiting.
// This is mainly required for device-lost scenarios.
// See https://github.com/ppy/osu-framework/pull/3378 for further discussion.
foreach (int d in initialised_devices.ToArray())
FreeDevice(d);
if (wasapiUserHandle.IsAllocated)
wasapiUserHandle.Free();
}
#region BASS Initialisation
// TODO: All this bass init stuff should probably not be in this class.
// WASAPI callbacks must never be allowed to be GC'd while native code may still call into them.
// Use static delegates with a stable user pointer back to this thread instance.
private static readonly WasapiProcedure wasapi_procedure_static = (buffer, length, user) =>
{
var thread = getWasapiOwner(user);
int? mixer = thread?.globalMixerHandle.Value;
if (mixer == null)
return 0;
return Bass.ChannelGetData(mixer.Value, buffer, length);
};
private static readonly WasapiNotifyProcedure wasapi_notify_procedure_static = (notify, device, user) =>
{
var thread = getWasapiOwner(user);
if (thread == null)
return;
thread.Scheduler.Add(() =>
{
if (notify == WasapiNotificationType.DefaultOutput)
{
thread.freeWasapi();
thread.initWasapi(device, thread.wasapiExclusiveActive);
}
});
};
private static AudioThread? getWasapiOwner(IntPtr user)
{
if (user == IntPtr.Zero)
return null;
try
{
return (AudioThread?)GCHandle.FromIntPtr(user).Target;
}
catch
{
return null;
}
}
private GCHandle wasapiUserHandle;
private readonly IntPtr wasapiUserPtr;
private bool wasapiExclusiveActive;
/// <summary>
/// Reference to the AudioManager that owns this AudioThread.
/// Used to trigger events when ASIO devices are initialized.
/// </summary>
internal AudioManager? Manager { get; set; }
/// <summary>
/// If a global mixer is being used, this will be the BASS handle for it.
/// If non-null, all game mixers should be added to this mixer.
/// </summary>
private readonly Bindable<int?> globalMixerHandle = new Bindable<int?>();
internal bool InitDevice(int deviceId, AudioOutputMode outputMode, double preferredSampleRate)
{
Debug.Assert(ThreadSafety.IsAudioThread);
Trace.Assert(deviceId != -1); // The real device ID should always be used, as the -1 device has special cases which are hard to work with.
// Important: stop any existing output first.
// In particular, WASAPI exclusive can hold the device such that a subsequent Bass.Init() returns Busy.
// If we can't initialise BASS, we also won't get a chance to clean up the previous output mode.
freeAsio();
freeWasapi();
// 对于ASIO模式在初始化前添加额外延迟以确保设备完全释放
if (outputMode == AudioOutputMode.Asio)
{
Logger.Log("检测到ASIO模式在设备初始化前添加额外延迟", name: "audio", level: LogLevel.Debug);
// 增加延迟以确保设备完全释放
Thread.Sleep(200);
}
// Try to initialise the device, or request a re-initialise.
// 128 == BASS_DEVICE_REINIT. Only use it when the device is already initialised.
var initFlags = initialised_devices.Contains(deviceId) ? (DeviceInitFlags)128 : 0;
if (!Bass.Init(deviceId, Flags: initFlags))
{
Logger.Log($"BASS.Init({deviceId}) failed: {Bass.LastError}", name: "audio", level: LogLevel.Error);
return false;
}
switch (outputMode)
{
case AudioOutputMode.Default:
break;
case AudioOutputMode.WasapiShared:
if (!attemptWasapiInitialisation(deviceId, exclusive: false))
{
Logger.Log($"BassWasapi initialisation failed (shared mode). BASS error: {Bass.LastError}", name: "audio", level: LogLevel.Error);
return false;
}
break;
case AudioOutputMode.WasapiExclusive:
if (!attemptWasapiInitialisation(deviceId, exclusive: true))
{
Logger.Log($"BassWasapi initialisation failed (exclusive mode). BASS error: {Bass.LastError}", name: "audio", level: LogLevel.Error);
return false;
}
break;
case AudioOutputMode.Asio:
if (!initAsio(deviceId, preferredSampleRate))
return false;
break;
}
initialised_devices.Add(deviceId);
return true;
}
internal void FreeDevice(int deviceId)
{
Debug.Assert(ThreadSafety.IsAudioThread);
int selectedDevice = Bass.CurrentDevice;
// 对于ASIO设备先释放ASIO再释放BASS以确保正确的清理顺序
freeAsio();
if (canSelectDevice(deviceId))
{
Bass.CurrentDevice = deviceId;
Bass.Free();
}
freeWasapi();
if (selectedDevice != deviceId && canSelectDevice(selectedDevice))
Bass.CurrentDevice = selectedDevice;
initialised_devices.Remove(deviceId);
static bool canSelectDevice(int deviceId) => Bass.GetDeviceInfo(deviceId, out var deviceInfo) && deviceInfo.IsInitialized;
}
/// <summary>
/// 使BASS库可用以供使用。
/// </summary>
internal static void PreloadBass()
{
if (RuntimeInfo.OS == RuntimeInfo.Platform.Windows)
{
if (Interlocked.Exchange(ref asioResolverRegistered, 1) == 1)
return;
NativeLibrary.SetDllImportResolver(typeof(BassAsio).Assembly, resolveBassAsio);
}
if (RuntimeInfo.OS == RuntimeInfo.Platform.Linux)
{
// 暂时需要此操作来解决libbass_fx.so加载失败问题 (see https://github.com/ppy/osu/issues/2852)
Library.Load("libbass.so", Library.LoadFlags.RTLD_LAZY | Library.LoadFlags.RTLD_GLOBAL);
}
}
private static IntPtr resolveBassAsio(string libraryName, Assembly assembly, DllImportSearchPath? searchPath)
{
if (!libraryName.Equals("bassasio", StringComparison.OrdinalIgnoreCase)
&& !libraryName.Equals("bassasio.dll", StringComparison.OrdinalIgnoreCase))
return IntPtr.Zero;
// 从架构特定的目录加载bassasio.dll
try
{
// 首先尝试使用默认搜索路径加载
IntPtr result = NativeLibrary.Load(libraryName, assembly, searchPath ?? DllImportSearchPath.UseDllDirectoryForDependencies | DllImportSearchPath.SafeDirectories);
if (result != IntPtr.Zero)
return result;
}
catch
{
// 忽略并尝试其他加载方式
}
// 如果是64位进程尝试从x64目录加载
if (Environment.Is64BitProcess)
{
try
{
string dllPath = Path.Combine(AppContext.BaseDirectory, "x64", "bassasio.dll");
if (File.Exists(dllPath))
return NativeLibrary.Load(dllPath);
}
catch
{
// 忽略并继续
}
}
else
{
// 如果是32位进程尝试从x86目录加载
try
{
string dllPath = Path.Combine(AppContext.BaseDirectory, "x86", "bassasio.dll");
if (File.Exists(dllPath))
return NativeLibrary.Load(dllPath);
}
catch
{
// 忽略并继续
}
}
// 如果所有尝试都失败,让运行时使用默认解析
return IntPtr.Zero;
}
private bool attemptWasapiInitialisation() => attemptWasapiInitialisation(Bass.CurrentDevice, exclusive: false);
private bool attemptWasapiInitialisation(int bassDeviceId, bool exclusive)
{
if (RuntimeInfo.OS != RuntimeInfo.Platform.Windows)
return false;
Logger.Log("Attempting local BassWasapi initialisation");
try
{
return attemptWasapiInitialisationInternal(bassDeviceId, exclusive);
}
catch (DllNotFoundException e)
{
logWasapiNativeUnavailableOnce($"WASAPI output is unavailable because basswasapi.dll could not be loaded ({e.Message}).");
return false;
}
catch (EntryPointNotFoundException e)
{
logWasapiNativeUnavailableOnce($"WASAPI output is unavailable because basswasapi.dll is incompatible/mismatched ({e.Message}).");
return false;
}
catch (Exception e)
{
Logger.Log($"WASAPI initialisation failed with exception: {e}", name: "audio", level: LogLevel.Error);
return false;
}
}
private bool attemptWasapiInitialisationInternal(int bassDeviceId, bool exclusive)
{
Logger.Log($"Attempting local BassWasapi initialisation (exclusive: {exclusive})", name: "audio", level: LogLevel.Verbose);
int wasapiDevice = -1;
// WASAPI device indices don't match normal BASS devices.
// Each device is listed multiple times with each supported channel/frequency pair.
//
// Working backwards to find the correct device is how bass does things internally (see BassWasapi.GetBassDevice).
if (bassDeviceId > 0)
{
string driver = Bass.GetDeviceInfo(bassDeviceId).Driver;
if (!string.IsNullOrEmpty(driver))
{
var candidates = new List<(int index, int freq, int chans)>();
// WASAPI device indices don't match normal BASS devices.
// Each device is listed multiple times with each supported channel/frequency pair.
//
// Working backwards to find the correct device is how bass does things internally (see BassWasapi.GetBassDevice).
// We replicate this by scanning the full list and remembering the last match.
for (int i = 0; i < 16384; i++)
{
if (!BassWasapi.GetDeviceInfo(i, out WasapiDeviceInfo info))
break;
// Only consider output devices (not input/loopback), since we're initialising audio output.
if (info.ID == driver && info.IsEnabled && !info.IsInput && !info.IsLoopback)
candidates.Add((i, info.MixFrequency, info.MixChannels));
}
if (candidates.Count > 0)
{
// Prefer common stereo formats.
var best = candidates
.OrderBy(c => c.chans == 2 ? 0 : 1)
.ThenBy(c => Math.Abs(c.freq - 48000))
.ThenBy(c => Math.Abs(c.freq - 44100))
.First();
wasapiDevice = best.index;
Logger.Log($"Mapped BASS device {bassDeviceId} (driver '{driver}') to WASAPI device {wasapiDevice} (mix: {best.freq}Hz/{best.chans}ch).", name: "audio",
level: LogLevel.Verbose);
// In exclusive mode, the chosen (freq/chans) pair matters. If the preferred candidate fails,
// we will retry other candidates below.
if (exclusive)
{
foreach (var candidate in candidates
.OrderBy(c => c.chans == 2 ? 0 : 1)
.ThenBy(c => Math.Abs(c.freq - 48000))
.ThenBy(c => Math.Abs(c.freq - 44100)))
{
freeWasapi();
if (initWasapi(candidate.index, exclusive))
return true;
}
Logger.Log($"All WASAPI exclusive format candidates failed for BASS device {bassDeviceId} (driver '{driver}').", name: "audio", level: LogLevel.Verbose);
return false;
}
}
else
{
// If the user selected a specific non-default device, do not fall back to system default.
// Fallback would likely be busy (e.g. browser playing on default), and would mask the real issue.
Logger.Log($"Could not map BASS device {bassDeviceId} (driver '{driver}') to a WASAPI output device; refusing to fall back to default (-1).", name: "audio",
level: LogLevel.Verbose);
return false;
}
}
else
{
Logger.Log($"BASS device {bassDeviceId} did not provide a driver identifier; falling back to default WASAPI device (-1).", name: "audio", level: LogLevel.Verbose);
}
}
if (wasapiDevice == -1)
Logger.Log("Using default WASAPI device (-1).", name: "audio", level: LogLevel.Verbose);
// To keep things in a sane state let's only keep one device initialised via wasapi.
freeWasapi();
return initWasapi(wasapiDevice, exclusive);
}
private bool initWasapi(int wasapiDevice, bool exclusive)
{
try
{
wasapiExclusiveActive = exclusive;
// BASSWASAPI flags:
// - 0x1 = EXCLUSIVE
// - 0x10 = EVENT (event-driven)
// ManagedBass bindings used here do not currently expose Exclusive, so we use the documented value.
const WasapiInitFlags exclusive_flag = (WasapiInitFlags)0x1;
int requestedFrequency = 0;
int requestedChannels = 0;
// Shared mode can use event-driven callbacks and auto-format.
// Exclusive mode should use an explicit supported format (freq/chans) from the chosen WASAPI entry.
var flags = (WasapiInitFlags)0;
if (exclusive)
{
flags |= exclusive_flag;
if (wasapiDevice >= 0 && BassWasapi.GetDeviceInfo(wasapiDevice, out WasapiDeviceInfo selectedInfo))
{
requestedFrequency = selectedInfo.MixFrequency;
requestedChannels = selectedInfo.MixChannels;
}
}
else
{
flags |= WasapiInitFlags.EventDriven | WasapiInitFlags.AutoFormat;
}
// Important: in exclusive mode, the underlying implementation may not support event-driven callbacks
// and can fall back to polling. Using a near-zero period (float.Epsilon) can then cause a busy-loop,
// leading to time running far too fast (and eventual instability elsewhere).
float bufferSeconds = exclusive ? 0.05f : 0f;
float periodSeconds = exclusive ? 0.01f : float.Epsilon;
bool initialised = BassWasapi.Init(wasapiDevice, Frequency: requestedFrequency, Channels: requestedChannels, Procedure: wasapi_procedure_static, Flags: flags, Buffer: bufferSeconds,
Period: periodSeconds, User: wasapiUserPtr);
Logger.Log(
$"Initialising BassWasapi for device {wasapiDevice} (exclusive: {exclusive}, buffer: {bufferSeconds:0.###}s, period: {periodSeconds:0.###}s)...{(initialised ? "success!" : "FAILED")}",
name: "audio", level: LogLevel.Verbose);
if (!initialised)
return false;
BassWasapi.GetInfo(out var wasapiInfo);
Logger.Log($"WASAPI info: Freq={wasapiInfo.Frequency}, Chans={wasapiInfo.Channels}, Format={wasapiInfo.Format}", name: "audio", level: LogLevel.Verbose);
globalMixerHandle.Value = BassMix.CreateMixerStream(wasapiInfo.Frequency, wasapiInfo.Channels, BassFlags.MixerNonStop | BassFlags.Decode | BassFlags.Float);
BassWasapi.Start();
BassWasapi.SetNotify(wasapi_notify_procedure_static, wasapiUserPtr);
return true;
}
catch (DllNotFoundException e)
{
logWasapiNativeUnavailableOnce($"WASAPI output is unavailable because basswasapi.dll could not be loaded ({e.Message}).");
freeWasapi();
return false;
}
catch (EntryPointNotFoundException e)
{
logWasapiNativeUnavailableOnce($"WASAPI output is unavailable because basswasapi.dll is incompatible/mismatched ({e.Message}).");
freeWasapi();
return false;
}
catch (Exception e)
{
Logger.Log($"WASAPI init failed with exception: {e}", name: "audio", level: LogLevel.Error);
freeWasapi();
return false;
}
}
private void freeWasapi()
{
int? mixerToFree = globalMixerHandle.Value;
try
{
// The mixer probably doesn't need to be recycled. Just keeping things sane for now.
// Stop WASAPI first to prevent callbacks from accessing disposed resources.
BassWasapi.SetNotify(null, IntPtr.Zero);
BassWasapi.Stop();
BassWasapi.Free();
if (mixerToFree != null)
Bass.StreamFree(mixerToFree.Value);
}
catch (DllNotFoundException e)
{
logWasapiNativeUnavailableOnce($"WASAPI cleanup failed because basswasapi.dll could not be loaded ({e.Message}).");
}
catch (EntryPointNotFoundException e)
{
logWasapiNativeUnavailableOnce($"WASAPI cleanup failed because basswasapi.dll is incompatible/mismatched ({e.Message}).");
}
catch (Exception e)
{
Logger.Log($"WASAPI cleanup failed with exception: {e}", name: "audio", level: LogLevel.Error);
}
finally
{
globalMixerHandle.Value = null;
}
}
private static void logWasapiNativeUnavailableOnce(string message)
{
if (Interlocked.Exchange(ref wasapiNativeUnavailableLogged, 1) == 1)
return;
Logger.Log(message, name: "audio", level: LogLevel.Error);
}
private bool initAsio(int asioDeviceIndex, double preferredSampleRate = 48000)
{
freeAsio();
// 使用来自AudioManager的统一采样率和缓冲区大小
if (Manager != null)
{
preferredSampleRate = Manager.SAMPLE_RATE.Value;
int bufferSize = Manager.ASIO_BUFFER_SIZE.Value;
if (!AsioDeviceManager.InitializeDevice(asioDeviceIndex, preferredSampleRate, bufferSize))
{
Logger.Log($"AsioDeviceManager.InitializeDevice({asioDeviceIndex}, {preferredSampleRate}, {bufferSize}) 失败", name: "audio", level: LogLevel.Error);
// 不要自动释放BASS设备 - 让AudioManager处理回退决策
// 这可以防止过度激进的设备切换导致设备可用性降低
return false;
}
// 初始化后获取设备信息
var deviceInfo = AsioDeviceManager.GetCurrentDeviceInfo();
if (deviceInfo == null)
{
Logger.Log("初始化后无法获取ASIO设备信息", name: "audio", level: LogLevel.Error);
freeAsio();
// 不要自动释放BASS设备 - 让AudioManager处理回退决策
return false;
}
int outputChannels = Math.Max(1, deviceInfo.Value.Outputs);
int inputChannels = Math.Max(0, deviceInfo.Value.Inputs);
// 验证设备信息
if (outputChannels < 2)
{
Logger.Log($"ASIO设备输出通道不足 ({outputChannels})立体声至少需要2个通道", name: "audio", level: LogLevel.Important);
freeAsio();
FreeDevice(Bass.CurrentDevice);
return false;
}
double sampleRate = BassAsio.Rate;
Logger.Log($"ASIO设备采样率: {sampleRate}Hz", name: "audio", level: LogLevel.Verbose);
// 验证采样率
if (sampleRate <= 0 || sampleRate > 1000000 || double.IsNaN(sampleRate) || double.IsInfinity(sampleRate))
{
Logger.Log($"检测到无效采样率 ({sampleRate}Hz),使用 {AsioDeviceManager.DEFAULT_SAMPLE_RATE}Hz 作为回退", name: "audio", level: LogLevel.Important);
sampleRate = AsioDeviceManager.DEFAULT_SAMPLE_RATE;
}
// Logger.Log($"使用ASIO设备配置 - 输出: {outputChannels}, 输入: {inputChannels}, 采样率: {sampleRate}Hz", name: "audio", level: LogLevel.Verbose);
// 创建立体声混音器(游戏音频始终是立体声)
const int mixer_channels = 2;
// Logger.Log($"创建ASIO混音器流: 采样率={sampleRate}, 混音器通道={mixer_channels} (立体声)", name: "audio", level: LogLevel.Verbose);
globalMixerHandle.Value = BassMix.CreateMixerStream((int)sampleRate, mixer_channels, BassFlags.MixerNonStop | BassFlags.Decode | BassFlags.Float);
if (globalMixerHandle.Value == 0)
{
var mixerError = Bass.LastError;
Logger.Log($"创建ASIO混音器流失败: {(int)mixerError} ({mixerError}), 采样率={sampleRate}, 通道={mixer_channels}", name: "audio", level: LogLevel.Error);
freeAsio();
// 释放BASS设备以便AudioManager可以使用不同的设备重试
FreeDevice(Bass.CurrentDevice);
return false;
}
// Logger.Log($"创建了带有 {mixer_channels} 个通道的ASIO混音器流采样率为 {sampleRate}Hz (句柄: {globalMixerHandle.Value})", name: "audio", level: LogLevel.Verbose);
// 为ASIO设备管理器设置全局混音器句柄
AsioDeviceManager.SetGlobalMixerHandle(globalMixerHandle.Value.Value);
// 使用设备管理器启动ASIO设备
if (!AsioDeviceManager.StartDevice())
{
Logger.Log("AsioDeviceManager.StartDevice() 失败", name: "audio", level: LogLevel.Error);
freeAsio();
// 不要自动释放BASS设备 - 让AudioManager处理回退决策
return false;
}
// Logger.Log($"ASIO设备初始化成功 - 采样率: {sampleRate}Hz, 输出: {outputChannels}, 输入: {inputChannels}", name: "audio", level: LogLevel.Important);
// 通知ASIO设备已使用实际采样率初始化
Manager?.OnAsioDeviceInitialized?.Invoke(sampleRate);
}
return true;
}
private void freeAsio()
{
if (RuntimeInfo.OS != RuntimeInfo.Platform.Windows)
return;
Logger.Log("Freeing ASIO device", name: "audio", level: LogLevel.Important);
try
{
// 首先停止ASIO设备
AsioDeviceManager.StopDevice();
// 停止后稍作延迟让设备稳定
Thread.Sleep(50);
// 释放ASIO设备
AsioDeviceManager.FreeDevice();
}
catch (Exception ex)
{
Logger.Log($"ASIO设备清理过程中出现异常: {ex.Message},尝试强制重置", name: "audio", level: LogLevel.Error);
try
{
AsioDeviceManager.ForceReset();
}
catch (Exception resetEx)
{
Logger.Log($"强制重置也失败: {resetEx.Message}", name: "audio", level: LogLevel.Error);
}
}
// 清理混音器句柄
if (globalMixerHandle.Value != null)
{
try
{
Bass.StreamFree(globalMixerHandle.Value.Value);
}
catch (Exception ex)
{
Logger.Log($"释放ASIO混音器时出现异常: {ex.Message}", name: "audio", level: LogLevel.Error);
}
globalMixerHandle.Value = null;
}
// 释放ASIO设备后添加较长延迟
// 这可以防止设备繁忙错误当在ASIO设备之间切换时
Thread.Sleep(300);
}
/// <summary>
/// 获取ASIO驱动的输出延迟
/// </summary>
/// <returns>输出延迟(毫秒),失败返回-1</returns>
internal double GetAsioOutputLatency()
{
try
{
if (BassAsio.IsStarted)
{
// 估算 ASIO 输出延迟
return 2.0; // ASIO 通常延迟较低
}
}
catch (Exception ex)
{
Logger.Log($"获取ASIO输出延迟失败: {ex.Message}", name: "audio", level: LogLevel.Error);
}
return -1;
}
/// <summary>
/// 获取WASAPI流的延迟
/// </summary>
/// <returns>流延迟(毫秒),失败返回-1</returns>
internal double GetWasapiStreamLatency()
{
try
{
if (BassWasapi.IsStarted)
{
// 使用 IAudioClient::GetStreamLatency
// 注意BassWasapi 可能不直接暴露这个,需要通过底层 API
// 暂时返回估算值
return 10.0; // 估算的 WASAPI 延迟
}
}
catch (Exception ex)
{
Logger.Log($"获取WASAPI流延迟失败: {ex.Message}", name: "audio", level: LogLevel.Error);
}
return -1;
}
/// <summary>
/// 获取当前音频驱动的缓冲区延迟
/// </summary>
/// <param name="outputMode">输出模式</param>
/// <returns>缓冲区延迟(毫秒),失败返回-1</returns>
internal double GetDriverBufferLatency(AudioOutputMode outputMode)
{
switch (outputMode)
{
case AudioOutputMode.Asio:
return GetAsioOutputLatency();
case AudioOutputMode.WasapiExclusive:
case AudioOutputMode.WasapiShared:
return GetWasapiStreamLatency();
default:
return -1;
}
}
/// <summary>
/// 记录延迟数据到日志
/// </summary>
/// <param name="inputLatency">输入延迟</param>
/// <param name="playbackLatency">播放延迟</param>
/// <param name="totalLatency">总延迟</param>
/// <param name="uncontrollableLatency">不可控延迟</param>
internal void LogLatencyData(double inputLatency, double playbackLatency, double totalLatency, double uncontrollableLatency)
{
const string driver_type = "Unknown";
int sampleRate = 44100;
const int buffer_size = 128;
// 获取当前驱动信息
if (Manager != null)
{
sampleRate = Manager.SAMPLE_RATE.Value;
// TODO: 获取bufferSize
}
// 确定驱动类型
// TODO: 从当前输出模式确定驱动类型
LogModule.LogLatency(
LatencyTestModule.RecordTimestamp(),
driver_type,
sampleRate,
buffer_size,
inputLatency,
playbackLatency,
totalLatency,
uncontrollableLatency
);
}
#endregion
}
}