Skip to content

Commit

Permalink
feat: add state variable filter
Browse files Browse the repository at this point in the history
  • Loading branch information
danigb committed Jul 4, 2024
1 parent d35ec18 commit 0c5217e
Show file tree
Hide file tree
Showing 11 changed files with 494 additions and 0 deletions.
14 changes: 14 additions & 0 deletions packages/state-variable-filter/README.md
Original file line number Diff line number Diff line change
@@ -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" })]);
```
35 changes: 35 additions & 0 deletions packages/state-variable-filter/package.json
Original file line number Diff line number Diff line change
@@ -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": "[email protected]",
"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"
}
}
Original file line number Diff line number Diff line change
@@ -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,
]
`;
Original file line number Diff line number Diff line change
@@ -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",
},
]
`;
23 changes: 23 additions & 0 deletions packages/state-variable-filter/src/filter.test.ts
Original file line number Diff line number Diff line change
@@ -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();
});
});
83 changes: 83 additions & 0 deletions packages/state-variable-filter/src/filter.ts
Original file line number Diff line number Diff line change
@@ -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 };
}
96 changes: 96 additions & 0 deletions packages/state-variable-filter/src/index.ts
Original file line number Diff line number Diff line change
@@ -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<void> {
(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<void> {
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<StateVariableFilterParams> = {}
): 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;
}
1 change: 1 addition & 0 deletions packages/state-variable-filter/src/processor.ts
Original file line number Diff line number Diff line change
@@ -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<n.length;o++)i=(n[o]-d*f-m)*x,l=e*i+f,h=e*l+m,f=e*i+l,m=e*l+h,p[o]=t===1?h:t===3?i:l}return{fill:V}}var b=class extends AudioWorkletProcessor{static parameterDescriptors=[{name:"frequency",defaultValue:1e3,minValue:16,maxValue:2e4,automationRate:"k-rate"},{name:"resonance",defaultValue:.5,minValue:0,maxValue:40,automationRate:"k-rate"}];u;r;constructor(){super(),this.u=g(sampleRate),this.r=!0,this.port.onmessage=t=>{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);})();`;
Loading

0 comments on commit 0c5217e

Please sign in to comment.