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