// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. using System; using System.Collections.Generic; using System.Collections.Immutable; using System.Diagnostics; using System.Linq; using System.Threading; using ManagedBass; 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.Extensions.TypeExtensions; using osu.Framework.IO.Stores; using osu.Framework.Logging; using osu.Framework.Threading; namespace osu.Framework.Audio { public class AudioManager : AudioCollectionManager { /// /// The manager component responsible for audio tracks (e.g. songs). /// public ITrackStore Tracks => globalTrackStore.Value; /// /// The manager component responsible for audio samples (e.g. sound effects). /// public ISampleStore Samples => globalSampleStore.Value; /// /// The thread audio operations (mainly Bass calls) are ran on. /// private readonly AudioThread thread; /// /// The global mixer which all tracks are routed into by default. /// public readonly AudioMixer TrackMixer; /// /// The global mixer which all samples are routed into by default. /// public readonly AudioMixer SampleMixer; /// /// The names of all available audio devices. /// /// /// This property does not contain the names of disabled audio devices. /// public IEnumerable AudioDeviceNames => audioDeviceNames; /// /// Is fired whenever a new audio device is discovered and provides its name. /// public event Action OnNewDevice; /// /// Is fired whenever an audio device is lost and provides its name. /// public event Action OnLostDevice; /// /// The preferred audio device we should use. A value of /// denotes the OS default. /// public readonly Bindable AudioDevice = new Bindable(); /// /// Volume of all samples played game-wide. /// public readonly BindableDouble VolumeSample = new BindableDouble(1) { MinValue = 0, MaxValue = 1 }; /// /// Volume of all tracks played game-wide. /// public readonly BindableDouble VolumeTrack = new BindableDouble(1) { MinValue = 0, MaxValue = 1 }; 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 ImmutableList audioDevices = ImmutableList.Empty; private ImmutableList audioDeviceNames = ImmutableList.Empty; private Scheduler scheduler => thread.Scheduler; private Scheduler eventScheduler => EventScheduler ?? scheduler; private readonly CancellationTokenSource cancelSource = new CancellationTokenSource(); private readonly DeviceInfoUpdateComparer updateComparer = new DeviceInfoUpdateComparer(); /// /// The scheduler used for invoking publicly exposed delegate events. /// public Scheduler EventScheduler; internal IBindableList ActiveMixerHandles => activeMixerHandles; private readonly BindableList activeMixerHandles = new BindableList(); private readonly Lazy globalTrackStore; private readonly Lazy globalSampleStore; /// /// Constructs an AudioStore given a track resource store, and a sample resource store. /// /// The host's audio thread. /// The resource store containing all audio tracks to be used in the future. /// The sample store containing all audio samples to be used in the future. public AudioManager(AudioThread audioThread, ResourceStore trackStore, ResourceStore sampleStore) { thread = audioThread; thread.RegisterManager(this); AudioDevice.ValueChanged += onDeviceChanged; globalTrackStore = new Lazy(() => { var store = new TrackStore(trackStore, TrackMixer); AddItem(store); store.AddAdjustment(AdjustableProperty.Volume, VolumeTrack); return store; }); globalSampleStore = new Lazy(() => { var store = new SampleStore(sampleStore, SampleMixer); AddItem(store); store.AddAdjustment(AdjustableProperty.Volume, VolumeSample); return store; }); AddItem(TrackMixer = createAudioMixer(null)); AddItem(SampleMixer = createAudioMixer(null)); CancellationToken token = cancelSource.Token; scheduler.Add(() => { // sync audioDevices every 1000ms new Thread(() => { while (!token.IsCancellationRequested) { try { syncAudioDevices(); Thread.Sleep(1000); } catch { } } }) { IsBackground = true }.Start(); }); } protected override void Dispose(bool disposing) { cancelSource.Cancel(); thread.UnregisterManager(this); OnNewDevice = null; OnLostDevice = null; base.Dispose(disposing); } private void onDeviceChanged(ValueChangedEvent args) { scheduler.Add(() => setAudioDevice(args.NewValue)); } private void onDevicesChanged() { scheduler.Add(() => { if (cancelSource.IsCancellationRequested) return; if (!IsCurrentDeviceValid()) setAudioDevice(); }); } /// /// Creates a new . /// /// /// Channels removed from this fall back to the global . /// public AudioMixer CreateAudioMixer() => createAudioMixer(SampleMixer); private AudioMixer createAudioMixer(AudioMixer globalMixer) { var mixer = new BassAudioMixer(globalMixer); mixer.HandleCreated += i => activeMixerHandles.Add(i); mixer.HandleDestroyed += i => activeMixerHandles.Remove(i); AddItem(mixer); return mixer; } /// /// Obtains the corresponding to a given resource store. /// Returns the global if no resource store is passed. /// /// The of which to retrieve the . public ITrackStore GetTrackStore(IResourceStore store = null) { if (store == null) return globalTrackStore.Value; TrackStore tm = new TrackStore(store, TrackMixer); globalTrackStore.Value.AddItem(tm); return tm; } /// /// Obtains the corresponding to a given resource store. /// Returns the global if no resource store is passed. /// /// The of which to retrieve the . public ISampleStore GetSampleStore(IResourceStore store = null) { if (store == null) return globalSampleStore.Value; SampleStore sm = new SampleStore(store, SampleMixer); globalSampleStore.Value.AddItem(sm); return sm; } /// /// Sets the output audio device by its name. /// This will automatically fall back to the system default device on failure. /// /// Name of the audio device, or null to use the configured device preference . private bool setAudioDevice(string deviceName = null) { deviceName ??= AudioDevice.Value; // try using the specified device if (setAudioDevice(audioDevices.FindIndex(d => d.Name == deviceName))) return true; // try using the system default device if (setAudioDevice(audioDevices.FindIndex(d => d.Name != deviceName && d.IsDefault))) return true; // no audio devices can be used, so try using Bass-provided "No sound" device as last resort if (setAudioDevice(Bass.NoSoundDevice)) return true; //we're fucked. even "No sound" device won't initialise. return false; } private bool setAudioDevice(int deviceIndex) { var device = audioDevices.ElementAtOrDefault(deviceIndex); // device is invalid if (!device.IsEnabled) return false; // initialize new device bool initSuccess = InitBass(deviceIndex); if (Bass.LastError != Errors.Already && BassUtils.CheckFaulted(false)) return false; if (!initSuccess) { Logger.Log("BASS failed to initialize but did not provide an error code", level: LogLevel.Error); return false; } Logger.Log($@"BASS Initialized BASS Version: {Bass.Version} BASS FX Version: {ManagedBass.Fx.BassFx.Version} Device: {device.Name} Drive: {device.Driver}"); //we have successfully initialised a new device. UpdateDevice(deviceIndex); return true; } /// /// This method calls . /// It can be overridden for unit testing. /// protected virtual bool InitBass(int device) { if (Bass.CurrentDevice == device) return true; // reduce latency to a known sane minimum. Bass.Configure(ManagedBass.Configuration.DeviceBufferLength, 10); Bass.Configure(ManagedBass.Configuration.PlaybackBufferLength, 100); // this likely doesn't help us but also doesn't seem to cause any issues or any cpu increase. Bass.Configure(ManagedBass.Configuration.UpdatePeriod, 5); // without this, if bass falls back to directsound legacy mode the audio playback offset will be way off. Bass.Configure(ManagedBass.Configuration.TruePlayPosition, 0); // Enable custom BASS_CONFIG_MP3_OLDGAPS flag for backwards compatibility. Bass.Configure((ManagedBass.Configuration)68, 1); // For iOS devices, set the default audio policy to one that obeys the mute switch. Bass.Configure(ManagedBass.Configuration.IOSMixAudio, 5); // ensure there are no brief delays on audio operations (causing stream STALLs etc.) after periods of silence. Bass.Configure(ManagedBass.Configuration.DevNonStop, true); var didInit = Bass.Init(device); // If the device was already initialised, the device can be used without much fuss. if (Bass.LastError == Errors.Already) { Bass.CurrentDevice = device; // Without this call, on windows, a device which is disconnected then reconnected will look initialised // but not work correctly in practice. AudioThread.FreeDevice(device); didInit = Bass.Init(device); } if (didInit) thread.RegisterInitialisedDevice(device); return didInit; } private void syncAudioDevices() { // audioDevices are updated if: // - A new device is added // - An existing device is Enabled/Disabled or set as Default var updatedAudioDevices = EnumerateAllDevices().ToImmutableList(); if (audioDevices.SequenceEqual(updatedAudioDevices, updateComparer)) return; audioDevices = updatedAudioDevices; // Bass should always be providing "No sound" device Trace.Assert(audioDevices.Count > 0, "Bass did not provide any audio devices."); onDevicesChanged(); var oldDeviceNames = audioDeviceNames; var newDeviceNames = audioDeviceNames = audioDevices.Skip(1).Where(d => d.IsEnabled).Select(d => d.Name).ToImmutableList(); var newDevices = newDeviceNames.Except(oldDeviceNames).ToList(); var lostDevices = oldDeviceNames.Except(newDeviceNames).ToList(); if (newDevices.Count > 0 || lostDevices.Count > 0) { eventScheduler.Add(delegate { foreach (var d in newDevices) OnNewDevice?.Invoke(d); foreach (var d in lostDevices) OnLostDevice?.Invoke(d); }); } } protected virtual IEnumerable EnumerateAllDevices() { int deviceCount = Bass.DeviceCount; for (int i = 0; i < deviceCount; i++) yield return Bass.GetDeviceInfo(i); } // 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() { var deviceName = audioDevices.ElementAtOrDefault(Bass.CurrentDevice).Name; return $@"{GetType().ReadableName()} ({deviceName ?? "Unknown"})"; } private class DeviceInfoUpdateComparer : IEqualityComparer { public bool Equals(DeviceInfo x, DeviceInfo y) => x.IsEnabled == y.IsEnabled && x.IsDefault == y.IsDefault; public int GetHashCode(DeviceInfo obj) => obj.Name.GetHashCode(); } } }