Initial Commit
This commit is contained in:
@@ -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");
|
||||
});
|
||||
});
|
||||
331
packages/media/audio/audio-engine-browser/tests/fake-context.ts
Normal file
331
packages/media/audio/audio-engine-browser/tests/fake-context.ts
Normal 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,
|
||||
};
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user