From 9d26ea5cd76bbab8c4dfbb7119343fb46ef73ae9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rapha=C3=ABl=20Forment?= Date: Mon, 6 Oct 2025 16:36:59 +0200 Subject: [PATCH] OK ready --- public/worklets/chorus-processor.js | 167 ++++++ public/worklets/fm-processor.js | 139 +++-- public/worklets/fold-crush-processor.js | 245 +++++++-- public/worklets/output-limiter.js | 10 +- public/worklets/ring-mod-processor.js | 92 ++++ src/components/controls/EffectsBar.tsx | 2 +- src/components/ui/Knob.tsx | 37 +- src/config/parameters.ts | 173 ++++-- src/domain/audio/AudioPlayer.ts | 2 + src/domain/audio/effects/ChorusEffect.ts | 102 ++++ src/domain/audio/effects/EffectsChain.ts | 10 + src/domain/audio/effects/FoldCrushEffect.ts | 3 + src/domain/audio/effects/ReverbEffect.ts | 581 +++++--------------- src/domain/audio/effects/RingModEffect.ts | 61 ++ src/utils/fmPatches.ts | 2 +- 15 files changed, 1031 insertions(+), 595 deletions(-) create mode 100644 public/worklets/chorus-processor.js create mode 100644 public/worklets/ring-mod-processor.js create mode 100644 src/domain/audio/effects/ChorusEffect.ts create mode 100644 src/domain/audio/effects/RingModEffect.ts diff --git a/public/worklets/chorus-processor.js b/public/worklets/chorus-processor.js new file mode 100644 index 00000000..4f7a77c9 --- /dev/null +++ b/public/worklets/chorus-processor.js @@ -0,0 +1,167 @@ +class ChorusProcessor extends AudioWorkletProcessor { + constructor() { + super() + + this.mode = 'chorus' + this.rate = 0.5 + this.depth = 0.5 + this.feedback = 0 + this.spread = 0.3 + this.bypassed = false + + this.delayBufferSize = Math.floor(sampleRate * 0.05) + this.delayBufferL = new Float32Array(this.delayBufferSize) + this.delayBufferR = new Float32Array(this.delayBufferSize) + this.writeIndex = 0 + + this.lfoPhase = 0 + this.lfoPhaseRight = 0 + + this.port.onmessage = (event) => { + const { type, value } = event.data + switch (type) { + case 'mode': + this.mode = value + break + case 'frequency': + this.rate = value + break + case 'depth': + this.depth = value + break + case 'feedback': + this.feedback = value + break + case 'spread': + this.spread = value + break + case 'bypass': + this.bypassed = value + break + } + } + } + + processChorus(sampleL, sampleR) { + const baseDelay = 15 + const maxDepth = 8 + + this.lfoPhase += this.rate / sampleRate + this.lfoPhaseRight += this.rate / sampleRate + + if (this.lfoPhase >= 1) this.lfoPhase -= 1 + if (this.lfoPhaseRight >= 1) this.lfoPhaseRight -= 1 + + const spreadPhase = this.spread * 0.5 + const lfoL = Math.sin(this.lfoPhase * Math.PI * 2) + const lfoR = Math.sin((this.lfoPhaseRight + spreadPhase) * Math.PI * 2) + + const delayTimeL = baseDelay + lfoL * maxDepth * this.depth + const delayTimeR = baseDelay + lfoR * maxDepth * this.depth + + const delaySamplesL = (delayTimeL / 1000) * sampleRate + const delaySamplesR = (delayTimeR / 1000) * sampleRate + + const readIndexL = (this.writeIndex - delaySamplesL + this.delayBufferSize) % this.delayBufferSize + const readIndexR = (this.writeIndex - delaySamplesR + this.delayBufferSize) % this.delayBufferSize + + const readIndexL0 = Math.floor(readIndexL) % this.delayBufferSize + const readIndexL1 = (readIndexL0 + 1) % this.delayBufferSize + const fracL = readIndexL - Math.floor(readIndexL) + + const readIndexR0 = Math.floor(readIndexR) % this.delayBufferSize + const readIndexR1 = (readIndexR0 + 1) % this.delayBufferSize + const fracR = readIndexR - Math.floor(readIndexR) + + const delayedL = this.delayBufferL[readIndexL0] * (1 - fracL) + this.delayBufferL[readIndexL1] * fracL + const delayedR = this.delayBufferR[readIndexR0] * (1 - fracR) + this.delayBufferR[readIndexR1] * fracR + + this.delayBufferL[this.writeIndex] = sampleL + delayedL * this.feedback + this.delayBufferR[this.writeIndex] = sampleR + delayedR * this.feedback + + return [delayedL, delayedR] + } + + processFlanger(sampleL, sampleR) { + const baseDelay = 1 + const maxDepth = 5 + + this.lfoPhase += this.rate / sampleRate + this.lfoPhaseRight += this.rate / sampleRate + + if (this.lfoPhase >= 1) this.lfoPhase -= 1 + if (this.lfoPhaseRight >= 1) this.lfoPhaseRight -= 1 + + const spreadPhase = this.spread * 0.5 + const lfoL = Math.sin(this.lfoPhase * Math.PI * 2) + const lfoR = Math.sin((this.lfoPhaseRight + spreadPhase) * Math.PI * 2) + + const delayTimeL = baseDelay + lfoL * maxDepth * this.depth + const delayTimeR = baseDelay + lfoR * maxDepth * this.depth + + const delaySamplesL = (delayTimeL / 1000) * sampleRate + const delaySamplesR = (delayTimeR / 1000) * sampleRate + + const readIndexL = (this.writeIndex - delaySamplesL + this.delayBufferSize) % this.delayBufferSize + const readIndexR = (this.writeIndex - delaySamplesR + this.delayBufferSize) % this.delayBufferSize + + const readIndexL0 = Math.floor(readIndexL) % this.delayBufferSize + const readIndexL1 = (readIndexL0 + 1) % this.delayBufferSize + const fracL = readIndexL - Math.floor(readIndexL) + + const readIndexR0 = Math.floor(readIndexR) % this.delayBufferSize + const readIndexR1 = (readIndexR0 + 1) % this.delayBufferSize + const fracR = readIndexR - Math.floor(readIndexR) + + const delayedL = this.delayBufferL[readIndexL0] * (1 - fracL) + this.delayBufferL[readIndexL1] * fracL + const delayedR = this.delayBufferR[readIndexR0] * (1 - fracR) + this.delayBufferR[readIndexR1] * fracR + + this.delayBufferL[this.writeIndex] = sampleL + delayedL * this.feedback * 0.9 + this.delayBufferR[this.writeIndex] = sampleR + delayedR * this.feedback * 0.9 + + return [delayedL, delayedR] + } + + process(inputs, outputs) { + const input = inputs[0] + const output = outputs[0] + + if (!input || input.length === 0 || !output || output.length === 0) { + return true + } + + const inputL = input[0] + const inputR = input[1] || input[0] + const outputL = output[0] + const outputR = output[1] || output[0] + + if (!inputL || !outputL) { + return true + } + + for (let i = 0; i < inputL.length; i++) { + if (this.bypassed) { + outputL[i] = inputL[i] + if (outputR) outputR[i] = inputR[i] + continue + } + + let processedL, processedR + + if (this.mode === 'flanger') { + [processedL, processedR] = this.processFlanger(inputL[i], inputR[i]) + } else { + [processedL, processedR] = this.processChorus(inputL[i], inputR[i]) + } + + outputL[i] = processedL + if (outputR) outputR[i] = processedR + + this.writeIndex = (this.writeIndex + 1) % this.delayBufferSize + } + + return true + } +} + +registerProcessor('chorus-processor', ChorusProcessor) diff --git a/public/worklets/fm-processor.js b/public/worklets/fm-processor.js index 0b17450f..245aa64d 100644 --- a/public/worklets/fm-processor.js +++ b/public/worklets/fm-processor.js @@ -35,6 +35,16 @@ class FMProcessor extends AudioWorkletProcessor { this.pitchLFODepth = 0.1 this.pitchLFOBaseRate = 2.0 + this.sampleHoldValue = 0 + this.sampleHoldCounter = 0 + this.sampleHoldInterval = 500 + this.driftValue = 0 + this.perlinA = Math.random() * 2 - 1 + this.perlinB = Math.random() * 2 - 1 + this.perlinPhase = 0 + this.perlinInterval = 2000 + this.chaosX = 0.5 + this.port.onmessage = (event) => { const { type, value } = event.data switch (type) { @@ -72,6 +82,13 @@ class FMProcessor extends AudioWorkletProcessor { this.phase4 = 0 this.sampleCount = 0 this.feedbackSample = 0 + this.sampleHoldValue = 0 + this.sampleHoldCounter = 0 + this.driftValue = 0 + this.perlinA = Math.random() * 2 - 1 + this.perlinB = Math.random() * 2 - 1 + this.perlinPhase = 0 + this.chaosX = 0.5 break case 'loopLength': this.loopLength = value @@ -96,6 +113,32 @@ class FMProcessor extends AudioWorkletProcessor { return normalizedPhase % 1 < 0.5 ? 1 : -1 case 3: // sawtooth return 2 * (normalizedPhase % 1) - 1 + case 4: // sample & hold random (glitchy) + this.sampleHoldCounter++ + if (this.sampleHoldCounter >= this.sampleHoldInterval) { + this.sampleHoldValue = Math.random() * 2 - 1 + this.sampleHoldCounter = 0 + this.sampleHoldInterval = Math.floor(100 + Math.random() * 900) + } + return this.sampleHoldValue + case 5: // drift (random walk) + this.driftValue += (Math.random() - 0.5) * 0.002 + this.driftValue = Math.max(-1, Math.min(1, this.driftValue)) + return this.driftValue + case 6: // perlin noise (smooth random) + this.perlinPhase++ + if (this.perlinPhase >= this.perlinInterval) { + this.perlinA = this.perlinB + this.perlinB = Math.random() * 2 - 1 + this.perlinPhase = 0 + this.perlinInterval = Math.floor(1000 + Math.random() * 2000) + } + const t = this.perlinPhase / this.perlinInterval + const smoothT = t * t * (3 - 2 * t) + return this.perlinA + (this.perlinB - this.perlinA) * smoothT + case 7: // chaos (logistic map) + this.chaosX = 3.9 * this.chaosX * (1 - this.chaosX) + return this.chaosX * 2 - 1 default: return 0 } @@ -103,7 +146,7 @@ class FMProcessor extends AudioWorkletProcessor { synthesize(algorithm) { const TWO_PI = Math.PI * 2 - const sampleRate = 44100 + const sampleRate = globalThis.sampleRate || 44100 const avgDiff = (Math.abs(this.opLevel1 - this.opLevel3) + Math.abs(this.opLevel2 - this.opLevel4)) / (2 * 255) const pitchLFORate = this.pitchLFOBaseRate * (0.3 + avgDiff * 1.4) @@ -129,6 +172,17 @@ class FMProcessor extends AudioWorkletProcessor { const level3 = (this.opLevel3 / 255.0) * (1 + this.lfoDepth * lfo3) const level4 = (this.opLevel4 / 255.0) * (1 + this.lfoDepth * lfo4) + const nyquist = sampleRate / 2 + const maxCarrierFreq = Math.max( + modulatedBaseFreq * this.frequencyRatios[0], + modulatedBaseFreq * this.frequencyRatios[1], + modulatedBaseFreq * this.frequencyRatios[2], + modulatedBaseFreq * this.frequencyRatios[3] + ) + const antiAliasFactor = Math.min(1.0, nyquist / (maxCarrierFreq * 5)) + const modDepth = 10 * antiAliasFactor + const modDepthLight = 1.5 * antiAliasFactor + this.lfoPhase1 += (this.lfoRate1 * TWO_PI) / sampleRate this.lfoPhase2 += (this.lfoRate2 * TWO_PI) / sampleRate this.lfoPhase3 += (this.lfoRate3 * TWO_PI) / sampleRate @@ -157,12 +211,12 @@ class FMProcessor extends AudioWorkletProcessor { case 1: { const op1 = Math.sin(this.phase1) * level1 - const mod1 = op1 * 10 + const mod1 = op1 * modDepth const op2 = Math.sin(this.phase2 + mod1) * level2 - const mod2 = op2 * 10 + const mod2 = op2 * modDepth const op3 = Math.sin(this.phase3 + mod2) * level3 - const mod3 = op3 * 10 - const op4 = Math.sin(this.phase4 + mod3 + this.feedbackSample * this.feedback * 10) * level4 + const mod3 = op3 * modDepth + const op4 = Math.sin(this.phase4 + mod3 + this.feedbackSample * this.feedback * modDepth) * level4 output = op4 this.feedbackSample = op4 this.phase1 += freq1 * this.playbackRate @@ -174,10 +228,10 @@ class FMProcessor extends AudioWorkletProcessor { case 2: { const op1 = Math.sin(this.phase1) * level1 - const mod1 = op1 * 10 + const mod1 = op1 * modDepth const op2 = Math.sin(this.phase2 + mod1) * level2 const op3 = Math.sin(this.phase3) * level3 - const mod3 = op3 * 10 + const mod3 = op3 * modDepth const op4 = Math.sin(this.phase4 + mod3) * level4 output = (op2 + op4) * 0.5 this.phase1 += freq1 * this.playbackRate @@ -189,9 +243,9 @@ class FMProcessor extends AudioWorkletProcessor { case 3: { const op1 = Math.sin(this.phase1) * level1 - const mod1 = op1 * 10 + const mod1 = op1 * modDepth const op2 = Math.sin(this.phase2 + mod1) * level2 - const mod2 = op2 * 10 + const mod2 = op2 * modDepth const op3 = Math.sin(this.phase3 + mod2) * level3 const op4 = Math.sin(this.phase4) * level4 output = (op3 + op4) * 0.5 @@ -204,11 +258,11 @@ class FMProcessor extends AudioWorkletProcessor { case 4: { const op1 = Math.sin(this.phase1) * level1 - const mod1 = op1 * 10 + const mod1 = op1 * modDepth const op2 = Math.sin(this.phase2) * level2 - const mod2 = op2 * 10 + const mod2 = op2 * modDepth const op3 = Math.sin(this.phase3 + mod1 + mod2) * level3 - const mod3 = op3 * 10 + const mod3 = op3 * modDepth const op4 = Math.sin(this.phase4 + mod3) * level4 output = op4 this.phase1 += freq1 * this.playbackRate @@ -220,11 +274,11 @@ class FMProcessor extends AudioWorkletProcessor { case 5: { const op1 = Math.sin(this.phase1) * level1 - const mod1 = op1 * 10 + const mod1 = op1 * modDepth const op2 = Math.sin(this.phase2 + mod1) * level2 - const mod2 = op2 * 10 + const mod2 = op2 * modDepth const op3 = Math.sin(this.phase3 + mod1) * level3 - const mod3 = op3 * 10 + const mod3 = op3 * modDepth const op4 = Math.sin(this.phase4 + mod2 + mod3) * level4 output = op4 this.phase1 += freq1 * this.playbackRate @@ -236,7 +290,7 @@ class FMProcessor extends AudioWorkletProcessor { case 6: { const op1 = Math.sin(this.phase1) * level1 - const mod1 = op1 * 10 + const mod1 = op1 * modDepth const op2 = Math.sin(this.phase2 + mod1) * level2 const op3 = Math.sin(this.phase3) * level3 const op4 = Math.sin(this.phase4) * level4 @@ -250,11 +304,11 @@ class FMProcessor extends AudioWorkletProcessor { case 7: { const op1 = Math.sin(this.phase1) * level1 - const mod1 = op1 * 10 + const mod1 = op1 * modDepth const op2 = Math.sin(this.phase2 + mod1) * level2 - const mod2 = op2 * 1.5 + const mod2 = op2 * modDepthLight const op3 = Math.sin(this.phase3 + mod1) * level3 - const mod3 = op3 * 1.5 + const mod3 = op3 * modDepthLight const op4 = Math.sin(this.phase4 + mod2 + mod3) * level4 output = op4 this.phase1 += freq1 * this.playbackRate @@ -266,10 +320,10 @@ class FMProcessor extends AudioWorkletProcessor { case 8: { const op1 = Math.sin(this.phase1) * level1 - const mod1 = op1 * 10 + const mod1 = op1 * modDepth const op3 = Math.sin(this.phase3 + mod1) * level3 const op2 = Math.sin(this.phase2) * level2 - const mod2 = op2 * 10 + const mod2 = op2 * modDepth const op4 = Math.sin(this.phase4 + mod2) * level4 output = (op3 + op4) * 0.5 this.phase1 += freq1 * this.playbackRate @@ -281,10 +335,10 @@ class FMProcessor extends AudioWorkletProcessor { case 9: { const op1 = Math.sin(this.phase1) * level1 - const mod1 = op1 * 10 + const mod1 = op1 * modDepth const op4 = Math.sin(this.phase4 + mod1) * level4 const op2 = Math.sin(this.phase2) * level2 - const mod2 = op2 * 10 + const mod2 = op2 * modDepth const op3 = Math.sin(this.phase3 + mod2) * level3 output = (op3 + op4) * 0.5 this.phase1 += freq1 * this.playbackRate @@ -296,10 +350,10 @@ class FMProcessor extends AudioWorkletProcessor { case 10: { const op1 = Math.sin(this.phase1) * level1 - const mod1 = op1 * 10 + const mod1 = op1 * modDepth const op2 = Math.sin(this.phase2 + mod1) * level2 const op3 = Math.sin(this.phase3) * level3 - const mod3 = op3 * 10 + const mod3 = op3 * modDepth const op4 = Math.sin(this.phase4 + mod3) * level4 output = (op2 + op4) * 0.5 this.phase1 += freq1 * this.playbackRate @@ -312,9 +366,9 @@ class FMProcessor extends AudioWorkletProcessor { case 11: { const op1 = Math.sin(this.phase1) * level1 const op2 = Math.sin(this.phase2) * level2 - const mod2 = op2 * 10 + const mod2 = op2 * modDepth const op3 = Math.sin(this.phase3 + mod2) * level3 - const mod3 = op3 * 10 + const mod3 = op3 * modDepth const op4 = Math.sin(this.phase4 + mod3) * level4 output = (op1 + op4) * 0.5 this.phase1 += freq1 * this.playbackRate @@ -326,9 +380,9 @@ class FMProcessor extends AudioWorkletProcessor { case 12: { const op1 = Math.sin(this.phase1) * level1 - const mod1 = op1 * 10 + const mod1 = op1 * modDepth const op2 = Math.sin(this.phase2 + mod1) * level2 - const mod2 = op2 * 10 + const mod2 = op2 * modDepth const op4 = Math.sin(this.phase4 + mod2) * level4 const op3 = Math.sin(this.phase3) * level3 output = (op3 + op4) * 0.5 @@ -341,11 +395,11 @@ class FMProcessor extends AudioWorkletProcessor { case 13: { const op1 = Math.sin(this.phase1) * level1 - const mod1 = op1 * 10 + const mod1 = op1 * modDepth const op2 = Math.sin(this.phase2 + mod1) * level2 - const mod2 = op2 * 10 + const mod2 = op2 * modDepth const op3 = Math.sin(this.phase3 + mod1) * level3 - const mod3 = op3 * 10 + const mod3 = op3 * modDepth const op4 = Math.sin(this.phase4 + mod2 + mod3) * level4 output = op4 this.phase1 += freq1 * this.playbackRate @@ -357,11 +411,11 @@ class FMProcessor extends AudioWorkletProcessor { case 14: { const op1 = Math.sin(this.phase1) * level1 - const mod1 = op1 * 10 + const mod1 = op1 * modDepth const op3 = Math.sin(this.phase3) * level3 - const mod3 = op3 * 10 + const mod3 = op3 * modDepth const op4 = Math.sin(this.phase4 + mod3) * level4 - const mod4 = op4 * 1.5 + const mod4 = op4 * modDepthLight const op2 = Math.sin(this.phase2 + mod1 + mod4) * level2 output = op2 this.phase1 += freq1 * this.playbackRate @@ -373,11 +427,11 @@ class FMProcessor extends AudioWorkletProcessor { case 15: { const op1 = Math.sin(this.phase1) * level1 - const mod1 = op1 * 10 + const mod1 = op1 * modDepth const op2 = Math.sin(this.phase2) * level2 - const mod2 = op2 * 10 + const mod2 = op2 * modDepth const op3 = Math.sin(this.phase3) * level3 - const mod3 = op3 * 10 + const mod3 = op3 * modDepth const op4 = Math.sin(this.phase4 + mod1 + mod2 + mod3) * level4 output = op4 this.phase1 += freq1 * this.playbackRate @@ -391,11 +445,10 @@ class FMProcessor extends AudioWorkletProcessor { output = 0 } - const TWO_PI_LIMIT = TWO_PI * 10 - if (this.phase1 > TWO_PI_LIMIT) this.phase1 -= TWO_PI_LIMIT - if (this.phase2 > TWO_PI_LIMIT) this.phase2 -= TWO_PI_LIMIT - if (this.phase3 > TWO_PI_LIMIT) this.phase3 -= TWO_PI_LIMIT - if (this.phase4 > TWO_PI_LIMIT) this.phase4 -= TWO_PI_LIMIT + this.phase1 = this.phase1 % TWO_PI + this.phase2 = this.phase2 % TWO_PI + this.phase3 = this.phase3 % TWO_PI + this.phase4 = this.phase4 % TWO_PI return output } diff --git a/public/worklets/fold-crush-processor.js b/public/worklets/fold-crush-processor.js index 6d0ee5ee..500638ad 100644 --- a/public/worklets/fold-crush-processor.js +++ b/public/worklets/fold-crush-processor.js @@ -8,6 +8,24 @@ class FoldCrushProcessor extends AudioWorkletProcessor { this.crushAmount = 0 this.bitcrushPhase = 0 this.lastCrushedValue = 0 + this.glitchAmount = 0 + + this.dcBlockerX = 0 + this.dcBlockerY = 0 + this.dcBlockerCoeff = 0.995 + + this.preEmphasisLast = 0 + this.deEmphasisLast = 0 + + this.grainBuffer = new Float32Array(256) + this.grainBufferIndex = 0 + this.grainPlaybackActive = false + this.grainPlaybackIndex = 0 + this.grainPlaybackStart = 0 + this.grainPlaybackLength = 0 + this.grainPlaybackRemaining = 0 + this.grainReversed = false + this.grainInverted = false this.port.onmessage = (event) => { const { type, value } = event.data @@ -24,6 +42,9 @@ class FoldCrushProcessor extends AudioWorkletProcessor { case 'crushAmount': this.crushAmount = value break + case 'glitchAmount': + this.glitchAmount = value + break } } } @@ -32,62 +53,128 @@ class FoldCrushProcessor extends AudioWorkletProcessor { return Math.max(min, Math.min(max, x)) } - mod(x, y) { - return ((x % y) + y) % y + dcBlocker(x) { + this.dcBlockerY = x - this.dcBlockerX + this.dcBlockerCoeff * this.dcBlockerY + this.dcBlockerX = x + return this.dcBlockerY } - squash(x) { - return x / (1 + Math.abs(x)) + preEmphasis(x) { + const amount = 0.7 + const output = x - amount * this.preEmphasisLast + this.preEmphasisLast = x + return output } - soft(x, k) { - return Math.tanh(x * (1 + k)) + deEmphasis(x) { + const amount = 0.7 + const output = x + amount * this.deEmphasisLast + this.deEmphasisLast = output + return output } - hard(x, k) { - return this.clamp((1 + k) * x, -1, 1) + tube(x, k) { + const gain = 1 + k * 2 + const biasAmount = 0.1 * k + const bias = biasAmount + + const driven = (x + bias) * gain + + if (driven > 1.0) { + return 1.0 - Math.exp(-(driven - 1.0) * 2) + } else if (driven < -1.0) { + return -1.0 + Math.exp((driven + 1.0) * 1.5) + } else { + return Math.tanh(driven * 1.2) + } + } + + tape(x, k) { + const gain = 1 + k * 1.5 + const driven = x * gain + + const threshold = 0.3 + const knee = 0.5 + + if (Math.abs(driven) < threshold) { + return driven + } else { + const excess = Math.abs(driven) - threshold + const compressed = threshold + excess / (1 + excess / knee) + return Math.sign(driven) * compressed + } + } + + fuzz(x, k) { + const gain = 1 + k * 10 + const driven = x * gain + + const fuzzAmount = Math.tanh(driven * 3) + const hardClip = this.clamp(driven, -0.9, 0.9) + + const mix = Math.min(k / 2, 0.7) + return fuzzAmount * mix + hardClip * (1 - mix) } fold(x, k) { - let y = (1 + 0.5 * k) * x - const window = this.mod(y + 1, 4) - return 1 - Math.abs(window - 2) - } + const gain = 1 + k * 3 + let y = x * gain - cubic(x, k) { - const t = this.squash(Math.log1p(k)) - const cubic = (x - (t / 3) * x * x * x) / (1 - t / 3) - return this.soft(cubic, k) - } - - diode(x, k) { - const g = 1 + 2 * k - const t = this.squash(Math.log1p(k)) - const bias = 0.07 * t - const pos = this.soft(x + bias, 2 * k) - const neg = this.soft(-x + bias, 2 * k) - const y = pos - neg - const sech = 1 / Math.cosh(g * bias) - const sech2 = sech * sech - const denom = Math.max(1e-8, 2 * g * sech2) - return this.soft(y / denom, k) - } - - processWavefolder(sample) { - switch (this.clipMode) { - case 'soft': - return this.soft(sample, this.drive) - case 'hard': - return this.hard(sample, this.drive) - case 'fold': - return this.fold(sample, this.drive) - case 'cubic': - return this.cubic(sample, this.drive) - case 'diode': - return this.diode(sample, this.drive) - default: - return sample + while (y > 1.0 || y < -1.0) { + if (y > 1.0) { + y = 2.0 - y + } else if (y < -1.0) { + y = -2.0 - y + } } + + return Math.sin(y * Math.PI / 2) + } + + crush(x, k) { + const gain = 1 + k * 4 + let driven = x * gain + + const foldThreshold = 0.8 + let folds = 0 + while (Math.abs(driven) > foldThreshold && folds < 8) { + if (driven > foldThreshold) { + driven = 2 * foldThreshold - driven + } else if (driven < -foldThreshold) { + driven = -2 * foldThreshold - driven + } + folds++ + } + + return this.clamp(driven + (Math.random() - 0.5) * k * 0.02, -1, 1) + } + + processDistortion(sample) { + let processed = this.preEmphasis(sample) + + switch (this.clipMode) { + case 'tube': + processed = this.tube(processed, this.drive) + break + case 'tape': + processed = this.tape(processed, this.drive) + break + case 'fuzz': + processed = this.fuzz(processed, this.drive) + break + case 'fold': + processed = this.fold(processed, this.drive) + break + case 'crush': + processed = this.crush(processed, this.drive) + break + default: + processed = this.fold(processed, this.drive) + } + + processed = this.deEmphasis(processed) + + return this.dcBlocker(processed) } processBitcrush(sample) { @@ -102,19 +189,71 @@ class FoldCrushProcessor extends AudioWorkletProcessor { if (this.bitcrushPhase >= 1.0) { this.bitcrushPhase -= 1.0 - const crushed = Math.floor(sample / step + 0.5) * step - this.lastCrushedValue = Math.max(-1, Math.min(1, crushed)) + + const dither = (Math.random() - 0.5) * step * 0.5 + const crushed = Math.floor((sample + dither) / step + 0.5) * step + + this.lastCrushedValue = this.clamp(crushed, -1, 1) return this.lastCrushedValue } else { return this.lastCrushedValue } } - safetyLimiter(sample) { - const threshold = 0.8 - if (Math.abs(sample) > threshold) { - return Math.tanh(sample * 0.9) / Math.tanh(0.9) + processGlitch(sample) { + if (this.glitchAmount === 0) { + return sample } + + this.grainBuffer[this.grainBufferIndex] = sample + this.grainBufferIndex = (this.grainBufferIndex + 1) % 256 + + if (this.grainPlaybackActive) { + this.grainPlaybackRemaining-- + + let readIndex + if (this.grainReversed) { + readIndex = this.grainPlaybackStart + this.grainPlaybackLength - 1 - (this.grainPlaybackIndex % this.grainPlaybackLength) + } else { + readIndex = this.grainPlaybackStart + (this.grainPlaybackIndex % this.grainPlaybackLength) + } + + readIndex = readIndex % 256 + let output = this.grainBuffer[readIndex] + + if (this.grainInverted) { + output = -output + } + + this.grainPlaybackIndex++ + + if (this.grainPlaybackRemaining <= 0) { + this.grainPlaybackActive = false + } + + return output + } + + const glitchIntensity = this.glitchAmount / 100 + const triggerProb = glitchIntensity * 0.001 + + if (Math.random() < triggerProb) { + this.grainPlaybackStart = this.grainBufferIndex + this.grainPlaybackLength = Math.floor(16 + Math.random() * 48) + this.grainPlaybackRemaining = Math.floor(100 + Math.random() * 200 * glitchIntensity) + this.grainPlaybackIndex = 0 + this.grainPlaybackActive = true + this.grainReversed = Math.random() < 0.4 + this.grainInverted = Math.random() < 0.2 + + let readIndex = this.grainPlaybackStart + let output = this.grainBuffer[readIndex] + if (this.grainInverted) { + output = -output + } + return output + } + return sample } @@ -127,9 +266,9 @@ class FoldCrushProcessor extends AudioWorkletProcessor { const outputChannel = output[0] for (let i = 0; i < inputChannel.length; i++) { - let processed = this.processWavefolder(inputChannel[i]) + let processed = this.processDistortion(inputChannel[i]) processed = this.processBitcrush(processed) - processed = this.safetyLimiter(processed) + processed = this.processGlitch(processed) outputChannel[i] = processed } } diff --git a/public/worklets/output-limiter.js b/public/worklets/output-limiter.js index 6e35ee09..feed8718 100644 --- a/public/worklets/output-limiter.js +++ b/public/worklets/output-limiter.js @@ -10,9 +10,9 @@ class OutputLimiter extends AudioWorkletProcessor { }, { name: 'makeup', - defaultValue: 1.5, - minValue: 1.0, - maxValue: 3.0, + defaultValue: 0.5, + minValue: 0.1, + maxValue: 2.0, automationRate: 'k-rate' } ] @@ -47,8 +47,8 @@ class OutputLimiter extends AudioWorkletProcessor { const outputChannel = output[channel] for (let i = 0; i < inputChannel.length; i++) { - let sample = inputChannel[i] * makeup - sample = this.softClip(sample, threshold) + let sample = this.softClip(inputChannel[i], threshold) + sample = sample * makeup outputChannel[i] = sample } } diff --git a/public/worklets/ring-mod-processor.js b/public/worklets/ring-mod-processor.js new file mode 100644 index 00000000..0316b403 --- /dev/null +++ b/public/worklets/ring-mod-processor.js @@ -0,0 +1,92 @@ +class RingModProcessor extends AudioWorkletProcessor { + constructor() { + super() + + this.frequency = 200 + this.shape = 'sine' + this.spread = 0 + this.bypassed = false + + this.phase = 0 + this.phaseRight = 0 + + this.port.onmessage = (event) => { + const { type, value } = event.data + switch (type) { + case 'frequency': + this.frequency = value + break + case 'shape': + this.shape = value + break + case 'spread': + this.spread = value + break + case 'bypass': + this.bypassed = value + break + } + } + } + + generateWaveform(phase, shape) { + switch (shape) { + case 'sine': + return Math.sin(phase * Math.PI * 2) + case 'square': + return phase < 0.5 ? 1 : -1 + case 'saw': + return 2 * phase - 1 + case 'triangle': + return phase < 0.5 ? 4 * phase - 1 : 3 - 4 * phase + default: + return Math.sin(phase * Math.PI * 2) + } + } + + process(inputs, outputs) { + const input = inputs[0] + const output = outputs[0] + + if (!input || input.length === 0 || !output || output.length === 0) { + return true + } + + const inputL = input[0] + const inputR = input[1] || input[0] + const outputL = output[0] + const outputR = output[1] || output[0] + + if (!inputL || !outputL) { + return true + } + + for (let i = 0; i < inputL.length; i++) { + if (this.bypassed) { + outputL[i] = inputL[i] + if (outputR) outputR[i] = inputR[i] + continue + } + + const spreadAmount = this.spread * 0.1 + const freqL = this.frequency * (1 - spreadAmount) + const freqR = this.frequency * (1 + spreadAmount) + + this.phase += freqL / sampleRate + this.phaseRight += freqR / sampleRate + + if (this.phase >= 1) this.phase -= 1 + if (this.phaseRight >= 1) this.phaseRight -= 1 + + const carrierL = this.generateWaveform(this.phase, this.shape) + const carrierR = this.generateWaveform(this.phaseRight, this.shape) + + outputL[i] = inputL[i] * carrierL + if (outputR) outputR[i] = inputR[i] * carrierR + } + + return true + } +} + +registerProcessor('ring-mod-processor', RingModProcessor) diff --git a/src/components/controls/EffectsBar.tsx b/src/components/controls/EffectsBar.tsx index b7e9f1ce..a7fb5849 100644 --- a/src/components/controls/EffectsBar.tsx +++ b/src/components/controls/EffectsBar.tsx @@ -36,7 +36,7 @@ export function EffectsBar({ values, onChange, onMapClick, getMappedLFOs }: Effe return (
{/* Desktop: Grid layout */} -
+
{EFFECTS.map(effect => { return (
diff --git a/src/components/ui/Knob.tsx b/src/components/ui/Knob.tsx index 2d6305bc..37181ae0 100644 --- a/src/components/ui/Knob.tsx +++ b/src/components/ui/Knob.tsx @@ -67,6 +67,19 @@ export function Knob({ e.preventDefault() } + const handleTouchStart = (e: React.TouchEvent) => { + if (isInMappingMode && paramId && mappingModeState.activeLFO !== null && onMapClick) { + onMapClick(paramId, mappingModeState.activeLFO) + e.preventDefault() + return + } + + setIsDragging(true) + startYRef.current = e.touches[0].clientY + startValueRef.current = value + e.preventDefault() + } + const handleMouseMove = useCallback((e: MouseEvent) => { if (!isDragging) return @@ -79,26 +92,48 @@ export function Knob({ onChange(steppedValue) }, [isDragging, max, min, step, onChange]) + const handleTouchMove = useCallback((e: TouchEvent) => { + if (!isDragging) return + + const deltaY = startYRef.current - e.touches[0].clientY + const range = max - min + const sensitivity = range / 200 + const newValue = Math.max(min, Math.min(max, startValueRef.current + deltaY * sensitivity)) + + const steppedValue = Math.round(newValue / step) * step + onChange(steppedValue) + e.preventDefault() + }, [isDragging, max, min, step, onChange]) + const handleMouseUp = useCallback(() => { setIsDragging(false) }, []) + const handleTouchEnd = useCallback(() => { + setIsDragging(false) + }, []) + useEffect(() => { if (isDragging) { window.addEventListener('mousemove', handleMouseMove) window.addEventListener('mouseup', handleMouseUp) + window.addEventListener('touchmove', handleTouchMove, { passive: false }) + window.addEventListener('touchend', handleTouchEnd) return () => { window.removeEventListener('mousemove', handleMouseMove) window.removeEventListener('mouseup', handleMouseUp) + window.removeEventListener('touchmove', handleTouchMove) + window.removeEventListener('touchend', handleTouchEnd) } } - }, [isDragging, handleMouseMove, handleMouseUp]) + }, [isDragging, handleMouseMove, handleMouseUp, handleTouchMove, handleTouchEnd]) return (
{ + this.processorNode = new AudioWorkletNode(audioContext, 'chorus-processor', { + numberOfInputs: 1, + numberOfOutputs: 1, + outputChannelCount: [2] + }) + this.processorNode.port.postMessage({ type: 'bypass', value: true }) + this.inputNode.connect(this.processorNode) + this.processorNode.connect(this.wetNode) + this.wetNode.connect(this.outputNode) + } + + getInputNode(): AudioNode { + return this.inputNode + } + + getOutputNode(): AudioNode { + return this.outputNode + } + + setBypass(bypass: boolean): void { + this.bypassed = bypass + if (this.processorNode) { + this.processorNode.port.postMessage({ type: 'bypass', value: bypass }) + } + if (bypass) { + this.wetNode.gain.value = 0 + this.dryNode.gain.value = 1 + } else { + this.wetNode.gain.value = this.currentWetValue + this.dryNode.gain.value = this.currentDryValue + } + } + + updateParams(values: Record): void { + if (!this.processorNode) return + + if (values.chorusType !== undefined) { + this.processorNode.port.postMessage({ type: 'mode', value: values.chorusType }) + } + if (values.chorusRate !== undefined) { + this.processorNode.port.postMessage({ type: 'frequency', value: values.chorusRate }) + } + if (values.chorusDepth !== undefined && typeof values.chorusDepth === 'number') { + this.processorNode.port.postMessage({ type: 'depth', value: values.chorusDepth / 100 }) + } + if (values.chorusFeedback !== undefined && typeof values.chorusFeedback === 'number') { + this.processorNode.port.postMessage({ type: 'feedback', value: values.chorusFeedback / 100 }) + } + if (values.chorusSpread !== undefined && typeof values.chorusSpread === 'number') { + this.processorNode.port.postMessage({ type: 'spread', value: values.chorusSpread / 100 }) + } + if (values.chorusMix !== undefined && typeof values.chorusMix === 'number') { + const wet = values.chorusMix / 100 + this.currentWetValue = wet + this.currentDryValue = 1 - wet + + if (!this.bypassed) { + this.wetNode.gain.value = this.currentWetValue + this.dryNode.gain.value = this.currentDryValue + } + } + } + + dispose(): void { + if (this.processorNode) { + this.processorNode.disconnect() + } + this.wetNode.disconnect() + this.dryNode.disconnect() + this.inputNode.disconnect() + this.outputNode.disconnect() + } +} diff --git a/src/domain/audio/effects/EffectsChain.ts b/src/domain/audio/effects/EffectsChain.ts index 95155795..5c813cfb 100644 --- a/src/domain/audio/effects/EffectsChain.ts +++ b/src/domain/audio/effects/EffectsChain.ts @@ -2,6 +2,8 @@ import type { Effect } from './Effect.interface' import { FilterEffect } from './FilterEffect' import { FoldCrushEffect } from './FoldCrushEffect' import { DelayEffect } from './DelayEffect' +import { RingModEffect } from './RingModEffect' +import { ChorusEffect } from './ChorusEffect' import { ReverbEffect } from './ReverbEffect' import { OutputLimiter } from './OutputLimiter' @@ -12,6 +14,8 @@ export class EffectsChain { private effects: Effect[] private filterEffect: FilterEffect private foldCrushEffect: FoldCrushEffect + private ringModEffect: RingModEffect + private chorusEffect: ChorusEffect private outputLimiter: OutputLimiter constructor(audioContext: AudioContext) { @@ -21,9 +25,13 @@ export class EffectsChain { this.filterEffect = new FilterEffect(audioContext) this.foldCrushEffect = new FoldCrushEffect(audioContext) + this.ringModEffect = new RingModEffect(audioContext) + this.chorusEffect = new ChorusEffect(audioContext) this.outputLimiter = new OutputLimiter(audioContext) this.effects = [ + this.ringModEffect, + this.chorusEffect, this.filterEffect, this.foldCrushEffect, new DelayEffect(audioContext), @@ -38,6 +46,8 @@ export class EffectsChain { await Promise.all([ this.filterEffect.initialize(audioContext), this.foldCrushEffect.initialize(audioContext), + this.ringModEffect.initialize(audioContext), + this.chorusEffect.initialize(audioContext), this.outputLimiter.initialize(audioContext) ]) } diff --git a/src/domain/audio/effects/FoldCrushEffect.ts b/src/domain/audio/effects/FoldCrushEffect.ts index ae5abcbe..ecb58ae3 100644 --- a/src/domain/audio/effects/FoldCrushEffect.ts +++ b/src/domain/audio/effects/FoldCrushEffect.ts @@ -62,6 +62,9 @@ export class FoldCrushEffect implements Effect { if (values.bitcrushRate !== undefined) { this.processorNode.port.postMessage({ type: 'crushAmount', value: values.bitcrushRate }) } + if (values.glitchAmount !== undefined) { + this.processorNode.port.postMessage({ type: 'glitchAmount', value: values.glitchAmount }) + } } dispose(): void { diff --git a/src/domain/audio/effects/ReverbEffect.ts b/src/domain/audio/effects/ReverbEffect.ts index b1f29b74..7145d748 100644 --- a/src/domain/audio/effects/ReverbEffect.ts +++ b/src/domain/audio/effects/ReverbEffect.ts @@ -8,202 +8,144 @@ export class ReverbEffect implements Effect { private outputNode: GainNode private wetNode: GainNode private dryNode: GainNode - private mixNode: GainNode - private pannerNode: StereoPannerNode - private panLfoNode: OscillatorNode - private panLfoGainNode: GainNode + + private convolverA: ConvolverNode + private convolverB: ConvolverNode + private gainA: GainNode + private gainB: GainNode + private activeConvolver: 'A' | 'B' = 'A' + private bypassed: boolean = false private currentWetValue: number = 0 private currentDryValue: number = 1 - private currentDecay: number = 0.5 + private currentDecay: number = 0.7 private currentDamping: number = 0.5 + private currentSize: number = 0.5 - private earlyReflectionsNode: GainNode - private earlyReflectionDelays: DelayNode[] = [] - private earlyReflectionGains: GainNode[] = [] - private earlyReflectionFilters: BiquadFilterNode[] = [] - - private lowBandSplitter: BiquadFilterNode - private midBandLowPass: BiquadFilterNode - private midBandHighPass: BiquadFilterNode - private highBandSplitter: BiquadFilterNode - - private lowBandProcessor: BandProcessor - private midBandProcessor: BandProcessor - private highBandProcessor: BandProcessor - - private lowEnvFollower: DynamicsCompressorNode - private midEnvFollower: DynamicsCompressorNode - private highEnvFollower: DynamicsCompressorNode - - private lowToHighModGain: GainNode - private highToLowModGain: GainNode - private midToGlobalModGain: GainNode - - private bandMixer: GainNode + private pendingDecay: number = 0.7 + private pendingDamping: number = 0.5 + private pendingSize: number = 0.5 + private debounceTimer: number | null = null + private readonly DEBOUNCE_MS = 250 constructor(audioContext: AudioContext) { this.audioContext = audioContext - const sr = audioContext.sampleRate this.inputNode = audioContext.createGain() this.outputNode = audioContext.createGain() - this.mixNode = audioContext.createGain() this.wetNode = audioContext.createGain() this.dryNode = audioContext.createGain() - this.pannerNode = audioContext.createStereoPanner() - this.panLfoNode = audioContext.createOscillator() - this.panLfoGainNode = audioContext.createGain() + + this.convolverA = audioContext.createConvolver() + this.convolverB = audioContext.createConvolver() + this.gainA = audioContext.createGain() + this.gainB = audioContext.createGain() this.wetNode.gain.value = 0 this.dryNode.gain.value = 1 - - this.panLfoNode.frequency.value = 0 - this.panLfoGainNode.gain.value = 0 - this.panLfoNode.connect(this.panLfoGainNode) - this.panLfoGainNode.connect(this.pannerNode.pan) - this.panLfoNode.start() - - this.earlyReflectionsNode = audioContext.createGain() - this.buildEarlyReflections(sr) - - this.lowBandSplitter = audioContext.createBiquadFilter() - this.lowBandSplitter.type = 'lowpass' - this.lowBandSplitter.frequency.value = 250 - this.lowBandSplitter.Q.value = 0.707 - - this.midBandHighPass = audioContext.createBiquadFilter() - this.midBandHighPass.type = 'highpass' - this.midBandHighPass.frequency.value = 250 - this.midBandHighPass.Q.value = 0.707 - - this.midBandLowPass = audioContext.createBiquadFilter() - this.midBandLowPass.type = 'lowpass' - this.midBandLowPass.frequency.value = 2500 - this.midBandLowPass.Q.value = 0.707 - - this.highBandSplitter = audioContext.createBiquadFilter() - this.highBandSplitter.type = 'highpass' - this.highBandSplitter.frequency.value = 2500 - this.highBandSplitter.Q.value = 0.707 - - this.lowBandProcessor = new BandProcessor(audioContext, 'low', sr) - this.midBandProcessor = new BandProcessor(audioContext, 'mid', sr) - this.highBandProcessor = new BandProcessor(audioContext, 'high', sr) - - this.lowEnvFollower = audioContext.createDynamicsCompressor() - this.lowEnvFollower.threshold.value = -50 - this.lowEnvFollower.knee.value = 40 - this.lowEnvFollower.ratio.value = 12 - this.lowEnvFollower.attack.value = 0.003 - this.lowEnvFollower.release.value = 0.25 - - this.midEnvFollower = audioContext.createDynamicsCompressor() - this.midEnvFollower.threshold.value = -50 - this.midEnvFollower.knee.value = 40 - this.midEnvFollower.ratio.value = 12 - this.midEnvFollower.attack.value = 0.003 - this.midEnvFollower.release.value = 0.25 - - this.highEnvFollower = audioContext.createDynamicsCompressor() - this.highEnvFollower.threshold.value = -50 - this.highEnvFollower.knee.value = 40 - this.highEnvFollower.ratio.value = 12 - this.highEnvFollower.attack.value = 0.001 - this.highEnvFollower.release.value = 0.1 - - this.lowToHighModGain = audioContext.createGain() - this.highToLowModGain = audioContext.createGain() - this.midToGlobalModGain = audioContext.createGain() - - this.bandMixer = audioContext.createGain() - - this.buildGraph() - this.updateDecayAndDamping() + this.gainA.gain.value = 1 + this.gainB.gain.value = 0 this.inputNode.connect(this.dryNode) - this.dryNode.connect(this.mixNode) - this.wetNode.connect(this.mixNode) - this.mixNode.connect(this.pannerNode) - this.pannerNode.connect(this.outputNode) + this.dryNode.connect(this.outputNode) + + this.inputNode.connect(this.convolverA) + this.convolverA.connect(this.gainA) + this.gainA.connect(this.wetNode) + + this.inputNode.connect(this.convolverB) + this.convolverB.connect(this.gainB) + this.gainB.connect(this.wetNode) + + this.wetNode.connect(this.outputNode) + + this.generateImpulseResponse('A', this.currentDecay, this.currentDamping, this.currentSize) } - private buildEarlyReflections(sr: number): void { - const primes = [17, 29, 41, 59, 71, 97, 113, 127] - const scale = sr / 48000 + private generateImpulseResponse(target: 'A' | 'B', decay: number, damping: number, size: number): void { + const sampleRate = this.audioContext.sampleRate + const decayTime = 0.5 + decay * 3.5 + const length = Math.floor(sampleRate * decayTime) - for (let i = 0; i < primes.length; i++) { - const delay = this.audioContext.createDelay(0.2) - delay.delayTime.value = (primes[i] * scale) / 1000 + const impulse = this.audioContext.createBuffer(2, length, sampleRate) + const leftChannel = impulse.getChannelData(0) + const rightChannel = impulse.getChannelData(1) - const gain = this.audioContext.createGain() - gain.gain.value = 0.7 * Math.pow(0.85, i) + const fadeInSamples = Math.floor(0.001 * sampleRate * (0.5 + size * 1.5)) - const filter = this.audioContext.createBiquadFilter() - filter.type = i % 2 === 0 ? 'lowpass' : 'highshelf' - filter.frequency.value = 3000 + i * 500 - filter.gain.value = -2 * i + const dampingFreq = 1000 + damping * 8000 + const dampingCoeff = Math.exp(-2 * Math.PI * dampingFreq / sampleRate) - this.earlyReflectionDelays.push(delay) - this.earlyReflectionGains.push(gain) - this.earlyReflectionFilters.push(filter) + let leftLPState = 0 + let rightLPState = 0 + + for (let i = 0; i < length; i++) { + const decayValue = Math.exp(-3 * i / length / decay) + + const fadeIn = i < fadeInSamples ? i / fadeInSamples : 1.0 + + const leftNoise = (Math.random() * 2 - 1) * decayValue * fadeIn + const rightNoise = (Math.random() * 2 - 1) * decayValue * fadeIn + + const dampingAmount = Math.min(1, i / (length * 0.3)) + const currentDampingCoeff = 1 - dampingAmount * (1 - dampingCoeff) + + leftLPState = leftLPState * currentDampingCoeff + leftNoise * (1 - currentDampingCoeff) + rightLPState = rightLPState * currentDampingCoeff + rightNoise * (1 - currentDampingCoeff) + + leftChannel[i] = leftLPState * 0.5 + rightChannel[i] = rightLPState * 0.5 + } + + if (target === 'A') { + this.convolverA.buffer = impulse + } else { + this.convolverB.buffer = impulse } } - private buildGraph(): void { - this.inputNode.connect(this.earlyReflectionsNode) + private crossfadeToStandby(): void { + const now = this.audioContext.currentTime + const crossfadeDuration = 0.02 - for (let i = 0; i < this.earlyReflectionDelays.length; i++) { - this.earlyReflectionsNode.connect(this.earlyReflectionDelays[i]) - this.earlyReflectionDelays[i].connect(this.earlyReflectionFilters[i]) - this.earlyReflectionFilters[i].connect(this.earlyReflectionGains[i]) - this.earlyReflectionGains[i].connect(this.wetNode) + if (this.activeConvolver === 'A') { + this.gainA.gain.setValueAtTime(1, now) + this.gainA.gain.exponentialRampToValueAtTime(0.001, now + crossfadeDuration) + + this.gainB.gain.setValueAtTime(0.001, now) + this.gainB.gain.exponentialRampToValueAtTime(1, now + crossfadeDuration) + + this.activeConvolver = 'B' + } else { + this.gainB.gain.setValueAtTime(1, now) + this.gainB.gain.exponentialRampToValueAtTime(0.001, now + crossfadeDuration) + + this.gainA.gain.setValueAtTime(0.001, now) + this.gainA.gain.exponentialRampToValueAtTime(1, now + crossfadeDuration) + + this.activeConvolver = 'A' } - - this.earlyReflectionsNode.connect(this.lowBandSplitter) - this.earlyReflectionsNode.connect(this.midBandHighPass) - this.earlyReflectionsNode.connect(this.highBandSplitter) - - this.midBandHighPass.connect(this.midBandLowPass) - - this.lowBandSplitter.connect(this.lowBandProcessor.getInputNode()) - this.midBandLowPass.connect(this.midBandProcessor.getInputNode()) - this.highBandSplitter.connect(this.highBandProcessor.getInputNode()) - - this.lowBandProcessor.getOutputNode().connect(this.lowEnvFollower) - this.midBandProcessor.getOutputNode().connect(this.midEnvFollower) - this.highBandProcessor.getOutputNode().connect(this.highEnvFollower) - - this.lowEnvFollower.connect(this.lowToHighModGain) - this.highEnvFollower.connect(this.highToLowModGain) - this.midEnvFollower.connect(this.midToGlobalModGain) - - this.lowToHighModGain.connect(this.highBandProcessor.getModulationTarget()) - this.highToLowModGain.connect(this.lowBandProcessor.getModulationTarget()) - - this.lowBandProcessor.getOutputNode().connect(this.bandMixer) - this.midBandProcessor.getOutputNode().connect(this.bandMixer) - this.highBandProcessor.getOutputNode().connect(this.bandMixer) - - this.bandMixer.connect(this.wetNode) } - private updateDecayAndDamping(): void { - const decay = this.currentDecay - const damping = this.currentDamping + private scheduleRegeneration(): void { + if (this.debounceTimer !== null) { + clearTimeout(this.debounceTimer) + } - this.lowBandProcessor.setDecay(decay * 1.2) - this.midBandProcessor.setDecay(decay) - this.highBandProcessor.setDecay(decay * 0.6) + this.debounceTimer = window.setTimeout(() => { + this.currentDecay = this.pendingDecay + this.currentDamping = this.pendingDamping + this.currentSize = this.pendingSize - this.lowBandProcessor.setDamping(damping * 0.5) - this.midBandProcessor.setDamping(damping) - this.highBandProcessor.setDamping(damping * 1.5) + const standbyConvolver = this.activeConvolver === 'A' ? 'B' : 'A' + this.generateImpulseResponse(standbyConvolver, this.currentDecay, this.currentDamping, this.currentSize) - const modAmount = 0.3 - this.lowToHighModGain.gain.value = modAmount - this.highToLowModGain.gain.value = modAmount * 0.7 - this.midToGlobalModGain.gain.value = modAmount * 0.5 + setTimeout(() => { + this.crossfadeToStandby() + }, 10) + + this.debounceTimer = null + }, this.DEBOUNCE_MS) } getInputNode(): AudioNode { @@ -226,16 +168,21 @@ export class ReverbEffect implements Effect { } updateParams(values: Record): void { - let needsUpdate = false + let needsRegenerate = false if (values.reverbDecay !== undefined && typeof values.reverbDecay === 'number') { - this.currentDecay = values.reverbDecay / 100 - needsUpdate = true + this.pendingDecay = values.reverbDecay / 100 + needsRegenerate = true } if (values.reverbDamping !== undefined && typeof values.reverbDamping === 'number') { - this.currentDamping = values.reverbDamping / 100 - needsUpdate = true + this.pendingDamping = values.reverbDamping / 100 + needsRegenerate = true + } + + if (values.reverbSize !== undefined && typeof values.reverbSize === 'number') { + this.pendingSize = values.reverbSize / 100 + needsRegenerate = true } if (values.reverbWetDry !== undefined && typeof values.reverbWetDry === 'number') { @@ -249,303 +196,23 @@ export class ReverbEffect implements Effect { } } - if (values.reverbPanRate !== undefined && typeof values.reverbPanRate === 'number') { - const rate = values.reverbPanRate - this.panLfoNode.frequency.setTargetAtTime( - rate, - this.audioContext.currentTime, - 0.01 - ) - } - - if (values.reverbPanWidth !== undefined && typeof values.reverbPanWidth === 'number') { - const width = values.reverbPanWidth / 100 - this.panLfoGainNode.gain.setTargetAtTime( - width, - this.audioContext.currentTime, - 0.01 - ) - } - - if (needsUpdate) { - this.updateDecayAndDamping() + if (needsRegenerate) { + this.scheduleRegeneration() } } dispose(): void { - this.panLfoNode.stop() - this.panLfoNode.disconnect() - this.panLfoGainNode.disconnect() + if (this.debounceTimer !== null) { + clearTimeout(this.debounceTimer) + } + this.inputNode.disconnect() this.outputNode.disconnect() - this.mixNode.disconnect() this.wetNode.disconnect() this.dryNode.disconnect() - this.pannerNode.disconnect() - this.earlyReflectionsNode.disconnect() - - this.earlyReflectionDelays.forEach(d => d.disconnect()) - this.earlyReflectionGains.forEach(g => g.disconnect()) - this.earlyReflectionFilters.forEach(f => f.disconnect()) - - this.lowBandSplitter.disconnect() - this.midBandHighPass.disconnect() - this.midBandLowPass.disconnect() - this.highBandSplitter.disconnect() - - this.lowBandProcessor.dispose() - this.midBandProcessor.dispose() - this.highBandProcessor.dispose() - - this.lowEnvFollower.disconnect() - this.midEnvFollower.disconnect() - this.highEnvFollower.disconnect() - - this.lowToHighModGain.disconnect() - this.highToLowModGain.disconnect() - this.midToGlobalModGain.disconnect() - - this.bandMixer.disconnect() - } -} - -class BandProcessor { - private audioContext: AudioContext - private bandType: 'low' | 'mid' | 'high' - - private inputNode: GainNode - private outputNode: GainNode - private modulationTarget: GainNode - - private delay1: DelayNode - private delay2: DelayNode - private allpass1: DelayNode - private allpass2: DelayNode - - private ap1Gain: GainNode - private ap2Gain: GainNode - - private filter1: BiquadFilterNode - private filter2: BiquadFilterNode - private filter3: BiquadFilterNode - - private feedbackGain: GainNode - private saturation: WaveShaperNode - - private feedbackMixer: GainNode - - constructor(audioContext: AudioContext, bandType: 'low' | 'mid' | 'high', sr: number) { - this.audioContext = audioContext - this.bandType = bandType - - this.inputNode = audioContext.createGain() - this.outputNode = audioContext.createGain() - this.modulationTarget = audioContext.createGain() - this.modulationTarget.gain.value = 0 - - const scale = sr / 48000 - const delayTimes = this.getDelayTimes(bandType, scale, sr) - - this.delay1 = audioContext.createDelay(1.0) - this.delay2 = audioContext.createDelay(1.0) - this.delay1.delayTime.value = delayTimes.d1 - this.delay2.delayTime.value = delayTimes.d2 - - this.allpass1 = audioContext.createDelay(0.1) - this.allpass2 = audioContext.createDelay(0.1) - this.allpass1.delayTime.value = delayTimes.ap1 - this.allpass2.delayTime.value = delayTimes.ap2 - - this.ap1Gain = audioContext.createGain() - this.ap2Gain = audioContext.createGain() - this.ap1Gain.gain.value = 0.7 - this.ap2Gain.gain.value = 0.7 - - this.filter1 = audioContext.createBiquadFilter() - this.filter2 = audioContext.createBiquadFilter() - this.filter3 = audioContext.createBiquadFilter() - this.setupFilters(bandType) - - this.feedbackGain = audioContext.createGain() - this.feedbackGain.gain.value = 0.5 - - this.saturation = audioContext.createWaveShaper() - this.saturation.curve = this.createSaturationCurve(bandType) - this.saturation.oversample = '2x' - - this.feedbackMixer = audioContext.createGain() - - this.buildGraph() - } - - private getDelayTimes(bandType: string, scale: number, sr: number) { - const times: Record = { - low: { - d1: (1201 * scale) / sr, - d2: (6171 * scale) / sr, - ap1: (2333 * scale) / sr, - ap2: (4513 * scale) / sr, - }, - mid: { - d1: (907 * scale) / sr, - d2: (4217 * scale) / sr, - ap1: (1801 * scale) / sr, - ap2: (3119 * scale) / sr, - }, - high: { - d1: (503 * scale) / sr, - d2: (2153 * scale) / sr, - ap1: (907 * scale) / sr, - ap2: (1453 * scale) / sr, - }, - } - return times[bandType] - } - - private setupFilters(bandType: string): void { - if (bandType === 'low') { - this.filter1.type = 'lowpass' - this.filter1.frequency.value = 1200 - this.filter1.Q.value = 0.707 - - this.filter2.type = 'lowshelf' - this.filter2.frequency.value = 200 - this.filter2.gain.value = 2 - - this.filter3.type = 'peaking' - this.filter3.frequency.value = 600 - this.filter3.Q.value = 1.0 - this.filter3.gain.value = -3 - } else if (bandType === 'mid') { - this.filter1.type = 'lowpass' - this.filter1.frequency.value = 5000 - this.filter1.Q.value = 0.707 - - this.filter2.type = 'peaking' - this.filter2.frequency.value = 1200 - this.filter2.Q.value = 1.5 - this.filter2.gain.value = -2 - - this.filter3.type = 'highshelf' - this.filter3.frequency.value = 3000 - this.filter3.gain.value = -4 - } else { - this.filter1.type = 'lowpass' - this.filter1.frequency.value = 12000 - this.filter1.Q.value = 0.5 - - this.filter2.type = 'lowpass' - this.filter2.frequency.value = 8000 - this.filter2.Q.value = 0.707 - - this.filter3.type = 'highshelf' - this.filter3.frequency.value = 5000 - this.filter3.gain.value = -6 - } - } - - private createSaturationCurve(bandType: string): Float32Array { - const samples = 4096 - const curve = new Float32Array(samples) - const amount = bandType === 'low' ? 0.8 : bandType === 'mid' ? 0.5 : 0.3 - - for (let i = 0; i < samples; i++) { - const x = (i * 2) / samples - 1 - curve[i] = Math.tanh(x * (1 + amount)) / (1 + amount * 0.5) - } - - return curve - } - - private buildGraph(): void { - this.inputNode.connect(this.delay1) - this.delay1.connect(this.filter1) - this.filter1.connect(this.filter2) - this.filter2.connect(this.filter3) - this.filter3.connect(this.delay2) - - const ap1Out = this.createAllPass(this.delay2, this.allpass1, this.ap1Gain) - const ap2Out = this.createAllPass(ap1Out, this.allpass2, this.ap2Gain) - - ap2Out.connect(this.feedbackGain) - this.feedbackGain.connect(this.saturation) - this.saturation.connect(this.feedbackMixer) - - this.modulationTarget.connect(this.feedbackMixer) - - this.feedbackMixer.connect(this.inputNode) - - ap2Out.connect(this.outputNode) - } - - private createAllPass(input: AudioNode, delay: DelayNode, gain: GainNode): AudioNode { - const output = this.audioContext.createGain() - const feedbackGain = this.audioContext.createGain() - feedbackGain.gain.value = -1 - - input.connect(delay) - input.connect(feedbackGain) - feedbackGain.connect(output) - - delay.connect(gain) - gain.connect(output) - gain.connect(input) - - return output - } - - getInputNode(): AudioNode { - return this.inputNode - } - - getOutputNode(): AudioNode { - return this.outputNode - } - - getModulationTarget(): AudioNode { - return this.modulationTarget - } - - setDecay(decay: number): void { - this.feedbackGain.gain.setTargetAtTime( - Math.min(0.95, decay), - this.audioContext.currentTime, - 0.01 - ) - } - - setDamping(damping: number): void { - let cutoff: number - if (this.bandType === 'low') { - cutoff = 500 + damping * 1500 - } else if (this.bandType === 'mid') { - cutoff = 2000 + damping * 6000 - } else { - cutoff = 4000 + damping * 10000 - } - - this.filter1.frequency.setTargetAtTime( - cutoff, - this.audioContext.currentTime, - 0.01 - ) - } - - dispose(): void { - this.inputNode.disconnect() - this.outputNode.disconnect() - this.modulationTarget.disconnect() - this.delay1.disconnect() - this.delay2.disconnect() - this.allpass1.disconnect() - this.allpass2.disconnect() - this.ap1Gain.disconnect() - this.ap2Gain.disconnect() - this.filter1.disconnect() - this.filter2.disconnect() - this.filter3.disconnect() - this.feedbackGain.disconnect() - this.saturation.disconnect() - this.feedbackMixer.disconnect() + this.convolverA.disconnect() + this.convolverB.disconnect() + this.gainA.disconnect() + this.gainB.disconnect() } } diff --git a/src/domain/audio/effects/RingModEffect.ts b/src/domain/audio/effects/RingModEffect.ts new file mode 100644 index 00000000..ae0816f4 --- /dev/null +++ b/src/domain/audio/effects/RingModEffect.ts @@ -0,0 +1,61 @@ +import type { Effect } from './Effect.interface' + +export class RingModEffect implements Effect { + readonly id = 'ring' + + private inputNode: GainNode + private outputNode: GainNode + private processorNode: AudioWorkletNode | null = null + + constructor(audioContext: AudioContext) { + this.inputNode = audioContext.createGain() + this.outputNode = audioContext.createGain() + } + + async initialize(audioContext: AudioContext): Promise { + this.processorNode = new AudioWorkletNode(audioContext, 'ring-mod-processor', { + numberOfInputs: 1, + numberOfOutputs: 1, + outputChannelCount: [2] + }) + this.processorNode.port.postMessage({ type: 'bypass', value: true }) + this.inputNode.connect(this.processorNode) + this.processorNode.connect(this.outputNode) + } + + getInputNode(): AudioNode { + return this.inputNode + } + + getOutputNode(): AudioNode { + return this.outputNode + } + + setBypass(bypass: boolean): void { + if (this.processorNode) { + this.processorNode.port.postMessage({ type: 'bypass', value: bypass }) + } + } + + updateParams(values: Record): void { + if (!this.processorNode) return + + if (values.ringFreq !== undefined) { + this.processorNode.port.postMessage({ type: 'frequency', value: values.ringFreq }) + } + if (values.ringShape !== undefined) { + this.processorNode.port.postMessage({ type: 'shape', value: values.ringShape }) + } + if (values.ringSpread !== undefined && typeof values.ringSpread === 'number') { + this.processorNode.port.postMessage({ type: 'spread', value: values.ringSpread / 100 }) + } + } + + dispose(): void { + if (this.processorNode) { + this.processorNode.disconnect() + } + this.inputNode.disconnect() + this.outputNode.disconnect() + } +} diff --git a/src/utils/fmPatches.ts b/src/utils/fmPatches.ts index 040626fa..524bc2c4 100644 --- a/src/utils/fmPatches.ts +++ b/src/utils/fmPatches.ts @@ -38,7 +38,7 @@ export function generateRandomFMPatch(complexity: number = 1): FMPatchConfig { ] const pitchLFO = { - waveform: Math.floor(Math.random() * 4), + waveform: Math.floor(Math.random() * 8), depth: Math.random() < 0.4 ? 0.03 + Math.random() * 0.22 : 0, baseRate: 0.1 + Math.random() * 9.9 }