Files
osu-framework/osu.Framework/Audio/AudioManager.cs
2026-02-10 18:29:40 +09:00

549 lines
23 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.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>
/// 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 => audioDeviceNames;
/// <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>
/// 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 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>
/// 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 += _ => scheduler.AddOnce(initCurrentDevice);
UseExperimentalWasapi.ValueChanged += _ => 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;
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()
{
string deviceName = AudioDevice.Value;
// try using the specified device
int deviceIndex = audioDeviceNames.FindIndex(d => d == deviceName);
if (deviceIndex >= 0 && trySetDevice(BASS_INTERNAL_DEVICE_COUNT + deviceIndex)) 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)) return;
// no audio devices can be used, so try using Bass-provided "No sound" device as last resort.
trySetDevice(Bass.NoSoundDevice);
// we're boned. even "No sound" device won't initialise.
return;
bool trySetDevice(int deviceId)
{
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))
return false;
//we have successfully initialised a new device.
UpdateDevice(deviceId);
return true;
}
}
/// <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>
protected virtual bool InitBass(int device)
{
// 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 success = attemptInit();
if (success || !UseExperimentalWasapi.Value)
return success;
// in the case we're using experimental WASAPI, give a second chance of initialisation by forcefully disabling it.
Logger.Log($"BASS device {device} failed to initialise with experimental WASAPI, disabling", level: LogLevel.Error);
UseExperimentalWasapi.Value = false;
return attemptInit();
bool attemptInit()
{
bool innerSuccess = thread.InitDevice(device, UseExperimentalWasapi.Value);
bool alreadyInitialised = Bass.LastError == Errors.Already;
if (alreadyInitialised)
return true;
if (BassUtils.CheckFaulted(false))
return false;
if (!innerSuccess)
{
Logger.Log("BASS failed to initialize but did not provide an error code", 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;
}
}
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);
});
}
}
/// <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);
bool isFallback = string.IsNullOrEmpty(AudioDevice.Value) ? !device.IsDefault : device.Name != AudioDevice.Value;
return device.IsEnabled && device.IsInitialized && !isFallback;
}
public override string ToString()
{
string deviceName = audioDevices.ElementAtOrDefault(Bass.CurrentDevice).Name;
return $@"{GetType().ReadableName()} ({deviceName ?? "Unknown"})";
}
}
}