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);
}
}
}
}