1 #region Copyright & License Information
2 /*
3  * Copyright 2007-2020 The OpenRA Developers (see AUTHORS)
4  * This file is part of OpenRA, which is free software. It is made
5  * available to you under the terms of the GNU General Public License
6  * as published by the Free Software Foundation, either version 3 of
7  * the License, or (at your option) any later version. For more
8  * information, see COPYING.
9  */
10 #endregion
11 
12 using System;
13 using System.Collections.Generic;
14 using System.IO;
15 using System.Linq;
16 using System.Runtime.InteropServices;
17 using System.Text;
18 using System.Threading;
19 using System.Threading.Tasks;
20 using OpenAL;
21 
22 namespace OpenRA.Platforms.Default
23 {
24 	sealed class OpenAlSoundEngine : ISoundEngine
25 	{
26 		public bool Dummy { get { return false; } }
27 
AvailableDevices()28 		public SoundDevice[] AvailableDevices()
29 		{
30 			var defaultDevices = new[]
31 			{
32 				new SoundDevice(null, "Default Output"),
33 			};
34 
35 			var physicalDevices = PhysicalDevices().Select(d => new SoundDevice(d, d));
36 			return defaultDevices.Concat(physicalDevices).ToArray();
37 		}
38 
39 		class PoolSlot
40 		{
41 			public bool IsActive;
42 			public int FrameStarted;
43 			public WPos Pos;
44 			public bool IsRelative;
45 			public OpenAlSoundSource SoundSource;
46 			public OpenAlSound Sound;
47 		}
48 
49 		const int MaxInstancesPerFrame = 3;
50 		const int GroupDistance = 2730;
51 		const int GroupDistanceSqr = GroupDistance * GroupDistance;
52 		const int PoolSize = 32;
53 
54 		readonly Dictionary<uint, PoolSlot> sourcePool = new Dictionary<uint, PoolSlot>(PoolSize);
55 		float volume = 1f;
56 		IntPtr device;
57 		IntPtr context;
58 
QueryDevices(string label, int type)59 		static string[] QueryDevices(string label, int type)
60 		{
61 			// Clear error bit
62 			AL10.alGetError();
63 
64 			// Returns a null separated list of strings, terminated by two nulls.
65 			var devicesPtr = ALC10.alcGetString(IntPtr.Zero, type);
66 			if (devicesPtr == IntPtr.Zero || AL10.alGetError() != AL10.AL_NO_ERROR)
67 			{
68 				Log.Write("sound", "Failed to query OpenAL device list using {0}", label);
69 				return new string[0];
70 			}
71 
72 			var devices = new List<string>();
73 			var buffer = new List<byte>();
74 			var offset = 0;
75 
76 			while (true)
77 			{
78 				var b = Marshal.ReadByte(devicesPtr, offset++);
79 				if (b != 0)
80 				{
81 					buffer.Add(b);
82 					continue;
83 				}
84 
85 				// A null indicates termination of that string, so add that to our list.
86 				devices.Add(Encoding.UTF8.GetString(buffer.ToArray()));
87 				buffer.Clear();
88 
89 				// Two successive nulls indicates the end of the list.
90 				if (Marshal.ReadByte(devicesPtr, offset) == 0)
91 					break;
92 			}
93 
94 			return devices.ToArray();
95 		}
96 
PhysicalDevices()97 		static string[] PhysicalDevices()
98 		{
99 			// Returns all devices under Windows Vista and newer
100 			if (ALC11.alcIsExtensionPresent(IntPtr.Zero, "ALC_ENUMERATE_ALL_EXT"))
101 				return QueryDevices("ALC_ENUMERATE_ALL_EXT", ALC11.ALC_ALL_DEVICES_SPECIFIER);
102 
103 			if (ALC11.alcIsExtensionPresent(IntPtr.Zero, "ALC_ENUMERATION_EXT"))
104 				return QueryDevices("ALC_ENUMERATION_EXT", ALC10.ALC_DEVICE_SPECIFIER);
105 
106 			return new string[] { };
107 		}
108 
MakeALFormat(int channels, int bits)109 		internal static int MakeALFormat(int channels, int bits)
110 		{
111 			if (channels == 1)
112 				return bits == 16 ? AL10.AL_FORMAT_MONO16 : AL10.AL_FORMAT_MONO8;
113 			else
114 				return bits == 16 ? AL10.AL_FORMAT_STEREO16 : AL10.AL_FORMAT_STEREO8;
115 		}
116 
OpenAlSoundEngine(string deviceName)117 		public OpenAlSoundEngine(string deviceName)
118 		{
119 			if (deviceName != null)
120 				Console.WriteLine("Using sound device `{0}`", deviceName);
121 			else
122 				Console.WriteLine("Using default sound device");
123 
124 			device = ALC10.alcOpenDevice(deviceName);
125 			if (device == IntPtr.Zero)
126 			{
127 				Console.WriteLine("Failed to open device. Falling back to default");
128 				device = ALC10.alcOpenDevice(null);
129 				if (device == IntPtr.Zero)
130 					throw new InvalidOperationException("Can't create OpenAL device");
131 			}
132 
133 			context = ALC10.alcCreateContext(device, null);
134 			if (context == IntPtr.Zero)
135 				throw new InvalidOperationException("Can't create OpenAL context");
136 			ALC10.alcMakeContextCurrent(context);
137 
138 			for (var i = 0; i < PoolSize; i++)
139 			{
140 				var source = 0U;
141 				AL10.alGenSources(1, out source);
142 				if (AL10.alGetError() != AL10.AL_NO_ERROR)
143 				{
144 					Log.Write("sound", "Failed generating OpenAL source {0}", i);
145 					return;
146 				}
147 
148 				sourcePool.Add(source, new PoolSlot() { IsActive = false });
149 			}
150 		}
151 
TryGetSourceFromPool(out uint source)152 		bool TryGetSourceFromPool(out uint source)
153 		{
154 			foreach (var kv in sourcePool)
155 			{
156 				if (!kv.Value.IsActive)
157 				{
158 					sourcePool[kv.Key].IsActive = true;
159 					source = kv.Key;
160 					return true;
161 				}
162 			}
163 
164 			var freeSources = new List<uint>();
165 			foreach (var kv in sourcePool)
166 			{
167 				var sound = kv.Value.Sound;
168 				if (sound != null && sound.Complete)
169 				{
170 					var freeSource = kv.Key;
171 					freeSources.Add(freeSource);
172 					AL10.alSourceRewind(freeSource);
173 					AL10.alSourcei(freeSource, AL10.AL_BUFFER, 0);
174 				}
175 			}
176 
177 			if (freeSources.Count == 0)
178 			{
179 				source = 0;
180 				return false;
181 			}
182 
183 			foreach (var freeSource in freeSources)
184 			{
185 				var slot = sourcePool[freeSource];
186 				slot.SoundSource = null;
187 				slot.Sound = null;
188 				slot.IsActive = false;
189 			}
190 
191 			source = freeSources[0];
192 			sourcePool[source].IsActive = true;
193 			return true;
194 		}
195 
AddSoundSourceFromMemory(byte[] data, int channels, int sampleBits, int sampleRate)196 		public ISoundSource AddSoundSourceFromMemory(byte[] data, int channels, int sampleBits, int sampleRate)
197 		{
198 			return new OpenAlSoundSource(data, data.Length, channels, sampleBits, sampleRate);
199 		}
200 
Play2D(ISoundSource soundSource, bool loop, bool relative, WPos pos, float volume, bool attenuateVolume)201 		public ISound Play2D(ISoundSource soundSource, bool loop, bool relative, WPos pos, float volume, bool attenuateVolume)
202 		{
203 			if (soundSource == null)
204 			{
205 				Log.Write("sound", "Attempt to Play2D a null `ISoundSource`");
206 				return null;
207 			}
208 
209 			var alSoundSource = (OpenAlSoundSource)soundSource;
210 
211 			var currFrame = Game.LocalTick;
212 			var atten = 1f;
213 
214 			// Check if max # of instances-per-location reached:
215 			if (attenuateVolume)
216 			{
217 				int instances = 0, activeCount = 0;
218 				foreach (var s in sourcePool.Values)
219 				{
220 					if (!s.IsActive)
221 						continue;
222 					if (s.IsRelative != relative)
223 						continue;
224 
225 					++activeCount;
226 					if (s.SoundSource != alSoundSource)
227 						continue;
228 					if (currFrame - s.FrameStarted >= 5)
229 						continue;
230 
231 					// Too far away to count?
232 					var lensqr = (s.Pos - pos).LengthSquared;
233 					if (lensqr >= GroupDistanceSqr)
234 						continue;
235 
236 					// If we are starting too many instances of the same sound within a short time then stop this one:
237 					if (++instances == MaxInstancesPerFrame)
238 						return null;
239 				}
240 
241 				// Attenuate a little bit based on number of active sounds:
242 				atten = 0.66f * ((PoolSize - activeCount * 0.5f) / PoolSize);
243 			}
244 
245 			uint source;
246 			if (!TryGetSourceFromPool(out source))
247 				return null;
248 
249 			var slot = sourcePool[source];
250 			slot.Pos = pos;
251 			slot.FrameStarted = currFrame;
252 			slot.IsRelative = relative;
253 			slot.SoundSource = alSoundSource;
254 			slot.Sound = new OpenAlSound(source, loop, relative, pos, volume * atten, alSoundSource.SampleRate, alSoundSource.Buffer);
255 			return slot.Sound;
256 		}
257 
Play2DStream(Stream stream, int channels, int sampleBits, int sampleRate, bool loop, bool relative, WPos pos, float volume)258 		public ISound Play2DStream(Stream stream, int channels, int sampleBits, int sampleRate, bool loop, bool relative, WPos pos, float volume)
259 		{
260 			var currFrame = Game.LocalTick;
261 
262 			uint source;
263 			if (!TryGetSourceFromPool(out source))
264 				return null;
265 
266 			var slot = sourcePool[source];
267 			slot.Pos = pos;
268 			slot.FrameStarted = currFrame;
269 			slot.IsRelative = relative;
270 			slot.SoundSource = null;
271 			slot.Sound = new OpenAlAsyncLoadSound(source, loop, relative, pos, volume, channels, sampleBits, sampleRate, stream);
272 			return slot.Sound;
273 		}
274 
275 		public float Volume
276 		{
277 			get { return volume; }
278 			set { AL10.alListenerf(AL10.AL_GAIN, volume = value); }
279 		}
280 
PauseSound(ISound sound, bool paused)281 		public void PauseSound(ISound sound, bool paused)
282 		{
283 			if (sound == null)
284 				return;
285 
286 			var source = ((OpenAlSound)sound).Source;
287 			PauseSound(source, paused);
288 		}
289 
SetAllSoundsPaused(bool paused)290 		public void SetAllSoundsPaused(bool paused)
291 		{
292 			foreach (var source in sourcePool.Keys)
293 				PauseSound(source, paused);
294 		}
295 
PauseSound(uint source, bool paused)296 		void PauseSound(uint source, bool paused)
297 		{
298 			int state;
299 			AL10.alGetSourcei(source, AL10.AL_SOURCE_STATE, out state);
300 			if (paused)
301 			{
302 				if (state == AL10.AL_PLAYING)
303 					AL10.alSourcePause(source);
304 				else if (state == AL10.AL_INITIAL)
305 				{
306 					// If a sound hasn't started yet,
307 					// we indicate it should not play be transitioning it to the stopped state.
308 					AL10.alSourcePlay(source);
309 					AL10.alSourceStop(source);
310 				}
311 			}
312 			else if (!paused && state != AL10.AL_PLAYING)
313 				AL10.alSourcePlay(source);
314 		}
315 
SetSoundVolume(float volume, ISound music, ISound video)316 		public void SetSoundVolume(float volume, ISound music, ISound video)
317 		{
318 			var sounds = sourcePool.Keys.Where(key =>
319 			{
320 				int state;
321 				AL10.alGetSourcei(key, AL10.AL_SOURCE_STATE, out state);
322 				return (state == AL10.AL_PLAYING || state == AL10.AL_PAUSED) &&
323 					   (music == null || key != ((OpenAlSound)music).Source) &&
324 					   (video == null || key != ((OpenAlSound)video).Source);
325 			});
326 
327 			foreach (var s in sounds)
328 				AL10.alSourcef(s, AL10.AL_GAIN, volume);
329 		}
330 
StopSound(ISound sound)331 		public void StopSound(ISound sound)
332 		{
333 			if (sound == null)
334 				return;
335 
336 			((OpenAlSound)sound).Stop();
337 		}
338 
StopAllSounds()339 		public void StopAllSounds()
340 		{
341 			foreach (var slot in sourcePool.Values)
342 				if (slot.Sound != null)
343 					slot.Sound.Stop();
344 		}
345 
SetListenerPosition(WPos position)346 		public void SetListenerPosition(WPos position)
347 		{
348 			// Move the listener out of the plane so that sounds near the middle of the screen aren't too positional
349 			AL10.alListener3f(AL10.AL_POSITION, position.X, position.Y, position.Z + 2133);
350 
351 			var orientation = new[] { 0f, 0f, 1f, 0f, -1f, 0f };
352 			AL10.alListenerfv(AL10.AL_ORIENTATION, orientation);
353 			AL10.alListenerf(EFX.AL_METERS_PER_UNIT, .01f);
354 		}
355 
~OpenAlSoundEngine()356 		~OpenAlSoundEngine()
357 		{
358 			Dispose(false);
359 		}
360 
Dispose()361 		public void Dispose()
362 		{
363 			Dispose(true);
364 			GC.SuppressFinalize(this);
365 		}
366 
Dispose(bool disposing)367 		void Dispose(bool disposing)
368 		{
369 			StopAllSounds();
370 
371 			if (context != IntPtr.Zero)
372 			{
373 				ALC10.alcMakeContextCurrent(IntPtr.Zero);
374 				ALC10.alcDestroyContext(context);
375 				context = IntPtr.Zero;
376 			}
377 
378 			if (device != IntPtr.Zero)
379 			{
380 				ALC10.alcCloseDevice(device);
381 				device = IntPtr.Zero;
382 			}
383 		}
384 	}
385 
386 	class OpenAlSoundSource : ISoundSource
387 	{
388 		uint buffer;
389 		bool disposed;
390 
391 		public uint Buffer { get { return buffer; } }
392 		public int SampleRate { get; private set; }
393 
OpenAlSoundSource(byte[] data, int byteCount, int channels, int sampleBits, int sampleRate)394 		public OpenAlSoundSource(byte[] data, int byteCount, int channels, int sampleBits, int sampleRate)
395 		{
396 			SampleRate = sampleRate;
397 			AL10.alGenBuffers(1, out buffer);
398 			AL10.alBufferData(buffer, OpenAlSoundEngine.MakeALFormat(channels, sampleBits), data, byteCount, sampleRate);
399 		}
400 
Dispose(bool disposing)401 		protected virtual void Dispose(bool disposing)
402 		{
403 			if (!disposed)
404 			{
405 				AL10.alDeleteBuffers(1, ref buffer);
406 				disposed = true;
407 			}
408 		}
409 
~OpenAlSoundSource()410 		~OpenAlSoundSource()
411 		{
412 			Dispose(false);
413 		}
414 
Dispose()415 		public void Dispose()
416 		{
417 			Dispose(true);
418 			GC.SuppressFinalize(this);
419 		}
420 	}
421 
422 	class OpenAlSound : ISound
423 	{
424 		public readonly uint Source;
425 		protected readonly float SampleRate;
426 
OpenAlSound(uint source, bool looping, bool relative, WPos pos, float volume, int sampleRate, uint buffer)427 		public OpenAlSound(uint source, bool looping, bool relative, WPos pos, float volume, int sampleRate, uint buffer)
428 			: this(source, looping, relative, pos, volume, sampleRate)
429 		{
430 			AL10.alSourcei(source, AL10.AL_BUFFER, (int)buffer);
431 			AL10.alSourcePlay(source);
432 		}
433 
OpenAlSound(uint source, bool looping, bool relative, WPos pos, float volume, int sampleRate)434 		protected OpenAlSound(uint source, bool looping, bool relative, WPos pos, float volume, int sampleRate)
435 		{
436 			Source = source;
437 			SampleRate = sampleRate;
438 			Volume = volume;
439 
440 			AL10.alSourcef(source, AL10.AL_PITCH, 1f);
441 			AL10.alSource3f(source, AL10.AL_POSITION, pos.X, pos.Y, pos.Z);
442 			AL10.alSource3f(source, AL10.AL_VELOCITY, 0f, 0f, 0f);
443 			AL10.alSourcei(source, AL10.AL_LOOPING, looping ? 1 : 0);
444 			AL10.alSourcei(source, AL10.AL_SOURCE_RELATIVE, relative ? 1 : 0);
445 
446 			AL10.alSourcef(source, AL10.AL_REFERENCE_DISTANCE, 6826);
447 			AL10.alSourcef(source, AL10.AL_MAX_DISTANCE, 136533);
448 		}
449 
450 		public float Volume
451 		{
452 			get { float volume; AL10.alGetSourcef(Source, AL10.AL_GAIN, out volume); return volume; }
453 			set { AL10.alSourcef(Source, AL10.AL_GAIN, value); }
454 		}
455 
456 		public virtual float SeekPosition
457 		{
458 			get
459 			{
460 				int sampleOffset;
461 				AL10.alGetSourcei(Source, AL11.AL_SAMPLE_OFFSET, out sampleOffset);
462 				return sampleOffset / SampleRate;
463 			}
464 		}
465 
466 		public virtual bool Complete
467 		{
468 			get
469 			{
470 				int state;
471 				AL10.alGetSourcei(Source, AL10.AL_SOURCE_STATE, out state);
472 				return state == AL10.AL_STOPPED;
473 			}
474 		}
475 
SetPosition(WPos pos)476 		public void SetPosition(WPos pos)
477 		{
478 			AL10.alSource3f(Source, AL10.AL_POSITION, pos.X, pos.Y, pos.Z);
479 		}
480 
StopSource()481 		protected void StopSource()
482 		{
483 			int state;
484 			AL10.alGetSourcei(Source, AL10.AL_SOURCE_STATE, out state);
485 			if (state == AL10.AL_PLAYING || state == AL10.AL_PAUSED)
486 				AL10.alSourceStop(Source);
487 		}
488 
Stop()489 		public virtual void Stop()
490 		{
491 			StopSource();
492 			AL10.alSourcei(Source, AL10.AL_BUFFER, 0);
493 		}
494 	}
495 
496 	class OpenAlAsyncLoadSound : OpenAlSound
497 	{
498 		static readonly byte[] SilentData = new byte[2];
499 		readonly CancellationTokenSource cts = new CancellationTokenSource();
500 		readonly Task playTask;
501 
OpenAlAsyncLoadSound(uint source, bool looping, bool relative, WPos pos, float volume, int channels, int sampleBits, int sampleRate, Stream stream)502 		public OpenAlAsyncLoadSound(uint source, bool looping, bool relative, WPos pos, float volume, int channels, int sampleBits, int sampleRate, Stream stream)
503 			: base(source, looping, relative, pos, volume, sampleRate)
504 		{
505 			// Load a silent buffer into the source. Without this,
506 			// attempting to change the state (i.e. play/pause) the source fails on some systems.
507 			var silentSource = new OpenAlSoundSource(SilentData, SilentData.Length, channels, sampleBits, sampleRate);
508 			AL10.alSourcei(source, AL10.AL_BUFFER, (int)silentSource.Buffer);
509 
510 			playTask = Task.Run(async () =>
511 			{
512 				MemoryStream memoryStream;
513 				using (stream)
514 				{
515 					try
516 					{
517 						memoryStream = new MemoryStream((int)stream.Length);
518 					}
519 					catch (NotSupportedException)
520 					{
521 						// Fallback for stream types that don't support Length.
522 						memoryStream = new MemoryStream();
523 					}
524 
525 					try
526 					{
527 						await stream.CopyToAsync(memoryStream, 81920, cts.Token);
528 					}
529 					catch (TaskCanceledException)
530 					{
531 						// Sound was stopped early, cleanup the unused buffer and exit.
532 						AL10.alSourceStop(source);
533 						AL10.alSourcei(source, AL10.AL_BUFFER, 0);
534 						silentSource.Dispose();
535 						return;
536 					}
537 				}
538 
539 				var data = memoryStream.GetBuffer();
540 				var dataLength = (int)memoryStream.Length;
541 				var bytesPerSample = sampleBits / 8f;
542 				var lengthInSecs = dataLength / (channels * bytesPerSample * sampleRate);
543 				using (var soundSource = new OpenAlSoundSource(data, dataLength, channels, sampleBits, sampleRate))
544 				{
545 					// Need to stop the source, before attaching the real input and deleting the silent one.
546 					AL10.alSourceStop(source);
547 					AL10.alSourcei(source, AL10.AL_BUFFER, (int)soundSource.Buffer);
548 					silentSource.Dispose();
549 
550 					lock (cts)
551 					{
552 						if (!cts.IsCancellationRequested)
553 						{
554 							// TODO: A race condition can happen between the state check and playing/rewinding if a
555 							// user pauses/resumes at the right moment. The window of opportunity is small and the
556 							// consequences are minor, so for now we'll ignore it.
557 							int state;
558 							AL10.alGetSourcei(Source, AL10.AL_SOURCE_STATE, out state);
559 							if (state != AL10.AL_STOPPED)
560 								AL10.alSourcePlay(source);
561 							else
562 							{
563 								// A stopped sound indicates it was paused before we finishing loaded.
564 								// We don't want to start playing it right away.
565 								// We rewind the source so when it is started, it plays from the beginning.
566 								AL10.alSourceRewind(source);
567 							}
568 						}
569 					}
570 
571 					while (!cts.IsCancellationRequested)
572 					{
573 						// Need to check seek before state. Otherwise, the music can stop after our state check at
574 						// which point the seek will be zero, meaning we'll wait the full track length before seeing it
575 						// has stopped.
576 						var currentSeek = SeekPosition;
577 
578 						int state;
579 						AL10.alGetSourcei(Source, AL10.AL_SOURCE_STATE, out state);
580 						if (state == AL10.AL_STOPPED)
581 							break;
582 
583 						try
584 						{
585 							// Wait until the track is due to complete, and at most 60 times a second to prevent a
586 							// busy-wait.
587 							var delaySecs = Math.Max(lengthInSecs - currentSeek, 1 / 60f);
588 							await Task.Delay(TimeSpan.FromSeconds(delaySecs), cts.Token);
589 						}
590 						catch (TaskCanceledException)
591 						{
592 							// Sound was stopped early, allow normal cleanup to occur.
593 						}
594 					}
595 
596 					AL10.alSourcei(Source, AL10.AL_BUFFER, 0);
597 				}
598 			});
599 		}
600 
Stop()601 		public override void Stop()
602 		{
603 			lock (cts)
604 			{
605 				StopSource();
606 				cts.Cancel();
607 			}
608 
609 			try
610 			{
611 				playTask.Wait();
612 			}
613 			catch (AggregateException)
614 			{
615 			}
616 		}
617 
618 		public override bool Complete
619 		{
620 			get { return playTask.IsCompleted; }
621 		}
622 	}
623 }
624