165 lines
5.8 KiB
JavaScript
165 lines
5.8 KiB
JavaScript
import assert from "node:assert/strict";
|
|
import fs from "node:fs";
|
|
import os from "node:os";
|
|
import path from "node:path";
|
|
import test from "node:test";
|
|
import { pathToFileURL } from "node:url";
|
|
import ts from "typescript";
|
|
|
|
const root = path.resolve(import.meta.dirname, "../../..");
|
|
const temporary = fs.mkdtempSync(path.join(os.tmpdir(), "sequencer-audio-recovery-unit-"));
|
|
const protocolPath = path.join(root, "web/protocol/sequencer-audio-session.ts");
|
|
const runtimePath = path.join(root, "web/app/src/sequencer/SequencerAudioSession.ts");
|
|
|
|
function transpile(source, fileName) {
|
|
const result = ts.transpileModule(source, {
|
|
compilerOptions: { module: ts.ModuleKind.ES2022, target: ts.ScriptTarget.ES2022 },
|
|
fileName,
|
|
reportDiagnostics: true,
|
|
});
|
|
assert.deepEqual(result.diagnostics, []);
|
|
return result.outputText;
|
|
}
|
|
|
|
fs.writeFileSync(
|
|
path.join(temporary, "sequencer-audio-session.mjs"),
|
|
transpile(fs.readFileSync(protocolPath, "utf8").replace('import type { ErrorCode } from "./error";\n', ""), protocolPath),
|
|
);
|
|
fs.writeFileSync(
|
|
path.join(temporary, "SequencerAudioSession.mjs"),
|
|
transpile(
|
|
fs.readFileSync(runtimePath, "utf8").replace(
|
|
'from "../../../protocol/sequencer-audio-session";',
|
|
'from "./sequencer-audio-session.mjs";',
|
|
),
|
|
runtimePath,
|
|
),
|
|
);
|
|
|
|
const protocol = await import(pathToFileURL(path.join(temporary, "sequencer-audio-session.mjs")));
|
|
const runtime = await import(pathToFileURL(path.join(temporary, "SequencerAudioSession.mjs")));
|
|
const golden = JSON.parse(fs.readFileSync(path.join(root, "tests/golden/M11-13/sequencer-audio-recovery.json"), "utf8"));
|
|
|
|
class FakeAudioParam {
|
|
value = 1;
|
|
events = [];
|
|
cancelScheduledValues(time) { this.events.push(["cancel", time]); }
|
|
setValueAtTime(value, time) { this.value = value; this.events.push(["set", value, time]); }
|
|
}
|
|
|
|
class FakeGainNode {
|
|
gain = new FakeAudioParam();
|
|
connected = false;
|
|
disconnected = false;
|
|
connect() { this.connected = true; }
|
|
disconnect() { this.disconnected = true; }
|
|
}
|
|
|
|
class FakeAudioContext {
|
|
static instances = [];
|
|
state = "suspended";
|
|
currentTime = 1;
|
|
destination = {};
|
|
output = new FakeGainNode();
|
|
closeCount = 0;
|
|
constructor() { FakeAudioContext.instances.push(this); }
|
|
createGain() { return this.output; }
|
|
async resume() { this.state = "running"; }
|
|
async suspend() { this.state = "suspended"; }
|
|
async close() { this.closeCount += 1; this.state = "closed"; }
|
|
}
|
|
|
|
const summary = (report) => [
|
|
report.contextState,
|
|
report.outputState,
|
|
report.muted,
|
|
report.outputGain,
|
|
report.issueCode,
|
|
];
|
|
|
|
test("M11-13 restores output after real session suspend and mute transitions", async () => {
|
|
FakeAudioContext.instances.length = 0;
|
|
const session = new runtime.SequencerAudioSession({
|
|
scope: { AudioContext: FakeAudioContext },
|
|
outputGain: golden.outputGain,
|
|
});
|
|
const reports = [];
|
|
reports.push(await session.initialize());
|
|
reports.push(session.setMuted(true));
|
|
reports.push(await session.resume());
|
|
reports.push(session.setMuted(false));
|
|
reports.push(await session.suspend());
|
|
reports.push(await session.resume());
|
|
reports.push(await session.close());
|
|
|
|
assert.deepEqual(reports.map(summary), golden.lifecycle);
|
|
assert.deepEqual(reports.map((report) => report.revision), [1, 2, 3, 4, 5, 6, 7]);
|
|
const context = FakeAudioContext.instances[0];
|
|
assert.equal(context.output.gain.value, 0);
|
|
assert.equal(context.output.connected, true);
|
|
assert.equal(context.output.disconnected, true);
|
|
assert.equal(context.closeCount, 1);
|
|
assert.ok(context.output.gain.events.some((event) => event[0] === "set" && event[1] === 0));
|
|
assert.ok(context.output.gain.events.some((event) => event[0] === "set" && event[1] === golden.outputGain));
|
|
});
|
|
|
|
test("M11-13 blocks a missing audio device and can retry after the runtime becomes available", async () => {
|
|
FakeAudioContext.instances.length = 0;
|
|
const scope = {};
|
|
const session = new runtime.SequencerAudioSession({ scope, outputGain: golden.outputGain });
|
|
const missing = await session.initialize();
|
|
assert.deepEqual(
|
|
[missing.contextState, missing.outputState, missing.issueCode],
|
|
golden.missingDevice,
|
|
);
|
|
assert.deepEqual(session.snapshot(), missing);
|
|
|
|
scope.AudioContext = FakeAudioContext;
|
|
const recovered = await session.recoverDevice();
|
|
assert.deepEqual(summary(recovered), golden.lifecycle[0]);
|
|
const resumed = await session.resume();
|
|
assert.deepEqual(summary(resumed), ["RUNNING", "ENABLED", false, golden.outputGain, null]);
|
|
await session.close();
|
|
});
|
|
|
|
test("M11-13 keeps failed resume and malformed reports silent and structured", async () => {
|
|
class ResumeFailureContext extends FakeAudioContext {
|
|
async resume() { throw new Error("injected resume failure"); }
|
|
}
|
|
const failed = new runtime.SequencerAudioSession({
|
|
contextFactory: () => new ResumeFailureContext(),
|
|
outputGain: golden.outputGain,
|
|
});
|
|
await failed.initialize();
|
|
const resumeFailure = await failed.resume();
|
|
assert.deepEqual(
|
|
[resumeFailure.contextState, resumeFailure.outputState, resumeFailure.issueCode],
|
|
golden.resumeFailure,
|
|
);
|
|
await failed.close();
|
|
|
|
const unavailable = new runtime.SequencerAudioSession({
|
|
contextFactory: () => { throw new Error("no output device"); },
|
|
});
|
|
assert.deepEqual(
|
|
[
|
|
(await unavailable.initialize()).contextState,
|
|
unavailable.snapshot().outputState,
|
|
unavailable.snapshot().issueCode,
|
|
],
|
|
golden.missingDevice,
|
|
);
|
|
assert.throws(() => new runtime.SequencerAudioSession({ outputGain: 0 }), {
|
|
code: "SEQUENCER_AUDIO_CONTEXT_INVALID",
|
|
});
|
|
assert.throws(() => protocol.parseSequencerAudioSessionReport({
|
|
schemaVersion: 1,
|
|
revision: 1,
|
|
contextState: "RUNNING",
|
|
outputState: "ENABLED",
|
|
muted: true,
|
|
outputGain: 1,
|
|
issueCode: null,
|
|
}), { code: "SEQUENCER_AUDIO_CONTEXT_INVALID" });
|
|
});
|