Files
suaveplan/packages/media/audio-fx/audio-fx-plugin-base/src/base/audio-fx-plugin.ts
2026-05-27 23:47:53 +01:00

294 lines
9.1 KiB
TypeScript

import type { BlockContext, ProcessorConfig } from "@suaveplan/audio-core";
import type { ZodType } from "zod";
import { AUDIO_FX_CODES, AudioFxError } from "../error-codes/error-codes.js";
import type {
AudioFxPlugin,
ChainContext,
PluginMetadata,
UnsubscribeFn,
} from "./audio-fx-plugin.types.js";
import {
capabilitiesAgreeWithMembers,
defaultCapabilities,
} from "./capabilities.js";
import type {
OptionalPluginMembers,
PluginCapabilities,
} from "./capabilities.types.js";
import type { ParameterCurve, PluginParameter } from "./parameter.types.js";
import { ParameterSchedule } from "./parameter-schedule.js";
import {
assertChannelMatch,
assertNotAborted,
requireAutomatable,
requireParameter,
} from "./plugin-internals/index.js";
import type { PluginIo } from "./plugin-io.types.js";
import {
applyPresetParameters,
assertPresetMatchesPluginType,
assertPresetVersionSupported,
buildPresetSnapshot,
clamp,
parsePresetSnapshot,
validatePresetData,
} from "./preset.js";
import type { PresetSnapshot } from "./preset.types.js";
/** Construction options for `AbstractAudioFxPlugin`. See `audio-fx-plugin.md`. */
export interface AbstractAudioFxPluginOptions {
readonly metadata: PluginMetadata;
readonly schema: ZodType<unknown>;
readonly io: PluginIo;
readonly parameters: ReadonlyArray<PluginParameter>;
readonly capabilities?: PluginCapabilities;
readonly supportedSchemaVersion: number;
}
/** Reference base; concrete plugins implement `processBlock` + state hooks. */
export abstract class AbstractAudioFxPlugin implements AudioFxPlugin {
public readonly metadata: PluginMetadata;
public readonly schema: ZodType<unknown>;
public readonly parameters: ReadonlyArray<PluginParameter>;
public readonly parameterIndex: ReadonlyMap<string, PluginParameter>;
public readonly io: PluginIo;
public readonly capabilities: PluginCapabilities;
public get name(): string {
return this.metadata.id;
}
private readonly schedules: ReadonlyMap<string, ParameterSchedule>;
private readonly latencyListeners = new Set<(samples: number) => void>();
private currentLatencySamples: number;
private readonly supportedSchemaVersion: number;
private initialised = false;
private nowSec = 0;
protected chainContext?: ChainContext;
protected config?: ProcessorConfig;
public constructor(options: AbstractAudioFxPluginOptions) {
this.metadata = options.metadata;
this.schema = options.schema;
this.io = options.io;
this.parameters = options.parameters;
this.parameterIndex = new Map(options.parameters.map((p) => [p.id, p]));
this.capabilities =
options.capabilities ?? defaultCapabilities(options.io);
this.currentLatencySamples = this.capabilities.latencySamples;
this.supportedSchemaVersion = options.supportedSchemaVersion;
this.schedules = new Map(
options.parameters.map((p) => [
p.id,
new ParameterSchedule(p.value),
])
);
if (
!capabilitiesAgreeWithMembers(
this.capabilities,
this as unknown as OptionalPluginMembers
)
) {
throw new AudioFxError(
AUDIO_FX_CODES.INVALID_OPTIONS,
"audio-fx.capability-method-mismatch",
{ context: { pluginId: this.metadata.id } }
);
}
}
protected setupState(
_config: ProcessorConfig,
_chain: ChainContext,
_opts?: { signal?: AbortSignal }
): Promise<void> | void {
return undefined;
}
protected abstract processBlock(
input: Float32Array[],
output: Float32Array[],
ctx: BlockContext
): void;
protected resetState(): void {}
protected disposeState(): void {}
protected getPresetData(): unknown {
return undefined;
}
protected applyPresetData(_data: unknown): void {}
/** Sample-accurate parameter value at current time. */
protected parameterValue(id: string): number {
requireParameter(this.parameterIndex, id, this.metadata.id);
const sched = this.schedules.get(id)!;
const transportTime =
this.chainContext!.ambient.transport.transportTimeSec;
return sched.valueAtBlockOffset(this.nowSec, transportTime);
}
/** Emit a latency change to every listener. */
protected notifyLatencyChanged(samples: number): void {
if (samples === this.currentLatencySamples) {
return;
}
this.currentLatencySamples = samples;
for (const listener of this.latencyListeners) {
listener(samples);
}
}
public async setup(
config: ProcessorConfig,
chain: ChainContext,
opts?: { signal?: AbortSignal }
): Promise<void> {
assertNotAborted(
opts?.signal,
this.metadata.id,
"audio-fx.setup-pre-aborted"
);
this.config = config;
this.chainContext = chain;
await Promise.resolve(this.setupState(config, chain, opts));
assertNotAborted(
opts?.signal,
this.metadata.id,
"audio-fx.setup-mid-aborted"
);
this.initialised = true;
}
public process(
input: Float32Array[],
output: Float32Array[],
ctx: BlockContext
): void {
if (!this.initialised) {
throw new AudioFxError(
AUDIO_FX_CODES.NOT_INITIALIZED,
"audio-fx.process-before-setup",
{ context: { pluginId: this.metadata.id } }
);
}
const channels = this.config!.channels;
const inSpec = this.io.inputs[0]?.count;
const outSpec = this.io.outputs[0]?.count;
const expectedInput = typeof inSpec === "number" ? inSpec : channels;
const expectedOutput = typeof outSpec === "number" ? outSpec : channels;
assertChannelMatch(
input,
output,
expectedInput,
expectedOutput,
this.metadata.id
);
const nowOpt = ctx.opts["nowSec"];
if (typeof nowOpt === "number") {
this.nowSec = nowOpt;
}
this.processBlock(input, output, ctx);
}
public reset(): void {
const transportTime =
this.chainContext?.ambient.transport.transportTimeSec ?? 0;
for (const sched of this.schedules.values()) {
sched.reset(transportTime);
}
this.resetState();
}
public dispose(): void {
this.initialised = false;
this.latencyListeners.clear();
this.disposeState();
}
public getLatencySamples(): number {
return this.currentLatencySamples;
}
public onLatencyChanged(
listener: (samples: number) => void
): UnsubscribeFn {
this.latencyListeners.add(listener);
return () => {
this.latencyListeners.delete(listener);
};
}
public getParameter(id: string): number {
const sched = this.schedules.get(id);
if (sched === undefined) {
throw new AudioFxError(
AUDIO_FX_CODES.UNKNOWN_PARAMETER,
"audio-fx.unknown-parameter",
{ context: { paramId: id, pluginId: this.metadata.id } }
);
}
return sched.value;
}
public setParameter(id: string, value: number): void {
const param = requireParameter(
this.parameterIndex,
id,
this.metadata.id
);
this.schedules.get(id)!.set(clamp(value, param.min, param.max));
}
public setParameterCurve(id: string, curve: ParameterCurve): void {
const param = requireAutomatable(
this.parameterIndex,
id,
this.metadata.id
);
const target = clamp(curve.value, param.min, param.max);
this.schedules
.get(id)!
.scheduleCurve({ ...curve, value: target }, this.nowSec);
}
public setParameterAt(
id: string,
value: number,
transportTimeSec: number
): void {
const param = requireAutomatable(
this.parameterIndex,
id,
this.metadata.id
);
this.schedules
.get(id)!
.scheduleAt(clamp(value, param.min, param.max), transportTimeSec);
}
public getPreset(): PresetSnapshot {
return buildPresetSnapshot(
this.metadata.id,
this.supportedSchemaVersion,
this.schedules,
this.getPresetData()
);
}
public applyPreset(preset: PresetSnapshot): void {
const validated = parsePresetSnapshot(preset);
assertPresetMatchesPluginType(validated, this.metadata.id);
assertPresetVersionSupported(validated, this.supportedSchemaVersion);
validatePresetData(this.schema, validated.data, this.metadata.id);
applyPresetParameters(
validated.parameters,
this.schedules,
this.parameterIndex
);
this.applyPresetData(validated.data);
}
}