Skip to content

Commit

Permalink
v0.8.0 - karplus-strong (#31)
Browse files Browse the repository at this point in the history
  • Loading branch information
danigb authored Sep 30, 2024
1 parent 1e709de commit 16aa1e0
Show file tree
Hide file tree
Showing 17 changed files with 477 additions and 40 deletions.
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,8 @@ This library wouldn't be possible with all the people writing books, blog posts
- [Cmajor](https://github.com/SoundStacks/cmajor)
- [VCVRack](https://github.com/VCVRack/Rack)
- [The Synthesis ToolKit](https://github.com/thestk/stk)
- [Surge synth](https://github.com/surge-synthesizer/surge)
- [Surge Rust](https://github.com/klebs6/surge-rs)
- https://github.com/jd-13/WE-Core
- https://github.com/mhetrick/nonlinearcircuits
- https://github.com/timowest/analogue
Expand Down
36 changes: 26 additions & 10 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

5 changes: 5 additions & 0 deletions packages/karplus-strong/CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
# @synthlet/karplus-strong-oscillator

## 0.1.0

- Initial release
5 changes: 5 additions & 0 deletions packages/karplus-strong/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
# @synthlet/karplus-strong-oscillator

> An oscillator based on Karplus-Strong synthesis
Part of [Synthlet](https://github.com/danigb/synthlet)
35 changes: 35 additions & 0 deletions packages/karplus-strong/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
{
"name": "@synthlet/karplus-strong",
"version": "0.0.0",
"description": "Karplus Strong generator audio worklet",
"keywords": [
"karplus-strong-oscillator",
"modular",
"synthesis",
"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"
}
}
119 changes: 119 additions & 0 deletions packages/karplus-strong/src/_worklet.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,119 @@
// DON'T EDIT THIS FILE unless inside scripts/_worklet.ts
// use ./scripts/copy_files.ts to copy this file to the right place
// the goal is to avoid external dependencies on packages

// A "Connector" is a function that takes an AudioContext and returns an AudioNode
// or an custom object with a connect method (that returns a disconnect method)
export type Connector<N extends AudioNode> = (context: AudioContext) => N;

export type ParamInput = number | Connector<AudioNode> | AudioNode;

type CreateWorkletOptions<N, P> = {
processorName: string;
paramNames: readonly string[];
workletOptions: (params: Partial<P>) => AudioWorkletNodeOptions;
postCreate?: (node: N) => void;
};

export type Disposable<N extends AudioNode> = N & { dispose: () => void };

export function createWorkletConstructor<
N extends AudioWorkletNode,
P extends Record<string, ParamInput>
>(options: CreateWorkletOptions<N, P>) {
return (
audioContext: AudioContext,
inputs: Partial<P> = {}
): Disposable<N> => {
const node = new AudioWorkletNode(
audioContext,
options.processorName,
options.workletOptions(inputs)
) as N;

(node as any).__PROCESSOR_NAME__ = options.processorName;
const connected = connectParams(node, options.paramNames, inputs);
options.postCreate?.(node);
return disposable(node, connected);
};
}

type ConnectedUnit = AudioNode | (() => void);

export function connectParams(
node: any,
paramNames: readonly string[],
inputs: any
): ConnectedUnit[] {
const connected: ConnectedUnit[] = [];

for (const paramName of paramNames) {
if (node.parameters) {
node[paramName] = node.parameters.get(paramName);
}
const param = node[paramName];
if (!param) throw Error("Invalid param name: " + paramName);
const input = inputs[paramName];
if (typeof input === "number") {
param.value = input;
} else if (input instanceof AudioNode) {
param.value = 0;
input.connect(param);
connected.push(input);
} else if (typeof input === "function") {
param.value = 0;
const source = input(node.context);
source.connect(param);
connected.push(source);
}
}

return connected;
}

export function disposable<N extends AudioNode>(
node: N,
dependencies?: ConnectedUnit[]
): Disposable<N> {
let disposed = false;
return Object.assign(node, {
dispose() {
if (disposed) return;
disposed = true;

node.disconnect();
(node as any).port?.postMessage({ type: "DISPOSE" });
if (!dependencies) return;

while (dependencies.length) {
const conn = dependencies.pop();
if (conn instanceof AudioNode) {
if (typeof (conn as any).dispose === "function") {
(conn as any).dispose?.();
} else {
conn.disconnect();
}
} else if (typeof conn === "function") {
conn();
}
}
},
});
}

export function createRegistrar(processorName: string, processor: string) {
return function (context: AudioContext): Promise<void> {
const key = "__" + processorName + "__";
if (key in context) return (context as any)[key];

if (!context.audioWorklet || !context.audioWorklet.addModule) {
throw Error("AudioWorklet not supported");
}

const blob = new Blob([processor], { type: "application/javascript" });
const url = URL.createObjectURL(blob);
const promise = context.audioWorklet.addModule(url);
(context as any)[key] = promise;
return promise;
};
}
79 changes: 79 additions & 0 deletions packages/karplus-strong/src/dsp.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
export function createKS(sampleRate: number, minFrequency: number) {
const targetAmplitude = 0.001; // Amplitude decays to 0.1% of initial value
const maxDelayLineLength = Math.ceil(sampleRate / minFrequency) + 2; // Extra samples for interpolation

const delayLine = new Float32Array(maxDelayLineLength);

let delayInSamples = maxDelayLineLength - 2;
let writeIndex = 0;

let isPlaying = false;
let prevTrigger = 0;

return (
output: Float32Array,
trigger: number,
frequency: number,
decay: number
) => {
const outputLength = output.length;

const decayTimeInSamples = 0.1 * decay * sampleRate;
const filterCoefficient = Math.pow(targetAmplitude, 1 / decayTimeInSamples);

if (trigger >= 1 && prevTrigger < 0.9) {
// Some hysterisis to avoid double triggering
delayInSamples = sampleRate / frequency;
delayInSamples = Math.min(
Math.max(delayInSamples, 1),
maxDelayLineLength - 2
);

for (let i = 0; i < maxDelayLineLength; i++) {
delayLine[i] = Math.random() * 2 - 1;
}
writeIndex = 0;
isPlaying = true;
}
prevTrigger = trigger;

if (isPlaying) {
for (let i = 0; i < outputLength; i++) {
let readIndex = writeIndex - delayInSamples;
if (readIndex < 0) {
readIndex += maxDelayLineLength;
}

const readIndexInt = Math.floor(readIndex);
const frac = readIndex - readIndexInt;

// Wrap around the delay line
const index0 = readIndexInt % maxDelayLineLength;
const index1 = (readIndexInt + 1) % maxDelayLineLength;

// Linear interpolation between two samples
const sample0 = delayLine[index0];
const sample1 = delayLine[index1];
const currentSample = sample0 + frac * (sample1 - sample0);

const nextSample = filterCoefficient * currentSample;
delayLine[writeIndex] = nextSample;
output[i] = currentSample;

writeIndex = (writeIndex + 1) % maxDelayLineLength;

// Stop playing if the signal has decayed below a threshold
if (Math.abs(currentSample) < 1e-8) {
isPlaying = false;
for (let j = i + 1; j < outputLength; j++) {
output[j] = 0;
}
break;
}
}
} else {
// Output silence when not playing
output.fill(0);
}
};
}
36 changes: 36 additions & 0 deletions packages/karplus-strong/src/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
import {
createRegistrar,
createWorkletConstructor,
ParamInput,
} from "./_worklet";
import { PROCESSOR } from "./processor";

export const registerKarplusStrongWorklet = createRegistrar(
"KS-OSC",
PROCESSOR
);

export type KarplusStrongInputs = {
trigger: ParamInput;
frequency: ParamInput;
decay: ParamInput;
};

export type KarplusStrongWorkletNode = AudioWorkletNode & {
trigger: AudioParam;
frequency: AudioParam;
decay: AudioParam;
dispose(): void;
};

export const KarplusStrong = createWorkletConstructor<
KarplusStrongWorkletNode,
KarplusStrongInputs
>({
processorName: "KsProcessor",
paramNames: ["trigger", "frequency", "decay"],
workletOptions: () => ({
numberOfInputs: 0,
numberOfOutputs: 1,
}),
});
1 change: 1 addition & 0 deletions packages/karplus-strong/src/processor.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export const PROCESSOR = `"use strict";(()=>{function b(o,n){let e=Math.ceil(o/n)+2,t=new Float32Array(e),i=e-2,a=0,f=!1,h=0;return(c,y,S,A)=>{let g=c.length,I=.1*A*o,M=Math.pow(.001,1/I);if(y>=1&&h<.9){i=o/S,i=Math.min(Math.max(i,1),e-2);for(let r=0;r<e;r++)t[r]=Math.random()*2-1;a=0,f=!0}if(h=y,f)for(let r=0;r<g;r++){let l=a-i;l<0&&(l+=e);let m=Math.floor(l),k=l-m,w=m%e,F=(m+1)%e,x=t[w],L=t[F],u=x+k*(L-x),P=M*u;if(t[a]=P,c[r]=u,a=(a+1)%e,Math.abs(u)<1e-8){f=!1;for(let p=r+1;p<g;p++)c[p]=0;break}}else c.fill(0)}}var d=class extends AudioWorkletProcessor{r;g;constructor(){super(),this.r=!0,this.g=b(sampleRate,100),this.port.onmessage=n=>{switch(n.data.type){case"DISPOSE":this.r=!1;break}}}process(n,s,e){let t=s[0][0];return this.g(t,e.trigger[0],e.frequency[0],e.decay[0]),this.r}static get parameterDescriptors(){return[["trigger",0,0,1],["frequency",440,20,2e4],["decay",.1,.01,5]].map(([n,s,e,t])=>({name:n,defaultValue:s,minValue:e,maxValue:t,automationRate:"k-rate"}))}};registerProcessor("KsProcessor",d);})();`;
Loading

0 comments on commit 16aa1e0

Please sign in to comment.