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,40 @@
import * as fs from "node:fs";
import { join } from "node:path";
import { describe, expect, it } from "@suaveplan/testing/runner";
const PKG_ROOT = join(import.meta.dirname ?? __dirname, "..");
describe("exports", () => {
it("every feature module has a subpath export entry", {
timeout: 5000,
}, () => {
const pkg = JSON.parse(
fs.readFileSync(join(PKG_ROOT, "package.json"), "utf8")
) as { exports: Record<string, unknown> };
const required = [
".",
"./adapter/browser-adapter",
"./graph/web-audio-graph",
"./worklet/worklet-manager",
"./fallback/html5-fallback",
"./offline/offline-render",
];
for (const key of required) {
expect(Object.keys(pkg.exports)).toContain(key);
}
});
it("sideEffects is false", { timeout: 5000 }, () => {
const pkg = JSON.parse(
fs.readFileSync(join(PKG_ROOT, "package.json"), "utf8")
) as { sideEffects: boolean };
expect(pkg.sideEffects).toBe(false);
});
it("package version is 0.1.0", { timeout: 5000 }, () => {
const pkg = JSON.parse(
fs.readFileSync(join(PKG_ROOT, "package.json"), "utf8")
) as { version: string };
expect(pkg.version).toBe("0.1.0");
});
});

View File

@@ -0,0 +1,331 @@
/**
* Deterministic hand-rolled `BaseAudioContext`-shaped shim for tests.
*
* This is NOT a mock — it is a complete, in-memory implementation of
* the surface `WebAudioGraph`, `AudioWorkletManager`, and
* `BrowserTimelineAdapter` consume. Tests inject an instance and
* assert against its recorded graph state.
*/
interface ConnectRecord {
readonly target: FakeAudioNode;
readonly outputIndex?: number;
readonly inputIndex?: number;
}
function tagCtor(target: object, name: string): void {
Object.defineProperty(target, "constructor", {
value: { name },
configurable: true,
});
}
export class FakeAudioParam {
public value: number;
public readonly history: Array<{ value: number; time: number }> = [];
public constructor(initial = 1) {
this.value = initial;
}
public setValueAtTime(value: number, time: number): this {
this.value = value;
this.history.push({ value, time });
return this;
}
public linearRampToValueAtTime(value: number, time: number): this {
this.value = value;
this.history.push({ value, time });
return this;
}
public exponentialRampToValueAtTime(value: number, time: number): this {
this.value = value;
this.history.push({ value, time });
return this;
}
public cancelScheduledValues(_time: number): this {
return this;
}
}
export class FakeAudioNode {
public readonly connections: ConnectRecord[] = [];
public disconnected = false;
public connect(
target: FakeAudioNode,
outputIndex?: number,
inputIndex?: number
): FakeAudioNode {
const rec: ConnectRecord = {
target,
...(outputIndex !== undefined ? { outputIndex } : {}),
...(inputIndex !== undefined ? { inputIndex } : {}),
};
this.connections.push(rec);
return target;
}
public disconnect(target?: FakeAudioNode): void {
if (target === undefined) {
this.connections.length = 0;
this.disconnected = true;
return;
}
for (let i = this.connections.length - 1; i >= 0; i -= 1) {
if (this.connections[i].target === target) {
this.connections.splice(i, 1);
}
}
}
}
class GainNode extends FakeAudioNode {
public readonly gain = new FakeAudioParam(1);
}
class StereoPannerNode extends FakeAudioNode {
public readonly pan = new FakeAudioParam(0);
}
class BiquadFilterNode extends FakeAudioNode {
public readonly frequency = new FakeAudioParam(350);
public readonly Q = new FakeAudioParam(1);
public type: BiquadFilterType = "lowpass";
}
type BiquadFilterType =
| "lowpass"
| "highpass"
| "bandpass"
| "lowshelf"
| "highshelf"
| "peaking"
| "notch"
| "allpass";
class DelayNode extends FakeAudioNode {
public readonly delayTime = new FakeAudioParam(0);
}
class DynamicsCompressorNode extends FakeAudioNode {
public readonly threshold = new FakeAudioParam(-24);
public readonly ratio = new FakeAudioParam(12);
}
class ConvolverNode extends FakeAudioNode {
public buffer: unknown = null;
}
class AnalyserNode extends FakeAudioNode {
public fftSize = 2048;
}
class OscillatorNode extends FakeAudioNode {
public readonly frequency = new FakeAudioParam(440);
public start(_time?: number): void {}
public stop(_time?: number): void {}
}
class AudioBufferSourceNode extends FakeAudioNode {
public buffer: unknown = null;
public loop = false;
public readonly playbackRate = new FakeAudioParam(1);
public started = false;
public stopped = false;
public start(_time?: number, _offset?: number, _duration?: number): void {
this.started = true;
}
public stop(_time?: number): void {
this.stopped = true;
}
}
class ChannelSplitterNode extends FakeAudioNode {}
class ChannelMergerNode extends FakeAudioNode {}
class ScriptProcessorNode extends FakeAudioNode {
public onaudioprocess: ((e: unknown) => void) | null = null;
}
class AudioDestinationNode extends FakeAudioNode {}
class WorkletPort {
public readonly posted: unknown[] = [];
private readonly listeners: Array<(e: MessageEvent) => void> = [];
public postMessage(message: unknown, _transfer?: Transferable[]): void {
this.posted.push(message);
}
public addEventListener(
_type: "message",
listener: (e: MessageEvent) => void
): void {
this.listeners.push(listener);
}
public removeEventListener(
_type: "message",
listener: (e: MessageEvent) => void
): void {
const i = this.listeners.indexOf(listener);
if (i >= 0) this.listeners.splice(i, 1);
}
public emit(message: unknown): void {
const ev = { data: message } as MessageEvent;
for (const l of this.listeners) l(ev);
}
}
class FakeAudioWorkletNode extends FakeAudioNode {
public readonly port = new WorkletPort();
public readonly processorName: string;
public readonly options: unknown;
public constructor(
_context: unknown,
processorName: string,
options?: unknown
) {
super();
this.processorName = processorName;
this.options = options;
}
}
class FakeAudioBuffer {
public readonly numberOfChannels: number;
public readonly length: number;
public readonly sampleRate: number;
private readonly channels: Float32Array[];
public constructor(channels: number, length: number, sampleRate: number) {
this.numberOfChannels = channels;
this.length = length;
this.sampleRate = sampleRate;
this.channels = Array.from(
{ length: channels },
() => new Float32Array(length)
);
}
public getChannelData(ch: number): Float32Array {
return this.channels[ch]!;
}
}
class FakeAudioWorklet {
public readonly loaded = new Set<string>();
public failNext = false;
public async addModule(
url: string,
_options?: { credentials?: string }
): Promise<void> {
if (this.failNext) {
this.failNext = false;
throw new Error("addModule failure");
}
this.loaded.add(url);
}
}
export interface FakeContextOptions {
readonly sampleRate?: number;
readonly length?: number;
readonly withWorklet?: boolean;
readonly offline?: boolean;
}
export class FakeAudioContext {
public readonly sampleRate: number;
public readonly length: number;
public readonly destination: AudioDestinationNode;
public readonly audioWorklet: FakeAudioWorklet | undefined;
public readonly offline: boolean;
public state: "suspended" | "running" | "closed" = "suspended";
public currentTime = 0;
private renderResult: FakeAudioBuffer | null = null;
public rejectStartRendering: Error | null = null;
public constructor(options: FakeContextOptions = {}) {
this.sampleRate = options.sampleRate ?? 48000;
this.length = options.length ?? 0;
this.offline = options.offline ?? false;
this.destination = new AudioDestinationNode();
tagCtor(this.destination, "AudioDestinationNode");
this.audioWorklet =
options.withWorklet === false ? undefined : new FakeAudioWorklet();
}
public async resume(): Promise<void> {
this.state = "running";
}
public async close(): Promise<void> {
this.state = "closed";
}
public async startRendering(): Promise<FakeAudioBuffer> {
if (this.rejectStartRendering) throw this.rejectStartRendering;
this.renderResult ??= new FakeAudioBuffer(
2,
this.length || Math.ceil(this.sampleRate),
this.sampleRate
);
return this.renderResult;
}
public createGain(): GainNode {
return tag(new GainNode(), "GainNode");
}
public createStereoPanner(): StereoPannerNode {
return tag(new StereoPannerNode(), "StereoPannerNode");
}
public createBiquadFilter(): BiquadFilterNode {
return tag(new BiquadFilterNode(), "BiquadFilterNode");
}
public createDelay(_max?: number): DelayNode {
return tag(new DelayNode(), "DelayNode");
}
public createDynamicsCompressor(): DynamicsCompressorNode {
return tag(new DynamicsCompressorNode(), "DynamicsCompressorNode");
}
public createConvolver(): ConvolverNode {
return tag(new ConvolverNode(), "ConvolverNode");
}
public createAnalyser(): AnalyserNode {
return tag(new AnalyserNode(), "AnalyserNode");
}
public createOscillator(): OscillatorNode {
return tag(new OscillatorNode(), "OscillatorNode");
}
public createBufferSource(): AudioBufferSourceNode {
return tag(new AudioBufferSourceNode(), "AudioBufferSourceNode");
}
public createChannelSplitter(_n?: number): ChannelSplitterNode {
return tag(new ChannelSplitterNode(), "ChannelSplitterNode");
}
public createChannelMerger(_n?: number): ChannelMergerNode {
return tag(new ChannelMergerNode(), "ChannelMergerNode");
}
public createScriptProcessor(
_bufferSize?: number,
_inputs?: number,
_outputs?: number
): ScriptProcessorNode {
return tag(new ScriptProcessorNode(), "ScriptProcessorNode");
}
public createBuffer(
channels: number,
length: number,
sr: number
): FakeAudioBuffer {
return new FakeAudioBuffer(channels, length, sr);
}
}
function tag<T extends FakeAudioNode>(node: T, name: string): T {
Object.defineProperty(node, "constructor", {
value: { name },
configurable: true,
});
return node;
}
export {
AnalyserNode as FakeAnalyserNode,
AudioBufferSourceNode as FakeAudioBufferSourceNode,
BiquadFilterNode as FakeBiquadFilterNode,
DynamicsCompressorNode as FakeDynamicsCompressorNode,
FakeAudioBuffer,
FakeAudioWorklet,
FakeAudioWorkletNode,
GainNode as FakeGainNode,
ScriptProcessorNode as FakeScriptProcessorNode,
StereoPannerNode as FakeStereoPannerNode,
};

View File

@@ -0,0 +1,49 @@
import { TimelineEngine } from "@suaveplan/audio-timeline";
import { describe, expect, it } from "@suaveplan/testing/runner";
import { BrowserTimelineAdapter } from "../src/adapter/browser-adapter.js";
import { renderOffline } from "../src/offline/offline-render.js";
import { FakeAudioContext } from "./fake-context.js";
describe("BrowserTimelineAdapter integration", () => {
it("TimelineEngine drives BrowserTimelineAdapter end to end", {
timeout: 30000,
}, async () => {
const ctx = new FakeAudioContext();
const adapter = new BrowserTimelineAdapter({
context: ctx as never,
});
await adapter.init();
const timeline = new TimelineEngine();
timeline.setAdapter(adapter);
timeline.setTracks([
{
id: "t1",
name: "t1",
clips: [],
volume: 1,
muted: false,
soloed: false,
pan: 0,
},
]);
timeline.play();
expect(adapter.snapshotGraph().nodes["t1:track-gain"]).toBe("gain");
adapter.dispose();
});
it("offline render integration with TimelineEngine", {
timeout: 30000,
}, async () => {
const timeline = new TimelineEngine();
const buffer = await renderOffline(timeline, 1, 22050, {
contextFactory: (args) =>
new FakeAudioContext({
length: args.length,
sampleRate: args.sampleRate,
offline: true,
}),
});
expect(buffer.length).toBe(22050);
expect(buffer.sampleRate).toBe(22050);
});
});

View File

@@ -0,0 +1,67 @@
import * as fs from "node:fs";
import { join } from "node:path";
import { describe, expect, it } from "@suaveplan/testing/runner";
const PKG_ROOT = join(import.meta.dirname ?? __dirname, "..");
const DIST = join(PKG_ROOT, "dist");
function walk(dir: string, out: string[] = []): string[] {
for (const name of fs.readdirSync(dir)) {
const p = join(dir, name);
const st = fs.statSync(p);
if (st.isDirectory()) walk(p, out);
else if (p.endsWith(".js")) out.push(p);
}
return out;
}
describe("purity", () => {
it("dist references no Node globals", { timeout: 5000 }, () => {
const files = walk(DIST);
expect(files.length).toBeGreaterThan(0);
const banned = [/\bnode:fs\b/, /\bnode:path\b/, /\bnode:crypto\b/];
const hits: string[] = [];
for (const file of files) {
const content = fs.readFileSync(file, "utf8");
for (const rx of banned) {
if (rx.test(content)) hits.push(`${file}:${rx}`);
}
}
expect(hits).toEqual([]);
});
it("dependency set is limited to the declared workspace, standardized-audio-context, and zod", {
timeout: 5000,
}, () => {
const pkg = JSON.parse(
fs.readFileSync(join(PKG_ROOT, "package.json"), "utf8")
) as { dependencies: Record<string, string> };
const allowed = new Set([
"@suaveplan/audio-core",
"@suaveplan/audio-fx-plugin-base",
"@suaveplan/audio-timeline",
"@suaveplan/audio-transport",
"@suaveplan/error",
"@suaveplan/events",
"@opentelemetry/api",
"standardized-audio-context",
"tslib",
"zod",
]);
for (const dep of Object.keys(pkg.dependencies)) {
expect(allowed).toContain(dep);
}
});
it("importing the package mutates no global state", {
timeout: 5000,
}, async () => {
const before = new Set(Object.keys(globalThis));
await import("../src/index.js");
const after = new Set(Object.keys(globalThis));
for (const k of after) {
if (before.has(k)) continue;
expect(k).not.toMatch(/audio.engine.browser/i);
}
});
});