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; readonly io: PluginIo; readonly parameters: ReadonlyArray; 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; public readonly parameters: ReadonlyArray; public readonly parameterIndex: ReadonlyMap; public readonly io: PluginIo; public readonly capabilities: PluginCapabilities; public get name(): string { return this.metadata.id; } private readonly schedules: ReadonlyMap; 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 { 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 { 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); } }