using Amphora; using System; using System.Collections.Generic; namespace Susurrus.XAudio2 { /// /// Plays sounds through XAudio2 on the machine's default audio device. It plays mono and stereo sounds, from pools /// of source voices that makes up front, so no voice is created while a game runs. /// public class XAudio2Module : IModule { public const int DefaultMonoVoiceCount = 32; public const int DefaultStereoVoiceCount = 16; /// /// The rate every voice is created at. A voice is set to its sound's rate when the sound starts, and pitched /// from there within the headroom gives it. /// public const int NominalSampleRate = 48000; // Fixed when a voice is made. The most XAudio2 allows costs about 80 KB a voice, which is cheap enough to give // every voice the whole range of Pitch. public const float MaxFrequencyRatio = Pitch.Max; private static readonly SampleFormat[] Formats = { SampleFormat.Pcm16, SampleFormat.Float32 }; private readonly int monoVoiceCount; private readonly int stereoVoiceCount; private XAudio2Engine engine; private MasteringVoice master; private List pools; private VoiceTable voices; private SourceVoice[] sources; private SourceVoicePool[] owners; private Sound[] sounds; private XAudio2StreamedSound[] streams; private int deviceChannels; private float[] levels; private bool limiterEnabled = true; // A stopped voice keeps its slot until its flushed buffer has left the queue, since the mixing thread can still // be reading the sound's samples until then. private bool[] stopped; public XAudio2Module() : this(DefaultMonoVoiceCount, DefaultStereoVoiceCount) { } /// /// Sizes the pools, which exist once for each sample format. /// public XAudio2Module(int monoVoiceCount, int stereoVoiceCount) { if (monoVoiceCount < 1) { throw new ArgumentOutOfRangeException(nameof(monoVoiceCount), monoVoiceCount, "Mono sounds need at least one voice."); } if (stereoVoiceCount < 1) { throw new ArgumentOutOfRangeException(nameof(stereoVoiceCount), stereoVoiceCount, "Stereo sounds need at least one voice."); } this.monoVoiceCount = monoVoiceCount; this.stereoVoiceCount = stereoVoiceCount; } public int Priority { get; set; } = 100; /// /// Gets or sets whether a limiter keeps the mixed output from clipping. It is on unless turned off, takes /// effect at once, and is kept across loads. /// public bool LimiterEnabled { get => limiterEnabled; set { limiterEnabled = value; if (master != null) { Remember(master.SetLimiterEnabled(value)); } } } /// /// Gets whether the limiter is working now, which is false when the module isn't loaded or this machine's /// XAudio2 couldn't supply one. /// public bool IsLimiting => master != null && master.IsLimiting; /// /// Gets whether XAudio2 and an audio device are both there. It asks the machine each time, unless the module /// is loaded. /// public bool IsSupported { get { if (IsLoaded) { return true; } StatusOr probe = XAudio2Engine.Create(); if (!probe.IsOk) { return false; } using (probe.Value) { StatusOr output = probe.Value.CreateMasteringVoice(); if (!output.IsOk) { return false; } output.Value.Destroy(); return true; } } } public bool IsLoaded => engine != null; /// /// Gets how many channels the audio device plays, or zero when the module isn't loaded. /// public int DeviceChannelCount => deviceChannels; /// /// Gets why the last call that couldn't play or change a sound failed, or an OK status if none has. Those /// calls don't throw, since a device that is unplugged mid-game isn't a bug in the caller. /// public Status LastFailure { get; private set; } private static void ThrowFailed(string what, Status status) { throw new InvalidOperationException($"{what}: {status.Describe()}"); } private void ThrowIfNotLoaded() { if (!IsLoaded) { throw new InvalidOperationException("The module has to be loaded first."); } } private SourceVoicePool BuildPool(int channelCount, SampleFormat format, int voiceCount) { WaveFormatEx waveFormat = WaveFormatEx.Of(format, channelCount, NominalSampleRate); SourceVoice[] made = new SourceVoice[voiceCount]; for (int i = 0; i < voiceCount; ++i) { StatusOr created = engine.CreateSourceVoice(waveFormat, MaxFrequencyRatio); if (!created.IsOk) { for (int j = 0; j < i; ++j) { made[j].Destroy(); } ThrowFailed("Couldn't make a source voice", created.Status); } made[i] = created.Value; } return new SourceVoicePool(channelCount, format, made); } private SourceVoicePool FindPool(int channelCount, SampleFormat format) { // Load makes a mono pool and then a stereo pool for each format, in the order of Formats. return pools[Array.IndexOf(Formats, format) * 2 + channelCount - 1]; } private void Remember(Status status) { if (!status.IsOk) { LastFailure = status; } } private Status ApplyPan(SourceVoice source, int sourceChannels, float pan) { OutputMatrix.Fill(levels, Pan.Matrix(pan, sourceChannels), sourceChannels, deviceChannels); return source.SetOutputMatrix(sourceChannels, deviceChannels, levels); } // Ends the hold a voice took on its sound, once the mixing thread can no longer be reading the samples. private void ReleaseSound(int slot) { if (streams[slot] != null) { streams[slot].End(); } else { ((XAudio2Sound)sounds[slot]).ReleaseBuffer(); } } private void Forget(int slot) { sources[slot] = null; owners[slot] = null; sounds[slot] = null; streams[slot] = null; stopped[slot] = false; } private void Teardown() { if (pools != null) { for (int i = 0; i < pools.Count; ++i) { pools[i].Destroy(); } } // Destroying a voice waits for the mixing thread to finish with it, so the samples can be let go after. if (sounds != null) { for (int slot = 0; slot < sounds.Length; ++slot) { if (sounds[slot] != null) { ReleaseSound(slot); } } } if (master != null) { master.Destroy(); } if (engine != null) { engine.Dispose(); } pools = null; master = null; engine = null; voices = null; sources = null; owners = null; sounds = null; streams = null; stopped = null; levels = null; } /// /// Opens the audio device and makes the voices. Throws if it can't, which tells /// beforehand. /// public void Load() { if (IsLoaded) { throw new InvalidOperationException("The module is already loaded."); } StatusOr created = XAudio2Engine.Create(); if (!created.IsOk) { ThrowFailed("Couldn't start XAudio2", created.Status); } engine = created.Value; try { StatusOr output = engine.CreateMasteringVoice(); if (!output.IsOk) { ThrowFailed("Couldn't open the audio device", output.Status); } master = output.Value; deviceChannels = master.GetChannelCount(); levels = new float[2 * deviceChannels]; // Unlimited sound is better than none, so a failure is remembered and not thrown. Remember(master.AttachLimiter(limiterEnabled)); pools = new List(); for (int i = 0; i < Formats.Length; ++i) { pools.Add(BuildPool(channelCount: 1, Formats[i], monoVoiceCount)); pools.Add(BuildPool(channelCount: 2, Formats[i], stereoVoiceCount)); } } catch { Teardown(); throw; } int total = (monoVoiceCount + stereoVoiceCount) * Formats.Length; voices = new VoiceTable(total); sources = new SourceVoice[total]; owners = new SourceVoicePool[total]; sounds = new Sound[total]; streams = new XAudio2StreamedSound[total]; stopped = new bool[total]; } /// /// Stops every voice and closes the audio device. Does nothing when the module isn't loaded. /// public void Unload() { Teardown(); } /// /// Copies the samples of , so the caller can drop or reuse its own. It works before /// , and the sound plays after any later load. /// public Sound LoadSound(DecodedSound decoded) { if (decoded == null) { throw new ArgumentNullException(nameof(decoded)); } if (decoded.ChannelCount > 2) { throw new ArgumentException( $"XAudio2Module plays mono and stereo sounds, and this one has {decoded.ChannelCount} channels.", nameof(decoded)); } return new XAudio2Sound(decoded); } public SoundLoad LoadSound(ByteView encoded) { StatusOr decoded = SoundDecoder.Decode(encoded); if (!decoded.IsOk) { return new SoundLoad(decoded.Status); } if (decoded.Value.ChannelCount > 2) { return new SoundLoad( SoundFailures.TooManyChannels.Fail($"{decoded.Value.ChannelCount} channels")); } return new SoundLoad(LoadSound(decoded.Value)); } /// /// Opens the file as an . It works before , and the sound /// plays after any later load. /// public SoundLoad LoadStream(IChunkedBytes source) { if (source == null) { throw new ArgumentNullException(nameof(source)); } StatusOr opened = SoundStreams.Open(source); if (!opened.IsOk) { return new SoundLoad(opened.Status); } if (opened.Value.ChannelCount > 2) { int channelCount = opened.Value.ChannelCount; opened.Value.Dispose(); return new SoundLoad(SoundFailures.TooManyChannels.Fail($"{channelCount} channels")); } return new SoundLoad(new XAudio2StreamedSound(opened.Value)); } public Voice Play(Sound sound, float volume, float pitch = Pitch.Natural, float pan = Pan.Center, Loop loop = default) { ThrowIfNotLoaded(); if (sound == null) { throw new ArgumentNullException(nameof(sound)); } XAudio2Sound ours = sound as XAudio2Sound; XAudio2StreamedSound streamed = sound as XAudio2StreamedSound; if ((ours == null && streamed == null) || sound.ChannelCount > 2) { throw new ArgumentException( "The sound has to come from this module's LoadSound or LoadStream.", nameof(sound)); } Volume.Check(volume, nameof(volume)); Pitch.Check(pitch, nameof(pitch)); Pan.Check(pan, nameof(pan)); loop.CheckAgainst(sound.Duration, nameof(loop)); if (streamed != null && streamed.IsDisposed) { throw new ObjectDisposedException(nameof(sound), "The sound has been disposed."); } if (streamed != null && streamed.IsHeld) { return Voice.None; } SampleFormat format = ours != null ? ours.Format : streamed.Stream.Format; int sampleRate = ours != null ? ours.SampleRate : streamed.Stream.SampleRate; SourceVoicePool pool = FindPool(sound.ChannelCount, format); SourceVoice source = pool.Take(); if (source == null) { return Voice.None; } // The table holds as many voices as the pools together, so a pool voice being free means a slot is. Voice voice = voices.Acquire(); int slot = voice.Slot; sources[slot] = source; owners[slot] = pool; sounds[slot] = sound; streams[slot] = streamed; Status status = Status.Ok; XAudio2Buffer buffer = default; // Every slot holds its sound until Update gives the slot back, whether or not a buffer got queued. if (streamed != null) { status = streamed.Begin(loop); } else { buffer = ours.AcquireBuffer(loop); // A voice given an empty buffer keeps it queued for good, so nothing is submitted. The voice ends at // the next Update, like any other whose sound is over. if (ours.ByteCount == 0) { return voice; } } if (status.IsOk) { status = source.SetSampleRate(sampleRate); } if (status.IsOk) { status = source.SetVolume(volume); } if (status.IsOk) { status = source.SetFrequencyRatio(pitch); } if (status.IsOk) { status = ApplyPan(source, sound.ChannelCount, pan); } if (status.IsOk && streamed != null) { status = streamed.Refill(source); } else if (status.IsOk) { status = source.Submit(in buffer); } if (status.IsOk) { status = source.Start(); } if (!status.IsOk) { // Update gives the slot back once the flush has emptied the queue. Remember(status); stopped[slot] = true; source.Flush(); return Voice.None; } return voice; } public void Stop(Voice voice) { if (!IsPlaying(voice)) { return; } SourceVoice source = sources[voice.Slot]; stopped[voice.Slot] = true; Remember(source.Stop()); Remember(source.Flush()); } public void SetVolume(Voice voice, float volume) { Volume.Check(volume, nameof(volume)); if (IsPlaying(voice)) { Remember(sources[voice.Slot].SetVolume(volume)); } } public void SetPitch(Voice voice, float pitch) { Pitch.Check(pitch, nameof(pitch)); if (IsPlaying(voice)) { Remember(sources[voice.Slot].SetFrequencyRatio(pitch)); } } public void SetPan(Voice voice, float pan) { Pan.Check(pan, nameof(pan)); if (IsPlaying(voice)) { Remember(ApplyPan(sources[voice.Slot], sounds[voice.Slot].ChannelCount, pan)); } } // Stop without flags keeps the queued buffers and the position, so Start carries on from the same sample. public void SetPaused(Voice voice, bool paused) { if (IsPlaying(voice)) { SourceVoice source = sources[voice.Slot]; Remember(paused ? source.Stop() : source.Start()); } } /// /// Returns how many frames of its sound the engine has played of so far, or zero when /// it has ended. A paused voice stays at the same count. /// public long FramesPlayedOf(Voice voice) { if (!IsPlaying(voice)) { return 0; } return (long)sources[voice.Slot].GetState().SamplesPlayed; } /// /// Returns the frequency ratio the engine plays at, or one when it has ended. /// public float PitchOf(Voice voice) { if (!IsPlaying(voice)) { return Pitch.Natural; } return sources[voice.Slot].GetFrequencyRatio(); } /// /// Reads into what the engine sends from each channel of to /// each channel of the device, laid out as does. Does nothing when the voice /// has ended. /// public void GetOutputLevels(Voice voice, float[] levels) { if (IsPlaying(voice)) { sources[voice.Slot].GetOutputMatrix(sounds[voice.Slot].ChannelCount, deviceChannels, levels); } } public bool IsPlaying(Voice voice) { return IsLoaded && voices.IsActive(voice) && !stopped[voice.Slot]; } /// /// Returns what the engine reports about its load, such as the voices playing and the time spent mixing. /// public XAudio2PerformanceData GetPerformanceData() { ThrowIfNotLoaded(); return engine.GetPerformanceData(); } public void Update() { if (!IsLoaded) { return; } for (int slot = 0; slot < voices.Capacity; ++slot) { Voice voice = voices.VoiceAt(slot); if (voice.IsNone) { continue; } // Feeding comes first, so a voice that ran dry because Update came late is topped up, not ended. XAudio2StreamedSound streamed = streams[slot]; bool feeding = streamed != null && !stopped[slot] && !streamed.IsFed; if (feeding) { Remember(streamed.Refill(sources[slot])); feeding = !streamed.IsFed; } if (feeding || sources[slot].GetBuffersQueued() != 0) { continue; } // A voice whose sound has ended keeps running on silence until stopped, which costs mixing time. A // failure to stop it only means that, and the next Play starts the voice again regardless. sources[slot].Stop(); owners[slot].Return(sources[slot]); ReleaseSound(slot); Forget(slot); voices.Release(voice); } } } }