using Amphora;
using System;
using System.Collections.Generic;
using System.Runtime.InteropServices.JavaScript;
using System.Threading.Tasks;
namespace Susurrus.WebAudio
{
///
/// Plays sounds through the browser's Web Audio. A browser keeps its audio silent until the page has had a user
/// gesture, so returns until . Await before using the module.
///
public class WebAudioModule : IModule
{
public const int DefaultVoiceCount = 32;
private readonly int voiceCount;
private readonly JSObject suppliedContext;
private readonly List pending = new List();
// A voice with nothing to play ends at the next Update.
private readonly List silentSlots = new List();
private JSObject engine;
private VoiceTable voices;
private bool limiterEnabled = true;
// A stopped voice keeps its slot until the browser reports its source ended, which it does a moment later.
private bool[] stopped;
// The streamed sound each voice plays, or null for a voice with a resident sound.
private WebAudioStreamedSound[] streams;
public WebAudioModule() : this(DefaultVoiceCount)
{
}
public WebAudioModule(int voiceCount) : this(voiceCount, context: null)
{
}
///
/// Plays through when it is given, and leaves it open when unloaded. A host that
/// passes none gets a new AudioContext that the module closes itself.
///
public WebAudioModule(int voiceCount, JSObject context)
{
if (voiceCount < 1)
{
throw new ArgumentOutOfRangeException(nameof(voiceCount), voiceCount,
"A module needs at least one voice.");
}
this.voiceCount = voiceCount;
this.suppliedContext = context;
}
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 (IsLoaded)
{
WebAudioInterop.SetLimiterEnabled(engine, value);
}
}
}
public bool IsSupported => IsLoaded || WebAudioInterop.IsAvailable();
public bool IsLoaded => engine != null;
///
/// Gets whether the browser lets the module make sound yet, which it doesn't until the page has had a user
/// gesture.
///
public bool IsRunning => IsLoaded && WebAudioInterop.IsRunning(engine);
///
/// Gets why the last stream that failed while playing did, or an OK status if none has. The failure ends the
/// voice, which a caller can't see happen, so it is kept here.
///
public Status LastFailure { get; private set; }
private void ThrowIfNotLoaded()
{
if (!IsLoaded)
{
throw new InvalidOperationException("The module has to be loaded first.");
}
}
///
/// Makes the browser's buffer of a sound, or says why the browser won't.
///
private StatusOr Upload(DecodedSound decoded)
{
if (decoded.ChannelCount > 2)
{
return SoundFailures.TooManyChannels.Fail($"{decoded.ChannelCount} channels");
}
// The browser can't make a buffer of no frames.
if (decoded.Samples.Length == 0)
{
return new WebAudioSound(decoded.Duration, decoded.ChannelCount, buffer: null);
}
byte[] samples = new byte[decoded.Samples.Length];
decoded.Samples.CopyTo(samples, 0);
try
{
JSObject buffer = WebAudioInterop.MakeBuffer(engine, samples, decoded.SampleRate,
decoded.ChannelCount, isFloat: decoded.Format == SampleFormat.Float32);
return new WebAudioSound(decoded.Duration, decoded.ChannelCount, buffer);
}
catch (JSException exception)
{
return WebAudioFailures.BufferRejected.Fail(exception.Message);
}
}
private SoundLoad DecodeNow(ByteView encoded)
{
StatusOr decoded = SoundDecoder.Decode(encoded);
if (!decoded.IsOk)
{
return new SoundLoad(decoded.Status);
}
return new SoundLoad(Upload(decoded.Value));
}
private void Finish(PendingDecode finished)
{
if (finished.Decoding.Status == TaskStatus.RanToCompletion)
{
JSObject buffer = finished.Decoding.Result;
int channelCount = WebAudioInterop.GetChannelCount(buffer);
if (channelCount > 2)
{
buffer.Dispose();
finished.Load.Complete(SoundFailures.TooManyChannels.Fail($"{channelCount} channels"));
return;
}
TimeSpan duration = TimeSpan.FromSeconds(WebAudioInterop.GetDuration(buffer));
finished.Load.Complete(new WebAudioSound(duration, channelCount, buffer));
return;
}
// A browser that can't decode the file may only lack the codec, so managed code gets its turn. If the file
// is broken, the decoder's own status says how.
Exception ignored = finished.Decoding.Exception;
StatusOr decoded = SoundDecoder.Decode(new ByteView(finished.Encoded));
if (!decoded.IsOk)
{
finished.Load.Complete(decoded.Status);
return;
}
finished.Load.Complete(Upload(decoded.Value));
}
private void FinishLoads()
{
for (int i = pending.Count - 1; i >= 0; --i)
{
if (pending[i].Decoding.IsCompleted)
{
PendingDecode finished = pending[i];
pending.RemoveAt(i);
Finish(finished);
}
}
}
private void ReleaseEndedVoices()
{
for (int word = 0; word * 32 < voiceCount; ++word)
{
int ended = WebAudioInterop.TakeEnded(engine, word);
for (int bit = 0; ended != 0 && bit < 32; ++bit)
{
if ((ended & (1 << bit)) != 0)
{
Release(word * 32 + bit);
}
}
}
for (int i = 0; i < silentSlots.Count; ++i)
{
Release(silentSlots[i]);
}
silentSlots.Clear();
}
private void Release(int slot)
{
voices.Release(voices.VoiceAt(slot));
stopped[slot] = false;
}
// Ends a streamed voice, whether it played out, was stopped or failed, and frees its sound to play again.
private void ReleaseStream(int slot)
{
WebAudioInterop.StreamEnd(engine, slot);
streams[slot].Release();
streams[slot] = null;
Release(slot);
}
private void FeedStreams()
{
for (int slot = 0; slot < voiceCount; ++slot)
{
WebAudioStreamedSound streamed = streams[slot];
if (streamed == null)
{
continue;
}
// Feeding comes first, so a voice whose chunks ran out because Update was late is topped up.
if (!stopped[slot] && !streamed.IsFed)
{
Status status = streamed.Refill(engine, slot);
if (!status.IsOk)
{
LastFailure = status;
}
}
bool feeding = !stopped[slot] && !streamed.IsFed;
if (!feeding && WebAudioInterop.StreamQueued(engine, slot) == 0)
{
ReleaseStream(slot);
}
}
}
///
/// Opens the audio context. A context the browser keeps silent until a user gesture is still loaded, and says when it starts.
///
public void Load()
{
if (IsLoaded)
{
throw new InvalidOperationException("The module is already loaded.");
}
JSObject context = suppliedContext ?? WebAudioInterop.NewAudioContext();
engine = WebAudioInterop.Create(context, voiceCount, limit: limiterEnabled);
voices = new VoiceTable(voiceCount);
stopped = new bool[voiceCount];
streams = new WebAudioStreamedSound[voiceCount];
}
///
/// Stops every voice and closes the context. A load still going finishes as failed. Does nothing when the
/// module isn't loaded.
///
public void Unload()
{
if (!IsLoaded)
{
return;
}
for (int i = 0; i < pending.Count; ++i)
{
pending[i].Load.Complete(WebAudioFailures.Unloaded.Fail());
}
pending.Clear();
silentSlots.Clear();
for (int slot = 0; slot < voiceCount; ++slot)
{
if (streams[slot] != null)
{
streams[slot].Release();
}
}
WebAudioInterop.Destroy(engine, closeContext: suppliedContext == null);
engine.Dispose();
engine = null;
voices = null;
stopped = null;
streams = null;
}
///
/// Makes a sound of in the browser's memory. The module has to be loaded. Throws
/// for a sound the browser can't play, such as one with an out of range sample rate.
///
public Sound LoadSound(DecodedSound decoded)
{
ThrowIfNotLoaded();
if (decoded == null)
{
throw new ArgumentNullException(nameof(decoded));
}
StatusOr uploaded = Upload(decoded);
if (!uploaded.IsOk)
{
throw new ArgumentException(uploaded.Status.Describe(), nameof(decoded));
}
return uploaded.Value;
}
///
/// Starts loading a sound from the bytes of a file. An Ogg Vorbis file goes to the browser to decode, and
/// finishes during . Anything else decodes at once. The module has to be loaded.
///
public SoundLoad LoadSound(ByteView encoded)
{
ThrowIfNotLoaded();
if (encoded.IsDefault)
{
throw new ArgumentException("A file needs a view of some array, even an empty one.", nameof(encoded));
}
SoundFormat format = SoundFormats.Detect(encoded[0, Math.Min(encoded.Length, SoundFormats.HeaderLength)]);
if (format != SoundFormat.OggVorbis)
{
return DecodeNow(encoded);
}
byte[] bytes = new byte[encoded.Length];
encoded.CopyTo(bytes, 0);
SoundLoad load = new SoundLoad();
pending.Add(new PendingDecode(load, WebAudioInterop.Decode(engine, bytes), bytes));
return load;
}
///
/// Opens the file as a , which decodes in managed code a chunk at a time as
/// it plays. It works before .
///
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 WebAudioStreamedSound(opened.Value));
}
private Voice PlayStream(WebAudioStreamedSound streamed, float volume, float pitch, float pan, Loop loop)
{
Voice voice = voices.Acquire();
if (voice.IsNone)
{
return voice;
}
int slot = voice.Slot;
streams[slot] = streamed;
Status status = streamed.Begin(loop);
if (status.IsOk)
{
WebAudioInterop.StreamStart(engine, slot, volume, pitch, pan);
status = streamed.Refill(engine, slot);
}
if (!status.IsOk)
{
LastFailure = status;
ReleaseStream(slot);
return Voice.None;
}
return voice;
}
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));
}
WebAudioSound ours = sound as WebAudioSound;
WebAudioStreamedSound streamed = sound as WebAudioStreamedSound;
if (ours == null && streamed == null)
{
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 (!WebAudioInterop.IsRunning(engine))
{
return Voice.None;
}
if (streamed != null)
{
return streamed.IsHeld ? Voice.None : PlayStream(streamed, volume, pitch, pan, loop);
}
Voice voice = voices.Acquire();
if (voice.IsNone)
{
return voice;
}
if (ours.Buffer == null)
{
silentSlots.Add(voice.Slot);
return voice;
}
WebAudioInterop.Play(engine, voice.Slot, ours.Buffer, volume, pitch, pan, loop.IsLooping,
loop.Start.TotalSeconds, loop.EndIn(ours.Duration).TotalSeconds);
return voice;
}
public void Stop(Voice voice)
{
if (!IsPlaying(voice))
{
return;
}
stopped[voice.Slot] = true;
WebAudioInterop.Stop(engine, voice.Slot);
}
public void SetVolume(Voice voice, float volume)
{
Volume.Check(volume, nameof(volume));
if (IsPlaying(voice))
{
WebAudioInterop.SetVolume(engine, voice.Slot, volume);
}
}
public void SetPitch(Voice voice, float pitch)
{
Pitch.Check(pitch, nameof(pitch));
if (IsPlaying(voice))
{
WebAudioInterop.SetPitch(engine, voice.Slot, pitch);
}
}
public void SetPan(Voice voice, float pan)
{
Pan.Check(pan, nameof(pan));
if (IsPlaying(voice))
{
WebAudioInterop.SetPan(engine, voice.Slot, pan);
}
}
public void SetPaused(Voice voice, bool paused)
{
if (IsPlaying(voice))
{
WebAudioInterop.SetPaused(engine, voice.Slot, paused);
}
}
public bool IsPlaying(Voice voice)
{
return IsLoaded && voices.IsActive(voice) && !stopped[voice.Slot];
}
public void Update()
{
if (!IsLoaded)
{
return;
}
FinishLoads();
ReleaseEndedVoices();
FeedStreams();
}
}
}