Initial Commit
This commit is contained in:
@@ -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();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user