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