Initial Commit
This commit is contained in:
@@ -0,0 +1,293 @@
|
||||
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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user