294 lines
9.1 KiB
TypeScript
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);
|
|
}
|
|
}
|