528 lines
19 KiB
TypeScript
528 lines
19 KiB
TypeScript
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();
|
|
});
|
|
});
|