diff --git a/packages/state-variable-filter/README.md b/packages/state-variable-filter/README.md new file mode 100644 index 0000000..42f07ea --- /dev/null +++ b/packages/state-variable-filter/README.md @@ -0,0 +1,14 @@ +# @synthlet/state-variable-filter + +> State Variable Filter module for [synthlet](https://github.com/danigb/synthlet) + +## Usage + +If you're using synthlet, this filter is included by default and exposed by `patch.filter`: + +```ts +import { Patch } from "synthlet"; + +const patch = new Patch(); +await patch.create([patch.osc(), patch.filter({ type: "lowpass" })]); +``` diff --git a/packages/state-variable-filter/package.json b/packages/state-variable-filter/package.json new file mode 100644 index 0000000..2617f42 --- /dev/null +++ b/packages/state-variable-filter/package.json @@ -0,0 +1,35 @@ +{ + "name": "@synthlet/state-variable-filter", + "version": "0.0.0", + "description": "State Variable Filter module for synthlet", + "keywords": [ + "modular", + "synthesizer", + "filter", + "synthlet" + ], + "main": "dist/index.js", + "module": "dist/index.mjs", + "types": "dist/index.d.ts", + "files": [ + "dist" + ], + "author": "danigb@gmail.com", + "license": "MIT", + "publishConfig": { + "access": "public" + }, + "devDependencies": { + "@types/jest": "^29.5.11", + "jest": "^29.7.0", + "ts-jest": "^29.1.1" + }, + "jest": { + "preset": "ts-jest" + }, + "scripts": { + "worklet": "esbuild src/worklet.ts --bundle --minify | sed -e 's/^/export const PROCESSOR = \\`/' -e 's/$/\\`;/' > src/processor.ts", + "lib": "tsup src/index.ts --sourcemap --dts --format esm,cjs", + "build": "npm run worklet && npm run lib" + } +} diff --git a/packages/state-variable-filter/src/__snapshots__/filter.test.ts.snap b/packages/state-variable-filter/src/__snapshots__/filter.test.ts.snap new file mode 100644 index 0000000..81e306c --- /dev/null +++ b/packages/state-variable-filter/src/__snapshots__/filter.test.ts.snap @@ -0,0 +1,76 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`SVFilter filters the signal 1`] = ` +Float32Array [ + -1, + 1, + -1, + 1, + -1, + 1, + -1, + 1, + -1, + 1, + -1, + 1, + -1, + 1, + -1, + 1, + -1, + 1, + -1, + 1, +] +`; + +exports[`SVFilter filters the signal 2`] = ` +Float32Array [ + -7.058988571166992, + -96.1963882446289, + -933.3245239257812, + -7950.83740234375, + -63193.6796875, + -481030.5, + -3555095.5, + -25716442, + -183016032, + -1285889280, + -8941918208, + -61654106112, + -422081658880, + -2872091344896, + -19441708433408, + -131008733118464, + -879299460595712, + -5880895244337152, + -39209108632502270, + -260680291251650560, +] +`; + +exports[`SVFilter filters the signal 3`] = ` +Float32Array [ + -1728730658831859700, + -11437933590730506000, + -75519349223376030000, + -497665280641740050000, + -3.2738075880989456e+21, + -2.150135330517005e+22, + -1.4100311966854957e+23, + -9.233990443912205e+23, + -6.03935217634656e+24, + -3.9452060773814655e+25, + -2.574315262255496e+26, + -1.6780219466165328e+27, + -1.0927115680685831e+28, + -7.109042947525591e+28, + -4.621019964675439e+29, + -3.001284075478557e+30, + -1.9477740115635436e+31, + -1.263136567931854e+32, + -8.18576013175832e+32, + -5.3012721012231e+33, +] +`; diff --git a/packages/state-variable-filter/src/__snapshots__/worklet.test.ts.snap b/packages/state-variable-filter/src/__snapshots__/worklet.test.ts.snap new file mode 100644 index 0000000..37916a7 --- /dev/null +++ b/packages/state-variable-filter/src/__snapshots__/worklet.test.ts.snap @@ -0,0 +1,20 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`ProcessorNode has parameter descriptors 1`] = ` +[ + { + "automationRate": "k-rate", + "defaultValue": 1000, + "maxValue": 20000, + "minValue": 16, + "name": "frequency", + }, + { + "automationRate": "k-rate", + "defaultValue": 0.5, + "maxValue": 40, + "minValue": 0, + "name": "resonance", + }, +] +`; diff --git a/packages/state-variable-filter/src/filter.test.ts b/packages/state-variable-filter/src/filter.test.ts new file mode 100644 index 0000000..83906b2 --- /dev/null +++ b/packages/state-variable-filter/src/filter.test.ts @@ -0,0 +1,23 @@ +import { SVFilter, SVFilterType } from "./filter"; + +describe("SVFilter", () => { + it("filters the signal", () => { + const filter = SVFilter(20, SVFilterType.LowPass); + const input = new Float32Array(20); + for (let i = 0; i < input.length; i++) { + input[i] = i % 2 ? 1 : -1; + } + expect(input).toMatchSnapshot(); + const output = new Float32Array(20); + filter.fill(input, output, { + frequency: [10], + resonance: [0.5], + }); + expect(output).toMatchSnapshot(); + filter.fill(input, output, { + frequency: [5], + resonance: [0.5], + }); + expect(output).toMatchSnapshot(); + }); +}); diff --git a/packages/state-variable-filter/src/filter.ts b/packages/state-variable-filter/src/filter.ts new file mode 100644 index 0000000..190a0d8 --- /dev/null +++ b/packages/state-variable-filter/src/filter.ts @@ -0,0 +1,83 @@ +/** + * Limit the value between min and max + * @param value + * @param min + * @param max + * @returns + */ +export function clamp(value: number, min: number, max: number): number { + return Math.max(min, Math.min(max, value)); +} + +export enum SVFilterType { + LowPass = 1, + BandPass = 2, + HighPass = 3, +} + +export type Inputs = { + frequency: number[]; + resonance: number[]; +}; + +/** + * A State Variable Filter following the Andy Simper's implementation described in http://www.cytomic.com/files/dsp/SvfLinearTrapOptimised2.pdf + * + * Various implementations: + * https://github.com/Ardour/ardour/blob/71e049202c017c0a546f39b455bdc9e4be182f06/libs/plugins/a-eq.lv2/a-eq.c + * https://github.com/SoundStacks/cmajor/blob/main/standard_library/std_library_filters.cmajor#L157 + */ +export function SVFilter(sampleRate: number, type = SVFilterType.LowPass) { + // Params + let $frequency = 1000; + let $resonance = 0.5; + + const period = 0.5 / sampleRate; + const pi2 = 2 * Math.PI; + const freqCoef = 2.0 * sampleRate * period; + + let d = 0; + let a = 0; + let g1 = 0; + let z0 = 0; + let z1 = 0; + let high = 0; + let band = 0; + let low = 0; + + function read(inputs: Inputs) { + if ( + inputs.frequency[0] !== $frequency || + inputs.resonance[0] !== $resonance + ) { + $frequency = inputs.frequency[0]; + $resonance = inputs.resonance[0]; + const cutoffFreq = clamp($frequency, 16, sampleRate / 2); + const Q = clamp($resonance, 0.025, 40); + const invQ = 1.0 / Q; + // TODO: review - something weird here (sampleRate * period = 0.5) + a = 2.0 * sampleRate * Math.tan(pi2 * period * cutoffFreq) * period; + d = 1.0 / (1.0 + invQ * a + a * a); + g1 = a + invQ; + } + } + + function fill(input: Float32Array, output: Float32Array, inputs: Inputs) { + read(inputs); + for (let i = 0; i < input.length; i++) { + const x = input[i]; + high = (x - g1 * z0 - z1) * d; + band = a * high + z0; + low = a * band + z1; + z0 = a * high + band; + z1 = a * band + low; + output[i] = + type === SVFilterType.LowPass + ? low + : type === SVFilterType.HighPass + ? high + : band; + } + } + return { fill }; +} diff --git a/packages/state-variable-filter/src/index.ts b/packages/state-variable-filter/src/index.ts new file mode 100644 index 0000000..d0996d7 --- /dev/null +++ b/packages/state-variable-filter/src/index.ts @@ -0,0 +1,96 @@ +import { PROCESSOR } from "./processor"; + +export type ProcessorOptions = { + mode?: "generator" | "modulator"; +}; + +export type StateVariableFilterParams = { + frequency: number; + resonance: number; +}; + +export type StateVariableFilterWorkletNode = AudioWorkletNode & { + frequency: AudioParam; + resonance: AudioParam; +}; + +const PARAM_NAMES = ["frequency", "resonance"] as const; + +export function getProcessorName() { + return "StateVariableFilterWorkletProcessor"; // Can't import from worklet because globals +} + +export function getWorkletUrl() { + const blob = new Blob([PROCESSOR], { type: "application/javascript" }); + return URL.createObjectURL(blob); +} + +function isSupported(audioContext: AudioContext): boolean { + return ( + audioContext.audioWorklet && + typeof audioContext.audioWorklet.addModule === "function" + ); +} + +function isRegistered(audioContext: AudioContext): boolean { + return (audioContext.audioWorklet as any).__SYNTHLET_ADSR_REGISTERED__; +} + +function register(audioContext: AudioContext): Promise { + (audioContext.audioWorklet as any).__SYNTHLET_ADSR_REGISTERED__ = true; + return audioContext.audioWorklet.addModule(getWorkletUrl()); +} + +/** + * Register the AudioWorklet processor in the AudioContext. + * No matter how many times is called, it will register only once. + * + * @param audioContext + * @returns A promise that resolves when the processor is registered + */ +export function registerStateVariableFilterWorkletOnce( + audioContext: AudioContext +): Promise { + if (!isSupported(audioContext)) throw Error("AudioWorklet not supported"); + return isRegistered(audioContext) + ? Promise.resolve() + : register(audioContext); +} + +/** + * Create a State Variable Filter worklet + * + * @param audioContext - The AudioContext + * @returns StateVariableFilterAudioWorkletNode + */ +export function createStateVariableFilter( + audioContext: AudioContext +): StateVariableFilterWorkletNode { + return createWorkletNode(audioContext, {}); +} + +function createWorkletNode( + audioContext: AudioContext, + processorOptions: ProcessorOptions, + params: Partial = {} +): StateVariableFilterWorkletNode { + const node = new AudioWorkletNode(audioContext, getProcessorName(), { + numberOfInputs: 1, + numberOfOutputs: 1, + processorOptions, + }) as StateVariableFilterWorkletNode; + + for (const paramName of PARAM_NAMES) { + const param = node.parameters.get(paramName)!; + const value = params[paramName as keyof StateVariableFilterParams]; + if (typeof value === "number") param.value = value; + node[paramName] = param; + } + let _disconnect = node.disconnect.bind(node); + node.disconnect = (param?, output?, input?) => { + node.port.postMessage({ type: "DISCONNECT" }); + // @ts-ignore + return _disconnect(param, output, input); + }; + return node; +} diff --git a/packages/state-variable-filter/src/processor.ts b/packages/state-variable-filter/src/processor.ts new file mode 100644 index 0000000..5443b19 --- /dev/null +++ b/packages/state-variable-filter/src/processor.ts @@ -0,0 +1 @@ +export const PROCESSOR = `"use strict";(()=>{function P(r,t,a){return Math.max(t,Math.min(a,r))}function g(r,t=1){let a=1e3,u=.5,s=.5/r,c=2*Math.PI,F=2*r*s,x=0,e=0,d=0,f=0,m=0,i=0,l=0,h=0;function q(n){if(n.frequency[0]!==a||n.resonance[0]!==u){a=n.frequency[0],u=n.resonance[0];let p=P(a,16,r/2),o=1/P(u,.025,40);e=2*r*Math.tan(c*s*p)*s,x=1/(1+o*e+e*e),d=e+o}}function V(n,p,y){q(y);for(let o=0;o{switch(t.data.type){case"STOP":this.r=!1;break}}}process(t,a,u){let s=t[0][0],c=a[0][0];return s&&c&&this.u.fill(s,c,u),this.r}};registerProcessor("StateVariableFilterWorkletProcessor",b);})();`; diff --git a/packages/state-variable-filter/src/test-utils.ts b/packages/state-variable-filter/src/test-utils.ts new file mode 100644 index 0000000..a0210d9 --- /dev/null +++ b/packages/state-variable-filter/src/test-utils.ts @@ -0,0 +1,52 @@ +export function createWorkletTestContext(sampleRate = 10) { + // @ts-ignore + global.sampleRate = sampleRate; + // @ts-ignore + global.AudioWorkletProcessor = class AudioWorkletNodeStub { + port: { + postMessage: jest.Mock; + onmessage: jest.Mock; + }; + + constructor() { + this.port = { + postMessage: jest.fn(), + onmessage: jest.fn(), + }; + } + }; + // @ts-ignore + global.registerProcessor = jest.fn(); // Mock registerProcessor +} + +function createInputsOutputs( + options: { ins?: number; outs?: number; length?: number } = {} +) { + const inCount = options.ins ?? 1; + const outCount = options.outs ?? 1; + const length = options.length ?? 10; + const inputs: Float32Array[][] = []; + const outputs: Float32Array[][] = []; + + for (let i = 0; i < inCount; i++) { + inputs.push([new Float32Array(length)]); + } + for (let i = 0; i < outCount; i++) { + outputs.push([new Float32Array(length)]); + } + return { inputs, outputs }; +} + +type Worklet = { + process: ( + inputs: Float32Array[][], + outputs: Float32Array[][], + parameters: any + ) => boolean; +}; + +export function runProcessMono(worklet: Worklet, size: number, params: any) { + const { inputs, outputs } = createInputsOutputs({ length: size }); + worklet.process(inputs, outputs, params); + return outputs[0][0]; +} diff --git a/packages/state-variable-filter/src/worklet.test.ts b/packages/state-variable-filter/src/worklet.test.ts new file mode 100644 index 0000000..0e9bdd4 --- /dev/null +++ b/packages/state-variable-filter/src/worklet.test.ts @@ -0,0 +1,43 @@ +import { getProcessorName } from "./index"; + +describe("ProcessorNode", () => { + let Processor: any; + const sampleRate = 40; + + beforeAll(async () => { + createWorkletTestContext(sampleRate); + Processor = (await import("./worklet")).Processor; + }); + + it("registers processor", () => { + expect(global.registerProcessor).toHaveBeenCalledWith( + getProcessorName(), + Processor + ); + }); + + it("has parameter descriptors", () => { + expect(Processor.parameterDescriptors).toMatchSnapshot(); + }); +}); + +function createWorkletTestContext(sampleRate = 10) { + // @ts-ignore + global.sampleRate = sampleRate; + // @ts-ignore + global.AudioWorkletProcessor = class AudioWorkletNodeStub { + port: { + postMessage: jest.Mock; + onmessage: jest.Mock; + }; + + constructor() { + this.port = { + postMessage: jest.fn(), + onmessage: jest.fn(), + }; + } + }; + // @ts-ignore + global.registerProcessor = jest.fn(); // Mock registerProcessor +} diff --git a/packages/state-variable-filter/src/worklet.ts b/packages/state-variable-filter/src/worklet.ts new file mode 100644 index 0000000..d710184 --- /dev/null +++ b/packages/state-variable-filter/src/worklet.ts @@ -0,0 +1,51 @@ +import { SVFilter } from "./filter"; + +export class Processor extends AudioWorkletProcessor { + static parameterDescriptors = [ + { + name: "frequency", + defaultValue: 1000, + minValue: 16, + maxValue: 20000, + automationRate: "k-rate", + }, + { + name: "resonance", + defaultValue: 0.5, + minValue: 0, + maxValue: 40, + automationRate: "k-rate", + }, + ]; + + u: ReturnType; + r: boolean; // running + + constructor() { + super(); + this.u = SVFilter(sampleRate); + this.r = true; + this.port.onmessage = (event) => { + switch (event.data.type) { + case "STOP": + this.r = false; + break; + } + }; + } + + process( + inputs: Float32Array[][], + outputs: Float32Array[][], + parameters: any + ) { + let input = inputs[0][0]; + let output = outputs[0][0]; + if (input && output) { + this.u.fill(input, output, parameters); + } + return this.r; + } +} + +registerProcessor("StateVariableFilterWorkletProcessor", Processor);