mirror of
https://github.com/SK-la/osu-framework.git
synced 2026-03-13 11:20:31 +00:00
1104 lines
45 KiB
C#
1104 lines
45 KiB
C#
// 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.
|
||
|
||
#nullable disable
|
||
|
||
using System;
|
||
using System.Collections.Generic;
|
||
using System.Collections.Immutable;
|
||
using System.Diagnostics;
|
||
using System.Linq;
|
||
using System.Threading;
|
||
using JetBrains.Annotations;
|
||
using ManagedBass;
|
||
using ManagedBass.Fx;
|
||
using ManagedBass.Mix;
|
||
using osu.Framework.Audio.EzLatency;
|
||
using AsioDeviceManager = osu.Framework.Audio.Asio.AsioDeviceManager;
|
||
using osu.Framework.Audio.Mixing;
|
||
using osu.Framework.Audio.Mixing.Bass;
|
||
using osu.Framework.Audio.Sample;
|
||
using osu.Framework.Audio.Track;
|
||
using osu.Framework.Bindables;
|
||
using osu.Framework.Configuration;
|
||
using osu.Framework.Development;
|
||
using osu.Framework.Extensions.TypeExtensions;
|
||
using osu.Framework.IO.Stores;
|
||
using osu.Framework.Logging;
|
||
using osu.Framework.Threading;
|
||
|
||
namespace osu.Framework.Audio
|
||
{
|
||
public class AudioManager : AudioCollectionManager<AudioComponent>
|
||
{
|
||
/// <summary>
|
||
/// The number of BASS audio devices preceding the first real audio device.
|
||
/// Consisting of <see cref="Bass.NoSoundDevice"/> and <see cref="bass_default_device"/>.
|
||
/// </summary>
|
||
protected const int BASS_INTERNAL_DEVICE_COUNT = 2;
|
||
|
||
/// <summary>
|
||
/// The index of the BASS audio device denoting the OS default.
|
||
/// </summary>
|
||
/// <remarks>
|
||
/// See http://www.un4seen.com/doc/#bass/BASS_CONFIG_DEV_DEFAULT.html for more information on the included device.
|
||
/// </remarks>
|
||
private const int bass_default_device = 1;
|
||
|
||
/// <summary>
|
||
/// The manager component responsible for audio tracks (e.g. songs).
|
||
/// </summary>
|
||
public ITrackStore Tracks => globalTrackStore.Value;
|
||
|
||
/// <summary>
|
||
/// The manager component responsible for audio samples (e.g. sound effects).
|
||
/// </summary>
|
||
public ISampleStore Samples => globalSampleStore.Value;
|
||
|
||
/// <summary>
|
||
/// The thread audio operations (mainly Bass calls) are ran on.
|
||
/// </summary>
|
||
private readonly AudioThread thread;
|
||
|
||
/// <summary>
|
||
/// The global mixer which all tracks are routed into by default.
|
||
/// </summary>
|
||
public readonly AudioMixer TrackMixer;
|
||
|
||
/// <summary>
|
||
/// The global mixer which all samples are routed into by default.
|
||
/// </summary>
|
||
public readonly AudioMixer SampleMixer;
|
||
|
||
/// <summary>
|
||
/// 采样率,用于ASIO设备的初始化和运行时更改。
|
||
/// </summary>
|
||
public readonly Bindable<int> SAMPLE_RATE = new Bindable<int>(48000);
|
||
|
||
/// <summary>
|
||
/// ASIO缓冲区大小,默认为128,用于ASIO设备的初始化。
|
||
/// </summary>
|
||
public readonly Bindable<int> ASIO_BUFFER_SIZE = new Bindable<int>(128);
|
||
|
||
/// <summary>
|
||
/// The names of all available audio devices.
|
||
/// </summary>
|
||
/// <remarks>
|
||
/// <para>
|
||
/// This property does not contain the names of disabled audio devices.
|
||
/// </para>
|
||
/// <para>
|
||
/// This property may also not necessarily contain the name of the default audio device provided by the OS.
|
||
/// Consumers should provide a "Default" audio device entry which sets <see cref="AudioDevice"/> to an empty string.
|
||
/// </para>
|
||
/// </remarks>
|
||
public IEnumerable<string> AudioDeviceNames => getAudioDeviceEntries();
|
||
|
||
/// <summary>
|
||
/// Is fired whenever a new audio device is discovered and provides its name.
|
||
/// </summary>
|
||
public event Action<string> OnNewDevice;
|
||
|
||
/// <summary>
|
||
/// Is fired whenever an audio device is lost and provides its name.
|
||
/// </summary>
|
||
public event Action<string> OnLostDevice;
|
||
|
||
/// <summary>
|
||
/// 在ASIO设备成功初始化后触发,提供实际使用的采样率。
|
||
/// </summary>
|
||
[CanBeNull]
|
||
public Action<double> OnAsioDeviceInitialized;
|
||
|
||
/// <summary>
|
||
/// The preferred audio device we should use. A value of
|
||
/// <see cref="string.Empty"/> denotes the OS default.
|
||
/// </summary>
|
||
public readonly Bindable<string> AudioDevice = new Bindable<string>();
|
||
|
||
/// <summary>
|
||
/// Whether to use experimental WASAPI initialisation on windows.
|
||
/// This generally results in lower audio latency, but also changes the audio synchronisation from
|
||
/// historical expectations, meaning users / application will have to account for different offsets.
|
||
/// </summary>
|
||
public readonly BindableBool UseExperimentalWasapi = new BindableBool();
|
||
|
||
/// <summary>
|
||
/// Volume of all samples played game-wide.
|
||
/// </summary>
|
||
public readonly BindableDouble VolumeSample = new BindableDouble(1)
|
||
{
|
||
MinValue = 0,
|
||
MaxValue = 1
|
||
};
|
||
|
||
/// <summary>
|
||
/// Volume of all tracks played game-wide.
|
||
/// </summary>
|
||
public readonly BindableDouble VolumeTrack = new BindableDouble(1)
|
||
{
|
||
MinValue = 0,
|
||
MaxValue = 1
|
||
};
|
||
|
||
/// <summary>
|
||
/// Whether a global mixer is being used for audio routing.
|
||
/// For now, this is only the case on Windows when using shared mode WASAPI initialisation.
|
||
/// </summary>
|
||
public IBindable<bool> UsingGlobalMixer => usingGlobalMixer;
|
||
|
||
private readonly Bindable<bool> usingGlobalMixer = new BindableBool();
|
||
|
||
/// <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>
|
||
/// <remarks>
|
||
/// When this is non-null, all mixers created via <see cref="CreateAudioMixer"/>
|
||
/// will themselves be added to the global mixer, which will handle playback itself.
|
||
///
|
||
/// In this mode of operation, nested mixers will be created with the <see cref="BassFlags.Decode"/>
|
||
/// flag, meaning they no longer handle playback directly.
|
||
///
|
||
/// An eventual goal would be to use a global mixer across all platforms as it can result
|
||
/// in more control and better playback performance.
|
||
/// </remarks>
|
||
internal readonly IBindable<int?> GlobalMixerHandle = new Bindable<int?>();
|
||
|
||
public override bool IsLoaded => base.IsLoaded &&
|
||
// bass default device is a null device (-1), not the actual system default.
|
||
Bass.CurrentDevice != Bass.DefaultDevice;
|
||
|
||
// Mutated by multiple threads, must be thread safe.
|
||
private ImmutableArray<DeviceInfo> audioDevices = ImmutableArray<DeviceInfo>.Empty;
|
||
private ImmutableList<string> audioDeviceNames = ImmutableList<string>.Empty;
|
||
|
||
private static int asioNativeUnavailableLogged;
|
||
|
||
private const string legacy_type_bass = "BASS";
|
||
private const string legacy_type_wasapi_shared = "WASAPI Shared";
|
||
private const string type_wasapi_exclusive = "WASAPI Exclusive";
|
||
private const string type_asio = "ASIO";
|
||
|
||
private bool syncingSelection;
|
||
|
||
private void setUserBindableValueLeaseSafe<T>(Bindable<T> bindable, T newValue)
|
||
{
|
||
if (EqualityComparer<T>.Default.Equals(bindable.Value, newValue))
|
||
return;
|
||
|
||
// These bindables are bound into UI (osu!) and can trigger transforms/animations.
|
||
// Ensure mutations happen on the update thread (Game.Scheduler) to avoid cross-thread Drawable mutations.
|
||
if (ThreadSafety.IsUpdateThread || EventScheduler == null)
|
||
{
|
||
setBindableValueLeaseSafe(bindable, newValue);
|
||
return;
|
||
}
|
||
|
||
eventScheduler.Add(() => setBindableValueLeaseSafe(bindable, newValue));
|
||
}
|
||
|
||
private static void setBindableValueLeaseSafe<T>(Bindable<T> bindable, T newValue)
|
||
{
|
||
if (EqualityComparer<T>.Default.Equals(bindable.Value, newValue))
|
||
return;
|
||
|
||
// Bindables may be in a leased state (Disabled=true), in which case Value setter throws.
|
||
// We still want internal state/config to reflect the effective output fallback.
|
||
if (bindable.Disabled)
|
||
bindable.SetValue(bindable.Value, newValue, true);
|
||
else
|
||
bindable.Value = newValue;
|
||
}
|
||
|
||
private Scheduler scheduler => thread.Scheduler;
|
||
|
||
private Scheduler eventScheduler => EventScheduler ?? scheduler;
|
||
|
||
private readonly CancellationTokenSource cancelSource = new CancellationTokenSource();
|
||
|
||
/// <summary>
|
||
/// The scheduler used for invoking publicly exposed delegate events.
|
||
/// </summary>
|
||
public Scheduler EventScheduler;
|
||
|
||
internal IBindableList<AudioMixer> ActiveMixers => activeMixers;
|
||
private readonly BindableList<AudioMixer> activeMixers = new BindableList<AudioMixer>();
|
||
|
||
private readonly Lazy<TrackStore> globalTrackStore;
|
||
private readonly Lazy<SampleStore> globalSampleStore;
|
||
|
||
/// <summary>
|
||
/// 设置ASIO采样率,对外接口。
|
||
/// </summary>
|
||
/// <param name="sampleRate">The preferred sample rate in Hz.</param>
|
||
public void SetPreferredAsioSampleRate(int sampleRate)
|
||
{
|
||
SAMPLE_RATE.Value = sampleRate;
|
||
}
|
||
|
||
/// <summary>
|
||
/// 设置ASIO缓冲区大小,对外接口。
|
||
/// </summary>
|
||
/// <param name="bufferSize">The preferred buffer size for ASIO device.</param>
|
||
public void SetAsioBufferSize(int bufferSize)
|
||
{
|
||
ASIO_BUFFER_SIZE.Value = bufferSize;
|
||
}
|
||
|
||
/// <summary>
|
||
/// Constructs an AudioStore given a track resource store, and a sample resource store.
|
||
/// </summary>
|
||
/// <param name="audioThread">The host's audio thread.</param>
|
||
/// <param name="trackStore">The resource store containing all audio tracks to be used in the future.</param>
|
||
/// <param name="sampleStore">The sample store containing all audio samples to be used in the future.</param>
|
||
/// <param name="config"></param>
|
||
public AudioManager(AudioThread audioThread, ResourceStore<byte[]> trackStore, ResourceStore<byte[]> sampleStore, [CanBeNull] FrameworkConfigManager config)
|
||
{
|
||
thread = audioThread;
|
||
|
||
thread.RegisterManager(this);
|
||
|
||
if (config != null)
|
||
{
|
||
// attach config bindables
|
||
config.BindWith(FrameworkSetting.AudioDevice, AudioDevice);
|
||
config.BindWith(FrameworkSetting.AudioUseExperimentalWasapi, UseExperimentalWasapi);
|
||
config.BindWith(FrameworkSetting.VolumeUniversal, Volume);
|
||
config.BindWith(FrameworkSetting.VolumeEffect, VolumeSample);
|
||
config.BindWith(FrameworkSetting.VolumeMusic, VolumeTrack);
|
||
}
|
||
|
||
AudioDevice.ValueChanged += _ =>
|
||
{
|
||
if (syncingSelection)
|
||
return;
|
||
|
||
scheduler.AddOnce(initCurrentDevice);
|
||
};
|
||
UseExperimentalWasapi.ValueChanged += e =>
|
||
{
|
||
if (syncingSelection)
|
||
return;
|
||
|
||
// Option (1): when experimental audio is enabled, the dropdown should not expose ASIO/Exclusive entries.
|
||
// Coerce any typed selection back to an allowed value.
|
||
if (UseExperimentalWasapi.Value && RuntimeInfo.OS == RuntimeInfo.Platform.Windows)
|
||
{
|
||
string selection = AudioDevice.Value;
|
||
|
||
if (tryParseSuffixed(selection, type_wasapi_exclusive, out string baseName))
|
||
{
|
||
syncingSelection = true;
|
||
|
||
try
|
||
{
|
||
setBindableValueLeaseSafe(AudioDevice, baseName);
|
||
}
|
||
finally
|
||
{
|
||
syncingSelection = false;
|
||
}
|
||
}
|
||
else if (tryParseSuffixed(selection, type_asio, out _))
|
||
{
|
||
syncingSelection = true;
|
||
|
||
try
|
||
{
|
||
// An ASIO selection is incompatible with experimental(shared) mode.
|
||
// Fall back to OS default.
|
||
setBindableValueLeaseSafe(AudioDevice, string.Empty);
|
||
}
|
||
finally
|
||
{
|
||
syncingSelection = false;
|
||
}
|
||
}
|
||
}
|
||
|
||
// Shared-mode WASAPI is still controlled by this checkbox.
|
||
// Keep dropdown values as the raw BASS device name to preserve historical UX.
|
||
scheduler.AddOnce(initCurrentDevice);
|
||
};
|
||
// initCurrentDevice not required for changes to `GlobalMixerHandle` as it is only changed when experimental wasapi is toggled (handled above).
|
||
GlobalMixerHandle.ValueChanged += handle => usingGlobalMixer.Value = handle.NewValue.HasValue;
|
||
|
||
// Listen for unified sample rate changes and reinitialize device if supported
|
||
SAMPLE_RATE.ValueChanged += e =>
|
||
{
|
||
if (syncingSelection)
|
||
return;
|
||
|
||
syncingSelection = true;
|
||
|
||
try
|
||
{
|
||
// Only reinitialize if we're currently using an ASIO device (only ASIO supports runtime sample rate changes)
|
||
if (hasTypeSuffix(AudioDevice.Value) && tryParseSuffixed(AudioDevice.Value, type_asio, out string _))
|
||
{
|
||
Logger.Log($"Sample rate changed to {SAMPLE_RATE.Value}Hz, reinitializing ASIO device", name: "audio", level: LogLevel.Important);
|
||
Logger.Log($"Current audio device before reinitialization: {AudioDevice.Value}", name: "audio", level: LogLevel.Debug);
|
||
|
||
scheduler.AddOnce(() =>
|
||
{
|
||
initCurrentDevice();
|
||
|
||
if (!IsCurrentDeviceValid())
|
||
{
|
||
Logger.Log("Sample rate setting failed, device invalid", name: "audio", level: LogLevel.Error);
|
||
}
|
||
|
||
syncingSelection = false;
|
||
});
|
||
}
|
||
else
|
||
{
|
||
Logger.Log($"Sample rate changed to {SAMPLE_RATE.Value}Hz, but current device ({AudioDevice.Value}) does not support runtime sample rate changes", name: "audio", level: LogLevel.Debug);
|
||
syncingSelection = false;
|
||
}
|
||
}
|
||
catch
|
||
{
|
||
syncingSelection = false;
|
||
}
|
||
};
|
||
|
||
// Listen for ASIO buffer size changes and reinitialize device if supported
|
||
ASIO_BUFFER_SIZE.ValueChanged += e =>
|
||
{
|
||
if (syncingSelection)
|
||
return;
|
||
|
||
syncingSelection = true;
|
||
|
||
try
|
||
{
|
||
// Only reinitialize if we're currently using an ASIO device (only ASIO supports runtime buffer size changes)
|
||
if (hasTypeSuffix(AudioDevice.Value) && tryParseSuffixed(AudioDevice.Value, type_asio, out string _))
|
||
{
|
||
Logger.Log($"ASIO buffer size changed to {ASIO_BUFFER_SIZE.Value}, reinitializing ASIO device", name: "audio", level: LogLevel.Important);
|
||
Logger.Log($"Current audio device before reinitialization: {AudioDevice.Value}", name: "audio", level: LogLevel.Debug);
|
||
|
||
scheduler.AddOnce(() =>
|
||
{
|
||
initCurrentDevice();
|
||
|
||
if (!IsCurrentDeviceValid())
|
||
{
|
||
Logger.Log("Buffer size setting failed, device invalid", name: "audio", level: LogLevel.Error);
|
||
}
|
||
|
||
syncingSelection = false;
|
||
});
|
||
}
|
||
else
|
||
{
|
||
Logger.Log($"ASIO buffer size changed to {ASIO_BUFFER_SIZE.Value}, but current device ({AudioDevice.Value}) does not support runtime buffer size changes", name: "audio", level: LogLevel.Debug);
|
||
syncingSelection = false;
|
||
}
|
||
}
|
||
catch
|
||
{
|
||
syncingSelection = false;
|
||
}
|
||
};
|
||
|
||
AddItem(TrackMixer = createAudioMixer(null, nameof(TrackMixer)));
|
||
AddItem(SampleMixer = createAudioMixer(null, nameof(SampleMixer)));
|
||
|
||
globalTrackStore = new Lazy<TrackStore>(() =>
|
||
{
|
||
var store = new TrackStore(trackStore, TrackMixer);
|
||
AddItem(store);
|
||
store.AddAdjustment(AdjustableProperty.Volume, VolumeTrack);
|
||
return store;
|
||
});
|
||
|
||
globalSampleStore = new Lazy<SampleStore>(() =>
|
||
{
|
||
var store = new SampleStore(sampleStore, SampleMixer);
|
||
AddItem(store);
|
||
store.AddAdjustment(AdjustableProperty.Volume, VolumeSample);
|
||
return store;
|
||
});
|
||
|
||
syncAudioDevices();
|
||
|
||
// check for changes in any audio devices every 1000ms (slightly expensive operation)
|
||
CancellationToken token = cancelSource.Token;
|
||
scheduler.AddDelayed(() =>
|
||
{
|
||
new Thread(() =>
|
||
{
|
||
while (!token.IsCancellationRequested)
|
||
{
|
||
try
|
||
{
|
||
if (CheckForDeviceChanges(audioDevices))
|
||
syncAudioDevices();
|
||
Thread.Sleep(1000);
|
||
}
|
||
catch
|
||
{
|
||
}
|
||
}
|
||
})
|
||
{
|
||
IsBackground = true
|
||
}.Start();
|
||
}, 1000);
|
||
}
|
||
|
||
protected override void Dispose(bool disposing)
|
||
{
|
||
cancelSource.Cancel();
|
||
|
||
thread.UnregisterManager(this);
|
||
|
||
OnNewDevice = null;
|
||
OnLostDevice = null;
|
||
|
||
base.Dispose(disposing);
|
||
}
|
||
|
||
private static int userMixerID;
|
||
|
||
/// <summary>
|
||
/// Creates a new <see cref="AudioMixer"/>.
|
||
/// </summary>
|
||
/// <remarks>
|
||
/// Channels removed from this <see cref="AudioMixer"/> fall back to the global <see cref="SampleMixer"/>.
|
||
/// </remarks>
|
||
/// <param name="identifier">An identifier displayed on the audio mixer visualiser.</param>
|
||
public AudioMixer CreateAudioMixer(string identifier = null) => createAudioMixer(SampleMixer, !string.IsNullOrEmpty(identifier) ? identifier : $"user #{Interlocked.Increment(ref userMixerID)}");
|
||
|
||
private AudioMixer createAudioMixer(AudioMixer fallbackMixer, string identifier)
|
||
{
|
||
var mixer = new BassAudioMixer(this, fallbackMixer, identifier);
|
||
AddItem(mixer);
|
||
return mixer;
|
||
}
|
||
|
||
protected override void ItemAdded(AudioComponent item)
|
||
{
|
||
base.ItemAdded(item);
|
||
if (item is AudioMixer mixer)
|
||
activeMixers.Add(mixer);
|
||
}
|
||
|
||
protected override void ItemRemoved(AudioComponent item)
|
||
{
|
||
base.ItemRemoved(item);
|
||
if (item is AudioMixer mixer)
|
||
activeMixers.Remove(mixer);
|
||
}
|
||
|
||
/// <summary>
|
||
/// Obtains the <see cref="TrackStore"/> corresponding to a given resource store.
|
||
/// Returns the global <see cref="TrackStore"/> if no resource store is passed.
|
||
/// </summary>
|
||
/// <param name="store">The <see cref="IResourceStore{T}"/> of which to retrieve the <see cref="TrackStore"/>.</param>
|
||
/// <param name="mixer">The <see cref="AudioMixer"/> to use for tracks created by this store. Defaults to the global <see cref="TrackMixer"/>.</param>
|
||
public ITrackStore GetTrackStore(IResourceStore<byte[]> store = null, AudioMixer mixer = null)
|
||
{
|
||
if (store == null) return globalTrackStore.Value;
|
||
|
||
TrackStore tm = new TrackStore(store, mixer ?? TrackMixer);
|
||
globalTrackStore.Value.AddItem(tm);
|
||
return tm;
|
||
}
|
||
|
||
/// <summary>
|
||
/// Obtains the <see cref="SampleStore"/> corresponding to a given resource store.
|
||
/// Returns the global <see cref="SampleStore"/> if no resource store is passed.
|
||
/// </summary>
|
||
/// <remarks>
|
||
/// By default, <c>.wav</c> and <c>.ogg</c> extensions will be automatically appended to lookups on the returned store
|
||
/// if the lookup does not correspond directly to an existing filename.
|
||
/// Additional extensions can be added via <see cref="ISampleStore.AddExtension"/>.
|
||
/// </remarks>
|
||
/// <param name="store">The <see cref="IResourceStore{T}"/> of which to retrieve the <see cref="SampleStore"/>.</param>
|
||
/// <param name="mixer">The <see cref="AudioMixer"/> to use for samples created by this store. Defaults to the global <see cref="SampleMixer"/>.</param>
|
||
public ISampleStore GetSampleStore(IResourceStore<byte[]> store = null, AudioMixer mixer = null)
|
||
{
|
||
if (store == null) return globalSampleStore.Value;
|
||
|
||
SampleStore sm = new SampleStore(store, mixer ?? SampleMixer);
|
||
globalSampleStore.Value.AddItem(sm);
|
||
return sm;
|
||
}
|
||
|
||
/// <summary>
|
||
/// (Re-)Initialises BASS for the current <see cref="AudioDevice"/>.
|
||
/// This will automatically fall back to the system default device on failure.
|
||
/// </summary>
|
||
private void initCurrentDevice()
|
||
{
|
||
// Note: normalisation may write back to bindables; ensure those writes are update-thread-safe.
|
||
normaliseLegacySelection();
|
||
|
||
var (mode, deviceName) = parseSelection(AudioDevice.Value);
|
||
|
||
bool isExplicitSelection = !string.IsNullOrEmpty(AudioDevice.Value);
|
||
bool isTypedSelection = hasTypeSuffix(AudioDevice.Value);
|
||
|
||
// keep legacy setting and dropdown selection in sync.
|
||
if (!syncingSelection)
|
||
{
|
||
syncingSelection = true;
|
||
|
||
try
|
||
{
|
||
// Option (1): experimental(shared) mode hides Exclusive/ASIO entries.
|
||
// If we still see these modes (eg. from config), force experimental off to keep behaviour consistent.
|
||
if (UseExperimentalWasapi.Value && (mode == AudioOutputMode.WasapiExclusive || mode == AudioOutputMode.Asio))
|
||
setUserBindableValueLeaseSafe(UseExperimentalWasapi, false);
|
||
}
|
||
finally
|
||
{
|
||
syncingSelection = false;
|
||
}
|
||
}
|
||
|
||
// try using the specified device
|
||
if (mode == AudioOutputMode.Asio)
|
||
{
|
||
// ASIO output still requires BASS to be initialised, but output is performed by BassAsio.
|
||
// Use the OS default BASS device as a fallback initialisation target.
|
||
// For ASIO mode, add retry logic since device initialization can be flaky
|
||
const int max_asio_retries = 2;
|
||
bool asioInitSuccess = false;
|
||
|
||
for (int retry = 0; retry < max_asio_retries; retry++)
|
||
{
|
||
if (trySetDevice(bass_default_device, mode))
|
||
{
|
||
asioInitSuccess = true;
|
||
}
|
||
else
|
||
{
|
||
Logger.Log($"ASIO device initialization failed, retrying in 300ms (attempt {retry + 1}/{max_asio_retries})", name: "audio", level: LogLevel.Important);
|
||
Thread.Sleep(200);
|
||
|
||
// Force cleanup between retries to prevent resource conflicts
|
||
scheduler.Add(() =>
|
||
{
|
||
thread.FreeDevice(bass_default_device);
|
||
Thread.Sleep(100);
|
||
});
|
||
}
|
||
}
|
||
|
||
if (!asioInitSuccess)
|
||
{
|
||
Logger.Log("ASIO device initialization failed after all retries, falling back to default device", name: "audio", level: LogLevel.Important);
|
||
}
|
||
|
||
return;
|
||
}
|
||
else
|
||
{
|
||
// try using the specified device
|
||
int deviceIndex = audioDeviceNames.FindIndex(d => d == deviceName);
|
||
if (deviceIndex >= 0 && trySetDevice(BASS_INTERNAL_DEVICE_COUNT + deviceIndex, mode)) return;
|
||
}
|
||
|
||
// try using the system default if there is any device present.
|
||
// mobiles are an exception as the built-in speakers may not be provided as an audio device name,
|
||
// but they are still provided by BASS under the internal device name "Default".
|
||
if ((audioDeviceNames.Count > 0 || RuntimeInfo.IsMobile) && trySetDevice(bass_default_device, mode)) return;
|
||
|
||
// If an explicit selection failed, revert to Default and try again in default output mode.
|
||
// Keep checkbox state unless Exclusive/ASIO was chosen.
|
||
if (isExplicitSelection)
|
||
{
|
||
if (trySetDevice(bass_default_device, AudioOutputMode.Default))
|
||
{
|
||
revertSelectionToDefault();
|
||
return;
|
||
}
|
||
|
||
// If even default failed, still revert selection (we'll fall through to NoSound).
|
||
revertSelectionToDefault();
|
||
}
|
||
|
||
// no audio devices can be used, so try using Bass-provided "No sound" device as last resort.
|
||
trySetDevice(Bass.NoSoundDevice, AudioOutputMode.Default);
|
||
|
||
// we're boned. even "No sound" device won't initialise.
|
||
return;
|
||
|
||
bool trySetDevice(int deviceId, AudioOutputMode outputMode)
|
||
{
|
||
var device = audioDevices.ElementAtOrDefault(deviceId);
|
||
|
||
// device is invalid
|
||
if (!device.IsEnabled)
|
||
return false;
|
||
|
||
// we don't want bass initializing with real audio device on headless test runs.
|
||
if (deviceId != Bass.NoSoundDevice && DebugUtils.IsNUnitRunning)
|
||
return false;
|
||
|
||
// initialize new device
|
||
if (!InitBass(deviceId, outputMode))
|
||
return false;
|
||
|
||
//we have successfully initialised a new device.
|
||
UpdateDevice(deviceId);
|
||
|
||
return true;
|
||
}
|
||
|
||
void revertSelectionToDefault()
|
||
{
|
||
if (syncingSelection)
|
||
return;
|
||
|
||
syncingSelection = true;
|
||
|
||
try
|
||
{
|
||
// Ensure "Default" means OS default device.
|
||
// Preserve shared-WASAPI checkbox unless an exclusive/ASIO entry was explicitly selected.
|
||
if (isTypedSelection || mode == AudioOutputMode.WasapiExclusive || mode == AudioOutputMode.Asio)
|
||
setUserBindableValueLeaseSafe(UseExperimentalWasapi, false);
|
||
|
||
setUserBindableValueLeaseSafe(AudioDevice, string.Empty);
|
||
}
|
||
finally
|
||
{
|
||
syncingSelection = false;
|
||
}
|
||
}
|
||
}
|
||
|
||
private void normaliseLegacySelection()
|
||
{
|
||
if (syncingSelection)
|
||
return;
|
||
|
||
string selection = AudioDevice.Value;
|
||
if (string.IsNullOrEmpty(selection))
|
||
return;
|
||
|
||
// Earlier iterations stored typed entries for BASS/Shared WASAPI directly in AudioDevice.
|
||
// The dropdown now shows raw device names (plus appended Exclusive/ASIO), so rewrite old values.
|
||
if (tryParseSuffixed(selection, legacy_type_bass, out string baseName))
|
||
{
|
||
syncingSelection = true;
|
||
|
||
try
|
||
{
|
||
setUserBindableValueLeaseSafe(AudioDevice, baseName);
|
||
}
|
||
finally
|
||
{
|
||
syncingSelection = false;
|
||
}
|
||
|
||
return;
|
||
}
|
||
|
||
if (tryParseSuffixed(selection, legacy_type_wasapi_shared, out baseName))
|
||
{
|
||
syncingSelection = true;
|
||
|
||
try
|
||
{
|
||
setUserBindableValueLeaseSafe(AudioDevice, baseName);
|
||
setUserBindableValueLeaseSafe(UseExperimentalWasapi, true);
|
||
}
|
||
finally
|
||
{
|
||
syncingSelection = false;
|
||
}
|
||
}
|
||
}
|
||
|
||
private static bool hasTypeSuffix(string value) => !string.IsNullOrEmpty(value) && (value.EndsWith($" ({type_wasapi_exclusive})", StringComparison.Ordinal)
|
||
|| value.EndsWith($" ({type_asio})", StringComparison.Ordinal));
|
||
|
||
/// <summary>
|
||
/// This method calls <see cref="Bass.Init(int, int, DeviceInitFlags, IntPtr, IntPtr)"/>.
|
||
/// It can be overridden for unit testing.
|
||
/// </summary>
|
||
/// <param name="device">The device to initialise.</param>
|
||
/// <param name="outputMode">The output mode to use for playback.</param>
|
||
protected virtual bool InitBass(int device, AudioOutputMode outputMode)
|
||
{
|
||
// this likely doesn't help us but also doesn't seem to cause any issues or any cpu increase.
|
||
Bass.UpdatePeriod = 5;
|
||
|
||
// reduce latency to a known sane minimum.
|
||
Bass.DeviceBufferLength = 10;
|
||
Bass.PlaybackBufferLength = 100;
|
||
|
||
// ensure there are no brief delays on audio operations (causing stream stalls etc.) after periods of silence.
|
||
Bass.DeviceNonStop = true;
|
||
|
||
// without this, if bass falls back to directsound legacy mode the audio playback offset will be way off.
|
||
Bass.Configure(ManagedBass.Configuration.TruePlayPosition, 0);
|
||
|
||
// Set BASS_IOS_SESSION_DISABLE here to leave session configuration in our hands (see iOS project).
|
||
Bass.Configure(ManagedBass.Configuration.IOSSession, 16);
|
||
|
||
// Always provide a default device. This should be a no-op, but we have asserts for this behaviour.
|
||
Bass.Configure(ManagedBass.Configuration.IncludeDefaultDevice, true);
|
||
|
||
// Enable custom BASS_CONFIG_MP3_OLDGAPS flag for backwards compatibility.
|
||
// - This disables support for ItunSMPB tag parsing to match previous expectations.
|
||
// - This also disables a change which assumes a 529 sample (2116 byte in stereo 16-bit) delay if the MP3 file doesn't specify one.
|
||
// (That was added in Bass for more consistent results across platforms and standard/mp3-free BASS versions, because OSX/iOS's MP3 decoder always removes 529 samples)
|
||
// Bass.Configure((ManagedBass.Configuration)68, 1);
|
||
|
||
// Disable BASS_CONFIG_DEV_TIMEOUT flag to keep BASS audio output from pausing on device processing timeout.
|
||
// See https://www.un4seen.com/forum/?topic=19601 for more information.
|
||
Bass.Configure((ManagedBass.Configuration)70, false);
|
||
|
||
bool attemptInit()
|
||
{
|
||
bool innerSuccess;
|
||
|
||
try
|
||
{
|
||
innerSuccess = thread.InitDevice(device, outputMode, SAMPLE_RATE.Value);
|
||
}
|
||
catch (Exception e)
|
||
{
|
||
Logger.Log($"Audio device initialisation threw an exception (mode: {outputMode}, device: {device}): {e}", name: "audio", level: LogLevel.Error);
|
||
return false;
|
||
}
|
||
|
||
// For ASIO mode, initialization failure should be treated as a critical failure
|
||
// since ASIO devices require specific initialization that may not be recoverable
|
||
if (outputMode == AudioOutputMode.Asio && !innerSuccess)
|
||
{
|
||
Logger.Log("ASIO device initialization failed - this is treated as a critical failure", name: "audio", level: LogLevel.Error);
|
||
return false;
|
||
}
|
||
|
||
bool alreadyInitialised = Bass.LastError == Errors.Already;
|
||
|
||
if (alreadyInitialised)
|
||
{
|
||
// 对于ASIO模式,如果InitDevice失败,则应将其视为失败
|
||
// 因为即使BASS已经初始化,ASIO设备也可能无法正确初始化
|
||
if (outputMode == AudioOutputMode.Asio && !innerSuccess)
|
||
{
|
||
Logger.Log("ASIO device initialization failed even though BASS was already initialized", name: "audio", level: LogLevel.Error);
|
||
return false;
|
||
}
|
||
|
||
return true;
|
||
}
|
||
|
||
if (BassUtils.CheckFaulted(false))
|
||
return false;
|
||
|
||
if (!innerSuccess)
|
||
{
|
||
Logger.Log("BASS failed to initialize but did not provide an error code", name: "audio", level: LogLevel.Error);
|
||
return false;
|
||
}
|
||
|
||
var deviceInfo = audioDevices.ElementAtOrDefault(device);
|
||
|
||
Logger.Log($@"🔈 BASS initialised
|
||
BASS version: {Bass.Version}
|
||
BASS FX version: {BassFx.Version}
|
||
BASS MIX version: {BassMix.Version}
|
||
Device: {deviceInfo.Name}
|
||
Driver: {deviceInfo.Driver}
|
||
Update period: {Bass.UpdatePeriod} ms
|
||
Device buffer length: {Bass.DeviceBufferLength} ms
|
||
Playback buffer length: {Bass.PlaybackBufferLength} ms");
|
||
|
||
return true;
|
||
}
|
||
|
||
return attemptInit();
|
||
}
|
||
|
||
private void syncAudioDevices()
|
||
{
|
||
audioDevices = GetAllDevices();
|
||
|
||
// Bass should always be providing "No sound" and "Default" device.
|
||
Trace.Assert(audioDevices.Length >= BASS_INTERNAL_DEVICE_COUNT, "Bass did not provide any audio devices.");
|
||
|
||
var oldDeviceNames = audioDeviceNames;
|
||
var newDeviceNames = audioDeviceNames = audioDevices.Skip(BASS_INTERNAL_DEVICE_COUNT).Where(d => d.IsEnabled).Select(d => d.Name).ToImmutableList();
|
||
|
||
scheduler.Add(() =>
|
||
{
|
||
if (cancelSource.IsCancellationRequested)
|
||
return;
|
||
|
||
if (!IsCurrentDeviceValid())
|
||
initCurrentDevice();
|
||
}, false);
|
||
|
||
var newDevices = newDeviceNames.Except(oldDeviceNames).ToList();
|
||
var lostDevices = oldDeviceNames.Except(newDeviceNames).ToList();
|
||
|
||
if (newDevices.Count > 0 || lostDevices.Count > 0)
|
||
{
|
||
eventScheduler.Add(delegate
|
||
{
|
||
foreach (string d in newDevices)
|
||
OnNewDevice?.Invoke(d);
|
||
foreach (string d in lostDevices)
|
||
OnLostDevice?.Invoke(d);
|
||
});
|
||
}
|
||
}
|
||
|
||
private IEnumerable<string> getAudioDeviceEntries()
|
||
{
|
||
var entries = new List<string>();
|
||
|
||
// Base BASS devices (historical UX: raw device names).
|
||
entries.AddRange(audioDeviceNames);
|
||
|
||
if (RuntimeInfo.OS == RuntimeInfo.Platform.Windows)
|
||
{
|
||
// Option (1): when experimental(shared) audio is enabled, do not expose Exclusive/ASIO entries.
|
||
if (UseExperimentalWasapi.Value)
|
||
return entries;
|
||
|
||
// Only append extra entries; shared WASAPI remains controlled by the checkbox.
|
||
entries.AddRange(audioDeviceNames.Select(d => formatEntry(d, type_wasapi_exclusive)));
|
||
|
||
// ASIO drivers.
|
||
int asioCount = 0;
|
||
|
||
foreach (var device in AsioDeviceManager.EnumerateAsioDevices())
|
||
{
|
||
entries.Add(formatEntry(device.Name, type_asio));
|
||
asioCount++;
|
||
}
|
||
|
||
Logger.Log($"Found {asioCount} ASIO devices", name: "audio", level: LogLevel.Verbose);
|
||
}
|
||
|
||
return entries;
|
||
}
|
||
|
||
private static string formatEntry(string name, string type) => $"{name} ({type})";
|
||
|
||
private (AudioOutputMode mode, string deviceName) parseSelection(string selection)
|
||
{
|
||
// Default device.
|
||
if (string.IsNullOrEmpty(selection))
|
||
{
|
||
return (UseExperimentalWasapi.Value && RuntimeInfo.OS == RuntimeInfo.Platform.Windows
|
||
? AudioOutputMode.WasapiShared
|
||
: AudioOutputMode.Default, string.Empty);
|
||
}
|
||
|
||
// Option (1): if experimental(shared) is enabled, typed entries should be treated as legacy/config leftovers.
|
||
// Coerce them back to shared mode.
|
||
if (UseExperimentalWasapi.Value && RuntimeInfo.OS == RuntimeInfo.Platform.Windows)
|
||
{
|
||
if (tryParseSuffixed(selection, type_wasapi_exclusive, out string baseName))
|
||
return (AudioOutputMode.WasapiShared, baseName);
|
||
|
||
if (tryParseSuffixed(selection, type_asio, out _))
|
||
return (AudioOutputMode.WasapiShared, string.Empty);
|
||
}
|
||
|
||
if (tryParseSuffixed(selection, type_wasapi_exclusive, out string name))
|
||
return (AudioOutputMode.WasapiExclusive, name);
|
||
|
||
if (tryParseSuffixed(selection, type_asio, out name))
|
||
{
|
||
return (AudioOutputMode.Asio, name);
|
||
}
|
||
|
||
// Legacy value (raw BASS device name). Keep old behaviour: the experimental flag decides shared WASAPI.
|
||
if (UseExperimentalWasapi.Value && RuntimeInfo.OS == RuntimeInfo.Platform.Windows)
|
||
return (AudioOutputMode.WasapiShared, selection);
|
||
|
||
return (AudioOutputMode.Default, selection);
|
||
}
|
||
|
||
private static bool tryParseSuffixed(string value, string type, out string baseName)
|
||
{
|
||
string suffix = $" ({type})";
|
||
|
||
if (value.EndsWith(suffix, StringComparison.Ordinal))
|
||
{
|
||
baseName = value[..^suffix.Length];
|
||
return true;
|
||
}
|
||
|
||
baseName = string.Empty;
|
||
return false;
|
||
}
|
||
|
||
private static void logAsioNativeUnavailableOnce(Exception e)
|
||
{
|
||
if (Interlocked.Exchange(ref asioNativeUnavailableLogged, 1) == 1)
|
||
return;
|
||
|
||
// Keep message actionable but non-intrusive (no UI popups).
|
||
Logger.Log($"ASIO output is unavailable because the native bassasio library could not be loaded ({e.GetType().Name}: {e.Message}). Ensure bassasio.dll is present alongside other BASS native libraries (typically in the x64/x86 subdirectories; the BASS.ASIO NuGet package will copy it automatically when referenced).", name: "audio", level: LogLevel.Error);
|
||
}
|
||
|
||
/// <summary>
|
||
/// Check whether any audio device changes have occurred.
|
||
///
|
||
/// Changes supported are:
|
||
/// - A new device is added
|
||
/// - An existing device is Enabled/Disabled or set as Default
|
||
/// </summary>
|
||
/// <remarks>
|
||
/// This method is optimised to incur the lowest overhead possible.
|
||
/// </remarks>
|
||
/// <param name="previousDevices">The previous audio devices array.</param>
|
||
/// <returns>Whether a change was detected.</returns>
|
||
protected virtual bool CheckForDeviceChanges(ImmutableArray<DeviceInfo> previousDevices)
|
||
{
|
||
int deviceCount = Bass.DeviceCount;
|
||
|
||
if (previousDevices.Length != deviceCount)
|
||
return true;
|
||
|
||
for (int i = 0; i < deviceCount; i++)
|
||
{
|
||
var prevInfo = previousDevices[i];
|
||
|
||
Bass.GetDeviceInfo(i, out var info);
|
||
|
||
if (info.IsEnabled != prevInfo.IsEnabled)
|
||
return true;
|
||
|
||
if (info.IsDefault != prevInfo.IsDefault)
|
||
return true;
|
||
}
|
||
|
||
return false;
|
||
}
|
||
|
||
protected virtual ImmutableArray<DeviceInfo> GetAllDevices()
|
||
{
|
||
int deviceCount = Bass.DeviceCount;
|
||
|
||
var devices = ImmutableArray.CreateBuilder<DeviceInfo>(deviceCount);
|
||
for (int i = 0; i < deviceCount; i++)
|
||
devices.Add(Bass.GetDeviceInfo(i));
|
||
|
||
return devices.MoveToImmutable();
|
||
}
|
||
|
||
// The current device is considered valid if it is enabled, initialized, and not a fallback device.
|
||
protected virtual bool IsCurrentDeviceValid()
|
||
{
|
||
var device = audioDevices.ElementAtOrDefault(Bass.CurrentDevice);
|
||
var (mode, selectedName) = parseSelection(AudioDevice.Value);
|
||
|
||
// ASIO output selection does not map to a BASS device name; ensure we're initialised and ASIO is working.
|
||
if (mode == AudioOutputMode.Asio)
|
||
{
|
||
if (!device.IsEnabled || !device.IsInitialized)
|
||
return false;
|
||
|
||
// For ASIO mode, also verify that ASIO device is actually initialized and working
|
||
try
|
||
{
|
||
var asioInfo = AsioDeviceManager.GetCurrentDeviceInfo();
|
||
double currentRate = AsioDeviceManager.GetCurrentSampleRate();
|
||
|
||
// Check if ASIO device info exists and sample rate is valid
|
||
return asioInfo != null && currentRate > 0 && !double.IsNaN(currentRate) && !double.IsInfinity(currentRate);
|
||
}
|
||
catch
|
||
{
|
||
return false;
|
||
}
|
||
}
|
||
|
||
bool isFallback = string.IsNullOrEmpty(selectedName) ? !device.IsDefault : device.Name != selectedName;
|
||
return device.IsEnabled && device.IsInitialized && !isFallback;
|
||
}
|
||
|
||
public override string ToString()
|
||
{
|
||
string deviceName = audioDevices.ElementAtOrDefault(Bass.CurrentDevice).Name;
|
||
return $@"{GetType().ReadableName()} ({deviceName ?? "Unknown"})";
|
||
}
|
||
}
|
||
}
|
||
|
||
// #region 受支持的采样率设置
|
||
|
||
// /// <summary>
|
||
// /// Gets the supported sample rates for a specific ASIO device.
|
||
// /// This is useful for populating UI dropdowns with device-specific sample rate options.
|
||
// /// </summary>
|
||
// /// <param name="deviceName">The name of the ASIO device.</param>
|
||
// /// <returns>An array of supported sample rates, or an empty array if the device is not found or not an ASIO device.</returns>
|
||
// public double[] GetAsioDeviceSupportedSampleRates(string deviceName)
|
||
// {
|
||
// if (RuntimeInfo.OS != RuntimeInfo.Platform.Windows)
|
||
// return Array.Empty<double>();
|
||
//
|
||
// try
|
||
// {
|
||
// foreach (var device in AsioDeviceManager.AvailableDevicesWithSampleRates)
|
||
// {
|
||
// if (device.Name == deviceName)
|
||
// {
|
||
// return device.SupportedSampleRates;
|
||
// }
|
||
// }
|
||
// }
|
||
// catch (DllNotFoundException e)
|
||
// {
|
||
// logAsioNativeUnavailableOnce(e);
|
||
// }
|
||
// catch (EntryPointNotFoundException e)
|
||
// {
|
||
// logAsioNativeUnavailableOnce(e);
|
||
// }
|
||
// catch (Exception ex)
|
||
// {
|
||
// Logger.Log($"Error getting ASIO device sample rates for {deviceName}: {ex.Message}", LoggingTarget.Runtime, LogLevel.Error);
|
||
// }
|
||
//
|
||
// return Array.Empty<double>();
|
||
// }
|
||
// /// <summary>
|
||
// /// Gets all available ASIO devices with their supported sample rates.
|
||
// /// This is useful for populating UI with device-specific sample rate options.
|
||
// /// </summary>
|
||
// /// <returns>An enumerable of tuples containing device index, name, and supported sample rates.</returns>
|
||
// public IEnumerable<(int Index, string Name, double[] SupportedSampleRates)> GetAvailableAsioDevicesWithSampleRates()
|
||
// {
|
||
// if (RuntimeInfo.OS != RuntimeInfo.Platform.Windows)
|
||
// return Enumerable.Empty<(int, string, double[])>();
|
||
//
|
||
// try
|
||
// {
|
||
// return AsioDeviceManager.AvailableDevicesWithSampleRates;
|
||
// }
|
||
// catch (DllNotFoundException e)
|
||
// {
|
||
// logAsioNativeUnavailableOnce(e);
|
||
// }
|
||
// catch (EntryPointNotFoundException e)
|
||
// {
|
||
// logAsioNativeUnavailableOnce(e);
|
||
// }
|
||
// catch (Exception ex)
|
||
// {
|
||
// Logger.Log($"Error getting ASIO devices with sample rates: {ex.Message}", LoggingTarget.Runtime, LogLevel.Error);
|
||
// }
|
||
//
|
||
// return Enumerable.Empty<(int, string, double[])>();
|
||
// }
|
||
|
||
// #endregion
|