From 5a0a49ca898eca78810782c152f8048eef76586f Mon Sep 17 00:00:00 2001 From: danigb Date: Sat, 27 Jul 2024 15:01:26 +0200 Subject: [PATCH] Feat: PolyBLEP oscillator (#13) --- README.md | 29 ++- package-lock.json | 24 ++ packages/adsr/src/index.ts | 2 +- packages/noise/README.md | 4 +- packages/polyblep-oscillator/README.md | 22 ++ packages/polyblep-oscillator/package.json | 36 +++ .../src/__snapshots__/worklet.test.ts.snap | 216 ++++++++++++++++++ .../polyblep-oscillator/src/index.test.ts | 88 +++++++ packages/polyblep-oscillator/src/index.ts | 154 +++++++++++++ packages/polyblep-oscillator/src/polyblep.ts | 116 ++++++++++ packages/polyblep-oscillator/src/processor.ts | 1 + .../polyblep-oscillator/src/worklet.test.ts | 102 +++++++++ packages/polyblep-oscillator/src/worklet.ts | 64 ++++++ packages/state-variable-filter/src/index.ts | 4 +- .../state-variable-filter/src/processor.ts | 2 +- packages/state-variable-filter/src/worklet.ts | 4 +- packages/synthlet/CHANGELOG.md | 9 +- packages/synthlet/README.md | 7 + packages/synthlet/package.json | 3 +- packages/synthlet/src/index.ts | 6 + 20 files changed, 874 insertions(+), 19 deletions(-) create mode 100644 packages/polyblep-oscillator/README.md create mode 100644 packages/polyblep-oscillator/package.json create mode 100644 packages/polyblep-oscillator/src/__snapshots__/worklet.test.ts.snap create mode 100644 packages/polyblep-oscillator/src/index.test.ts create mode 100644 packages/polyblep-oscillator/src/index.ts create mode 100644 packages/polyblep-oscillator/src/polyblep.ts create mode 100644 packages/polyblep-oscillator/src/processor.ts create mode 100644 packages/polyblep-oscillator/src/worklet.test.ts create mode 100644 packages/polyblep-oscillator/src/worklet.ts create mode 100644 packages/synthlet/README.md diff --git a/README.md b/README.md index a1c16aa..1c629f7 100644 --- a/README.md +++ b/README.md @@ -7,17 +7,18 @@ Collection of synth modules implemented as AudioWorklets. ```ts import { registerSynthletOnce, - createAdsr, - createWavetableOscillator, + createVca, + createStateVariableFilter + createPolyblepOscillator, } from "synthlet"; const audioContext = new AudioContext(); await registerSynthletOnce(audioContext); // Simplest synth: Oscillator -> Filter -> Amplifier -const osc = createWavetableOscillator(audioContext); -const filter = createStateVariableFilter(audioContext); -const vca = createVca(audioContext); +const osc = createPolyblepOscillator(audioContext, { type: "saw", frequency: 440 }); +const filter = createStateVariableFilter(audioContext, { type: "lowpass", frequency: 4000 }); +const vca = createVca(audioContext, { attack: 0.1, release: 0.5 }); osc.connect(filter).connect(vca).connect(audioContext.destination); @@ -44,7 +45,21 @@ npm i @synthlet/adsr ## Documentation -- [ADSR](/packages/adsr) +#### Oscillators + +- [PolyblepOscillator](/packages/polyblep-oscilllator) +- [WavetableOscillator](/packages/wavetable-oscilllator) - [Noise](/packages/noise) + +#### Envelopes + +- [ADSR](/packages/adsr) + +#### Modulators + - [StateVariableFilter](/packages/state-variable-filter) -- [WavetableOscillator](/packages/wavetable-oscilllator) + +## References + +- https://github.com/BillyDM/awesome-audio-dsp +- https://paulbatchelor.github.io/sndkit/algos/ diff --git a/package-lock.json b/package-lock.json index 55b1b49..04f4660 100644 --- a/package-lock.json +++ b/package-lock.json @@ -2320,6 +2320,10 @@ "resolved": "packages/noise", "link": true }, + "node_modules/@synthlet/polyblep-oscillator": { + "resolved": "packages/polyblep-oscillator", + "link": true + }, "node_modules/@synthlet/state-variable-filter": { "resolved": "packages/state-variable-filter", "link": true @@ -8256,7 +8260,17 @@ "ts-jest": "^29.1.1" } }, + "packages/polyblep-oscillator": { + "version": "0.1.0", + "license": "MIT", + "devDependencies": { + "@types/jest": "^29.5.11", + "jest": "^29.7.0", + "ts-jest": "^29.1.1" + } + }, "packages/state-variable-filter": { + "name": "@synthlet/state-variable-filter", "version": "0.1.0", "license": "MIT", "devDependencies": { @@ -8271,6 +8285,7 @@ "dependencies": { "@synthlet/adsr": "^0.1.0", "@synthlet/noise": "^0.1.0", + "@synthlet/polyblep-oscillator": "^0.1.0", "@synthlet/state-variable-filter": "^0.1.0", "@synthlet/wavetable-oscillator": "^0.1.0" } @@ -9940,6 +9955,14 @@ "ts-jest": "^29.1.1" } }, + "@synthlet/polyblep-oscillator": { + "version": "file:packages/polyblep-oscillator", + "requires": { + "@types/jest": "^29.5.11", + "jest": "^29.7.0", + "ts-jest": "^29.1.1" + } + }, "@synthlet/state-variable-filter": { "version": "file:packages/state-variable-filter", "requires": { @@ -13464,6 +13487,7 @@ "requires": { "@synthlet/adsr": "^0.1.0", "@synthlet/noise": "^0.1.0", + "@synthlet/polyblep-oscillator": "^0.1.0", "@synthlet/state-variable-filter": "^0.1.0", "@synthlet/wavetable-oscillator": "^0.1.0" } diff --git a/packages/adsr/src/index.ts b/packages/adsr/src/index.ts index 426d073..5a9602a 100644 --- a/packages/adsr/src/index.ts +++ b/packages/adsr/src/index.ts @@ -111,7 +111,7 @@ export function createAdsr( audioContext: AudioContext, params?: Partial ): AudioWorkletNode { - return createWorkletNode(audioContext, { mode: "generator" }); + return createWorkletNode(audioContext, { mode: "generator" }, params); } function createWorkletNode( diff --git a/packages/noise/README.md b/packages/noise/README.md index b93384a..2a8d80d 100644 --- a/packages/noise/README.md +++ b/packages/noise/README.md @@ -3,15 +3,13 @@ > Noise generator module for [synthlet](https://github.com/danigb/synthlet) ```ts -import { registerNoiseWorkletOnce, createWhiteNoise } from "@synthlet/adsr"; +import { registerNoiseWorkletOnce, createWhiteNoise } from "@synthlet/noise"; const audioContext = new AudioContext(); await registerNoiseWorkletOnce(audioContext); -// Create a VCA (Voltage Controlled Amplifier) const noise = createWhiteNoise(); -// Connect the noise to the output noise.connect(audioContext.destination); ``` diff --git a/packages/polyblep-oscillator/README.md b/packages/polyblep-oscillator/README.md new file mode 100644 index 0000000..1bdca92 --- /dev/null +++ b/packages/polyblep-oscillator/README.md @@ -0,0 +1,22 @@ +# @synthlet/polyblep-oscillator + +> An Oscillator module implemented with PolyBLEP algorithm for [synthlet](https://github.com/danigb/synthlet) + +An oscillator implemented using the PolyBLEP (Polynomial Band-limited Step Functions -Valimaki et. al 2010) algorithm. + +```js +import {} from "@synthlet/polyblep-oscillator"; +``` + +## Install + +```bash +npm i @synthlet/polyblep-oscillator +``` + +## References + +- https://paulbatchelor.github.io/sndkit/blep/ +- https://www.martin-finke.de/articles/audio-plugins-018-polyblep-oscillator/ +- https://www.metafunction.co.uk/post/all-about-digital-oscillators-part-2-blits-bleps +- https://github.com/cmajor-lang/cmajor/blob/main/standard_library/std_library_oscillators.cmajor diff --git a/packages/polyblep-oscillator/package.json b/packages/polyblep-oscillator/package.json new file mode 100644 index 0000000..2feda75 --- /dev/null +++ b/packages/polyblep-oscillator/package.json @@ -0,0 +1,36 @@ +{ + "name": "@synthlet/polyblep-oscillator", + "version": "0.1.0", + "description": "PolyBLEP Oscillator module for synthlet", + "keywords": [ + "modular", + "synthesizer", + "poly-blep", + "oscillator", + "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/polyblep-oscillator/src/__snapshots__/worklet.test.ts.snap b/packages/polyblep-oscillator/src/__snapshots__/worklet.test.ts.snap new file mode 100644 index 0000000..87342b0 --- /dev/null +++ b/packages/polyblep-oscillator/src/__snapshots__/worklet.test.ts.snap @@ -0,0 +1,216 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`ProcessorNode generates saw 1`] = ` +[ + [ + Float32Array [ + 0, + -0.8999999761581421, + -0.800000011920929, + -0.699999988079071, + -0.6000000238418579, + -0.5, + -0.4000000059604645, + -0.30000001192092896, + -0.20000000298023224, + -0.10000000149011612, + -1.1102230246251565e-16, + 0.10000000149011612, + 0.20000000298023224, + 0.30000001192092896, + 0.4000000059604645, + 0.5, + 0.6000000238418579, + 0.699999988079071, + 0.800000011920929, + 0.8999999761581421, + -8.43769498715119e-15, + -0.8999999761581421, + -0.800000011920929, + -0.699999988079071, + -0.6000000238418579, + -0.5, + -0.4000000059604645, + -0.30000001192092896, + -0.20000000298023224, + -0.10000000149011612, + 4.440892098500626e-16, + 0.10000000149011612, + 0.20000000298023224, + 0.30000001192092896, + 0.4000000059604645, + 0.5, + 0.6000000238418579, + 0.699999988079071, + 0.800000011920929, + 0.8999999761581421, + ], + ], +] +`; + +exports[`ProcessorNode generates sine 1`] = ` +[ + [ + Float32Array [ + 0, + 0.04997916892170906, + 0.0998334139585495, + 0.14943812787532806, + 0.19866932928562164, + 0.24740396440029144, + 0.29552021622657776, + 0.34289780259132385, + 0.3894183337688446, + 0.4349655210971832, + 0.4794255495071411, + 0.5226872563362122, + 0.5646424889564514, + 0.605186402797699, + 0.6442176699638367, + 0.681638777256012, + 0.7173560857772827, + 0.7512804269790649, + 0.7833269238471985, + 0.81341552734375, + 2.220446049250313e-16, + 0.04997916892170906, + 0.0998334139585495, + 0.14943812787532806, + 0.19866932928562164, + 0.24740396440029144, + 0.29552021622657776, + 0.34289780259132385, + 0.3894183337688446, + 0.4349655210971832, + 0.4794255495071411, + 0.5226872563362122, + 0.5646424889564514, + 0.605186402797699, + 0.6442176699638367, + 0.681638777256012, + 0.7173560857772827, + 0.7512804269790649, + 0.7833269238471985, + 0.81341552734375, + ], + ], +] +`; + +exports[`ProcessorNode generates square 1`] = ` +[ + [ + Float32Array [ + 0, + -1, + -1, + -1, + -1, + -1, + -1, + -1, + -1, + -1, + -2, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + -8.881784197001252e-15, + -1, + -1, + -1, + -1, + -1, + -1, + -1, + -1, + -1, + 8.881784197001252e-15, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + ], + ], +] +`; + +exports[`ProcessorNode generates triangle 1`] = ` +[ + [ + Float32Array [ + 0, + -1.600000023841858, + -1.6000726222991943, + -1.6000726222991943, + -1.6000726222991943, + -1.6000726222991943, + -1.6000726222991943, + -1.6000726222991943, + -1.6000726222991943, + -1.6000726222991943, + -3.2000725269317627, + 1.5998547077178955, + 1.6000726222991943, + 1.6000726222991943, + 1.6000726222991943, + 1.6000726222991943, + 1.6000726222991943, + 1.6000726222991943, + 1.6000726222991943, + 1.6000726222991943, + 0.00007264318264788017, + -1.600000023841858, + -1.6000726222991943, + -1.6000726222991943, + -1.6000726222991943, + -1.6000726222991943, + -1.6000726222991943, + -1.6000726222991943, + -1.6000726222991943, + -1.6000726222991943, + -0.00007264318264788017, + 1.600000023841858, + 1.6000726222991943, + 1.6000726222991943, + 1.6000726222991943, + 1.6000726222991943, + 1.6000726222991943, + 1.6000726222991943, + 1.6000726222991943, + 1.6000726222991943, + ], + ], +] +`; + +exports[`ProcessorNode has parameter descriptors 1`] = ` +[ + { + "automationRate": "k-rate", + "defaultValue": 1, + "maxValue": 3, + "minValue": 0, + "name": "waveform", + }, + { + "automationRate": "k-rate", + "defaultValue": 1000, + "maxValue": 20000, + "minValue": 16, + "name": "frequency", + }, +] +`; diff --git a/packages/polyblep-oscillator/src/index.test.ts b/packages/polyblep-oscillator/src/index.test.ts new file mode 100644 index 0000000..06378a7 --- /dev/null +++ b/packages/polyblep-oscillator/src/index.test.ts @@ -0,0 +1,88 @@ +import { + createPolyblepOscillator, + registerPolyblepOscillatorWorkletOnce, +} from "./index"; + +describe("PolyblepOscillator", () => { + it("registers only once", () => { + const context = new AudioContextMock(); + + registerPolyblepOscillatorWorkletOnce(context.asAudioContext()); + expect(context.audioWorklet?.addModule).toHaveBeenCalledTimes(1); + registerPolyblepOscillatorWorkletOnce(context.asAudioContext()); + expect(context.audioWorklet?.addModule).toHaveBeenCalledTimes(1); + }); + + it("creates the worklet node with default parameters", () => { + // @ts-ignore + global.AudioWorkletNode = AudioWorkletNodeMock; + const node = createPolyblepOscillator( + new AudioContextMock().asAudioContext() + ); + expect(node.frequency.value).toBe(440); + expect(node.waveform.value).toBe(1); + expect(node.type).toEqual("sawtooth"); + }); + + it("changes the waveform using type property", () => { + // @ts-ignore + global.AudioWorkletNode = AudioWorkletNodeMock; + const node = createPolyblepOscillator( + new AudioContextMock().asAudioContext() + ); + expect(node.waveform.value).toBe(1); + node.type = "triangle"; + expect(node.waveform.value).toBe(3); + }); +}); + +class AudioContextMock { + audioWorklet?: { addModule: jest.Mock }; + + constructor(worklets = true) { + if (worklets) { + this.audioWorklet = { + addModule: jest.fn(), + }; + } + } + + asAudioContext(): AudioContext { + return this as unknown as AudioContext; + } +} + +class AudioWorkletNodeMock { + params: Record; + parameters: { get(name: string): void }; + disconnect = jest.fn(); + + constructor( + public context: any, + public processorName: any, + public options: any + ) { + this.params = { + frequency: new ParamMock(), + waveform: new ParamMock(), + }; + + const get = (name: string): ParamMock => { + return this.params[name]; + }; + + this.parameters = { + get, + }; + } +} + +class ParamMock { + value = 0; + values: { value: number; time: number }[] = []; + + setValueAtTime(value: number, time: number) { + this.values.push({ value, time }); + if (time === 0) this.value = value; + } +} diff --git a/packages/polyblep-oscillator/src/index.ts b/packages/polyblep-oscillator/src/index.ts new file mode 100644 index 0000000..2b5acad --- /dev/null +++ b/packages/polyblep-oscillator/src/index.ts @@ -0,0 +1,154 @@ +import { PROCESSOR } from "./processor"; + +export type PolyblepWaveformType = "sine" | "sawtooth" | "square" | "triangle"; + +export type PolyblepOscillatorOptions = { + type: PolyblepWaveformType; + waveform: 0 | 1 | 2 | 3; + frequency: number; +}; + +type PolyblepOscillatorParams = { + waveform: number; + frequency: number; +}; + +export function getProcessorName() { + return "PolyBLEPWorkletProcessor"; // Can't import from worklet because globals +} + +const PARAM_NAMES = ["waveform", "frequency"] as const; + +/** + * A PolyBLEP Oscillator AudioWorkletNode + */ +export type PolyblepWorkletNode = AudioWorkletNode & { + type: PolyblepWaveformType; + waveform: AudioParam; + frequency: AudioParam; +}; + +export function getWorkletUrl() { + const blob = new Blob([PROCESSOR], { type: "application/javascript" }); + return URL.createObjectURL(blob); +} + +/** + * Create a PolyblepOscillator worklet node + * @param audioContext + * @param options + * @returns PolyblepWorkletNode + */ +export function createPolyblepOscillator( + audioContext: AudioContext, + options: Partial = {} +): PolyblepWorkletNode { + const params = optionsToParams(options); + const node = new AudioWorkletNode(audioContext, getProcessorName(), { + numberOfInputs: 1, + numberOfOutputs: 1, + }) as PolyblepWorkletNode; + + for (const paramName of PARAM_NAMES) { + const param = node.parameters.get(paramName)!; + node[paramName] = param; + const value = params[paramName as keyof PolyblepOscillatorParams]; + param.setValueAtTime(value, 0); + } + + Object.defineProperty(node, "type", { + get() { + return waveformToType(this.waveform.value); + }, + set(value: string) { + let waveform = typeToWaveform(value); + if (waveform !== undefined) this.waveform.setValueAtTime(waveform, 0); + }, + }); + + 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; +} + +/** + * Register the PolyBLEP Oscillator 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 registerPolyblepOscillatorWorkletOnce( + audioContext: AudioContext +): Promise { + if (!isSupported(audioContext)) throw Error("AudioWorklet not supported"); + return isRegistered(audioContext) + ? Promise.resolve() + : register(audioContext); +} + +//// UTILITIES //// + +function typeToWaveform(type?: string): number | undefined { + return type === "sine" + ? 0 + : type === "sawtooth" + ? 1 + : type === "square" + ? 2 + : type === "triangle" + ? 3 + : undefined; +} + +function waveformToType(waveform: number): string | undefined { + return waveform === 0 + ? "sine" + : waveform === 1 + ? "sawtooth" + : waveform === 2 + ? "square" + : waveform === 3 + ? "triangle" + : undefined; +} + +function optionsToParams( + options: Partial +): PolyblepOscillatorParams { + if (options.type !== undefined && options.waveform !== undefined) { + throw Error( + "PolyblepOscillator error - only waveform or type can be defined in options" + ); + } + const frequency = + typeof options.frequency === "number" ? options.frequency : 440; + + const waveform = + typeof options.waveform === "number" + ? options.waveform + : typeToWaveform(options.type) ?? 1; + + return { frequency, waveform }; +} + +function isSupported(audioContext: AudioContext): boolean { + return ( + audioContext.audioWorklet && + typeof audioContext.audioWorklet.addModule === "function" + ); +} + +function isRegistered(audioContext: AudioContext): boolean { + return (audioContext.audioWorklet as any).__SYNTHLET_POLYBLEP_REGISTERED__; +} + +function register(audioContext: AudioContext): Promise { + (audioContext.audioWorklet as any).__SYNTHLET_POLYBLEP_REGISTERED__ = true; + return audioContext.audioWorklet.addModule(getWorkletUrl()); +} diff --git a/packages/polyblep-oscillator/src/polyblep.ts b/packages/polyblep-oscillator/src/polyblep.ts new file mode 100644 index 0000000..ea77be6 --- /dev/null +++ b/packages/polyblep-oscillator/src/polyblep.ts @@ -0,0 +1,116 @@ +export class PolyBlep { + // Inverse of sample rate + onedsr: number; + + // Phasor state (phase and increment) + freq: number; + phs: number; + inc: number; + + // Leaky integrator (for saw) + prev: number; + + // DC blocker (for saw) + R: number; + x: number; + y: number; + + constructor(sampleRate: number) { + this.onedsr = 1 / sampleRate; + this.freq = 0.0; + this.phs = 0.0; + this.inc = 0.0; + this.prev = 0.0; + // The DC blocking coefficient R has been chosen to be close to 0.99 (a common DC blocker coefficient value) when the sampling rate is 44.1kHz. + this.R = Math.exp(-1.0 / (0.0025 * sampleRate)); + this.x = 0; + this.y = 0; + } + + setFreq(frequency: number) { + if (this.freq !== frequency) { + this.freq = frequency; + this.inc = frequency * this.onedsr; + } + } + + sine() { + // compute sin + const out = Math.sin(this.phs); + + // advance phasor + this.phs += this.inc; + if (this.phs > 1.0) this.phs -= 1.0; + + return out; + } + + saw() { + // compute saw + const out = polyblepSawtooth(this.phs, this.inc); + + // advance phasor + this.phs += this.inc; + if (this.phs > 1.0) this.phs -= 1.0; + + return out; + } + + square() { + // compute square + const out = polyblepSquare(this.phs, this.inc); + + // advance phasor + this.phs += this.inc; + if (this.phs > 1.0) this.phs -= 1.0; + + return out; + } + + triangle() { + // compute square + let val = polyblepSquare(this.phs, this.inc); + // scale and integrate + val *= 4.0 / this.freq; + val += this.prev; + this.prev = val; + // dc blocker + this.y = val - this.x + this.R * this.y; + this.x = val; + const out = this.y * 0.8; + + // advance phasor + this.phs += this.inc; + if (this.phs > 1.0) this.phs -= 1.0; + + return out; + } +} + +/* + * This algorithm centers around a tiny function called polyblep. + * It applies two different polynomial curves if the position is at the beginning or ends of the position. + */ +function polyblep(phase: number, increment: number): number { + if (phase < increment) { + const p = phase / increment; + return p + p - p * p - 1; + } else if (phase > 1 - increment) { + const p = (phase - 1) / increment; + return p + p + p * p + 1; + } else { + return 0; + } +} + +function polyblepSquare(phase: number, increment: number): number { + return ( + (phase < 0.5 ? -1 : 1) - + polyblep(phase, increment) + + polyblep((phase + 0.5) % 1, increment) + ); +} + +function polyblepSawtooth(phase: number, increment: number): number { + return phase * 2 - 1 - polyblep(phase, increment); +} diff --git a/packages/polyblep-oscillator/src/processor.ts b/packages/polyblep-oscillator/src/processor.ts new file mode 100644 index 0000000..1ca3c49 --- /dev/null +++ b/packages/polyblep-oscillator/src/processor.ts @@ -0,0 +1 @@ +export const PROCESSOR = `"use strict";(()=>{var r=class{onedsr;freq;phs;inc;prev;R;x;y;constructor(t){this.onedsr=1/t,this.freq=0,this.phs=0,this.inc=0,this.prev=0,this.R=Math.exp(-1/(.0025*t)),this.x=0,this.y=0}setFreq(t){this.freq!==t&&(this.freq=t,this.inc=t*this.onedsr)}sine(){let t=Math.sin(this.phs);return this.phs+=this.inc,this.phs>1&&(this.phs-=1),t}saw(){let t=m(this.phs,this.inc);return this.phs+=this.inc,this.phs>1&&(this.phs-=1),t}square(){let t=p(this.phs,this.inc);return this.phs+=this.inc,this.phs>1&&(this.phs-=1),t}triangle(){let t=p(this.phs,this.inc);t*=4/this.freq,t+=this.prev,this.prev=t,this.y=t-this.x+this.R*this.y,this.x=t;let i=this.y*.8;return this.phs+=this.inc,this.phs>1&&(this.phs-=1),i}};function h(s,t){if(s1-t){let i=(s-1)/t;return i+i+i*i+1}else return 0}function p(s,t){return(s<.5?-1:1)-h(s,t)+h((s+.5)%1,t)}function m(s,t){return s*2-1-h(s,t)}var n=class extends AudioWorkletProcessor{static parameterDescriptors=[{name:"waveform",defaultValue:1,minValue:0,maxValue:3,automationRate:"k-rate"},{name:"frequency",defaultValue:1e3,minValue:16,maxValue:2e4,automationRate:"k-rate"}];r;p;w;constructor(){super(),this.r=!0,this.p=new r(sampleRate),this.w=[this.p.sine.bind(this.p),this.p.saw.bind(this.p),this.p.square.bind(this.p),this.p.triangle.bind(this.p)],this.port.onmessage=t=>{switch(t.data.type){case"DISCONNECT":this.r=!1;break}}}process(t,i,o){let a=o.frequency[0],l=o.waveform[0],c=this.w[l];this.p.setFreq(a);let u=i[0][0];for(let e=0;e { + 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(); + }); + + it("generates sine", () => { + const processor = new Processor(); + const { inputs, outputs } = createInputsOutputs({ length: sampleRate }); + const params = { + waveform: [0], + frequency: [2], + }; + processor.process(inputs, outputs, params); + expect(outputs).toMatchSnapshot(); + }); + it("generates saw", () => { + const processor = new Processor(); + const { inputs, outputs } = createInputsOutputs({ length: sampleRate }); + const params = { + waveform: [1], + frequency: [2], + }; + processor.process(inputs, outputs, params); + expect(outputs).toMatchSnapshot(); + }); + it("generates square", () => { + const processor = new Processor(); + const { inputs, outputs } = createInputsOutputs({ length: sampleRate }); + const params = { + waveform: [2], + frequency: [2], + }; + processor.process(inputs, outputs, params); + expect(outputs).toMatchSnapshot(); + }); + it("generates triangle", () => { + const processor = new Processor(); + const { inputs, outputs } = createInputsOutputs({ length: sampleRate }); + const params = { + waveform: [3], + frequency: [2], + }; + processor.process(inputs, outputs, params); + expect(outputs).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 +} + +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 }; +} diff --git a/packages/polyblep-oscillator/src/worklet.ts b/packages/polyblep-oscillator/src/worklet.ts new file mode 100644 index 0000000..d2773b1 --- /dev/null +++ b/packages/polyblep-oscillator/src/worklet.ts @@ -0,0 +1,64 @@ +import { PolyBlep } from "./polyblep"; + +type Waveform = () => number; + +export class Processor extends AudioWorkletProcessor { + static parameterDescriptors = [ + { + name: "waveform", + defaultValue: 1, + minValue: 0, + maxValue: 3, + automationRate: "k-rate", + }, + { + name: "frequency", + defaultValue: 1000, + minValue: 16, + maxValue: 20000, + automationRate: "k-rate", + }, + ]; + + r: boolean; // running + p: PolyBlep; + w: Waveform[]; + + constructor() { + super(); + this.r = true; + this.p = new PolyBlep(sampleRate); + this.w = [ + this.p.sine.bind(this.p), + this.p.saw.bind(this.p), + this.p.square.bind(this.p), + this.p.triangle.bind(this.p), + ]; + this.port.onmessage = (event) => { + switch (event.data.type) { + case "DISCONNECT": + this.r = false; + break; + } + }; + } + + process( + _inputs: Float32Array[][], + outputs: Float32Array[][], + parameters: any + ) { + const freq = parameters["frequency"][0]; + const waveform = parameters["waveform"][0]; + const fn = this.w[waveform]; + this.p.setFreq(freq); + const output = outputs[0][0]; + for (let i = 0; i < output.length; i++) { + output[i] = fn(); + } + + return this.r; + } +} + +registerProcessor("PolyBLEPWorkletProcessor", Processor); diff --git a/packages/state-variable-filter/src/index.ts b/packages/state-variable-filter/src/index.ts index d0996d7..79c3ff7 100644 --- a/packages/state-variable-filter/src/index.ts +++ b/packages/state-variable-filter/src/index.ts @@ -82,9 +82,9 @@ function createWorkletNode( 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; + const value = params[paramName as keyof StateVariableFilterParams]; + if (typeof value === "number") param.setValueAtTime(value, 0); } let _disconnect = node.disconnect.bind(node); node.disconnect = (param?, output?, input?) => { diff --git a/packages/state-variable-filter/src/processor.ts b/packages/state-variable-filter/src/processor.ts index 5443b19..dfab9c1 100644 --- a/packages/state-variable-filter/src/processor.ts +++ b/packages/state-variable-filter/src/processor.ts @@ -1 +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);})();`; +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,l=2*Math.PI,F=2*r*s,x=0,e=0,d=0,f=0,m=0,i=0,c=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(l*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],l=a[0][0];return s&&l&&this.u.fill(s,l,u),this.r}};registerProcessor("StateVariableFilterWorkletProcessor",b);})();`; diff --git a/packages/state-variable-filter/src/worklet.ts b/packages/state-variable-filter/src/worklet.ts index d710184..c8da885 100644 --- a/packages/state-variable-filter/src/worklet.ts +++ b/packages/state-variable-filter/src/worklet.ts @@ -39,8 +39,8 @@ export class Processor extends AudioWorkletProcessor { outputs: Float32Array[][], parameters: any ) { - let input = inputs[0][0]; - let output = outputs[0][0]; + const input = inputs[0][0]; + const output = outputs[0][0]; if (input && output) { this.u.fill(input, output, parameters); } diff --git a/packages/synthlet/CHANGELOG.md b/packages/synthlet/CHANGELOG.md index eac3204..09f2596 100644 --- a/packages/synthlet/CHANGELOG.md +++ b/packages/synthlet/CHANGELOG.md @@ -1,10 +1,15 @@ # synthlet -## 0.1.0 +## 0.2.0 -Initial implementation of: +Packages: +- PolyBLEP Oscillator - ADSR - White Noise - State Variable Filter - Wavetable Oscillator + +## 0.1.0 + +Initial implementation diff --git a/packages/synthlet/README.md b/packages/synthlet/README.md new file mode 100644 index 0000000..cd4079b --- /dev/null +++ b/packages/synthlet/README.md @@ -0,0 +1,7 @@ +# Synthlet + +[![npm version](https://img.shields.io/npm/v/synthlet)](https://www.npmjs.com/package/synthlet) + +This is the _facade_ package that exports all other @synthlet packages just for convenience. + +Read [main readme](/README.md) for details. diff --git a/packages/synthlet/package.json b/packages/synthlet/package.json index 0237a68..45e93af 100644 --- a/packages/synthlet/package.json +++ b/packages/synthlet/package.json @@ -1,6 +1,6 @@ { "name": "synthlet", - "version": "0.1.0", + "version": "0.2.0", "description": "AudioWorklet synth modules", "keywords": [ "modular", @@ -22,6 +22,7 @@ "dependencies": { "@synthlet/adsr": "^0.1.0", "@synthlet/noise": "^0.1.0", + "@synthlet/polyblep-oscillator": "^0.1.0", "@synthlet/state-variable-filter": "^0.1.0", "@synthlet/wavetable-oscillator": "^0.1.0" }, diff --git a/packages/synthlet/src/index.ts b/packages/synthlet/src/index.ts index f8c797a..17498d2 100644 --- a/packages/synthlet/src/index.ts +++ b/packages/synthlet/src/index.ts @@ -1,10 +1,15 @@ import { registerAdsrWorkletOnce } from "@synthlet/adsr"; import { registerNoiseWorkletOnce } from "@synthlet/noise"; +import { registerPolyblepOscillatorWorkletOnce } from "@synthlet/polyblep-oscillator"; import { registerStateVariableFilterWorkletOnce } from "@synthlet/state-variable-filter/src"; import { registerWavetableOscillatorWorkletOnce } from "@synthlet/wavetable-oscillator"; export { createAdsr, createVca, registerAdsrWorkletOnce } from "@synthlet/adsr"; export { createWhiteNoise, registerNoiseWorkletOnce } from "@synthlet/noise"; +export { + createPolyblepOscillator, + registerPolyblepOscillatorWorkletOnce, +} from "@synthlet/polyblep-oscillator"; export { createStateVariableFilter, registerStateVariableFilterWorkletOnce, @@ -18,6 +23,7 @@ export function registerSynthletOnce(context: AudioContext) { return Promise.all([ registerAdsrWorkletOnce(context), registerNoiseWorkletOnce(context), + registerPolyblepOscillatorWorkletOnce(context), registerStateVariableFilterWorkletOnce(context), registerWavetableOscillatorWorkletOnce(context), ]);