mirror of
https://github.com/sehugg/8bitworkshop.git
synced 2026-10-25 15:05:25 +00:00
269 lines
9.5 KiB
TypeScript
269 lines
9.5 KiB
TypeScript
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<void>) {
|
|
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);
|
|
});
|
|
});
|
|
});
|