Compare commits
2 Commits
ff5add97e8
...
9d26ea5cd7
| Author | SHA1 | Date | |
|---|---|---|---|
| 9d26ea5cd7 | |||
| 90f2f4209c |
167
public/worklets/chorus-processor.js
Normal file
167
public/worklets/chorus-processor.js
Normal file
@ -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)
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -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
|
||||
|
||||
while (y > 1.0 || y < -1.0) {
|
||||
if (y > 1.0) {
|
||||
y = 2.0 - y
|
||||
} else if (y < -1.0) {
|
||||
y = -2.0 - y
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
return Math.sin(y * Math.PI / 2)
|
||||
}
|
||||
|
||||
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)
|
||||
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++
|
||||
}
|
||||
|
||||
processWavefolder(sample) {
|
||||
return this.clamp(driven + (Math.random() - 0.5) * k * 0.02, -1, 1)
|
||||
}
|
||||
|
||||
processDistortion(sample) {
|
||||
let processed = this.preEmphasis(sample)
|
||||
|
||||
switch (this.clipMode) {
|
||||
case 'soft':
|
||||
return this.soft(sample, this.drive)
|
||||
case 'hard':
|
||||
return this.hard(sample, this.drive)
|
||||
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':
|
||||
return this.fold(sample, this.drive)
|
||||
case 'cubic':
|
||||
return this.cubic(sample, this.drive)
|
||||
case 'diode':
|
||||
return this.diode(sample, this.drive)
|
||||
processed = this.fold(processed, this.drive)
|
||||
break
|
||||
case 'crush':
|
||||
processed = this.crush(processed, this.drive)
|
||||
break
|
||||
default:
|
||||
return sample
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
@ -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
|
||||
}
|
||||
}
|
||||
|
||||
92
public/worklets/ring-mod-processor.js
Normal file
92
public/worklets/ring-mod-processor.js
Normal file
@ -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)
|
||||
31
src/App.tsx
31
src/App.tsx
@ -1,6 +1,6 @@
|
||||
import { useState, useRef } from 'react'
|
||||
import { useStore } from '@nanostores/react'
|
||||
import { Square, Archive, Dices, Sparkles } from 'lucide-react'
|
||||
import { Square, Archive, Dices, Sparkles, Blend } from 'lucide-react'
|
||||
import { DownloadService } from './services/DownloadService'
|
||||
import { generateRandomFormula } from './utils/bytebeatFormulas'
|
||||
import { BytebeatTile } from './components/tile/BytebeatTile'
|
||||
@ -55,7 +55,7 @@ function App() {
|
||||
}
|
||||
})
|
||||
|
||||
const { saveCurrentParams, loadParams, handleEngineChange, handleEffectChange, randomizeParams, randomizeAllParams } =
|
||||
const { saveCurrentParams, loadParams, handleEngineChange, handleEffectChange, randomizeParams, randomizeAllParams, interpolateParams } =
|
||||
useParameterSync({
|
||||
tiles,
|
||||
setTiles,
|
||||
@ -171,9 +171,18 @@ function App() {
|
||||
}
|
||||
}
|
||||
|
||||
const handleRegenerate = (row: number, col: number) => {
|
||||
const newTile = regenerateTile(row, col)
|
||||
const tileId = getTileId(row, col)
|
||||
|
||||
if (playing === tileId) {
|
||||
play(newTile.formula, tileId, newTile)
|
||||
}
|
||||
}
|
||||
|
||||
const handleKeyboardR = () => {
|
||||
if (focusedTile !== 'custom') {
|
||||
regenerateTile(focusedTile.row, focusedTile.col)
|
||||
handleRegenerate(focusedTile.row, focusedTile.col)
|
||||
}
|
||||
}
|
||||
|
||||
@ -200,6 +209,7 @@ function App() {
|
||||
onShiftR: handleRandom,
|
||||
onC: handleKeyboardC,
|
||||
onShiftC: randomizeAllParams,
|
||||
onI: interpolateParams,
|
||||
onEscape: exitMappingMode
|
||||
})
|
||||
|
||||
@ -296,6 +306,12 @@ function App() {
|
||||
>
|
||||
<Sparkles size={12} strokeWidth={2} className="mx-auto" />
|
||||
</button>
|
||||
<button
|
||||
onClick={interpolateParams}
|
||||
className="flex-1 px-2 py-2 bg-white text-black font-mono text-[9px] tracking-[0.2em] hover:bg-black hover:text-white border-2 border-white transition-all"
|
||||
>
|
||||
<Blend size={12} strokeWidth={2} className="mx-auto" />
|
||||
</button>
|
||||
<button
|
||||
onClick={handleDownloadAll}
|
||||
disabled={downloading}
|
||||
@ -390,6 +406,13 @@ function App() {
|
||||
<Sparkles size={12} strokeWidth={2} />
|
||||
CHAOS
|
||||
</button>
|
||||
<button
|
||||
onClick={interpolateParams}
|
||||
className="px-4 py-2 bg-white text-black font-mono text-[10px] tracking-[0.2em] hover:bg-black hover:text-white border-2 border-white transition-all flex items-center gap-1"
|
||||
>
|
||||
<Blend size={12} strokeWidth={2} />
|
||||
MORPH
|
||||
</button>
|
||||
<button
|
||||
onClick={handleDownloadAll}
|
||||
disabled={downloading}
|
||||
@ -442,7 +465,7 @@ function App() {
|
||||
onPlay={handleTileClick}
|
||||
onDoubleClick={handleTileDoubleClick}
|
||||
onDownload={handleDownloadFormula}
|
||||
onRegenerate={regenerateTile}
|
||||
onRegenerate={handleRegenerate}
|
||||
/>
|
||||
)
|
||||
})
|
||||
|
||||
@ -36,7 +36,7 @@ export function EffectsBar({ values, onChange, onMapClick, getMappedLFOs }: Effe
|
||||
return (
|
||||
<div className="bg-black border-t-2 border-white px-2 lg:px-6 py-3 lg:py-4">
|
||||
{/* Desktop: Grid layout */}
|
||||
<div className="hidden lg:grid lg:grid-cols-4 lg:gap-4">
|
||||
<div className="hidden lg:grid lg:grid-cols-6 lg:gap-3">
|
||||
{EFFECTS.map(effect => {
|
||||
return (
|
||||
<div key={effect.id} className="border-2 border-white p-3">
|
||||
|
||||
@ -31,7 +31,8 @@ export function EngineControls({ values, onChange, onMapClick, getMappedLFOs, sh
|
||||
return getAlgorithmLabel(value)
|
||||
default: {
|
||||
const param = ENGINE_CONTROLS[0].parameters.find(p => p.id === id)
|
||||
return `${value}${param?.unit || ''}`
|
||||
const formattedValue = Number.isInteger(value) ? value.toString() : value.toFixed(1)
|
||||
return `${formattedValue}${param?.unit || ''}`
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -77,6 +77,10 @@ export function HelpModal({ onClose, showStartButton = false }: HelpModalProps)
|
||||
<td className="p-2 md:p-3 border-r border-white">SHIFT + C</td>
|
||||
<td className="p-2 md:p-3">Randomize all params (CHAOS)</td>
|
||||
</tr>
|
||||
<tr className="border-b border-white">
|
||||
<td className="p-2 md:p-3 border-r border-white">I</td>
|
||||
<td className="p-2 md:p-3">Interpolate params (MORPH)</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td className="p-2 md:p-3 border-r border-white">ESC</td>
|
||||
<td className="p-2 md:p-3">Exit mapping mode</td>
|
||||
|
||||
@ -38,7 +38,14 @@ export function Knob({
|
||||
const startValueRef = useRef<number>(0)
|
||||
const mappingModeState = useStore(mappingMode)
|
||||
|
||||
const displayValue = formatValue && valueId ? formatValue(valueId, value) : `${value}${unit || ''}`
|
||||
const formatNumber = (num: number) => {
|
||||
if (Number.isInteger(num)) return num.toString()
|
||||
return num.toFixed(1)
|
||||
}
|
||||
|
||||
const displayValue = formatValue && valueId
|
||||
? formatValue(valueId, value)
|
||||
: `${formatNumber(value)}${unit || ''}`
|
||||
const isInMappingMode = mappingModeState.isActive && paramId
|
||||
const hasMappings = mappedLFOs.length > 0
|
||||
|
||||
@ -60,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
|
||||
|
||||
@ -72,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 (
|
||||
<div className="relative flex flex-col items-center">
|
||||
<div
|
||||
className={`relative select-none ${isInMappingMode ? 'cursor-pointer' : 'cursor-ns-resize'}`}
|
||||
onMouseDown={handleMouseDown}
|
||||
onTouchStart={handleTouchStart}
|
||||
style={{ width: size, height: size }}
|
||||
>
|
||||
<svg
|
||||
|
||||
@ -31,7 +31,15 @@ export function Slider({
|
||||
mappedLFOs = []
|
||||
}: SliderProps) {
|
||||
const mappingModeState = useStore(mappingMode)
|
||||
const displayValue = formatValue && valueId ? formatValue(valueId, value) : `${value}${unit || ''}`
|
||||
|
||||
const formatNumber = (num: number) => {
|
||||
if (Number.isInteger(num)) return num.toString()
|
||||
return num.toFixed(1)
|
||||
}
|
||||
|
||||
const displayValue = formatValue && valueId
|
||||
? formatValue(valueId, value)
|
||||
: `${formatNumber(value)}${unit || ''}`
|
||||
const isInMappingMode = !!(mappingModeState.isActive && paramId)
|
||||
const hasMappings = mappedLFOs.length > 0
|
||||
|
||||
|
||||
@ -45,8 +45,8 @@ export const ENGINE_CONTROLS: ParameterGroup[] = [
|
||||
id: 'masterVolume',
|
||||
label: 'Vol',
|
||||
min: 0,
|
||||
max: 100,
|
||||
default: 75,
|
||||
max: 80,
|
||||
default: 50,
|
||||
step: 1,
|
||||
unit: '%'
|
||||
},
|
||||
@ -118,6 +118,111 @@ export const ENGINE_CONTROLS: ParameterGroup[] = [
|
||||
]
|
||||
|
||||
export const EFFECTS: ParameterGroup[] = [
|
||||
{
|
||||
id: 'ring',
|
||||
name: 'Ring Mod',
|
||||
bypassable: true,
|
||||
parameters: [
|
||||
{
|
||||
id: 'ringShape',
|
||||
label: 'Shape',
|
||||
min: 0,
|
||||
max: 0,
|
||||
default: 'sine',
|
||||
step: 1,
|
||||
unit: '',
|
||||
options: [
|
||||
{ value: 'sine', label: 'Sine' },
|
||||
{ value: 'square', label: 'Square' },
|
||||
{ value: 'saw', label: 'Saw' },
|
||||
{ value: 'triangle', label: 'Tri' }
|
||||
]
|
||||
},
|
||||
{
|
||||
id: 'ringFreq',
|
||||
label: 'Freq',
|
||||
min: 0.1,
|
||||
max: 1000,
|
||||
default: 200,
|
||||
step: 0.1,
|
||||
unit: 'Hz'
|
||||
},
|
||||
{
|
||||
id: 'ringSpread',
|
||||
label: 'Spread',
|
||||
min: 0,
|
||||
max: 100,
|
||||
default: 0,
|
||||
step: 1,
|
||||
unit: '%'
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
id: 'chorus',
|
||||
name: 'Chorus',
|
||||
bypassable: true,
|
||||
parameters: [
|
||||
{
|
||||
id: 'chorusType',
|
||||
label: 'Type',
|
||||
min: 0,
|
||||
max: 0,
|
||||
default: 'chorus',
|
||||
step: 1,
|
||||
unit: '',
|
||||
options: [
|
||||
{ value: 'chorus', label: 'Chorus' },
|
||||
{ value: 'flanger', label: 'Flanger' }
|
||||
]
|
||||
},
|
||||
{
|
||||
id: 'chorusRate',
|
||||
label: 'Rate',
|
||||
min: 0.1,
|
||||
max: 10,
|
||||
default: 0.5,
|
||||
step: 0.1,
|
||||
unit: 'Hz'
|
||||
},
|
||||
{
|
||||
id: 'chorusDepth',
|
||||
label: 'Depth',
|
||||
min: 0,
|
||||
max: 100,
|
||||
default: 50,
|
||||
step: 1,
|
||||
unit: '%'
|
||||
},
|
||||
{
|
||||
id: 'chorusFeedback',
|
||||
label: 'Feedback',
|
||||
min: 0,
|
||||
max: 100,
|
||||
default: 0,
|
||||
step: 1,
|
||||
unit: '%'
|
||||
},
|
||||
{
|
||||
id: 'chorusSpread',
|
||||
label: 'Spread',
|
||||
min: 0,
|
||||
max: 100,
|
||||
default: 30,
|
||||
step: 1,
|
||||
unit: '%'
|
||||
},
|
||||
{
|
||||
id: 'chorusMix',
|
||||
label: 'Mix',
|
||||
min: 0,
|
||||
max: 100,
|
||||
default: 50,
|
||||
step: 1,
|
||||
unit: '%'
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
id: 'filter',
|
||||
name: 'Filter',
|
||||
@ -150,7 +255,7 @@ export const EFFECTS: ParameterGroup[] = [
|
||||
{
|
||||
id: 'filterRes',
|
||||
label: 'Res',
|
||||
min: 0.5,
|
||||
min: 0.05,
|
||||
max: 10,
|
||||
default: 0.707,
|
||||
step: 0.1,
|
||||
@ -160,7 +265,7 @@ export const EFFECTS: ParameterGroup[] = [
|
||||
},
|
||||
{
|
||||
id: 'foldcrush',
|
||||
name: 'Fold and Crush',
|
||||
name: 'Distortion',
|
||||
bypassable: true,
|
||||
parameters: [
|
||||
{
|
||||
@ -172,34 +277,43 @@ export const EFFECTS: ParameterGroup[] = [
|
||||
step: 1,
|
||||
unit: '',
|
||||
options: [
|
||||
{ value: 'tube', label: 'Tube' },
|
||||
{ value: 'tape', label: 'Tape' },
|
||||
{ value: 'fuzz', label: 'Fuzz' },
|
||||
{ value: 'fold', label: 'Fold' },
|
||||
{ value: 'soft', label: 'Soft' },
|
||||
{ value: 'cubic', label: 'Cubic' },
|
||||
{ value: 'diode', label: 'Diode' },
|
||||
{ value: 'hard', label: 'Hard' }
|
||||
{ value: 'crush', label: 'Crush' }
|
||||
]
|
||||
},
|
||||
{
|
||||
id: 'wavefolderDrive',
|
||||
label: 'Drive',
|
||||
min: 0.001,
|
||||
min: 0,
|
||||
max: 10,
|
||||
default: 1,
|
||||
default: 0,
|
||||
step: 0.1,
|
||||
unit: ''
|
||||
},
|
||||
{
|
||||
id: 'bitcrushDepth',
|
||||
label: 'Depth',
|
||||
label: 'Bits',
|
||||
min: 1,
|
||||
max: 16,
|
||||
default: 16,
|
||||
step: 1,
|
||||
unit: 'bit'
|
||||
unit: ''
|
||||
},
|
||||
{
|
||||
id: 'bitcrushRate',
|
||||
label: 'Rate',
|
||||
label: 'Downsample',
|
||||
min: 0,
|
||||
max: 100,
|
||||
default: 0,
|
||||
step: 1,
|
||||
unit: '%'
|
||||
},
|
||||
{
|
||||
id: 'glitchAmount',
|
||||
label: 'Glitch',
|
||||
min: 0,
|
||||
max: 100,
|
||||
default: 0,
|
||||
@ -217,8 +331,8 @@ export const EFFECTS: ParameterGroup[] = [
|
||||
id: 'delayTime',
|
||||
label: 'Time',
|
||||
min: 10,
|
||||
max: 2000,
|
||||
default: 250,
|
||||
max: 10000,
|
||||
default: 500,
|
||||
step: 10,
|
||||
unit: 'ms'
|
||||
},
|
||||
@ -250,20 +364,20 @@ export const EFFECTS: ParameterGroup[] = [
|
||||
unit: '%'
|
||||
},
|
||||
{
|
||||
id: 'delaySaturation',
|
||||
label: 'Saturation',
|
||||
id: 'delayPingPong',
|
||||
label: 'Ping-Pong',
|
||||
min: 0,
|
||||
max: 100,
|
||||
default: 20,
|
||||
default: 0,
|
||||
step: 1,
|
||||
unit: '%'
|
||||
},
|
||||
{
|
||||
id: 'delayFlutter',
|
||||
label: 'Flutter',
|
||||
id: 'delayDiffusion',
|
||||
label: 'Diffusion',
|
||||
min: 0,
|
||||
max: 100,
|
||||
default: 15,
|
||||
default: 0,
|
||||
step: 1,
|
||||
unit: '%'
|
||||
}
|
||||
@ -276,7 +390,7 @@ export const EFFECTS: ParameterGroup[] = [
|
||||
parameters: [
|
||||
{
|
||||
id: 'reverbWetDry',
|
||||
label: 'Amount',
|
||||
label: 'Mix',
|
||||
min: 0,
|
||||
max: 100,
|
||||
default: 0,
|
||||
@ -284,17 +398,8 @@ export const EFFECTS: ParameterGroup[] = [
|
||||
unit: '%'
|
||||
},
|
||||
{
|
||||
id: 'reverbDecay',
|
||||
label: 'Decay',
|
||||
min: 0.1,
|
||||
max: 5,
|
||||
default: 2,
|
||||
step: 0.1,
|
||||
unit: 's'
|
||||
},
|
||||
{
|
||||
id: 'reverbDamping',
|
||||
label: 'Damping',
|
||||
id: 'reverbSize',
|
||||
label: 'Size',
|
||||
min: 0,
|
||||
max: 100,
|
||||
default: 50,
|
||||
@ -302,17 +407,17 @@ export const EFFECTS: ParameterGroup[] = [
|
||||
unit: '%'
|
||||
},
|
||||
{
|
||||
id: 'reverbPanRate',
|
||||
label: 'Pan Rate',
|
||||
min: 0,
|
||||
max: 10,
|
||||
default: 0,
|
||||
step: 0.1,
|
||||
unit: 'Hz'
|
||||
id: 'reverbDecay',
|
||||
label: 'Decay',
|
||||
min: 10,
|
||||
max: 95,
|
||||
default: 70,
|
||||
step: 1,
|
||||
unit: '%'
|
||||
},
|
||||
{
|
||||
id: 'reverbPanWidth',
|
||||
label: 'Pan Width',
|
||||
id: 'reverbDamping',
|
||||
label: 'Damping',
|
||||
min: 0,
|
||||
max: 100,
|
||||
default: 50,
|
||||
|
||||
@ -74,6 +74,8 @@ export class AudioPlayer {
|
||||
context.audioWorklet.addModule('/worklets/fold-crush-processor.js'),
|
||||
context.audioWorklet.addModule('/worklets/bytebeat-processor.js'),
|
||||
context.audioWorklet.addModule('/worklets/fm-processor.js'),
|
||||
context.audioWorklet.addModule('/worklets/ring-mod-processor.js'),
|
||||
context.audioWorklet.addModule('/worklets/chorus-processor.js'),
|
||||
context.audioWorklet.addModule('/worklets/output-limiter.js')
|
||||
])
|
||||
this.workletRegistered = true
|
||||
|
||||
102
src/domain/audio/effects/ChorusEffect.ts
Normal file
102
src/domain/audio/effects/ChorusEffect.ts
Normal file
@ -0,0 +1,102 @@
|
||||
import type { Effect } from './Effect.interface'
|
||||
|
||||
export class ChorusEffect implements Effect {
|
||||
readonly id = 'chorus'
|
||||
|
||||
private inputNode: GainNode
|
||||
private outputNode: GainNode
|
||||
private processorNode: AudioWorkletNode | null = null
|
||||
private wetNode: GainNode
|
||||
private dryNode: GainNode
|
||||
|
||||
private bypassed: boolean = false
|
||||
private currentWetValue: number = 0.5
|
||||
private currentDryValue: number = 0.5
|
||||
|
||||
constructor(audioContext: AudioContext) {
|
||||
this.inputNode = audioContext.createGain()
|
||||
this.outputNode = audioContext.createGain()
|
||||
this.wetNode = audioContext.createGain()
|
||||
this.dryNode = audioContext.createGain()
|
||||
|
||||
this.wetNode.gain.value = 0.5
|
||||
this.dryNode.gain.value = 0.5
|
||||
|
||||
this.inputNode.connect(this.dryNode)
|
||||
this.dryNode.connect(this.outputNode)
|
||||
}
|
||||
|
||||
async initialize(audioContext: AudioContext): Promise<void> {
|
||||
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<string, number | string>): 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()
|
||||
}
|
||||
}
|
||||
@ -6,73 +6,120 @@ export class DelayEffect implements Effect {
|
||||
private audioContext: AudioContext
|
||||
private inputNode: GainNode
|
||||
private outputNode: GainNode
|
||||
private delayNode: DelayNode
|
||||
private feedbackNode: GainNode
|
||||
private wetNode: GainNode
|
||||
private dryNode: GainNode
|
||||
private wetNode: GainNode
|
||||
|
||||
private leftDelayNode: DelayNode
|
||||
private rightDelayNode: DelayNode
|
||||
private leftFeedbackNode: GainNode
|
||||
private rightFeedbackNode: GainNode
|
||||
|
||||
private filterNode: BiquadFilterNode
|
||||
private saturatorNode: WaveShaperNode
|
||||
private lfoNode: OscillatorNode
|
||||
private lfoGainNode: GainNode
|
||||
private dcBlockerNode: BiquadFilterNode
|
||||
|
||||
private splitterNode: ChannelSplitterNode
|
||||
private mergerNode: ChannelMergerNode
|
||||
|
||||
private diffusionNodes: BiquadFilterNode[] = []
|
||||
private diffusionMixNode: GainNode
|
||||
private diffusionWetNode: GainNode
|
||||
private diffusionDryNode: GainNode
|
||||
|
||||
private bypassed: boolean = false
|
||||
private currentWetValue: number = 0.5
|
||||
private currentDryValue: number = 0.5
|
||||
private currentPingPong: number = 0
|
||||
|
||||
constructor(audioContext: AudioContext) {
|
||||
this.audioContext = audioContext
|
||||
|
||||
this.inputNode = audioContext.createGain()
|
||||
this.outputNode = audioContext.createGain()
|
||||
this.delayNode = audioContext.createDelay(2.0)
|
||||
this.feedbackNode = audioContext.createGain()
|
||||
this.wetNode = audioContext.createGain()
|
||||
this.dryNode = audioContext.createGain()
|
||||
this.filterNode = audioContext.createBiquadFilter()
|
||||
this.saturatorNode = audioContext.createWaveShaper()
|
||||
this.wetNode = audioContext.createGain()
|
||||
|
||||
this.delayNode.delayTime.value = 0.25
|
||||
this.splitterNode = audioContext.createChannelSplitter(2)
|
||||
this.mergerNode = audioContext.createChannelMerger(2)
|
||||
|
||||
this.leftDelayNode = audioContext.createDelay(11)
|
||||
this.rightDelayNode = audioContext.createDelay(11)
|
||||
this.leftFeedbackNode = audioContext.createGain()
|
||||
this.rightFeedbackNode = audioContext.createGain()
|
||||
|
||||
this.filterNode = audioContext.createBiquadFilter()
|
||||
this.dcBlockerNode = audioContext.createBiquadFilter()
|
||||
|
||||
this.diffusionMixNode = audioContext.createGain()
|
||||
this.diffusionWetNode = audioContext.createGain()
|
||||
this.diffusionDryNode = audioContext.createGain()
|
||||
|
||||
this.leftDelayNode.delayTime.value = 0.5
|
||||
this.rightDelayNode.delayTime.value = 0.5
|
||||
this.leftFeedbackNode.gain.value = 0.5
|
||||
this.rightFeedbackNode.gain.value = 0.5
|
||||
this.dryNode.gain.value = 0.5
|
||||
this.wetNode.gain.value = 0.5
|
||||
this.feedbackNode.gain.value = 0.5
|
||||
|
||||
this.filterNode.type = 'lowpass'
|
||||
this.filterNode.frequency.value = 5000
|
||||
this.filterNode.Q.value = 0.7
|
||||
|
||||
this.createSaturationCurve(0.2)
|
||||
this.dcBlockerNode.type = 'highpass'
|
||||
this.dcBlockerNode.frequency.value = 5
|
||||
this.dcBlockerNode.Q.value = 0.707
|
||||
|
||||
this.lfoNode = audioContext.createOscillator()
|
||||
this.lfoGainNode = audioContext.createGain()
|
||||
this.lfoNode.frequency.value = 2.5
|
||||
this.lfoGainNode.gain.value = 0.0015
|
||||
this.lfoNode.connect(this.lfoGainNode)
|
||||
this.lfoGainNode.connect(this.delayNode.delayTime)
|
||||
this.lfoNode.start()
|
||||
this.diffusionWetNode.gain.value = 0
|
||||
this.diffusionDryNode.gain.value = 1
|
||||
|
||||
const diffusionDelays = [5.3, 11.7, 19.3, 29.1]
|
||||
for (let i = 0; i < diffusionDelays.length; i++) {
|
||||
const allpass = audioContext.createBiquadFilter()
|
||||
allpass.type = 'allpass'
|
||||
allpass.frequency.value = 1000 / (diffusionDelays[i] / 1000)
|
||||
allpass.Q.value = 0.707
|
||||
this.diffusionNodes.push(allpass)
|
||||
}
|
||||
|
||||
this.setupRouting()
|
||||
}
|
||||
|
||||
private setupRouting(): void {
|
||||
this.inputNode.connect(this.dryNode)
|
||||
this.inputNode.connect(this.delayNode)
|
||||
this.delayNode.connect(this.saturatorNode)
|
||||
this.saturatorNode.connect(this.filterNode)
|
||||
this.filterNode.connect(this.feedbackNode)
|
||||
this.feedbackNode.connect(this.delayNode)
|
||||
this.delayNode.connect(this.wetNode)
|
||||
this.dryNode.connect(this.outputNode)
|
||||
|
||||
this.inputNode.connect(this.splitterNode)
|
||||
this.splitterNode.connect(this.leftDelayNode, 0)
|
||||
this.splitterNode.connect(this.rightDelayNode, 1)
|
||||
|
||||
this.leftDelayNode.connect(this.filterNode)
|
||||
this.filterNode.connect(this.dcBlockerNode)
|
||||
this.dcBlockerNode.connect(this.diffusionMixNode)
|
||||
|
||||
this.diffusionMixNode.connect(this.diffusionDryNode)
|
||||
this.diffusionMixNode.connect(this.diffusionNodes[0])
|
||||
|
||||
let lastNode: AudioNode = this.diffusionNodes[0]
|
||||
for (let i = 1; i < this.diffusionNodes.length; i++) {
|
||||
lastNode.connect(this.diffusionNodes[i])
|
||||
lastNode = this.diffusionNodes[i]
|
||||
}
|
||||
lastNode.connect(this.diffusionWetNode)
|
||||
|
||||
this.diffusionDryNode.connect(this.leftFeedbackNode)
|
||||
this.diffusionWetNode.connect(this.leftFeedbackNode)
|
||||
this.leftFeedbackNode.connect(this.rightDelayNode)
|
||||
|
||||
this.rightDelayNode.connect(this.rightFeedbackNode)
|
||||
this.rightFeedbackNode.connect(this.leftDelayNode)
|
||||
|
||||
this.diffusionDryNode.connect(this.mergerNode, 0, 0)
|
||||
this.diffusionWetNode.connect(this.mergerNode, 0, 0)
|
||||
this.rightDelayNode.connect(this.mergerNode, 0, 1)
|
||||
|
||||
this.mergerNode.connect(this.wetNode)
|
||||
this.wetNode.connect(this.outputNode)
|
||||
}
|
||||
|
||||
private createSaturationCurve(amount: number): void {
|
||||
const samples = 2048
|
||||
const curve = new Float32Array(samples)
|
||||
const drive = 1 + amount * 9
|
||||
|
||||
for (let i = 0; i < samples; i++) {
|
||||
const x = (i * 2) / samples - 1
|
||||
curve[i] = Math.tanh(x * drive) / Math.tanh(drive)
|
||||
}
|
||||
|
||||
this.saturatorNode.curve = curve
|
||||
this.saturatorNode.oversample = '2x'
|
||||
}
|
||||
|
||||
getInputNode(): AudioNode {
|
||||
return this.inputNode
|
||||
}
|
||||
@ -86,27 +133,39 @@ export class DelayEffect implements Effect {
|
||||
if (bypass) {
|
||||
this.wetNode.gain.value = 0
|
||||
this.dryNode.gain.value = 1
|
||||
this.leftFeedbackNode.disconnect()
|
||||
this.rightFeedbackNode.disconnect()
|
||||
} else {
|
||||
this.wetNode.gain.value = this.currentWetValue
|
||||
this.dryNode.gain.value = this.currentDryValue
|
||||
this.leftFeedbackNode.disconnect()
|
||||
this.rightFeedbackNode.disconnect()
|
||||
this.leftFeedbackNode.connect(this.rightDelayNode)
|
||||
this.rightFeedbackNode.connect(this.leftDelayNode)
|
||||
}
|
||||
}
|
||||
|
||||
updateParams(values: Record<string, number | string>): void {
|
||||
const now = this.audioContext.currentTime
|
||||
|
||||
if (values.delayTime !== undefined && typeof values.delayTime === 'number') {
|
||||
const time = values.delayTime / 1000
|
||||
this.delayNode.delayTime.setTargetAtTime(
|
||||
time,
|
||||
this.audioContext.currentTime,
|
||||
0.01
|
||||
)
|
||||
this.leftDelayNode.delayTime.setTargetAtTime(time, now, 0.01)
|
||||
this.rightDelayNode.delayTime.setTargetAtTime(time, now, 0.01)
|
||||
}
|
||||
|
||||
if (values.delayFeedback !== undefined && typeof values.delayFeedback === 'number') {
|
||||
const feedback = values.delayFeedback / 100
|
||||
this.feedbackNode.gain.setTargetAtTime(
|
||||
feedback * 0.95,
|
||||
this.audioContext.currentTime,
|
||||
const pingPongFactor = Math.max(0.5, this.currentPingPong / 100)
|
||||
|
||||
this.leftFeedbackNode.gain.setTargetAtTime(
|
||||
feedback * 0.95 * pingPongFactor,
|
||||
now,
|
||||
0.01
|
||||
)
|
||||
this.rightFeedbackNode.gain.setTargetAtTime(
|
||||
feedback * 0.95 * pingPongFactor,
|
||||
now,
|
||||
0.01
|
||||
)
|
||||
}
|
||||
@ -117,57 +176,51 @@ export class DelayEffect implements Effect {
|
||||
this.currentDryValue = 1 - wet
|
||||
|
||||
if (!this.bypassed) {
|
||||
this.wetNode.gain.setTargetAtTime(
|
||||
this.currentWetValue,
|
||||
this.audioContext.currentTime,
|
||||
0.01
|
||||
)
|
||||
this.dryNode.gain.setTargetAtTime(
|
||||
this.currentDryValue,
|
||||
this.audioContext.currentTime,
|
||||
0.01
|
||||
)
|
||||
this.wetNode.gain.setTargetAtTime(this.currentWetValue, now, 0.01)
|
||||
this.dryNode.gain.setTargetAtTime(this.currentDryValue, now, 0.01)
|
||||
}
|
||||
}
|
||||
|
||||
if (values.delayTone !== undefined && typeof values.delayTone === 'number') {
|
||||
const tone = values.delayTone / 100
|
||||
const freq = 200 + tone * 7800
|
||||
this.filterNode.frequency.setTargetAtTime(
|
||||
freq,
|
||||
this.audioContext.currentTime,
|
||||
0.01
|
||||
)
|
||||
this.filterNode.frequency.setTargetAtTime(freq, now, 0.01)
|
||||
}
|
||||
|
||||
if (values.delaySaturation !== undefined && typeof values.delaySaturation === 'number') {
|
||||
const saturation = values.delaySaturation / 100
|
||||
this.createSaturationCurve(saturation)
|
||||
if (values.delayPingPong !== undefined && typeof values.delayPingPong === 'number') {
|
||||
this.currentPingPong = values.delayPingPong
|
||||
}
|
||||
|
||||
if (values.delayFlutter !== undefined && typeof values.delayFlutter === 'number') {
|
||||
const flutter = values.delayFlutter / 100
|
||||
const baseDelay = this.delayNode.delayTime.value
|
||||
const modDepth = baseDelay * flutter * 0.1
|
||||
this.lfoGainNode.gain.setTargetAtTime(
|
||||
modDepth,
|
||||
this.audioContext.currentTime,
|
||||
0.01
|
||||
)
|
||||
if (values.delayDiffusion !== undefined && typeof values.delayDiffusion === 'number') {
|
||||
const diffusion = values.delayDiffusion / 100
|
||||
this.diffusionWetNode.gain.setTargetAtTime(diffusion, now, 0.01)
|
||||
this.diffusionDryNode.gain.setTargetAtTime(1 - diffusion, now, 0.01)
|
||||
|
||||
for (const filter of this.diffusionNodes) {
|
||||
filter.Q.setTargetAtTime(0.707 + diffusion * 4, now, 0.01)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
dispose(): void {
|
||||
this.lfoNode.stop()
|
||||
this.lfoNode.disconnect()
|
||||
this.lfoGainNode.disconnect()
|
||||
this.inputNode.disconnect()
|
||||
this.outputNode.disconnect()
|
||||
this.delayNode.disconnect()
|
||||
this.feedbackNode.disconnect()
|
||||
this.wetNode.disconnect()
|
||||
this.dryNode.disconnect()
|
||||
this.wetNode.disconnect()
|
||||
this.leftDelayNode.disconnect()
|
||||
this.rightDelayNode.disconnect()
|
||||
this.leftFeedbackNode.disconnect()
|
||||
this.rightFeedbackNode.disconnect()
|
||||
this.filterNode.disconnect()
|
||||
this.saturatorNode.disconnect()
|
||||
this.dcBlockerNode.disconnect()
|
||||
this.splitterNode.disconnect()
|
||||
this.mergerNode.disconnect()
|
||||
this.diffusionMixNode.disconnect()
|
||||
this.diffusionWetNode.disconnect()
|
||||
this.diffusionDryNode.disconnect()
|
||||
|
||||
for (const filter of this.diffusionNodes) {
|
||||
filter.disconnect()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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)
|
||||
])
|
||||
}
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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 scheduleRegeneration(): void {
|
||||
if (this.debounceTimer !== null) {
|
||||
clearTimeout(this.debounceTimer)
|
||||
}
|
||||
|
||||
private updateDecayAndDamping(): void {
|
||||
const decay = this.currentDecay
|
||||
const damping = this.currentDamping
|
||||
this.debounceTimer = window.setTimeout(() => {
|
||||
this.currentDecay = this.pendingDecay
|
||||
this.currentDamping = this.pendingDamping
|
||||
this.currentSize = this.pendingSize
|
||||
|
||||
this.lowBandProcessor.setDecay(decay * 1.2)
|
||||
this.midBandProcessor.setDecay(decay)
|
||||
this.highBandProcessor.setDecay(decay * 0.6)
|
||||
const standbyConvolver = this.activeConvolver === 'A' ? 'B' : 'A'
|
||||
this.generateImpulseResponse(standbyConvolver, this.currentDecay, this.currentDamping, this.currentSize)
|
||||
|
||||
this.lowBandProcessor.setDamping(damping * 0.5)
|
||||
this.midBandProcessor.setDamping(damping)
|
||||
this.highBandProcessor.setDamping(damping * 1.5)
|
||||
setTimeout(() => {
|
||||
this.crossfadeToStandby()
|
||||
}, 10)
|
||||
|
||||
const modAmount = 0.3
|
||||
this.lowToHighModGain.gain.value = modAmount
|
||||
this.highToLowModGain.gain.value = modAmount * 0.7
|
||||
this.midToGlobalModGain.gain.value = modAmount * 0.5
|
||||
this.debounceTimer = null
|
||||
}, this.DEBOUNCE_MS)
|
||||
}
|
||||
|
||||
getInputNode(): AudioNode {
|
||||
@ -226,16 +168,21 @@ export class ReverbEffect implements Effect {
|
||||
}
|
||||
|
||||
updateParams(values: Record<string, number | string>): 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<string, { d1: number; d2: number; ap1: number; ap2: number }> = {
|
||||
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()
|
||||
}
|
||||
}
|
||||
|
||||
61
src/domain/audio/effects/RingModEffect.ts
Normal file
61
src/domain/audio/effects/RingModEffect.ts
Normal file
@ -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<void> {
|
||||
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<string, number | string>): 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()
|
||||
}
|
||||
}
|
||||
@ -12,6 +12,7 @@ export interface KeyboardShortcutHandlers {
|
||||
onShiftR?: () => void
|
||||
onC?: () => void
|
||||
onShiftC?: () => void
|
||||
onI?: () => void
|
||||
onEscape?: () => void
|
||||
}
|
||||
|
||||
@ -92,6 +93,12 @@ export function useKeyboardShortcuts(handlers: KeyboardShortcutHandlers) {
|
||||
}
|
||||
break
|
||||
|
||||
case 'i':
|
||||
case 'I':
|
||||
e.preventDefault()
|
||||
h.onI?.()
|
||||
break
|
||||
|
||||
case 'Escape':
|
||||
e.preventDefault()
|
||||
h.onEscape?.()
|
||||
|
||||
@ -1,11 +1,13 @@
|
||||
import { useCallback } from 'react'
|
||||
import { useCallback, useRef } from 'react'
|
||||
import type { TileState } from '../types/tiles'
|
||||
import type { FocusedTile } from '../utils/tileHelpers'
|
||||
import { engineSettings, effectSettings } from '../stores/settings'
|
||||
import { engineSettings, effectSettings, lfoSettings } from '../stores/settings'
|
||||
import { loadTileParams, saveTileParams, randomizeTileParams } from '../utils/tileState'
|
||||
import { getTileFromGrid } from '../utils/tileHelpers'
|
||||
import { DEFAULT_VARIABLES } from '../constants/defaults'
|
||||
import type { PlaybackManager } from '../services/PlaybackManager'
|
||||
import { ENGINE_CONTROLS, EFFECTS } from '../config/parameters'
|
||||
import type { LFOWaveform } from '../domain/modulation/LFO'
|
||||
|
||||
interface UseParameterSyncProps {
|
||||
tiles: TileState[][]
|
||||
@ -28,6 +30,7 @@ export function useParameterSync({
|
||||
playing,
|
||||
playbackId
|
||||
}: UseParameterSyncProps) {
|
||||
const interpolationRef = useRef<number | null>(null)
|
||||
|
||||
const saveCurrentParams = useCallback(() => {
|
||||
if (focusedTile === 'custom') {
|
||||
@ -201,12 +204,206 @@ export function useParameterSync({
|
||||
}
|
||||
}, [playing, playbackId, setCustomTile, setTiles, focusedTile, playbackManager])
|
||||
|
||||
const interpolateParams = useCallback(() => {
|
||||
if (interpolationRef.current !== null) {
|
||||
return
|
||||
}
|
||||
|
||||
const startTime = Date.now()
|
||||
const currentEngine = engineSettings.get()
|
||||
const currentEffect = effectSettings.get()
|
||||
const currentLFOs = lfoSettings.get()
|
||||
|
||||
interface ParamTransition {
|
||||
start: number
|
||||
end: number
|
||||
duration: number
|
||||
paramId: string
|
||||
paramType: 'engine' | 'effect' | 'lfo'
|
||||
isBoolean?: boolean
|
||||
isString?: boolean
|
||||
isInteger?: boolean
|
||||
lfoIndex?: number
|
||||
}
|
||||
|
||||
const transitions: ParamTransition[] = []
|
||||
|
||||
EFFECTS.forEach(effect => {
|
||||
if (effect.bypassable) {
|
||||
const bypassKey = `${effect.id}Bypass`
|
||||
const targetBypass = Math.random() > 0.5
|
||||
setTimeout(() => {
|
||||
handleEffectChange(bypassKey, targetBypass)
|
||||
}, 500 + Math.random() * 4500)
|
||||
}
|
||||
})
|
||||
|
||||
const waveforms: LFOWaveform[] = ['sine', 'triangle', 'square', 'sawtooth']
|
||||
const lfoKeys = ['lfo1', 'lfo2', 'lfo3', 'lfo4'] as const
|
||||
lfoKeys.forEach((lfoKey, index) => {
|
||||
const currentLFO = currentLFOs[lfoKey]
|
||||
const targetFrequency = Math.random() * 10
|
||||
|
||||
transitions.push({
|
||||
start: currentLFO.frequency,
|
||||
end: targetFrequency,
|
||||
duration: 500 + Math.random() * 4500,
|
||||
paramId: 'frequency',
|
||||
paramType: 'lfo',
|
||||
lfoIndex: index
|
||||
})
|
||||
|
||||
const targetWaveform = waveforms[Math.floor(Math.random() * waveforms.length)]
|
||||
const targetPhase = Math.random() * 360
|
||||
setTimeout(() => {
|
||||
if (playbackManager.current) {
|
||||
const updatedLFO = {
|
||||
...currentLFO,
|
||||
waveform: targetWaveform,
|
||||
phase: targetPhase,
|
||||
frequency: targetFrequency
|
||||
}
|
||||
lfoSettings.setKey(lfoKey, updatedLFO)
|
||||
playbackManager.current.setLFOConfig(index, updatedLFO)
|
||||
}
|
||||
}, 500 + Math.random() * 4500)
|
||||
})
|
||||
|
||||
ENGINE_CONTROLS.forEach(control => {
|
||||
control.parameters.forEach(param => {
|
||||
const currentValue = currentEngine[param.id as keyof typeof currentEngine] as number
|
||||
let targetValue: number
|
||||
const isIntegerParam = param.step === 1 && Number.isInteger(param.min as number)
|
||||
|
||||
if (param.id === 'sampleRate' || param.id === 'bitDepth') {
|
||||
return
|
||||
} else if (param.id === 'pitch') {
|
||||
targetValue = 0.1 + Math.random() * 1.4
|
||||
} else if (param.id === 'a' || param.id === 'b' || param.id === 'c' || param.id === 'd') {
|
||||
targetValue = Math.floor(Math.random() * 256)
|
||||
} else {
|
||||
const range = (param.max as number) - (param.min as number)
|
||||
targetValue = (param.min as number) + Math.random() * range
|
||||
}
|
||||
|
||||
transitions.push({
|
||||
start: currentValue,
|
||||
end: targetValue,
|
||||
duration: 500 + Math.random() * 4500,
|
||||
paramId: param.id,
|
||||
paramType: 'engine',
|
||||
isInteger: isIntegerParam
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
EFFECTS.forEach(effect => {
|
||||
effect.parameters.forEach(param => {
|
||||
const currentValue = currentEffect[param.id as keyof typeof currentEffect]
|
||||
const isIntegerParam = param.step === 1 && Number.isInteger(param.min as number)
|
||||
|
||||
if (param.options) {
|
||||
const options = param.options
|
||||
const randomOption = options[Math.floor(Math.random() * options.length)]
|
||||
transitions.push({
|
||||
start: 0,
|
||||
end: 0,
|
||||
duration: 500 + Math.random() * 4500,
|
||||
paramId: param.id,
|
||||
paramType: 'effect',
|
||||
isString: true
|
||||
})
|
||||
setTimeout(() => {
|
||||
handleEffectChange(param.id, randomOption.value)
|
||||
}, 500 + Math.random() * 4500)
|
||||
} else if (typeof param.default === 'boolean') {
|
||||
const targetValue = Math.random() > 0.5
|
||||
transitions.push({
|
||||
start: 0,
|
||||
end: 0,
|
||||
duration: 500 + Math.random() * 4500,
|
||||
paramId: param.id,
|
||||
paramType: 'effect',
|
||||
isBoolean: true
|
||||
})
|
||||
setTimeout(() => {
|
||||
handleEffectChange(param.id, targetValue)
|
||||
}, 500 + Math.random() * 4500)
|
||||
} else {
|
||||
const range = (param.max as number) - (param.min as number)
|
||||
const targetValue = (param.min as number) + Math.random() * range
|
||||
|
||||
transitions.push({
|
||||
start: currentValue as number,
|
||||
end: targetValue,
|
||||
duration: 500 + Math.random() * 4500,
|
||||
paramId: param.id,
|
||||
paramType: 'effect',
|
||||
isInteger: isIntegerParam
|
||||
})
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
const animate = () => {
|
||||
const elapsed = Date.now() - startTime
|
||||
let allComplete = true
|
||||
|
||||
transitions.forEach(transition => {
|
||||
if (transition.isBoolean || transition.isString) {
|
||||
return
|
||||
}
|
||||
|
||||
const progress = Math.min(elapsed / transition.duration, 1)
|
||||
const eased = progress < 0.5
|
||||
? 2 * progress * progress
|
||||
: 1 - Math.pow(-2 * progress + 2, 2) / 2
|
||||
|
||||
let currentValue = transition.start + (transition.end - transition.start) * eased
|
||||
|
||||
if (transition.isInteger) {
|
||||
currentValue = Math.round(currentValue)
|
||||
}
|
||||
|
||||
if (transition.paramType === 'engine') {
|
||||
handleEngineChange(transition.paramId, currentValue)
|
||||
} else if (transition.paramType === 'effect') {
|
||||
handleEffectChange(transition.paramId, currentValue)
|
||||
} else if (transition.paramType === 'lfo' && transition.lfoIndex !== undefined) {
|
||||
if (playbackManager.current) {
|
||||
const lfoKey = ['lfo1', 'lfo2', 'lfo3', 'lfo4'][transition.lfoIndex] as 'lfo1' | 'lfo2' | 'lfo3' | 'lfo4'
|
||||
const currentLFO = lfoSettings.get()[lfoKey]
|
||||
const updatedLFO = {
|
||||
...currentLFO,
|
||||
frequency: currentValue
|
||||
}
|
||||
lfoSettings.setKey(lfoKey, updatedLFO)
|
||||
playbackManager.current.setLFOConfig(transition.lfoIndex, updatedLFO)
|
||||
}
|
||||
}
|
||||
|
||||
if (progress < 1) {
|
||||
allComplete = false
|
||||
}
|
||||
})
|
||||
|
||||
if (!allComplete) {
|
||||
interpolationRef.current = requestAnimationFrame(animate)
|
||||
} else {
|
||||
interpolationRef.current = null
|
||||
}
|
||||
}
|
||||
|
||||
interpolationRef.current = requestAnimationFrame(animate)
|
||||
}, [handleEngineChange, handleEffectChange, playbackManager])
|
||||
|
||||
return {
|
||||
saveCurrentParams,
|
||||
loadParams,
|
||||
handleEngineChange,
|
||||
handleEffectChange,
|
||||
randomizeParams,
|
||||
randomizeAllParams
|
||||
randomizeAllParams,
|
||||
interpolateParams
|
||||
}
|
||||
}
|
||||
|
||||
@ -26,7 +26,7 @@ export function useTileGrid() {
|
||||
}
|
||||
}, [mode, engineValues.complexity])
|
||||
|
||||
const regenerateTile = useCallback((row: number, col: number) => {
|
||||
const regenerateTile = useCallback((row: number, col: number): TileState => {
|
||||
let newTile: TileState
|
||||
|
||||
if (mode === 'fm') {
|
||||
@ -43,6 +43,8 @@ export function useTileGrid() {
|
||||
newTiles[row][col] = newTile
|
||||
return newTiles
|
||||
})
|
||||
|
||||
return newTile
|
||||
}, [mode, engineValues.complexity])
|
||||
|
||||
const switchMode = useCallback((newMode: SynthesisMode) => {
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user