A game framework written with osu! in mind.
at master 413 lines 16 kB view raw
1// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence. 2// See the LICENCE file in the repository root for full licence text. 3 4using System; 5using System.Collections.Generic; 6using System.Collections.Immutable; 7using System.Diagnostics; 8using System.Linq; 9using System.Threading; 10using ManagedBass; 11using osu.Framework.Audio.Mixing; 12using osu.Framework.Audio.Mixing.Bass; 13using osu.Framework.Audio.Sample; 14using osu.Framework.Audio.Track; 15using osu.Framework.Bindables; 16using osu.Framework.Extensions.TypeExtensions; 17using osu.Framework.IO.Stores; 18using osu.Framework.Logging; 19using osu.Framework.Threading; 20 21namespace osu.Framework.Audio 22{ 23 public class AudioManager : AudioCollectionManager<AdjustableAudioComponent> 24 { 25 /// <summary> 26 /// The manager component responsible for audio tracks (e.g. songs). 27 /// </summary> 28 public ITrackStore Tracks => globalTrackStore.Value; 29 30 /// <summary> 31 /// The manager component responsible for audio samples (e.g. sound effects). 32 /// </summary> 33 public ISampleStore Samples => globalSampleStore.Value; 34 35 /// <summary> 36 /// The thread audio operations (mainly Bass calls) are ran on. 37 /// </summary> 38 private readonly AudioThread thread; 39 40 /// <summary> 41 /// The global mixer which all tracks are routed into by default. 42 /// </summary> 43 public readonly AudioMixer TrackMixer; 44 45 /// <summary> 46 /// The global mixer which all samples are routed into by default. 47 /// </summary> 48 public readonly AudioMixer SampleMixer; 49 50 /// <summary> 51 /// The names of all available audio devices. 52 /// </summary> 53 /// <remarks> 54 /// This property does not contain the names of disabled audio devices. 55 /// </remarks> 56 public IEnumerable<string> AudioDeviceNames => audioDeviceNames; 57 58 /// <summary> 59 /// Is fired whenever a new audio device is discovered and provides its name. 60 /// </summary> 61 public event Action<string> OnNewDevice; 62 63 /// <summary> 64 /// Is fired whenever an audio device is lost and provides its name. 65 /// </summary> 66 public event Action<string> OnLostDevice; 67 68 /// <summary> 69 /// The preferred audio device we should use. A value of 70 /// <see cref="string.Empty"/> denotes the OS default. 71 /// </summary> 72 public readonly Bindable<string> AudioDevice = new Bindable<string>(); 73 74 /// <summary> 75 /// Volume of all samples played game-wide. 76 /// </summary> 77 public readonly BindableDouble VolumeSample = new BindableDouble(1) 78 { 79 MinValue = 0, 80 MaxValue = 1 81 }; 82 83 /// <summary> 84 /// Volume of all tracks played game-wide. 85 /// </summary> 86 public readonly BindableDouble VolumeTrack = new BindableDouble(1) 87 { 88 MinValue = 0, 89 MaxValue = 1 90 }; 91 92 public override bool IsLoaded => base.IsLoaded && 93 // bass default device is a null device (-1), not the actual system default. 94 Bass.CurrentDevice != Bass.DefaultDevice; 95 96 // Mutated by multiple threads, must be thread safe. 97 private ImmutableList<DeviceInfo> audioDevices = ImmutableList<DeviceInfo>.Empty; 98 private ImmutableList<string> audioDeviceNames = ImmutableList<string>.Empty; 99 100 private Scheduler scheduler => thread.Scheduler; 101 102 private Scheduler eventScheduler => EventScheduler ?? scheduler; 103 104 private readonly CancellationTokenSource cancelSource = new CancellationTokenSource(); 105 private readonly DeviceInfoUpdateComparer updateComparer = new DeviceInfoUpdateComparer(); 106 107 /// <summary> 108 /// The scheduler used for invoking publicly exposed delegate events. 109 /// </summary> 110 public Scheduler EventScheduler; 111 112 internal IBindableList<int> ActiveMixerHandles => activeMixerHandles; 113 private readonly BindableList<int> activeMixerHandles = new BindableList<int>(); 114 115 private readonly Lazy<TrackStore> globalTrackStore; 116 private readonly Lazy<SampleStore> globalSampleStore; 117 118 /// <summary> 119 /// Constructs an AudioStore given a track resource store, and a sample resource store. 120 /// </summary> 121 /// <param name="audioThread">The host's audio thread.</param> 122 /// <param name="trackStore">The resource store containing all audio tracks to be used in the future.</param> 123 /// <param name="sampleStore">The sample store containing all audio samples to be used in the future.</param> 124 public AudioManager(AudioThread audioThread, ResourceStore<byte[]> trackStore, ResourceStore<byte[]> sampleStore) 125 { 126 thread = audioThread; 127 128 thread.RegisterManager(this); 129 130 AudioDevice.ValueChanged += onDeviceChanged; 131 132 globalTrackStore = new Lazy<TrackStore>(() => 133 { 134 var store = new TrackStore(trackStore, TrackMixer); 135 AddItem(store); 136 store.AddAdjustment(AdjustableProperty.Volume, VolumeTrack); 137 return store; 138 }); 139 140 globalSampleStore = new Lazy<SampleStore>(() => 141 { 142 var store = new SampleStore(sampleStore, SampleMixer); 143 AddItem(store); 144 store.AddAdjustment(AdjustableProperty.Volume, VolumeSample); 145 return store; 146 }); 147 148 AddItem(TrackMixer = createAudioMixer(null)); 149 AddItem(SampleMixer = createAudioMixer(null)); 150 151 CancellationToken token = cancelSource.Token; 152 153 scheduler.Add(() => 154 { 155 // sync audioDevices every 1000ms 156 new Thread(() => 157 { 158 while (!token.IsCancellationRequested) 159 { 160 try 161 { 162 syncAudioDevices(); 163 Thread.Sleep(1000); 164 } 165 catch 166 { 167 } 168 } 169 }) { IsBackground = true }.Start(); 170 }); 171 } 172 173 protected override void Dispose(bool disposing) 174 { 175 cancelSource.Cancel(); 176 177 thread.UnregisterManager(this); 178 179 OnNewDevice = null; 180 OnLostDevice = null; 181 182 base.Dispose(disposing); 183 } 184 185 private void onDeviceChanged(ValueChangedEvent<string> args) 186 { 187 scheduler.Add(() => setAudioDevice(args.NewValue)); 188 } 189 190 private void onDevicesChanged() 191 { 192 scheduler.Add(() => 193 { 194 if (cancelSource.IsCancellationRequested) 195 return; 196 197 if (!IsCurrentDeviceValid()) 198 setAudioDevice(); 199 }); 200 } 201 202 /// <summary> 203 /// Creates a new <see cref="AudioMixer"/>. 204 /// </summary> 205 /// <remarks> 206 /// Channels removed from this <see cref="AudioMixer"/> fall back to the global <see cref="SampleMixer"/>. 207 /// </remarks> 208 public AudioMixer CreateAudioMixer() => createAudioMixer(SampleMixer); 209 210 private AudioMixer createAudioMixer(AudioMixer globalMixer) 211 { 212 var mixer = new BassAudioMixer(globalMixer); 213 mixer.HandleCreated += i => activeMixerHandles.Add(i); 214 mixer.HandleDestroyed += i => activeMixerHandles.Remove(i); 215 AddItem(mixer); 216 return mixer; 217 } 218 219 /// <summary> 220 /// Obtains the <see cref="TrackStore"/> corresponding to a given resource store. 221 /// Returns the global <see cref="TrackStore"/> if no resource store is passed. 222 /// </summary> 223 /// <param name="store">The <see cref="IResourceStore{T}"/> of which to retrieve the <see cref="TrackStore"/>.</param> 224 public ITrackStore GetTrackStore(IResourceStore<byte[]> store = null) 225 { 226 if (store == null) return globalTrackStore.Value; 227 228 TrackStore tm = new TrackStore(store, TrackMixer); 229 globalTrackStore.Value.AddItem(tm); 230 return tm; 231 } 232 233 /// <summary> 234 /// Obtains the <see cref="SampleStore"/> corresponding to a given resource store. 235 /// Returns the global <see cref="SampleStore"/> if no resource store is passed. 236 /// </summary> 237 /// <param name="store">The <see cref="IResourceStore{T}"/> of which to retrieve the <see cref="SampleStore"/>.</param> 238 public ISampleStore GetSampleStore(IResourceStore<byte[]> store = null) 239 { 240 if (store == null) return globalSampleStore.Value; 241 242 SampleStore sm = new SampleStore(store, SampleMixer); 243 globalSampleStore.Value.AddItem(sm); 244 return sm; 245 } 246 247 /// <summary> 248 /// Sets the output audio device by its name. 249 /// This will automatically fall back to the system default device on failure. 250 /// </summary> 251 /// <param name="deviceName">Name of the audio device, or null to use the configured device preference <see cref="AudioDevice"/>.</param> 252 private bool setAudioDevice(string deviceName = null) 253 { 254 deviceName ??= AudioDevice.Value; 255 256 // try using the specified device 257 if (setAudioDevice(audioDevices.FindIndex(d => d.Name == deviceName))) 258 return true; 259 260 // try using the system default device 261 if (setAudioDevice(audioDevices.FindIndex(d => d.Name != deviceName && d.IsDefault))) 262 return true; 263 264 // no audio devices can be used, so try using Bass-provided "No sound" device as last resort 265 if (setAudioDevice(Bass.NoSoundDevice)) 266 return true; 267 268 //we're fucked. even "No sound" device won't initialise. 269 return false; 270 } 271 272 private bool setAudioDevice(int deviceIndex) 273 { 274 var device = audioDevices.ElementAtOrDefault(deviceIndex); 275 276 // device is invalid 277 if (!device.IsEnabled) 278 return false; 279 280 // initialize new device 281 bool initSuccess = InitBass(deviceIndex); 282 if (Bass.LastError != Errors.Already && BassUtils.CheckFaulted(false)) 283 return false; 284 285 if (!initSuccess) 286 { 287 Logger.Log("BASS failed to initialize but did not provide an error code", level: LogLevel.Error); 288 return false; 289 } 290 291 Logger.Log($@"BASS Initialized 292 BASS Version: {Bass.Version} 293 BASS FX Version: {ManagedBass.Fx.BassFx.Version} 294 Device: {device.Name} 295 Drive: {device.Driver}"); 296 297 //we have successfully initialised a new device. 298 UpdateDevice(deviceIndex); 299 300 return true; 301 } 302 303 /// <summary> 304 /// This method calls <see cref="Bass.Init(int, int, DeviceInitFlags, IntPtr, IntPtr)"/>. 305 /// It can be overridden for unit testing. 306 /// </summary> 307 protected virtual bool InitBass(int device) 308 { 309 if (Bass.CurrentDevice == device) 310 return true; 311 312 // reduce latency to a known sane minimum. 313 Bass.Configure(ManagedBass.Configuration.DeviceBufferLength, 10); 314 Bass.Configure(ManagedBass.Configuration.PlaybackBufferLength, 100); 315 316 // this likely doesn't help us but also doesn't seem to cause any issues or any cpu increase. 317 Bass.Configure(ManagedBass.Configuration.UpdatePeriod, 5); 318 319 // without this, if bass falls back to directsound legacy mode the audio playback offset will be way off. 320 Bass.Configure(ManagedBass.Configuration.TruePlayPosition, 0); 321 322 // Enable custom BASS_CONFIG_MP3_OLDGAPS flag for backwards compatibility. 323 Bass.Configure((ManagedBass.Configuration)68, 1); 324 325 // For iOS devices, set the default audio policy to one that obeys the mute switch. 326 Bass.Configure(ManagedBass.Configuration.IOSMixAudio, 5); 327 328 // ensure there are no brief delays on audio operations (causing stream STALLs etc.) after periods of silence. 329 Bass.Configure(ManagedBass.Configuration.DevNonStop, true); 330 331 var didInit = Bass.Init(device); 332 333 // If the device was already initialised, the device can be used without much fuss. 334 if (Bass.LastError == Errors.Already) 335 { 336 Bass.CurrentDevice = device; 337 338 // Without this call, on windows, a device which is disconnected then reconnected will look initialised 339 // but not work correctly in practice. 340 AudioThread.FreeDevice(device); 341 342 didInit = Bass.Init(device); 343 } 344 345 if (didInit) 346 thread.RegisterInitialisedDevice(device); 347 348 return didInit; 349 } 350 351 private void syncAudioDevices() 352 { 353 // audioDevices are updated if: 354 // - A new device is added 355 // - An existing device is Enabled/Disabled or set as Default 356 var updatedAudioDevices = EnumerateAllDevices().ToImmutableList(); 357 if (audioDevices.SequenceEqual(updatedAudioDevices, updateComparer)) 358 return; 359 360 audioDevices = updatedAudioDevices; 361 362 // Bass should always be providing "No sound" device 363 Trace.Assert(audioDevices.Count > 0, "Bass did not provide any audio devices."); 364 365 onDevicesChanged(); 366 367 var oldDeviceNames = audioDeviceNames; 368 var newDeviceNames = audioDeviceNames = audioDevices.Skip(1).Where(d => d.IsEnabled).Select(d => d.Name).ToImmutableList(); 369 370 var newDevices = newDeviceNames.Except(oldDeviceNames).ToList(); 371 var lostDevices = oldDeviceNames.Except(newDeviceNames).ToList(); 372 373 if (newDevices.Count > 0 || lostDevices.Count > 0) 374 { 375 eventScheduler.Add(delegate 376 { 377 foreach (var d in newDevices) 378 OnNewDevice?.Invoke(d); 379 foreach (var d in lostDevices) 380 OnLostDevice?.Invoke(d); 381 }); 382 } 383 } 384 385 protected virtual IEnumerable<DeviceInfo> EnumerateAllDevices() 386 { 387 int deviceCount = Bass.DeviceCount; 388 for (int i = 0; i < deviceCount; i++) 389 yield return Bass.GetDeviceInfo(i); 390 } 391 392 // The current device is considered valid if it is enabled, initialized, and not a fallback device. 393 protected virtual bool IsCurrentDeviceValid() 394 { 395 var device = audioDevices.ElementAtOrDefault(Bass.CurrentDevice); 396 bool isFallback = string.IsNullOrEmpty(AudioDevice.Value) ? !device.IsDefault : device.Name != AudioDevice.Value; 397 return device.IsEnabled && device.IsInitialized && !isFallback; 398 } 399 400 public override string ToString() 401 { 402 var deviceName = audioDevices.ElementAtOrDefault(Bass.CurrentDevice).Name; 403 return $@"{GetType().ReadableName()} ({deviceName ?? "Unknown"})"; 404 } 405 406 private class DeviceInfoUpdateComparer : IEqualityComparer<DeviceInfo> 407 { 408 public bool Equals(DeviceInfo x, DeviceInfo y) => x.IsEnabled == y.IsEnabled && x.IsDefault == y.IsDefault; 409 410 public int GetHashCode(DeviceInfo obj) => obj.Name.GetHashCode(); 411 } 412 } 413}