Compare commits
11 Commits
ef50cc9918
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 14e1a8ec48 | |||
| 9d26ea5cd7 | |||
| 90f2f4209c | |||
| ff5add97e8 | |||
| 324cf9d2ed | |||
| 0110a9760b | |||
| 871dd6ca39 | |||
| a113294acf | |||
| 71e01488dc | |||
| 7559a2bfb5 | |||
| a4a26333b3 |
@ -2,7 +2,7 @@
|
|||||||
<html lang="en">
|
<html lang="en">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no" />
|
||||||
<meta name="description" content="Bytebeat playground" />
|
<meta name="description" content="Bytebeat playground" />
|
||||||
<meta name="author" content="Raphaël Forment" />
|
<meta name="author" content="Raphaël Forment" />
|
||||||
<link rel="icon" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32'><text x='16' y='22' text-anchor='middle' font-size='20' font-family='monospace' font-weight='bold'>&</text></svg>" />
|
<link rel="icon" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32'><text x='16' y='22' text-anchor='middle' font-size='20' font-family='monospace' font-weight='bold'>&</text></svg>" />
|
||||||
|
|||||||
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)
|
||||||
481
public/worklets/fm-processor.js
Normal file
481
public/worklets/fm-processor.js
Normal file
@ -0,0 +1,481 @@
|
|||||||
|
class FMProcessor extends AudioWorkletProcessor {
|
||||||
|
constructor() {
|
||||||
|
super()
|
||||||
|
|
||||||
|
this.phase1 = 0
|
||||||
|
this.phase2 = 0
|
||||||
|
this.phase3 = 0
|
||||||
|
this.phase4 = 0
|
||||||
|
this.baseFreq = 220
|
||||||
|
this.algorithm = 0
|
||||||
|
this.opLevel1 = 128
|
||||||
|
this.opLevel2 = 128
|
||||||
|
this.opLevel3 = 128
|
||||||
|
this.opLevel4 = 128
|
||||||
|
this.feedback = 0
|
||||||
|
this.playbackRate = 1.0
|
||||||
|
this.loopLength = 0
|
||||||
|
this.sampleCount = 0
|
||||||
|
this.feedbackSample = 0
|
||||||
|
|
||||||
|
this.frequencyRatios = [1.0, 1.0, 1.0, 1.0]
|
||||||
|
|
||||||
|
this.lfoPhase1 = 0
|
||||||
|
this.lfoPhase2 = 0
|
||||||
|
this.lfoPhase3 = 0
|
||||||
|
this.lfoPhase4 = 0
|
||||||
|
this.lfoRate1 = 0.37
|
||||||
|
this.lfoRate2 = 0.53
|
||||||
|
this.lfoRate3 = 0.71
|
||||||
|
this.lfoRate4 = 0.43
|
||||||
|
this.lfoDepth = 0.3
|
||||||
|
|
||||||
|
this.pitchLFOPhase = 0
|
||||||
|
this.pitchLFOWaveform = 0
|
||||||
|
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) {
|
||||||
|
case 'algorithm':
|
||||||
|
this.algorithm = value.id
|
||||||
|
this.frequencyRatios = value.ratios
|
||||||
|
if (value.lfoRates) {
|
||||||
|
this.lfoRate1 = value.lfoRates[0]
|
||||||
|
this.lfoRate2 = value.lfoRates[1]
|
||||||
|
this.lfoRate3 = value.lfoRates[2]
|
||||||
|
this.lfoRate4 = value.lfoRates[3]
|
||||||
|
}
|
||||||
|
if (value.pitchLFO) {
|
||||||
|
this.pitchLFOWaveform = value.pitchLFO.waveform
|
||||||
|
this.pitchLFODepth = value.pitchLFO.depth
|
||||||
|
this.pitchLFOBaseRate = value.pitchLFO.baseRate
|
||||||
|
}
|
||||||
|
break
|
||||||
|
case 'operatorLevels':
|
||||||
|
this.opLevel1 = value.a
|
||||||
|
this.opLevel2 = value.b
|
||||||
|
this.opLevel3 = value.c
|
||||||
|
this.opLevel4 = value.d
|
||||||
|
break
|
||||||
|
case 'baseFreq':
|
||||||
|
this.baseFreq = value
|
||||||
|
break
|
||||||
|
case 'feedback':
|
||||||
|
this.feedback = value / 100.0
|
||||||
|
break
|
||||||
|
case 'reset':
|
||||||
|
this.phase1 = 0
|
||||||
|
this.phase2 = 0
|
||||||
|
this.phase3 = 0
|
||||||
|
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
|
||||||
|
break
|
||||||
|
case 'playbackRate':
|
||||||
|
this.playbackRate = value
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
generatePitchLFO(phase, waveform) {
|
||||||
|
const TWO_PI = Math.PI * 2
|
||||||
|
const normalizedPhase = phase / TWO_PI
|
||||||
|
|
||||||
|
switch (waveform) {
|
||||||
|
case 0: // sine
|
||||||
|
return Math.sin(phase)
|
||||||
|
case 1: // triangle
|
||||||
|
return 2 * Math.abs(2 * (normalizedPhase % 1 - 0.5)) - 1
|
||||||
|
case 2: // square
|
||||||
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
synthesize(algorithm) {
|
||||||
|
const TWO_PI = Math.PI * 2
|
||||||
|
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)
|
||||||
|
const pitchLFOValue = this.generatePitchLFO(this.pitchLFOPhase, this.pitchLFOWaveform)
|
||||||
|
const pitchMod = 1 + pitchLFOValue * this.pitchLFODepth
|
||||||
|
const modulatedBaseFreq = this.baseFreq * pitchMod
|
||||||
|
|
||||||
|
this.pitchLFOPhase += (pitchLFORate * TWO_PI) / sampleRate
|
||||||
|
if (this.pitchLFOPhase > TWO_PI) this.pitchLFOPhase -= TWO_PI
|
||||||
|
|
||||||
|
const freq1 = (modulatedBaseFreq * this.frequencyRatios[0] * TWO_PI) / sampleRate
|
||||||
|
const freq2 = (modulatedBaseFreq * this.frequencyRatios[1] * TWO_PI) / sampleRate
|
||||||
|
const freq3 = (modulatedBaseFreq * this.frequencyRatios[2] * TWO_PI) / sampleRate
|
||||||
|
const freq4 = (modulatedBaseFreq * this.frequencyRatios[3] * TWO_PI) / sampleRate
|
||||||
|
|
||||||
|
const lfo1 = Math.sin(this.lfoPhase1)
|
||||||
|
const lfo2 = Math.sin(this.lfoPhase2)
|
||||||
|
const lfo3 = Math.sin(this.lfoPhase3)
|
||||||
|
const lfo4 = Math.sin(this.lfoPhase4)
|
||||||
|
|
||||||
|
const level1 = (this.opLevel1 / 255.0) * (1 + this.lfoDepth * lfo1)
|
||||||
|
const level2 = (this.opLevel2 / 255.0) * (1 + this.lfoDepth * lfo2)
|
||||||
|
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
|
||||||
|
this.lfoPhase4 += (this.lfoRate4 * TWO_PI) / sampleRate
|
||||||
|
|
||||||
|
if (this.lfoPhase1 > TWO_PI) this.lfoPhase1 -= TWO_PI
|
||||||
|
if (this.lfoPhase2 > TWO_PI) this.lfoPhase2 -= TWO_PI
|
||||||
|
if (this.lfoPhase3 > TWO_PI) this.lfoPhase3 -= TWO_PI
|
||||||
|
if (this.lfoPhase4 > TWO_PI) this.lfoPhase4 -= TWO_PI
|
||||||
|
|
||||||
|
let output = 0
|
||||||
|
|
||||||
|
switch (algorithm) {
|
||||||
|
case 0: {
|
||||||
|
const op1 = Math.sin(this.phase1) * level1
|
||||||
|
const op2 = Math.sin(this.phase2) * level2
|
||||||
|
const op3 = Math.sin(this.phase3) * level3
|
||||||
|
const op4 = Math.sin(this.phase4) * level4
|
||||||
|
output = (op1 + op2 + op3 + op4) * 0.25
|
||||||
|
this.phase1 += freq1 * this.playbackRate
|
||||||
|
this.phase2 += freq2 * this.playbackRate
|
||||||
|
this.phase3 += freq3 * this.playbackRate
|
||||||
|
this.phase4 += freq4 * this.playbackRate
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
case 1: {
|
||||||
|
const op1 = Math.sin(this.phase1) * level1
|
||||||
|
const mod1 = op1 * modDepth
|
||||||
|
const op2 = Math.sin(this.phase2 + mod1) * level2
|
||||||
|
const mod2 = op2 * modDepth
|
||||||
|
const op3 = Math.sin(this.phase3 + mod2) * level3
|
||||||
|
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
|
||||||
|
this.phase2 += freq2 * this.playbackRate
|
||||||
|
this.phase3 += freq3 * this.playbackRate
|
||||||
|
this.phase4 += freq4 * this.playbackRate
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
case 2: {
|
||||||
|
const op1 = Math.sin(this.phase1) * level1
|
||||||
|
const mod1 = op1 * modDepth
|
||||||
|
const op2 = Math.sin(this.phase2 + mod1) * level2
|
||||||
|
const op3 = Math.sin(this.phase3) * level3
|
||||||
|
const mod3 = op3 * modDepth
|
||||||
|
const op4 = Math.sin(this.phase4 + mod3) * level4
|
||||||
|
output = (op2 + op4) * 0.5
|
||||||
|
this.phase1 += freq1 * this.playbackRate
|
||||||
|
this.phase2 += freq2 * this.playbackRate
|
||||||
|
this.phase3 += freq3 * this.playbackRate
|
||||||
|
this.phase4 += freq4 * this.playbackRate
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
case 3: {
|
||||||
|
const op1 = Math.sin(this.phase1) * level1
|
||||||
|
const mod1 = op1 * modDepth
|
||||||
|
const op2 = Math.sin(this.phase2 + mod1) * level2
|
||||||
|
const mod2 = op2 * modDepth
|
||||||
|
const op3 = Math.sin(this.phase3 + mod2) * level3
|
||||||
|
const op4 = Math.sin(this.phase4) * level4
|
||||||
|
output = (op3 + op4) * 0.5
|
||||||
|
this.phase1 += freq1 * this.playbackRate
|
||||||
|
this.phase2 += freq2 * this.playbackRate
|
||||||
|
this.phase3 += freq3 * this.playbackRate
|
||||||
|
this.phase4 += freq4 * this.playbackRate
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
case 4: {
|
||||||
|
const op1 = Math.sin(this.phase1) * level1
|
||||||
|
const mod1 = op1 * modDepth
|
||||||
|
const op2 = Math.sin(this.phase2) * level2
|
||||||
|
const mod2 = op2 * modDepth
|
||||||
|
const op3 = Math.sin(this.phase3 + mod1 + mod2) * level3
|
||||||
|
const mod3 = op3 * modDepth
|
||||||
|
const op4 = Math.sin(this.phase4 + mod3) * level4
|
||||||
|
output = op4
|
||||||
|
this.phase1 += freq1 * this.playbackRate
|
||||||
|
this.phase2 += freq2 * this.playbackRate
|
||||||
|
this.phase3 += freq3 * this.playbackRate
|
||||||
|
this.phase4 += freq4 * this.playbackRate
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
case 5: {
|
||||||
|
const op1 = Math.sin(this.phase1) * level1
|
||||||
|
const mod1 = op1 * modDepth
|
||||||
|
const op2 = Math.sin(this.phase2 + mod1) * level2
|
||||||
|
const mod2 = op2 * modDepth
|
||||||
|
const op3 = Math.sin(this.phase3 + mod1) * level3
|
||||||
|
const mod3 = op3 * modDepth
|
||||||
|
const op4 = Math.sin(this.phase4 + mod2 + mod3) * level4
|
||||||
|
output = op4
|
||||||
|
this.phase1 += freq1 * this.playbackRate
|
||||||
|
this.phase2 += freq2 * this.playbackRate
|
||||||
|
this.phase3 += freq3 * this.playbackRate
|
||||||
|
this.phase4 += freq4 * this.playbackRate
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
case 6: {
|
||||||
|
const op1 = Math.sin(this.phase1) * level1
|
||||||
|
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
|
||||||
|
output = (op2 + op3 + op4) * 0.333
|
||||||
|
this.phase1 += freq1 * this.playbackRate
|
||||||
|
this.phase2 += freq2 * this.playbackRate
|
||||||
|
this.phase3 += freq3 * this.playbackRate
|
||||||
|
this.phase4 += freq4 * this.playbackRate
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
case 7: {
|
||||||
|
const op1 = Math.sin(this.phase1) * level1
|
||||||
|
const mod1 = op1 * modDepth
|
||||||
|
const op2 = Math.sin(this.phase2 + mod1) * level2
|
||||||
|
const mod2 = op2 * modDepthLight
|
||||||
|
const op3 = Math.sin(this.phase3 + mod1) * level3
|
||||||
|
const mod3 = op3 * modDepthLight
|
||||||
|
const op4 = Math.sin(this.phase4 + mod2 + mod3) * level4
|
||||||
|
output = op4
|
||||||
|
this.phase1 += freq1 * this.playbackRate
|
||||||
|
this.phase2 += freq2 * this.playbackRate
|
||||||
|
this.phase3 += freq3 * this.playbackRate
|
||||||
|
this.phase4 += freq4 * this.playbackRate
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
case 8: {
|
||||||
|
const op1 = Math.sin(this.phase1) * level1
|
||||||
|
const mod1 = op1 * modDepth
|
||||||
|
const op3 = Math.sin(this.phase3 + mod1) * level3
|
||||||
|
const op2 = Math.sin(this.phase2) * level2
|
||||||
|
const mod2 = op2 * modDepth
|
||||||
|
const op4 = Math.sin(this.phase4 + mod2) * level4
|
||||||
|
output = (op3 + op4) * 0.5
|
||||||
|
this.phase1 += freq1 * this.playbackRate
|
||||||
|
this.phase2 += freq2 * this.playbackRate
|
||||||
|
this.phase3 += freq3 * this.playbackRate
|
||||||
|
this.phase4 += freq4 * this.playbackRate
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
case 9: {
|
||||||
|
const op1 = Math.sin(this.phase1) * level1
|
||||||
|
const mod1 = op1 * modDepth
|
||||||
|
const op4 = Math.sin(this.phase4 + mod1) * level4
|
||||||
|
const op2 = Math.sin(this.phase2) * level2
|
||||||
|
const mod2 = op2 * modDepth
|
||||||
|
const op3 = Math.sin(this.phase3 + mod2) * level3
|
||||||
|
output = (op3 + op4) * 0.5
|
||||||
|
this.phase1 += freq1 * this.playbackRate
|
||||||
|
this.phase2 += freq2 * this.playbackRate
|
||||||
|
this.phase3 += freq3 * this.playbackRate
|
||||||
|
this.phase4 += freq4 * this.playbackRate
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
case 10: {
|
||||||
|
const op1 = Math.sin(this.phase1) * level1
|
||||||
|
const mod1 = op1 * modDepth
|
||||||
|
const op2 = Math.sin(this.phase2 + mod1) * level2
|
||||||
|
const op3 = Math.sin(this.phase3) * level3
|
||||||
|
const mod3 = op3 * modDepth
|
||||||
|
const op4 = Math.sin(this.phase4 + mod3) * level4
|
||||||
|
output = (op2 + op4) * 0.5
|
||||||
|
this.phase1 += freq1 * this.playbackRate
|
||||||
|
this.phase2 += freq2 * this.playbackRate
|
||||||
|
this.phase3 += freq3 * this.playbackRate
|
||||||
|
this.phase4 += freq4 * this.playbackRate
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
case 11: {
|
||||||
|
const op1 = Math.sin(this.phase1) * level1
|
||||||
|
const op2 = Math.sin(this.phase2) * level2
|
||||||
|
const mod2 = op2 * modDepth
|
||||||
|
const op3 = Math.sin(this.phase3 + mod2) * level3
|
||||||
|
const mod3 = op3 * modDepth
|
||||||
|
const op4 = Math.sin(this.phase4 + mod3) * level4
|
||||||
|
output = (op1 + op4) * 0.5
|
||||||
|
this.phase1 += freq1 * this.playbackRate
|
||||||
|
this.phase2 += freq2 * this.playbackRate
|
||||||
|
this.phase3 += freq3 * this.playbackRate
|
||||||
|
this.phase4 += freq4 * this.playbackRate
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
case 12: {
|
||||||
|
const op1 = Math.sin(this.phase1) * level1
|
||||||
|
const mod1 = op1 * modDepth
|
||||||
|
const op2 = Math.sin(this.phase2 + mod1) * level2
|
||||||
|
const mod2 = op2 * modDepth
|
||||||
|
const op4 = Math.sin(this.phase4 + mod2) * level4
|
||||||
|
const op3 = Math.sin(this.phase3) * level3
|
||||||
|
output = (op3 + op4) * 0.5
|
||||||
|
this.phase1 += freq1 * this.playbackRate
|
||||||
|
this.phase2 += freq2 * this.playbackRate
|
||||||
|
this.phase3 += freq3 * this.playbackRate
|
||||||
|
this.phase4 += freq4 * this.playbackRate
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
case 13: {
|
||||||
|
const op1 = Math.sin(this.phase1) * level1
|
||||||
|
const mod1 = op1 * modDepth
|
||||||
|
const op2 = Math.sin(this.phase2 + mod1) * level2
|
||||||
|
const mod2 = op2 * modDepth
|
||||||
|
const op3 = Math.sin(this.phase3 + mod1) * level3
|
||||||
|
const mod3 = op3 * modDepth
|
||||||
|
const op4 = Math.sin(this.phase4 + mod2 + mod3) * level4
|
||||||
|
output = op4
|
||||||
|
this.phase1 += freq1 * this.playbackRate
|
||||||
|
this.phase2 += freq2 * this.playbackRate
|
||||||
|
this.phase3 += freq3 * this.playbackRate
|
||||||
|
this.phase4 += freq4 * this.playbackRate
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
case 14: {
|
||||||
|
const op1 = Math.sin(this.phase1) * level1
|
||||||
|
const mod1 = op1 * modDepth
|
||||||
|
const op3 = Math.sin(this.phase3) * level3
|
||||||
|
const mod3 = op3 * modDepth
|
||||||
|
const op4 = Math.sin(this.phase4 + mod3) * level4
|
||||||
|
const mod4 = op4 * modDepthLight
|
||||||
|
const op2 = Math.sin(this.phase2 + mod1 + mod4) * level2
|
||||||
|
output = op2
|
||||||
|
this.phase1 += freq1 * this.playbackRate
|
||||||
|
this.phase2 += freq2 * this.playbackRate
|
||||||
|
this.phase3 += freq3 * this.playbackRate
|
||||||
|
this.phase4 += freq4 * this.playbackRate
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
case 15: {
|
||||||
|
const op1 = Math.sin(this.phase1) * level1
|
||||||
|
const mod1 = op1 * modDepth
|
||||||
|
const op2 = Math.sin(this.phase2) * level2
|
||||||
|
const mod2 = op2 * modDepth
|
||||||
|
const op3 = Math.sin(this.phase3) * level3
|
||||||
|
const mod3 = op3 * modDepth
|
||||||
|
const op4 = Math.sin(this.phase4 + mod1 + mod2 + mod3) * level4
|
||||||
|
output = op4
|
||||||
|
this.phase1 += freq1 * this.playbackRate
|
||||||
|
this.phase2 += freq2 * this.playbackRate
|
||||||
|
this.phase3 += freq3 * this.playbackRate
|
||||||
|
this.phase4 += freq4 * this.playbackRate
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
default:
|
||||||
|
output = 0
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
process(inputs, outputs) {
|
||||||
|
const output = outputs[0]
|
||||||
|
|
||||||
|
if (output.length > 0) {
|
||||||
|
const outputChannel = output[0]
|
||||||
|
|
||||||
|
for (let i = 0; i < outputChannel.length; i++) {
|
||||||
|
outputChannel[i] = this.synthesize(this.algorithm)
|
||||||
|
|
||||||
|
this.sampleCount++
|
||||||
|
if (this.loopLength > 0 && this.sampleCount >= this.loopLength) {
|
||||||
|
this.phase1 = 0
|
||||||
|
this.phase2 = 0
|
||||||
|
this.phase3 = 0
|
||||||
|
this.phase4 = 0
|
||||||
|
this.sampleCount = 0
|
||||||
|
this.feedbackSample = 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
registerProcessor('fm-processor', FMProcessor)
|
||||||
@ -8,6 +8,24 @@ class FoldCrushProcessor extends AudioWorkletProcessor {
|
|||||||
this.crushAmount = 0
|
this.crushAmount = 0
|
||||||
this.bitcrushPhase = 0
|
this.bitcrushPhase = 0
|
||||||
this.lastCrushedValue = 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) => {
|
this.port.onmessage = (event) => {
|
||||||
const { type, value } = event.data
|
const { type, value } = event.data
|
||||||
@ -24,6 +42,9 @@ class FoldCrushProcessor extends AudioWorkletProcessor {
|
|||||||
case 'crushAmount':
|
case 'crushAmount':
|
||||||
this.crushAmount = value
|
this.crushAmount = value
|
||||||
break
|
break
|
||||||
|
case 'glitchAmount':
|
||||||
|
this.glitchAmount = value
|
||||||
|
break
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -32,62 +53,128 @@ class FoldCrushProcessor extends AudioWorkletProcessor {
|
|||||||
return Math.max(min, Math.min(max, x))
|
return Math.max(min, Math.min(max, x))
|
||||||
}
|
}
|
||||||
|
|
||||||
mod(x, y) {
|
dcBlocker(x) {
|
||||||
return ((x % y) + y) % y
|
this.dcBlockerY = x - this.dcBlockerX + this.dcBlockerCoeff * this.dcBlockerY
|
||||||
|
this.dcBlockerX = x
|
||||||
|
return this.dcBlockerY
|
||||||
}
|
}
|
||||||
|
|
||||||
squash(x) {
|
preEmphasis(x) {
|
||||||
return x / (1 + Math.abs(x))
|
const amount = 0.7
|
||||||
|
const output = x - amount * this.preEmphasisLast
|
||||||
|
this.preEmphasisLast = x
|
||||||
|
return output
|
||||||
}
|
}
|
||||||
|
|
||||||
soft(x, k) {
|
deEmphasis(x) {
|
||||||
return Math.tanh(x * (1 + k))
|
const amount = 0.7
|
||||||
|
const output = x + amount * this.deEmphasisLast
|
||||||
|
this.deEmphasisLast = output
|
||||||
|
return output
|
||||||
}
|
}
|
||||||
|
|
||||||
hard(x, k) {
|
tube(x, k) {
|
||||||
return this.clamp((1 + k) * x, -1, 1)
|
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) {
|
fold(x, k) {
|
||||||
let y = (1 + 0.5 * k) * x
|
const gain = 1 + k * 3
|
||||||
const window = this.mod(y + 1, 4)
|
let y = x * gain
|
||||||
return 1 - Math.abs(window - 2)
|
|
||||||
|
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) {
|
return Math.sin(y * Math.PI / 2)
|
||||||
const t = this.squash(Math.log1p(k))
|
|
||||||
const cubic = (x - (t / 3) * x * x * x) / (1 - t / 3)
|
|
||||||
return this.soft(cubic, k)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
diode(x, k) {
|
crush(x, k) {
|
||||||
const g = 1 + 2 * k
|
const gain = 1 + k * 4
|
||||||
const t = this.squash(Math.log1p(k))
|
let driven = x * gain
|
||||||
const bias = 0.07 * t
|
|
||||||
const pos = this.soft(x + bias, 2 * k)
|
const foldThreshold = 0.8
|
||||||
const neg = this.soft(-x + bias, 2 * k)
|
let folds = 0
|
||||||
const y = pos - neg
|
while (Math.abs(driven) > foldThreshold && folds < 8) {
|
||||||
const sech = 1 / Math.cosh(g * bias)
|
if (driven > foldThreshold) {
|
||||||
const sech2 = sech * sech
|
driven = 2 * foldThreshold - driven
|
||||||
const denom = Math.max(1e-8, 2 * g * sech2)
|
} else if (driven < -foldThreshold) {
|
||||||
return this.soft(y / denom, k)
|
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) {
|
switch (this.clipMode) {
|
||||||
case 'soft':
|
case 'tube':
|
||||||
return this.soft(sample, this.drive)
|
processed = this.tube(processed, this.drive)
|
||||||
case 'hard':
|
break
|
||||||
return this.hard(sample, this.drive)
|
case 'tape':
|
||||||
|
processed = this.tape(processed, this.drive)
|
||||||
|
break
|
||||||
|
case 'fuzz':
|
||||||
|
processed = this.fuzz(processed, this.drive)
|
||||||
|
break
|
||||||
case 'fold':
|
case 'fold':
|
||||||
return this.fold(sample, this.drive)
|
processed = this.fold(processed, this.drive)
|
||||||
case 'cubic':
|
break
|
||||||
return this.cubic(sample, this.drive)
|
case 'crush':
|
||||||
case 'diode':
|
processed = this.crush(processed, this.drive)
|
||||||
return this.diode(sample, this.drive)
|
break
|
||||||
default:
|
default:
|
||||||
return sample
|
processed = this.fold(processed, this.drive)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
processed = this.deEmphasis(processed)
|
||||||
|
|
||||||
|
return this.dcBlocker(processed)
|
||||||
}
|
}
|
||||||
|
|
||||||
processBitcrush(sample) {
|
processBitcrush(sample) {
|
||||||
@ -102,19 +189,71 @@ class FoldCrushProcessor extends AudioWorkletProcessor {
|
|||||||
|
|
||||||
if (this.bitcrushPhase >= 1.0) {
|
if (this.bitcrushPhase >= 1.0) {
|
||||||
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
|
return this.lastCrushedValue
|
||||||
} else {
|
} else {
|
||||||
return this.lastCrushedValue
|
return this.lastCrushedValue
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
safetyLimiter(sample) {
|
processGlitch(sample) {
|
||||||
const threshold = 0.8
|
if (this.glitchAmount === 0) {
|
||||||
if (Math.abs(sample) > threshold) {
|
return sample
|
||||||
return Math.tanh(sample * 0.9) / Math.tanh(0.9)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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
|
return sample
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -127,9 +266,9 @@ class FoldCrushProcessor extends AudioWorkletProcessor {
|
|||||||
const outputChannel = output[0]
|
const outputChannel = output[0]
|
||||||
|
|
||||||
for (let i = 0; i < inputChannel.length; i++) {
|
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.processBitcrush(processed)
|
||||||
processed = this.safetyLimiter(processed)
|
processed = this.processGlitch(processed)
|
||||||
outputChannel[i] = processed
|
outputChannel[i] = processed
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -10,9 +10,9 @@ class OutputLimiter extends AudioWorkletProcessor {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: 'makeup',
|
name: 'makeup',
|
||||||
defaultValue: 1.5,
|
defaultValue: 0.5,
|
||||||
minValue: 1.0,
|
minValue: 0.1,
|
||||||
maxValue: 3.0,
|
maxValue: 2.0,
|
||||||
automationRate: 'k-rate'
|
automationRate: 'k-rate'
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
@ -47,8 +47,8 @@ class OutputLimiter extends AudioWorkletProcessor {
|
|||||||
const outputChannel = output[channel]
|
const outputChannel = output[channel]
|
||||||
|
|
||||||
for (let i = 0; i < inputChannel.length; i++) {
|
for (let i = 0; i < inputChannel.length; i++) {
|
||||||
let sample = inputChannel[i] * makeup
|
let sample = this.softClip(inputChannel[i], threshold)
|
||||||
sample = this.softClip(sample, threshold)
|
sample = sample * makeup
|
||||||
outputChannel[i] = sample
|
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)
|
||||||
701
src/App.tsx
701
src/App.tsx
@ -1,167 +1,88 @@
|
|||||||
import { useState, useRef, useEffect } from 'react'
|
import { useState, useRef } from 'react'
|
||||||
import { useStore } from '@nanostores/react'
|
import { useStore } from '@nanostores/react'
|
||||||
import { Square, Archive, Dices, Sparkles } from 'lucide-react'
|
import { Square, Archive, Dices, Sparkles, ArrowLeftRight } from 'lucide-react'
|
||||||
import { PlaybackManager } from './services/PlaybackManager'
|
|
||||||
import { DownloadService } from './services/DownloadService'
|
import { DownloadService } from './services/DownloadService'
|
||||||
import { generateTileGrid, generateRandomFormula } from './utils/bytebeatFormulas'
|
import { generateRandomFormula } from './utils/bytebeatFormulas'
|
||||||
import { BytebeatTile } from './components/BytebeatTile'
|
import { BytebeatTile } from './components/tile/BytebeatTile'
|
||||||
import { EffectsBar } from './components/EffectsBar'
|
import { EffectsBar } from './components/controls/EffectsBar'
|
||||||
import { EngineControls } from './components/EngineControls'
|
import { EngineControls } from './components/controls/EngineControls'
|
||||||
import { FormulaEditor } from './components/FormulaEditor'
|
import { FormulaEditor } from './components/tile/FormulaEditor'
|
||||||
import { LFOPanel } from './components/LFOPanel'
|
import { LFOPanel } from './components/controls/LFOPanel'
|
||||||
import { AudioContextWarning } from './components/AudioContextWarning'
|
import { AudioContextWarning } from './components/modals/AudioContextWarning'
|
||||||
import { HelpModal } from './components/HelpModal'
|
import { HelpModal } from './components/modals/HelpModal'
|
||||||
import { getSampleRateFromIndex } from './config/effects'
|
import { engineSettings, effectSettings } from './stores/settings'
|
||||||
import { engineSettings, effectSettings, lfoSettings, type LFOConfig } from './stores/settings'
|
|
||||||
import { exitMappingMode } from './stores/mappingMode'
|
import { exitMappingMode } from './stores/mappingMode'
|
||||||
import { useKeyboardShortcuts } from './hooks/useKeyboardShortcuts'
|
import { useKeyboardShortcuts } from './hooks/useKeyboardShortcuts'
|
||||||
import { useTileParams } from './hooks/useTileParams'
|
import { useTileGrid } from './hooks/useTileGrid'
|
||||||
|
import { usePlaybackControl } from './hooks/usePlaybackControl'
|
||||||
|
import { useFocusNavigation } from './hooks/useFocusNavigation'
|
||||||
|
import { useParameterSync } from './hooks/useParameterSync'
|
||||||
|
import { useLFOMapping } from './hooks/useLFOMapping'
|
||||||
import type { TileState } from './types/tiles'
|
import type { TileState } from './types/tiles'
|
||||||
import { createTileStateFromCurrent, loadTileParams, randomizeTileParams } from './utils/tileState'
|
import { createTileStateFromCurrent } from './utils/tileState'
|
||||||
import { DEFAULT_VARIABLES, PLAYBACK_ID, TILE_GRID, DEFAULT_DOWNLOAD_OPTIONS, LOOP_DURATION } from './constants/defaults'
|
import { DEFAULT_DOWNLOAD_OPTIONS, PLAYBACK_ID } from './constants/defaults'
|
||||||
import { getTileId, getTileFromGrid, type FocusedTile } from './utils/tileHelpers'
|
import { getTileId, getTileFromGrid } from './utils/tileHelpers'
|
||||||
|
|
||||||
function App() {
|
function App() {
|
||||||
const engineValues = useStore(engineSettings)
|
const engineValues = useStore(engineSettings)
|
||||||
const effectValues = useStore(effectSettings)
|
const effectValues = useStore(effectSettings)
|
||||||
|
|
||||||
const [tiles, setTiles] = useState<TileState[][]>(() =>
|
|
||||||
generateTileGrid(TILE_GRID.SIZE, TILE_GRID.COLUMNS, engineValues.complexity)
|
|
||||||
)
|
|
||||||
const [playing, setPlaying] = useState<string | null>(null)
|
|
||||||
const [queued, setQueued] = useState<string | null>(null)
|
|
||||||
const [playbackPosition, setPlaybackPosition] = useState<number>(0)
|
|
||||||
const [downloading, setDownloading] = useState(false)
|
const [downloading, setDownloading] = useState(false)
|
||||||
const [focusedTile, setFocusedTile] = useState<FocusedTile>({ row: 0, col: 0 })
|
|
||||||
const [customTile, setCustomTile] = useState<TileState>(() => createTileStateFromCurrent('t*(8&t>>9)'))
|
const [customTile, setCustomTile] = useState<TileState>(() => createTileStateFromCurrent('t*(8&t>>9)'))
|
||||||
const [showWarning, setShowWarning] = useState(true)
|
const [showWarning, setShowWarning] = useState(true)
|
||||||
const [showHelp, setShowHelp] = useState(false)
|
const [showHelp, setShowHelp] = useState(false)
|
||||||
const playbackManagerRef = useRef<PlaybackManager | null>(null)
|
const [mobileHeaderTab, setMobileHeaderTab] = useState<'global' | 'options' | 'modulate'>('global')
|
||||||
const downloadServiceRef = useRef<DownloadService>(new DownloadService())
|
const downloadServiceRef = useRef<DownloadService>(new DownloadService())
|
||||||
const switchTimerRef = useRef<number | null>(null)
|
|
||||||
|
|
||||||
const { saveCurrentTileParams } = useTileParams({ tiles, setTiles, customTile, setCustomTile, focusedTile })
|
const { tiles, setTiles, mode, regenerateAll, regenerateTile, switchMode } = useTileGrid()
|
||||||
|
|
||||||
useEffect(() => {
|
const { playing, queued, playbackPosition, playbackManager, play, stop, queue, cancelQueue, updateMode } =
|
||||||
if (playbackManagerRef.current) {
|
usePlaybackControl({ mode })
|
||||||
playbackManagerRef.current.setPlaybackPositionCallback(setPlaybackPosition)
|
|
||||||
|
const { focusedTile, setFocus, moveFocus } = useFocusNavigation({
|
||||||
|
tiles,
|
||||||
|
onFocusChange: (tile) => {
|
||||||
|
if (tile !== 'custom') {
|
||||||
|
const tileData = getTileFromGrid(tiles, tile.row, tile.col)
|
||||||
|
if (tileData) {
|
||||||
|
saveCurrentParams()
|
||||||
|
loadParams(tileData)
|
||||||
}
|
}
|
||||||
}, [])
|
} else {
|
||||||
|
saveCurrentParams()
|
||||||
useEffect(() => {
|
loadParams(customTile)
|
||||||
effectSettings.setKey('masterVolume', engineValues.masterVolume)
|
|
||||||
}, [engineValues.masterVolume])
|
|
||||||
|
|
||||||
const clearSwitchTimer = () => {
|
|
||||||
if (switchTimerRef.current !== null) {
|
|
||||||
clearTimeout(switchTimerRef.current)
|
|
||||||
switchTimerRef.current = null
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
})
|
||||||
|
|
||||||
const startSwitchTimer = (queuedId: string) => {
|
const { saveCurrentParams, loadParams, handleEngineChange, handleEffectChange, randomizeParams, randomizeAllParams, interpolateParams } =
|
||||||
clearSwitchTimer()
|
useParameterSync({
|
||||||
|
tiles,
|
||||||
|
setTiles,
|
||||||
|
customTile,
|
||||||
|
setCustomTile,
|
||||||
|
focusedTile,
|
||||||
|
playbackManager,
|
||||||
|
playing,
|
||||||
|
playbackId: PLAYBACK_ID.CUSTOM
|
||||||
|
})
|
||||||
|
|
||||||
switchTimerRef.current = window.setTimeout(() => {
|
const { handleLFOChange, handleParameterMapClick, handleUpdateMappingDepth, handleRemoveMapping, getMappedLFOs } =
|
||||||
const [rowStr, colStr] = queuedId.split('-')
|
useLFOMapping({
|
||||||
const row = parseInt(rowStr, 10)
|
playbackManager,
|
||||||
const col = parseInt(colStr, 10)
|
saveCurrentParams
|
||||||
const tile = getTileFromGrid(tiles, row, col)
|
})
|
||||||
|
|
||||||
if (tile) {
|
|
||||||
playFormula(tile.formula, queuedId)
|
|
||||||
}
|
|
||||||
}, engineValues.loopCount * 1000)
|
|
||||||
}
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
return () => clearSwitchTimer()
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
const handleRandom = () => {
|
const handleRandom = () => {
|
||||||
clearSwitchTimer()
|
cancelQueue()
|
||||||
setTiles(generateTileGrid(TILE_GRID.SIZE, TILE_GRID.COLUMNS, engineValues.complexity))
|
regenerateAll()
|
||||||
setQueued(null)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleRandomizeAllParams = () => {
|
const handleModeToggle = () => {
|
||||||
clearSwitchTimer()
|
const newMode = mode === 'bytebeat' ? 'fm' : 'bytebeat'
|
||||||
let newRandomized: TileState | null = null
|
stop()
|
||||||
|
switchMode(newMode)
|
||||||
if (playing === PLAYBACK_ID.CUSTOM) {
|
updateMode(newMode)
|
||||||
setCustomTile(prev => {
|
|
||||||
const randomized = randomizeTileParams(prev)
|
|
||||||
newRandomized = randomized
|
|
||||||
return randomized
|
|
||||||
})
|
|
||||||
} else {
|
|
||||||
setTiles(prevTiles => {
|
|
||||||
const newTiles = prevTiles.map((row, rowIdx) =>
|
|
||||||
row.map((tile, colIdx) => {
|
|
||||||
const randomized = randomizeTileParams(tile)
|
|
||||||
if (playing && focusedTile !== 'custom') {
|
|
||||||
const tileId = getTileId(focusedTile.row, focusedTile.col)
|
|
||||||
if (playing === tileId && rowIdx === focusedTile.row && colIdx === focusedTile.col) {
|
|
||||||
newRandomized = randomized
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return randomized
|
|
||||||
})
|
|
||||||
)
|
|
||||||
return newTiles
|
|
||||||
})
|
|
||||||
|
|
||||||
setCustomTile(prev => randomizeTileParams(prev))
|
|
||||||
}
|
|
||||||
|
|
||||||
if (newRandomized && playbackManagerRef.current) {
|
|
||||||
const params = newRandomized as TileState
|
|
||||||
loadTileParams(params)
|
|
||||||
|
|
||||||
playbackManagerRef.current.setEffects(params.effectParams)
|
|
||||||
playbackManagerRef.current.setVariables(
|
|
||||||
params.engineParams.a ?? DEFAULT_VARIABLES.a,
|
|
||||||
params.engineParams.b ?? DEFAULT_VARIABLES.b,
|
|
||||||
params.engineParams.c ?? DEFAULT_VARIABLES.c,
|
|
||||||
params.engineParams.d ?? DEFAULT_VARIABLES.d
|
|
||||||
)
|
|
||||||
playbackManagerRef.current.setPitch(params.engineParams.pitch ?? 1.0)
|
|
||||||
|
|
||||||
if (params.lfoConfigs) {
|
|
||||||
playbackManagerRef.current.setLFOConfig(0, params.lfoConfigs.lfo1)
|
|
||||||
playbackManagerRef.current.setLFOConfig(1, params.lfoConfigs.lfo2)
|
|
||||||
playbackManagerRef.current.setLFOConfig(2, params.lfoConfigs.lfo3)
|
|
||||||
playbackManagerRef.current.setLFOConfig(3, params.lfoConfigs.lfo4)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
setQueued(null)
|
|
||||||
}
|
|
||||||
|
|
||||||
const playFormula = async (formula: string, id: string) => {
|
|
||||||
const sampleRate = getSampleRateFromIndex(engineValues.sampleRate)
|
|
||||||
const duration = LOOP_DURATION
|
|
||||||
|
|
||||||
if (!playbackManagerRef.current) {
|
|
||||||
playbackManagerRef.current = new PlaybackManager({ sampleRate, duration })
|
|
||||||
} else {
|
|
||||||
await playbackManagerRef.current.updateOptions({ sampleRate, duration })
|
|
||||||
}
|
|
||||||
|
|
||||||
playbackManagerRef.current.stop()
|
|
||||||
playbackManagerRef.current.setEffects(effectValues)
|
|
||||||
playbackManagerRef.current.setVariables(
|
|
||||||
engineValues.a ?? DEFAULT_VARIABLES.a,
|
|
||||||
engineValues.b ?? DEFAULT_VARIABLES.b,
|
|
||||||
engineValues.c ?? DEFAULT_VARIABLES.c,
|
|
||||||
engineValues.d ?? DEFAULT_VARIABLES.d
|
|
||||||
)
|
|
||||||
playbackManagerRef.current.setPitch(engineValues.pitch ?? 1.0)
|
|
||||||
|
|
||||||
await playbackManagerRef.current.play(formula)
|
|
||||||
setPlaying(id)
|
|
||||||
setQueued(null)
|
|
||||||
return true
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleTileClick = (_formula: string, row: number, col: number, isDoubleClick: boolean = false) => {
|
const handleTileClick = (_formula: string, row: number, col: number, isDoubleClick: boolean = false) => {
|
||||||
@ -170,26 +91,17 @@ function App() {
|
|||||||
|
|
||||||
if (!tile) return
|
if (!tile) return
|
||||||
|
|
||||||
if (focusedTile === 'custom' || (focusedTile.row !== row || focusedTile.col !== col)) {
|
setFocus({ row, col })
|
||||||
saveCurrentTileParams()
|
|
||||||
}
|
|
||||||
|
|
||||||
if (tile) {
|
|
||||||
loadTileParams(tile)
|
|
||||||
}
|
|
||||||
setFocusedTile({ row, col })
|
|
||||||
|
|
||||||
if (playing === id) {
|
|
||||||
handleStop()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if (isDoubleClick || playing === null) {
|
if (isDoubleClick || playing === null) {
|
||||||
clearSwitchTimer()
|
play(tile.formula, id, tile)
|
||||||
playFormula(tile.formula, id)
|
|
||||||
} else {
|
} else {
|
||||||
setQueued(id)
|
queue(id, () => {
|
||||||
startSwitchTimer(id)
|
const queuedTile = getTileFromGrid(tiles, row, col)
|
||||||
|
if (queuedTile) {
|
||||||
|
play(queuedTile.formula, id, queuedTile)
|
||||||
|
}
|
||||||
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -197,119 +109,6 @@ function App() {
|
|||||||
handleTileClick(formula, row, col, true)
|
handleTileClick(formula, row, col, true)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
const handleEngineChange = async (parameterId: string, value: number) => {
|
|
||||||
engineSettings.setKey(parameterId as keyof typeof engineValues, value)
|
|
||||||
saveCurrentTileParams()
|
|
||||||
|
|
||||||
if (parameterId === 'masterVolume' && playbackManagerRef.current) {
|
|
||||||
playbackManagerRef.current.setEffects({ ...effectValues, masterVolume: value })
|
|
||||||
}
|
|
||||||
|
|
||||||
if (parameterId === 'pitch' && playbackManagerRef.current && playing) {
|
|
||||||
playbackManagerRef.current.setPitch(value)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (['a', 'b', 'c', 'd'].includes(parameterId) && playbackManagerRef.current && playing) {
|
|
||||||
const updatedValues = { ...engineValues, [parameterId]: value }
|
|
||||||
playbackManagerRef.current.setVariables(
|
|
||||||
updatedValues.a ?? DEFAULT_VARIABLES.a,
|
|
||||||
updatedValues.b ?? DEFAULT_VARIABLES.b,
|
|
||||||
updatedValues.c ?? DEFAULT_VARIABLES.c,
|
|
||||||
updatedValues.d ?? DEFAULT_VARIABLES.d
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleEffectChange = (parameterId: string, value: number | boolean | string) => {
|
|
||||||
effectSettings.setKey(parameterId as keyof typeof effectValues, value as never)
|
|
||||||
saveCurrentTileParams()
|
|
||||||
|
|
||||||
if (playbackManagerRef.current) {
|
|
||||||
playbackManagerRef.current.setEffects(effectValues)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleLFOChange = (lfoIndex: number, config: LFOConfig) => {
|
|
||||||
if (playbackManagerRef.current) {
|
|
||||||
playbackManagerRef.current.setLFOConfig(lfoIndex, config)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleParameterMapClick = (paramId: string, lfoIndex: number) => {
|
|
||||||
const lfoKey = `lfo${lfoIndex + 1}` as 'lfo1' | 'lfo2' | 'lfo3' | 'lfo4'
|
|
||||||
const currentLFO = lfoSettings.get()[lfoKey]
|
|
||||||
|
|
||||||
const existingMappingIndex = currentLFO.mappings.findIndex(m => m.targetParam === paramId)
|
|
||||||
|
|
||||||
let updatedMappings
|
|
||||||
if (existingMappingIndex >= 0) {
|
|
||||||
updatedMappings = currentLFO.mappings.filter((_, i) => i !== existingMappingIndex)
|
|
||||||
} else {
|
|
||||||
updatedMappings = [...currentLFO.mappings, { targetParam: paramId, depth: 50 }]
|
|
||||||
}
|
|
||||||
|
|
||||||
const updatedLFO = { ...currentLFO, mappings: updatedMappings }
|
|
||||||
lfoSettings.setKey(lfoKey, updatedLFO)
|
|
||||||
|
|
||||||
if (playbackManagerRef.current) {
|
|
||||||
playbackManagerRef.current.setLFOConfig(lfoIndex, updatedLFO)
|
|
||||||
}
|
|
||||||
|
|
||||||
saveCurrentTileParams()
|
|
||||||
|
|
||||||
if (updatedMappings.length === 0 || existingMappingIndex >= 0) {
|
|
||||||
exitMappingMode()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleUpdateMappingDepth = (lfoIndex: number, paramId: string, depth: number) => {
|
|
||||||
const lfoKey = `lfo${lfoIndex + 1}` as 'lfo1' | 'lfo2' | 'lfo3' | 'lfo4'
|
|
||||||
const currentLFO = lfoSettings.get()[lfoKey]
|
|
||||||
|
|
||||||
const updatedMappings = currentLFO.mappings.map(m =>
|
|
||||||
m.targetParam === paramId ? { ...m, depth } : m
|
|
||||||
)
|
|
||||||
|
|
||||||
const updatedLFO = { ...currentLFO, mappings: updatedMappings }
|
|
||||||
lfoSettings.setKey(lfoKey, updatedLFO)
|
|
||||||
|
|
||||||
if (playbackManagerRef.current) {
|
|
||||||
playbackManagerRef.current.setLFOConfig(lfoIndex, updatedLFO)
|
|
||||||
}
|
|
||||||
|
|
||||||
saveCurrentTileParams()
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleRemoveMapping = (lfoIndex: number, paramId: string) => {
|
|
||||||
const lfoKey = `lfo${lfoIndex + 1}` as 'lfo1' | 'lfo2' | 'lfo3' | 'lfo4'
|
|
||||||
const currentLFO = lfoSettings.get()[lfoKey]
|
|
||||||
|
|
||||||
const updatedMappings = currentLFO.mappings.filter(m => m.targetParam !== paramId)
|
|
||||||
|
|
||||||
const updatedLFO = { ...currentLFO, mappings: updatedMappings }
|
|
||||||
lfoSettings.setKey(lfoKey, updatedLFO)
|
|
||||||
|
|
||||||
if (playbackManagerRef.current) {
|
|
||||||
playbackManagerRef.current.setLFOConfig(lfoIndex, updatedLFO)
|
|
||||||
}
|
|
||||||
|
|
||||||
saveCurrentTileParams()
|
|
||||||
}
|
|
||||||
|
|
||||||
const getMappedLFOs = (paramId: string): number[] => {
|
|
||||||
const lfos = lfoSettings.get()
|
|
||||||
const mapped: number[] = []
|
|
||||||
|
|
||||||
Object.entries(lfos).forEach(([, lfo], index) => {
|
|
||||||
if (lfo.mappings.some((m: { targetParam: string }) => m.targetParam === paramId)) {
|
|
||||||
mapped.push(index)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
return mapped
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleDownloadAll = async () => {
|
const handleDownloadAll = async () => {
|
||||||
setDownloading(true)
|
setDownloading(true)
|
||||||
const formulas = tiles.map(row => row.map(tile => tile.formula))
|
const formulas = tiles.map(row => row.map(tile => tile.formula))
|
||||||
@ -327,40 +126,15 @@ function App() {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleRegenerate = (row: number, col: number) => {
|
|
||||||
const newFormula = generateRandomFormula(engineValues.complexity)
|
|
||||||
const newTile = createTileStateFromCurrent(newFormula)
|
|
||||||
|
|
||||||
setTiles(prevTiles => {
|
|
||||||
const newTiles = [...prevTiles]
|
|
||||||
newTiles[row] = [...newTiles[row]]
|
|
||||||
newTiles[row][col] = newTile
|
|
||||||
return newTiles
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleStop = () => {
|
|
||||||
clearSwitchTimer()
|
|
||||||
playbackManagerRef.current?.stop()
|
|
||||||
setPlaying(null)
|
|
||||||
setQueued(null)
|
|
||||||
setPlaybackPosition(0)
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleCustomEvaluate = (formula: string) => {
|
const handleCustomEvaluate = (formula: string) => {
|
||||||
if (focusedTile !== 'custom') {
|
setFocus('custom')
|
||||||
saveCurrentTileParams()
|
|
||||||
loadTileParams(customTile)
|
|
||||||
}
|
|
||||||
|
|
||||||
setFocusedTile('custom')
|
|
||||||
setCustomTile({ ...customTile, formula })
|
setCustomTile({ ...customTile, formula })
|
||||||
playFormula(formula, PLAYBACK_ID.CUSTOM)
|
play(formula, PLAYBACK_ID.CUSTOM, { ...customTile, formula })
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleCustomStop = () => {
|
const handleCustomStop = () => {
|
||||||
if (playing === PLAYBACK_ID.CUSTOM) {
|
if (playing === PLAYBACK_ID.CUSTOM) {
|
||||||
handleStop()
|
stop()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -368,44 +142,9 @@ function App() {
|
|||||||
return generateRandomFormula(engineValues.complexity)
|
return generateRandomFormula(engineValues.complexity)
|
||||||
}
|
}
|
||||||
|
|
||||||
const moveFocus = (direction: 'up' | 'down' | 'left' | 'right', step: number = 1) => {
|
|
||||||
saveCurrentTileParams()
|
|
||||||
|
|
||||||
setFocusedTile(prev => {
|
|
||||||
if (prev === 'custom') return prev
|
|
||||||
|
|
||||||
let { row, col } = prev
|
|
||||||
const maxRow = tiles.length - 1
|
|
||||||
const maxCol = (tiles[row]?.length || 1) - 1
|
|
||||||
|
|
||||||
switch (direction) {
|
|
||||||
case 'up':
|
|
||||||
row = Math.max(0, row - step)
|
|
||||||
break
|
|
||||||
case 'down':
|
|
||||||
row = Math.min(maxRow, row + step)
|
|
||||||
break
|
|
||||||
case 'left':
|
|
||||||
col = Math.max(0, col - step)
|
|
||||||
break
|
|
||||||
case 'right':
|
|
||||||
col = Math.min(maxCol, col + step)
|
|
||||||
break
|
|
||||||
}
|
|
||||||
|
|
||||||
const newTile = tiles[row]?.[col]
|
|
||||||
if (newTile) {
|
|
||||||
loadTileParams(newTile)
|
|
||||||
return { row, col }
|
|
||||||
}
|
|
||||||
|
|
||||||
return prev
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleKeyboardSpace = () => {
|
const handleKeyboardSpace = () => {
|
||||||
if (playing) {
|
if (playing) {
|
||||||
handleStop()
|
stop()
|
||||||
} else if (focusedTile !== 'custom') {
|
} else if (focusedTile !== 'custom') {
|
||||||
const tile = tiles[focusedTile.row]?.[focusedTile.col]
|
const tile = tiles[focusedTile.row]?.[focusedTile.col]
|
||||||
if (tile) {
|
if (tile) {
|
||||||
@ -432,81 +171,26 @@ 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 = () => {
|
const handleKeyboardR = () => {
|
||||||
if (focusedTile !== 'custom') {
|
if (focusedTile !== 'custom') {
|
||||||
handleRegenerate(focusedTile.row, focusedTile.col)
|
handleRegenerate(focusedTile.row, focusedTile.col)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleKeyboardShiftR = () => {
|
|
||||||
handleRandom()
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleEscape = () => {
|
|
||||||
exitMappingMode()
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleKeyboardC = () => {
|
const handleKeyboardC = () => {
|
||||||
if (focusedTile === 'custom') {
|
const tileId = focusedTile === 'custom'
|
||||||
setCustomTile(prev => {
|
? PLAYBACK_ID.CUSTOM
|
||||||
const randomized = randomizeTileParams(prev)
|
: getTileId(focusedTile.row, focusedTile.col)
|
||||||
loadTileParams(randomized)
|
randomizeParams(tileId)
|
||||||
|
|
||||||
if (playing === PLAYBACK_ID.CUSTOM && playbackManagerRef.current) {
|
|
||||||
playbackManagerRef.current.setEffects(randomized.effectParams)
|
|
||||||
playbackManagerRef.current.setVariables(
|
|
||||||
randomized.engineParams.a ?? DEFAULT_VARIABLES.a,
|
|
||||||
randomized.engineParams.b ?? DEFAULT_VARIABLES.b,
|
|
||||||
randomized.engineParams.c ?? DEFAULT_VARIABLES.c,
|
|
||||||
randomized.engineParams.d ?? DEFAULT_VARIABLES.d
|
|
||||||
)
|
|
||||||
playbackManagerRef.current.setPitch(randomized.engineParams.pitch ?? 1.0)
|
|
||||||
|
|
||||||
if (randomized.lfoConfigs) {
|
|
||||||
playbackManagerRef.current.setLFOConfig(0, randomized.lfoConfigs.lfo1)
|
|
||||||
playbackManagerRef.current.setLFOConfig(1, randomized.lfoConfigs.lfo2)
|
|
||||||
playbackManagerRef.current.setLFOConfig(2, randomized.lfoConfigs.lfo3)
|
|
||||||
playbackManagerRef.current.setLFOConfig(3, randomized.lfoConfigs.lfo4)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return randomized
|
|
||||||
})
|
|
||||||
} else {
|
|
||||||
const tileId = getTileId(focusedTile.row, focusedTile.col)
|
|
||||||
setTiles(prevTiles => {
|
|
||||||
const newTiles = [...prevTiles]
|
|
||||||
newTiles[focusedTile.row] = [...newTiles[focusedTile.row]]
|
|
||||||
const randomized = randomizeTileParams(newTiles[focusedTile.row][focusedTile.col])
|
|
||||||
newTiles[focusedTile.row][focusedTile.col] = randomized
|
|
||||||
|
|
||||||
loadTileParams(randomized)
|
|
||||||
|
|
||||||
if (playing === tileId && playbackManagerRef.current) {
|
|
||||||
playbackManagerRef.current.setEffects(randomized.effectParams)
|
|
||||||
playbackManagerRef.current.setVariables(
|
|
||||||
randomized.engineParams.a ?? DEFAULT_VARIABLES.a,
|
|
||||||
randomized.engineParams.b ?? DEFAULT_VARIABLES.b,
|
|
||||||
randomized.engineParams.c ?? DEFAULT_VARIABLES.c,
|
|
||||||
randomized.engineParams.d ?? DEFAULT_VARIABLES.d
|
|
||||||
)
|
|
||||||
playbackManagerRef.current.setPitch(randomized.engineParams.pitch ?? 1.0)
|
|
||||||
|
|
||||||
if (randomized.lfoConfigs) {
|
|
||||||
playbackManagerRef.current.setLFOConfig(0, randomized.lfoConfigs.lfo1)
|
|
||||||
playbackManagerRef.current.setLFOConfig(1, randomized.lfoConfigs.lfo2)
|
|
||||||
playbackManagerRef.current.setLFOConfig(2, randomized.lfoConfigs.lfo3)
|
|
||||||
playbackManagerRef.current.setLFOConfig(3, randomized.lfoConfigs.lfo4)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return newTiles
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleKeyboardShiftC = () => {
|
|
||||||
handleRandomizeAllParams()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleDismissWarning = () => {
|
const handleDismissWarning = () => {
|
||||||
@ -522,69 +206,216 @@ function App() {
|
|||||||
onEnter: handleKeyboardEnter,
|
onEnter: handleKeyboardEnter,
|
||||||
onDoubleEnter: handleKeyboardDoubleEnter,
|
onDoubleEnter: handleKeyboardDoubleEnter,
|
||||||
onR: handleKeyboardR,
|
onR: handleKeyboardR,
|
||||||
onShiftR: handleKeyboardShiftR,
|
onShiftR: handleRandom,
|
||||||
onC: handleKeyboardC,
|
onC: handleKeyboardC,
|
||||||
onShiftC: handleKeyboardShiftC,
|
onShiftC: randomizeAllParams,
|
||||||
onEscape: handleEscape
|
onI: interpolateParams,
|
||||||
|
onEscape: exitMappingMode
|
||||||
})
|
})
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (focusedTile !== 'custom') {
|
|
||||||
const element = document.querySelector(`[data-tile-id="${focusedTile.row}-${focusedTile.col}"]`)
|
|
||||||
if (element) {
|
|
||||||
element.scrollIntoView({ behavior: 'smooth', block: 'nearest' })
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}, [focusedTile])
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="w-screen h-screen flex flex-col bg-black overflow-hidden">
|
<div className="w-screen h-screen flex flex-col bg-black overflow-hidden">
|
||||||
{showWarning && <AudioContextWarning onDismiss={handleDismissWarning} />}
|
{showWarning && <AudioContextWarning onDismiss={handleDismissWarning} />}
|
||||||
{showHelp && <HelpModal onClose={() => setShowHelp(false)} />}
|
{showHelp && <HelpModal onClose={() => setShowHelp(false)} />}
|
||||||
<header className="bg-black border-b-2 border-white px-6 py-3">
|
<header className="bg-black border-b-2 border-white px-2 lg:px-6 py-2 lg:py-3">
|
||||||
<div className="flex items-center justify-between gap-6">
|
{/* Mobile header */}
|
||||||
|
<div className="lg:hidden">
|
||||||
|
<div className="flex items-center justify-between mb-2">
|
||||||
<h1
|
<h1
|
||||||
onClick={() => setShowHelp(true)}
|
onClick={() => setShowHelp(true)}
|
||||||
className="font-mono text-sm tracking-[0.3em] text-white flex-shrink-0 cursor-pointer hover:opacity-70 transition-opacity"
|
className="font-mono text-[10px] tracking-[0.3em] text-white cursor-pointer hover:opacity-70 transition-opacity"
|
||||||
|
>
|
||||||
|
BRUIT
|
||||||
|
</h1>
|
||||||
|
<div className="flex gap-1">
|
||||||
|
<div className="flex border-2 border-white">
|
||||||
|
<button
|
||||||
|
onClick={handleModeToggle}
|
||||||
|
className={`px-2 py-1 font-mono text-[7px] tracking-[0.15em] transition-colors ${
|
||||||
|
mode === 'bytebeat'
|
||||||
|
? 'bg-white text-black'
|
||||||
|
: 'bg-black text-white hover:bg-white/10'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
BYTE
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={handleModeToggle}
|
||||||
|
className={`px-2 py-1 font-mono text-[7px] tracking-[0.15em] transition-colors ${
|
||||||
|
mode === 'fm'
|
||||||
|
? 'bg-white text-black'
|
||||||
|
: 'bg-black text-white hover:bg-white/10'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
4OP-FM
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="flex border-2 border-white">
|
||||||
|
<button
|
||||||
|
onClick={() => setMobileHeaderTab('global')}
|
||||||
|
className={`px-2 py-1 font-mono text-[8px] tracking-[0.15em] transition-colors ${
|
||||||
|
mobileHeaderTab === 'global'
|
||||||
|
? 'bg-white text-black'
|
||||||
|
: 'bg-black text-white hover:bg-white/10'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
GLOBAL
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setMobileHeaderTab('options')}
|
||||||
|
className={`px-2 py-1 font-mono text-[8px] tracking-[0.15em] transition-colors ${
|
||||||
|
mobileHeaderTab === 'options'
|
||||||
|
? 'bg-white text-black'
|
||||||
|
: 'bg-black text-white hover:bg-white/10'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
OPTIONS
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setMobileHeaderTab('modulate')}
|
||||||
|
className={`px-2 py-1 font-mono text-[8px] tracking-[0.15em] transition-colors ${
|
||||||
|
mobileHeaderTab === 'modulate'
|
||||||
|
? 'bg-white text-black'
|
||||||
|
: 'bg-black text-white hover:bg-white/10'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
MOD
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{mobileHeaderTab === 'global' && (
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<button
|
||||||
|
onClick={stop}
|
||||||
|
disabled={!playing}
|
||||||
|
className="flex-1 px-2 py-2 bg-black text-white border-2 border-white font-mono text-[9px] tracking-[0.2em] hover:bg-white hover:text-black transition-all disabled:opacity-30 disabled:cursor-not-allowed"
|
||||||
|
>
|
||||||
|
<Square size={12} strokeWidth={2} fill="currentColor" className="mx-auto" />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={handleRandom}
|
||||||
|
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"
|
||||||
|
>
|
||||||
|
<Dices size={12} strokeWidth={2} className="mx-auto" />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={randomizeAllParams}
|
||||||
|
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"
|
||||||
|
>
|
||||||
|
<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"
|
||||||
|
>
|
||||||
|
<ArrowLeftRight size={12} strokeWidth={2} className="mx-auto" />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={handleDownloadAll}
|
||||||
|
disabled={downloading}
|
||||||
|
className="flex-1 px-2 py-2 bg-black text-white border-2 border-white font-mono text-[9px] tracking-[0.2em] hover:bg-white hover:text-black transition-all disabled:opacity-30 disabled:cursor-not-allowed"
|
||||||
|
>
|
||||||
|
<Archive size={12} strokeWidth={2} className="mx-auto" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{mobileHeaderTab === 'options' && (
|
||||||
|
<EngineControls
|
||||||
|
values={engineValues}
|
||||||
|
onChange={handleEngineChange}
|
||||||
|
onMapClick={handleParameterMapClick}
|
||||||
|
getMappedLFOs={getMappedLFOs}
|
||||||
|
showOnlySliders
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{mobileHeaderTab === 'modulate' && (
|
||||||
|
<EngineControls
|
||||||
|
values={engineValues}
|
||||||
|
onChange={handleEngineChange}
|
||||||
|
onMapClick={handleParameterMapClick}
|
||||||
|
getMappedLFOs={getMappedLFOs}
|
||||||
|
showOnlyKnobs
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Desktop header */}
|
||||||
|
<div className="hidden lg:flex items-center gap-4">
|
||||||
|
<div className="flex flex-col gap-1 flex-shrink-0">
|
||||||
|
<h1
|
||||||
|
onClick={() => setShowHelp(true)}
|
||||||
|
className="font-mono text-sm tracking-[0.3em] text-white cursor-pointer hover:opacity-70 transition-opacity"
|
||||||
>
|
>
|
||||||
BRUITISTE
|
BRUITISTE
|
||||||
</h1>
|
</h1>
|
||||||
|
<div className="flex border-2 border-white w-fit">
|
||||||
|
<button
|
||||||
|
onClick={handleModeToggle}
|
||||||
|
className={`px-2 py-0.5 font-mono text-[7px] tracking-[0.15em] transition-colors whitespace-nowrap ${
|
||||||
|
mode === 'bytebeat'
|
||||||
|
? 'bg-white text-black'
|
||||||
|
: 'bg-black text-white hover:bg-white/10'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
1-BIT
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={handleModeToggle}
|
||||||
|
className={`px-2 py-0.5 font-mono text-[7px] tracking-[0.15em] transition-colors whitespace-nowrap ${
|
||||||
|
mode === 'fm'
|
||||||
|
? 'bg-white text-black'
|
||||||
|
: 'bg-black text-white hover:bg-white/10'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
4OP-FM
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex-1">
|
||||||
<EngineControls
|
<EngineControls
|
||||||
values={engineValues}
|
values={engineValues}
|
||||||
onChange={handleEngineChange}
|
onChange={handleEngineChange}
|
||||||
onMapClick={handleParameterMapClick}
|
onMapClick={handleParameterMapClick}
|
||||||
getMappedLFOs={getMappedLFOs}
|
getMappedLFOs={getMappedLFOs}
|
||||||
/>
|
/>
|
||||||
<div className="flex gap-4 flex-shrink-0">
|
</div>
|
||||||
|
<div className="flex gap-3 flex-shrink-0">
|
||||||
<button
|
<button
|
||||||
onClick={handleStop}
|
onClick={stop}
|
||||||
disabled={!playing}
|
disabled={!playing}
|
||||||
className="px-6 py-2 bg-black text-white border-2 border-white font-mono text-[11px] tracking-[0.2em] hover:bg-white hover:text-black transition-all disabled:opacity-30 disabled:cursor-not-allowed flex items-center gap-2"
|
className="px-4 py-2 bg-black text-white border-2 border-white font-mono text-[10px] tracking-[0.2em] hover:bg-white hover:text-black transition-all disabled:opacity-30 disabled:cursor-not-allowed"
|
||||||
>
|
>
|
||||||
<Square size={14} strokeWidth={2} fill="currentColor" />
|
<Square size={12} strokeWidth={2} fill="currentColor" />
|
||||||
STOP
|
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={handleRandom}
|
onClick={handleRandom}
|
||||||
className="px-6 py-2 bg-white text-black font-mono text-[11px] tracking-[0.2em] hover:bg-black hover:text-white border-2 border-white transition-all flex items-center gap-2"
|
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"
|
||||||
>
|
>
|
||||||
<Dices size={14} strokeWidth={2} />
|
<Dices size={12} strokeWidth={2} />
|
||||||
RANDOM
|
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={handleRandomizeAllParams}
|
onClick={randomizeAllParams}
|
||||||
className="px-6 py-2 bg-white text-black font-mono text-[11px] tracking-[0.2em] hover:bg-black hover:text-white border-2 border-white transition-all flex items-center gap-2"
|
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"
|
||||||
>
|
>
|
||||||
<Sparkles size={14} strokeWidth={2} />
|
<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"
|
||||||
|
>
|
||||||
|
<ArrowLeftRight size={12} strokeWidth={2} />
|
||||||
|
MORPH
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={handleDownloadAll}
|
onClick={handleDownloadAll}
|
||||||
disabled={downloading}
|
disabled={downloading}
|
||||||
className="px-6 py-2 bg-black text-white border-2 border-white font-mono text-[11px] tracking-[0.2em] hover:bg-white hover:text-black transition-all disabled:opacity-30 disabled:cursor-not-allowed flex items-center gap-2"
|
className="px-4 py-2 bg-black text-white border-2 border-white font-mono text-[10px] tracking-[0.2em] hover:bg-white hover:text-black transition-all disabled:opacity-30 disabled:cursor-not-allowed"
|
||||||
>
|
>
|
||||||
<Archive size={14} strokeWidth={2} />
|
<Archive size={12} strokeWidth={2} />
|
||||||
{downloading ? 'DOWNLOADING...' : 'PACK'}
|
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -593,8 +424,9 @@ function App() {
|
|||||||
<LFOPanel onChange={handleLFOChange} onUpdateDepth={handleUpdateMappingDepth} onRemoveMapping={handleRemoveMapping} />
|
<LFOPanel onChange={handleLFOChange} onUpdateDepth={handleUpdateMappingDepth} onRemoveMapping={handleRemoveMapping} />
|
||||||
|
|
||||||
<div className="flex-1 flex flex-col overflow-auto bg-white">
|
<div className="flex-1 flex flex-col overflow-auto bg-white">
|
||||||
<div className="grid grid-cols-4 gap-[1px] bg-white p-[1px]">
|
{mode === 'bytebeat' && (
|
||||||
<div className="col-span-4">
|
<div className="grid grid-cols-1 lg:grid-cols-4 gap-[1px] bg-white p-[1px]">
|
||||||
|
<div className="col-span-1 lg:col-span-4">
|
||||||
<FormulaEditor
|
<FormulaEditor
|
||||||
formula={customTile.formula}
|
formula={customTile.formula}
|
||||||
isPlaying={playing === PLAYBACK_ID.CUSTOM}
|
isPlaying={playing === PLAYBACK_ID.CUSTOM}
|
||||||
@ -606,8 +438,9 @@ function App() {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<div className="flex-1 grid grid-cols-4 auto-rows-min gap-[1px] bg-white p-[1px]">
|
<div className="flex-1 grid grid-cols-1 lg:grid-cols-4 auto-rows-min gap-[1px] bg-white p-[1px]">
|
||||||
{tiles.map((row, i) =>
|
{tiles.map((row, i) =>
|
||||||
row.map((tile, j) => {
|
row.map((tile, j) => {
|
||||||
const id = getTileId(i, j)
|
const id = getTileId(i, j)
|
||||||
@ -636,6 +469,7 @@ function App() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div className="flex-shrink-0">
|
||||||
<EffectsBar
|
<EffectsBar
|
||||||
values={effectValues}
|
values={effectValues}
|
||||||
onChange={handleEffectChange}
|
onChange={handleEffectChange}
|
||||||
@ -643,6 +477,7 @@ function App() {
|
|||||||
getMappedLFOs={getMappedLFOs}
|
getMappedLFOs={getMappedLFOs}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -1,116 +0,0 @@
|
|||||||
import { Dices } from 'lucide-react'
|
|
||||||
import { Slider } from './Slider'
|
|
||||||
import { Switch } from './Switch'
|
|
||||||
import { Dropdown } from './Dropdown'
|
|
||||||
import { EFFECTS } from '../config/effects'
|
|
||||||
import type { EffectValues } from '../types/effects'
|
|
||||||
|
|
||||||
interface EffectsBarProps {
|
|
||||||
values: EffectValues
|
|
||||||
onChange: (parameterId: string, value: number | boolean | string) => void
|
|
||||||
onMapClick?: (paramId: string, lfoIndex: number) => void
|
|
||||||
getMappedLFOs?: (paramId: string) => number[]
|
|
||||||
}
|
|
||||||
|
|
||||||
export function EffectsBar({ values, onChange, onMapClick, getMappedLFOs }: EffectsBarProps) {
|
|
||||||
const randomizeEffect = (effect: typeof EFFECTS[number]) => {
|
|
||||||
effect.parameters.forEach(param => {
|
|
||||||
if (param.id.endsWith('Enable')) return
|
|
||||||
|
|
||||||
if (param.options) {
|
|
||||||
const randomOption = param.options[Math.floor(Math.random() * param.options.length)]
|
|
||||||
onChange(param.id, randomOption.value)
|
|
||||||
} else {
|
|
||||||
const range = param.max - param.min
|
|
||||||
const steps = Math.floor(range / param.step)
|
|
||||||
const randomStep = Math.floor(Math.random() * (steps + 1))
|
|
||||||
const randomValue = param.min + (randomStep * param.step)
|
|
||||||
onChange(param.id, randomValue)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="bg-black border-t-2 border-white px-6 py-4">
|
|
||||||
<div className="grid grid-cols-4 gap-4">
|
|
||||||
{EFFECTS.map(effect => {
|
|
||||||
return (
|
|
||||||
<div key={effect.id} className="border-2 border-white p-3">
|
|
||||||
<div className="flex items-center justify-between mb-3">
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<h3 className="font-mono text-[10px] tracking-[0.2em] text-white">
|
|
||||||
{effect.name.toUpperCase()}
|
|
||||||
</h3>
|
|
||||||
<button
|
|
||||||
onClick={() => randomizeEffect(effect)}
|
|
||||||
className="p-1 text-white hover:bg-white hover:text-black transition-colors"
|
|
||||||
>
|
|
||||||
<Dices size={12} strokeWidth={2} />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
{effect.bypassable && (
|
|
||||||
<Switch
|
|
||||||
checked={!values[`${effect.id}Bypass`]}
|
|
||||||
onChange={(checked) => onChange(`${effect.id}Bypass`, !checked)}
|
|
||||||
label={values[`${effect.id}Bypass`] ? 'OFF' : 'ON'}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<div className="flex flex-col gap-3">
|
|
||||||
{effect.parameters.map(param => {
|
|
||||||
if (param.options) {
|
|
||||||
return (
|
|
||||||
<Dropdown
|
|
||||||
key={param.id}
|
|
||||||
label={param.label}
|
|
||||||
value={values[param.id] as string ?? param.default as string}
|
|
||||||
options={param.options}
|
|
||||||
onChange={(value) => onChange(param.id, value)}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
const isSwitch = param.min === 0 && param.max === 1 && param.step === 1
|
|
||||||
|
|
||||||
if (isSwitch) {
|
|
||||||
return (
|
|
||||||
<div key={param.id} className="flex flex-col gap-1 mt-2">
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<span className="font-mono text-[9px] tracking-[0.15em] text-white">
|
|
||||||
{param.label.toUpperCase()}
|
|
||||||
</span>
|
|
||||||
<Switch
|
|
||||||
checked={Boolean(values[param.id])}
|
|
||||||
onChange={(checked) => onChange(param.id, checked ? 1 : 0)}
|
|
||||||
label={values[param.id] ? 'ON' : 'OFF'}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Slider
|
|
||||||
key={param.id}
|
|
||||||
label={param.label}
|
|
||||||
value={values[param.id] as number ?? param.default as number}
|
|
||||||
min={param.min}
|
|
||||||
max={param.max}
|
|
||||||
step={param.step}
|
|
||||||
unit={param.unit}
|
|
||||||
onChange={(value) => onChange(param.id, value)}
|
|
||||||
valueId={param.id}
|
|
||||||
paramId={param.id}
|
|
||||||
onMapClick={onMapClick}
|
|
||||||
mappedLFOs={getMappedLFOs ? getMappedLFOs(param.id) : []}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@ -1,105 +0,0 @@
|
|||||||
interface HelpModalProps {
|
|
||||||
onClose: () => void
|
|
||||||
showStartButton?: boolean
|
|
||||||
}
|
|
||||||
|
|
||||||
export function HelpModal({ onClose, showStartButton = false }: HelpModalProps) {
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
className="fixed inset-0 bg-black/70 flex items-center justify-center z-50"
|
|
||||||
onClick={onClose}
|
|
||||||
onKeyDown={(e) => {
|
|
||||||
if (e.key === 'Escape' || e.key === 'Enter' || e.key === ' ') {
|
|
||||||
onClose()
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
tabIndex={0}
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
className="border-4 border-white bg-black p-12 max-w-4xl w-full mx-8"
|
|
||||||
onClick={(e) => e.stopPropagation()}
|
|
||||||
>
|
|
||||||
<h1 className="font-mono text-2xl tracking-[0.3em] text-white mb-6 text-center">
|
|
||||||
BRUITISTE
|
|
||||||
</h1>
|
|
||||||
<p className="font-mono text-sm text-white mb-2 leading-relaxed text-center">
|
|
||||||
Harsh noise soundbox made as a love statement to all weird noises, hums, audio glitches and ominous textures. Be careful, lower your volume! Tweak some parameters!
|
|
||||||
</p>
|
|
||||||
<p className="font-mono text-xs text-white mb-6 opacity-70 text-center">
|
|
||||||
Made by Raphaël Forment (BuboBubo) — <a href="https://raphaelforment.fr" target="_blank" rel="noopener noreferrer" className="underline hover:opacity-100">raphaelforment.fr</a>
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<div className="font-mono text-sm text-white mb-8">
|
|
||||||
<h2 className="text-lg tracking-[0.2em] mb-4">KEYBOARD SHORTCUTS</h2>
|
|
||||||
|
|
||||||
<table className="w-full border-2 border-white">
|
|
||||||
<thead>
|
|
||||||
<tr className="border-b-2 border-white">
|
|
||||||
<th className="text-left p-3 bg-white text-black tracking-[0.1em]">KEY</th>
|
|
||||||
<th className="text-left p-3 bg-white text-black tracking-[0.1em]">ACTION</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
<tr className="border-b border-white">
|
|
||||||
<td className="p-3 border-r border-white">SPACE</td>
|
|
||||||
<td className="p-3">Play/Stop current tile</td>
|
|
||||||
</tr>
|
|
||||||
<tr className="border-b border-white">
|
|
||||||
<td className="p-3 border-r border-white">ARROWS</td>
|
|
||||||
<td className="p-3">Navigate tiles</td>
|
|
||||||
</tr>
|
|
||||||
<tr className="border-b border-white">
|
|
||||||
<td className="p-3 border-r border-white">SHIFT + ARROWS</td>
|
|
||||||
<td className="p-3">Jump 10 tiles</td>
|
|
||||||
</tr>
|
|
||||||
<tr className="border-b border-white">
|
|
||||||
<td className="p-3 border-r border-white">ENTER</td>
|
|
||||||
<td className="p-3">Queue tile (play after current)</td>
|
|
||||||
</tr>
|
|
||||||
<tr className="border-b border-white">
|
|
||||||
<td className="p-3 border-r border-white">DOUBLE ENTER</td>
|
|
||||||
<td className="p-3">Play immediately</td>
|
|
||||||
</tr>
|
|
||||||
<tr className="border-b border-white">
|
|
||||||
<td className="p-3 border-r border-white">R</td>
|
|
||||||
<td className="p-3">Regenerate current tile</td>
|
|
||||||
</tr>
|
|
||||||
<tr className="border-b border-white">
|
|
||||||
<td className="p-3 border-r border-white">SHIFT + R</td>
|
|
||||||
<td className="p-3">Randomize all tiles</td>
|
|
||||||
</tr>
|
|
||||||
<tr className="border-b border-white">
|
|
||||||
<td className="p-3 border-r border-white">C</td>
|
|
||||||
<td className="p-3">Randomize current tile params</td>
|
|
||||||
</tr>
|
|
||||||
<tr className="border-b border-white">
|
|
||||||
<td className="p-3 border-r border-white">SHIFT + C</td>
|
|
||||||
<td className="p-3">Randomize all params (CHAOS)</td>
|
|
||||||
</tr>
|
|
||||||
<tr>
|
|
||||||
<td className="p-3 border-r border-white">ESC</td>
|
|
||||||
<td className="p-3">Exit mapping mode</td>
|
|
||||||
</tr>
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{showStartButton ? (
|
|
||||||
<button
|
|
||||||
onClick={onClose}
|
|
||||||
className="w-full px-8 py-4 bg-white text-black font-mono text-sm tracking-[0.2em] hover:bg-black hover:text-white border-2 border-white transition-all"
|
|
||||||
>
|
|
||||||
START
|
|
||||||
</button>
|
|
||||||
) : (
|
|
||||||
<button
|
|
||||||
onClick={onClose}
|
|
||||||
className="w-full px-8 py-4 bg-white text-black font-mono text-sm tracking-[0.2em] hover:bg-black hover:text-white border-2 border-white transition-all"
|
|
||||||
>
|
|
||||||
CLOSE
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
221
src/components/controls/EffectsBar.tsx
Normal file
221
src/components/controls/EffectsBar.tsx
Normal file
@ -0,0 +1,221 @@
|
|||||||
|
import { useState } from 'react'
|
||||||
|
import { Dices } from 'lucide-react'
|
||||||
|
import { Slider } from '../ui/Slider'
|
||||||
|
import { Switch } from '../ui/Switch'
|
||||||
|
import { Dropdown } from '../ui/Dropdown'
|
||||||
|
import { EFFECTS } from '../../config/parameters'
|
||||||
|
import type { EffectValues } from '../../types/effects'
|
||||||
|
|
||||||
|
interface EffectsBarProps {
|
||||||
|
values: EffectValues
|
||||||
|
onChange: (parameterId: string, value: number | boolean | string) => void
|
||||||
|
onMapClick?: (paramId: string, lfoIndex: number) => void
|
||||||
|
getMappedLFOs?: (paramId: string) => number[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export function EffectsBar({ values, onChange, onMapClick, getMappedLFOs }: EffectsBarProps) {
|
||||||
|
const [activeTab, setActiveTab] = useState<string>(EFFECTS[0].id)
|
||||||
|
const [isCollapsed, setIsCollapsed] = useState(true)
|
||||||
|
const randomizeEffect = (effect: typeof EFFECTS[number]) => {
|
||||||
|
effect.parameters.forEach(param => {
|
||||||
|
if (param.id.endsWith('Enable')) return
|
||||||
|
|
||||||
|
if (param.options) {
|
||||||
|
const randomOption = param.options[Math.floor(Math.random() * param.options.length)]
|
||||||
|
onChange(param.id, randomOption.value)
|
||||||
|
} else {
|
||||||
|
const range = param.max - param.min
|
||||||
|
const steps = Math.floor(range / param.step)
|
||||||
|
const randomStep = Math.floor(Math.random() * (steps + 1))
|
||||||
|
const randomValue = param.min + (randomStep * param.step)
|
||||||
|
onChange(param.id, randomValue)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
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-6 lg:gap-3">
|
||||||
|
{EFFECTS.map(effect => {
|
||||||
|
return (
|
||||||
|
<div key={effect.id} className="border-2 border-white p-3">
|
||||||
|
<div className="flex items-center justify-between mb-3">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<h3 className="font-mono text-[10px] tracking-[0.2em] text-white">
|
||||||
|
{effect.name.toUpperCase()}
|
||||||
|
</h3>
|
||||||
|
<button
|
||||||
|
onClick={() => randomizeEffect(effect)}
|
||||||
|
className="p-1 text-white hover:bg-white hover:text-black transition-colors"
|
||||||
|
>
|
||||||
|
<Dices size={12} strokeWidth={2} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{effect.bypassable && (
|
||||||
|
<Switch
|
||||||
|
checked={!values[`${effect.id}Bypass`]}
|
||||||
|
onChange={(checked) => onChange(`${effect.id}Bypass`, !checked)}
|
||||||
|
label={values[`${effect.id}Bypass`] ? 'OFF' : 'ON'}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col gap-3">
|
||||||
|
{effect.parameters.map(param => {
|
||||||
|
if (param.options) {
|
||||||
|
return (
|
||||||
|
<Dropdown
|
||||||
|
key={param.id}
|
||||||
|
label={param.label}
|
||||||
|
value={values[param.id] as string ?? param.default as string}
|
||||||
|
options={param.options}
|
||||||
|
onChange={(value) => onChange(param.id, value)}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const isSwitch = param.min === 0 && param.max === 1 && param.step === 1
|
||||||
|
|
||||||
|
if (isSwitch) {
|
||||||
|
return (
|
||||||
|
<div key={param.id} className="flex flex-col gap-1 mt-2">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<span className="font-mono text-[9px] tracking-[0.15em] text-white">
|
||||||
|
{param.label.toUpperCase()}
|
||||||
|
</span>
|
||||||
|
<Switch
|
||||||
|
checked={Boolean(values[param.id])}
|
||||||
|
onChange={(checked) => onChange(param.id, checked ? 1 : 0)}
|
||||||
|
label={values[param.id] ? 'ON' : 'OFF'}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Slider
|
||||||
|
key={param.id}
|
||||||
|
label={param.label}
|
||||||
|
value={values[param.id] as number ?? param.default as number}
|
||||||
|
min={param.min}
|
||||||
|
max={param.max}
|
||||||
|
step={param.step}
|
||||||
|
unit={param.unit}
|
||||||
|
onChange={(value) => onChange(param.id, value)}
|
||||||
|
valueId={param.id}
|
||||||
|
paramId={param.id}
|
||||||
|
onMapClick={onMapClick}
|
||||||
|
mappedLFOs={getMappedLFOs ? getMappedLFOs(param.id) : []}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Mobile: Tabbed layout */}
|
||||||
|
<div className="lg:hidden flex flex-col">
|
||||||
|
<div className="flex border-2 border-white">
|
||||||
|
{EFFECTS.map(effect => (
|
||||||
|
<button
|
||||||
|
key={effect.id}
|
||||||
|
onClick={() => {
|
||||||
|
if (activeTab === effect.id && !isCollapsed) {
|
||||||
|
setIsCollapsed(true)
|
||||||
|
} else {
|
||||||
|
setActiveTab(effect.id)
|
||||||
|
setIsCollapsed(false)
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
className={`flex-1 p-2 font-mono text-[9px] tracking-[0.15em] transition-colors ${
|
||||||
|
activeTab === effect.id && !isCollapsed
|
||||||
|
? 'bg-white text-black'
|
||||||
|
: 'bg-black text-white hover:bg-white/10'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{effect.name.toUpperCase()}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
{!isCollapsed && EFFECTS.map(effect => {
|
||||||
|
if (activeTab !== effect.id) return null
|
||||||
|
return (
|
||||||
|
<div key={effect.id} className="border-2 border-t-0 border-white p-3">
|
||||||
|
<div className="flex items-center justify-between mb-3">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<button
|
||||||
|
onClick={() => randomizeEffect(effect)}
|
||||||
|
className="p-1 text-white hover:bg-white hover:text-black transition-colors"
|
||||||
|
>
|
||||||
|
<Dices size={14} strokeWidth={2} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{effect.bypassable && (
|
||||||
|
<Switch
|
||||||
|
checked={!values[`${effect.id}Bypass`]}
|
||||||
|
onChange={(checked) => onChange(`${effect.id}Bypass`, !checked)}
|
||||||
|
label={values[`${effect.id}Bypass`] ? 'OFF' : 'ON'}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col gap-3">
|
||||||
|
{effect.parameters.map(param => {
|
||||||
|
if (param.options) {
|
||||||
|
return (
|
||||||
|
<Dropdown
|
||||||
|
key={param.id}
|
||||||
|
label={param.label}
|
||||||
|
value={values[param.id] as string ?? param.default as string}
|
||||||
|
options={param.options}
|
||||||
|
onChange={(value) => onChange(param.id, value)}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const isSwitch = param.min === 0 && param.max === 1 && param.step === 1
|
||||||
|
|
||||||
|
if (isSwitch) {
|
||||||
|
return (
|
||||||
|
<div key={param.id} className="flex flex-col gap-1 mt-2">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<span className="font-mono text-[9px] tracking-[0.15em] text-white">
|
||||||
|
{param.label.toUpperCase()}
|
||||||
|
</span>
|
||||||
|
<Switch
|
||||||
|
checked={Boolean(values[param.id])}
|
||||||
|
onChange={(checked) => onChange(param.id, checked ? 1 : 0)}
|
||||||
|
label={values[param.id] ? 'ON' : 'OFF'}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Slider
|
||||||
|
key={param.id}
|
||||||
|
label={param.label}
|
||||||
|
value={values[param.id] as number ?? param.default as number}
|
||||||
|
min={param.min}
|
||||||
|
max={param.max}
|
||||||
|
step={param.step}
|
||||||
|
unit={param.unit}
|
||||||
|
onChange={(value) => onChange(param.id, value)}
|
||||||
|
valueId={param.id}
|
||||||
|
paramId={param.id}
|
||||||
|
onMapClick={onMapClick}
|
||||||
|
mappedLFOs={getMappedLFOs ? getMappedLFOs(param.id) : []}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@ -1,18 +1,24 @@
|
|||||||
import { ENGINE_CONTROLS } from '../config/effects'
|
import { useStore } from '@nanostores/react'
|
||||||
import { getComplexityLabel, getBitDepthLabel, getSampleRateLabel } from '../utils/formatters'
|
import { ENGINE_CONTROLS } from '../../config/parameters'
|
||||||
import type { EffectValues } from '../types/effects'
|
import { getComplexityLabel, getBitDepthLabel, getSampleRateLabel, getAlgorithmLabel } from '../../utils/formatters'
|
||||||
import { Knob } from './Knob'
|
import type { EffectValues } from '../../types/effects'
|
||||||
|
import { Knob } from '../ui/Knob'
|
||||||
|
import { synthesisMode } from '../../stores/synthesisMode'
|
||||||
|
|
||||||
interface EngineControlsProps {
|
interface EngineControlsProps {
|
||||||
values: EffectValues
|
values: EffectValues
|
||||||
onChange: (parameterId: string, value: number) => void
|
onChange: (parameterId: string, value: number) => void
|
||||||
onMapClick?: (paramId: string, lfoIndex: number) => void
|
onMapClick?: (paramId: string, lfoIndex: number) => void
|
||||||
getMappedLFOs?: (paramId: string) => number[]
|
getMappedLFOs?: (paramId: string) => number[]
|
||||||
|
showOnlySliders?: boolean
|
||||||
|
showOnlyKnobs?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
const KNOB_PARAMS = ['masterVolume', 'pitch', 'a', 'b', 'c', 'd']
|
const KNOB_PARAMS = ['masterVolume', 'pitch', 'a', 'b', 'c', 'd']
|
||||||
|
|
||||||
export function EngineControls({ values, onChange, onMapClick, getMappedLFOs }: EngineControlsProps) {
|
export function EngineControls({ values, onChange, onMapClick, getMappedLFOs, showOnlySliders, showOnlyKnobs }: EngineControlsProps) {
|
||||||
|
const mode = useStore(synthesisMode)
|
||||||
|
|
||||||
const formatValue = (id: string, value: number): string => {
|
const formatValue = (id: string, value: number): string => {
|
||||||
switch (id) {
|
switch (id) {
|
||||||
case 'sampleRate':
|
case 'sampleRate':
|
||||||
@ -21,18 +27,27 @@ export function EngineControls({ values, onChange, onMapClick, getMappedLFOs }:
|
|||||||
return getComplexityLabel(value)
|
return getComplexityLabel(value)
|
||||||
case 'bitDepth':
|
case 'bitDepth':
|
||||||
return getBitDepthLabel(value)
|
return getBitDepthLabel(value)
|
||||||
|
case 'fmAlgorithm':
|
||||||
|
return getAlgorithmLabel(value)
|
||||||
default: {
|
default: {
|
||||||
const param = ENGINE_CONTROLS[0].parameters.find(p => p.id === id)
|
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 || ''}`
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center gap-6">
|
<div className="flex flex-wrap items-center gap-2 md:gap-4 xl:gap-6">
|
||||||
{ENGINE_CONTROLS[0].parameters.map(param => {
|
{ENGINE_CONTROLS[0].parameters.map(param => {
|
||||||
const useKnob = KNOB_PARAMS.includes(param.id)
|
const useKnob = KNOB_PARAMS.includes(param.id)
|
||||||
|
|
||||||
|
if (mode === 'bytebeat' && (param.id === 'fmAlgorithm' || param.id === 'fmFeedback')) return null
|
||||||
|
if (mode === 'fm' && (param.id === 'complexity' || param.id === 'bitDepth')) return null
|
||||||
|
|
||||||
|
if (showOnlySliders && useKnob) return null
|
||||||
|
if (showOnlyKnobs && !useKnob) return null
|
||||||
|
|
||||||
if (useKnob) {
|
if (useKnob) {
|
||||||
return (
|
return (
|
||||||
<Knob
|
<Knob
|
||||||
@ -46,6 +61,7 @@ export function EngineControls({ values, onChange, onMapClick, getMappedLFOs }:
|
|||||||
onChange={(value) => onChange(param.id, value)}
|
onChange={(value) => onChange(param.id, value)}
|
||||||
formatValue={formatValue}
|
formatValue={formatValue}
|
||||||
valueId={param.id}
|
valueId={param.id}
|
||||||
|
size={40}
|
||||||
paramId={param.id}
|
paramId={param.id}
|
||||||
onMapClick={onMapClick}
|
onMapClick={onMapClick}
|
||||||
mappedLFOs={getMappedLFOs ? getMappedLFOs(param.id) : []}
|
mappedLFOs={getMappedLFOs ? getMappedLFOs(param.id) : []}
|
||||||
@ -54,12 +70,12 @@ export function EngineControls({ values, onChange, onMapClick, getMappedLFOs }:
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div key={param.id} className="flex flex-col gap-1 min-w-[100px]">
|
<div key={param.id} className="flex flex-col gap-1 min-w-[70px] flex-1 lg:flex-initial lg:w-[80px] lg:min-w-0">
|
||||||
<div className="flex justify-between items-baseline">
|
<div className="flex justify-between items-baseline gap-1">
|
||||||
<label className="font-mono text-[9px] tracking-[0.15em] text-white">
|
<label className="font-mono text-[7px] lg:text-[9px] tracking-[0.1em] lg:tracking-[0.15em] text-white truncate">
|
||||||
{param.label.toUpperCase()}
|
{param.label.toUpperCase()}
|
||||||
</label>
|
</label>
|
||||||
<span className="font-mono text-[9px] text-white">
|
<span className="font-mono text-[7px] lg:text-[9px] text-white whitespace-nowrap">
|
||||||
{formatValue(param.id, (values[param.id] as number) ?? param.default)}
|
{formatValue(param.id, (values[param.id] as number) ?? param.default)}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
@ -1,9 +1,9 @@
|
|||||||
import { useStore } from '@nanostores/react'
|
import { useStore } from '@nanostores/react'
|
||||||
import { lfoSettings } from '../stores/settings'
|
import { lfoSettings } from '../../stores/settings'
|
||||||
import { toggleMappingMode } from '../stores/mappingMode'
|
import { toggleMappingMode } from '../../stores/mappingMode'
|
||||||
import { LFOScope } from './LFOScope'
|
import { LFOScope } from '../scopes/LFOScope'
|
||||||
import type { LFOConfig } from '../stores/settings'
|
import type { LFOConfig } from '../../stores/settings'
|
||||||
import type { LFOWaveform } from '../domain/modulation/LFO'
|
import type { LFOWaveform } from '../../domain/modulation/LFO'
|
||||||
|
|
||||||
interface LFOPanelProps {
|
interface LFOPanelProps {
|
||||||
onChange: (lfoIndex: number, config: LFOConfig) => void
|
onChange: (lfoIndex: number, config: LFOConfig) => void
|
||||||
@ -36,11 +36,11 @@ export function LFOPanel({ onChange, onUpdateDepth, onRemoveMapping }: LFOPanelP
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="bg-black border-t-2 border-white">
|
<div className="bg-black border-t-2 border-white">
|
||||||
<div className="grid grid-cols-4 divide-x-2 divide-white">
|
<div className="grid grid-cols-2 sm:grid-cols-4 gap-[1px] bg-white p-[1px]">
|
||||||
{lfoConfigs.map(({ key, index }) => {
|
{lfoConfigs.map(({ key, index }) => {
|
||||||
const lfo = lfoValues[key]
|
const lfo = lfoValues[key]
|
||||||
return (
|
return (
|
||||||
<div key={key} className="px-2 py-3 flex items-center">
|
<div key={key} className="px-2 py-2 flex items-center bg-black">
|
||||||
<LFOScope
|
<LFOScope
|
||||||
lfoIndex={index}
|
lfoIndex={index}
|
||||||
waveform={lfo.waveform}
|
waveform={lfo.waveform}
|
||||||
@ -1,4 +1,4 @@
|
|||||||
import { parameterRegistry } from '../domain/modulation/ParameterRegistry'
|
import { parameterRegistry } from '../../domain/modulation/ParameterRegistry'
|
||||||
|
|
||||||
interface Mapping {
|
interface Mapping {
|
||||||
targetParam: string
|
targetParam: string
|
||||||
22
src/components/index.ts
Normal file
22
src/components/index.ts
Normal file
@ -0,0 +1,22 @@
|
|||||||
|
// UI Components
|
||||||
|
export { Knob } from './ui/Knob'
|
||||||
|
export { Slider } from './ui/Slider'
|
||||||
|
export { Switch } from './ui/Switch'
|
||||||
|
export { Dropdown } from './ui/Dropdown'
|
||||||
|
|
||||||
|
// Tile Components
|
||||||
|
export { BytebeatTile } from './tile/BytebeatTile'
|
||||||
|
export { FormulaEditor } from './tile/FormulaEditor'
|
||||||
|
|
||||||
|
// Control Components
|
||||||
|
export { EngineControls } from './controls/EngineControls'
|
||||||
|
export { EffectsBar } from './controls/EffectsBar'
|
||||||
|
export { LFOPanel } from './controls/LFOPanel'
|
||||||
|
export { MappingEditor } from './controls/MappingEditor'
|
||||||
|
|
||||||
|
// Modal Components
|
||||||
|
export { AudioContextWarning } from './modals/AudioContextWarning'
|
||||||
|
export { HelpModal } from './modals/HelpModal'
|
||||||
|
|
||||||
|
// Scope Components
|
||||||
|
export { LFOScope } from './scopes/LFOScope'
|
||||||
111
src/components/modals/HelpModal.tsx
Normal file
111
src/components/modals/HelpModal.tsx
Normal file
@ -0,0 +1,111 @@
|
|||||||
|
interface HelpModalProps {
|
||||||
|
onClose: () => void
|
||||||
|
showStartButton?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export function HelpModal({ onClose, showStartButton = false }: HelpModalProps) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="fixed inset-0 bg-black/70 flex items-center justify-center z-50 p-4 overflow-y-auto"
|
||||||
|
onClick={onClose}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === 'Escape' || e.key === 'Enter' || e.key === ' ') {
|
||||||
|
onClose()
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
tabIndex={0}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className="border-2 md:border-4 border-white bg-black p-4 md:p-8 lg:p-12 max-w-4xl w-full my-auto"
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
<h1 className="font-mono text-lg md:text-2xl tracking-[0.3em] text-white mb-4 md:mb-6 text-center">
|
||||||
|
BRUITISTE
|
||||||
|
</h1>
|
||||||
|
<p className="font-mono text-xs md:text-sm text-white mb-2 leading-relaxed text-center">
|
||||||
|
Harsh noise soundbox made as a love statement to all weird noises, hums, audio glitches and ominous textures. Be careful, lower your volume! Tweak some parameters!
|
||||||
|
</p>
|
||||||
|
<p className="font-mono text-[10px] md:text-xs text-white mb-4 md:mb-6 opacity-70 text-center">
|
||||||
|
Made by Raphaël Forment (BuboBubo) — <a href="https://raphaelforment.fr" target="_blank" rel="noopener noreferrer" className="underline hover:opacity-100">raphaelforment.fr</a>
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div className="font-mono text-xs md:text-sm text-white mb-6 md:mb-8">
|
||||||
|
<h2 className="text-sm md:text-lg tracking-[0.2em] mb-3 md:mb-4">KEYBOARD SHORTCUTS</h2>
|
||||||
|
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<table className="w-full border-2 border-white">
|
||||||
|
<thead>
|
||||||
|
<tr className="border-b-2 border-white">
|
||||||
|
<th className="text-left p-2 md:p-3 bg-white text-black tracking-[0.1em] text-[10px] md:text-sm">KEY</th>
|
||||||
|
<th className="text-left p-2 md:p-3 bg-white text-black tracking-[0.1em] text-[10px] md:text-sm">ACTION</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr className="border-b border-white">
|
||||||
|
<td className="p-2 md:p-3 border-r border-white">SPACE</td>
|
||||||
|
<td className="p-2 md:p-3">Play/Stop current tile</td>
|
||||||
|
</tr>
|
||||||
|
<tr className="border-b border-white">
|
||||||
|
<td className="p-2 md:p-3 border-r border-white">ARROWS</td>
|
||||||
|
<td className="p-2 md:p-3">Navigate tiles</td>
|
||||||
|
</tr>
|
||||||
|
<tr className="border-b border-white">
|
||||||
|
<td className="p-2 md:p-3 border-r border-white">SHIFT + ARROWS</td>
|
||||||
|
<td className="p-2 md:p-3">Jump 10 tiles</td>
|
||||||
|
</tr>
|
||||||
|
<tr className="border-b border-white">
|
||||||
|
<td className="p-2 md:p-3 border-r border-white">ENTER</td>
|
||||||
|
<td className="p-2 md:p-3">Queue tile (play after current)</td>
|
||||||
|
</tr>
|
||||||
|
<tr className="border-b border-white">
|
||||||
|
<td className="p-2 md:p-3 border-r border-white">DOUBLE ENTER</td>
|
||||||
|
<td className="p-2 md:p-3">Play immediately</td>
|
||||||
|
</tr>
|
||||||
|
<tr className="border-b border-white">
|
||||||
|
<td className="p-2 md:p-3 border-r border-white">R</td>
|
||||||
|
<td className="p-2 md:p-3">Regenerate current tile</td>
|
||||||
|
</tr>
|
||||||
|
<tr className="border-b border-white">
|
||||||
|
<td className="p-2 md:p-3 border-r border-white">SHIFT + R</td>
|
||||||
|
<td className="p-2 md:p-3">Randomize all tiles</td>
|
||||||
|
</tr>
|
||||||
|
<tr className="border-b border-white">
|
||||||
|
<td className="p-2 md:p-3 border-r border-white">C</td>
|
||||||
|
<td className="p-2 md:p-3">Randomize current tile params</td>
|
||||||
|
</tr>
|
||||||
|
<tr className="border-b border-white">
|
||||||
|
<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>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{showStartButton ? (
|
||||||
|
<button
|
||||||
|
onClick={onClose}
|
||||||
|
className="w-full px-4 md:px-8 py-3 md:py-4 bg-white text-black font-mono text-xs md:text-sm tracking-[0.2em] hover:bg-black hover:text-white border-2 border-white transition-all"
|
||||||
|
>
|
||||||
|
START
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
<button
|
||||||
|
onClick={onClose}
|
||||||
|
className="w-full px-4 md:px-8 py-3 md:py-4 bg-white text-black font-mono text-xs md:text-sm tracking-[0.2em] hover:bg-black hover:text-white border-2 border-white transition-all"
|
||||||
|
>
|
||||||
|
CLOSE
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@ -1,9 +1,9 @@
|
|||||||
import { useEffect, useRef, useState, useCallback } from 'react'
|
import { useEffect, useRef, useState, useCallback } from 'react'
|
||||||
import { useStore } from '@nanostores/react'
|
import { useStore } from '@nanostores/react'
|
||||||
import { LFO, type LFOWaveform } from '../domain/modulation/LFO'
|
import { LFO, type LFOWaveform } from '../../domain/modulation/LFO'
|
||||||
import { mappingMode } from '../stores/mappingMode'
|
import { mappingMode } from '../../stores/mappingMode'
|
||||||
import { parameterRegistry } from '../domain/modulation/ParameterRegistry'
|
import { parameterRegistry } from '../../domain/modulation/ParameterRegistry'
|
||||||
import { MappingEditor } from './MappingEditor'
|
import { MappingEditor } from '../controls/MappingEditor'
|
||||||
|
|
||||||
interface LFOScopeProps {
|
interface LFOScopeProps {
|
||||||
lfoIndex: number
|
lfoIndex: number
|
||||||
@ -19,7 +19,6 @@ interface LFOScopeProps {
|
|||||||
|
|
||||||
const WAVEFORMS: LFOWaveform[] = ['sine', 'triangle', 'square', 'sawtooth', 'random']
|
const WAVEFORMS: LFOWaveform[] = ['sine', 'triangle', 'square', 'sawtooth', 'random']
|
||||||
|
|
||||||
const CANVAS_WIDTH = 340
|
|
||||||
const CANVAS_HEIGHT = 60
|
const CANVAS_HEIGHT = 60
|
||||||
const MIN_FREQ = 0.01
|
const MIN_FREQ = 0.01
|
||||||
const MAX_FREQ = 20
|
const MAX_FREQ = 20
|
||||||
@ -33,6 +32,7 @@ export function LFOScope({ lfoIndex, waveform, frequency, phase, mappings, onCha
|
|||||||
const [showEditor, setShowEditor] = useState(false)
|
const [showEditor, setShowEditor] = useState(false)
|
||||||
const dragStartRef = useRef<{ x: number; y: number; freq: number; phase: number; moved: boolean } | null>(null)
|
const dragStartRef = useRef<{ x: number; y: number; freq: number; phase: number; moved: boolean } | null>(null)
|
||||||
const mappingModeState = useStore(mappingMode)
|
const mappingModeState = useStore(mappingMode)
|
||||||
|
const [canvasWidth, setCanvasWidth] = useState(340)
|
||||||
|
|
||||||
const getLFOValueAtPhase = useCallback((phaseVal: number): number => {
|
const getLFOValueAtPhase = useCallback((phaseVal: number): number => {
|
||||||
const normalizedPhase = phaseVal % 1
|
const normalizedPhase = phaseVal % 1
|
||||||
@ -65,6 +65,26 @@ export function LFOScope({ lfoIndex, waveform, frequency, phase, mappings, onCha
|
|||||||
}
|
}
|
||||||
}, [frequency, phase, waveform])
|
}, [frequency, phase, waveform])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const canvas = canvasRef.current
|
||||||
|
if (!canvas) return
|
||||||
|
|
||||||
|
const updateCanvasSize = () => {
|
||||||
|
const rect = canvas.getBoundingClientRect()
|
||||||
|
const width = Math.floor(rect.width)
|
||||||
|
setCanvasWidth(width)
|
||||||
|
canvas.width = width
|
||||||
|
canvas.height = CANVAS_HEIGHT
|
||||||
|
}
|
||||||
|
|
||||||
|
updateCanvasSize()
|
||||||
|
window.addEventListener('resize', updateCanvasSize)
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener('resize', updateCanvasSize)
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const canvas = canvasRef.current
|
const canvas = canvasRef.current
|
||||||
if (!canvas) return
|
if (!canvas) return
|
||||||
@ -78,13 +98,13 @@ export function LFOScope({ lfoIndex, waveform, frequency, phase, mappings, onCha
|
|||||||
if (!lfoRef.current) return
|
if (!lfoRef.current) return
|
||||||
|
|
||||||
ctx.fillStyle = '#000000'
|
ctx.fillStyle = '#000000'
|
||||||
ctx.fillRect(0, 0, CANVAS_WIDTH, CANVAS_HEIGHT)
|
ctx.fillRect(0, 0, canvasWidth, CANVAS_HEIGHT)
|
||||||
|
|
||||||
ctx.strokeStyle = '#ffffff'
|
ctx.strokeStyle = '#ffffff'
|
||||||
ctx.lineWidth = 2
|
ctx.lineWidth = 2
|
||||||
ctx.beginPath()
|
ctx.beginPath()
|
||||||
|
|
||||||
const samples = CANVAS_WIDTH
|
const samples = canvasWidth
|
||||||
const centerY = CANVAS_HEIGHT / 2
|
const centerY = CANVAS_HEIGHT / 2
|
||||||
|
|
||||||
for (let x = 0; x < samples; x++) {
|
for (let x = 0; x < samples; x++) {
|
||||||
@ -119,7 +139,7 @@ export function LFOScope({ lfoIndex, waveform, frequency, phase, mappings, onCha
|
|||||||
cancelAnimationFrame(animationRef.current)
|
cancelAnimationFrame(animationRef.current)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}, [frequency, waveform, phase, getLFOValueAtPhase])
|
}, [frequency, waveform, phase, getLFOValueAtPhase, canvasWidth])
|
||||||
|
|
||||||
const handleMouseDown = (e: React.MouseEvent<HTMLCanvasElement>) => {
|
const handleMouseDown = (e: React.MouseEvent<HTMLCanvasElement>) => {
|
||||||
if (e.button === 2) return
|
if (e.button === 2) return
|
||||||
@ -185,12 +205,10 @@ export function LFOScope({ lfoIndex, waveform, frequency, phase, mappings, onCha
|
|||||||
<div className="flex items-center gap-1 w-full relative">
|
<div className="flex items-center gap-1 w-full relative">
|
||||||
<canvas
|
<canvas
|
||||||
ref={canvasRef}
|
ref={canvasRef}
|
||||||
width={CANVAS_WIDTH}
|
|
||||||
height={CANVAS_HEIGHT}
|
height={CANVAS_HEIGHT}
|
||||||
className={`border-2 border-white cursor-move flex-1 ${
|
className={`border-2 border-white cursor-move w-full ${
|
||||||
isActive ? 'animate-pulse' : ''
|
isActive ? 'animate-pulse' : ''
|
||||||
}`}
|
}`}
|
||||||
style={{ maxWidth: CANVAS_WIDTH }}
|
|
||||||
onMouseDown={handleMouseDown}
|
onMouseDown={handleMouseDown}
|
||||||
onMouseMove={handleMouseMove}
|
onMouseMove={handleMouseMove}
|
||||||
onMouseUp={handleMouseUp}
|
onMouseUp={handleMouseUp}
|
||||||
@ -1,6 +1,11 @@
|
|||||||
import { useRef, useEffect } from 'react'
|
import { useRef, useEffect } from 'react'
|
||||||
|
import { useStore } from '@nanostores/react'
|
||||||
import { Download, Dices } from 'lucide-react'
|
import { Download, Dices } from 'lucide-react'
|
||||||
import { generateWaveformData, drawWaveform } from '../utils/waveformGenerator'
|
import { generateWaveformData, drawWaveform } from '../../utils/waveformGenerator'
|
||||||
|
import { generateFMWaveformData } from '../../utils/fmWaveformGenerator'
|
||||||
|
import { synthesisMode } from '../../stores/synthesisMode'
|
||||||
|
import { parseFMPatch } from '../../utils/fmPatches'
|
||||||
|
import { getAlgorithmName } from '../../config/fmAlgorithms'
|
||||||
|
|
||||||
interface BytebeatTileProps {
|
interface BytebeatTileProps {
|
||||||
formula: string
|
formula: string
|
||||||
@ -22,6 +27,7 @@ interface BytebeatTileProps {
|
|||||||
|
|
||||||
export function BytebeatTile({ formula, row, col, isPlaying, isQueued, isFocused, playbackPosition, a, b, c, d, onPlay, onDoubleClick, onDownload, onRegenerate }: BytebeatTileProps) {
|
export function BytebeatTile({ formula, row, col, isPlaying, isQueued, isFocused, playbackPosition, a, b, c, d, onPlay, onDoubleClick, onDownload, onRegenerate }: BytebeatTileProps) {
|
||||||
const canvasRef = useRef<HTMLCanvasElement>(null)
|
const canvasRef = useRef<HTMLCanvasElement>(null)
|
||||||
|
const mode = useStore(synthesisMode)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const canvas = canvasRef.current
|
const canvas = canvasRef.current
|
||||||
@ -31,10 +37,19 @@ export function BytebeatTile({ formula, row, col, isPlaying, isQueued, isFocused
|
|||||||
canvas.width = rect.width * window.devicePixelRatio
|
canvas.width = rect.width * window.devicePixelRatio
|
||||||
canvas.height = rect.height * window.devicePixelRatio
|
canvas.height = rect.height * window.devicePixelRatio
|
||||||
|
|
||||||
const waveformData = generateWaveformData(formula, canvas.width, 8000, 0.5, a, b, c, d)
|
let waveformData: number[]
|
||||||
|
if (mode === 'fm') {
|
||||||
|
const fmPatch = parseFMPatch(formula)
|
||||||
|
const algorithmId = fmPatch?.algorithm ?? 0
|
||||||
|
const feedback = fmPatch?.feedback ?? 0
|
||||||
|
waveformData = generateFMWaveformData(algorithmId, a, b, c, d, feedback, canvas.width, 8000, 0.5)
|
||||||
|
} else {
|
||||||
|
waveformData = generateWaveformData(formula, canvas.width, 8000, 0.5, a, b, c, d)
|
||||||
|
}
|
||||||
|
|
||||||
const color = isPlaying ? 'rgba(0, 0, 0, 0.3)' : 'rgba(255, 255, 255, 0.35)'
|
const color = isPlaying ? 'rgba(0, 0, 0, 0.3)' : 'rgba(255, 255, 255, 0.35)'
|
||||||
drawWaveform(canvas, waveformData, color)
|
drawWaveform(canvas, waveformData, color)
|
||||||
}, [formula, isPlaying, isQueued, a, b, c, d])
|
}, [formula, isPlaying, isQueued, a, b, c, d, mode])
|
||||||
|
|
||||||
const handleDownload = (e: React.MouseEvent) => {
|
const handleDownload = (e: React.MouseEvent) => {
|
||||||
e.stopPropagation()
|
e.stopPropagation()
|
||||||
@ -66,7 +81,11 @@ export function BytebeatTile({ formula, row, col, isPlaying, isQueued, isFocused
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
<div className="text-xs break-all font-light flex-1 relative z-10">
|
<div className="text-xs break-all font-light flex-1 relative z-10">
|
||||||
{formula}
|
{mode === 'fm' ? (() => {
|
||||||
|
const fmPatch = parseFMPatch(formula)
|
||||||
|
const algorithmName = getAlgorithmName(fmPatch?.algorithm ?? 0)
|
||||||
|
return `${algorithmName} [${a},${b},${c},${d}] FB:${fmPatch?.feedback ?? 0}`
|
||||||
|
})() : formula}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex gap-1 flex-shrink-0 relative z-10">
|
<div className="flex gap-1 flex-shrink-0 relative z-10">
|
||||||
<div
|
<div
|
||||||
@ -1,6 +1,6 @@
|
|||||||
import { useRef, useState, useEffect, useCallback } from 'react'
|
import { useRef, useState, useEffect, useCallback } from 'react'
|
||||||
import { useStore } from '@nanostores/react'
|
import { useStore } from '@nanostores/react'
|
||||||
import { mappingMode } from '../stores/mappingMode'
|
import { mappingMode } from '../../stores/mappingMode'
|
||||||
|
|
||||||
interface KnobProps {
|
interface KnobProps {
|
||||||
label: string
|
label: string
|
||||||
@ -38,13 +38,22 @@ export function Knob({
|
|||||||
const startValueRef = useRef<number>(0)
|
const startValueRef = useRef<number>(0)
|
||||||
const mappingModeState = useStore(mappingMode)
|
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 isInMappingMode = mappingModeState.isActive && paramId
|
||||||
const hasMappings = mappedLFOs.length > 0
|
const hasMappings = mappedLFOs.length > 0
|
||||||
|
|
||||||
const normalizedValue = (value - min) / (max - min)
|
const normalizedValue = (value - min) / (max - min)
|
||||||
const angle = -225 + normalizedValue * 270
|
const angle = -225 + normalizedValue * 270
|
||||||
|
|
||||||
|
const fontSize = size <= 32 ? 'text-[7px]' : size <= 36 ? 'text-[8px]' : 'text-[9px]'
|
||||||
|
|
||||||
const handleMouseDown = (e: React.MouseEvent) => {
|
const handleMouseDown = (e: React.MouseEvent) => {
|
||||||
if (isInMappingMode && paramId && mappingModeState.activeLFO !== null && onMapClick) {
|
if (isInMappingMode && paramId && mappingModeState.activeLFO !== null && onMapClick) {
|
||||||
onMapClick(paramId, mappingModeState.activeLFO)
|
onMapClick(paramId, mappingModeState.activeLFO)
|
||||||
@ -58,6 +67,19 @@ export function Knob({
|
|||||||
e.preventDefault()
|
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) => {
|
const handleMouseMove = useCallback((e: MouseEvent) => {
|
||||||
if (!isDragging) return
|
if (!isDragging) return
|
||||||
|
|
||||||
@ -70,26 +92,48 @@ export function Knob({
|
|||||||
onChange(steppedValue)
|
onChange(steppedValue)
|
||||||
}, [isDragging, max, min, step, onChange])
|
}, [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(() => {
|
const handleMouseUp = useCallback(() => {
|
||||||
setIsDragging(false)
|
setIsDragging(false)
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
|
const handleTouchEnd = useCallback(() => {
|
||||||
|
setIsDragging(false)
|
||||||
|
}, [])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (isDragging) {
|
if (isDragging) {
|
||||||
window.addEventListener('mousemove', handleMouseMove)
|
window.addEventListener('mousemove', handleMouseMove)
|
||||||
window.addEventListener('mouseup', handleMouseUp)
|
window.addEventListener('mouseup', handleMouseUp)
|
||||||
|
window.addEventListener('touchmove', handleTouchMove, { passive: false })
|
||||||
|
window.addEventListener('touchend', handleTouchEnd)
|
||||||
return () => {
|
return () => {
|
||||||
window.removeEventListener('mousemove', handleMouseMove)
|
window.removeEventListener('mousemove', handleMouseMove)
|
||||||
window.removeEventListener('mouseup', handleMouseUp)
|
window.removeEventListener('mouseup', handleMouseUp)
|
||||||
|
window.removeEventListener('touchmove', handleTouchMove)
|
||||||
|
window.removeEventListener('touchend', handleTouchEnd)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}, [isDragging, handleMouseMove, handleMouseUp])
|
}, [isDragging, handleMouseMove, handleMouseUp, handleTouchMove, handleTouchEnd])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="relative flex flex-col items-center">
|
<div className="relative flex flex-col items-center">
|
||||||
<div
|
<div
|
||||||
className={`relative select-none ${isInMappingMode ? 'cursor-pointer' : 'cursor-ns-resize'}`}
|
className={`relative select-none ${isInMappingMode ? 'cursor-pointer' : 'cursor-ns-resize'}`}
|
||||||
onMouseDown={handleMouseDown}
|
onMouseDown={handleMouseDown}
|
||||||
|
onTouchStart={handleTouchStart}
|
||||||
style={{ width: size, height: size }}
|
style={{ width: size, height: size }}
|
||||||
>
|
>
|
||||||
<svg
|
<svg
|
||||||
@ -135,7 +179,7 @@ export function Knob({
|
|||||||
</svg>
|
</svg>
|
||||||
|
|
||||||
<div className="absolute inset-0 flex items-center justify-center pointer-events-none">
|
<div className="absolute inset-0 flex items-center justify-center pointer-events-none">
|
||||||
<span className={`font-mono text-[9px] tracking-[0.15em] text-white ${isInMappingMode ? 'animate-pulse' : ''}`}>
|
<span className={`font-mono ${fontSize} tracking-[0.15em] text-white ${isInMappingMode ? 'animate-pulse' : ''}`}>
|
||||||
{isDragging ? displayValue : label.toUpperCase()}
|
{isDragging ? displayValue : label.toUpperCase()}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
@ -1,5 +1,5 @@
|
|||||||
import { useStore } from '@nanostores/react'
|
import { useStore } from '@nanostores/react'
|
||||||
import { mappingMode } from '../stores/mappingMode'
|
import { mappingMode } from '../../stores/mappingMode'
|
||||||
|
|
||||||
interface SliderProps {
|
interface SliderProps {
|
||||||
label: string
|
label: string
|
||||||
@ -31,7 +31,15 @@ export function Slider({
|
|||||||
mappedLFOs = []
|
mappedLFOs = []
|
||||||
}: SliderProps) {
|
}: SliderProps) {
|
||||||
const mappingModeState = useStore(mappingMode)
|
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 isInMappingMode = !!(mappingModeState.isActive && paramId)
|
||||||
const hasMappings = mappedLFOs.length > 0
|
const hasMappings = mappedLFOs.length > 0
|
||||||
|
|
||||||
97
src/config/fmAlgorithms.ts
Normal file
97
src/config/fmAlgorithms.ts
Normal file
@ -0,0 +1,97 @@
|
|||||||
|
export interface FMAlgorithm {
|
||||||
|
id: number
|
||||||
|
name: string
|
||||||
|
frequencyRatios: [number, number, number, number]
|
||||||
|
}
|
||||||
|
|
||||||
|
export const FM_ALGORITHMS: FMAlgorithm[] = [
|
||||||
|
{
|
||||||
|
id: 0,
|
||||||
|
name: 'Algo 0',
|
||||||
|
frequencyRatios: [1.0, 1.0, 1.0, 1.0]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 1,
|
||||||
|
name: 'Algo 1',
|
||||||
|
frequencyRatios: [1.0, 1.0, 1.0, 1.0]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 2,
|
||||||
|
name: 'Algo 2',
|
||||||
|
frequencyRatios: [1.0, 2.0, 1.0, 2.0]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 3,
|
||||||
|
name: 'Algo 3',
|
||||||
|
frequencyRatios: [1.0, 2.0, 3.0, 1.0]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 4,
|
||||||
|
name: 'Algo 4',
|
||||||
|
frequencyRatios: [1.0, 1.414, 2.0, 2.828]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 5,
|
||||||
|
name: 'Algo 5',
|
||||||
|
frequencyRatios: [1.0, 2.0, 3.0, 4.0]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 6,
|
||||||
|
name: 'Algo 6',
|
||||||
|
frequencyRatios: [1.0, 2.5, 1.0, 1.0]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 7,
|
||||||
|
name: 'Algo 7',
|
||||||
|
frequencyRatios: [1.0, 1.5, 1.5, 2.0]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 8,
|
||||||
|
name: 'Algo 8',
|
||||||
|
frequencyRatios: [1.0, 1.0, 2.0, 2.0]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 9,
|
||||||
|
name: 'Algo 9',
|
||||||
|
frequencyRatios: [1.0, 1.0, 2.0, 1.5]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 10,
|
||||||
|
name: 'Algo 10',
|
||||||
|
frequencyRatios: [1.0, 1.0, 1.0, 2.0]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 11,
|
||||||
|
name: 'Algo 11',
|
||||||
|
frequencyRatios: [1.0, 1.0, 2.0, 3.0]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 12,
|
||||||
|
name: 'Algo 12',
|
||||||
|
frequencyRatios: [1.0, 1.5, 1.0, 2.0]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 13,
|
||||||
|
name: 'Algo 13',
|
||||||
|
frequencyRatios: [1.0, 2.0, 3.0, 4.0]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 14,
|
||||||
|
name: 'Algo 14',
|
||||||
|
frequencyRatios: [1.0, 2.0, 1.5, 2.5]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 15,
|
||||||
|
name: 'Algo 15',
|
||||||
|
frequencyRatios: [1.0, 1.414, 1.732, 2.0]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
||||||
|
export function getAlgorithmById(id: number): FMAlgorithm {
|
||||||
|
return FM_ALGORITHMS[id % FM_ALGORITHMS.length] || FM_ALGORITHMS[0]
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getAlgorithmName(id: number): string {
|
||||||
|
const alg = getAlgorithmById(id)
|
||||||
|
return `${alg.name}`
|
||||||
|
}
|
||||||
2
src/config/index.ts
Normal file
2
src/config/index.ts
Normal file
@ -0,0 +1,2 @@
|
|||||||
|
export { ENGINE_CONTROLS, EFFECTS, SAMPLE_RATES, getDefaultEngineValues, getDefaultEffectValues, getSampleRateFromIndex } from './parameters'
|
||||||
|
export { FM_ALGORITHMS, getAlgorithmById, getAlgorithmName } from './fmAlgorithms'
|
||||||
@ -1,6 +1,6 @@
|
|||||||
import type { EffectConfig } from '../types/effects'
|
import type { ParameterGroup } from '../types/parameters'
|
||||||
|
|
||||||
export const ENGINE_CONTROLS: EffectConfig[] = [
|
export const ENGINE_CONTROLS: ParameterGroup[] = [
|
||||||
{
|
{
|
||||||
id: 'engine',
|
id: 'engine',
|
||||||
name: 'Engine',
|
name: 'Engine',
|
||||||
@ -45,8 +45,8 @@ export const ENGINE_CONTROLS: EffectConfig[] = [
|
|||||||
id: 'masterVolume',
|
id: 'masterVolume',
|
||||||
label: 'Vol',
|
label: 'Vol',
|
||||||
min: 0,
|
min: 0,
|
||||||
max: 100,
|
max: 80,
|
||||||
default: 75,
|
default: 50,
|
||||||
step: 1,
|
step: 1,
|
||||||
unit: '%'
|
unit: '%'
|
||||||
},
|
},
|
||||||
@ -94,12 +94,135 @@ export const ENGINE_CONTROLS: EffectConfig[] = [
|
|||||||
default: 64,
|
default: 64,
|
||||||
step: 1,
|
step: 1,
|
||||||
unit: ''
|
unit: ''
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'fmAlgorithm',
|
||||||
|
label: 'Algorithm',
|
||||||
|
min: 0,
|
||||||
|
max: 15,
|
||||||
|
default: 0,
|
||||||
|
step: 1,
|
||||||
|
unit: ''
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'fmFeedback',
|
||||||
|
label: 'Feedback',
|
||||||
|
min: 0,
|
||||||
|
max: 100,
|
||||||
|
default: 0,
|
||||||
|
step: 1,
|
||||||
|
unit: '%'
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
|
||||||
export const EFFECTS: EffectConfig[] = [
|
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',
|
id: 'filter',
|
||||||
name: 'Filter',
|
name: 'Filter',
|
||||||
@ -132,7 +255,7 @@ export const EFFECTS: EffectConfig[] = [
|
|||||||
{
|
{
|
||||||
id: 'filterRes',
|
id: 'filterRes',
|
||||||
label: 'Res',
|
label: 'Res',
|
||||||
min: 0.5,
|
min: 0.05,
|
||||||
max: 10,
|
max: 10,
|
||||||
default: 0.707,
|
default: 0.707,
|
||||||
step: 0.1,
|
step: 0.1,
|
||||||
@ -142,7 +265,7 @@ export const EFFECTS: EffectConfig[] = [
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'foldcrush',
|
id: 'foldcrush',
|
||||||
name: 'Fold and Crush',
|
name: 'Distortion',
|
||||||
bypassable: true,
|
bypassable: true,
|
||||||
parameters: [
|
parameters: [
|
||||||
{
|
{
|
||||||
@ -154,34 +277,43 @@ export const EFFECTS: EffectConfig[] = [
|
|||||||
step: 1,
|
step: 1,
|
||||||
unit: '',
|
unit: '',
|
||||||
options: [
|
options: [
|
||||||
|
{ value: 'tube', label: 'Tube' },
|
||||||
|
{ value: 'tape', label: 'Tape' },
|
||||||
|
{ value: 'fuzz', label: 'Fuzz' },
|
||||||
{ value: 'fold', label: 'Fold' },
|
{ value: 'fold', label: 'Fold' },
|
||||||
{ value: 'soft', label: 'Soft' },
|
{ value: 'crush', label: 'Crush' }
|
||||||
{ value: 'cubic', label: 'Cubic' },
|
|
||||||
{ value: 'diode', label: 'Diode' },
|
|
||||||
{ value: 'hard', label: 'Hard' }
|
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'wavefolderDrive',
|
id: 'wavefolderDrive',
|
||||||
label: 'Drive',
|
label: 'Drive',
|
||||||
min: 0.001,
|
min: 0,
|
||||||
max: 10,
|
max: 10,
|
||||||
default: 1,
|
default: 0,
|
||||||
step: 0.1,
|
step: 0.1,
|
||||||
unit: ''
|
unit: ''
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'bitcrushDepth',
|
id: 'bitcrushDepth',
|
||||||
label: 'Depth',
|
label: 'Bits',
|
||||||
min: 1,
|
min: 1,
|
||||||
max: 16,
|
max: 16,
|
||||||
default: 16,
|
default: 16,
|
||||||
step: 1,
|
step: 1,
|
||||||
unit: 'bit'
|
unit: ''
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'bitcrushRate',
|
id: 'bitcrushRate',
|
||||||
label: 'Rate',
|
label: 'Downsample',
|
||||||
|
min: 0,
|
||||||
|
max: 100,
|
||||||
|
default: 0,
|
||||||
|
step: 1,
|
||||||
|
unit: '%'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'glitchAmount',
|
||||||
|
label: 'Glitch',
|
||||||
min: 0,
|
min: 0,
|
||||||
max: 100,
|
max: 100,
|
||||||
default: 0,
|
default: 0,
|
||||||
@ -199,8 +331,8 @@ export const EFFECTS: EffectConfig[] = [
|
|||||||
id: 'delayTime',
|
id: 'delayTime',
|
||||||
label: 'Time',
|
label: 'Time',
|
||||||
min: 10,
|
min: 10,
|
||||||
max: 2000,
|
max: 10000,
|
||||||
default: 250,
|
default: 500,
|
||||||
step: 10,
|
step: 10,
|
||||||
unit: 'ms'
|
unit: 'ms'
|
||||||
},
|
},
|
||||||
@ -232,20 +364,20 @@ export const EFFECTS: EffectConfig[] = [
|
|||||||
unit: '%'
|
unit: '%'
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'delaySaturation',
|
id: 'delayPingPong',
|
||||||
label: 'Saturation',
|
label: 'Ping-Pong',
|
||||||
min: 0,
|
min: 0,
|
||||||
max: 100,
|
max: 100,
|
||||||
default: 20,
|
default: 0,
|
||||||
step: 1,
|
step: 1,
|
||||||
unit: '%'
|
unit: '%'
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'delayFlutter',
|
id: 'delayDiffusion',
|
||||||
label: 'Flutter',
|
label: 'Diffusion',
|
||||||
min: 0,
|
min: 0,
|
||||||
max: 100,
|
max: 100,
|
||||||
default: 15,
|
default: 0,
|
||||||
step: 1,
|
step: 1,
|
||||||
unit: '%'
|
unit: '%'
|
||||||
}
|
}
|
||||||
@ -258,7 +390,7 @@ export const EFFECTS: EffectConfig[] = [
|
|||||||
parameters: [
|
parameters: [
|
||||||
{
|
{
|
||||||
id: 'reverbWetDry',
|
id: 'reverbWetDry',
|
||||||
label: 'Amount',
|
label: 'Mix',
|
||||||
min: 0,
|
min: 0,
|
||||||
max: 100,
|
max: 100,
|
||||||
default: 0,
|
default: 0,
|
||||||
@ -266,17 +398,8 @@ export const EFFECTS: EffectConfig[] = [
|
|||||||
unit: '%'
|
unit: '%'
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'reverbDecay',
|
id: 'reverbSize',
|
||||||
label: 'Decay',
|
label: 'Size',
|
||||||
min: 0.1,
|
|
||||||
max: 5,
|
|
||||||
default: 2,
|
|
||||||
step: 0.1,
|
|
||||||
unit: 's'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'reverbDamping',
|
|
||||||
label: 'Damping',
|
|
||||||
min: 0,
|
min: 0,
|
||||||
max: 100,
|
max: 100,
|
||||||
default: 50,
|
default: 50,
|
||||||
@ -284,17 +407,17 @@ export const EFFECTS: EffectConfig[] = [
|
|||||||
unit: '%'
|
unit: '%'
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'reverbPanRate',
|
id: 'reverbDecay',
|
||||||
label: 'Pan Rate',
|
label: 'Decay',
|
||||||
min: 0,
|
min: 10,
|
||||||
max: 10,
|
max: 95,
|
||||||
default: 0,
|
default: 70,
|
||||||
step: 0.1,
|
step: 1,
|
||||||
unit: 'Hz'
|
unit: '%'
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'reverbPanWidth',
|
id: 'reverbDamping',
|
||||||
label: 'Pan Width',
|
label: 'Damping',
|
||||||
min: 0,
|
min: 0,
|
||||||
max: 100,
|
max: 100,
|
||||||
default: 50,
|
default: 50,
|
||||||
@ -1,8 +1,11 @@
|
|||||||
import { EffectsChain } from './effects/EffectsChain'
|
import { EffectsChain } from './effects/EffectsChain'
|
||||||
import { BytebeatSourceEffect } from './effects/BytebeatSourceEffect'
|
import { BytebeatSourceEffect } from './effects/BytebeatSourceEffect'
|
||||||
|
import { FMSourceEffect } from './effects/FMSourceEffect'
|
||||||
import { ModulationEngine } from '../modulation/ModulationEngine'
|
import { ModulationEngine } from '../modulation/ModulationEngine'
|
||||||
import type { LFOWaveform } from '../modulation/LFO'
|
import type { LFOWaveform } from '../modulation/LFO'
|
||||||
import type { EffectValues } from '../../types/effects'
|
import type { EffectValues } from '../../types/effects'
|
||||||
|
import type { SynthesisMode } from '../../stores/synthesisMode'
|
||||||
|
import { getAlgorithmById } from '../../config/fmAlgorithms'
|
||||||
|
|
||||||
export interface AudioPlayerOptions {
|
export interface AudioPlayerOptions {
|
||||||
sampleRate: number
|
sampleRate: number
|
||||||
@ -12,6 +15,7 @@ export interface AudioPlayerOptions {
|
|||||||
export class AudioPlayer {
|
export class AudioPlayer {
|
||||||
private audioContext: AudioContext | null = null
|
private audioContext: AudioContext | null = null
|
||||||
private bytebeatSource: BytebeatSourceEffect | null = null
|
private bytebeatSource: BytebeatSourceEffect | null = null
|
||||||
|
private fmSource: FMSourceEffect | null = null
|
||||||
private effectsChain: EffectsChain | null = null
|
private effectsChain: EffectsChain | null = null
|
||||||
private modulationEngine: ModulationEngine | null = null
|
private modulationEngine: ModulationEngine | null = null
|
||||||
private effectValues: EffectValues = {}
|
private effectValues: EffectValues = {}
|
||||||
@ -20,6 +24,9 @@ export class AudioPlayer {
|
|||||||
private duration: number
|
private duration: number
|
||||||
private workletRegistered: boolean = false
|
private workletRegistered: boolean = false
|
||||||
private currentPitch: number = 1.0
|
private currentPitch: number = 1.0
|
||||||
|
private currentMode: SynthesisMode = 'bytebeat'
|
||||||
|
private currentAlgorithm: number = 0
|
||||||
|
private currentFeedback: number = 0
|
||||||
|
|
||||||
constructor(options: AudioPlayerOptions) {
|
constructor(options: AudioPlayerOptions) {
|
||||||
this.sampleRate = options.sampleRate
|
this.sampleRate = options.sampleRate
|
||||||
@ -66,6 +73,9 @@ export class AudioPlayer {
|
|||||||
context.audioWorklet.addModule('/worklets/svf-processor.js'),
|
context.audioWorklet.addModule('/worklets/svf-processor.js'),
|
||||||
context.audioWorklet.addModule('/worklets/fold-crush-processor.js'),
|
context.audioWorklet.addModule('/worklets/fold-crush-processor.js'),
|
||||||
context.audioWorklet.addModule('/worklets/bytebeat-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')
|
context.audioWorklet.addModule('/worklets/output-limiter.js')
|
||||||
])
|
])
|
||||||
this.workletRegistered = true
|
this.workletRegistered = true
|
||||||
@ -141,9 +151,29 @@ export class AudioPlayer {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
setMode(mode: SynthesisMode): void {
|
||||||
|
this.currentMode = mode
|
||||||
|
}
|
||||||
|
|
||||||
|
setAlgorithm(algorithmId: number, lfoRates?: number[], pitchLFO?: { waveform: number; depth: number; baseRate: number }): void {
|
||||||
|
this.currentAlgorithm = algorithmId
|
||||||
|
if (this.fmSource) {
|
||||||
|
const algorithm = getAlgorithmById(algorithmId)
|
||||||
|
this.fmSource.setAlgorithm(algorithm, lfoRates, pitchLFO)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
setFeedback(feedback: number): void {
|
||||||
|
this.currentFeedback = feedback
|
||||||
|
if (this.fmSource) {
|
||||||
|
this.fmSource.setFeedback(feedback)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async playRealtime(formula: string, a: number, b: number, c: number, d: number): Promise<void> {
|
async playRealtime(formula: string, a: number, b: number, c: number, d: number): Promise<void> {
|
||||||
await this.ensureAudioContext()
|
await this.ensureAudioContext()
|
||||||
|
|
||||||
|
if (this.currentMode === 'bytebeat') {
|
||||||
if (!this.bytebeatSource) {
|
if (!this.bytebeatSource) {
|
||||||
this.bytebeatSource = new BytebeatSourceEffect(this.audioContext!)
|
this.bytebeatSource = new BytebeatSourceEffect(this.audioContext!)
|
||||||
await this.bytebeatSource.initialize(this.audioContext!)
|
await this.bytebeatSource.initialize(this.audioContext!)
|
||||||
@ -156,6 +186,27 @@ export class AudioPlayer {
|
|||||||
this.bytebeatSource.reset()
|
this.bytebeatSource.reset()
|
||||||
|
|
||||||
this.bytebeatSource.getOutputNode().connect(this.effectsChain!.getInputNode())
|
this.bytebeatSource.getOutputNode().connect(this.effectsChain!.getInputNode())
|
||||||
|
} else {
|
||||||
|
if (!this.fmSource) {
|
||||||
|
this.fmSource = new FMSourceEffect(this.audioContext!)
|
||||||
|
await this.fmSource.initialize(this.audioContext!)
|
||||||
|
}
|
||||||
|
|
||||||
|
this.fmSource.setLoopLength(this.sampleRate, this.duration)
|
||||||
|
const algorithm = getAlgorithmById(this.currentAlgorithm)
|
||||||
|
const patch = formula ? JSON.parse(formula) : null
|
||||||
|
const lfoRates = patch?.lfoRates || undefined
|
||||||
|
const pitchLFO = patch?.pitchLFO || undefined
|
||||||
|
this.fmSource.setAlgorithm(algorithm, lfoRates, pitchLFO)
|
||||||
|
this.fmSource.setOperatorLevels(a, b, c, d)
|
||||||
|
this.fmSource.setBaseFrequency(220 * this.currentPitch)
|
||||||
|
this.fmSource.setFeedback(this.currentFeedback)
|
||||||
|
this.fmSource.setPlaybackRate(1.0)
|
||||||
|
this.fmSource.reset()
|
||||||
|
|
||||||
|
this.fmSource.getOutputNode().connect(this.effectsChain!.getInputNode())
|
||||||
|
}
|
||||||
|
|
||||||
this.effectsChain!.getOutputNode().connect(this.audioContext!.destination)
|
this.effectsChain!.getOutputNode().connect(this.audioContext!.destination)
|
||||||
|
|
||||||
if (this.modulationEngine) {
|
if (this.modulationEngine) {
|
||||||
@ -166,14 +217,18 @@ export class AudioPlayer {
|
|||||||
}
|
}
|
||||||
|
|
||||||
updateRealtimeVariables(a: number, b: number, c: number, d: number): void {
|
updateRealtimeVariables(a: number, b: number, c: number, d: number): void {
|
||||||
if (this.bytebeatSource) {
|
if (this.currentMode === 'bytebeat' && this.bytebeatSource) {
|
||||||
this.bytebeatSource.setVariables(a, b, c, d)
|
this.bytebeatSource.setVariables(a, b, c, d)
|
||||||
|
} else if (this.currentMode === 'fm' && this.fmSource) {
|
||||||
|
this.fmSource.setOperatorLevels(a, b, c, d)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private applyPitch(pitch: number): void {
|
private applyPitch(pitch: number): void {
|
||||||
if (this.bytebeatSource) {
|
if (this.currentMode === 'bytebeat' && this.bytebeatSource) {
|
||||||
this.bytebeatSource.setPlaybackRate(pitch)
|
this.bytebeatSource.setPlaybackRate(pitch)
|
||||||
|
} else if (this.currentMode === 'fm' && this.fmSource) {
|
||||||
|
this.fmSource.setBaseFrequency(220 * pitch)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -197,6 +252,9 @@ export class AudioPlayer {
|
|||||||
if (this.bytebeatSource) {
|
if (this.bytebeatSource) {
|
||||||
this.bytebeatSource.getOutputNode().disconnect()
|
this.bytebeatSource.getOutputNode().disconnect()
|
||||||
}
|
}
|
||||||
|
if (this.fmSource) {
|
||||||
|
this.fmSource.getOutputNode().disconnect()
|
||||||
|
}
|
||||||
if (this.modulationEngine) {
|
if (this.modulationEngine) {
|
||||||
this.modulationEngine.stop()
|
this.modulationEngine.stop()
|
||||||
}
|
}
|
||||||
@ -209,6 +267,10 @@ export class AudioPlayer {
|
|||||||
this.bytebeatSource.dispose()
|
this.bytebeatSource.dispose()
|
||||||
this.bytebeatSource = null
|
this.bytebeatSource = null
|
||||||
}
|
}
|
||||||
|
if (this.fmSource) {
|
||||||
|
this.fmSource.dispose()
|
||||||
|
this.fmSource = null
|
||||||
|
}
|
||||||
if (this.modulationEngine) {
|
if (this.modulationEngine) {
|
||||||
this.modulationEngine.dispose()
|
this.modulationEngine.dispose()
|
||||||
this.modulationEngine = null
|
this.modulationEngine = null
|
||||||
|
|||||||
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 audioContext: AudioContext
|
||||||
private inputNode: GainNode
|
private inputNode: GainNode
|
||||||
private outputNode: GainNode
|
private outputNode: GainNode
|
||||||
private delayNode: DelayNode
|
|
||||||
private feedbackNode: GainNode
|
|
||||||
private wetNode: GainNode
|
|
||||||
private dryNode: GainNode
|
private dryNode: GainNode
|
||||||
|
private wetNode: GainNode
|
||||||
|
|
||||||
|
private leftDelayNode: DelayNode
|
||||||
|
private rightDelayNode: DelayNode
|
||||||
|
private leftFeedbackNode: GainNode
|
||||||
|
private rightFeedbackNode: GainNode
|
||||||
|
|
||||||
private filterNode: BiquadFilterNode
|
private filterNode: BiquadFilterNode
|
||||||
private saturatorNode: WaveShaperNode
|
private dcBlockerNode: BiquadFilterNode
|
||||||
private lfoNode: OscillatorNode
|
|
||||||
private lfoGainNode: GainNode
|
private splitterNode: ChannelSplitterNode
|
||||||
|
private mergerNode: ChannelMergerNode
|
||||||
|
|
||||||
|
private diffusionNodes: BiquadFilterNode[] = []
|
||||||
|
private diffusionMixNode: GainNode
|
||||||
|
private diffusionWetNode: GainNode
|
||||||
|
private diffusionDryNode: GainNode
|
||||||
|
|
||||||
private bypassed: boolean = false
|
private bypassed: boolean = false
|
||||||
private currentWetValue: number = 0.5
|
private currentWetValue: number = 0.5
|
||||||
private currentDryValue: number = 0.5
|
private currentDryValue: number = 0.5
|
||||||
|
private currentPingPong: number = 0
|
||||||
|
|
||||||
constructor(audioContext: AudioContext) {
|
constructor(audioContext: AudioContext) {
|
||||||
this.audioContext = audioContext
|
this.audioContext = audioContext
|
||||||
|
|
||||||
this.inputNode = audioContext.createGain()
|
this.inputNode = audioContext.createGain()
|
||||||
this.outputNode = 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.dryNode = audioContext.createGain()
|
||||||
this.filterNode = audioContext.createBiquadFilter()
|
this.wetNode = audioContext.createGain()
|
||||||
this.saturatorNode = audioContext.createWaveShaper()
|
|
||||||
|
|
||||||
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.dryNode.gain.value = 0.5
|
||||||
this.wetNode.gain.value = 0.5
|
this.wetNode.gain.value = 0.5
|
||||||
this.feedbackNode.gain.value = 0.5
|
|
||||||
|
|
||||||
this.filterNode.type = 'lowpass'
|
this.filterNode.type = 'lowpass'
|
||||||
this.filterNode.frequency.value = 5000
|
this.filterNode.frequency.value = 5000
|
||||||
this.filterNode.Q.value = 0.7
|
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.diffusionWetNode.gain.value = 0
|
||||||
this.lfoGainNode = audioContext.createGain()
|
this.diffusionDryNode.gain.value = 1
|
||||||
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()
|
|
||||||
|
|
||||||
|
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.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.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)
|
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 {
|
getInputNode(): AudioNode {
|
||||||
return this.inputNode
|
return this.inputNode
|
||||||
}
|
}
|
||||||
@ -86,27 +133,39 @@ export class DelayEffect implements Effect {
|
|||||||
if (bypass) {
|
if (bypass) {
|
||||||
this.wetNode.gain.value = 0
|
this.wetNode.gain.value = 0
|
||||||
this.dryNode.gain.value = 1
|
this.dryNode.gain.value = 1
|
||||||
|
this.leftFeedbackNode.disconnect()
|
||||||
|
this.rightFeedbackNode.disconnect()
|
||||||
} else {
|
} else {
|
||||||
this.wetNode.gain.value = this.currentWetValue
|
this.wetNode.gain.value = this.currentWetValue
|
||||||
this.dryNode.gain.value = this.currentDryValue
|
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 {
|
updateParams(values: Record<string, number | string>): void {
|
||||||
|
const now = this.audioContext.currentTime
|
||||||
|
|
||||||
if (values.delayTime !== undefined && typeof values.delayTime === 'number') {
|
if (values.delayTime !== undefined && typeof values.delayTime === 'number') {
|
||||||
const time = values.delayTime / 1000
|
const time = values.delayTime / 1000
|
||||||
this.delayNode.delayTime.setTargetAtTime(
|
this.leftDelayNode.delayTime.setTargetAtTime(time, now, 0.01)
|
||||||
time,
|
this.rightDelayNode.delayTime.setTargetAtTime(time, now, 0.01)
|
||||||
this.audioContext.currentTime,
|
|
||||||
0.01
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (values.delayFeedback !== undefined && typeof values.delayFeedback === 'number') {
|
if (values.delayFeedback !== undefined && typeof values.delayFeedback === 'number') {
|
||||||
const feedback = values.delayFeedback / 100
|
const feedback = values.delayFeedback / 100
|
||||||
this.feedbackNode.gain.setTargetAtTime(
|
const pingPongFactor = Math.max(0.5, this.currentPingPong / 100)
|
||||||
feedback * 0.95,
|
|
||||||
this.audioContext.currentTime,
|
this.leftFeedbackNode.gain.setTargetAtTime(
|
||||||
|
feedback * 0.95 * pingPongFactor,
|
||||||
|
now,
|
||||||
|
0.01
|
||||||
|
)
|
||||||
|
this.rightFeedbackNode.gain.setTargetAtTime(
|
||||||
|
feedback * 0.95 * pingPongFactor,
|
||||||
|
now,
|
||||||
0.01
|
0.01
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@ -117,57 +176,51 @@ export class DelayEffect implements Effect {
|
|||||||
this.currentDryValue = 1 - wet
|
this.currentDryValue = 1 - wet
|
||||||
|
|
||||||
if (!this.bypassed) {
|
if (!this.bypassed) {
|
||||||
this.wetNode.gain.setTargetAtTime(
|
this.wetNode.gain.setTargetAtTime(this.currentWetValue, now, 0.01)
|
||||||
this.currentWetValue,
|
this.dryNode.gain.setTargetAtTime(this.currentDryValue, now, 0.01)
|
||||||
this.audioContext.currentTime,
|
|
||||||
0.01
|
|
||||||
)
|
|
||||||
this.dryNode.gain.setTargetAtTime(
|
|
||||||
this.currentDryValue,
|
|
||||||
this.audioContext.currentTime,
|
|
||||||
0.01
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (values.delayTone !== undefined && typeof values.delayTone === 'number') {
|
if (values.delayTone !== undefined && typeof values.delayTone === 'number') {
|
||||||
const tone = values.delayTone / 100
|
const tone = values.delayTone / 100
|
||||||
const freq = 200 + tone * 7800
|
const freq = 200 + tone * 7800
|
||||||
this.filterNode.frequency.setTargetAtTime(
|
this.filterNode.frequency.setTargetAtTime(freq, now, 0.01)
|
||||||
freq,
|
|
||||||
this.audioContext.currentTime,
|
|
||||||
0.01
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (values.delaySaturation !== undefined && typeof values.delaySaturation === 'number') {
|
if (values.delayPingPong !== undefined && typeof values.delayPingPong === 'number') {
|
||||||
const saturation = values.delaySaturation / 100
|
this.currentPingPong = values.delayPingPong
|
||||||
this.createSaturationCurve(saturation)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (values.delayFlutter !== undefined && typeof values.delayFlutter === 'number') {
|
if (values.delayDiffusion !== undefined && typeof values.delayDiffusion === 'number') {
|
||||||
const flutter = values.delayFlutter / 100
|
const diffusion = values.delayDiffusion / 100
|
||||||
const baseDelay = this.delayNode.delayTime.value
|
this.diffusionWetNode.gain.setTargetAtTime(diffusion, now, 0.01)
|
||||||
const modDepth = baseDelay * flutter * 0.1
|
this.diffusionDryNode.gain.setTargetAtTime(1 - diffusion, now, 0.01)
|
||||||
this.lfoGainNode.gain.setTargetAtTime(
|
|
||||||
modDepth,
|
for (const filter of this.diffusionNodes) {
|
||||||
this.audioContext.currentTime,
|
filter.Q.setTargetAtTime(0.707 + diffusion * 4, now, 0.01)
|
||||||
0.01
|
}
|
||||||
)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
dispose(): void {
|
dispose(): void {
|
||||||
this.lfoNode.stop()
|
|
||||||
this.lfoNode.disconnect()
|
|
||||||
this.lfoGainNode.disconnect()
|
|
||||||
this.inputNode.disconnect()
|
this.inputNode.disconnect()
|
||||||
this.outputNode.disconnect()
|
this.outputNode.disconnect()
|
||||||
this.delayNode.disconnect()
|
|
||||||
this.feedbackNode.disconnect()
|
|
||||||
this.wetNode.disconnect()
|
|
||||||
this.dryNode.disconnect()
|
this.dryNode.disconnect()
|
||||||
|
this.wetNode.disconnect()
|
||||||
|
this.leftDelayNode.disconnect()
|
||||||
|
this.rightDelayNode.disconnect()
|
||||||
|
this.leftFeedbackNode.disconnect()
|
||||||
|
this.rightFeedbackNode.disconnect()
|
||||||
this.filterNode.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 { FilterEffect } from './FilterEffect'
|
||||||
import { FoldCrushEffect } from './FoldCrushEffect'
|
import { FoldCrushEffect } from './FoldCrushEffect'
|
||||||
import { DelayEffect } from './DelayEffect'
|
import { DelayEffect } from './DelayEffect'
|
||||||
|
import { RingModEffect } from './RingModEffect'
|
||||||
|
import { ChorusEffect } from './ChorusEffect'
|
||||||
import { ReverbEffect } from './ReverbEffect'
|
import { ReverbEffect } from './ReverbEffect'
|
||||||
import { OutputLimiter } from './OutputLimiter'
|
import { OutputLimiter } from './OutputLimiter'
|
||||||
|
|
||||||
@ -12,6 +14,8 @@ export class EffectsChain {
|
|||||||
private effects: Effect[]
|
private effects: Effect[]
|
||||||
private filterEffect: FilterEffect
|
private filterEffect: FilterEffect
|
||||||
private foldCrushEffect: FoldCrushEffect
|
private foldCrushEffect: FoldCrushEffect
|
||||||
|
private ringModEffect: RingModEffect
|
||||||
|
private chorusEffect: ChorusEffect
|
||||||
private outputLimiter: OutputLimiter
|
private outputLimiter: OutputLimiter
|
||||||
|
|
||||||
constructor(audioContext: AudioContext) {
|
constructor(audioContext: AudioContext) {
|
||||||
@ -21,9 +25,13 @@ export class EffectsChain {
|
|||||||
|
|
||||||
this.filterEffect = new FilterEffect(audioContext)
|
this.filterEffect = new FilterEffect(audioContext)
|
||||||
this.foldCrushEffect = new FoldCrushEffect(audioContext)
|
this.foldCrushEffect = new FoldCrushEffect(audioContext)
|
||||||
|
this.ringModEffect = new RingModEffect(audioContext)
|
||||||
|
this.chorusEffect = new ChorusEffect(audioContext)
|
||||||
this.outputLimiter = new OutputLimiter(audioContext)
|
this.outputLimiter = new OutputLimiter(audioContext)
|
||||||
|
|
||||||
this.effects = [
|
this.effects = [
|
||||||
|
this.ringModEffect,
|
||||||
|
this.chorusEffect,
|
||||||
this.filterEffect,
|
this.filterEffect,
|
||||||
this.foldCrushEffect,
|
this.foldCrushEffect,
|
||||||
new DelayEffect(audioContext),
|
new DelayEffect(audioContext),
|
||||||
@ -38,6 +46,8 @@ export class EffectsChain {
|
|||||||
await Promise.all([
|
await Promise.all([
|
||||||
this.filterEffect.initialize(audioContext),
|
this.filterEffect.initialize(audioContext),
|
||||||
this.foldCrushEffect.initialize(audioContext),
|
this.foldCrushEffect.initialize(audioContext),
|
||||||
|
this.ringModEffect.initialize(audioContext),
|
||||||
|
this.chorusEffect.initialize(audioContext),
|
||||||
this.outputLimiter.initialize(audioContext)
|
this.outputLimiter.initialize(audioContext)
|
||||||
])
|
])
|
||||||
}
|
}
|
||||||
|
|||||||
91
src/domain/audio/effects/FMSourceEffect.ts
Normal file
91
src/domain/audio/effects/FMSourceEffect.ts
Normal file
@ -0,0 +1,91 @@
|
|||||||
|
import type { Effect } from './Effect.interface'
|
||||||
|
import type { FMAlgorithm } from '../../../config/fmAlgorithms'
|
||||||
|
|
||||||
|
export class FMSourceEffect implements Effect {
|
||||||
|
readonly id = 'fm-source'
|
||||||
|
|
||||||
|
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, 'fm-processor')
|
||||||
|
this.processorNode.connect(this.outputNode)
|
||||||
|
}
|
||||||
|
|
||||||
|
getInputNode(): AudioNode {
|
||||||
|
return this.inputNode
|
||||||
|
}
|
||||||
|
|
||||||
|
getOutputNode(): AudioNode {
|
||||||
|
return this.outputNode
|
||||||
|
}
|
||||||
|
|
||||||
|
setBypass(): void {
|
||||||
|
// Source node doesn't support bypass
|
||||||
|
}
|
||||||
|
|
||||||
|
updateParams(): void {
|
||||||
|
// Parameters handled via specific methods
|
||||||
|
}
|
||||||
|
|
||||||
|
setAlgorithm(algorithm: FMAlgorithm, lfoRates?: number[], pitchLFO?: { waveform: number; depth: number; baseRate: number }): void {
|
||||||
|
if (!this.processorNode) return
|
||||||
|
this.processorNode.port.postMessage({
|
||||||
|
type: 'algorithm',
|
||||||
|
value: {
|
||||||
|
id: algorithm.id,
|
||||||
|
ratios: algorithm.frequencyRatios,
|
||||||
|
lfoRates: lfoRates || [0.37, 0.53, 0.71, 0.43],
|
||||||
|
pitchLFO: pitchLFO || { waveform: 0, depth: 0.1, baseRate: 2.0 }
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
setOperatorLevels(a: number, b: number, c: number, d: number): void {
|
||||||
|
if (!this.processorNode) return
|
||||||
|
this.processorNode.port.postMessage({
|
||||||
|
type: 'operatorLevels',
|
||||||
|
value: { a, b, c, d }
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
setBaseFrequency(freq: number): void {
|
||||||
|
if (!this.processorNode) return
|
||||||
|
this.processorNode.port.postMessage({ type: 'baseFreq', value: freq })
|
||||||
|
}
|
||||||
|
|
||||||
|
setFeedback(amount: number): void {
|
||||||
|
if (!this.processorNode) return
|
||||||
|
this.processorNode.port.postMessage({ type: 'feedback', value: amount })
|
||||||
|
}
|
||||||
|
|
||||||
|
setLoopLength(sampleRate: number, duration: number): void {
|
||||||
|
if (!this.processorNode) return
|
||||||
|
const loopLength = sampleRate * duration
|
||||||
|
this.processorNode.port.postMessage({ type: 'loopLength', value: loopLength })
|
||||||
|
}
|
||||||
|
|
||||||
|
setPlaybackRate(rate: number): void {
|
||||||
|
if (!this.processorNode) return
|
||||||
|
this.processorNode.port.postMessage({ type: 'playbackRate', value: rate })
|
||||||
|
}
|
||||||
|
|
||||||
|
reset(): void {
|
||||||
|
if (!this.processorNode) return
|
||||||
|
this.processorNode.port.postMessage({ type: 'reset' })
|
||||||
|
}
|
||||||
|
|
||||||
|
dispose(): void {
|
||||||
|
if (this.processorNode) {
|
||||||
|
this.processorNode.disconnect()
|
||||||
|
}
|
||||||
|
this.inputNode.disconnect()
|
||||||
|
this.outputNode.disconnect()
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -62,6 +62,9 @@ export class FoldCrushEffect implements Effect {
|
|||||||
if (values.bitcrushRate !== undefined) {
|
if (values.bitcrushRate !== undefined) {
|
||||||
this.processorNode.port.postMessage({ type: 'crushAmount', value: values.bitcrushRate })
|
this.processorNode.port.postMessage({ type: 'crushAmount', value: values.bitcrushRate })
|
||||||
}
|
}
|
||||||
|
if (values.glitchAmount !== undefined) {
|
||||||
|
this.processorNode.port.postMessage({ type: 'glitchAmount', value: values.glitchAmount })
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
dispose(): void {
|
dispose(): void {
|
||||||
|
|||||||
@ -8,202 +8,144 @@ export class ReverbEffect implements Effect {
|
|||||||
private outputNode: GainNode
|
private outputNode: GainNode
|
||||||
private wetNode: GainNode
|
private wetNode: GainNode
|
||||||
private dryNode: GainNode
|
private dryNode: GainNode
|
||||||
private mixNode: GainNode
|
|
||||||
private pannerNode: StereoPannerNode
|
private convolverA: ConvolverNode
|
||||||
private panLfoNode: OscillatorNode
|
private convolverB: ConvolverNode
|
||||||
private panLfoGainNode: GainNode
|
private gainA: GainNode
|
||||||
|
private gainB: GainNode
|
||||||
|
private activeConvolver: 'A' | 'B' = 'A'
|
||||||
|
|
||||||
private bypassed: boolean = false
|
private bypassed: boolean = false
|
||||||
private currentWetValue: number = 0
|
private currentWetValue: number = 0
|
||||||
private currentDryValue: number = 1
|
private currentDryValue: number = 1
|
||||||
private currentDecay: number = 0.5
|
private currentDecay: number = 0.7
|
||||||
private currentDamping: number = 0.5
|
private currentDamping: number = 0.5
|
||||||
|
private currentSize: number = 0.5
|
||||||
|
|
||||||
private earlyReflectionsNode: GainNode
|
private pendingDecay: number = 0.7
|
||||||
private earlyReflectionDelays: DelayNode[] = []
|
private pendingDamping: number = 0.5
|
||||||
private earlyReflectionGains: GainNode[] = []
|
private pendingSize: number = 0.5
|
||||||
private earlyReflectionFilters: BiquadFilterNode[] = []
|
private debounceTimer: number | null = null
|
||||||
|
private readonly DEBOUNCE_MS = 250
|
||||||
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
|
|
||||||
|
|
||||||
constructor(audioContext: AudioContext) {
|
constructor(audioContext: AudioContext) {
|
||||||
this.audioContext = audioContext
|
this.audioContext = audioContext
|
||||||
const sr = audioContext.sampleRate
|
|
||||||
|
|
||||||
this.inputNode = audioContext.createGain()
|
this.inputNode = audioContext.createGain()
|
||||||
this.outputNode = audioContext.createGain()
|
this.outputNode = audioContext.createGain()
|
||||||
this.mixNode = audioContext.createGain()
|
|
||||||
this.wetNode = audioContext.createGain()
|
this.wetNode = audioContext.createGain()
|
||||||
this.dryNode = audioContext.createGain()
|
this.dryNode = audioContext.createGain()
|
||||||
this.pannerNode = audioContext.createStereoPanner()
|
|
||||||
this.panLfoNode = audioContext.createOscillator()
|
this.convolverA = audioContext.createConvolver()
|
||||||
this.panLfoGainNode = audioContext.createGain()
|
this.convolverB = audioContext.createConvolver()
|
||||||
|
this.gainA = audioContext.createGain()
|
||||||
|
this.gainB = audioContext.createGain()
|
||||||
|
|
||||||
this.wetNode.gain.value = 0
|
this.wetNode.gain.value = 0
|
||||||
this.dryNode.gain.value = 1
|
this.dryNode.gain.value = 1
|
||||||
|
this.gainA.gain.value = 1
|
||||||
this.panLfoNode.frequency.value = 0
|
this.gainB.gain.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.inputNode.connect(this.dryNode)
|
this.inputNode.connect(this.dryNode)
|
||||||
this.dryNode.connect(this.mixNode)
|
this.dryNode.connect(this.outputNode)
|
||||||
this.wetNode.connect(this.mixNode)
|
|
||||||
this.mixNode.connect(this.pannerNode)
|
this.inputNode.connect(this.convolverA)
|
||||||
this.pannerNode.connect(this.outputNode)
|
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 {
|
private generateImpulseResponse(target: 'A' | 'B', decay: number, damping: number, size: number): void {
|
||||||
const primes = [17, 29, 41, 59, 71, 97, 113, 127]
|
const sampleRate = this.audioContext.sampleRate
|
||||||
const scale = sr / 48000
|
const decayTime = 0.5 + decay * 3.5
|
||||||
|
const length = Math.floor(sampleRate * decayTime)
|
||||||
|
|
||||||
for (let i = 0; i < primes.length; i++) {
|
const impulse = this.audioContext.createBuffer(2, length, sampleRate)
|
||||||
const delay = this.audioContext.createDelay(0.2)
|
const leftChannel = impulse.getChannelData(0)
|
||||||
delay.delayTime.value = (primes[i] * scale) / 1000
|
const rightChannel = impulse.getChannelData(1)
|
||||||
|
|
||||||
const gain = this.audioContext.createGain()
|
const fadeInSamples = Math.floor(0.001 * sampleRate * (0.5 + size * 1.5))
|
||||||
gain.gain.value = 0.7 * Math.pow(0.85, i)
|
|
||||||
|
|
||||||
const filter = this.audioContext.createBiquadFilter()
|
const dampingFreq = 1000 + damping * 8000
|
||||||
filter.type = i % 2 === 0 ? 'lowpass' : 'highshelf'
|
const dampingCoeff = Math.exp(-2 * Math.PI * dampingFreq / sampleRate)
|
||||||
filter.frequency.value = 3000 + i * 500
|
|
||||||
filter.gain.value = -2 * i
|
|
||||||
|
|
||||||
this.earlyReflectionDelays.push(delay)
|
let leftLPState = 0
|
||||||
this.earlyReflectionGains.push(gain)
|
let rightLPState = 0
|
||||||
this.earlyReflectionFilters.push(filter)
|
|
||||||
|
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 {
|
private crossfadeToStandby(): void {
|
||||||
this.inputNode.connect(this.earlyReflectionsNode)
|
const now = this.audioContext.currentTime
|
||||||
|
const crossfadeDuration = 0.02
|
||||||
|
|
||||||
for (let i = 0; i < this.earlyReflectionDelays.length; i++) {
|
if (this.activeConvolver === 'A') {
|
||||||
this.earlyReflectionsNode.connect(this.earlyReflectionDelays[i])
|
this.gainA.gain.setValueAtTime(1, now)
|
||||||
this.earlyReflectionDelays[i].connect(this.earlyReflectionFilters[i])
|
this.gainA.gain.exponentialRampToValueAtTime(0.001, now + crossfadeDuration)
|
||||||
this.earlyReflectionFilters[i].connect(this.earlyReflectionGains[i])
|
|
||||||
this.earlyReflectionGains[i].connect(this.wetNode)
|
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)
|
private scheduleRegeneration(): void {
|
||||||
this.earlyReflectionsNode.connect(this.midBandHighPass)
|
if (this.debounceTimer !== null) {
|
||||||
this.earlyReflectionsNode.connect(this.highBandSplitter)
|
clearTimeout(this.debounceTimer)
|
||||||
|
|
||||||
this.midBandHighPass.connect(this.midBandLowPass)
|
|
||||||
|
|
||||||
this.lowBandSplitter.connect(this.lowBandProcessor.getInputNode())
|
|
||||||
this.midBandLowPass.connect(this.midBandProcessor.getInputNode())
|
|
||||||
this.highBandSplitter.connect(this.highBandProcessor.getInputNode())
|
|
||||||
|
|
||||||
this.lowBandProcessor.getOutputNode().connect(this.lowEnvFollower)
|
|
||||||
this.midBandProcessor.getOutputNode().connect(this.midEnvFollower)
|
|
||||||
this.highBandProcessor.getOutputNode().connect(this.highEnvFollower)
|
|
||||||
|
|
||||||
this.lowEnvFollower.connect(this.lowToHighModGain)
|
|
||||||
this.highEnvFollower.connect(this.highToLowModGain)
|
|
||||||
this.midEnvFollower.connect(this.midToGlobalModGain)
|
|
||||||
|
|
||||||
this.lowToHighModGain.connect(this.highBandProcessor.getModulationTarget())
|
|
||||||
this.highToLowModGain.connect(this.lowBandProcessor.getModulationTarget())
|
|
||||||
|
|
||||||
this.lowBandProcessor.getOutputNode().connect(this.bandMixer)
|
|
||||||
this.midBandProcessor.getOutputNode().connect(this.bandMixer)
|
|
||||||
this.highBandProcessor.getOutputNode().connect(this.bandMixer)
|
|
||||||
|
|
||||||
this.bandMixer.connect(this.wetNode)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private updateDecayAndDamping(): void {
|
this.debounceTimer = window.setTimeout(() => {
|
||||||
const decay = this.currentDecay
|
this.currentDecay = this.pendingDecay
|
||||||
const damping = this.currentDamping
|
this.currentDamping = this.pendingDamping
|
||||||
|
this.currentSize = this.pendingSize
|
||||||
|
|
||||||
this.lowBandProcessor.setDecay(decay * 1.2)
|
const standbyConvolver = this.activeConvolver === 'A' ? 'B' : 'A'
|
||||||
this.midBandProcessor.setDecay(decay)
|
this.generateImpulseResponse(standbyConvolver, this.currentDecay, this.currentDamping, this.currentSize)
|
||||||
this.highBandProcessor.setDecay(decay * 0.6)
|
|
||||||
|
|
||||||
this.lowBandProcessor.setDamping(damping * 0.5)
|
setTimeout(() => {
|
||||||
this.midBandProcessor.setDamping(damping)
|
this.crossfadeToStandby()
|
||||||
this.highBandProcessor.setDamping(damping * 1.5)
|
}, 10)
|
||||||
|
|
||||||
const modAmount = 0.3
|
this.debounceTimer = null
|
||||||
this.lowToHighModGain.gain.value = modAmount
|
}, this.DEBOUNCE_MS)
|
||||||
this.highToLowModGain.gain.value = modAmount * 0.7
|
|
||||||
this.midToGlobalModGain.gain.value = modAmount * 0.5
|
|
||||||
}
|
}
|
||||||
|
|
||||||
getInputNode(): AudioNode {
|
getInputNode(): AudioNode {
|
||||||
@ -226,16 +168,21 @@ export class ReverbEffect implements Effect {
|
|||||||
}
|
}
|
||||||
|
|
||||||
updateParams(values: Record<string, number | string>): void {
|
updateParams(values: Record<string, number | string>): void {
|
||||||
let needsUpdate = false
|
let needsRegenerate = false
|
||||||
|
|
||||||
if (values.reverbDecay !== undefined && typeof values.reverbDecay === 'number') {
|
if (values.reverbDecay !== undefined && typeof values.reverbDecay === 'number') {
|
||||||
this.currentDecay = values.reverbDecay / 100
|
this.pendingDecay = values.reverbDecay / 100
|
||||||
needsUpdate = true
|
needsRegenerate = true
|
||||||
}
|
}
|
||||||
|
|
||||||
if (values.reverbDamping !== undefined && typeof values.reverbDamping === 'number') {
|
if (values.reverbDamping !== undefined && typeof values.reverbDamping === 'number') {
|
||||||
this.currentDamping = values.reverbDamping / 100
|
this.pendingDamping = values.reverbDamping / 100
|
||||||
needsUpdate = true
|
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') {
|
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') {
|
if (needsRegenerate) {
|
||||||
const rate = values.reverbPanRate
|
this.scheduleRegeneration()
|
||||||
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()
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
dispose(): void {
|
dispose(): void {
|
||||||
this.panLfoNode.stop()
|
if (this.debounceTimer !== null) {
|
||||||
this.panLfoNode.disconnect()
|
clearTimeout(this.debounceTimer)
|
||||||
this.panLfoGainNode.disconnect()
|
}
|
||||||
|
|
||||||
this.inputNode.disconnect()
|
this.inputNode.disconnect()
|
||||||
this.outputNode.disconnect()
|
this.outputNode.disconnect()
|
||||||
this.mixNode.disconnect()
|
|
||||||
this.wetNode.disconnect()
|
this.wetNode.disconnect()
|
||||||
this.dryNode.disconnect()
|
this.dryNode.disconnect()
|
||||||
this.pannerNode.disconnect()
|
this.convolverA.disconnect()
|
||||||
this.earlyReflectionsNode.disconnect()
|
this.convolverB.disconnect()
|
||||||
|
this.gainA.disconnect()
|
||||||
this.earlyReflectionDelays.forEach(d => d.disconnect())
|
this.gainB.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()
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
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()
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -1,4 +1,4 @@
|
|||||||
import { ENGINE_CONTROLS, EFFECTS } from '../../config/effects'
|
import { ENGINE_CONTROLS, EFFECTS } from '../../config/parameters'
|
||||||
import type { EffectParameter } from '../../types/effects'
|
import type { EffectParameter } from '../../types/effects'
|
||||||
|
|
||||||
export type ParameterScaling = 'linear' | 'exponential'
|
export type ParameterScaling = 'linear' | 'exponential'
|
||||||
|
|||||||
7
src/hooks/index.ts
Normal file
7
src/hooks/index.ts
Normal file
@ -0,0 +1,7 @@
|
|||||||
|
export { useKeyboardShortcuts } from './useKeyboardShortcuts'
|
||||||
|
export { useTileParams } from './useTileParams'
|
||||||
|
export { useTileGrid } from './useTileGrid'
|
||||||
|
export { usePlaybackControl } from './usePlaybackControl'
|
||||||
|
export { useFocusNavigation } from './useFocusNavigation'
|
||||||
|
export { useParameterSync } from './useParameterSync'
|
||||||
|
export { useLFOMapping } from './useLFOMapping'
|
||||||
66
src/hooks/useFocusNavigation.ts
Normal file
66
src/hooks/useFocusNavigation.ts
Normal file
@ -0,0 +1,66 @@
|
|||||||
|
import { useState, useEffect, useCallback } from 'react'
|
||||||
|
import type { TileState } from '../types/tiles'
|
||||||
|
import type { FocusedTile } from '../utils/tileHelpers'
|
||||||
|
|
||||||
|
interface UseFocusNavigationProps {
|
||||||
|
tiles: TileState[][]
|
||||||
|
onFocusChange?: (tile: FocusedTile) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useFocusNavigation({ tiles, onFocusChange }: UseFocusNavigationProps) {
|
||||||
|
const [focusedTile, setFocusedTile] = useState<FocusedTile>({ row: 0, col: 0 })
|
||||||
|
|
||||||
|
const moveFocus = useCallback((direction: 'up' | 'down' | 'left' | 'right', step: number = 1) => {
|
||||||
|
setFocusedTile(prev => {
|
||||||
|
if (prev === 'custom') return prev
|
||||||
|
|
||||||
|
let { row, col } = prev
|
||||||
|
const maxRow = tiles.length - 1
|
||||||
|
const maxCol = (tiles[row]?.length || 1) - 1
|
||||||
|
|
||||||
|
switch (direction) {
|
||||||
|
case 'up':
|
||||||
|
row = Math.max(0, row - step)
|
||||||
|
break
|
||||||
|
case 'down':
|
||||||
|
row = Math.min(maxRow, row + step)
|
||||||
|
break
|
||||||
|
case 'left':
|
||||||
|
col = Math.max(0, col - step)
|
||||||
|
break
|
||||||
|
case 'right':
|
||||||
|
col = Math.min(maxCol, col + step)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
const newTile = tiles[row]?.[col]
|
||||||
|
if (newTile) {
|
||||||
|
const newFocus = { row, col }
|
||||||
|
onFocusChange?.(newFocus)
|
||||||
|
return newFocus
|
||||||
|
}
|
||||||
|
|
||||||
|
return prev
|
||||||
|
})
|
||||||
|
}, [tiles, onFocusChange])
|
||||||
|
|
||||||
|
const setFocus = useCallback((tile: FocusedTile) => {
|
||||||
|
setFocusedTile(tile)
|
||||||
|
onFocusChange?.(tile)
|
||||||
|
}, [onFocusChange])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (focusedTile !== 'custom') {
|
||||||
|
const element = document.querySelector(`[data-tile-id="${focusedTile.row}-${focusedTile.col}"]`)
|
||||||
|
if (element) {
|
||||||
|
element.scrollIntoView({ behavior: 'smooth', block: 'nearest' })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [focusedTile])
|
||||||
|
|
||||||
|
return {
|
||||||
|
focusedTile,
|
||||||
|
setFocus,
|
||||||
|
moveFocus
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -12,6 +12,7 @@ export interface KeyboardShortcutHandlers {
|
|||||||
onShiftR?: () => void
|
onShiftR?: () => void
|
||||||
onC?: () => void
|
onC?: () => void
|
||||||
onShiftC?: () => void
|
onShiftC?: () => void
|
||||||
|
onI?: () => void
|
||||||
onEscape?: () => void
|
onEscape?: () => void
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -92,6 +93,12 @@ export function useKeyboardShortcuts(handlers: KeyboardShortcutHandlers) {
|
|||||||
}
|
}
|
||||||
break
|
break
|
||||||
|
|
||||||
|
case 'i':
|
||||||
|
case 'I':
|
||||||
|
e.preventDefault()
|
||||||
|
h.onI?.()
|
||||||
|
break
|
||||||
|
|
||||||
case 'Escape':
|
case 'Escape':
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
h.onEscape?.()
|
h.onEscape?.()
|
||||||
|
|||||||
100
src/hooks/useLFOMapping.ts
Normal file
100
src/hooks/useLFOMapping.ts
Normal file
@ -0,0 +1,100 @@
|
|||||||
|
import { useCallback } from 'react'
|
||||||
|
import { lfoSettings, type LFOConfig } from '../stores/settings'
|
||||||
|
import { exitMappingMode } from '../stores/mappingMode'
|
||||||
|
import type { PlaybackManager } from '../services/PlaybackManager'
|
||||||
|
|
||||||
|
interface UseLFOMappingProps {
|
||||||
|
playbackManager: React.MutableRefObject<PlaybackManager | null>
|
||||||
|
saveCurrentParams: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useLFOMapping({ playbackManager, saveCurrentParams }: UseLFOMappingProps) {
|
||||||
|
|
||||||
|
const handleLFOChange = useCallback((lfoIndex: number, config: LFOConfig) => {
|
||||||
|
if (playbackManager.current) {
|
||||||
|
playbackManager.current.setLFOConfig(lfoIndex, config)
|
||||||
|
}
|
||||||
|
}, [playbackManager])
|
||||||
|
|
||||||
|
const handleParameterMapClick = useCallback((paramId: string, lfoIndex: number) => {
|
||||||
|
const lfoKey = `lfo${lfoIndex + 1}` as 'lfo1' | 'lfo2' | 'lfo3' | 'lfo4'
|
||||||
|
const currentLFO = lfoSettings.get()[lfoKey]
|
||||||
|
|
||||||
|
const existingMappingIndex = currentLFO.mappings.findIndex(m => m.targetParam === paramId)
|
||||||
|
|
||||||
|
let updatedMappings
|
||||||
|
if (existingMappingIndex >= 0) {
|
||||||
|
updatedMappings = currentLFO.mappings.filter((_, i) => i !== existingMappingIndex)
|
||||||
|
} else {
|
||||||
|
updatedMappings = [...currentLFO.mappings, { targetParam: paramId, depth: 50 }]
|
||||||
|
}
|
||||||
|
|
||||||
|
const updatedLFO = { ...currentLFO, mappings: updatedMappings }
|
||||||
|
lfoSettings.setKey(lfoKey, updatedLFO)
|
||||||
|
|
||||||
|
if (playbackManager.current) {
|
||||||
|
playbackManager.current.setLFOConfig(lfoIndex, updatedLFO)
|
||||||
|
}
|
||||||
|
|
||||||
|
saveCurrentParams()
|
||||||
|
|
||||||
|
if (updatedMappings.length === 0 || existingMappingIndex >= 0) {
|
||||||
|
exitMappingMode()
|
||||||
|
}
|
||||||
|
}, [playbackManager, saveCurrentParams])
|
||||||
|
|
||||||
|
const handleUpdateMappingDepth = useCallback((lfoIndex: number, paramId: string, depth: number) => {
|
||||||
|
const lfoKey = `lfo${lfoIndex + 1}` as 'lfo1' | 'lfo2' | 'lfo3' | 'lfo4'
|
||||||
|
const currentLFO = lfoSettings.get()[lfoKey]
|
||||||
|
|
||||||
|
const updatedMappings = currentLFO.mappings.map(m =>
|
||||||
|
m.targetParam === paramId ? { ...m, depth } : m
|
||||||
|
)
|
||||||
|
|
||||||
|
const updatedLFO = { ...currentLFO, mappings: updatedMappings }
|
||||||
|
lfoSettings.setKey(lfoKey, updatedLFO)
|
||||||
|
|
||||||
|
if (playbackManager.current) {
|
||||||
|
playbackManager.current.setLFOConfig(lfoIndex, updatedLFO)
|
||||||
|
}
|
||||||
|
|
||||||
|
saveCurrentParams()
|
||||||
|
}, [playbackManager, saveCurrentParams])
|
||||||
|
|
||||||
|
const handleRemoveMapping = useCallback((lfoIndex: number, paramId: string) => {
|
||||||
|
const lfoKey = `lfo${lfoIndex + 1}` as 'lfo1' | 'lfo2' | 'lfo3' | 'lfo4'
|
||||||
|
const currentLFO = lfoSettings.get()[lfoKey]
|
||||||
|
|
||||||
|
const updatedMappings = currentLFO.mappings.filter(m => m.targetParam !== paramId)
|
||||||
|
|
||||||
|
const updatedLFO = { ...currentLFO, mappings: updatedMappings }
|
||||||
|
lfoSettings.setKey(lfoKey, updatedLFO)
|
||||||
|
|
||||||
|
if (playbackManager.current) {
|
||||||
|
playbackManager.current.setLFOConfig(lfoIndex, updatedLFO)
|
||||||
|
}
|
||||||
|
|
||||||
|
saveCurrentParams()
|
||||||
|
}, [playbackManager, saveCurrentParams])
|
||||||
|
|
||||||
|
const getMappedLFOs = useCallback((paramId: string): number[] => {
|
||||||
|
const lfos = lfoSettings.get()
|
||||||
|
const mapped: number[] = []
|
||||||
|
|
||||||
|
Object.entries(lfos).forEach(([, lfo], index) => {
|
||||||
|
if (lfo.mappings.some((m: { targetParam: string }) => m.targetParam === paramId)) {
|
||||||
|
mapped.push(index)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
return mapped
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
return {
|
||||||
|
handleLFOChange,
|
||||||
|
handleParameterMapClick,
|
||||||
|
handleUpdateMappingDepth,
|
||||||
|
handleRemoveMapping,
|
||||||
|
getMappedLFOs
|
||||||
|
}
|
||||||
|
}
|
||||||
409
src/hooks/useParameterSync.ts
Normal file
409
src/hooks/useParameterSync.ts
Normal file
@ -0,0 +1,409 @@
|
|||||||
|
import { useCallback, useRef } from 'react'
|
||||||
|
import type { TileState } from '../types/tiles'
|
||||||
|
import type { FocusedTile } from '../utils/tileHelpers'
|
||||||
|
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[][]
|
||||||
|
setTiles: React.Dispatch<React.SetStateAction<TileState[][]>>
|
||||||
|
customTile: TileState
|
||||||
|
setCustomTile: React.Dispatch<React.SetStateAction<TileState>>
|
||||||
|
focusedTile: FocusedTile
|
||||||
|
playbackManager: React.MutableRefObject<PlaybackManager | null>
|
||||||
|
playing: string | null
|
||||||
|
playbackId: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useParameterSync({
|
||||||
|
tiles,
|
||||||
|
setTiles,
|
||||||
|
customTile,
|
||||||
|
setCustomTile,
|
||||||
|
focusedTile,
|
||||||
|
playbackManager,
|
||||||
|
playing,
|
||||||
|
playbackId
|
||||||
|
}: UseParameterSyncProps) {
|
||||||
|
const interpolationRef = useRef<number | null>(null)
|
||||||
|
|
||||||
|
const saveCurrentParams = useCallback(() => {
|
||||||
|
if (focusedTile === 'custom') {
|
||||||
|
setCustomTile(saveTileParams(customTile))
|
||||||
|
} else {
|
||||||
|
const currentTile = getTileFromGrid(tiles, focusedTile.row, focusedTile.col)
|
||||||
|
if (currentTile) {
|
||||||
|
const updatedTile = saveTileParams(currentTile)
|
||||||
|
setTiles(prevTiles => {
|
||||||
|
const newTiles = [...prevTiles]
|
||||||
|
newTiles[focusedTile.row] = [...newTiles[focusedTile.row]]
|
||||||
|
newTiles[focusedTile.row][focusedTile.col] = updatedTile
|
||||||
|
return newTiles
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [focusedTile, tiles, setTiles, customTile, setCustomTile])
|
||||||
|
|
||||||
|
const loadParams = useCallback((tile: TileState) => {
|
||||||
|
loadTileParams(tile)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const handleEngineChange = useCallback((parameterId: string, value: number) => {
|
||||||
|
engineSettings.setKey(parameterId as any, value)
|
||||||
|
saveCurrentParams()
|
||||||
|
|
||||||
|
if (parameterId === 'masterVolume' && playbackManager.current) {
|
||||||
|
const effectValues = effectSettings.get()
|
||||||
|
playbackManager.current.setEffects({ ...effectValues, masterVolume: value })
|
||||||
|
}
|
||||||
|
|
||||||
|
if (parameterId === 'pitch' && playbackManager.current && playing) {
|
||||||
|
playbackManager.current.setPitch(value)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (parameterId === 'fmAlgorithm' && playbackManager.current) {
|
||||||
|
playbackManager.current.setAlgorithm(value)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (parameterId === 'fmFeedback' && playbackManager.current) {
|
||||||
|
playbackManager.current.setFeedback(value)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (['a', 'b', 'c', 'd'].includes(parameterId) && playbackManager.current && playing) {
|
||||||
|
const engineValues = engineSettings.get()
|
||||||
|
const updatedValues = { ...engineValues, [parameterId]: value }
|
||||||
|
playbackManager.current.setVariables(
|
||||||
|
updatedValues.a ?? DEFAULT_VARIABLES.a,
|
||||||
|
updatedValues.b ?? DEFAULT_VARIABLES.b,
|
||||||
|
updatedValues.c ?? DEFAULT_VARIABLES.c,
|
||||||
|
updatedValues.d ?? DEFAULT_VARIABLES.d
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}, [saveCurrentParams, playbackManager, playing])
|
||||||
|
|
||||||
|
const handleEffectChange = useCallback((parameterId: string, value: number | boolean | string) => {
|
||||||
|
effectSettings.setKey(parameterId as any, value as never)
|
||||||
|
saveCurrentParams()
|
||||||
|
|
||||||
|
if (playbackManager.current) {
|
||||||
|
playbackManager.current.setEffects(effectSettings.get())
|
||||||
|
}
|
||||||
|
}, [saveCurrentParams, playbackManager])
|
||||||
|
|
||||||
|
const randomizeParams = useCallback((tileId: string) => {
|
||||||
|
if (focusedTile === 'custom') {
|
||||||
|
setCustomTile(prev => {
|
||||||
|
const randomized = randomizeTileParams(prev)
|
||||||
|
loadTileParams(randomized)
|
||||||
|
|
||||||
|
if (playing === playbackId && playbackManager.current) {
|
||||||
|
playbackManager.current.setEffects(randomized.effectParams)
|
||||||
|
playbackManager.current.setVariables(
|
||||||
|
randomized.engineParams.a ?? DEFAULT_VARIABLES.a,
|
||||||
|
randomized.engineParams.b ?? DEFAULT_VARIABLES.b,
|
||||||
|
randomized.engineParams.c ?? DEFAULT_VARIABLES.c,
|
||||||
|
randomized.engineParams.d ?? DEFAULT_VARIABLES.d
|
||||||
|
)
|
||||||
|
playbackManager.current.setPitch(randomized.engineParams.pitch ?? 1.0)
|
||||||
|
|
||||||
|
if (randomized.lfoConfigs) {
|
||||||
|
playbackManager.current.setLFOConfig(0, randomized.lfoConfigs.lfo1)
|
||||||
|
playbackManager.current.setLFOConfig(1, randomized.lfoConfigs.lfo2)
|
||||||
|
playbackManager.current.setLFOConfig(2, randomized.lfoConfigs.lfo3)
|
||||||
|
playbackManager.current.setLFOConfig(3, randomized.lfoConfigs.lfo4)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return randomized
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
setTiles(prevTiles => {
|
||||||
|
const newTiles = [...prevTiles]
|
||||||
|
newTiles[focusedTile.row] = [...newTiles[focusedTile.row]]
|
||||||
|
const randomized = randomizeTileParams(newTiles[focusedTile.row][focusedTile.col])
|
||||||
|
newTiles[focusedTile.row][focusedTile.col] = randomized
|
||||||
|
|
||||||
|
loadTileParams(randomized)
|
||||||
|
|
||||||
|
if (playing === tileId && playbackManager.current) {
|
||||||
|
playbackManager.current.setEffects(randomized.effectParams)
|
||||||
|
playbackManager.current.setVariables(
|
||||||
|
randomized.engineParams.a ?? DEFAULT_VARIABLES.a,
|
||||||
|
randomized.engineParams.b ?? DEFAULT_VARIABLES.b,
|
||||||
|
randomized.engineParams.c ?? DEFAULT_VARIABLES.c,
|
||||||
|
randomized.engineParams.d ?? DEFAULT_VARIABLES.d
|
||||||
|
)
|
||||||
|
playbackManager.current.setPitch(randomized.engineParams.pitch ?? 1.0)
|
||||||
|
|
||||||
|
if (randomized.lfoConfigs) {
|
||||||
|
playbackManager.current.setLFOConfig(0, randomized.lfoConfigs.lfo1)
|
||||||
|
playbackManager.current.setLFOConfig(1, randomized.lfoConfigs.lfo2)
|
||||||
|
playbackManager.current.setLFOConfig(2, randomized.lfoConfigs.lfo3)
|
||||||
|
playbackManager.current.setLFOConfig(3, randomized.lfoConfigs.lfo4)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return newTiles
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}, [focusedTile, setCustomTile, setTiles, playing, playbackId, playbackManager])
|
||||||
|
|
||||||
|
const randomizeAllParams = useCallback(() => {
|
||||||
|
if (!playbackManager.current) return
|
||||||
|
|
||||||
|
let playingTile: TileState | undefined
|
||||||
|
|
||||||
|
if (playing === playbackId) {
|
||||||
|
setCustomTile(prev => {
|
||||||
|
const randomized = randomizeTileParams(prev)
|
||||||
|
playingTile = randomized
|
||||||
|
return randomized
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
setTiles(prevTiles => {
|
||||||
|
const newTiles = prevTiles.map((row, rowIdx) =>
|
||||||
|
row.map((tile, colIdx) => {
|
||||||
|
const randomized = randomizeTileParams(tile)
|
||||||
|
if (playing && focusedTile !== 'custom') {
|
||||||
|
const tileId = `${focusedTile.row}-${focusedTile.col}`
|
||||||
|
if (playing === tileId && rowIdx === focusedTile.row && colIdx === focusedTile.col) {
|
||||||
|
playingTile = randomized
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return randomized
|
||||||
|
})
|
||||||
|
)
|
||||||
|
return newTiles
|
||||||
|
})
|
||||||
|
|
||||||
|
setCustomTile(prev => randomizeTileParams(prev))
|
||||||
|
}
|
||||||
|
|
||||||
|
if (playingTile) {
|
||||||
|
loadTileParams(playingTile)
|
||||||
|
playbackManager.current.setEffects(playingTile.effectParams)
|
||||||
|
playbackManager.current.setVariables(
|
||||||
|
playingTile.engineParams.a ?? DEFAULT_VARIABLES.a,
|
||||||
|
playingTile.engineParams.b ?? DEFAULT_VARIABLES.b,
|
||||||
|
playingTile.engineParams.c ?? DEFAULT_VARIABLES.c,
|
||||||
|
playingTile.engineParams.d ?? DEFAULT_VARIABLES.d
|
||||||
|
)
|
||||||
|
playbackManager.current.setPitch(playingTile.engineParams.pitch ?? 1.0)
|
||||||
|
|
||||||
|
if (playingTile.lfoConfigs) {
|
||||||
|
playbackManager.current.setLFOConfig(0, playingTile.lfoConfigs.lfo1)
|
||||||
|
playbackManager.current.setLFOConfig(1, playingTile.lfoConfigs.lfo2)
|
||||||
|
playbackManager.current.setLFOConfig(2, playingTile.lfoConfigs.lfo3)
|
||||||
|
playbackManager.current.setLFOConfig(3, playingTile.lfoConfigs.lfo4)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [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,
|
||||||
|
interpolateParams
|
||||||
|
}
|
||||||
|
}
|
||||||
126
src/hooks/usePlaybackControl.ts
Normal file
126
src/hooks/usePlaybackControl.ts
Normal file
@ -0,0 +1,126 @@
|
|||||||
|
import { useState, useRef, useEffect, useCallback } from 'react'
|
||||||
|
import { useStore } from '@nanostores/react'
|
||||||
|
import type { TileState } from '../types/tiles'
|
||||||
|
import type { SynthesisMode } from '../stores/synthesisMode'
|
||||||
|
import { PlaybackManager } from '../services/PlaybackManager'
|
||||||
|
import { engineSettings, effectSettings } from '../stores/settings'
|
||||||
|
import { getSampleRateFromIndex } from '../config/parameters'
|
||||||
|
import { DEFAULT_VARIABLES, LOOP_DURATION } from '../constants/defaults'
|
||||||
|
|
||||||
|
interface UsePlaybackControlProps {
|
||||||
|
mode: SynthesisMode
|
||||||
|
onPlaybackPositionUpdate?: (position: number) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export function usePlaybackControl({ mode, onPlaybackPositionUpdate }: UsePlaybackControlProps) {
|
||||||
|
const engineValues = useStore(engineSettings)
|
||||||
|
const effectValues = useStore(effectSettings)
|
||||||
|
|
||||||
|
const [playing, setPlaying] = useState<string | null>(null)
|
||||||
|
const [queued, setQueued] = useState<string | null>(null)
|
||||||
|
const [playbackPosition, setPlaybackPosition] = useState<number>(0)
|
||||||
|
|
||||||
|
const playbackManagerRef = useRef<PlaybackManager | null>(null)
|
||||||
|
const switchTimerRef = useRef<number | null>(null)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (playbackManagerRef.current) {
|
||||||
|
playbackManagerRef.current.setPlaybackPositionCallback((position) => {
|
||||||
|
setPlaybackPosition(position)
|
||||||
|
onPlaybackPositionUpdate?.(position)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}, [onPlaybackPositionUpdate])
|
||||||
|
|
||||||
|
const clearSwitchTimer = useCallback(() => {
|
||||||
|
if (switchTimerRef.current !== null) {
|
||||||
|
clearTimeout(switchTimerRef.current)
|
||||||
|
switchTimerRef.current = null
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const startSwitchTimer = useCallback((callback: () => void) => {
|
||||||
|
clearSwitchTimer()
|
||||||
|
switchTimerRef.current = window.setTimeout(callback, engineValues.loopCount * 1000)
|
||||||
|
}, [engineValues.loopCount, clearSwitchTimer])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
return () => clearSwitchTimer()
|
||||||
|
}, [clearSwitchTimer])
|
||||||
|
|
||||||
|
const play = useCallback(async (formula: string, id: string, tile?: TileState) => {
|
||||||
|
const sampleRate = getSampleRateFromIndex(engineValues.sampleRate)
|
||||||
|
const duration = LOOP_DURATION
|
||||||
|
|
||||||
|
if (!playbackManagerRef.current) {
|
||||||
|
playbackManagerRef.current = new PlaybackManager({ sampleRate, duration })
|
||||||
|
playbackManagerRef.current.setMode(mode)
|
||||||
|
} else {
|
||||||
|
await playbackManagerRef.current.updateOptions({ sampleRate, duration })
|
||||||
|
}
|
||||||
|
|
||||||
|
playbackManagerRef.current.stop()
|
||||||
|
playbackManagerRef.current.setEffects(effectValues)
|
||||||
|
playbackManagerRef.current.setVariables(
|
||||||
|
engineValues.a ?? DEFAULT_VARIABLES.a,
|
||||||
|
engineValues.b ?? DEFAULT_VARIABLES.b,
|
||||||
|
engineValues.c ?? DEFAULT_VARIABLES.c,
|
||||||
|
engineValues.d ?? DEFAULT_VARIABLES.d
|
||||||
|
)
|
||||||
|
playbackManagerRef.current.setPitch(engineValues.pitch ?? 1.0)
|
||||||
|
|
||||||
|
const fmPatch = mode === 'fm' ? JSON.parse(formula) : null
|
||||||
|
const lfoRates = fmPatch?.lfoRates || undefined
|
||||||
|
playbackManagerRef.current.setAlgorithm(engineValues.fmAlgorithm ?? 0, lfoRates)
|
||||||
|
playbackManagerRef.current.setFeedback(engineValues.fmFeedback ?? 0)
|
||||||
|
|
||||||
|
if (tile?.lfoConfigs) {
|
||||||
|
playbackManagerRef.current.setLFOConfig(0, tile.lfoConfigs.lfo1)
|
||||||
|
playbackManagerRef.current.setLFOConfig(1, tile.lfoConfigs.lfo2)
|
||||||
|
playbackManagerRef.current.setLFOConfig(2, tile.lfoConfigs.lfo3)
|
||||||
|
playbackManagerRef.current.setLFOConfig(3, tile.lfoConfigs.lfo4)
|
||||||
|
}
|
||||||
|
|
||||||
|
await playbackManagerRef.current.play(formula)
|
||||||
|
setPlaying(id)
|
||||||
|
setQueued(null)
|
||||||
|
}, [mode, engineValues, effectValues])
|
||||||
|
|
||||||
|
const stop = useCallback(() => {
|
||||||
|
clearSwitchTimer()
|
||||||
|
playbackManagerRef.current?.stop()
|
||||||
|
setPlaying(null)
|
||||||
|
setQueued(null)
|
||||||
|
setPlaybackPosition(0)
|
||||||
|
}, [clearSwitchTimer])
|
||||||
|
|
||||||
|
const queue = useCallback((id: string, callback: () => void) => {
|
||||||
|
setQueued(id)
|
||||||
|
startSwitchTimer(callback)
|
||||||
|
}, [startSwitchTimer])
|
||||||
|
|
||||||
|
const cancelQueue = useCallback(() => {
|
||||||
|
clearSwitchTimer()
|
||||||
|
setQueued(null)
|
||||||
|
}, [clearSwitchTimer])
|
||||||
|
|
||||||
|
const updateMode = useCallback((newMode: SynthesisMode) => {
|
||||||
|
if (playbackManagerRef.current) {
|
||||||
|
playbackManagerRef.current.setMode(newMode)
|
||||||
|
playbackManagerRef.current.setAlgorithm(engineValues.fmAlgorithm ?? 0)
|
||||||
|
playbackManagerRef.current.setFeedback(engineValues.fmFeedback ?? 0)
|
||||||
|
}
|
||||||
|
}, [engineValues.fmAlgorithm, engineValues.fmFeedback])
|
||||||
|
|
||||||
|
return {
|
||||||
|
playing,
|
||||||
|
queued,
|
||||||
|
playbackPosition,
|
||||||
|
playbackManager: playbackManagerRef,
|
||||||
|
play,
|
||||||
|
stop,
|
||||||
|
queue,
|
||||||
|
cancelQueue,
|
||||||
|
updateMode
|
||||||
|
}
|
||||||
|
}
|
||||||
68
src/hooks/useTileGrid.ts
Normal file
68
src/hooks/useTileGrid.ts
Normal file
@ -0,0 +1,68 @@
|
|||||||
|
import { useState, useCallback } from 'react'
|
||||||
|
import { useStore } from '@nanostores/react'
|
||||||
|
import type { TileState } from '../types/tiles'
|
||||||
|
import { synthesisMode, setSynthesisMode, type SynthesisMode } from '../stores/synthesisMode'
|
||||||
|
import { engineSettings } from '../stores/settings'
|
||||||
|
import { generateTileGrid, generateRandomFormula } from '../utils/bytebeatFormulas'
|
||||||
|
import { generateFMTileGrid, generateRandomFMPatch, createFMTileState } from '../utils/fmPatches'
|
||||||
|
import { createTileStateFromCurrent } from '../utils/tileState'
|
||||||
|
import { TILE_GRID } from '../constants/defaults'
|
||||||
|
|
||||||
|
export function useTileGrid() {
|
||||||
|
const mode = useStore(synthesisMode)
|
||||||
|
const engineValues = useStore(engineSettings)
|
||||||
|
|
||||||
|
const [tiles, setTiles] = useState<TileState[][]>(() =>
|
||||||
|
mode === 'fm'
|
||||||
|
? generateFMTileGrid(TILE_GRID.SIZE, TILE_GRID.COLUMNS, engineValues.complexity)
|
||||||
|
: generateTileGrid(TILE_GRID.SIZE, TILE_GRID.COLUMNS, engineValues.complexity)
|
||||||
|
)
|
||||||
|
|
||||||
|
const regenerateAll = useCallback(() => {
|
||||||
|
if (mode === 'fm') {
|
||||||
|
setTiles(generateFMTileGrid(TILE_GRID.SIZE, TILE_GRID.COLUMNS, engineValues.complexity))
|
||||||
|
} else {
|
||||||
|
setTiles(generateTileGrid(TILE_GRID.SIZE, TILE_GRID.COLUMNS, engineValues.complexity))
|
||||||
|
}
|
||||||
|
}, [mode, engineValues.complexity])
|
||||||
|
|
||||||
|
const regenerateTile = useCallback((row: number, col: number): TileState => {
|
||||||
|
let newTile: TileState
|
||||||
|
|
||||||
|
if (mode === 'fm') {
|
||||||
|
const patch = generateRandomFMPatch(engineValues.complexity)
|
||||||
|
newTile = createFMTileState(patch)
|
||||||
|
} else {
|
||||||
|
const newFormula = generateRandomFormula(engineValues.complexity)
|
||||||
|
newTile = createTileStateFromCurrent(newFormula)
|
||||||
|
}
|
||||||
|
|
||||||
|
setTiles(prevTiles => {
|
||||||
|
const newTiles = [...prevTiles]
|
||||||
|
newTiles[row] = [...newTiles[row]]
|
||||||
|
newTiles[row][col] = newTile
|
||||||
|
return newTiles
|
||||||
|
})
|
||||||
|
|
||||||
|
return newTile
|
||||||
|
}, [mode, engineValues.complexity])
|
||||||
|
|
||||||
|
const switchMode = useCallback((newMode: SynthesisMode) => {
|
||||||
|
setSynthesisMode(newMode)
|
||||||
|
|
||||||
|
if (newMode === 'fm') {
|
||||||
|
setTiles(generateFMTileGrid(TILE_GRID.SIZE, TILE_GRID.COLUMNS, engineValues.complexity))
|
||||||
|
} else {
|
||||||
|
setTiles(generateTileGrid(TILE_GRID.SIZE, TILE_GRID.COLUMNS, engineValues.complexity))
|
||||||
|
}
|
||||||
|
}, [engineValues.complexity])
|
||||||
|
|
||||||
|
return {
|
||||||
|
tiles,
|
||||||
|
setTiles,
|
||||||
|
mode,
|
||||||
|
regenerateAll,
|
||||||
|
regenerateTile,
|
||||||
|
switchMode
|
||||||
|
}
|
||||||
|
}
|
||||||
56
src/services/ParameterManager.ts
Normal file
56
src/services/ParameterManager.ts
Normal file
@ -0,0 +1,56 @@
|
|||||||
|
import { engineSettings, effectSettings, lfoSettings } from '../stores/settings'
|
||||||
|
import type { TileState } from '../types/tiles'
|
||||||
|
import type { LFOSettings, LFOConfig } from '../stores/settings'
|
||||||
|
|
||||||
|
export class ParameterManager {
|
||||||
|
loadTileParams(tile: TileState): void {
|
||||||
|
Object.entries(tile.engineParams).forEach(([key, value]) => {
|
||||||
|
engineSettings.setKey(key as any, value)
|
||||||
|
})
|
||||||
|
|
||||||
|
Object.entries(tile.effectParams).forEach(([key, value]) => {
|
||||||
|
effectSettings.setKey(key as any, value as any)
|
||||||
|
})
|
||||||
|
|
||||||
|
if (tile.lfoConfigs) {
|
||||||
|
Object.entries(tile.lfoConfigs).forEach(([key, value]) => {
|
||||||
|
lfoSettings.setKey(key as keyof LFOSettings, value)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
saveTileParams(tile: TileState): TileState {
|
||||||
|
return {
|
||||||
|
...tile,
|
||||||
|
engineParams: { ...engineSettings.get() },
|
||||||
|
effectParams: { ...effectSettings.get() },
|
||||||
|
lfoConfigs: JSON.parse(JSON.stringify(lfoSettings.get()))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
getEngineParams(): Record<string, number> {
|
||||||
|
return engineSettings.get() as Record<string, number>
|
||||||
|
}
|
||||||
|
|
||||||
|
getEffectParams(): Record<string, number | boolean | string> {
|
||||||
|
return effectSettings.get()
|
||||||
|
}
|
||||||
|
|
||||||
|
getLFOConfigs(): LFOSettings {
|
||||||
|
return lfoSettings.get()
|
||||||
|
}
|
||||||
|
|
||||||
|
setEngineParam(key: string, value: number): void {
|
||||||
|
engineSettings.setKey(key as any, value)
|
||||||
|
}
|
||||||
|
|
||||||
|
setEffectParam(key: string, value: number | boolean | string): void {
|
||||||
|
effectSettings.setKey(key as any, value as any)
|
||||||
|
}
|
||||||
|
|
||||||
|
setLFOConfig(lfoKey: keyof LFOSettings, config: LFOConfig): void {
|
||||||
|
lfoSettings.setKey(lfoKey, config)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const parameterManager = new ParameterManager()
|
||||||
@ -1,6 +1,7 @@
|
|||||||
import { AudioPlayer } from '../domain/audio/AudioPlayer'
|
import { AudioPlayer } from '../domain/audio/AudioPlayer'
|
||||||
import type { LFOWaveform } from '../domain/modulation/LFO'
|
import type { LFOWaveform } from '../domain/modulation/LFO'
|
||||||
import type { EffectValues } from '../types/effects'
|
import type { EffectValues } from '../types/effects'
|
||||||
|
import type { SynthesisMode } from '../stores/synthesisMode'
|
||||||
import { DEFAULT_VARIABLES } from '../constants/defaults'
|
import { DEFAULT_VARIABLES } from '../constants/defaults'
|
||||||
|
|
||||||
export interface PlaybackOptions {
|
export interface PlaybackOptions {
|
||||||
@ -40,6 +41,18 @@ export class PlaybackManager {
|
|||||||
this.player.updatePitch(pitch)
|
this.player.updatePitch(pitch)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
setMode(mode: SynthesisMode): void {
|
||||||
|
this.player.setMode(mode)
|
||||||
|
}
|
||||||
|
|
||||||
|
setAlgorithm(algorithmId: number, lfoRates?: number[], pitchLFO?: { waveform: number; depth: number; baseRate: number }): void {
|
||||||
|
this.player.setAlgorithm(algorithmId, lfoRates, pitchLFO)
|
||||||
|
}
|
||||||
|
|
||||||
|
setFeedback(feedback: number): void {
|
||||||
|
this.player.setFeedback(feedback)
|
||||||
|
}
|
||||||
|
|
||||||
setPlaybackPositionCallback(callback: (position: number) => void): void {
|
setPlaybackPositionCallback(callback: (position: number) => void): void {
|
||||||
this.playbackPositionCallback = callback
|
this.playbackPositionCallback = callback
|
||||||
}
|
}
|
||||||
|
|||||||
5
src/stores/index.ts
Normal file
5
src/stores/index.ts
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
export { engineSettings, effectSettings, lfoSettings, getDefaultLFOConfig, getDefaultLFOValues } from './settings'
|
||||||
|
export { synthesisMode, setSynthesisMode } from './synthesisMode'
|
||||||
|
export { mappingMode, toggleMappingMode, exitMappingMode } from './mappingMode'
|
||||||
|
export type { LFOMapping, LFOConfig, LFOSettings } from './settings'
|
||||||
|
export type { SynthesisMode } from './synthesisMode'
|
||||||
@ -1,5 +1,5 @@
|
|||||||
import { map } from 'nanostores'
|
import { map } from 'nanostores'
|
||||||
import { getDefaultEngineValues, getDefaultEffectValues } from '../config/effects'
|
import { getDefaultEngineValues, getDefaultEffectValues } from '../config/parameters'
|
||||||
import type { LFOWaveform } from '../domain/modulation/LFO'
|
import type { LFOWaveform } from '../domain/modulation/LFO'
|
||||||
|
|
||||||
const STORAGE_KEY_ENGINE = 'engine:'
|
const STORAGE_KEY_ENGINE = 'engine:'
|
||||||
@ -71,6 +71,17 @@ function saveToStorage() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Save incrementally on changes (debounced to avoid excessive writes)
|
||||||
if (typeof window !== 'undefined') {
|
if (typeof window !== 'undefined') {
|
||||||
|
let saveTimeout: number | null = null
|
||||||
|
const debouncedSave = () => {
|
||||||
|
if (saveTimeout !== null) clearTimeout(saveTimeout)
|
||||||
|
saveTimeout = window.setTimeout(saveToStorage, 500)
|
||||||
|
}
|
||||||
|
|
||||||
|
engineSettings.listen(debouncedSave)
|
||||||
|
effectSettings.listen(debouncedSave)
|
||||||
|
lfoSettings.listen(debouncedSave)
|
||||||
|
|
||||||
window.addEventListener('beforeunload', saveToStorage)
|
window.addEventListener('beforeunload', saveToStorage)
|
||||||
}
|
}
|
||||||
25
src/stores/synthesisMode.ts
Normal file
25
src/stores/synthesisMode.ts
Normal file
@ -0,0 +1,25 @@
|
|||||||
|
import { atom } from 'nanostores'
|
||||||
|
|
||||||
|
export type SynthesisMode = 'bytebeat' | 'fm'
|
||||||
|
|
||||||
|
const STORAGE_KEY = 'synthesisMode'
|
||||||
|
|
||||||
|
function loadFromStorage(): SynthesisMode {
|
||||||
|
try {
|
||||||
|
const stored = localStorage.getItem(STORAGE_KEY)
|
||||||
|
return (stored === 'fm' || stored === 'bytebeat') ? stored : 'bytebeat'
|
||||||
|
} catch {
|
||||||
|
return 'bytebeat'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const synthesisMode = atom<SynthesisMode>(loadFromStorage())
|
||||||
|
|
||||||
|
export function setSynthesisMode(mode: SynthesisMode): void {
|
||||||
|
synthesisMode.set(mode)
|
||||||
|
try {
|
||||||
|
localStorage.setItem(STORAGE_KEY, mode)
|
||||||
|
} catch {
|
||||||
|
// Silently fail on storage errors
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -1,19 +1,2 @@
|
|||||||
export interface EffectParameter {
|
// Re-export from parameters for backward compatibility
|
||||||
id: string
|
export type { ParameterConfig as EffectParameter, ParameterGroup as EffectConfig, EffectValues } from './parameters'
|
||||||
label: string
|
|
||||||
min: number
|
|
||||||
max: number
|
|
||||||
default: number | string
|
|
||||||
step: number
|
|
||||||
unit?: string
|
|
||||||
options?: { value: string; label: string }[]
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface EffectConfig {
|
|
||||||
id: string
|
|
||||||
name: string
|
|
||||||
parameters: EffectParameter[]
|
|
||||||
bypassable?: boolean
|
|
||||||
}
|
|
||||||
|
|
||||||
export type EffectValues = Record<string, number | boolean | string>
|
|
||||||
2
src/types/index.ts
Normal file
2
src/types/index.ts
Normal file
@ -0,0 +1,2 @@
|
|||||||
|
export type { ParameterConfig, ParameterGroup, ParameterValues, EngineValues, EffectValues } from './parameters'
|
||||||
|
export type { TileState } from './tiles'
|
||||||
22
src/types/parameters.ts
Normal file
22
src/types/parameters.ts
Normal file
@ -0,0 +1,22 @@
|
|||||||
|
export interface ParameterConfig {
|
||||||
|
id: string
|
||||||
|
label: string
|
||||||
|
min: number
|
||||||
|
max: number
|
||||||
|
default: number | string
|
||||||
|
step: number
|
||||||
|
unit?: string
|
||||||
|
options?: { value: string; label: string }[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ParameterGroup {
|
||||||
|
id: string
|
||||||
|
name: string
|
||||||
|
parameters: ParameterConfig[]
|
||||||
|
bypassable?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ParameterValues = Record<string, number | boolean | string>
|
||||||
|
|
||||||
|
export type EngineValues = ParameterValues
|
||||||
|
export type EffectValues = ParameterValues
|
||||||
100
src/utils/fmPatches.ts
Normal file
100
src/utils/fmPatches.ts
Normal file
@ -0,0 +1,100 @@
|
|||||||
|
import type { TileState } from '../types/tiles'
|
||||||
|
import { getDefaultEngineValues, getDefaultEffectValues } from '../config/parameters'
|
||||||
|
import { getDefaultLFOValues } from '../stores/settings'
|
||||||
|
|
||||||
|
export interface FMPatchConfig {
|
||||||
|
algorithm: number
|
||||||
|
feedback: number
|
||||||
|
lfoRates: [number, number, number, number]
|
||||||
|
pitchLFO: {
|
||||||
|
waveform: number
|
||||||
|
depth: number
|
||||||
|
baseRate: number
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function generateRandomFMPatch(complexity: number = 1): FMPatchConfig {
|
||||||
|
let algorithmRange: number[]
|
||||||
|
|
||||||
|
switch (complexity) {
|
||||||
|
case 0:
|
||||||
|
algorithmRange = [0, 2, 6, 8, 9, 10]
|
||||||
|
break
|
||||||
|
case 2:
|
||||||
|
algorithmRange = [1, 4, 5, 7, 11, 12, 13, 14, 15]
|
||||||
|
break
|
||||||
|
default:
|
||||||
|
algorithmRange = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15]
|
||||||
|
}
|
||||||
|
|
||||||
|
const algorithm = algorithmRange[Math.floor(Math.random() * algorithmRange.length)]
|
||||||
|
const feedback = Math.floor(Math.random() * 100)
|
||||||
|
|
||||||
|
const lfoRates: [number, number, number, number] = [
|
||||||
|
0.2 + Math.random() * 0.8,
|
||||||
|
0.3 + Math.random() * 1.0,
|
||||||
|
0.4 + Math.random() * 1.2,
|
||||||
|
0.25 + Math.random() * 0.9
|
||||||
|
]
|
||||||
|
|
||||||
|
const pitchLFO = {
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
return { algorithm, feedback, lfoRates, pitchLFO }
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createFMTileState(patch: FMPatchConfig): TileState {
|
||||||
|
const formula = JSON.stringify(patch)
|
||||||
|
|
||||||
|
const pitchValues = [0.25, 0.5, 0.75, 1.0, 1.25, 1.5, 2.0]
|
||||||
|
const randomPitch = pitchValues[Math.floor(Math.random() * pitchValues.length)]
|
||||||
|
|
||||||
|
return {
|
||||||
|
formula,
|
||||||
|
engineParams: {
|
||||||
|
...getDefaultEngineValues(),
|
||||||
|
a: Math.floor(Math.random() * 128) + 64,
|
||||||
|
b: Math.floor(Math.random() * 128) + 64,
|
||||||
|
c: Math.floor(Math.random() * 128) + 64,
|
||||||
|
d: Math.floor(Math.random() * 128) + 64,
|
||||||
|
pitch: randomPitch,
|
||||||
|
fmAlgorithm: patch.algorithm,
|
||||||
|
fmFeedback: patch.feedback
|
||||||
|
},
|
||||||
|
effectParams: getDefaultEffectValues(),
|
||||||
|
lfoConfigs: getDefaultLFOValues()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function generateFMTileGrid(size: number, columns: number, complexity: number = 1): TileState[][] {
|
||||||
|
const tiles: TileState[][] = []
|
||||||
|
const rows = Math.ceil(size / columns)
|
||||||
|
|
||||||
|
for (let i = 0; i < rows; i++) {
|
||||||
|
const row: TileState[] = []
|
||||||
|
for (let j = 0; j < columns; j++) {
|
||||||
|
if (i * columns + j < size) {
|
||||||
|
const patch = generateRandomFMPatch(complexity)
|
||||||
|
row.push(createFMTileState(patch))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
tiles.push(row)
|
||||||
|
}
|
||||||
|
|
||||||
|
return tiles
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseFMPatch(formula: string): FMPatchConfig | null {
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(formula)
|
||||||
|
if (typeof parsed.algorithm === 'number' && typeof parsed.feedback === 'number') {
|
||||||
|
return parsed
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Not a valid FM patch
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
252
src/utils/fmWaveformGenerator.ts
Normal file
252
src/utils/fmWaveformGenerator.ts
Normal file
@ -0,0 +1,252 @@
|
|||||||
|
import { getAlgorithmById } from '../config/fmAlgorithms'
|
||||||
|
|
||||||
|
export function generateFMWaveformData(
|
||||||
|
algorithmId: number,
|
||||||
|
a: number,
|
||||||
|
b: number,
|
||||||
|
c: number,
|
||||||
|
d: number,
|
||||||
|
feedback: number,
|
||||||
|
width: number,
|
||||||
|
sampleRate: number,
|
||||||
|
duration: number
|
||||||
|
): number[] {
|
||||||
|
const algorithm = getAlgorithmById(algorithmId)
|
||||||
|
const samples = Math.floor(sampleRate * duration)
|
||||||
|
const data: number[] = []
|
||||||
|
|
||||||
|
const level1 = a / 255.0
|
||||||
|
const level2 = b / 255.0
|
||||||
|
const level3 = c / 255.0
|
||||||
|
const level4 = d / 255.0
|
||||||
|
const feedbackAmount = feedback / 100.0
|
||||||
|
|
||||||
|
const baseFreq = 220
|
||||||
|
const TWO_PI = Math.PI * 2
|
||||||
|
|
||||||
|
const freq1 = (baseFreq * algorithm.frequencyRatios[0] * TWO_PI) / sampleRate
|
||||||
|
const freq2 = (baseFreq * algorithm.frequencyRatios[1] * TWO_PI) / sampleRate
|
||||||
|
const freq3 = (baseFreq * algorithm.frequencyRatios[2] * TWO_PI) / sampleRate
|
||||||
|
const freq4 = (baseFreq * algorithm.frequencyRatios[3] * TWO_PI) / sampleRate
|
||||||
|
|
||||||
|
let phase1 = 0
|
||||||
|
let phase2 = 0
|
||||||
|
let phase3 = 0
|
||||||
|
let phase4 = 0
|
||||||
|
let feedbackSample = 0
|
||||||
|
|
||||||
|
for (let i = 0; i < samples; i++) {
|
||||||
|
let output = 0
|
||||||
|
|
||||||
|
switch (algorithmId) {
|
||||||
|
case 0: {
|
||||||
|
const op1 = Math.sin(phase1) * level1
|
||||||
|
const op2 = Math.sin(phase2) * level2
|
||||||
|
const op3 = Math.sin(phase3) * level3
|
||||||
|
const op4 = Math.sin(phase4) * level4
|
||||||
|
output = (op1 + op2 + op3 + op4) * 0.25
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
case 1: {
|
||||||
|
const op1 = Math.sin(phase1) * level1
|
||||||
|
const mod1 = op1 * 10
|
||||||
|
const op2 = Math.sin(phase2 + mod1) * level2
|
||||||
|
const mod2 = op2 * 10
|
||||||
|
const op3 = Math.sin(phase3 + mod2) * level3
|
||||||
|
const mod3 = op3 * 10
|
||||||
|
const op4 = Math.sin(phase4 + mod3 + feedbackSample * feedbackAmount) * level4
|
||||||
|
output = op4
|
||||||
|
feedbackSample = op4
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
case 2: {
|
||||||
|
const op1 = Math.sin(phase1) * level1
|
||||||
|
const mod1 = op1 * 10
|
||||||
|
const op2 = Math.sin(phase2 + mod1) * level2
|
||||||
|
const op3 = Math.sin(phase3) * level3
|
||||||
|
const mod3 = op3 * 10
|
||||||
|
const op4 = Math.sin(phase4 + mod3) * level4
|
||||||
|
output = (op2 + op4) * 0.5
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
case 3: {
|
||||||
|
const op1 = Math.sin(phase1) * level1
|
||||||
|
const mod1 = op1 * 10
|
||||||
|
const op2 = Math.sin(phase2 + mod1) * level2
|
||||||
|
const mod2 = op2 * 10
|
||||||
|
const op3 = Math.sin(phase3 + mod2) * level3
|
||||||
|
const op4 = Math.sin(phase4) * level4
|
||||||
|
output = (op3 + op4) * 0.5
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
case 4: {
|
||||||
|
const op1 = Math.sin(phase1) * level1
|
||||||
|
const mod1 = op1 * 10
|
||||||
|
const op2 = Math.sin(phase2) * level2
|
||||||
|
const mod2 = op2 * 10
|
||||||
|
const op3 = Math.sin(phase3 + mod1 + mod2) * level3
|
||||||
|
const mod3 = op3 * 10
|
||||||
|
const op4 = Math.sin(phase4 + mod3) * level4
|
||||||
|
output = op4
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
case 5: {
|
||||||
|
const op1 = Math.sin(phase1) * level1
|
||||||
|
const mod1 = op1 * 10
|
||||||
|
const op2 = Math.sin(phase2 + mod1) * level2
|
||||||
|
const mod2 = op2 * 10
|
||||||
|
const op3 = Math.sin(phase3 + mod1) * level3
|
||||||
|
const mod3 = op3 * 10
|
||||||
|
const op4 = Math.sin(phase4 + mod2 + mod3) * level4
|
||||||
|
output = op4
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
case 6: {
|
||||||
|
const op1 = Math.sin(phase1) * level1
|
||||||
|
const mod1 = op1 * 10
|
||||||
|
const op2 = Math.sin(phase2 + mod1) * level2
|
||||||
|
const op3 = Math.sin(phase3) * level3
|
||||||
|
const op4 = Math.sin(phase4) * level4
|
||||||
|
output = (op2 + op3 + op4) * 0.333
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
case 7: {
|
||||||
|
const op1 = Math.sin(phase1) * level1
|
||||||
|
const mod1 = op1 * 10
|
||||||
|
const op2 = Math.sin(phase2 + mod1) * level2
|
||||||
|
const mod2 = op2 * 1.5
|
||||||
|
const op3 = Math.sin(phase3 + mod1) * level3
|
||||||
|
const mod3 = op3 * 1.5
|
||||||
|
const op4 = Math.sin(phase4 + mod2 + mod3) * level4
|
||||||
|
output = op4
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
case 8: {
|
||||||
|
const op1 = Math.sin(phase1) * level1
|
||||||
|
const mod1 = op1 * 10
|
||||||
|
const op3 = Math.sin(phase3 + mod1) * level3
|
||||||
|
const op2 = Math.sin(phase2) * level2
|
||||||
|
const mod2 = op2 * 10
|
||||||
|
const op4 = Math.sin(phase4 + mod2) * level4
|
||||||
|
output = (op3 + op4) * 0.5
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
case 9: {
|
||||||
|
const op1 = Math.sin(phase1) * level1
|
||||||
|
const mod1 = op1 * 10
|
||||||
|
const op4 = Math.sin(phase4 + mod1) * level4
|
||||||
|
const op2 = Math.sin(phase2) * level2
|
||||||
|
const mod2 = op2 * 10
|
||||||
|
const op3 = Math.sin(phase3 + mod2) * level3
|
||||||
|
output = (op3 + op4) * 0.5
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
case 10: {
|
||||||
|
const op1 = Math.sin(phase1) * level1
|
||||||
|
const mod1 = op1 * 10
|
||||||
|
const op2 = Math.sin(phase2 + mod1) * level2
|
||||||
|
const op3 = Math.sin(phase3) * level3
|
||||||
|
const mod3 = op3 * 10
|
||||||
|
const op4 = Math.sin(phase4 + mod3) * level4
|
||||||
|
output = (op2 + op4) * 0.5
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
case 11: {
|
||||||
|
const op1 = Math.sin(phase1) * level1
|
||||||
|
const op2 = Math.sin(phase2) * level2
|
||||||
|
const mod2 = op2 * 10
|
||||||
|
const op3 = Math.sin(phase3 + mod2) * level3
|
||||||
|
const mod3 = op3 * 10
|
||||||
|
const op4 = Math.sin(phase4 + mod3) * level4
|
||||||
|
output = (op1 + op4) * 0.5
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
case 12: {
|
||||||
|
const op1 = Math.sin(phase1) * level1
|
||||||
|
const mod1 = op1 * 10
|
||||||
|
const op2 = Math.sin(phase2 + mod1) * level2
|
||||||
|
const mod2 = op2 * 10
|
||||||
|
const op4 = Math.sin(phase4 + mod2) * level4
|
||||||
|
const op3 = Math.sin(phase3) * level3
|
||||||
|
output = (op3 + op4) * 0.5
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
case 13: {
|
||||||
|
const op1 = Math.sin(phase1) * level1
|
||||||
|
const mod1 = op1 * 10
|
||||||
|
const op2 = Math.sin(phase2 + mod1) * level2
|
||||||
|
const mod2 = op2 * 10
|
||||||
|
const op3 = Math.sin(phase3 + mod1) * level3
|
||||||
|
const mod3 = op3 * 10
|
||||||
|
const op4 = Math.sin(phase4 + mod2 + mod3) * level4
|
||||||
|
output = op4
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
case 14: {
|
||||||
|
const op1 = Math.sin(phase1) * level1
|
||||||
|
const mod1 = op1 * 10
|
||||||
|
const op3 = Math.sin(phase3) * level3
|
||||||
|
const mod3 = op3 * 10
|
||||||
|
const op4 = Math.sin(phase4 + mod3) * level4
|
||||||
|
const mod4 = op4 * 1.5
|
||||||
|
const op2 = Math.sin(phase2 + mod1 + mod4) * level2
|
||||||
|
output = op2
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
case 15: {
|
||||||
|
const op1 = Math.sin(phase1) * level1
|
||||||
|
const mod1 = op1 * 10
|
||||||
|
const op2 = Math.sin(phase2) * level2
|
||||||
|
const mod2 = op2 * 10
|
||||||
|
const op3 = Math.sin(phase3) * level3
|
||||||
|
const mod3 = op3 * 10
|
||||||
|
const op4 = Math.sin(phase4 + mod1 + mod2 + mod3) * level4
|
||||||
|
output = op4
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
data.push(output)
|
||||||
|
|
||||||
|
phase1 += freq1
|
||||||
|
phase2 += freq2
|
||||||
|
phase3 += freq3
|
||||||
|
phase4 += freq4
|
||||||
|
}
|
||||||
|
|
||||||
|
const samplesPerPixel = Math.max(1, Math.floor(samples / width))
|
||||||
|
const downsampledData: number[] = []
|
||||||
|
|
||||||
|
for (let i = 0; i < width; i++) {
|
||||||
|
let min = Infinity
|
||||||
|
let max = -Infinity
|
||||||
|
|
||||||
|
for (let s = 0; s < samplesPerPixel; s++) {
|
||||||
|
const index = i * samplesPerPixel + s
|
||||||
|
if (index < data.length) {
|
||||||
|
const value = data[index]
|
||||||
|
min = Math.min(min, value)
|
||||||
|
max = Math.max(max, value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
downsampledData.push(min === Infinity ? 0 : min, max === -Infinity ? 0 : max)
|
||||||
|
}
|
||||||
|
|
||||||
|
return downsampledData
|
||||||
|
}
|
||||||
@ -1,4 +1,5 @@
|
|||||||
import { SAMPLE_RATES } from '../config/effects'
|
import { SAMPLE_RATES } from '../config/parameters'
|
||||||
|
import { getAlgorithmName } from '../config/fmAlgorithms'
|
||||||
|
|
||||||
export function getComplexityLabel(index: number): string {
|
export function getComplexityLabel(index: number): string {
|
||||||
const labels = ['Simple', 'Medium', 'Complex']
|
const labels = ['Simple', 'Medium', 'Complex']
|
||||||
@ -13,3 +14,7 @@ export function getBitDepthLabel(index: number): string {
|
|||||||
export function getSampleRateLabel(index: number): string {
|
export function getSampleRateLabel(index: number): string {
|
||||||
return `${SAMPLE_RATES[index]}Hz`
|
return `${SAMPLE_RATES[index]}Hz`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function getAlgorithmLabel(index: number): string {
|
||||||
|
return getAlgorithmName(index)
|
||||||
|
}
|
||||||
8
src/utils/index.ts
Normal file
8
src/utils/index.ts
Normal file
@ -0,0 +1,8 @@
|
|||||||
|
export { generateTileGrid, generateRandomFormula } from './bytebeatFormulas'
|
||||||
|
export { generateFMTileGrid, generateRandomFMPatch, createFMTileState, parseFMPatch } from './fmPatches'
|
||||||
|
export { createTileState, createTileStateFromCurrent, loadTileParams, saveTileParams, cloneTileState, randomizeTileParams } from './tileState'
|
||||||
|
export { getTileId, getTileFromGrid, isCustomTileFocused, isTileFocused } from './tileHelpers'
|
||||||
|
export { generateWaveformData, drawWaveform } from './waveformGenerator'
|
||||||
|
export { generateFMWaveformData } from './fmWaveformGenerator'
|
||||||
|
export { getComplexityLabel, getBitDepthLabel, getSampleRateLabel, getAlgorithmLabel } from './formatters'
|
||||||
|
export type { FocusedTile } from './tileHelpers'
|
||||||
@ -1,7 +1,8 @@
|
|||||||
import type { TileState } from '../types/tiles'
|
import type { TileState } from '../types/tiles'
|
||||||
import { engineSettings, effectSettings, lfoSettings, getDefaultLFOValues } from '../stores/settings'
|
import { getDefaultLFOValues } from '../stores/settings'
|
||||||
import { getDefaultEngineValues, getDefaultEffectValues, ENGINE_CONTROLS, EFFECTS } from '../config/effects'
|
import { getDefaultEngineValues, getDefaultEffectValues, ENGINE_CONTROLS, EFFECTS } from '../config/parameters'
|
||||||
import type { LFOSettings } from '../stores/settings'
|
import type { LFOSettings } from '../stores/settings'
|
||||||
|
import { parameterManager } from '../services/ParameterManager'
|
||||||
|
|
||||||
export function createTileState(
|
export function createTileState(
|
||||||
formula: string,
|
formula: string,
|
||||||
@ -20,35 +21,18 @@ export function createTileState(
|
|||||||
export function createTileStateFromCurrent(formula: string): TileState {
|
export function createTileStateFromCurrent(formula: string): TileState {
|
||||||
return {
|
return {
|
||||||
formula,
|
formula,
|
||||||
engineParams: { ...engineSettings.get() },
|
engineParams: { ...parameterManager.getEngineParams() },
|
||||||
effectParams: { ...effectSettings.get() },
|
effectParams: { ...parameterManager.getEffectParams() },
|
||||||
lfoConfigs: JSON.parse(JSON.stringify(lfoSettings.get()))
|
lfoConfigs: JSON.parse(JSON.stringify(parameterManager.getLFOConfigs()))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function loadTileParams(tile: TileState): void {
|
export function loadTileParams(tile: TileState): void {
|
||||||
Object.entries(tile.engineParams).forEach(([key, value]) => {
|
parameterManager.loadTileParams(tile)
|
||||||
engineSettings.setKey(key as keyof ReturnType<typeof getDefaultEngineValues>, value)
|
|
||||||
})
|
|
||||||
|
|
||||||
Object.entries(tile.effectParams).forEach(([key, value]) => {
|
|
||||||
effectSettings.setKey(key as never, value as never)
|
|
||||||
})
|
|
||||||
|
|
||||||
if (tile.lfoConfigs) {
|
|
||||||
Object.entries(tile.lfoConfigs).forEach(([key, value]) => {
|
|
||||||
lfoSettings.setKey(key as keyof LFOSettings, value)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function saveTileParams(tile: TileState): TileState {
|
export function saveTileParams(tile: TileState): TileState {
|
||||||
return {
|
return parameterManager.saveTileParams(tile)
|
||||||
...tile,
|
|
||||||
engineParams: { ...engineSettings.get() },
|
|
||||||
effectParams: { ...effectSettings.get() },
|
|
||||||
lfoConfigs: JSON.parse(JSON.stringify(lfoSettings.get()))
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function cloneTileState(tile: TileState): TileState {
|
export function cloneTileState(tile: TileState): TileState {
|
||||||
|
|||||||
Reference in New Issue
Block a user