A game framework written with osu! in mind.
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}