import { mkdtemp, rm, stat } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; import type { BlockProcessor } from "@suaveplan/audio-core"; import { afterEach, beforeEach, describe, expect, it, } from "@suaveplan/testing/runner"; import { AUDIO_ENGINE_SERVER_CODES } from "../error-codes/error-codes.js"; import type { ChainPlugin } from "../fx/plugin-chain.types.js"; import { ServerTimelineAdapter } from "./server-adapter.js"; function passthroughProcessor(): BlockProcessor { return { name: "passthrough", setup: () => undefined, process: () => undefined, reset: () => undefined, dispose: () => undefined, }; } function makePlugin(id: string, latencySamples = 256): ChainPlugin { return { id, latencySamples, processor: passthroughProcessor() }; } function makeTrack(id: string) { return { id, name: id, clips: [], volume: 1, muted: false, soloed: false, pan: 0, }; } describe("ServerTimelineAdapter", () => { let dir: string; beforeEach(async () => { dir = await mkdtemp(join(tmpdir(), "aes-adapter-")); }); afterEach(async () => { await rm(dir, { recursive: true, force: true }); }); it("constructor rejects invalid options with AUDIO_ENGINE_SERVER.INVALID_OPTIONS", { timeout: 5000, }, () => { expect( () => new ServerTimelineAdapter({ sampleRate: -1 }) ).toThrowError( expect.objectContaining({ code: AUDIO_ENGINE_SERVER_CODES.INVALID_OPTIONS, }) ); }); it("implements every TimelineAdapter member with lookAhead 0", { timeout: 5000, }, () => { const a = new ServerTimelineAdapter({ outputPath: join(dir, "x.wav"), }); expect(a.lookAhead).toBe(0); expect(a.ppqn).toBe(960); a.init(); a.setTracks([]); a.addTrack(makeTrack("t1")); a.updateTrack("t1", makeTrack("t1")); a.setLoop(0, 1, true); a.setMasterVolume(0.5); a.setTrackVolume("t1", 0.7); a.setTrackMute("t1", false); a.setTrackSolo("t1", false); a.setTrackPan("t1", -0.3); a.setTempo(140); a.seek(0.25); a.pause(); a.stop(); expect(a.getCurrentTime()).toBe(0); expect(a.ticksToSeconds(960)).toBeCloseTo(60 / 140, 5); expect(a.secondsToTicks(60 / 140)).toBeCloseTo(960, 1); expect(a.masterVolume).toBe(0.5); expect(a.loop.enabled).toBe(true); expect(a.busRegistry).toBeDefined(); expect(a.maxEffectiveLatencySamples).toBe(0); a.removeTrack("t1"); a.dispose(); a.dispose(); }); it("each track hosts one PluginChain bound to a shared BusRegistry", { timeout: 5000, }, () => { const a = new ServerTimelineAdapter({ outputPath: join(dir, "x.wav") }); a.setTracks([makeTrack("a"), makeTrack("b")]); const pub = a.declareBusPublish("a", "kick", 2); a.declareBusSubscribe("b", "kick"); expect(a.busRegistry.get("kick")).toBe(pub.bus); a.dispose(); }); it("bus cycle is rejected with AUDIO_ENGINE_SERVER.BUS_CYCLE_DETECTED", { timeout: 5000, }, () => { const a = new ServerTimelineAdapter({ outputPath: join(dir, "x.wav") }); a.setTracks([makeTrack("a"), makeTrack("b")]); a.declareBusPublish("a", "x", 2); a.declareBusSubscribe("b", "x"); a.declareBusPublish("b", "y", 2); expect(() => a.declareBusSubscribe("a", "y")).toThrowError( expect.objectContaining({ code: AUDIO_ENGINE_SERVER_CODES.BUS_CYCLE_DETECTED, }) ); a.dispose(); }); it("ambient changes propagate into ChainContext on the next block", { timeout: 5000, }, () => { const a = new ServerTimelineAdapter({ outputPath: join(dir, "x.wav") }); a.setTracks([makeTrack("t1")]); a.setTrackPan("t1", -0.5); // No public chain accessor; verify via setter not throwing and // currentTime still 0. expect(a.getCurrentTime()).toBe(0); a.dispose(); }); it("play renders the timeline to the configured output file", { timeout: 30_000, }, async () => { const path = join(dir, "render.wav"); const a = new ServerTimelineAdapter({ outputPath: path, format: "wav", sampleRate: 44_100, }); a.init(); a.setTracks([makeTrack("t1")]); await a.render({ startTime: 0, endTime: 0.05 }); const s = await stat(path); expect(s.size).toBeGreaterThan(0); a.dispose(); }); it("render throws when outputPath is not configured", { timeout: 5000, }, async () => { const a = new ServerTimelineAdapter(); await expect(a.render()).rejects.toMatchObject({ code: AUDIO_ENGINE_SERVER_CODES.INVALID_OPTIONS, }); }); it("render rejects invalid options", { timeout: 5000 }, async () => { const a = new ServerTimelineAdapter({ outputPath: join(dir, "x.wav") }); await expect( a.render({ startTime: -1 } as unknown as { startTime?: number }) ).rejects.toMatchObject({ code: AUDIO_ENGINE_SERVER_CODES.INVALID_OPTIONS, }); }); it("mid-render abort removes the partial output file", { timeout: 30_000, }, async () => { const path = join(dir, "abort.wav"); const a = new ServerTimelineAdapter({ outputPath: path, sampleRate: 44_100, }); a.setTracks([makeTrack("a"), makeTrack("b")]); const ctrl = new AbortController(); ctrl.abort(); await expect(a.render({ signal: ctrl.signal })).rejects.toMatchObject({ code: AUDIO_ENGINE_SERVER_CODES.ABORTED, }); await expect(stat(path)).rejects.toThrow(); a.dispose(); }); it("addTrack is idempotent — duplicate ids are ignored", { timeout: 5000, }, () => { const a = new ServerTimelineAdapter({ outputPath: join(dir, "x.wav") }); a.addTrack(makeTrack("t1")); a.addTrack(makeTrack("t1")); a.dispose(); }); it("removeTrack throws for unknown tracks", { timeout: 5000 }, () => { const a = new ServerTimelineAdapter({ outputPath: join(dir, "x.wav") }); expect(() => a.removeTrack("ghost")).toThrowError( expect.objectContaining({ code: AUDIO_ENGINE_SERVER_CODES.INVALID_OPTIONS, }) ); a.dispose(); }); it("setTrackVolume throws for unknown tracks", { timeout: 5000 }, () => { const a = new ServerTimelineAdapter({ outputPath: join(dir, "x.wav") }); expect(() => a.setTrackVolume("ghost", 1)).toThrowError( expect.objectContaining({ code: AUDIO_ENGINE_SERVER_CODES.INVALID_OPTIONS, }) ); a.dispose(); }); it("methods after dispose throw", { timeout: 5000 }, () => { const a = new ServerTimelineAdapter({ outputPath: join(dir, "x.wav") }); a.dispose(); expect(() => a.addTrack(makeTrack("t1"))).toThrowError( expect.objectContaining({ code: AUDIO_ENGINE_SERVER_CODES.INVALID_OPTIONS, }) ); }); it("play kicks off a background render", { timeout: 30_000 }, async () => { const path = join(dir, "play.wav"); const a = new ServerTimelineAdapter({ outputPath: path, sampleRate: 44_100, }); a.setTracks([makeTrack("t1")]); a.play(0); // Allow the background render task to settle. await new Promise((r) => setTimeout(r, 200)); const s = await stat(path); expect(s.size).toBeGreaterThan(0); a.dispose(); }); it("setTrackMute / setTrackSolo / setTempo propagate to chain ambient", { timeout: 5000, }, () => { const a = new ServerTimelineAdapter({ outputPath: join(dir, "x.wav") }); a.setTracks([makeTrack("t1")]); a.setTrackMute("t1", true); a.setTrackSolo("t1", true); a.setTempo(150); a.seek(1.5); expect(a.getCurrentTime()).toBe(1.5); a.dispose(); }); it("declareBusPublish throws for unknown tracks", { timeout: 5000 }, () => { const a = new ServerTimelineAdapter({ outputPath: join(dir, "x.wav") }); expect(() => a.declareBusPublish("ghost", "kick")).toThrowError( expect.objectContaining({ code: AUDIO_ENGINE_SERVER_CODES.INVALID_OPTIONS, }) ); a.dispose(); }); it("declareBusSubscribe throws for unknown tracks", { timeout: 5000, }, () => { const a = new ServerTimelineAdapter({ outputPath: join(dir, "x.wav") }); expect(() => a.declareBusSubscribe("ghost", "kick")).toThrowError( expect.objectContaining({ code: AUDIO_ENGINE_SERVER_CODES.INVALID_OPTIONS, }) ); a.dispose(); }); it("render rethrows non-abort errors without invoking unlink", { timeout: 30_000, }, async () => { // Inject an fs whose writeFile fails — render's sink.save raises // IO_FAILED. The catch block sees (instanceof AES) but // (code !== ABORTED) so the unlink-cleanup branch must be skipped. let unlinkCalls = 0; const a = new ServerTimelineAdapter({ outputPath: join(dir, "fail.wav"), sampleRate: 44_100, fs: { readFile: async () => new Uint8Array(0), writeFile: async () => { throw new Error("ENOSPC"); }, mkdir: async () => undefined, stat: async () => ({ size: 0, isFile: true }), unlink: async () => { unlinkCalls++; }, createReadStream: () => new ReadableStream(), createWriteStream: () => new WritableStream(), }, }); a.setTracks([makeTrack("t1")]); await expect( a.render({ startTime: 0, endTime: 0.001 }) ).rejects.toMatchObject({ code: AUDIO_ENGINE_SERVER_CODES.IO_FAILED, }); expect(unlinkCalls).toBe(0); a.dispose(); }); it("setTracks replaces an existing track set", { timeout: 5000 }, () => { const a = new ServerTimelineAdapter({ outputPath: join(dir, "x.wav") }); a.setTracks([makeTrack("t1"), makeTrack("t2")]); // Second call must remove t1+t2 and install u1 — exercises the // existing-tracks unwind branch in setTracks. a.setTracks([makeTrack("u1")]); expect(() => a.declareBusPublish("u1", "kick")).not.toThrow(); expect(() => a.declareBusPublish("t1", "kick")).toThrowError( expect.objectContaining({ code: AUDIO_ENGINE_SERVER_CODES.INVALID_OPTIONS, }) ); a.dispose(); }); it("threads rootDir into the underlying sink path sanitiser", { timeout: 30_000, }, async () => { // A rooted adapter rejects traversal-escaping outputs at sink time. const a = new ServerTimelineAdapter({ outputPath: "../escape.wav", sampleRate: 44_100, rootDir: dir, }); a.setTracks([makeTrack("t1")]); await expect( a.render({ startTime: 0, endTime: 0.001 }) ).rejects.toMatchObject({ code: AUDIO_ENGINE_SERVER_CODES.INVALID_PATH, }); a.dispose(); }); it("forwards an injected CodecRegistry to the underlying sink", { timeout: 30_000, }, async () => { const { CodecRegistry, WavCodec } = await import( "@suaveplan/audio-codecs" ); const reg = new CodecRegistry({ codecs: [WavCodec] }); const path = join(dir, "reg.wav"); const a = new ServerTimelineAdapter({ outputPath: path, sampleRate: 44_100, registry: reg, }); a.setTracks([makeTrack("t1")]); await a.render({ startTime: 0, endTime: 0.002 }); const s = await stat(path); expect(s.size).toBeGreaterThan(0); a.dispose(); }); it("addPlugin propagates latency through maxEffectiveLatencySamples", { timeout: 5000, }, () => { const a = new ServerTimelineAdapter({ outputPath: join(dir, "x.wav") }); a.setTracks([makeTrack("t1"), makeTrack("t2")]); a.addPlugin("t1", makePlugin("p1", 128)); a.addPlugin("t2", makePlugin("p2", 256)); expect(a.maxEffectiveLatencySamples).toBe(256); const removed = a.removePlugin("t2", "p2"); expect(removed?.id).toBe("p2"); expect(a.maxEffectiveLatencySamples).toBe(128); a.dispose(); }); it("addPlugin and removePlugin reject unknown tracks", { timeout: 5000, }, () => { const a = new ServerTimelineAdapter({ outputPath: join(dir, "x.wav") }); expect(() => a.addPlugin("ghost", makePlugin("p"))).toThrowError( expect.objectContaining({ code: AUDIO_ENGINE_SERVER_CODES.INVALID_OPTIONS, }) ); expect(() => a.removePlugin("ghost", "p")).toThrowError( expect.objectContaining({ code: AUDIO_ENGINE_SERVER_CODES.INVALID_OPTIONS, }) ); a.dispose(); }); it("play with no time argument and pause with a time argument", { timeout: 30_000, }, async () => { const path = join(dir, "play-pause.wav"); const a = new ServerTimelineAdapter({ outputPath: path, sampleRate: 44_100, }); a.setTracks([makeTrack("t1")]); a.play(); // no time arg → exercises `time !== undefined` false branch a.pause(0.001); // time arg → exercises `time !== undefined` true branch expect(a.getCurrentTime()).toBe(0.001); a.stop(); // no time arg → `time ?? 0` right branch expect(a.getCurrentTime()).toBe(0); // Settle any background render kicked off by play(). await new Promise((r) => setTimeout(r, 100)); a.dispose(); }); it("render with no startTime defaults to zero and renders muted tracks silently", { timeout: 30_000, }, async () => { const path = join(dir, "muted.wav"); const a = new ServerTimelineAdapter({ outputPath: path, sampleRate: 44_100, }); const muted = { ...makeTrack("t1"), muted: true }; a.setTracks([muted]); await a.render({}); // no startTime → `?? 0` right branch; muted → skip chain.process const s = await stat(path); expect(s.size).toBeGreaterThan(0); a.dispose(); }); it("render accepts a non-aborted signal and completes normally", { timeout: 30_000, }, async () => { // Non-aborted signal wires addEventListener('abort', ...) — the // `else` branch on L288 in server-adapter.ts. const path = join(dir, "live.wav"); const a = new ServerTimelineAdapter({ outputPath: path, sampleRate: 44_100, }); a.setTracks([makeTrack("t1")]); const ctrl = new AbortController(); await a.render({ signal: ctrl.signal, startTime: 0, endTime: 0.005 }); const s = await stat(path); expect(s.size).toBeGreaterThan(0); a.dispose(); }); it("forwards abort events from the caller signal into the internal controller", { timeout: 30_000, }, async () => { // Register the abort listener (else-branch) and then fire it before // sink.save resolves, exercising the inline `() => ctrl.abort()` // callback. The async writeFile gives the microtask a chance to land // the abort signal before completion. const path = join(dir, "abort-event.wav"); let resolveWrite: (() => void) | undefined; const writePromise = new Promise((r) => { resolveWrite = r; }); const a = new ServerTimelineAdapter({ outputPath: path, sampleRate: 44_100, fs: { readFile: async () => new Uint8Array(0), writeFile: async () => { // Block writeFile so the outer abort lands before the // sink resolves. await writePromise; }, mkdir: async () => undefined, stat: async () => ({ size: 0, isFile: true }), unlink: async () => undefined, createReadStream: () => new ReadableStream(), createWriteStream: () => new WritableStream(), }, }); a.setTracks([makeTrack("t1")]); const ctrl = new AbortController(); const pending = a.render({ signal: ctrl.signal, startTime: 0, endTime: 0.001, }); // Yield once so render starts and registers the abort listener. await new Promise((r) => setTimeout(r, 0)); ctrl.abort(); // fires the inline `() => ctrl.abort()` callback. resolveWrite?.(); // Render does not throw — sink.save already completed before the // post-loop checkAborted would have fired — but the internal // controller has now observed the cascaded abort. await pending; a.dispose(); }); it("render cleans the partial output via successful unlink on abort", { timeout: 30_000, }, async () => { // Inject an fs whose unlink resolves cleanly — exercises the // happy-path side of the inner try/catch in the abort branch. let unlinkCalls = 0; const a = new ServerTimelineAdapter({ outputPath: join(dir, "abort-clean.wav"), sampleRate: 44_100, fs: { readFile: async () => new Uint8Array(0), writeFile: async () => undefined, mkdir: async () => undefined, stat: async () => ({ size: 0, isFile: true }), unlink: async () => { unlinkCalls++; }, createReadStream: () => new ReadableStream(), createWriteStream: () => new WritableStream(), }, }); a.setTracks([makeTrack("t1")]); const ctrl = new AbortController(); ctrl.abort(); await expect(a.render({ signal: ctrl.signal })).rejects.toMatchObject({ code: AUDIO_ENGINE_SERVER_CODES.ABORTED, }); expect(unlinkCalls).toBe(1); a.dispose(); }); });