From 930dabe4d6e4952445b952cb88c04a0bef49ee1d Mon Sep 17 00:00:00 2001 From: Felix Roos Date: Thu, 5 Jun 2025 09:54:44 +0200 Subject: [PATCH] dough as a singleton worklet with voice allocation --- packages/superdough/dough.mjs | 78 ++++++++++++++++++++++++++++++-- packages/superdough/worklets.mjs | 25 +++------- packages/webaudio/supradough.mjs | 40 ++++++++++------ 3 files changed, 104 insertions(+), 39 deletions(-) diff --git a/packages/superdough/dough.mjs b/packages/superdough/dough.mjs index 32ca86ef7..65cc3793f 100644 --- a/packages/superdough/dough.mjs +++ b/packages/superdough/dough.mjs @@ -351,9 +351,8 @@ const defaultDefaultValues = { let getDefaultValue = (key) => defaultDefaultValues[key]; -export class Dough { - init(value, sampleRate) { - +export class DoughVoice { + constructor(value) { // params without defaults: /* bank, @@ -427,7 +426,7 @@ export class Dough { this._lpf = this.cutoff ? new Lpf() : null; this._adsr = new ADSR(); - this.piOverSr = Math.PI / sampleRate; + this.piOverSr = Math.PI / value.sampleRate; this.eighthOverLogHalf = 0.125 / Math.log(0.5); } // credits to pulu: https://github.com/felixroos/kabelsalat/issues/35 @@ -455,7 +454,76 @@ export class Dough { const env = this._adsr.update(t, gate, this.attack, this.decay, this.sustain, this.release); s = s * env; - s = s * this.postgain * .2 + s = s * this.postgain * 0.2; return s; } } + +// this class is the interface to the "outer world" +// it handles spawning and despawning of DoughVoice's +export class Dough { + voices = []; // DoughVoice[] + vid = 0; + q = []; + l = 0; // tbd + r = 0; // tbd + t = 0; + // sampleRate: number, currentTime: number (seconds) + constructor(sampleRate, currentTime) { + this.sampleRate = sampleRate; + this.t = Math.floor(currentTime * sampleRate); // samples + // console.log('init dough', this.sampleRate, this.t); + } + scheduleSpawn(value) { + const time = value._begin; // set from supradough.mjs + this.schedule({ time, type: 'spawn', arg: value }); + } + spawn(value) { + value.id = this.vid++; + const voice = new DoughVoice(value); + this.voices.push(voice); + console.log('spawn', voice.id, value._holdDuration, 'voices:', this.voices.length); + // schedule removal + const endTime = Math.ceil(value._end * this.sampleRate); + this.schedule({ time: endTime /* + 48000 */, type: 'despawn', arg: voice.id }); + } + despawn(vid) { + this.voices = this.voices.filter((v) => v.id !== vid); + console.log('despawn', vid, 'voices:', this.voices.length); + } + // schedules a function call with a single argument + // msg = {time:number,type:string, arg: any} + // the Dough method "type" will be called with "arg" at "time" + schedule(msg) { + if (!this.q.length) { + // if empty, just push + this.q.push(msg); + return; + } + // not empty + // find index where msg.time fits in + let i = 0; + while (i < this.q.length && this.q[i].time < msg.time) { + i++; + } + // this ensures q stays sorted by time, so we only need to check q[0] + this.q.splice(i, 0, msg); + } + // maybe update should be called once per block instead for perf reasons? + update() { + // go over q + while (this.q.length > 0 && this.q[0].time <= this.t) { + // console.log('schedule', this.q[0]); + // trigger due messages. q is sorted, so we only need to check q[0] + this[this.q[0].type](this.q[0].arg); // type is expected to be a Dough method + this.q.shift(); + } + // add active voices + let sum = 0; + for (let v = 0; v < this.voices.length; v++) { + sum += this.voices[v].update(this.t / this.sampleRate); + } + this.t++; + return sum; + } +} diff --git a/packages/superdough/worklets.mjs b/packages/superdough/worklets.mjs index 7633c6441..5e5494255 100644 --- a/packages/superdough/worklets.mjs +++ b/packages/superdough/worklets.mjs @@ -900,36 +900,23 @@ registerProcessor('byte-beat-processor', ByteBeatProcessor); class DoughProcessor extends AudioWorkletProcessor { constructor() { super(); - this.dough = new Dough(); - this.port.onmessage = (event) => this.dough.init(event.data, sampleRate); + this.dough = new Dough(sampleRate, currentTime); + this.port.onmessage = (event) => { + event.data.sampleRate = sampleRate; + this.dough.scheduleSpawn(event.data); + }; } - process(inputs, outputs, params) { if (this.disconnected) { return false; } - if (this.dough._begin === undefined) { - return true; - } - if (currentTime <= this.dough._begin) { - return true; - } - if (currentTime >= this.dough._end + 1) { - return false; // this causes cracks for some reason (seems to kick in too early for some reason) - // it works with + 1 but not sure why this is needed - } - if (this.t === undefined) { - this.t = Math.floor(this.dough._begin * sampleRate); - } const output = outputs[0]; for (let i = 0; i < output[0].length; i++) { - // const out = this.dough.update(currentTime); - const out = this.dough.update(this.t / sampleRate); + const out = this.dough.update(); for (let c = 0; c < output.length; c++) { //prevent speaker blowout via clipping if threshold exceeds output[c][i] = clamp(out, -1, 1); } - this.t = this.t + 1; } return true; // keep the audio processing going } diff --git a/packages/webaudio/supradough.mjs b/packages/webaudio/supradough.mjs index dcf2cf4c0..f774b7180 100644 --- a/packages/webaudio/supradough.mjs +++ b/packages/webaudio/supradough.mjs @@ -1,10 +1,27 @@ import { Pattern } from '@strudel/core'; -import { connectToDestination, destroyAudioWorkletNode, getAudioContext, webAudioTimeout } from 'superdough'; +import { connectToDestination, getAudioContext, getWorklet } from 'superdough'; + +let doughWorklet; + +function initDoughWorklet() { + const ac = getAudioContext(); + doughWorklet = getWorklet( + ac, + 'dough-processor', + {}, + { + outputChannelCount: [2], + }, + ); + /* webAudioTimeout(ac, () => destroyAudioWorkletNode(doughWorklet), begin, end); */ + connectToDestination(doughWorklet); // channels? +} + Pattern.prototype.supradough = function () { return this.onTrigger((_, hap, __, cps, begin) => { const { value } = hap; + // todo: could these calculations be made inside dough as well? value.freq = getFrequencyFromValue(hap.value); - const ac = getAudioContext(); const release = getADSRValues( [value.attack, value.decay, value.sustain, value.release], @@ -17,19 +34,12 @@ Pattern.prototype.supradough = function () { const end = holdEnd + release + 0.01; value._begin = begin; // these are needed for the gate signal value._end = end; - value._holdEnd = holdEnd + value._holdEnd = holdEnd; + value._holdDuration = duration + release; - let o = getWorklet( - ac, - 'dough-processor', - {}, - { - outputChannelCount: [2], - }, - ); - - o.port.postMessage(value); // send value to worklet - webAudioTimeout(ac, () => destroyAudioWorkletNode(o), begin, end); - connectToDestination(o); // channels? + if (!doughWorklet) { + initDoughWorklet(); + } + doughWorklet.port.postMessage(value); }, 1); };