import assert from "assert"; import { describe, it, afterEach } from "mocha"; import { AUDIO_CHUNK_SAMPLES, AudioStream, SampledAudio, isAudioBlocked, onAudioStateChange, resumeAudioContext, setAudioStreamFactory } from "../../src/common/audio"; // A stand-in for the extension's worker-side stream (see extension/emuworker.ts). class TestStream implements AudioStream { chunks: Float32Array[] = []; started = false; stopped = false; constructor(readonly sampleRate: number) { } start() { this.started = true; } stop() { this.stopped = true; } push(samples: Float32Array) { this.chunks.push(samples); } } describe('SampleAudio streaming', function () { afterEach(function () { setAudioStreamFactory(null); }); it('hands full buffer chunks to the stream at the source rate', function () { var stream: TestStream; setAudioStreamFactory(() => (stream = new TestStream(48000))); const audio = new SampledAudio(48000); // source rate == stream rate, so sinc is 1 audio.start(); assert.ok(stream.started); for (var i = 0; i < AUDIO_CHUNK_SAMPLES; i++) audio.feedSample(0.5, 1); assert.equal(stream.chunks.length, 1); assert.equal(stream.chunks[0].length, AUDIO_CHUNK_SAMPLES); assert.equal(stream.chunks[0][0], 0.5); audio.stop(); assert.ok(stream.stopped); }); it('resamples a lower source rate up to the stream rate', function () { var stream: TestStream; setAudioStreamFactory(() => (stream = new TestStream(48000))); const audio = new SampledAudio(24000); // sinc = 48000 / 24000 = 2 audio.start(); for (var i = 0; i < 24000; i++) audio.feedSample(0.25, 1); // 24000 source samples * 2 = 48000 output samples; the final partial // buffer stays in SampleAudio until another chunk fills it var full = Math.floor(48000 / AUDIO_CHUNK_SAMPLES) * AUDIO_CHUNK_SAMPLES; var total = stream.chunks.reduce((n, c) => n + c.length, 0); assert.equal(total, full); }); it('reports its output rate once started', function () { setAudioStreamFactory(() => new TestStream(44100)); const audio = new SampledAudio(15720); audio.start(); assert.equal(audio.sampleRate, 44100); }); it('start is idempotent, so resume does not recreate the stream', function () { var count = 0; var stream: TestStream; setAudioStreamFactory(() => { count++; return (stream = new TestStream(48000)); }); const audio = new SampledAudio(48000); audio.start(); audio.start(); assert.equal(count, 1); assert.ok(stream.started); }); }); // A minimal Web Audio graph so the ScriptProcessor ring path (the only place // getStats has data) can run under Node. class FakeNode { type = ''; frequency = { value: 0 }; gain = { value: 0 }; connect() { return this; } } class FakeScriptProcessorNode extends FakeNode { onaudioprocess: any; module: any; } class FakeAudioContext { static last: FakeAudioContext; sampleRate = 44100; state = 'running'; destination = new FakeNode(); scriptProcessor = new FakeScriptProcessorNode(); constructor() { FakeAudioContext.last = this; } createBiquadFilter() { return new FakeNode(); } createDynamicsCompressor() { return new FakeNode(); } createScriptProcessor(_len: number, _in: number, _out: number) { return this.scriptProcessor; } resume() { return Promise.resolve(); } suspend() { return Promise.resolve(); } close() { } } // A context that starts suspended, the way the browser hands one back when it is // created before the first user gesture. class SuspendedAudioContext extends FakeAudioContext { state = 'suspended'; resumes = 0; suspend() { this.state = 'suspended'; return Promise.resolve(); } resume() { this.resumes++; this.state = 'running'; return Promise.resolve(); } } // Like a real context: resume() takes a moment, then fires statechange. Used to // check that the UI hint waits for Web Audio to actually start. class GestureAudioContext extends FakeAudioContext { state = 'suspended'; stateListeners: (() => void)[] = []; addEventListener(type: string, listener: () => void) { if (type === 'statechange') this.stateListeners.push(listener); } resume() { return Promise.resolve().then(() => { this.state = 'running'; for (const listener of this.stateListeners) listener(); }); } suspend() { return Promise.resolve().then(() => { this.state = 'suspended'; for (const listener of this.stateListeners) listener(); }); } } const RING_BUFFER_SAMPLES = 2048; function withFakeAudio(fn: () => void | Promise) { const prev = (global as any).window; (global as any).window = { AudioContext: FakeAudioContext }; try { return fn(); } finally { (global as any).window = prev; } } function feedBuffers(audio: SampledAudio, buffers: number, value = 0.25) { for (let i = 0; i < buffers * RING_BUFFER_SAMPLES; i++) audio.feedSample(value, 1); } function pullBlock(length = RING_BUFFER_SAMPLES): Float32Array { const out = new Float32Array(length); const node = FakeAudioContext.last.scriptProcessor; node.onaudioprocess.call(node, { outputBuffer: { getChannelData: () => out }, srcElement: node }); return out; } describe('SampleAudio autoplay unlock', function () { afterEach(function () { setAudioStreamFactory(null); }); it('resumes a context that was created suspended', async function () { const prev = (global as any).window; (global as any).window = { AudioContext: SuspendedAudioContext }; try { const audio = new SampledAudio(44100); audio.start(); const ctx = FakeAudioContext.last as SuspendedAudioContext; // the suspend()/resume() cycle is async so it can't race await new Promise((r) => setTimeout(r, 0)); assert.equal(ctx.resumes, 1); assert.equal(ctx.state, 'running'); // a later start (e.g. platform.resume()) must not recreate the graph audio.start(); assert.equal(ctx.resumes, 1); } finally { (global as any).window = prev; } }); it('reports a context as blocked until its resume() resolves', async function () { const ctx = new GestureAudioContext(); let changes = 0; const unsubscribe = onAudioStateChange(() => changes++); resumeAudioContext(ctx as any); assert.equal(isAudioBlocked(), true); await new Promise((r) => setTimeout(r, 0)); assert.equal(ctx.state, 'running'); assert.equal(isAudioBlocked(), false); assert.ok(changes >= 2, 'expected registration and unlock notifications'); unsubscribe(); }); it('does not treat a context the IDE suspends as blocked', async function () { const ctx = new GestureAudioContext(); resumeAudioContext(ctx as any); await new Promise((r) => setTimeout(r, 0)); assert.equal(isAudioBlocked(), false); await ctx.suspend(); assert.equal(isAudioBlocked(), false); }); }); describe('SampleAudio ring depth', function () { afterEach(function () { setAudioStreamFactory(null); }); it('fills to the full ring by default (behavior unchanged)', function () { withFakeAudio(() => { const audio = new SampledAudio(44100); audio.start(); const stats = audio.getStats(); assert.equal(stats.maxBuffers, 8); assert.equal(stats.targetDepth, 8); assert.equal(stats.sampleRate, 44100); }); }); it('drops the oldest buffer when the producer laps the consumer', function () { withFakeAudio(() => { const audio = new SampledAudio(44100); audio.start(); feedBuffers(audio, 8); // no consumer yet, so the 8th commit trips depth 8 assert.equal(audio.getStats().skippedBuffers, 1); }); }); it('serves committed buffers in order and records the fill', function () { withFakeAudio(() => { const audio = new SampledAudio(44100); audio.start(); audio.setStatsEnabled(true); feedBuffers(audio, 2); var out = pullBlock(); assert.equal(out[0], 0.25); var stats = audio.getStats(); assert.equal(stats.samples, 1); assert.equal(stats.underruns, 0); assert.equal(stats.fillHistogram[2], 1); // two buffers outstanding when pulled assert.equal(stats.meanFill, 2); // draining past the producer is an underrun, not stale audio pullBlock(); pullBlock(); assert.equal(audio.getStats().underruns, 1); }); }); it('turns the resample ratio up when the ring runs dry', async function () { await withFakeAudio(async () => { const audio = new SampledAudio(44100); audio.start(); feedBuffers(audio, 1); for (let i = 0; i < 20; i++) pullBlock(); // drains, so fillAvg ends up below target await new Promise((r) => setTimeout(r, 520)); feedBuffers(audio, 1); // first commit after the interval triggers an adjustment const stats = audio.getStats(); assert.ok(stats.fillAvg < 1.5, 'expected a dry ring, got fillAvg=' + stats.fillAvg); assert.ok(stats.trim > 1, 'expected trim > 1, got ' + stats.trim); }); }); it('turns the resample ratio down when the ring is too full', async function () { await withFakeAudio(async () => { const audio = new SampledAudio(44100); audio.start(); feedBuffers(audio, 3); pullBlock(); for (let i = 0; i < 20; i++) { feedBuffers(audio, 1); pullBlock(); } // holds fill at 3 await new Promise((r) => setTimeout(r, 520)); feedBuffers(audio, 1); const stats = audio.getStats(); assert.ok(stats.fillAvg > 1.5, 'expected a full ring, got fillAvg=' + stats.fillAvg); assert.ok(stats.trim < 1, 'expected trim < 1, got ' + stats.trim); }); }); });