Initial Commit

This commit is contained in:
2026-05-27 23:47:53 +01:00
parent fbc70f96e6
commit 9b28db724f
18103 changed files with 1600554 additions and 0 deletions

View File

@@ -0,0 +1,527 @@
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<void>((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();
});
});