diff --git a/CHANGELOG.md b/CHANGELOG.md index 362d93a..f6005b2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,12 @@ # synthlet +## 0.12.0 + +Measure and control output signal: + +- Add level-meter `@synhtlet/level-meter` +- Add lookahead-limiter `@synthlet/lookahead-limiter` + ## 0.11.0 - Granite effect module `@synthlet/granite` @@ -14,7 +21,7 @@ ## 0.8.0 -- KarplusStrong source module `@synthlet/karplus-strong` +- KarplusStrong module `@synthlet/karplus-strong` ## 0.7.0 diff --git a/README.md b/README.md index 8672048..7a4d0e7 100644 --- a/README.md +++ b/README.md @@ -6,33 +6,41 @@ Collection of synth modules implemented as AudioWorklets. ```ts import { - registerAllWorklets, - AdsrAmp, - Svf, - SvfType, - PolyblepOscillator + // Modules + Amp, // Amplifier with an envelope + Output, // Output with gain control + Polyblep, // Polyblep oscillator + Svf, // State Variable Filter + + // Connections + Patch, // Create a patch + Serial, // Serial connection + + // Utils + registerWorklets, // Register worklets } from "synthlet"; +// Initialize AudioContext const ac = new AudioContext(); -await registerAllWorklets(ac); +await registerWorklets(ac); -// Simplest synth: Oscillator -> Filter -> Amplifier -const osc = PolyblepOscillator(ac, { frequency: 440 }); -const filter = Svf(ac, { - type: SvfType.LowPass - frequency: 4000, -}); -const amp = AdsrAmp(ac, { attack: 0.1, release: 0.5 }); -osc.connect(filter).connect(amp).connect(ac.destination); +// Create modules +const osc = Polybleb.saw(ac, { frequency: 440 }); +const filter = Svf.lp(ac, { frequency: 1000 }); +const amp = Amp.adsr(ac, { trigger, attack: 0.01, release: 0.3 }); +const out = Output(ac, { db: -3 }); -// Change parameters -osc.frequency.value = 1200; +// Make connections +Serial([osc, filter, amp, out]); -// Start sound -amp.gate.value = 1; +// Create a patch +const patch = Patch(out, { osc, filter, amp }); -// Stop sound -vca.gate.value = 0; +// Use the patch +patch.connect(ctx.destination); +patch.osc.frequency.value = 300; +patch.amp.gate.value = 1; // start sound +patch.amp.gate.value = 0; // stop sound ``` ## Install diff --git a/docs/IDEAS.md b/docs/IDEAS.md index b7e65c0..15768e3 100644 --- a/docs/IDEAS.md +++ b/docs/IDEAS.md @@ -1,3 +1,186 @@ +# Ideas + +## API + +Use a "universal" Connect module: + +```ts +// OLD +const clock = Clock(ac, { bpm: 60 }); +const euclid = Euclid(ac, { steps: 16, beats: 4, clock }); +const clave = ClaveDrum(ac, { trigger: euclid }); +clave.connect(ac.destination); + +// NEW +const clock = Clock(ac, { bpm: 60 }); +const euclid = Euclid(ac, { steps: 16, beats: 4 }); +const clave = ClaveDrum(ac); +Connect([clock, euclid.clock], [euclid, clave.trigger]); +const out = Serial([clave, Output(ac, { db: -24 })]); +Patch(out, { clave, clock, euclid }); +``` + +```ts +/// FROM README + +// Create modules +const osc = Polybleb.saw(ac, { frequency: 440 }); +const filter = Svg.lp(ac, { frequency: 1000 }); +const amp = Amp.adsr(ac, { trigger, attack: 0.01, release: 0.3 }); +const out = Output(ac, { db: -3 }); +// Connect modules +Serial([osc, filter, amp, out]); + +// Create patch +const patch = Patch(out, { osc, filter, amp }); + +// Use patch +patch.connect(ac.destination); +patch.osc.frequency.value = 300; +patch.amp.gate.value = 1; // start sound +patch.amp.gate.value = 0; // stop sound + +// OLD +const trigger = Param(ac); +const volume = Param(ac, -24); +const out = Output(ac); +const ks = KarplusStrong(ac); + +Connect([trigger, ks.trigger], [volume, out.db], [ks, out]); + +return Object.assign(ConnSerial([ks, Gain.val(ac, volume)]), { + ks, + volume: volume.input, + trigger: trigger.input, +}); + +/// NEW +const trigger = Param(ac); +const volume = Param(ac, -24); +const osc = KarplusStrong(ac, { trigger }); +const out = Serial(osc, Output(ac, { db: volume })); +return Patch(out, { osc, trigger, volume }); // out.osc.frequency.value = 300 + +// OLD +const VcaSynth = (context: AudioContext) => { + const s = getSynthlet(context); + const trigger = s.param(); + const attack = s.param(0.01); + const release = s.param(0.3); + + return s.withParams( + s.conn.serial(s.osc.sin(440), s.amp.perc(trigger, attack, release)), + { + trigger, + attack, + release, + } + ); +}; + +/// ALTERNATIVE +const VcaSynth = (ac: AudioContext) => { + const s = Patch(ac, { + gate: Gate, + attack: 0.01, + release: 0.3, + volume: -12, + osc: [BlepOsc, { frequency: 440 }], + amp: [PercAmp, { attack: 0.01, release: 0.3 }], + out: [Output, { destination: true }], + }); + + s.patch( + [s.gate, s.amp.Gate], + [s.attack, s.amp.attack], + [s.release, s.amp.release], + [s.volume, s.out.db], + [s.osc, s.amp, s.out, s.destination] + ); +}; + +/// ALTERNATIVE +const KickDrum = (ac: AudioContext, inputs: DrumInputs = {}) => { + const p = Patch(ac, { + ...Inputs(ac, inputs, { decay: 0.5, volume: -12, tone: 0.5, trigger: 0 }), + osc: BlepOsc, + oscEnv: [EnvDecay, { gain: -50 }], + impulse: Impulse, + amp: AmpEnv, + clip: Clip, + out: Output, + }); + p.patch( + [p.trigger, [p.osc.trigger, p.impulse.trigger]], + [p.decay, p.oscEnv.decay], + [p.offset, p.osc.frequency], + [p.osc, p.amp, p.clip, p.out] + ); +}; + +/// ALT 3 +const trigger = Param(ac, input.trigger ?? 0); +const decay = Param(ac, input.decay ?? 0.5); +const volume = Param(ac, input.volume ?? -12); +const tone = Param(ac, input.tone ?? 0.5); +const frequency = Param.linear(tone, 20, 100); +const osc = Osc(ac, { + frequency: Mix([ + frequency, + Ramp(ac, { trigger, start: 0, end: -50, duration: Param.div(decay, 2) }), + ]), +}); +const click = Impulse(ac, { trigger }); +const amp = Amp.ad(ac, { trigger, attack: 0.01, release: decay }); +const clip = Clip.soft({ pre: 5, post: 0.6 }); +const out = Output(ac, { db: volume }); +Connect([Mix([osc, click]), amp, clip, out]); +Patch(out, { trigger, decay, volume, tone }); + +/// ALT 3 +const trigger = Param(ac, input.trigger ?? 0); +const decay = Param(ac, input.decay ?? 0.5); +const volume = Param(ac, input.volume ?? -12); +const tone = Param(ac, input.tone ?? 0.5); + +const osc = Mix( + [263, 400, 421, 474, 587, 845].map((f) => Osc(ac, { frequency: f })) +); +const out = Serial([ + osc, + Biquad.bp(ac, { frequency: Param.linear(tone, 20, 100) }), + Biquad.hp(ac, { frequency: Param.add(loFreq, -2000) }), + Amp.ad(ac, { trigger, attack: 0.01, release: decay }), + Output(ac, { db: volume }), +]); +Patch(out, { trigger, decay, volume, tone }); + +/// ALT 3 +export const CowBellDrum = (context: AudioContext, inputs: DrumInputs = {}) => { + const trigger = Param(ac, input.trigger ?? 0); + const decay = Param(ac, input.decay ?? 0.5); + const volume = Param(ac, input.volume ?? -12); + const tone = Param(ac, input.tone ?? 0.5); + + const hiFreq = Param.linear(tone, 700, 900); + const lowFreq = Param.linear(tone, 440, 540); + const shortDecay = Param.mul(decay, 0.1); + + const voice1 = Serial([ + Osc.square(ac, { frequency: hiFreq }), + Amp.perc(ac, { trigger, attack: 0.001, release: shortDecay }), + ]); + const voice2 = Serial([ + Osc.square(ac, { frequency: lowFreq }), + Amp.perc(ac, { trigger, attack: 0.001, release: shortDecay }), + ]); + const out = Serial(Mix([voice1, voice2]), Output(ac, { db: volume })); + Patch(out, { trigger, decay, volume, tone }); +}; +``` + +## Modules + - Snarebuzz - https://github.com/RCJacH/ReaScripts/blob/master/JSFX/Audio/RCNoiseBuzz.jsfx @@ -5,3 +188,7 @@ - Chorus - https://github.com/SpotlightKid/ykchorus + +``` + +``` diff --git a/package-lock.json b/package-lock.json index 582357b..b211e20 100644 --- a/package-lock.json +++ b/package-lock.json @@ -2056,10 +2056,18 @@ "resolved": "packages/karplus-strong", "link": true }, + "node_modules/@synthlet/level-meter": { + "resolved": "packages/level-meter", + "link": true + }, "node_modules/@synthlet/lfo": { "resolved": "packages/lfo", "link": true }, + "node_modules/@synthlet/lookahead-limiter": { + "resolved": "packages/lookahead-limiter", + "link": true + }, "node_modules/@synthlet/noise": { "resolved": "packages/noise", "link": true @@ -6600,6 +6608,7 @@ } }, "packages/granite": { + "name": "@synthlet/granite", "version": "0.1.0", "license": "MIT", "devDependencies": { @@ -6628,6 +6637,16 @@ "ts-jest": "^29.1.1" } }, + "packages/level-meter": { + "name": "@synthlet/level-meter", + "version": "0.0.0", + "license": "MIT", + "devDependencies": { + "@types/jest": "^29.5.13", + "jest": "^29.7.0", + "ts-jest": "^29.1.1" + } + }, "packages/lfo": { "name": "@synthlet/lfo", "version": "0.1.0", @@ -6638,6 +6657,16 @@ "ts-jest": "^29.1.1" } }, + "packages/lookahead-limiter": { + "name": "@synthlet/lookahead-limiter", + "version": "0.0.0", + "license": "MIT", + "devDependencies": { + "@types/jest": "^29.5.13", + "jest": "^29.7.0", + "ts-jest": "^29.1.1" + } + }, "packages/noise": { "name": "@synthlet/noise", "version": "0.1.0", @@ -6689,7 +6718,7 @@ } }, "packages/synthlet": { - "version": "0.10.0", + "version": "0.11.0", "license": "MIT", "dependencies": { "@synthlet/ad": "^0.1.0", @@ -6704,7 +6733,9 @@ "@synthlet/granite": "^0.1.0", "@synthlet/impulse": "^0.1.0", "@synthlet/karplus-strong": "^0.1.0", + "@synthlet/level-meter": "^0.0.0", "@synthlet/lfo": "^0.1.0", + "@synthlet/lookahead-limiter": "^0.0.0", "@synthlet/noise": "^0.1.0", "@synthlet/param": "^0.1.0", "@synthlet/polyblep-oscillator": "^0.2.0", diff --git a/packages/level-meter/CHANGELOG.md b/packages/level-meter/CHANGELOG.md new file mode 100644 index 0000000..da0749a --- /dev/null +++ b/packages/level-meter/CHANGELOG.md @@ -0,0 +1,5 @@ +# @synthlet/meter + +## 0.1.0 + +Initial release diff --git a/packages/level-meter/README.md b/packages/level-meter/README.md new file mode 100644 index 0000000..fcd9522 --- /dev/null +++ b/packages/level-meter/README.md @@ -0,0 +1,5 @@ +# @synthlet/level-meter + +> An audio level meter + +Part of [Synthlet](https://github.com/danigb/synthlet) diff --git a/packages/level-meter/package.json b/packages/level-meter/package.json new file mode 100644 index 0000000..8f91753 --- /dev/null +++ b/packages/level-meter/package.json @@ -0,0 +1,36 @@ +{ + "name": "@synthlet/level-meter", + "version": "0.1.0", + "description": "Audio level meter audio worklet", + "keywords": [ + "audio-level", + "level-meter", + "modular", + "synthesis", + "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.13", + "jest": "^29.7.0", + "ts-jest": "^29.1.1" + }, + "jest": { + "preset": "ts-jest" + }, + "scripts": { + "worklet": "esbuild src/processor.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/level-meter/src/_processor.ts b/packages/level-meter/src/_processor.ts new file mode 100644 index 0000000..08f2640 --- /dev/null +++ b/packages/level-meter/src/_processor.ts @@ -0,0 +1 @@ +export const PROCESSOR = `"use strict";(()=>{var n=class extends AudioWorkletProcessor{peaks;max;r;log=!0;constructor(t){super(),this.r=!0,console.log("level meter options",t);let s=t.processorOptions.peaksBuffer;this.peaks=new Float32Array(s),console.log("level meter data",t.processorOptions,s,this.peaks,this.peaks.length),this.max=8,this.port.onmessage=h=>{switch(h.data.type){case"DISPOSE":this.r=!1;break}}}process(t,s,h){let r=t[0],c=s[0],i=Math.min(r.length,this.max);this.log&&(console.log("level meter",r.length,c.length,r[0].length,i),this.log=!1);for(let e=0;e = (context: AudioContext) => N; + +export type ParamInput = number | Connector | AudioNode; + +type CreateWorkletOptions = { + processorName: string; + paramNames: readonly string[]; + workletOptions: (params: Partial

) => AudioWorkletNodeOptions; + postCreate?: (node: N) => void; +}; + +export type Disposable = N & { dispose: () => void }; + +export function createWorkletConstructor< + N extends AudioWorkletNode, + P extends Record +>(options: CreateWorkletOptions) { + return ( + audioContext: AudioContext, + inputs: Partial

= {} + ): Disposable => { + 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( + node: N, + dependencies?: ConnectedUnit[] +): Disposable { + 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 { + 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; + }; +} diff --git a/packages/level-meter/src/index.ts b/packages/level-meter/src/index.ts new file mode 100644 index 0000000..c4c54dc --- /dev/null +++ b/packages/level-meter/src/index.ts @@ -0,0 +1,53 @@ +import { PROCESSOR } from "./_processor"; +import { createRegistrar, disposable } from "./_worklet"; +export { LevelMeterUI } from "./meter-ui"; + +export const registerLevelMeterWorklet = createRegistrar( + "LEVEL_METER", + PROCESSOR +); + +export type LevelMeterInputs = {}; + +export type LevelMeterWorkletNode = AudioWorkletNode & { + dispose(): void; + getPeaks(): Float32Array; + getRms(): Float32Array; +}; + +export type LevelMeterOptions = { + maxChannels?: number; +}; + +export const LevelMeter = ( + context: AudioContext, + options: LevelMeterOptions = {} +): LevelMeterWorkletNode => { + const maxChannels = options.maxChannels || 16; + const peaksBuffer = new SharedArrayBuffer( + maxChannels * Float32Array.BYTES_PER_ELEMENT + ); + const rmsBuffer = new SharedArrayBuffer( + maxChannels * Float32Array.BYTES_PER_ELEMENT + ); + const peaks = new Float32Array(peaksBuffer); + const rms = new Float32Array(rmsBuffer); + const node = new AudioWorkletNode(context, "LevelMeterProcessor", { + numberOfInputs: 1, + numberOfOutputs: 1, + processorOptions: { + peaksBuffer, + rmsBuffer, + }, + }) as LevelMeterWorkletNode; + + node.getPeaks = () => { + return peaks; + }; + + node.getRms = () => { + return rms; + }; + + return disposable(node); +}; diff --git a/packages/level-meter/src/meter-ui.ts b/packages/level-meter/src/meter-ui.ts new file mode 100644 index 0000000..eb84bc2 --- /dev/null +++ b/packages/level-meter/src/meter-ui.ts @@ -0,0 +1,94 @@ +export type LevelMeterUIOptions = { + minDb: number; + maxDb: number; + // For now only horizontal is supported + orientation: "horizontal"; +}; + +export class LevelMeterUI { + canvas: HTMLCanvasElement | null = null; + context: CanvasRenderingContext2D | null = null; + width: number = 0; + height: number = 0; + minDb: number; + maxDb: number; + gradient?: CanvasGradient | null = null; + stripes?: CanvasPattern | null = null; + + constructor(options: Partial = {}) { + this.minDb = options.minDb ?? -40; + this.maxDb = options.maxDb ?? 0; + } + + setCanvas(canvas: HTMLCanvasElement) { + this.canvas = canvas; + this.context = canvas.getContext("2d"); + this.width = canvas.width; + this.height = canvas.height; + } + + render(data: Float32Array, channels: number) { + if (!this.canvas || !this.context) { + return; + } + if (!this.gradient) { + this.gradient = this.createMeterGradient(this.context); + this.stripes = this.createStipesPattern(this.context); + } + + const barHeight = this.height / channels - 2; + this.context.clearRect(0, 0, this.width, this.height); + + this.context.fillStyle = "black"; + this.context.fillRect(0, 0, this.width, this.height); + + this.context.fillStyle = this.gradient || "black"; + for (let i = 0; i < channels; i++) { + const peak = data[i]; + const db = 20 * Math.log10(peak); + const limitDb = db < this.minDb ? this.minDb : db; + const normalizedDb = (limitDb - this.minDb) / (this.maxDb - this.minDb); + const barWidth = Math.max(0, Math.min(1, normalizedDb)) * this.width; + this.context.fillRect(0, i * barHeight + 0.5, barWidth, barHeight - 0.5); + } + if (this.stripes) { + this.context.fillStyle = this.stripes; + this.context.fillRect(0, 0, this.width, this.height); + } + } + + private createMeterGradient(ctx: CanvasRenderingContext2D) { + const gradient = ctx.createLinearGradient(0, 0, this.width, 0); + + const dbToPosition = (db: number) => { + return (db - this.minDb) / (this.maxDb - this.minDb); + }; + + // Colors from reference + const lowGain = "rgba(60,180,60)"; + const highGain = "rgb(220,220,0)"; + const clipGain = "rgb(160,16,0)"; + + // Create smooth gradient transitions + gradient.addColorStop(0.0, lowGain); + gradient.addColorStop(dbToPosition(-18), lowGain); + gradient.addColorStop(dbToPosition(-6), highGain); + gradient.addColorStop(dbToPosition(-0), clipGain); + gradient.addColorStop(1.0, clipGain); + + return gradient; + } + + private createStipesPattern(ctx: CanvasRenderingContext2D) { + const canvas = document.createElement("canvas"); + canvas.width = 3; + canvas.height = 1; + const context = canvas.getContext("2d"); + + if (context) { + context.fillStyle = "rgba(39, 39, 39, 0.9)"; + context.fillRect(0, 0, 1, 1); + } + return ctx.createPattern(canvas, "repeat"); + } +} diff --git a/packages/level-meter/src/processor.ts b/packages/level-meter/src/processor.ts new file mode 100644 index 0000000..bc91b7e --- /dev/null +++ b/packages/level-meter/src/processor.ts @@ -0,0 +1,70 @@ +class LevelMeterProcessor extends AudioWorkletProcessor { + peaks: Float32Array; + max: number; + r: boolean; + + log = true; + + constructor(options: AudioWorkletNodeOptions) { + super(); + this.r = true; + console.log("level meter options", options); + const peaksBuffer = options.processorOptions.peaksBuffer; + this.peaks = new Float32Array(peaksBuffer); + console.log( + "level meter data", + options.processorOptions, + peaksBuffer, + this.peaks, + this.peaks.length + ); + this.max = 8; + this.port.onmessage = (event) => { + switch (event.data.type) { + case "DISPOSE": + this.r = false; + break; + } + }; + } + + process( + inputs: Float32Array[][], + outputs: Float32Array[][], + _parameters: Record + ): boolean { + const input = inputs[0]; + const output = outputs[0]; + + let channels = Math.min(input.length, this.max); + + if (this.log) { + console.log( + "level meter", + input.length, + output.length, + input[0].length, + channels + ); + this.log = false; + } + + for (let channel = 0; channel < channels; channel++) { + const chIn = input[channel]; + const chOut = output[channel]; + let peak = 0; + for (let i = 0; i < chIn.length; i++) { + peak = Math.max(peak, Math.abs(chIn[i])); + } + this.peaks[channel] = this.peaks[channel] * 0.9 + peak * 0.1; + chOut.set(chIn); + } + return true; + } + + static get parameterDescriptors() { + return []; + } +} + +registerProcessor("LevelMeterProcessor", LevelMeterProcessor); diff --git a/packages/level-meter/src/worklet.ts b/packages/level-meter/src/worklet.ts new file mode 100644 index 0000000..e69de29 diff --git a/packages/lookahead-limiter/CHANGELOG.md b/packages/lookahead-limiter/CHANGELOG.md new file mode 100644 index 0000000..67a2a09 --- /dev/null +++ b/packages/lookahead-limiter/CHANGELOG.md @@ -0,0 +1,5 @@ +# @synthlet/lookahead-limiter + +## 0.1.0 + +Initial release diff --git a/packages/lookahead-limiter/README.md b/packages/lookahead-limiter/README.md new file mode 100644 index 0000000..49d5976 --- /dev/null +++ b/packages/lookahead-limiter/README.md @@ -0,0 +1,5 @@ +# @synthlet/lookahead-limiter + +> An brickwall lookahead limiter for master output + +Part of [Synthlet](https://github.com/danigb/synthlet) diff --git a/packages/lookahead-limiter/package.json b/packages/lookahead-limiter/package.json new file mode 100644 index 0000000..bde32ba --- /dev/null +++ b/packages/lookahead-limiter/package.json @@ -0,0 +1,38 @@ +{ + "name": "@synthlet/lookahead-limiter", + "version": "0.1.0", + "description": "Audio lookahead brickwall limiter audio worklet", + "keywords": [ + "audio-limiter", + "brickwall", + "compressor", + "lookahead-limiter", + "modular", + "synthesis", + "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.13", + "jest": "^29.7.0", + "ts-jest": "^29.1.1" + }, + "jest": { + "preset": "ts-jest" + }, + "scripts": { + "worklet": "esbuild src/processor.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/lookahead-limiter/src/_processor.ts b/packages/lookahead-limiter/src/_processor.ts new file mode 100644 index 0000000..15bd7a7 --- /dev/null +++ b/packages/lookahead-limiter/src/_processor.ts @@ -0,0 +1 @@ +export const PROCESSOR = `"use strict";(()=>{function b(r){return Math.pow(10,r/20)}function A(r){return r<1e-6?-120:20*Math.log10(r)}var p=class extends AudioWorkletProcessor{thresholdDb;lookAheadFrames;releaseCoeff;audioDelayBufferL;audioDelayBufferR;audioWriteReadPos=0;gainSmoothingBuffer;gainBlockWriteStartPos=0;envelopeGain=0;constructor(l){super();let t=l?.processorOptions||{};this.thresholdDb=typeof t.thresholdDb=="number"?t.thresholdDb:-1;let n=typeof t.lookAheadSeconds=="number"&&t.lookAheadSeconds>=0?t.lookAheadSeconds:.005,f=typeof t.releaseSeconds=="number"&&t.releaseSeconds>0?t.releaseSeconds:.1;this.lookAheadFrames=Math.max(1,Math.ceil(n*sampleRate)),this.releaseCoeff=Math.exp(-1/(sampleRate*f)),this.audioDelayBufferL=new Float32Array(this.lookAheadFrames),this.audioDelayBufferR=new Float32Array(this.lookAheadFrames);let u=128+this.lookAheadFrames;this.gainSmoothingBuffer=new Float32Array(u),this.gainSmoothingBuffer.fill(0)}process(l,t){let n=l[0][0],f=l[0][1]||n,u=t[0][0],B=t[0][1]||u,h=n.length;for(let e=0;eo?this.gainSmoothingBuffer[i]=o:(g=this.lookAheadFrames>0?-a/this.lookAheadFrames:0,o=a),o+=g,o>0&&(o=0),i=(i-1+this.gainSmoothingBuffer.length)%this.gainSmoothingBuffer.length}for(let e=0;eo;++e){this.gainSmoothingBuffer[i]=o;o+=g,o>0&&(o=0),i=(i-1+this.gainSmoothingBuffer.length)%this.gainSmoothingBuffer.length}for(let e=0;e = (context: AudioContext) => N; + +export type ParamInput = number | Connector | AudioNode; + +type CreateWorkletOptions = { + processorName: string; + paramNames: readonly string[]; + workletOptions: (params: Partial

) => AudioWorkletNodeOptions; + postCreate?: (node: N) => void; +}; + +export type Disposable = N & { dispose: () => void }; + +export function createWorkletConstructor< + N extends AudioWorkletNode, + P extends Record +>(options: CreateWorkletOptions) { + return ( + audioContext: AudioContext, + inputs: Partial

= {} + ): Disposable => { + 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( + node: N, + dependencies?: ConnectedUnit[] +): Disposable { + 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 { + 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; + }; +} diff --git a/packages/lookahead-limiter/src/index.ts b/packages/lookahead-limiter/src/index.ts new file mode 100644 index 0000000..efe106a --- /dev/null +++ b/packages/lookahead-limiter/src/index.ts @@ -0,0 +1,26 @@ +import { PROCESSOR } from "./_processor"; +import { createRegistrar, createWorkletConstructor } from "./_worklet"; + +export const registerLookaheadLimiterWorklet = createRegistrar( + "LOOKAHEAD_LIMITER", + PROCESSOR +); + +export type LookaheadLimiterInputs = {}; + +export type LookaheadLimiterWorkletNode = AudioWorkletNode & { + dispose(): void; +}; + +export const LookaheadLimiter = createWorkletConstructor< + LookaheadLimiterWorkletNode, + LookaheadLimiterInputs +>({ + processorName: "LookaheadLimiterProcessor", + paramNames: [], + workletOptions: () => ({ + numberOfInputs: 1, + numberOfOutputs: 1, + outputChannelCount: [2], + }), +}); diff --git a/packages/lookahead-limiter/src/processor.ts b/packages/lookahead-limiter/src/processor.ts new file mode 100644 index 0000000..7fd67a2 --- /dev/null +++ b/packages/lookahead-limiter/src/processor.ts @@ -0,0 +1,168 @@ +const RENDER_QUANTUM = 128; +const MIN_ENVELOPE_GAIN = 1e-6; // Approx -120 dB +const MIN_DB_GAIN = -120.0; // Min practical dB for gain values + +// Helper functions (must be defined or imported) +function dbToGain(db: number): number { + return Math.pow(10, db / 20); +} +function gainToDb(gain: number): number { + if (gain < MIN_ENVELOPE_GAIN) return MIN_DB_GAIN; // Avoid log(0) or very small numbers + return 20 * Math.log10(gain); +} + +// https://github.com/DanielRudrich/SimpleCompressor/blob/master/docs/lookAheadLimiter.md +class LookAheadLimiterProcessor extends AudioWorkletProcessor { + // Configuration (fixed after construction) + private readonly thresholdDb: number; + private readonly lookAheadFrames: number; + private readonly releaseCoeff: number; + + // Audio Delay Buffers + private audioDelayBufferL: Float32Array; + private audioDelayBufferR: Float32Array; + private audioWriteReadPos: number = 0; // Single pointer for audio circular buffer + + // Gain Smoothing Buffer + private gainSmoothingBuffer: Float32Array; + private gainBlockWriteStartPos: number = 0; // Start index for writing the current block's raw gains + + // Envelope follower state + private envelopeGain: number = 0.0; + + constructor(options?: AudioWorkletNodeOptions) { + super(); + + const processorOptions = options?.processorOptions || {}; + this.thresholdDb = + typeof processorOptions.thresholdDb === "number" + ? processorOptions.thresholdDb + : -1.0; + const lookAheadSeconds = + typeof processorOptions.lookAheadSeconds === "number" && + processorOptions.lookAheadSeconds >= 0 + ? processorOptions.lookAheadSeconds + : 0.005; + const releaseSeconds = + typeof processorOptions.releaseSeconds === "number" && + processorOptions.releaseSeconds > 0 + ? processorOptions.releaseSeconds + : 0.1; + + this.lookAheadFrames = Math.max( + 1, + Math.ceil(lookAheadSeconds * sampleRate) + ); + this.releaseCoeff = Math.exp(-1.0 / (sampleRate * releaseSeconds)); + + this.audioDelayBufferL = new Float32Array(this.lookAheadFrames); + this.audioDelayBufferR = new Float32Array(this.lookAheadFrames); + + const gainSmoothingBufferLength = RENDER_QUANTUM + this.lookAheadFrames; + this.gainSmoothingBuffer = new Float32Array(gainSmoothingBufferLength); + this.gainSmoothingBuffer.fill(0.0); + } + + process(inputs: Float32Array[][], outputs: Float32Array[][]): boolean { + const inputL = inputs[0][0]; + const inputR = inputs[0][1] || inputL; + const outputL = outputs[0][0]; + const outputR = outputs[0][1] || outputL; + const numSamplesInBlock = inputL.length; // Should be RENDER_QUANTUM + + for (let i = 0; i < numSamplesInBlock; i++) { + const currentInputL = inputL[i]; + const currentInputR = inputR[i]; + + const peakInput = Math.max( + Math.abs(currentInputL), + Math.abs(currentInputR) + ); + if (this.envelopeGain < peakInput) { + this.envelopeGain = peakInput; + } else { + this.envelopeGain = + peakInput + this.releaseCoeff * (this.envelopeGain - peakInput); + } + const envelopeDb = gainToDb(this.envelopeGain); + const rawGainDb = Math.min(0.0, this.thresholdDb - envelopeDb); + + const gainWriteIdx = + (this.gainBlockWriteStartPos + i) % this.gainSmoothingBuffer.length; + this.gainSmoothingBuffer[gainWriteIdx] = rawGainDb; + } + + let nextSmoothedGainDb = 0.0; + let stepDb = 0.0; + let currentIndex = + (this.gainBlockWriteStartPos + + numSamplesInBlock - + 1 + + this.gainSmoothingBuffer.length) % + this.gainSmoothingBuffer.length; + + for (let k = 0; k < numSamplesInBlock; ++k) { + const sampleDb = this.gainSmoothingBuffer[currentIndex]; + if (sampleDb > nextSmoothedGainDb) { + this.gainSmoothingBuffer[currentIndex] = nextSmoothedGainDb; + } else { + stepDb = + this.lookAheadFrames > 0 ? -sampleDb / this.lookAheadFrames : 0.0; + nextSmoothedGainDb = sampleDb; + } + nextSmoothedGainDb += stepDb; + if (nextSmoothedGainDb > 0.0) nextSmoothedGainDb = 0.0; + currentIndex = + (currentIndex - 1 + this.gainSmoothingBuffer.length) % + this.gainSmoothingBuffer.length; + } + + for (let k = 0; k < this.lookAheadFrames; ++k) { + const sampleDb = this.gainSmoothingBuffer[currentIndex]; + if (sampleDb > nextSmoothedGainDb) { + this.gainSmoothingBuffer[currentIndex] = nextSmoothedGainDb; + } else { + break; + } + nextSmoothedGainDb += stepDb; + if (nextSmoothedGainDb > 0.0) nextSmoothedGainDb = 0.0; + currentIndex = + (currentIndex - 1 + this.gainSmoothingBuffer.length) % + this.gainSmoothingBuffer.length; + } + + for (let i = 0; i < numSamplesInBlock; i++) { + const delayedAudioL = this.audioDelayBufferL[this.audioWriteReadPos]; + const delayedAudioR = this.audioDelayBufferR[this.audioWriteReadPos]; + + this.audioDelayBufferL[this.audioWriteReadPos] = inputL[i]; + this.audioDelayBufferR[this.audioWriteReadPos] = inputR[i]; + + const smoothedGainReadIdx = + (this.gainBlockWriteStartPos + + i - + this.lookAheadFrames + + this.gainSmoothingBuffer.length) % + this.gainSmoothingBuffer.length; + + const finalSmoothedGainDb = this.gainSmoothingBuffer[smoothedGainReadIdx]; + const finalGainLinear = dbToGain( + Math.max(MIN_DB_GAIN, finalSmoothedGainDb) + ); + + outputL[i] = delayedAudioL * finalGainLinear; + outputR[i] = delayedAudioR * finalGainLinear; + + this.audioWriteReadPos = + (this.audioWriteReadPos + 1) % this.lookAheadFrames; // Advance for next sample + } + + this.gainBlockWriteStartPos = + (this.gainBlockWriteStartPos + numSamplesInBlock) % + this.gainSmoothingBuffer.length; + + return true; + } +} + +registerProcessor("LookaheadLimiterProcessor", LookAheadLimiterProcessor); diff --git a/packages/synthlet/CHANGELOG.md b/packages/synthlet/CHANGELOG.md index 362d93a..f6005b2 100644 --- a/packages/synthlet/CHANGELOG.md +++ b/packages/synthlet/CHANGELOG.md @@ -1,5 +1,12 @@ # synthlet +## 0.12.0 + +Measure and control output signal: + +- Add level-meter `@synhtlet/level-meter` +- Add lookahead-limiter `@synthlet/lookahead-limiter` + ## 0.11.0 - Granite effect module `@synthlet/granite` @@ -14,7 +21,7 @@ ## 0.8.0 -- KarplusStrong source module `@synthlet/karplus-strong` +- KarplusStrong module `@synthlet/karplus-strong` ## 0.7.0 diff --git a/packages/synthlet/package.json b/packages/synthlet/package.json index 1f3bb89..172073d 100644 --- a/packages/synthlet/package.json +++ b/packages/synthlet/package.json @@ -1,6 +1,6 @@ { "name": "synthlet", - "version": "0.11.0", + "version": "0.12.0", "description": "Modular synthesis in the browser", "keywords": [ "modular", @@ -23,8 +23,8 @@ "@synthlet/ad": "^0.1.0", "@synthlet/adsr": "^0.1.0", "@synthlet/arp": "^0.1.0", - "@synthlet/chorus": "^0.1.0", "@synthlet/chorus-t": "^0.1.1", + "@synthlet/chorus": "^0.1.0", "@synthlet/clip-amp": "^0.1.0", "@synthlet/clock": "^0.1.0", "@synthlet/dattorro-reverb": "^0.1.0", @@ -33,6 +33,8 @@ "@synthlet/impulse": "^0.1.0", "@synthlet/karplus-strong": "^0.1.0", "@synthlet/lfo": "^0.1.0", + "@synthlet/lookahead-limiter": "^0.1.0", + "@synthlet/level-meter": "^0.1.0", "@synthlet/noise": "^0.1.0", "@synthlet/param": "^0.1.0", "@synthlet/polyblep-oscillator": "^0.2.0", diff --git a/packages/synthlet/src/index.ts b/packages/synthlet/src/index.ts index 6e6cb82..1823817 100644 --- a/packages/synthlet/src/index.ts +++ b/packages/synthlet/src/index.ts @@ -7,10 +7,12 @@ import { registerClipAmpWorklet } from "@synthlet/clip-amp"; import { registerClockWorklet } from "@synthlet/clock"; import { registerDattorroReverbWorklet } from "@synthlet/dattorro-reverb"; import { registerEuclidWorklet } from "@synthlet/euclid"; -import { registerGraniteWorklet } from "@synthlet/granite/src"; +import { registerGraniteWorklet } from "@synthlet/granite"; import { registerImpulseWorklet } from "@synthlet/impulse"; import { registerKarplusStrongWorklet } from "@synthlet/karplus-strong"; +import { registerLevelMeterWorklet } from "@synthlet/level-meter"; import { registerLfoWorklet } from "@synthlet/lfo"; +import { registerLookaheadLimiterWorklet } from "@synthlet/lookahead-limiter"; import { registerNoiseWorklet } from "@synthlet/noise"; import { registerParamWorklet } from "@synthlet/param"; import { registerPolyblepOscillatorWorklet } from "@synthlet/polyblep-oscillator"; @@ -31,7 +33,9 @@ export * from "@synthlet/euclid"; export * from "@synthlet/granite"; export * from "@synthlet/impulse"; export * from "@synthlet/karplus-strong"; +export * from "@synthlet/level-meter"; export * from "@synthlet/lfo"; +export * from "@synthlet/lookahead-limiter"; export * from "@synthlet/noise"; export * from "@synthlet/param"; export * from "@synthlet/polyblep-oscillator"; @@ -62,7 +66,9 @@ export function registerAllWorklets( registerGraniteWorklet(context), registerImpulseWorklet(context), registerKarplusStrongWorklet(context), + registerLevelMeterWorklet(context), registerLfoWorklet(context), + registerLookaheadLimiterWorklet(context), registerNoiseWorklet(context), registerNoiseWorklet(context), registerParamWorklet(context), diff --git a/site/.source/index.js b/site/.source/index.js index c3556c1..48d32e2 100644 --- a/site/.source/index.js +++ b/site/.source/index.js @@ -8,29 +8,31 @@ import * as file_5 from "../content/docs/(effects)/chorus-t.mdx?collection=docs& import * as file_6 from "../content/docs/(effects)/chorus.mdx?collection=docs&hash=2b527dd5b30464f2ddc4c049fb69d30b9fcc145ae96eece40109d80d98642180" import * as file_7 from "../content/docs/(effects)/dattorro.mdx?collection=docs&hash=2b527dd5b30464f2ddc4c049fb69d30b9fcc145ae96eece40109d80d98642180" import * as file_8 from "../content/docs/(effects)/granite.mdx?collection=docs&hash=2b527dd5b30464f2ddc4c049fb69d30b9fcc145ae96eece40109d80d98642180" -import * as file_9 from "../content/docs/(effects)/reverb-delay.mdx?collection=docs&hash=2b527dd5b30464f2ddc4c049fb69d30b9fcc145ae96eece40109d80d98642180" -import * as file_10 from "../content/docs/(modulators)/ad.mdx?collection=docs&hash=2b527dd5b30464f2ddc4c049fb69d30b9fcc145ae96eece40109d80d98642180" -import * as file_11 from "../content/docs/(modulators)/adsr.mdx?collection=docs&hash=2b527dd5b30464f2ddc4c049fb69d30b9fcc145ae96eece40109d80d98642180" -import * as file_12 from "../content/docs/(modulators)/lfo.mdx?collection=docs&hash=2b527dd5b30464f2ddc4c049fb69d30b9fcc145ae96eece40109d80d98642180" -import * as file_13 from "../content/docs/(modulators)/param.mdx?collection=docs&hash=2b527dd5b30464f2ddc4c049fb69d30b9fcc145ae96eece40109d80d98642180" -import * as file_14 from "../content/docs/(modifiers)/ad-amp.mdx?collection=docs&hash=2b527dd5b30464f2ddc4c049fb69d30b9fcc145ae96eece40109d80d98642180" -import * as file_15 from "../content/docs/(modifiers)/adsr-amp.mdx?collection=docs&hash=2b527dd5b30464f2ddc4c049fb69d30b9fcc145ae96eece40109d80d98642180" -import * as file_16 from "../content/docs/(modifiers)/clip-amp.mdx?collection=docs&hash=2b527dd5b30464f2ddc4c049fb69d30b9fcc145ae96eece40109d80d98642180" -import * as file_17 from "../content/docs/(modifiers)/state-variable-filter.mdx?collection=docs&hash=2b527dd5b30464f2ddc4c049fb69d30b9fcc145ae96eece40109d80d98642180" -import * as file_18 from "../content/docs/(modifiers)/vaf.mdx?collection=docs&hash=2b527dd5b30464f2ddc4c049fb69d30b9fcc145ae96eece40109d80d98642180" -import * as file_19 from "../content/docs/(sources)/impulse.mdx?collection=docs&hash=2b527dd5b30464f2ddc4c049fb69d30b9fcc145ae96eece40109d80d98642180" -import * as file_20 from "../content/docs/(sources)/ks.mdx?collection=docs&hash=2b527dd5b30464f2ddc4c049fb69d30b9fcc145ae96eece40109d80d98642180" -import * as file_21 from "../content/docs/(sources)/noise.mdx?collection=docs&hash=2b527dd5b30464f2ddc4c049fb69d30b9fcc145ae96eece40109d80d98642180" -import * as file_22 from "../content/docs/(sources)/polyblep.mdx?collection=docs&hash=2b527dd5b30464f2ddc4c049fb69d30b9fcc145ae96eece40109d80d98642180" -import * as file_23 from "../content/docs/(sources)/wavetable.mdx?collection=docs&hash=2b527dd5b30464f2ddc4c049fb69d30b9fcc145ae96eece40109d80d98642180" -import * as file_24 from "../content/docs/(sequencers)/arp.mdx?collection=docs&hash=2b527dd5b30464f2ddc4c049fb69d30b9fcc145ae96eece40109d80d98642180" -import * as file_25 from "../content/docs/(sequencers)/clock.mdx?collection=docs&hash=2b527dd5b30464f2ddc4c049fb69d30b9fcc145ae96eece40109d80d98642180" -import * as file_26 from "../content/docs/(sequencers)/euclid.mdx?collection=docs&hash=2b527dd5b30464f2ddc4c049fb69d30b9fcc145ae96eece40109d80d98642180" -import * as file_27 from "../content/docs/meta.json?collection=meta&hash=2b527dd5b30464f2ddc4c049fb69d30b9fcc145ae96eece40109d80d98642180" -import * as file_28 from "../content/docs/(effects)/meta.json?collection=meta&hash=2b527dd5b30464f2ddc4c049fb69d30b9fcc145ae96eece40109d80d98642180" -import * as file_29 from "../content/docs/(modifiers)/meta.json?collection=meta&hash=2b527dd5b30464f2ddc4c049fb69d30b9fcc145ae96eece40109d80d98642180" -import * as file_30 from "../content/docs/(modulators)/meta.json?collection=meta&hash=2b527dd5b30464f2ddc4c049fb69d30b9fcc145ae96eece40109d80d98642180" -import * as file_31 from "../content/docs/(sequencers)/meta.json?collection=meta&hash=2b527dd5b30464f2ddc4c049fb69d30b9fcc145ae96eece40109d80d98642180" -import * as file_32 from "../content/docs/(sources)/meta.json?collection=meta&hash=2b527dd5b30464f2ddc4c049fb69d30b9fcc145ae96eece40109d80d98642180" -export const docs = [toRuntime("doc", file_0, {"path":"dsl.mdx","absolutePath":"/Users/danigb/Projects/Synthlet/synthlet/site/content/docs/dsl.mdx"}),toRuntime("doc", file_1, {"path":"guide.mdx","absolutePath":"/Users/danigb/Projects/Synthlet/synthlet/site/content/docs/guide.mdx"}),toRuntime("doc", file_2, {"path":"quick-start.mdx","absolutePath":"/Users/danigb/Projects/Synthlet/synthlet/site/content/docs/quick-start.mdx"}),toRuntime("doc", file_3, {"path":"synths.mdx","absolutePath":"/Users/danigb/Projects/Synthlet/synthlet/site/content/docs/synths.mdx"}),toRuntime("doc", file_4, {"path":"troubleshoo.mdx","absolutePath":"/Users/danigb/Projects/Synthlet/synthlet/site/content/docs/troubleshoo.mdx"}),toRuntime("doc", file_5, {"path":"(effects)/chorus-t.mdx","absolutePath":"/Users/danigb/Projects/Synthlet/synthlet/site/content/docs/(effects)/chorus-t.mdx"}),toRuntime("doc", file_6, {"path":"(effects)/chorus.mdx","absolutePath":"/Users/danigb/Projects/Synthlet/synthlet/site/content/docs/(effects)/chorus.mdx"}),toRuntime("doc", file_7, {"path":"(effects)/dattorro.mdx","absolutePath":"/Users/danigb/Projects/Synthlet/synthlet/site/content/docs/(effects)/dattorro.mdx"}),toRuntime("doc", file_8, {"path":"(effects)/granite.mdx","absolutePath":"/Users/danigb/Projects/Synthlet/synthlet/site/content/docs/(effects)/granite.mdx"}),toRuntime("doc", file_9, {"path":"(effects)/reverb-delay.mdx","absolutePath":"/Users/danigb/Projects/Synthlet/synthlet/site/content/docs/(effects)/reverb-delay.mdx"}),toRuntime("doc", file_10, {"path":"(modulators)/ad.mdx","absolutePath":"/Users/danigb/Projects/Synthlet/synthlet/site/content/docs/(modulators)/ad.mdx"}),toRuntime("doc", file_11, {"path":"(modulators)/adsr.mdx","absolutePath":"/Users/danigb/Projects/Synthlet/synthlet/site/content/docs/(modulators)/adsr.mdx"}),toRuntime("doc", file_12, {"path":"(modulators)/lfo.mdx","absolutePath":"/Users/danigb/Projects/Synthlet/synthlet/site/content/docs/(modulators)/lfo.mdx"}),toRuntime("doc", file_13, {"path":"(modulators)/param.mdx","absolutePath":"/Users/danigb/Projects/Synthlet/synthlet/site/content/docs/(modulators)/param.mdx"}),toRuntime("doc", file_14, {"path":"(modifiers)/ad-amp.mdx","absolutePath":"/Users/danigb/Projects/Synthlet/synthlet/site/content/docs/(modifiers)/ad-amp.mdx"}),toRuntime("doc", file_15, {"path":"(modifiers)/adsr-amp.mdx","absolutePath":"/Users/danigb/Projects/Synthlet/synthlet/site/content/docs/(modifiers)/adsr-amp.mdx"}),toRuntime("doc", file_16, {"path":"(modifiers)/clip-amp.mdx","absolutePath":"/Users/danigb/Projects/Synthlet/synthlet/site/content/docs/(modifiers)/clip-amp.mdx"}),toRuntime("doc", file_17, {"path":"(modifiers)/state-variable-filter.mdx","absolutePath":"/Users/danigb/Projects/Synthlet/synthlet/site/content/docs/(modifiers)/state-variable-filter.mdx"}),toRuntime("doc", file_18, {"path":"(modifiers)/vaf.mdx","absolutePath":"/Users/danigb/Projects/Synthlet/synthlet/site/content/docs/(modifiers)/vaf.mdx"}),toRuntime("doc", file_19, {"path":"(sources)/impulse.mdx","absolutePath":"/Users/danigb/Projects/Synthlet/synthlet/site/content/docs/(sources)/impulse.mdx"}),toRuntime("doc", file_20, {"path":"(sources)/ks.mdx","absolutePath":"/Users/danigb/Projects/Synthlet/synthlet/site/content/docs/(sources)/ks.mdx"}),toRuntime("doc", file_21, {"path":"(sources)/noise.mdx","absolutePath":"/Users/danigb/Projects/Synthlet/synthlet/site/content/docs/(sources)/noise.mdx"}),toRuntime("doc", file_22, {"path":"(sources)/polyblep.mdx","absolutePath":"/Users/danigb/Projects/Synthlet/synthlet/site/content/docs/(sources)/polyblep.mdx"}),toRuntime("doc", file_23, {"path":"(sources)/wavetable.mdx","absolutePath":"/Users/danigb/Projects/Synthlet/synthlet/site/content/docs/(sources)/wavetable.mdx"}),toRuntime("doc", file_24, {"path":"(sequencers)/arp.mdx","absolutePath":"/Users/danigb/Projects/Synthlet/synthlet/site/content/docs/(sequencers)/arp.mdx"}),toRuntime("doc", file_25, {"path":"(sequencers)/clock.mdx","absolutePath":"/Users/danigb/Projects/Synthlet/synthlet/site/content/docs/(sequencers)/clock.mdx"}),toRuntime("doc", file_26, {"path":"(sequencers)/euclid.mdx","absolutePath":"/Users/danigb/Projects/Synthlet/synthlet/site/content/docs/(sequencers)/euclid.mdx"})] -export const meta = [toRuntime("meta", file_27, {"path":"meta.json","absolutePath":"/Users/danigb/Projects/Synthlet/synthlet/site/content/docs/meta.json"}),toRuntime("meta", file_28, {"path":"(effects)/meta.json","absolutePath":"/Users/danigb/Projects/Synthlet/synthlet/site/content/docs/(effects)/meta.json"}),toRuntime("meta", file_29, {"path":"(modifiers)/meta.json","absolutePath":"/Users/danigb/Projects/Synthlet/synthlet/site/content/docs/(modifiers)/meta.json"}),toRuntime("meta", file_30, {"path":"(modulators)/meta.json","absolutePath":"/Users/danigb/Projects/Synthlet/synthlet/site/content/docs/(modulators)/meta.json"}),toRuntime("meta", file_31, {"path":"(sequencers)/meta.json","absolutePath":"/Users/danigb/Projects/Synthlet/synthlet/site/content/docs/(sequencers)/meta.json"}),toRuntime("meta", file_32, {"path":"(sources)/meta.json","absolutePath":"/Users/danigb/Projects/Synthlet/synthlet/site/content/docs/(sources)/meta.json"})] \ No newline at end of file +import * as file_9 from "../content/docs/(effects)/level-meter.mdx?collection=docs&hash=2b527dd5b30464f2ddc4c049fb69d30b9fcc145ae96eece40109d80d98642180" +import * as file_10 from "../content/docs/(effects)/lookahead-limiter.mdx?collection=docs&hash=2b527dd5b30464f2ddc4c049fb69d30b9fcc145ae96eece40109d80d98642180" +import * as file_11 from "../content/docs/(effects)/reverb-delay.mdx?collection=docs&hash=2b527dd5b30464f2ddc4c049fb69d30b9fcc145ae96eece40109d80d98642180" +import * as file_12 from "../content/docs/(modifiers)/ad-amp.mdx?collection=docs&hash=2b527dd5b30464f2ddc4c049fb69d30b9fcc145ae96eece40109d80d98642180" +import * as file_13 from "../content/docs/(modifiers)/adsr-amp.mdx?collection=docs&hash=2b527dd5b30464f2ddc4c049fb69d30b9fcc145ae96eece40109d80d98642180" +import * as file_14 from "../content/docs/(modifiers)/clip-amp.mdx?collection=docs&hash=2b527dd5b30464f2ddc4c049fb69d30b9fcc145ae96eece40109d80d98642180" +import * as file_15 from "../content/docs/(modifiers)/state-variable-filter.mdx?collection=docs&hash=2b527dd5b30464f2ddc4c049fb69d30b9fcc145ae96eece40109d80d98642180" +import * as file_16 from "../content/docs/(modifiers)/vaf.mdx?collection=docs&hash=2b527dd5b30464f2ddc4c049fb69d30b9fcc145ae96eece40109d80d98642180" +import * as file_17 from "../content/docs/(modulators)/ad.mdx?collection=docs&hash=2b527dd5b30464f2ddc4c049fb69d30b9fcc145ae96eece40109d80d98642180" +import * as file_18 from "../content/docs/(modulators)/adsr.mdx?collection=docs&hash=2b527dd5b30464f2ddc4c049fb69d30b9fcc145ae96eece40109d80d98642180" +import * as file_19 from "../content/docs/(modulators)/lfo.mdx?collection=docs&hash=2b527dd5b30464f2ddc4c049fb69d30b9fcc145ae96eece40109d80d98642180" +import * as file_20 from "../content/docs/(modulators)/param.mdx?collection=docs&hash=2b527dd5b30464f2ddc4c049fb69d30b9fcc145ae96eece40109d80d98642180" +import * as file_21 from "../content/docs/(sources)/impulse.mdx?collection=docs&hash=2b527dd5b30464f2ddc4c049fb69d30b9fcc145ae96eece40109d80d98642180" +import * as file_22 from "../content/docs/(sources)/ks.mdx?collection=docs&hash=2b527dd5b30464f2ddc4c049fb69d30b9fcc145ae96eece40109d80d98642180" +import * as file_23 from "../content/docs/(sources)/noise.mdx?collection=docs&hash=2b527dd5b30464f2ddc4c049fb69d30b9fcc145ae96eece40109d80d98642180" +import * as file_24 from "../content/docs/(sources)/polyblep.mdx?collection=docs&hash=2b527dd5b30464f2ddc4c049fb69d30b9fcc145ae96eece40109d80d98642180" +import * as file_25 from "../content/docs/(sources)/wavetable.mdx?collection=docs&hash=2b527dd5b30464f2ddc4c049fb69d30b9fcc145ae96eece40109d80d98642180" +import * as file_26 from "../content/docs/(sequencers)/arp.mdx?collection=docs&hash=2b527dd5b30464f2ddc4c049fb69d30b9fcc145ae96eece40109d80d98642180" +import * as file_27 from "../content/docs/(sequencers)/clock.mdx?collection=docs&hash=2b527dd5b30464f2ddc4c049fb69d30b9fcc145ae96eece40109d80d98642180" +import * as file_28 from "../content/docs/(sequencers)/euclid.mdx?collection=docs&hash=2b527dd5b30464f2ddc4c049fb69d30b9fcc145ae96eece40109d80d98642180" +import * as file_29 from "../content/docs/meta.json?collection=meta&hash=2b527dd5b30464f2ddc4c049fb69d30b9fcc145ae96eece40109d80d98642180" +import * as file_30 from "../content/docs/(effects)/meta.json?collection=meta&hash=2b527dd5b30464f2ddc4c049fb69d30b9fcc145ae96eece40109d80d98642180" +import * as file_31 from "../content/docs/(modifiers)/meta.json?collection=meta&hash=2b527dd5b30464f2ddc4c049fb69d30b9fcc145ae96eece40109d80d98642180" +import * as file_32 from "../content/docs/(modulators)/meta.json?collection=meta&hash=2b527dd5b30464f2ddc4c049fb69d30b9fcc145ae96eece40109d80d98642180" +import * as file_33 from "../content/docs/(sequencers)/meta.json?collection=meta&hash=2b527dd5b30464f2ddc4c049fb69d30b9fcc145ae96eece40109d80d98642180" +import * as file_34 from "../content/docs/(sources)/meta.json?collection=meta&hash=2b527dd5b30464f2ddc4c049fb69d30b9fcc145ae96eece40109d80d98642180" +export const docs = [toRuntime("doc", file_0, {"path":"dsl.mdx","absolutePath":"/Users/danigb/Projects/Synthlet/synthlet/site/content/docs/dsl.mdx"}),toRuntime("doc", file_1, {"path":"guide.mdx","absolutePath":"/Users/danigb/Projects/Synthlet/synthlet/site/content/docs/guide.mdx"}),toRuntime("doc", file_2, {"path":"quick-start.mdx","absolutePath":"/Users/danigb/Projects/Synthlet/synthlet/site/content/docs/quick-start.mdx"}),toRuntime("doc", file_3, {"path":"synths.mdx","absolutePath":"/Users/danigb/Projects/Synthlet/synthlet/site/content/docs/synths.mdx"}),toRuntime("doc", file_4, {"path":"troubleshoo.mdx","absolutePath":"/Users/danigb/Projects/Synthlet/synthlet/site/content/docs/troubleshoo.mdx"}),toRuntime("doc", file_5, {"path":"(effects)/chorus-t.mdx","absolutePath":"/Users/danigb/Projects/Synthlet/synthlet/site/content/docs/(effects)/chorus-t.mdx"}),toRuntime("doc", file_6, {"path":"(effects)/chorus.mdx","absolutePath":"/Users/danigb/Projects/Synthlet/synthlet/site/content/docs/(effects)/chorus.mdx"}),toRuntime("doc", file_7, {"path":"(effects)/dattorro.mdx","absolutePath":"/Users/danigb/Projects/Synthlet/synthlet/site/content/docs/(effects)/dattorro.mdx"}),toRuntime("doc", file_8, {"path":"(effects)/granite.mdx","absolutePath":"/Users/danigb/Projects/Synthlet/synthlet/site/content/docs/(effects)/granite.mdx"}),toRuntime("doc", file_9, {"path":"(effects)/level-meter.mdx","absolutePath":"/Users/danigb/Projects/Synthlet/synthlet/site/content/docs/(effects)/level-meter.mdx"}),toRuntime("doc", file_10, {"path":"(effects)/lookahead-limiter.mdx","absolutePath":"/Users/danigb/Projects/Synthlet/synthlet/site/content/docs/(effects)/lookahead-limiter.mdx"}),toRuntime("doc", file_11, {"path":"(effects)/reverb-delay.mdx","absolutePath":"/Users/danigb/Projects/Synthlet/synthlet/site/content/docs/(effects)/reverb-delay.mdx"}),toRuntime("doc", file_12, {"path":"(modifiers)/ad-amp.mdx","absolutePath":"/Users/danigb/Projects/Synthlet/synthlet/site/content/docs/(modifiers)/ad-amp.mdx"}),toRuntime("doc", file_13, {"path":"(modifiers)/adsr-amp.mdx","absolutePath":"/Users/danigb/Projects/Synthlet/synthlet/site/content/docs/(modifiers)/adsr-amp.mdx"}),toRuntime("doc", file_14, {"path":"(modifiers)/clip-amp.mdx","absolutePath":"/Users/danigb/Projects/Synthlet/synthlet/site/content/docs/(modifiers)/clip-amp.mdx"}),toRuntime("doc", file_15, {"path":"(modifiers)/state-variable-filter.mdx","absolutePath":"/Users/danigb/Projects/Synthlet/synthlet/site/content/docs/(modifiers)/state-variable-filter.mdx"}),toRuntime("doc", file_16, {"path":"(modifiers)/vaf.mdx","absolutePath":"/Users/danigb/Projects/Synthlet/synthlet/site/content/docs/(modifiers)/vaf.mdx"}),toRuntime("doc", file_17, {"path":"(modulators)/ad.mdx","absolutePath":"/Users/danigb/Projects/Synthlet/synthlet/site/content/docs/(modulators)/ad.mdx"}),toRuntime("doc", file_18, {"path":"(modulators)/adsr.mdx","absolutePath":"/Users/danigb/Projects/Synthlet/synthlet/site/content/docs/(modulators)/adsr.mdx"}),toRuntime("doc", file_19, {"path":"(modulators)/lfo.mdx","absolutePath":"/Users/danigb/Projects/Synthlet/synthlet/site/content/docs/(modulators)/lfo.mdx"}),toRuntime("doc", file_20, {"path":"(modulators)/param.mdx","absolutePath":"/Users/danigb/Projects/Synthlet/synthlet/site/content/docs/(modulators)/param.mdx"}),toRuntime("doc", file_21, {"path":"(sources)/impulse.mdx","absolutePath":"/Users/danigb/Projects/Synthlet/synthlet/site/content/docs/(sources)/impulse.mdx"}),toRuntime("doc", file_22, {"path":"(sources)/ks.mdx","absolutePath":"/Users/danigb/Projects/Synthlet/synthlet/site/content/docs/(sources)/ks.mdx"}),toRuntime("doc", file_23, {"path":"(sources)/noise.mdx","absolutePath":"/Users/danigb/Projects/Synthlet/synthlet/site/content/docs/(sources)/noise.mdx"}),toRuntime("doc", file_24, {"path":"(sources)/polyblep.mdx","absolutePath":"/Users/danigb/Projects/Synthlet/synthlet/site/content/docs/(sources)/polyblep.mdx"}),toRuntime("doc", file_25, {"path":"(sources)/wavetable.mdx","absolutePath":"/Users/danigb/Projects/Synthlet/synthlet/site/content/docs/(sources)/wavetable.mdx"}),toRuntime("doc", file_26, {"path":"(sequencers)/arp.mdx","absolutePath":"/Users/danigb/Projects/Synthlet/synthlet/site/content/docs/(sequencers)/arp.mdx"}),toRuntime("doc", file_27, {"path":"(sequencers)/clock.mdx","absolutePath":"/Users/danigb/Projects/Synthlet/synthlet/site/content/docs/(sequencers)/clock.mdx"}),toRuntime("doc", file_28, {"path":"(sequencers)/euclid.mdx","absolutePath":"/Users/danigb/Projects/Synthlet/synthlet/site/content/docs/(sequencers)/euclid.mdx"})] +export const meta = [toRuntime("meta", file_29, {"path":"meta.json","absolutePath":"/Users/danigb/Projects/Synthlet/synthlet/site/content/docs/meta.json"}),toRuntime("meta", file_30, {"path":"(effects)/meta.json","absolutePath":"/Users/danigb/Projects/Synthlet/synthlet/site/content/docs/(effects)/meta.json"}),toRuntime("meta", file_31, {"path":"(modifiers)/meta.json","absolutePath":"/Users/danigb/Projects/Synthlet/synthlet/site/content/docs/(modifiers)/meta.json"}),toRuntime("meta", file_32, {"path":"(modulators)/meta.json","absolutePath":"/Users/danigb/Projects/Synthlet/synthlet/site/content/docs/(modulators)/meta.json"}),toRuntime("meta", file_33, {"path":"(sequencers)/meta.json","absolutePath":"/Users/danigb/Projects/Synthlet/synthlet/site/content/docs/(sequencers)/meta.json"}),toRuntime("meta", file_34, {"path":"(sources)/meta.json","absolutePath":"/Users/danigb/Projects/Synthlet/synthlet/site/content/docs/(sources)/meta.json"})] \ No newline at end of file diff --git a/site/app/demos/granite/page.tsx b/site/app/demos/granite/page.tsx index 9eb50a5..8f6e8d1 100644 --- a/site/app/demos/granite/page.tsx +++ b/site/app/demos/granite/page.tsx @@ -7,6 +7,13 @@ export default function GranitePage() {

Granite

+ +

+ Song by + + Handheld Recordings + +

); } diff --git a/site/content/docs/(effects)/level-meter.mdx b/site/content/docs/(effects)/level-meter.mdx new file mode 100644 index 0000000..84928a2 --- /dev/null +++ b/site/content/docs/(effects)/level-meter.mdx @@ -0,0 +1,35 @@ +--- +title: Level Meter +description: A level meter +package: level-meter +--- + +Not an audio effect per se: a level meter that con be used to track output level meter. + +The level meter has two parts, one worklet that measures the signal: + +```ts +import { LevelMeter } from "synthlet"; + +const levelMeter = LevelMeter(audioContext); + +connect(levelMeter).connect(audioContext.destination); +``` + +And one UI component that displays the level: + +```ts +import { LevelMeterUI } from "synthlet"; + +const levelMeterUI = LevelMeterUI({ + orientation: "horizontal", +}); + +levelMeterUI.setCanvas(htmlCanvasElement); + +const numberOfChannels = 2; + +requestAnimationFrame(() => { + levelMeterUI.render(levelMeter.peaks, numberOfChannels); +}); +``` diff --git a/site/content/docs/(effects)/lookahead-limiter.mdx b/site/content/docs/(effects)/lookahead-limiter.mdx new file mode 100644 index 0000000..1d715ac --- /dev/null +++ b/site/content/docs/(effects)/lookahead-limiter.mdx @@ -0,0 +1,17 @@ +--- +title: Lookahead Limiter +description: A lookahead brickwall limiter +package: lookahead-limiter +--- + +A lookahead brickwall limiter that is designed to be used as last effect before the destination. It ensures the output level is always below the threshold. + +```ts +import { LookaheadLimiter } from "synthlet"; + +const limiter = LookaheadLimiter(audioContext, { + threshold: -1, +}); + +connect(limiter).connect(audioContext.destination); +``` diff --git a/site/content/docs/(sequencers)/euclid.mdx b/site/content/docs/(sequencers)/euclid.mdx index 1701552..dbed88b 100644 --- a/site/content/docs/(sequencers)/euclid.mdx +++ b/site/content/docs/(sequencers)/euclid.mdx @@ -9,19 +9,14 @@ import EuclidExample from "../../../examples/EuclidExample"; Generates an euclidean rhythm when connected to a clock: ```ts -import { - registerAllWorklets, - ClockNode, - EuclidNode, - ClaveDrumNode, -} from "synthlet"; +import { registerAllWorklets, Clock, Euclid, ClaveDrum } from "synthlet"; const ac = new AudioContext(); await registerAllWorklets(ac); -const clock = ClockNode(ac, { bpm: 60 }); -const euclid = EuclidNode(ac, { steps: 16, beats: 4, clock }); -const clave = ClaveDrumNode(ac, { trigger: euclid }); +const clock = Clock(ac, { bpm: 60 }); +const euclid = Euclid(ac, { steps: 16, beats: 4, clock }); +const clave = ClaveDrum(ac, { trigger: euclid }); clave.connect(ac.destination); clock.bpm.value = 150; ``` diff --git a/site/package.json b/site/package.json index beb10a3..ddb5e03 100644 --- a/site/package.json +++ b/site/package.json @@ -7,10 +7,10 @@ "dev": "next dev", "start": "next start", "postinstall": "fumadocs-mdx", - "deploy": "npm run deploy:build && npm run deploy:github", - "deploy:github": "npm run deploy:build && npm run deploy:fix && gh-pages -d out/ -t true", - "deploy:build": "DEPLOY=true next build", - "deploy:fix": "cp out/docs/quick-start.html out/docs/index.html" + "deploy": "npm run deploy:build && npm run deploy:push", + "deploy:build": "DEPLOY=true next build && npm run deploy:fix", + "deploy:fix": "cp out/docs/quick-start.html out/docs/index.html", + "deploy:push": "gh-pages -d out/ -t true" }, "dependencies": { "fumadocs-core": "^13.4.10",