using Amphora; using System; using System.Collections.Generic; using System.Runtime.InteropServices.JavaScript; using System.Threading.Tasks; namespace Susurrus.WebAudio.Testbed { /// /// Runs the module against offline contexts, which render a graph to a buffer with no speakers, and reports what /// the buffers hold. Open the testbed page with ?test to run it. /// public static class SelfTests { public const int Rate = Tones.SampleRate; // A mono sound at the center is split evenly between the two sides, which leaves each side at this gain. public const double MonoAtCenter = 0.70710678; /// /// A loaded module playing into an offline context of stereo and a given length. /// public class Session : IDisposable { private readonly JSObject context; public WebAudioModule Module { get; } private Session(JSObject context, WebAudioModule module) { this.context = context; this.Module = module; } // The limiter is off unless asked for, so the other tests read levels the way the sounds were made. public static Session Open(int seconds, int voiceCount = 4, bool withoutCodecs = false, bool limiter = false) { int frameCount = seconds * Rate; JSObject context = withoutCodecs ? TestbedInterop.NewOfflineContextWithoutCodecs(2, frameCount, Rate) : TestbedInterop.NewOfflineContext(2, frameCount, Rate); WebAudioModule module = new WebAudioModule(voiceCount, context); module.LimiterEnabled = limiter; module.Load(); return new Session(context, module); } /// /// Renders everything started so far, then lets the browser report the voices that ended, and updates the /// module so it sees them. /// public async Task RenderAsync() { JSObject rendered = await TestbedInterop.Render(context); await Task.Delay(100); Module.Update(); return rendered; } /// /// Renders as does, but updates the module every tenth of a second of the sound, /// and calls with the number of ticks so far. /// public async Task RenderWithUpdatesAsync(Action atTick = null) { int ticks = 0; JSObject rendered = await TestbedInterop.RenderWithTicks(context, intervalSeconds: 0.1, () => { ++ticks; if (atTick != null) { atTick(ticks); } Module.Update(); }); await Task.Delay(100); Module.Update(); return rendered; } public async Task WaitUntilDone(SoundLoad load) { for (int i = 0; i < 500 && !load.IsDone; ++i) { await Task.Delay(10); Module.Update(); } Expect(load.IsDone, "the load finishes within five seconds"); } public void Dispose() { Module.Unload(); context.Dispose(); } } public class Failure : Exception { public Failure(string message) : base(message) { } } public static void Expect(bool condition, string what) { if (!condition) { throw new Failure("expected " + what); } } public static void ExpectNear(string what, double expected, double actual, double tolerance) { if (Math.Abs(expected - actual) > tolerance) { throw new Failure($"{what}: expected {expected} within {tolerance}, got {actual}"); } } private static double Peak(JSObject rendered, int channel) { return TestbedInterop.Peak(rendered, channel, 0, rendered.GetPropertyAsInt32("length")); } private static async Task VolumeOne_PlaysAtTheSoundsOwnLevel() { using (Session session = Session.Open(seconds: 1)) { Sound sound = session.Module.LoadSound(Tones.Sine(440, amplitude: 0.8, frameCount: Rate / 2)); session.Module.Play(sound, volume: 1f); ExpectNear("peak", 0.8 * MonoAtCenter, Peak(await session.RenderAsync(), channel: 0), 0.01); } } private static async Task VolumeHalf_HalvesTheLevel() { using (Session session = Session.Open(seconds: 1)) { Sound sound = session.Module.LoadSound(Tones.Sine(440, amplitude: 0.8, frameCount: Rate / 2)); session.Module.Play(sound, volume: 0.5f); ExpectNear("peak", 0.4 * MonoAtCenter, Peak(await session.RenderAsync(), channel: 0), 0.01); } } private static async Task SetVolume_ChangesTheLevel() { using (Session session = Session.Open(seconds: 1)) { Sound sound = session.Module.LoadSound(Tones.Sine(440, amplitude: 0.8, frameCount: Rate / 2)); Voice voice = session.Module.Play(sound, volume: 1f); session.Module.SetVolume(voice, 0.25f); ExpectNear("peak", 0.2 * MonoAtCenter, Peak(await session.RenderAsync(), channel: 0), 0.01); } } private static async Task TwoVoices_AddTogether() { using (Session session = Session.Open(seconds: 1)) { Sound sound = session.Module.LoadSound(Tones.Sine(440, amplitude: 0.3, frameCount: Rate / 2)); session.Module.Play(sound, volume: 1f); session.Module.Play(sound, volume: 1f); ExpectNear("peak", 0.6 * MonoAtCenter, Peak(await session.RenderAsync(), channel: 0), 0.01); } } private static async Task AStereoSound_KeepsItsChannelsApart() { using (Session session = Session.Open(seconds: 1)) { Sound sound = session.Module.LoadSound( Tones.Chord(leftHertz: 440, rightHertz: 0, amplitude: 0.5, frameCount: Rate / 2)); session.Module.Play(sound, volume: 1f); JSObject rendered = await session.RenderAsync(); ExpectNear("left peak", 0.5, Peak(rendered, channel: 0), 0.01); ExpectNear("right peak", 0, Peak(rendered, channel: 1), 0.001); } } private static async Task ASoundAtAnotherRate_KeepsItsDuration() { using (Session session = Session.Open(seconds: 2)) { // One second of audio at 22050 frames per second. Sound sound = session.Module.LoadSound( Tones.Sine(440, amplitude: 0.8, frameCount: 22050, sampleRate: 22050)); session.Module.Play(sound, volume: 1f); int lastLoud = TestbedInterop.LastLoudFrame(await session.RenderAsync(), channel: 0, threshold: 0.01); ExpectNear("last loud frame", Rate, lastLoud, 300); } } private static async Task AMonoSound_IsPlacedByTheCoreLaw() { foreach (float pan in new[] { -1f, -0.5f, 0f, 0.5f, 1f }) { using (Session session = Session.Open(seconds: 1)) { Sound sound = session.Module.LoadSound(Tones.Sine(440, amplitude: 0.8, frameCount: Rate / 2)); session.Module.Play(sound, volume: 1f, pan: pan); PanMatrix law = Pan.Matrix(pan, sourceChannels: 1); JSObject rendered = await session.RenderAsync(); ExpectNear($"left peak at {pan}", 0.8 * law.LeftToLeft, Peak(rendered, channel: 0), 0.01); ExpectNear($"right peak at {pan}", 0.8 * law.LeftToRight, Peak(rendered, channel: 1), 0.01); } } } private static async Task AStereoSoundWithOnlyALeftChannel_IsPlacedByTheCoreLaw() { foreach (float pan in new[] { -1f, -0.5f, 0f, 0.5f, 1f }) { using (Session session = Session.Open(seconds: 1)) { Sound sound = session.Module.LoadSound( Tones.Chord(leftHertz: 440, rightHertz: 0, amplitude: 0.8, frameCount: Rate / 2)); session.Module.Play(sound, volume: 1f, pan: pan); PanMatrix law = Pan.Matrix(pan, sourceChannels: 2); JSObject rendered = await session.RenderAsync(); ExpectNear($"left peak at {pan}", 0.8 * law.LeftToLeft, Peak(rendered, channel: 0), 0.01); ExpectNear($"right peak at {pan}", 0.8 * law.LeftToRight, Peak(rendered, channel: 1), 0.01); } } } private static async Task AStereoSoundWithOnlyARightChannel_IsPlacedByTheCoreLaw() { foreach (float pan in new[] { -1f, -0.5f, 0f, 0.5f, 1f }) { using (Session session = Session.Open(seconds: 1)) { Sound sound = session.Module.LoadSound( Tones.Chord(leftHertz: 0, rightHertz: 440, amplitude: 0.8, frameCount: Rate / 2)); session.Module.Play(sound, volume: 1f, pan: pan); PanMatrix law = Pan.Matrix(pan, sourceChannels: 2); JSObject rendered = await session.RenderAsync(); ExpectNear($"left peak at {pan}", 0.8 * law.RightToLeft, Peak(rendered, channel: 0), 0.01); ExpectNear($"right peak at {pan}", 0.8 * law.RightToRight, Peak(rendered, channel: 1), 0.01); } } } private static async Task SetPan_MovesAPlayingVoice() { using (Session session = Session.Open(seconds: 1)) { Sound sound = session.Module.LoadSound(Tones.Sine(440, amplitude: 0.8, frameCount: Rate / 2)); Voice voice = session.Module.Play(sound, volume: 1f); session.Module.SetPan(voice, -1f); JSObject rendered = await session.RenderAsync(); ExpectNear("left peak", 0.8, Peak(rendered, channel: 0), 0.01); ExpectNear("right peak", 0, Peak(rendered, channel: 1), 0.001); } } private static async Task AWholeLoop_KeepsSoundingPastTheEndOfTheSound() { using (Session session = Session.Open(seconds: 1)) { Sound sound = session.Module.LoadSound(Tones.Sine(440, amplitude: 0.8, frameCount: Rate / 4)); session.Module.Play(sound, volume: 1f, loop: Loop.Whole); int lastLoud = TestbedInterop.LastLoudFrame(await session.RenderAsync(), channel: 0, threshold: 0.01); ExpectNear("last loud frame", Rate, lastLoud, 300); } } private static async Task ARegionLoop_KeepsSoundingPastTheEndOfTheSound() { using (Session session = Session.Open(seconds: 1)) { Sound sound = session.Module.LoadSound(Tones.Sine(440, amplitude: 0.8, frameCount: Rate / 4)); Loop loop = new Loop(TimeSpan.FromSeconds(0.1), TimeSpan.FromSeconds(0.2)); session.Module.Play(sound, volume: 1f, loop: loop); int lastLoud = TestbedInterop.LastLoudFrame(await session.RenderAsync(), channel: 0, threshold: 0.01); ExpectNear("last loud frame", Rate, lastLoud, 300); } } private static async Task AnOctaveUp_PlaysInHalfTheTime() { using (Session session = Session.Open(seconds: 2)) { Sound sound = session.Module.LoadSound(Tones.Sine(440, amplitude: 0.8, frameCount: Rate)); session.Module.Play(sound, volume: 1f, pitch: 2f); int lastLoud = TestbedInterop.LastLoudFrame(await session.RenderAsync(), channel: 0, threshold: 0.01); ExpectNear("last loud frame", Rate / 2, lastLoud, 300); } } private static async Task AnOctaveDown_PlaysInTwiceTheTime() { using (Session session = Session.Open(seconds: 3)) { Sound sound = session.Module.LoadSound(Tones.Sine(440, amplitude: 0.8, frameCount: Rate)); session.Module.Play(sound, volume: 1f, pitch: 0.5f); int lastLoud = TestbedInterop.LastLoudFrame(await session.RenderAsync(), channel: 0, threshold: 0.01); ExpectNear("last loud frame", Rate * 2, lastLoud, 300); } } private static async Task TheHighestPitch_PlaysTheWholeSoundInAThousandthOfTheTime() { using (Session session = Session.Open(seconds: 1)) { Sound sound = session.Module.LoadSound(Tones.Sine(440, amplitude: 0.8, frameCount: Rate)); session.Module.Play(sound, volume: 1f, pitch: Pitch.Max); int lastLoud = TestbedInterop.LastLoudFrame(await session.RenderAsync(), channel: 0, threshold: 0.01); ExpectNear("last loud frame", Rate / Pitch.Max, lastLoud, 3); } } private static async Task TheLowestPitch_PlaysTheStartOfTheSoundAcrossTheWholeRender() { using (Session session = Session.Open(seconds: 1)) { Sound sound = session.Module.LoadSound(Tones.Sine(440, amplitude: 0.8, frameCount: Rate)); session.Module.Play(sound, volume: 1f, pitch: Pitch.Min); int lastLoud = TestbedInterop.LastLoudFrame(await session.RenderAsync(), channel: 0, threshold: 0.0001); ExpectNear("last loud frame", Rate - 1, lastLoud, 1); } } private static async Task SetPitch_ChangesTheRateOfAPlayingVoice() { using (Session session = Session.Open(seconds: 2)) { Sound sound = session.Module.LoadSound(Tones.Sine(440, amplitude: 0.8, frameCount: Rate)); Voice voice = session.Module.Play(sound, volume: 1f); session.Module.SetPitch(voice, 2f); int lastLoud = TestbedInterop.LastLoudFrame(await session.RenderAsync(), channel: 0, threshold: 0.01); ExpectNear("last loud frame", Rate / 2, lastLoud, 300); } } private static async Task APitchOrPanOutOfRange_Throws() { using (Session session = Session.Open(seconds: 1)) { Sound sound = session.Module.LoadSound(Tones.Sine(440, amplitude: 0.3, frameCount: 10)); Voice voice = session.Module.Play(sound, volume: 1f); int thrown = 0; foreach (Action action in new Action[] { () => session.Module.Play(sound, volume: 1f, pitch: 0f), () => session.Module.Play(sound, volume: 1f, pan: 2f), () => session.Module.SetPitch(voice, 5000f), () => session.Module.SetPan(voice, float.NaN), }) { try { action(); } catch (ArgumentOutOfRangeException) { ++thrown; } } Expect(thrown == 4, "all four calls to throw, but " + thrown + " did"); await Task.CompletedTask; } } private static async Task Stop_SilencesTheVoiceAndFreesItsSlot() { using (Session session = Session.Open(seconds: 1, voiceCount: 1)) { Sound sound = session.Module.LoadSound(Tones.Sine(440, amplitude: 0.8, frameCount: Rate / 2)); Voice voice = session.Module.Play(sound, volume: 1f); session.Module.Stop(voice); Expect(!session.Module.IsPlaying(voice), "the voice to stop playing at once"); ExpectNear("peak", 0, Peak(await session.RenderAsync(), channel: 0), 0.001); Expect(!session.Module.Play(sound, volume: 1f).IsNone, "the slot to be free after an update"); } } private static async Task WhenEveryVoiceIsBusy_PlayReturnsNone() { using (Session session = Session.Open(seconds: 1, voiceCount: 2)) { Sound sound = session.Module.LoadSound(Tones.Sine(440, amplitude: 0.3, frameCount: Rate / 2)); session.Module.Play(sound, volume: 1f); session.Module.Play(sound, volume: 1f); Expect(session.Module.Play(sound, volume: 1f).IsNone, "no voice for a third sound"); await Task.CompletedTask; } } private static async Task AFinishedVoice_IsReleasedAtUpdate() { using (Session session = Session.Open(seconds: 1, voiceCount: 1)) { Sound sound = session.Module.LoadSound(Tones.Sine(440, amplitude: 0.3, frameCount: Rate / 10)); Voice voice = session.Module.Play(sound, volume: 1f); Expect(session.Module.IsPlaying(voice), "the voice to be playing before it renders"); await session.RenderAsync(); Expect(!session.Module.IsPlaying(voice), "the voice to have finished after it renders"); } } private static async Task ASoundWithNoSamples_EndsAtTheNextUpdate() { using (Session session = Session.Open(seconds: 1)) { Sound sound = session.Module.LoadSound(Tones.Sine(440, amplitude: 0.3, frameCount: 0)); Voice voice = session.Module.Play(sound, volume: 1f); Expect(session.Module.IsPlaying(voice), "the voice to be playing until an update"); session.Module.Update(); Expect(!session.Module.IsPlaying(voice), "the voice to have ended at the update"); await Task.CompletedTask; } } private static async Task WavBytes_LoadAtOnce() { using (Session session = Session.Open(seconds: 1)) { byte[] file = Tones.Wav(Tones.Sine(440, amplitude: 0.5, frameCount: Rate / 2)); SoundLoad load = session.Module.LoadSound(new ByteView(file)); Expect(load.IsDone, "a WAV file to load at once"); session.Module.Play(load.Result.Value, volume: 1f); ExpectNear("peak", 0.5 * MonoAtCenter, Peak(await session.RenderAsync(), channel: 0), 0.01); } } private static async Task AWavWithTooManyChannels_FailsTheLoad() { using (Session session = Session.Open(seconds: 1)) { byte[] file = Tones.Wav(Tones.Sine(440, amplitude: 0.5, frameCount: 100, channelCount: 3)); SoundLoad load = session.Module.LoadSound(new ByteView(file)); Expect(load.IsDone, "the load to be done"); Expect(load.Result.Kind == SoundFailures.TooManyChannels, "the load to fail for its channels"); await Task.CompletedTask; } } private static async Task ASampleRateTheBrowserRefuses_FailsTheLoad() { using (Session session = Session.Open(seconds: 1)) { byte[] file = Tones.Wav(Tones.Sine(440, amplitude: 0.5, frameCount: 10, sampleRate: 100000000)); SoundLoad load = session.Module.LoadSound(new ByteView(file)); Expect(load.IsDone, "the load to be done"); Expect(load.Result.Kind == WebAudioFailures.BufferRejected, "the load to fail for its rate"); await Task.CompletedTask; } } private static async Task OggDecodedByTheBrowser_PlaysAtItsOwnLength() { using (Session session = Session.Open(seconds: 2)) { byte[] file = Tones.OggFile(); TimeSpan expected = SoundDecoder.Decode(new ByteView(file)).Value.Duration; SoundLoad load = session.Module.LoadSound(new ByteView(file)); Expect(!load.IsDone, "the browser's decode to still be going"); await session.WaitUntilDone(load); session.Module.Play(load.Result.Value, volume: 1f); ExpectNear("duration", expected.TotalSeconds, load.Result.Value.Duration.TotalSeconds, 0.05); Expect(Peak(await session.RenderAsync(), channel: 0) > 0.05, "sound in the rendered buffer"); } } private static async Task OggDecodedByManagedCode_WhenTheBrowserCannot() { using (Session session = Session.Open(seconds: 2, withoutCodecs: true)) { byte[] file = Tones.OggFile(); TimeSpan expected = SoundDecoder.Decode(new ByteView(file)).Value.Duration; SoundLoad load = session.Module.LoadSound(new ByteView(file)); await session.WaitUntilDone(load); Expect(load.Result.IsOk, "the managed decoder to load what the browser couldn't"); session.Module.Play(load.Result.Value, volume: 1f); ExpectNear("duration", expected.TotalSeconds, load.Result.Value.Duration.TotalSeconds, 0.0001); Expect(Peak(await session.RenderAsync(), channel: 0) > 0.05, "sound in the rendered buffer"); } } private static async Task ATruncatedOgg_FailsTheLoad() { using (Session session = Session.Open(seconds: 1)) { byte[] file = Tones.OggFile(); byte[] truncated = new byte[300]; Array.Copy(file, truncated, truncated.Length); SoundLoad load = session.Module.LoadSound(new ByteView(truncated)); await session.WaitUntilDone(load); Expect(load.Result.Kind == SoundFailures.OggFailed, "the load to fail with an Ogg error"); } } private static async Task UnloadingWhileAFileDecodes_FailsTheLoad() { SoundLoad load; using (Session session = Session.Open(seconds: 1)) { load = session.Module.LoadSound(new ByteView(Tones.OggFile())); session.Module.Unload(); } Expect(load.IsDone, "the load to be done after the unload"); Expect(load.Result.Kind == WebAudioFailures.Unloaded, "the load to fail for the unload"); await Task.CompletedTask; } private static async Task PlayWhileTheBrowserKeepsSoundSilent_ReturnsNoneUntilAGesture() { JSObject context = await TestbedInterop.NewSuspendedAudioContext(); WebAudioModule module = new WebAudioModule(voiceCount: 2, context); module.Load(); try { Sound sound = module.LoadSound(Tones.Sine(440, amplitude: 0.3, frameCount: Rate * 5)); Expect(!module.IsRunning, "the module not to be running while the context is suspended"); Expect(module.Play(sound, volume: 1f).IsNone, "no voice before the browser allows sound"); TestbedInterop.PressPointer(); for (int i = 0; i < 200 && !module.IsRunning; ++i) { await Task.Delay(10); } Expect(module.IsRunning, "the gesture to start the context"); Expect(!module.Play(sound, volume: 1f).IsNone, "a voice once the browser allows sound"); } finally { module.Unload(); TestbedInterop.CloseContext(context); context.Dispose(); } } private static async Task AModuleThatIsNotLoaded_RefusesToLoadSounds() { WebAudioModule module = new WebAudioModule(); bool threw = false; try { module.LoadSound(Tones.Sine(440, amplitude: 0.3, frameCount: 10)); } catch (InvalidOperationException) { threw = true; } Expect(threw, "an exception for a sound loaded before the module"); await Task.CompletedTask; } // Two renders of the same sound should agree to within what a float sample can round to. private static void ExpectSameAudio(JSObject expected, JSObject actual) { ExpectNear("largest difference", 0, TestbedInterop.MaxDifference(expected, actual, channel: 0), 0.001); } private static StreamedSound OpenStream(WebAudioModule module, byte[] file) { SoundLoad load = module.LoadStream(new ByteChunks(new ByteView(file))); Expect(load.IsDone && load.Result.IsOk, "the stream to open"); return (StreamedSound)load.Result.Value; } private static StreamedSound OpenToneStream(WebAudioModule module, int frameCount, int channelCount = 1) { return OpenStream(module, Tones.Wav(Tones.Sine(440, amplitude: 0.8, frameCount: frameCount, channelCount: channelCount))); } private static async Task AStreamedSound_RendersWhatTheWholeSoundDoes() { JSObject resident; using (Session session = Session.Open(seconds: 3)) { session.Module.Play(session.Module.LoadSound(Tones.Sine(440, 0.8, frameCount: Rate * 2)), volume: 1f); resident = await session.RenderAsync(); } using (Session session = Session.Open(seconds: 3)) { session.Module.Play(OpenToneStream(session.Module, frameCount: Rate * 2), volume: 1f); JSObject streamed = await session.RenderWithUpdatesAsync(); ExpectSameAudio(resident, streamed); ExpectNear("peak", 0.8 * MonoAtCenter, Peak(streamed, channel: 0), 0.01); } } private static async Task AStreamedLoop_JoinsWithoutAGap() { JSObject resident; using (Session session = Session.Open(seconds: 2)) { // A sound that isn't a whole number of chunks, so the loop joins inside a chunk. session.Module.Play(session.Module.LoadSound(Tones.Sine(440, 0.8, frameCount: Rate * 3 / 10)), volume: 1f, loop: Loop.Whole); resident = await session.RenderAsync(); } using (Session session = Session.Open(seconds: 2)) { session.Module.Play(OpenToneStream(session.Module, frameCount: Rate * 3 / 10), volume: 1f, loop: Loop.Whole); ExpectSameAudio(resident, await session.RenderWithUpdatesAsync()); } } private static async Task AStreamedRegionLoop_RepeatsTheRegion() { JSObject resident; Loop loop = new Loop(TimeSpan.FromSeconds(0.1), TimeSpan.FromSeconds(0.2)); using (Session session = Session.Open(seconds: 2)) { session.Module.Play(session.Module.LoadSound(Tones.Sine(440, 0.8, frameCount: Rate / 4)), volume: 1f, loop: loop); resident = await session.RenderAsync(); } using (Session session = Session.Open(seconds: 2)) { session.Module.Play(OpenToneStream(session.Module, frameCount: Rate / 4), volume: 1f, loop: loop); ExpectSameAudio(resident, await session.RenderWithUpdatesAsync()); } } private static async Task AStereoStreamedSound_KeepsItsChannelsApart() { using (Session session = Session.Open(seconds: 1)) { DecodedSound chord = Tones.Chord(leftHertz: 440, rightHertz: 0, amplitude: 0.5, frameCount: Rate / 2); session.Module.Play(OpenStream(session.Module, Tones.Wav(chord)), volume: 1f); JSObject rendered = await session.RenderWithUpdatesAsync(); ExpectNear("left peak", 0.5, Peak(rendered, channel: 0), 0.01); ExpectNear("right peak", 0, Peak(rendered, channel: 1), 0.001); } } private static async Task AStreamedSoundAtAnotherRate_KeepsItsDuration() { using (Session session = Session.Open(seconds: 2)) { byte[] file = Tones.Wav(Tones.Sine(440, amplitude: 0.8, frameCount: 22050 * 2, sampleRate: 22050)); session.Module.Play(OpenStream(session.Module, file), volume: 1f); int lastLoud = TestbedInterop.LastLoudFrame( await session.RenderWithUpdatesAsync(), channel: 0, threshold: 0.01); ExpectNear("last loud frame", Rate * 2 - Rate / 10, lastLoud, Rate / 10 + 300); } } private static async Task AnOggStream_PlaysThroughTheManagedDecoder() { using (Session session = Session.Open(seconds: 1)) { session.Module.Play(OpenStream(session.Module, Tones.OggFile()), volume: 1f); Expect(Peak(await session.RenderWithUpdatesAsync(), channel: 0) > 0.05, "sound in the rendered buffer"); } } private static async Task AStreamedVoice_EndsWhenItsSoundDoesAndFreesTheSound() { using (Session session = Session.Open(seconds: 2)) { StreamedSound sound = OpenToneStream(session.Module, frameCount: Rate / 2); Voice voice = session.Module.Play(sound, volume: 1f); Expect(session.Module.IsPlaying(voice), "the voice to be playing before it renders"); Expect(sound.IsHeld, "the sound to be held while it plays"); await session.RenderWithUpdatesAsync(); Expect(!session.Module.IsPlaying(voice), "the voice to have ended"); Expect(!sound.IsHeld, "the sound to be free again"); } } private static async Task AStreamedSoundThatIsPlaying_GivesNoVoiceAndPlaysAgainWhenFree() { using (Session session = Session.Open(seconds: 2)) { StreamedSound sound = OpenToneStream(session.Module, frameCount: Rate / 2); session.Module.Play(sound, volume: 1f); Expect(session.Module.Play(sound, volume: 1f).IsNone, "no second voice for the sound"); await session.RenderWithUpdatesAsync(); Expect(!session.Module.Play(sound, volume: 1f).IsNone, "a voice once the first has ended"); } } private static async Task Stop_SilencesAStreamedVoiceAndFreesTheSound() { using (Session session = Session.Open(seconds: 1, voiceCount: 1)) { StreamedSound sound = OpenToneStream(session.Module, frameCount: Rate); Voice voice = session.Module.Play(sound, volume: 1f); session.Module.Stop(voice); Expect(!session.Module.IsPlaying(voice), "the voice to stop playing at once"); ExpectNear("peak", 0, Peak(await session.RenderWithUpdatesAsync(), channel: 0), 0.001); Expect(!sound.IsHeld, "the sound to be free after an update"); Expect(!session.Module.Play(sound, volume: 1f).IsNone, "the slot to be free after an update"); } } private static async Task SetPitch_ChangesTheRateOfAStreamedVoice() { using (Session session = Session.Open(seconds: 2)) { StreamedSound sound = OpenToneStream(session.Module, frameCount: Rate); Voice voice = session.Module.Play(sound, volume: 1f); session.Module.SetPitch(voice, 2f); int lastLoud = TestbedInterop.LastLoudFrame( await session.RenderWithUpdatesAsync(), channel: 0, threshold: 0.01); ExpectNear("last loud frame", Rate / 2, lastLoud, 300); } } private static async Task SetPitchPartWayThrough_KeepsTheStreamGoing() { using (Session session = Session.Open(seconds: 3)) { StreamedSound sound = OpenToneStream(session.Module, frameCount: Rate * 2); Voice voice = session.Module.Play(sound, volume: 1f); // Half a second at the natural rate, then the remaining one and a half seconds in three quarters. JSObject rendered = await session.RenderWithUpdatesAsync(tick => { if (tick == 5) { session.Module.SetPitch(voice, 2f); } }); int lastLoud = TestbedInterop.LastLoudFrame(rendered, channel: 0, threshold: 0.01); ExpectNear("last loud frame", Rate * 5 / 4, lastLoud, 600); ExpectNear("peak", 0.8 * MonoAtCenter, Peak(rendered, channel: 0), 0.01); } } private static async Task SetPan_MovesAStreamedVoice() { using (Session session = Session.Open(seconds: 1)) { Voice voice = session.Module.Play(OpenToneStream(session.Module, frameCount: Rate / 2), volume: 1f); session.Module.SetPan(voice, -1f); JSObject rendered = await session.RenderWithUpdatesAsync(); ExpectNear("left peak", 0.8, Peak(rendered, channel: 0), 0.01); ExpectNear("right peak", 0, Peak(rendered, channel: 1), 0.001); } } private static async Task AStreamedSoundWithTooManyChannels_FailsTheLoad() { using (Session session = Session.Open(seconds: 1)) { byte[] file = Tones.Wav(Tones.Sine(440, amplitude: 0.5, frameCount: 100, channelCount: 3)); SoundLoad load = session.Module.LoadStream(new ByteChunks(new ByteView(file))); Expect(load.IsDone, "the load to be done"); Expect(load.Result.Kind == SoundFailures.TooManyChannels, "the load to fail for its channels"); await Task.CompletedTask; } } private static async Task ABrokenStream_FailsTheLoad() { using (Session session = Session.Open(seconds: 1)) { SoundLoad load = session.Module.LoadStream(new ByteChunks(new ByteView(new byte[100]))); Expect(load.IsDone, "the load to be done"); Expect(load.Result.Kind == SoundFailures.UnrecognizedFormat, "the load to fail for its format"); await Task.CompletedTask; } } private static async Task AStreamedSoundTheBrowserRefusesTheRateOf_GivesNoVoice() { using (Session session = Session.Open(seconds: 1)) { byte[] file = Tones.Wav(Tones.Sine(440, amplitude: 0.5, frameCount: 100, sampleRate: 100000000)); StreamedSound sound = OpenStream(session.Module, file); Expect(session.Module.Play(sound, volume: 1f).IsNone, "no voice for a rate the browser refuses"); Expect(session.Module.LastFailure.Kind == WebAudioFailures.BufferRejected, "the failure to be kept"); Expect(!sound.IsHeld, "the sound to be free"); await Task.CompletedTask; } } private static async Task ADisposedStreamedSound_Throws() { using (Session session = Session.Open(seconds: 1)) { StreamedSound sound = OpenToneStream(session.Module, frameCount: 100); sound.Dispose(); bool threw = false; try { session.Module.Play(sound, volume: 1f); } catch (ObjectDisposedException) { threw = true; } Expect(threw, "an exception for a disposed sound"); await Task.CompletedTask; } } private static async Task UnloadingFreesAStreamedSound() { StreamedSound sound; using (Session session = Session.Open(seconds: 1)) { sound = OpenToneStream(session.Module, frameCount: Rate); session.Module.Play(sound, volume: 1f); session.Module.Unload(); } Expect(!sound.IsHeld, "the sound to be free after the unload"); await Task.CompletedTask; } // Pauses the voice at one tick and resumes it at another. A tick is a tenth of a second of the render. private static Action PausedBetween(WebAudioModule module, Voice voice, int pauseTick, int resumeTick) { return ticks => { if (ticks == pauseTick) { module.SetPaused(voice, true); } else if (ticks == resumeTick) { module.SetPaused(voice, false); } }; } private static void ExpectSilentBetween(JSObject rendered, double fromSeconds, double toSeconds) { ExpectNear($"peak from {fromSeconds} s to {toSeconds} s", 0, TestbedInterop.Peak(rendered, channel: 0, (int)(fromSeconds * Rate), (int)(toSeconds * Rate)), 0.001); } private static void ExpectLoudBetween(JSObject rendered, double fromSeconds, double toSeconds) { ExpectNear($"peak from {fromSeconds} s to {toSeconds} s", 0.8 * MonoAtCenter, TestbedInterop.Peak(rendered, channel: 0, (int)(fromSeconds * Rate), (int)(toSeconds * Rate)), 0.01); } private static void ExpectSoundEndsAt(JSObject rendered, double seconds) { int lastLoud = TestbedInterop.LastLoudFrame(rendered, channel: 0, threshold: 0.01); ExpectNear("last loud frame", seconds * Rate, lastLoud, Rate / 50); } private static async Task APausedSound_IsSilentThenFinishesWhereItStopped() { using (Session session = Session.Open(seconds: 3)) { Voice voice = session.Module.Play( session.Module.LoadSound(Tones.Sine(440, 0.8, frameCount: Rate)), volume: 1f); JSObject rendered = await session.RenderWithUpdatesAsync( PausedBetween(session.Module, voice, pauseTick: 3, resumeTick: 13)); ExpectLoudBetween(rendered, 0.1, 0.3); ExpectSilentBetween(rendered, 0.4, 1.2); ExpectLoudBetween(rendered, 1.4, 1.9); ExpectSoundEndsAt(rendered, 2.0); } } private static async Task ASoundPausedBeforeItHasPlayed_StartsOnResume() { using (Session session = Session.Open(seconds: 3)) { Voice voice = session.Module.Play( session.Module.LoadSound(Tones.Sine(440, 0.8, frameCount: Rate)), volume: 1f); session.Module.SetPaused(voice, true); JSObject rendered = await session.RenderWithUpdatesAsync( PausedBetween(session.Module, voice, pauseTick: -1, resumeTick: 5)); ExpectSilentBetween(rendered, 0, 0.45); ExpectSoundEndsAt(rendered, 1.5); } } private static async Task APausedSoundThatIsStopped_EndsAndFreesItsSlot() { using (Session session = Session.Open(seconds: 2, voiceCount: 1)) { Sound sound = session.Module.LoadSound(Tones.Sine(440, 0.8, frameCount: Rate)); Voice voice = session.Module.Play(sound, volume: 1f); await session.RenderWithUpdatesAsync(ticks => { if (ticks == 3) { session.Module.SetPaused(voice, true); } else if (ticks == 5) { session.Module.Stop(voice); } }); Expect(!session.Module.IsPlaying(voice), "the stopped voice to have ended"); Expect(!session.Module.Play(sound, volume: 1f).IsNone, "the slot to be free"); } } private static async Task SetPitchWhilePaused_TakesEffectOnResume() { using (Session session = Session.Open(seconds: 3)) { Voice voice = session.Module.Play( session.Module.LoadSound(Tones.Sine(440, 0.8, frameCount: Rate)), volume: 1f); Action pauseAndResume = PausedBetween(session.Module, voice, pauseTick: 3, resumeTick: 13); JSObject rendered = await session.RenderWithUpdatesAsync(ticks => { pauseAndResume(ticks); if (ticks == 4) { session.Module.SetPitch(voice, 2f); } }); ExpectSilentBetween(rendered, 0.4, 1.2); ExpectSoundEndsAt(rendered, 1.65); } } private static async Task APausedLoop_KeepsLoopingAfterResume() { using (Session session = Session.Open(seconds: 3)) { Voice voice = session.Module.Play( session.Module.LoadSound(Tones.Sine(440, 0.8, frameCount: Rate / 2)), volume: 1f, loop: Loop.Whole); Action pauseAndResume = PausedBetween(session.Module, voice, pauseTick: 7, resumeTick: 17); JSObject rendered = await session.RenderWithUpdatesAsync(ticks => { pauseAndResume(ticks); if (ticks == 22) { session.Module.Stop(voice); } }); ExpectSilentBetween(rendered, 0.8, 1.6); ExpectLoudBetween(rendered, 1.8, 2.1); ExpectSoundEndsAt(rendered, 2.2); } } private static async Task APausedStreamedSound_IsSilentThenFinishesWhereItStopped() { using (Session session = Session.Open(seconds: 3)) { Voice voice = session.Module.Play(OpenToneStream(session.Module, frameCount: Rate), volume: 1f); JSObject rendered = await session.RenderWithUpdatesAsync( PausedBetween(session.Module, voice, pauseTick: 3, resumeTick: 13)); ExpectLoudBetween(rendered, 0.1, 0.3); ExpectSilentBetween(rendered, 0.4, 1.2); ExpectLoudBetween(rendered, 1.4, 1.9); ExpectSoundEndsAt(rendered, 2.0); } } private static async Task AStreamedSoundPausedBeforeItHasPlayed_StartsOnResume() { using (Session session = Session.Open(seconds: 3)) { Voice voice = session.Module.Play(OpenToneStream(session.Module, frameCount: Rate), volume: 1f); session.Module.SetPaused(voice, true); JSObject rendered = await session.RenderWithUpdatesAsync( PausedBetween(session.Module, voice, pauseTick: -1, resumeTick: 5)); ExpectSilentBetween(rendered, 0, 0.45); ExpectSoundEndsAt(rendered, 1.5); } } private static async Task APausedStreamedVoice_StaysPlayingAndHoldsItsSound() { using (Session session = Session.Open(seconds: 3)) { StreamedSound sound = OpenToneStream(session.Module, frameCount: Rate); Voice voice = session.Module.Play(sound, volume: 1f); await session.RenderWithUpdatesAsync( PausedBetween(session.Module, voice, pauseTick: 3, resumeTick: -1)); Expect(session.Module.IsPlaying(voice), "the paused voice to be playing"); Expect(sound.IsHeld, "the sound to be held"); } } private static async Task APausedStreamedVoiceThatIsStopped_FreesItsSound() { using (Session session = Session.Open(seconds: 2)) { StreamedSound sound = OpenToneStream(session.Module, frameCount: Rate); Voice voice = session.Module.Play(sound, volume: 1f); await session.RenderWithUpdatesAsync(ticks => { if (ticks == 3) { session.Module.SetPaused(voice, true); } else if (ticks == 5) { session.Module.Stop(voice); } }); Expect(!session.Module.IsPlaying(voice), "the stopped voice to have ended"); Expect(!sound.IsHeld, "the sound to be free"); } } private static async Task SetPitchWhileAStreamIsPaused_TakesEffectOnResume() { using (Session session = Session.Open(seconds: 3)) { Voice voice = session.Module.Play(OpenToneStream(session.Module, frameCount: Rate), volume: 1f); Action pauseAndResume = PausedBetween(session.Module, voice, pauseTick: 3, resumeTick: 13); JSObject rendered = await session.RenderWithUpdatesAsync(ticks => { pauseAndResume(ticks); if (ticks == 4) { session.Module.SetPitch(voice, 2f); } }); ExpectSilentBetween(rendered, 0.4, 1.2); ExpectSoundEndsAt(rendered, 1.65); } } private const int ChorusSize = 12; private static async Task LoudChorusPeakAsync(bool limiter) { using (Session session = Session.Open(seconds: 1, voiceCount: ChorusSize, limiter: limiter)) { Sound sound = session.Module.LoadSound(Tones.Sine(440, amplitude: 0.8, frameCount: Rate / 2)); for (int i = 0; i < ChorusSize; ++i) { session.Module.Play(sound, volume: 1f); } return Peak(await session.RenderAsync(), channel: 0); } } private static async Task AChorusOfLoudVoices_ClipsWithoutTheLimiter() { Expect(await LoudChorusPeakAsync(limiter: false) > 4, "the unlimited chorus to reach far past full scale"); } private static async Task AChorusOfLoudVoices_StaysNearFullScaleWithTheLimiter() { double peak = await LoudChorusPeakAsync(limiter: true); Expect(peak <= 1 && peak > 0.7, $"the limited chorus to peak between 0.7 and full scale, got {peak}"); } private static async Task ASoundBelowTheCeiling_PassesTheLimiterAtItsOwnLevel() { using (Session session = Session.Open(seconds: 1, limiter: true)) { Sound sound = session.Module.LoadSound(Tones.Sine(440, amplitude: 0.3, frameCount: Rate / 2)); session.Module.Play(sound, volume: 1f); ExpectNear("peak", 0.3 * MonoAtCenter, Peak(await session.RenderAsync(), channel: 0), 0.02); } } private static async Task TurningTheLimiterOffAfterLoad_LetsTheChorusThrough() { using (Session session = Session.Open(seconds: 1, voiceCount: ChorusSize, limiter: true)) { session.Module.LimiterEnabled = false; Sound sound = session.Module.LoadSound(Tones.Sine(440, amplitude: 0.8, frameCount: Rate / 2)); for (int i = 0; i < ChorusSize; ++i) { session.Module.Play(sound, volume: 1f); } Expect(Peak(await session.RenderAsync(), channel: 0) > 4, "the chorus to pass at full strength"); } } public static async Task RunAll() { List>> tests = new List>> { new KeyValuePair>(nameof(VolumeOne_PlaysAtTheSoundsOwnLevel), VolumeOne_PlaysAtTheSoundsOwnLevel), new KeyValuePair>(nameof(VolumeHalf_HalvesTheLevel), VolumeHalf_HalvesTheLevel), new KeyValuePair>(nameof(SetVolume_ChangesTheLevel), SetVolume_ChangesTheLevel), new KeyValuePair>(nameof(TwoVoices_AddTogether), TwoVoices_AddTogether), new KeyValuePair>(nameof(AStereoSound_KeepsItsChannelsApart), AStereoSound_KeepsItsChannelsApart), new KeyValuePair>(nameof(ASoundAtAnotherRate_KeepsItsDuration), ASoundAtAnotherRate_KeepsItsDuration), new KeyValuePair>(nameof(AMonoSound_IsPlacedByTheCoreLaw), AMonoSound_IsPlacedByTheCoreLaw), new KeyValuePair>(nameof(AStereoSoundWithOnlyALeftChannel_IsPlacedByTheCoreLaw), AStereoSoundWithOnlyALeftChannel_IsPlacedByTheCoreLaw), new KeyValuePair>(nameof(AStereoSoundWithOnlyARightChannel_IsPlacedByTheCoreLaw), AStereoSoundWithOnlyARightChannel_IsPlacedByTheCoreLaw), new KeyValuePair>(nameof(SetPan_MovesAPlayingVoice), SetPan_MovesAPlayingVoice), new KeyValuePair>(nameof(AWholeLoop_KeepsSoundingPastTheEndOfTheSound), AWholeLoop_KeepsSoundingPastTheEndOfTheSound), new KeyValuePair>(nameof(ARegionLoop_KeepsSoundingPastTheEndOfTheSound), ARegionLoop_KeepsSoundingPastTheEndOfTheSound), new KeyValuePair>(nameof(AnOctaveUp_PlaysInHalfTheTime), AnOctaveUp_PlaysInHalfTheTime), new KeyValuePair>(nameof(AnOctaveDown_PlaysInTwiceTheTime), AnOctaveDown_PlaysInTwiceTheTime), new KeyValuePair>( nameof(TheHighestPitch_PlaysTheWholeSoundInAThousandthOfTheTime), TheHighestPitch_PlaysTheWholeSoundInAThousandthOfTheTime), new KeyValuePair>( nameof(TheLowestPitch_PlaysTheStartOfTheSoundAcrossTheWholeRender), TheLowestPitch_PlaysTheStartOfTheSoundAcrossTheWholeRender), new KeyValuePair>(nameof(SetPitch_ChangesTheRateOfAPlayingVoice), SetPitch_ChangesTheRateOfAPlayingVoice), new KeyValuePair>(nameof(APitchOrPanOutOfRange_Throws), APitchOrPanOutOfRange_Throws), new KeyValuePair>(nameof(Stop_SilencesTheVoiceAndFreesItsSlot), Stop_SilencesTheVoiceAndFreesItsSlot), new KeyValuePair>(nameof(WhenEveryVoiceIsBusy_PlayReturnsNone), WhenEveryVoiceIsBusy_PlayReturnsNone), new KeyValuePair>(nameof(AFinishedVoice_IsReleasedAtUpdate), AFinishedVoice_IsReleasedAtUpdate), new KeyValuePair>(nameof(ASoundWithNoSamples_EndsAtTheNextUpdate), ASoundWithNoSamples_EndsAtTheNextUpdate), new KeyValuePair>(nameof(WavBytes_LoadAtOnce), WavBytes_LoadAtOnce), new KeyValuePair>(nameof(AWavWithTooManyChannels_FailsTheLoad), AWavWithTooManyChannels_FailsTheLoad), new KeyValuePair>(nameof(ASampleRateTheBrowserRefuses_FailsTheLoad), ASampleRateTheBrowserRefuses_FailsTheLoad), new KeyValuePair>(nameof(OggDecodedByTheBrowser_PlaysAtItsOwnLength), OggDecodedByTheBrowser_PlaysAtItsOwnLength), new KeyValuePair>(nameof(OggDecodedByManagedCode_WhenTheBrowserCannot), OggDecodedByManagedCode_WhenTheBrowserCannot), new KeyValuePair>(nameof(ATruncatedOgg_FailsTheLoad), ATruncatedOgg_FailsTheLoad), new KeyValuePair>(nameof(UnloadingWhileAFileDecodes_FailsTheLoad), UnloadingWhileAFileDecodes_FailsTheLoad), new KeyValuePair>( nameof(PlayWhileTheBrowserKeepsSoundSilent_ReturnsNoneUntilAGesture), PlayWhileTheBrowserKeepsSoundSilent_ReturnsNoneUntilAGesture), new KeyValuePair>(nameof(AModuleThatIsNotLoaded_RefusesToLoadSounds), AModuleThatIsNotLoaded_RefusesToLoadSounds), new KeyValuePair>(nameof(AStreamedSound_RendersWhatTheWholeSoundDoes), AStreamedSound_RendersWhatTheWholeSoundDoes), new KeyValuePair>(nameof(AStreamedLoop_JoinsWithoutAGap), AStreamedLoop_JoinsWithoutAGap), new KeyValuePair>(nameof(AStreamedRegionLoop_RepeatsTheRegion), AStreamedRegionLoop_RepeatsTheRegion), new KeyValuePair>(nameof(AStereoStreamedSound_KeepsItsChannelsApart), AStereoStreamedSound_KeepsItsChannelsApart), new KeyValuePair>(nameof(AStreamedSoundAtAnotherRate_KeepsItsDuration), AStreamedSoundAtAnotherRate_KeepsItsDuration), new KeyValuePair>(nameof(AnOggStream_PlaysThroughTheManagedDecoder), AnOggStream_PlaysThroughTheManagedDecoder), new KeyValuePair>(nameof(AStreamedVoice_EndsWhenItsSoundDoesAndFreesTheSound), AStreamedVoice_EndsWhenItsSoundDoesAndFreesTheSound), new KeyValuePair>( nameof(AStreamedSoundThatIsPlaying_GivesNoVoiceAndPlaysAgainWhenFree), AStreamedSoundThatIsPlaying_GivesNoVoiceAndPlaysAgainWhenFree), new KeyValuePair>(nameof(Stop_SilencesAStreamedVoiceAndFreesTheSound), Stop_SilencesAStreamedVoiceAndFreesTheSound), new KeyValuePair>(nameof(SetPitch_ChangesTheRateOfAStreamedVoice), SetPitch_ChangesTheRateOfAStreamedVoice), new KeyValuePair>(nameof(SetPitchPartWayThrough_KeepsTheStreamGoing), SetPitchPartWayThrough_KeepsTheStreamGoing), new KeyValuePair>(nameof(SetPan_MovesAStreamedVoice), SetPan_MovesAStreamedVoice), new KeyValuePair>(nameof(AStreamedSoundWithTooManyChannels_FailsTheLoad), AStreamedSoundWithTooManyChannels_FailsTheLoad), new KeyValuePair>(nameof(ABrokenStream_FailsTheLoad), ABrokenStream_FailsTheLoad), new KeyValuePair>(nameof(AStreamedSoundTheBrowserRefusesTheRateOf_GivesNoVoice), AStreamedSoundTheBrowserRefusesTheRateOf_GivesNoVoice), new KeyValuePair>(nameof(ADisposedStreamedSound_Throws), ADisposedStreamedSound_Throws), new KeyValuePair>(nameof(UnloadingFreesAStreamedSound), UnloadingFreesAStreamedSound), new KeyValuePair>(nameof(APausedSound_IsSilentThenFinishesWhereItStopped), APausedSound_IsSilentThenFinishesWhereItStopped), new KeyValuePair>(nameof(ASoundPausedBeforeItHasPlayed_StartsOnResume), ASoundPausedBeforeItHasPlayed_StartsOnResume), new KeyValuePair>(nameof(APausedSoundThatIsStopped_EndsAndFreesItsSlot), APausedSoundThatIsStopped_EndsAndFreesItsSlot), new KeyValuePair>(nameof(SetPitchWhilePaused_TakesEffectOnResume), SetPitchWhilePaused_TakesEffectOnResume), new KeyValuePair>(nameof(APausedLoop_KeepsLoopingAfterResume), APausedLoop_KeepsLoopingAfterResume), new KeyValuePair>( nameof(APausedStreamedSound_IsSilentThenFinishesWhereItStopped), APausedStreamedSound_IsSilentThenFinishesWhereItStopped), new KeyValuePair>(nameof(AStreamedSoundPausedBeforeItHasPlayed_StartsOnResume), AStreamedSoundPausedBeforeItHasPlayed_StartsOnResume), new KeyValuePair>(nameof(APausedStreamedVoice_StaysPlayingAndHoldsItsSound), APausedStreamedVoice_StaysPlayingAndHoldsItsSound), new KeyValuePair>(nameof(APausedStreamedVoiceThatIsStopped_FreesItsSound), APausedStreamedVoiceThatIsStopped_FreesItsSound), new KeyValuePair>(nameof(SetPitchWhileAStreamIsPaused_TakesEffectOnResume), SetPitchWhileAStreamIsPaused_TakesEffectOnResume), new KeyValuePair>(nameof(AChorusOfLoudVoices_ClipsWithoutTheLimiter), AChorusOfLoudVoices_ClipsWithoutTheLimiter), new KeyValuePair>(nameof(AChorusOfLoudVoices_StaysNearFullScaleWithTheLimiter), AChorusOfLoudVoices_StaysNearFullScaleWithTheLimiter), new KeyValuePair>(nameof(ASoundBelowTheCeiling_PassesTheLimiterAtItsOwnLevel), ASoundBelowTheCeiling_PassesTheLimiterAtItsOwnLevel), new KeyValuePair>(nameof(TurningTheLimiterOffAfterLoad_LetsTheChorusThrough), TurningTheLimiterOffAfterLoad_LetsTheChorusThrough), }; int passed = 0; int failed = 0; foreach (KeyValuePair> test in tests) { try { await test.Value(); ++passed; TestbedInterop.Report(test.Key, "PASS"); } catch (Exception exception) { ++failed; TestbedInterop.Report($"{test.Key}: {exception.Message}", "FAIL"); } } TestbedInterop.Report($"SUMMARY {passed} passed, {failed} failed", failed == 0 ? "PASS" : "FAIL"); } } }