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