Compare commits

...

21 Commits

Author SHA1 Message Date
14e1a8ec48 mobile fix 2025-10-06 16:48:05 +02:00
9d26ea5cd7 OK ready 2025-10-06 16:36:59 +02:00
90f2f4209c Weird hybrid 2025-10-06 14:31:05 +02:00
ff5add97e8 Enhance FM synthesis + cleaning code architecture 2025-10-06 13:48:14 +02:00
324cf9d2ed Adding new FM synthesis mode 2025-10-06 13:08:59 +02:00
0110a9760b Issue with the effect section on mobile devices 2025-10-06 11:27:24 +02:00
871dd6ca39 Fixing build 2025-10-06 11:21:02 +02:00
a113294acf fixing desktop mode top bar 2025-10-06 11:19:01 +02:00
71e01488dc Mobile version is taking shape 2025-10-06 11:16:57 +02:00
7559a2bfb5 progress on responsive 2025-10-06 11:11:10 +02:00
a4a26333b3 progress on responsive 2025-10-06 10:56:46 +02:00
ef50cc9918 Code quality checks 2025-10-06 03:03:38 +02:00
5cc10dec0c loop mechanism rework 2025-10-06 02:32:19 +02:00
18766f3d8a loop mechanism rework 2025-10-06 02:24:34 +02:00
ac772054c9 slightly better 2025-10-06 02:16:23 +02:00
ba37b94908 Better code quality 2025-10-04 14:52:20 +02:00
c6cc1a47c0 Better reverberation 2025-10-04 12:54:43 +02:00
012c3534be Everything is a bit better 2025-10-04 00:27:14 +02:00
a960f4e18b UI improvements 2025-10-04 00:10:23 +02:00
2d0bfe2297 stop button 2025-10-03 23:39:58 +02:00
0fc7ffdee0 Safer fold and crush section 2025-10-03 23:34:45 +02:00
83 changed files with 6483 additions and 1450 deletions

View File

@ -2,7 +2,7 @@
<html lang="en">
<head>
<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="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'>&amp;</text></svg>" />

View File

@ -0,0 +1,89 @@
class BytebeatProcessor extends AudioWorkletProcessor {
constructor() {
super()
this.t = 0
this.a = 8
this.b = 16
this.c = 32
this.d = 64
this.formula = null
this.compiledFormula = null
this.sampleRate = 8000
this.duration = 4
this.loopLength = this.sampleRate * this.duration
this.playbackRate = 1.0
this.error = false
this.port.onmessage = (event) => {
const { type, value } = event.data
switch (type) {
case 'formula':
this.setFormula(value)
break
case 'variables':
this.a = value.a ?? this.a
this.b = value.b ?? this.b
this.c = value.c ?? this.c
this.d = value.d ?? this.d
break
case 'reset':
this.t = 0
break
case 'loopLength':
this.loopLength = value
break
case 'playbackRate':
this.playbackRate = value
break
}
}
}
setFormula(formulaString) {
try {
this.compiledFormula = new Function('t', 'a', 'b', 'c', 'd', `return ${formulaString}`)
this.formula = formulaString
this.error = false
} catch (e) {
console.error('Failed to compile bytebeat formula:', e)
this.error = true
this.compiledFormula = null
}
}
process(inputs, outputs) {
const output = outputs[0]
if (output.length > 0) {
const outputChannel = output[0]
for (let i = 0; i < outputChannel.length; i++) {
if (!this.compiledFormula || this.error) {
outputChannel[i] = 0
} else {
try {
const value = this.compiledFormula(this.t, this.a, this.b, this.c, this.d)
const byteValue = value & 0xFF
outputChannel[i] = (byteValue - 128) / 128
} catch (e) {
outputChannel[i] = 0
if (!this.error) {
console.error('Bytebeat runtime error:', e)
this.error = true
}
}
}
this.t += this.playbackRate
if (this.loopLength > 0 && this.t >= this.loopLength) {
this.t = 0
}
}
}
return true
}
}
registerProcessor('bytebeat-processor', BytebeatProcessor)

View 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)

View 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)

View File

@ -2,12 +2,30 @@ class FoldCrushProcessor extends AudioWorkletProcessor {
constructor() {
super()
this.clipMode = 'wrap'
this.clipMode = 'fold'
this.drive = 1
this.bitDepth = 16
this.crushAmount = 0
this.bitcrushPhase = 0
this.lastCrushedValue = 0
this.glitchAmount = 0
this.dcBlockerX = 0
this.dcBlockerY = 0
this.dcBlockerCoeff = 0.995
this.preEmphasisLast = 0
this.deEmphasisLast = 0
this.grainBuffer = new Float32Array(256)
this.grainBufferIndex = 0
this.grainPlaybackActive = false
this.grainPlaybackIndex = 0
this.grainPlaybackStart = 0
this.grainPlaybackLength = 0
this.grainPlaybackRemaining = 0
this.grainReversed = false
this.grainInverted = false
this.port.onmessage = (event) => {
const { type, value } = event.data
@ -24,46 +42,139 @@ class FoldCrushProcessor extends AudioWorkletProcessor {
case 'crushAmount':
this.crushAmount = value
break
case 'glitchAmount':
this.glitchAmount = value
break
}
}
}
wrap(sample) {
const range = 2.0
let wrapped = sample
while (wrapped > 1.0) wrapped -= range
while (wrapped < -1.0) wrapped += range
return wrapped
clamp(x, min, max) {
return Math.max(min, Math.min(max, x))
}
clamp(sample) {
return Math.max(-1.0, Math.min(1.0, sample))
dcBlocker(x) {
this.dcBlockerY = x - this.dcBlockerX + this.dcBlockerCoeff * this.dcBlockerY
this.dcBlockerX = x
return this.dcBlockerY
}
fold(sample) {
let folded = sample
while (folded > 1.0 || folded < -1.0) {
if (folded > 1.0) {
folded = 2.0 - folded
}
if (folded < -1.0) {
folded = -2.0 - folded
preEmphasis(x) {
const amount = 0.7
const output = x - amount * this.preEmphasisLast
this.preEmphasisLast = x
return output
}
deEmphasis(x) {
const amount = 0.7
const output = x + amount * this.deEmphasisLast
this.deEmphasisLast = output
return output
}
tube(x, k) {
const gain = 1 + k * 2
const biasAmount = 0.1 * k
const bias = biasAmount
const driven = (x + bias) * gain
if (driven > 1.0) {
return 1.0 - Math.exp(-(driven - 1.0) * 2)
} else if (driven < -1.0) {
return -1.0 + Math.exp((driven + 1.0) * 1.5)
} else {
return Math.tanh(driven * 1.2)
}
}
tape(x, k) {
const gain = 1 + k * 1.5
const driven = x * gain
const threshold = 0.3
const knee = 0.5
if (Math.abs(driven) < threshold) {
return driven
} else {
const excess = Math.abs(driven) - threshold
const compressed = threshold + excess / (1 + excess / knee)
return Math.sign(driven) * compressed
}
}
fuzz(x, k) {
const gain = 1 + k * 10
const driven = x * gain
const fuzzAmount = Math.tanh(driven * 3)
const hardClip = this.clamp(driven, -0.9, 0.9)
const mix = Math.min(k / 2, 0.7)
return fuzzAmount * mix + hardClip * (1 - mix)
}
fold(x, k) {
const gain = 1 + k * 3
let y = x * gain
while (y > 1.0 || y < -1.0) {
if (y > 1.0) {
y = 2.0 - y
} else if (y < -1.0) {
y = -2.0 - y
}
}
return folded
return Math.sin(y * Math.PI / 2)
}
processWavefolder(sample) {
crush(x, k) {
const gain = 1 + k * 4
let driven = x * gain
const foldThreshold = 0.8
let folds = 0
while (Math.abs(driven) > foldThreshold && folds < 8) {
if (driven > foldThreshold) {
driven = 2 * foldThreshold - driven
} else if (driven < -foldThreshold) {
driven = -2 * foldThreshold - driven
}
folds++
}
return this.clamp(driven + (Math.random() - 0.5) * k * 0.02, -1, 1)
}
processDistortion(sample) {
let processed = this.preEmphasis(sample)
switch (this.clipMode) {
case 'wrap':
return this.wrap(sample)
case 'clamp':
return this.clamp(sample)
case 'tube':
processed = this.tube(processed, this.drive)
break
case 'tape':
processed = this.tape(processed, this.drive)
break
case 'fuzz':
processed = this.fuzz(processed, this.drive)
break
case 'fold':
return this.fold(sample)
processed = this.fold(processed, this.drive)
break
case 'crush':
processed = this.crush(processed, this.drive)
break
default:
return sample
processed = this.fold(processed, this.drive)
}
processed = this.deEmphasis(processed)
return this.dcBlocker(processed)
}
processBitcrush(sample) {
@ -78,14 +189,74 @@ class FoldCrushProcessor extends AudioWorkletProcessor {
if (this.bitcrushPhase >= 1.0) {
this.bitcrushPhase -= 1.0
const crushed = Math.floor(sample / step + 0.5) * step
this.lastCrushedValue = Math.max(-1, Math.min(1, crushed))
const dither = (Math.random() - 0.5) * step * 0.5
const crushed = Math.floor((sample + dither) / step + 0.5) * step
this.lastCrushedValue = this.clamp(crushed, -1, 1)
return this.lastCrushedValue
} else {
return this.lastCrushedValue
}
}
processGlitch(sample) {
if (this.glitchAmount === 0) {
return sample
}
this.grainBuffer[this.grainBufferIndex] = sample
this.grainBufferIndex = (this.grainBufferIndex + 1) % 256
if (this.grainPlaybackActive) {
this.grainPlaybackRemaining--
let readIndex
if (this.grainReversed) {
readIndex = this.grainPlaybackStart + this.grainPlaybackLength - 1 - (this.grainPlaybackIndex % this.grainPlaybackLength)
} else {
readIndex = this.grainPlaybackStart + (this.grainPlaybackIndex % this.grainPlaybackLength)
}
readIndex = readIndex % 256
let output = this.grainBuffer[readIndex]
if (this.grainInverted) {
output = -output
}
this.grainPlaybackIndex++
if (this.grainPlaybackRemaining <= 0) {
this.grainPlaybackActive = false
}
return output
}
const glitchIntensity = this.glitchAmount / 100
const triggerProb = glitchIntensity * 0.001
if (Math.random() < triggerProb) {
this.grainPlaybackStart = this.grainBufferIndex
this.grainPlaybackLength = Math.floor(16 + Math.random() * 48)
this.grainPlaybackRemaining = Math.floor(100 + Math.random() * 200 * glitchIntensity)
this.grainPlaybackIndex = 0
this.grainPlaybackActive = true
this.grainReversed = Math.random() < 0.4
this.grainInverted = Math.random() < 0.2
let readIndex = this.grainPlaybackStart
let output = this.grainBuffer[readIndex]
if (this.grainInverted) {
output = -output
}
return output
}
return sample
}
process(inputs, outputs) {
const input = inputs[0]
const output = outputs[0]
@ -95,9 +266,9 @@ class FoldCrushProcessor extends AudioWorkletProcessor {
const outputChannel = output[0]
for (let i = 0; i < inputChannel.length; i++) {
const driven = inputChannel[i] * this.drive
let processed = this.processWavefolder(driven)
let processed = this.processDistortion(inputChannel[i])
processed = this.processBitcrush(processed)
processed = this.processGlitch(processed)
outputChannel[i] = processed
}
}

View File

@ -0,0 +1,60 @@
class OutputLimiter extends AudioWorkletProcessor {
static get parameterDescriptors() {
return [
{
name: 'threshold',
defaultValue: 0.8,
minValue: 0.1,
maxValue: 1.0,
automationRate: 'k-rate'
},
{
name: 'makeup',
defaultValue: 0.5,
minValue: 0.1,
maxValue: 2.0,
automationRate: 'k-rate'
}
]
}
constructor() {
super()
}
softClip(x, threshold) {
if (Math.abs(x) < threshold) {
return x
}
const sign = x < 0 ? -1 : 1
const scaled = (Math.abs(x) - threshold) / (1 - threshold)
return sign * (threshold + (1 - threshold) * Math.tanh(scaled))
}
process(inputs, outputs, parameters) {
const input = inputs[0]
const output = outputs[0]
if (input.length === 0 || output.length === 0) {
return true
}
const threshold = parameters.threshold[0]
const makeup = parameters.makeup[0]
for (let channel = 0; channel < input.length; channel++) {
const inputChannel = input[channel]
const outputChannel = output[channel]
for (let i = 0; i < inputChannel.length; i++) {
let sample = this.softClip(inputChannel[i], threshold)
sample = sample * makeup
outputChannel[i] = sample
}
}
return true
}
}
registerProcessor('output-limiter', OutputLimiter)

View 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)

View File

@ -0,0 +1,103 @@
class SVFProcessor extends AudioWorkletProcessor {
static get parameterDescriptors() {
return [
{
name: 'frequency',
defaultValue: 1000,
minValue: 20,
maxValue: 20000,
automationRate: 'k-rate'
},
{
name: 'resonance',
defaultValue: 0.707,
minValue: 0.05,
maxValue: 10,
automationRate: 'k-rate'
}
]
}
constructor() {
super()
this.mode = 'lowpass'
this.ic1eq = 0
this.ic2eq = 0
this.port.onmessage = (event) => {
const { type, value } = event.data
if (type === 'mode') {
this.mode = value
}
}
}
process(inputs, outputs, parameters) {
const input = inputs[0]
const output = outputs[0]
if (input.length === 0 || output.length === 0) {
return true
}
const inputChannel = input[0]
const outputChannel = output[0]
const frequency = parameters.frequency
const resonance = parameters.resonance
const sampleRate = globalThis.sampleRate || 44100
const isFreqArray = frequency.length > 1
const isResArray = resonance.length > 1
for (let i = 0; i < inputChannel.length; i++) {
const freq = isFreqArray ? frequency[i] : frequency[0]
const res = isResArray ? resonance[i] : resonance[0]
const g = Math.tan(Math.PI * Math.min(freq, sampleRate * 0.49) / sampleRate)
const k = 1 / Math.max(0.05, res)
const inputSample = inputChannel[i]
const a1 = 1 / (1 + g * (g + k))
const a2 = g * a1
const a3 = g * a2
const v3 = inputSample - this.ic2eq
const v1 = a1 * this.ic1eq + a2 * v3
const v2 = this.ic2eq + a2 * this.ic1eq + a3 * v3
this.ic1eq = 2 * v1 - this.ic1eq
this.ic2eq = 2 * v2 - this.ic2eq
const lp = v2
const bp = v1
const hp = inputSample - k * v1 - v2
const notch = inputSample - k * v1
let outSample
switch (this.mode) {
case 'lowpass':
outSample = lp
break
case 'highpass':
outSample = hp
break
case 'bandpass':
outSample = bp
break
case 'notch':
outSample = notch
break
default:
outSample = lp
}
outputChannel[i] = outSample
}
return true
}
}
registerProcessor('svf-processor', SVFProcessor)

View File

@ -1,110 +1,107 @@
import { useState, useRef, useEffect } from 'react'
import { useState, useRef } from 'react'
import { useStore } from '@nanostores/react'
import { PlaybackManager } from './services/PlaybackManager'
import { Square, Archive, Dices, Sparkles, ArrowLeftRight } from 'lucide-react'
import { DownloadService } from './services/DownloadService'
import { generateFormulaGrid, generateRandomFormula } from './utils/bytebeatFormulas'
import { BytebeatTile } from './components/BytebeatTile'
import { EffectsBar } from './components/EffectsBar'
import { EngineControls } from './components/EngineControls'
import { getSampleRateFromIndex } from './config/effects'
import { generateRandomFormula } from './utils/bytebeatFormulas'
import { BytebeatTile } from './components/tile/BytebeatTile'
import { EffectsBar } from './components/controls/EffectsBar'
import { EngineControls } from './components/controls/EngineControls'
import { FormulaEditor } from './components/tile/FormulaEditor'
import { LFOPanel } from './components/controls/LFOPanel'
import { AudioContextWarning } from './components/modals/AudioContextWarning'
import { HelpModal } from './components/modals/HelpModal'
import { engineSettings, effectSettings } from './stores/settings'
import { exitMappingMode } from './stores/mappingMode'
import { useKeyboardShortcuts } from './hooks/useKeyboardShortcuts'
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 { createTileStateFromCurrent } from './utils/tileState'
import { DEFAULT_DOWNLOAD_OPTIONS, PLAYBACK_ID } from './constants/defaults'
import { getTileId, getTileFromGrid } from './utils/tileHelpers'
function App() {
const engineValues = useStore(engineSettings)
const effectValues = useStore(effectSettings)
const [formulas, setFormulas] = useState<string[][]>(() =>
generateFormulaGrid(100, 2, engineValues.complexity)
)
const [playing, setPlaying] = useState<string | null>(null)
const [queued, setQueued] = useState<string | null>(null)
const [regenerating, setRegenerating] = useState<string | null>(null)
const [playbackPosition, setPlaybackPosition] = useState<number>(0)
const [downloading, setDownloading] = useState(false)
const playbackManagerRef = useRef<PlaybackManager | null>(null)
const [customTile, setCustomTile] = useState<TileState>(() => createTileStateFromCurrent('t*(8&t>>9)'))
const [showWarning, setShowWarning] = useState(true)
const [showHelp, setShowHelp] = useState(false)
const [mobileHeaderTab, setMobileHeaderTab] = useState<'global' | 'options' | 'modulate'>('global')
const downloadServiceRef = useRef<DownloadService>(new DownloadService())
const animationFrameRef = useRef<number | null>(null)
const formulasRef = useRef<string[][]>(formulas)
useEffect(() => {
formulasRef.current = formulas
}, [formulas])
const { tiles, setTiles, mode, regenerateAll, regenerateTile, switchMode } = useTileGrid()
useEffect(() => {
effectSettings.setKey('masterVolume', engineValues.masterVolume)
}, [engineValues.masterVolume])
const { playing, queued, playbackPosition, playbackManager, play, stop, queue, cancelQueue, updateMode } =
usePlaybackControl({ mode })
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()
loadParams(customTile)
}
}
})
const { saveCurrentParams, loadParams, handleEngineChange, handleEffectChange, randomizeParams, randomizeAllParams, interpolateParams } =
useParameterSync({
tiles,
setTiles,
customTile,
setCustomTile,
focusedTile,
playbackManager,
playing,
playbackId: PLAYBACK_ID.CUSTOM
})
const { handleLFOChange, handleParameterMapClick, handleUpdateMappingDepth, handleRemoveMapping, getMappedLFOs } =
useLFOMapping({
playbackManager,
saveCurrentParams
})
const handleRandom = () => {
setFormulas(generateFormulaGrid(100, 2, engineValues.complexity))
setQueued(null)
cancelQueue()
regenerateAll()
}
const playFormula = async (formula: string, id: string) => {
const sampleRate = getSampleRateFromIndex(engineValues.sampleRate)
const duration = engineValues.loopDuration
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.setPitch(engineValues.pitch ?? 1)
const success = await playbackManagerRef.current.play(formula, sampleRate, duration)
if (success) {
setPlaying(id)
setQueued(null)
startPlaybackTracking()
} else {
console.error('Failed to play formula')
}
const handleModeToggle = () => {
const newMode = mode === 'bytebeat' ? 'fm' : 'bytebeat'
stop()
switchMode(newMode)
updateMode(newMode)
}
const startPlaybackTracking = () => {
if (animationFrameRef.current) {
cancelAnimationFrame(animationFrameRef.current)
}
const handleTileClick = (_formula: string, row: number, col: number, isDoubleClick: boolean = false) => {
const id = getTileId(row, col)
const tile = getTileFromGrid(tiles, row, col)
const updatePosition = () => {
if (playbackManagerRef.current) {
const position = playbackManagerRef.current.getPlaybackPosition()
setPlaybackPosition(position)
animationFrameRef.current = requestAnimationFrame(updatePosition)
}
}
updatePosition()
}
if (!tile) return
const handleTileClick = (formula: string, row: number, col: number, isDoubleClick: boolean = false) => {
const id = `${row}-${col}`
if (playing === id) {
playbackManagerRef.current?.stop()
setPlaying(null)
setQueued(null)
if (animationFrameRef.current) {
cancelAnimationFrame(animationFrameRef.current)
animationFrameRef.current = null
}
return
}
setFocus({ row, col })
if (isDoubleClick || playing === null) {
playFormula(formula, id)
play(tile.formula, id, tile)
} else {
setQueued(id)
if (playbackManagerRef.current) {
playbackManagerRef.current.scheduleNextTrack(() => {
const queuedFormula = formulasRef.current.flat()[parseInt(id.split('-')[0]) * 2 + parseInt(id.split('-')[1])]
if (queuedFormula) {
playFormula(queuedFormula, id)
}
})
}
queue(id, () => {
const queuedTile = getTileFromGrid(tiles, row, col)
if (queuedTile) {
play(queuedTile.formula, id, queuedTile)
}
})
}
}
@ -112,111 +109,374 @@ function App() {
handleTileClick(formula, row, col, true)
}
const handleEngineChange = (parameterId: string, value: number) => {
engineSettings.setKey(parameterId as keyof typeof engineValues, value)
if (parameterId === 'masterVolume' && playbackManagerRef.current) {
playbackManagerRef.current.setEffects({ ...effectValues, masterVolume: value })
}
if (parameterId === 'pitch' && playbackManagerRef.current) {
playbackManagerRef.current.setPitch(value)
}
}
const handleEffectChange = (parameterId: string, value: number | boolean) => {
effectSettings.setKey(parameterId as any, value as any)
if (playbackManagerRef.current) {
playbackManagerRef.current.setEffects(effectValues)
}
}
const handleDownloadAll = async () => {
setDownloading(true)
await downloadServiceRef.current.downloadAll(formulas, { duration: 10, bitDepth: 8 })
const formulas = tiles.map(row => row.map(tile => tile.formula))
await downloadServiceRef.current.downloadAll(formulas, {
duration: DEFAULT_DOWNLOAD_OPTIONS.DURATION,
bitDepth: DEFAULT_DOWNLOAD_OPTIONS.BIT_DEPTH
})
setDownloading(false)
}
const handleDownloadFormula = (formula: string, filename: string) => {
downloadServiceRef.current.downloadFormula(formula, filename, { duration: 10, bitDepth: 8 })
downloadServiceRef.current.downloadFormula(formula, filename, {
duration: DEFAULT_DOWNLOAD_OPTIONS.DURATION,
bitDepth: DEFAULT_DOWNLOAD_OPTIONS.BIT_DEPTH
})
}
const handleRegenerate = (row: number, col: number) => {
const id = `${row}-${col}`
const newFormula = generateRandomFormula(engineValues.complexity)
const handleCustomEvaluate = (formula: string) => {
setFocus('custom')
setCustomTile({ ...customTile, formula })
play(formula, PLAYBACK_ID.CUSTOM, { ...customTile, formula })
}
if (playing === id && playbackManagerRef.current) {
setRegenerating(id)
playbackManagerRef.current.scheduleNextTrack(() => {
setFormulas(prevFormulas => {
const newFormulas = [...prevFormulas]
newFormulas[row] = [...newFormulas[row]]
newFormulas[row][col] = newFormula
return newFormulas
})
playFormula(newFormula, id)
setRegenerating(null)
})
} else {
setFormulas(prevFormulas => {
const newFormulas = [...prevFormulas]
newFormulas[row] = [...newFormulas[row]]
newFormulas[row][col] = newFormula
return newFormulas
})
const handleCustomStop = () => {
if (playing === PLAYBACK_ID.CUSTOM) {
stop()
}
}
const handleCustomRandom = () => {
return generateRandomFormula(engineValues.complexity)
}
const handleKeyboardSpace = () => {
if (playing) {
stop()
} else if (focusedTile !== 'custom') {
const tile = tiles[focusedTile.row]?.[focusedTile.col]
if (tile) {
handleTileClick(tile.formula, focusedTile.row, focusedTile.col, true)
}
}
}
const handleKeyboardEnter = () => {
if (focusedTile !== 'custom') {
const tile = tiles[focusedTile.row]?.[focusedTile.col]
if (tile) {
handleTileClick(tile.formula, focusedTile.row, focusedTile.col, false)
}
}
}
const handleKeyboardDoubleEnter = () => {
if (focusedTile !== 'custom') {
const tile = tiles[focusedTile.row]?.[focusedTile.col]
if (tile) {
handleTileClick(tile.formula, focusedTile.row, focusedTile.col, true)
}
}
}
const handleRegenerate = (row: number, col: number) => {
const newTile = regenerateTile(row, col)
const tileId = getTileId(row, col)
if (playing === tileId) {
play(newTile.formula, tileId, newTile)
}
}
const handleKeyboardR = () => {
if (focusedTile !== 'custom') {
handleRegenerate(focusedTile.row, focusedTile.col)
}
}
const handleKeyboardC = () => {
const tileId = focusedTile === 'custom'
? PLAYBACK_ID.CUSTOM
: getTileId(focusedTile.row, focusedTile.col)
randomizeParams(tileId)
}
const handleDismissWarning = () => {
setShowWarning(false)
}
useKeyboardShortcuts({
onSpace: handleKeyboardSpace,
onArrowUp: (shift) => moveFocus('up', shift ? 10 : 1),
onArrowDown: (shift) => moveFocus('down', shift ? 10 : 1),
onArrowLeft: (shift) => moveFocus('left', shift ? 10 : 1),
onArrowRight: (shift) => moveFocus('right', shift ? 10 : 1),
onEnter: handleKeyboardEnter,
onDoubleEnter: handleKeyboardDoubleEnter,
onR: handleKeyboardR,
onShiftR: handleRandom,
onC: handleKeyboardC,
onShiftC: randomizeAllParams,
onI: interpolateParams,
onEscape: exitMappingMode
})
return (
<div className="w-screen h-screen flex flex-col bg-black overflow-hidden">
<header className="bg-black border-b-2 border-white px-6 py-3">
<div className="flex items-center justify-between mb-3">
<h1 className="font-mono text-sm tracking-[0.3em] text-white">BRUITISTE</h1>
<div className="flex gap-4">
{showWarning && <AudioContextWarning onDismiss={handleDismissWarning} />}
{showHelp && <HelpModal onClose={() => setShowHelp(false)} />}
<header className="bg-black border-b-2 border-white px-2 lg:px-6 py-2 lg:py-3">
{/* Mobile header */}
<div className="lg:hidden">
<div className="flex items-center justify-between mb-2">
<h1
onClick={() => setShowHelp(true)}
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
</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
values={engineValues}
onChange={handleEngineChange}
onMapClick={handleParameterMapClick}
getMappedLFOs={getMappedLFOs}
/>
</div>
<div className="flex gap-3 flex-shrink-0">
<button
onClick={stop}
disabled={!playing}
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={12} strokeWidth={2} fill="currentColor" />
</button>
<button
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"
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"
>
RANDOM
<Dices size={12} strokeWidth={2} />
</button>
<button
onClick={randomizeAllParams}
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={12} strokeWidth={2} />
</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
onClick={handleDownloadAll}
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"
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"
>
{downloading ? 'DOWNLOADING...' : 'DOWNLOAD ALL'}
<Archive size={12} strokeWidth={2} />
</button>
</div>
</div>
<EngineControls values={engineValues} onChange={handleEngineChange} />
</header>
<div className="flex-1 grid grid-cols-2 auto-rows-min gap-[1px] bg-white p-[1px] overflow-auto">
{formulas.map((row, i) =>
row.map((formula, j) => {
const id = `${i}-${j}`
return (
<BytebeatTile
key={id}
formula={formula}
row={i}
col={j}
isPlaying={playing === id}
isQueued={queued === id}
isRegenerating={regenerating === id}
playbackPosition={playing === id ? playbackPosition : 0}
onPlay={handleTileClick}
onDoubleClick={handleTileDoubleClick}
onDownload={handleDownloadFormula}
onRegenerate={handleRegenerate}
<LFOPanel onChange={handleLFOChange} onUpdateDepth={handleUpdateMappingDepth} onRemoveMapping={handleRemoveMapping} />
<div className="flex-1 flex flex-col overflow-auto bg-white">
{mode === 'bytebeat' && (
<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
formula={customTile.formula}
isPlaying={playing === PLAYBACK_ID.CUSTOM}
isFocused={focusedTile === 'custom'}
playbackPosition={playing === PLAYBACK_ID.CUSTOM ? playbackPosition : 0}
onEvaluate={handleCustomEvaluate}
onStop={handleCustomStop}
onRandom={handleCustomRandom}
/>
)
})
</div>
</div>
)}
<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) =>
row.map((tile, j) => {
const id = getTileId(i, j)
return (
<BytebeatTile
key={id}
formula={tile.formula}
row={i}
col={j}
isPlaying={playing === id}
isQueued={queued === id}
isFocused={focusedTile !== 'custom' && focusedTile.row === i && focusedTile.col === j}
playbackPosition={playing === id ? playbackPosition : 0}
a={tile.engineParams.a ?? 8}
b={tile.engineParams.b ?? 16}
c={tile.engineParams.c ?? 32}
d={tile.engineParams.d ?? 64}
onPlay={handleTileClick}
onDoubleClick={handleTileDoubleClick}
onDownload={handleDownloadFormula}
onRegenerate={handleRegenerate}
/>
)
})
)}
</div>
</div>
<EffectsBar values={effectValues} onChange={handleEffectChange} />
<div className="flex-shrink-0">
<EffectsBar
values={effectValues}
onChange={handleEffectChange}
onMapClick={handleParameterMapClick}
getMappedLFOs={getMappedLFOs}
/>
</div>
</div>
)
}

View File

@ -1,142 +0,0 @@
import { Slider } from './Slider'
import { Switch } from './Switch'
import { EFFECTS } from '../config/effects'
import { getClipModeLabel } from '../utils/formatters'
import type { EffectValues } from '../types/effects'
interface EffectsBarProps {
values: EffectValues
onChange: (parameterId: string, value: number | boolean) => void
}
export function EffectsBar({ values, onChange }: EffectsBarProps) {
const formatValue = (id: string, value: number): string => {
if (id === 'clipMode') {
return getClipModeLabel(value)
}
return value.toString()
}
const renderFilterEffect = (effect: typeof EFFECTS[number]) => {
const filterGroups = [
{ prefix: 'hp', label: 'HP' },
{ prefix: 'lp', label: 'LP' },
{ prefix: 'bp', label: 'BP' }
]
return (
<div key={effect.id} className="border-2 border-white p-3">
<h3 className="font-mono text-[10px] tracking-[0.2em] text-white mb-3">
{effect.name.toUpperCase()}
</h3>
<div className="flex flex-col gap-3">
{filterGroups.map(group => {
const enableParam = effect.parameters.find(p => p.id === `${group.prefix}Enable`)
const freqParam = effect.parameters.find(p => p.id === `${group.prefix}Freq`)
const resParam = effect.parameters.find(p => p.id === `${group.prefix}Res`)
if (!enableParam || !freqParam || !resParam) return null
return (
<div key={group.prefix} className="flex gap-2 items-center">
<Switch
checked={Boolean(values[enableParam.id])}
onChange={(checked) => onChange(enableParam.id, checked ? 1 : 0)}
vertical
/>
<div className="flex-1 flex flex-col gap-2">
<Slider
label={freqParam.label}
value={values[freqParam.id] as number ?? freqParam.default}
min={freqParam.min}
max={freqParam.max}
step={freqParam.step}
unit={freqParam.unit}
onChange={(value) => onChange(freqParam.id, value)}
valueId={freqParam.id}
/>
<Slider
label={resParam.label}
value={values[resParam.id] as number ?? resParam.default}
min={resParam.min}
max={resParam.max}
step={resParam.step}
unit={resParam.unit}
onChange={(value) => onChange(resParam.id, value)}
valueId={resParam.id}
/>
</div>
</div>
)
})}
</div>
</div>
)
}
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 => {
if (effect.id === 'filter') {
return renderFilterEffect(effect)
}
return (
<div key={effect.id} className="border-2 border-white p-3">
<div className="flex items-center justify-between mb-3">
<h3 className="font-mono text-[10px] tracking-[0.2em] text-white">
{effect.name.toUpperCase()}
</h3>
{effect.bypassable && (
<Switch
checked={!Boolean(values[`${effect.id}Bypass`])}
onChange={(checked) => onChange(`${effect.id}Bypass`, !checked)}
label={Boolean(values[`${effect.id}Bypass`]) ? 'OFF' : 'ON'}
/>
)}
</div>
<div className="flex flex-col gap-3">
{effect.parameters.map(param => {
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={Boolean(values[param.id]) ? 'ON' : 'OFF'}
/>
</div>
</div>
)
}
return (
<Slider
key={param.id}
label={param.label}
value={values[param.id] as number ?? param.default}
min={param.min}
max={param.max}
step={param.step}
unit={param.unit}
onChange={(value) => onChange(param.id, value)}
formatValue={param.id === 'clipMode' ? formatValue : undefined}
valueId={param.id}
/>
)
})}
</div>
</div>
)
})}
</div>
</div>
)
}

View File

@ -1,50 +0,0 @@
import { ENGINE_CONTROLS } from '../config/effects'
import { getComplexityLabel, getBitDepthLabel, getSampleRateLabel } from '../utils/formatters'
import type { EffectValues } from '../types/effects'
interface EngineControlsProps {
values: EffectValues
onChange: (parameterId: string, value: number) => void
}
export function EngineControls({ values, onChange }: EngineControlsProps) {
const formatValue = (id: string, value: number): string => {
switch (id) {
case 'sampleRate':
return getSampleRateLabel(value)
case 'complexity':
return getComplexityLabel(value)
case 'bitDepth':
return getBitDepthLabel(value)
default:
const param = ENGINE_CONTROLS[0].parameters.find(p => p.id === id)
return `${value}${param?.unit || ''}`
}
}
return (
<div className="flex items-center gap-6">
{ENGINE_CONTROLS[0].parameters.map(param => (
<div key={param.id} className="flex flex-col gap-1 min-w-[100px]">
<div className="flex justify-between items-baseline">
<label className="font-mono text-[9px] tracking-[0.15em] text-white">
{param.label.toUpperCase()}
</label>
<span className="font-mono text-[9px] text-white">
{formatValue(param.id, (values[param.id] as number) ?? param.default)}
</span>
</div>
<input
type="range"
min={param.min}
max={param.max}
step={param.step}
value={(values[param.id] as number) ?? param.default}
onChange={(e) => onChange(param.id, Number(e.target.value))}
className="w-full h-[2px] bg-white appearance-none cursor-pointer"
/>
</div>
))}
</div>
)
}

View File

@ -1,37 +0,0 @@
interface SliderProps {
label: string
value: number
min: number
max: number
step: number
unit?: string
onChange: (value: number) => void
formatValue?: (id: string, value: number) => string
valueId?: string
}
export function Slider({ label, value, min, max, step, unit, onChange, formatValue, valueId }: SliderProps) {
const displayValue = formatValue && valueId ? formatValue(valueId, value) : `${value}${unit || ''}`
return (
<div className="flex flex-col gap-2">
<div className="flex justify-between items-baseline">
<label className="font-mono text-[10px] tracking-[0.2em] text-white">
{label.toUpperCase()}
</label>
<span className="font-mono text-[10px] text-white">
{displayValue}
</span>
</div>
<input
type="range"
min={min}
max={max}
step={step}
value={value}
onChange={(e) => onChange(Number(e.target.value))}
className="w-full h-[2px] bg-white appearance-none cursor-pointer slider"
/>
</div>
)
}

View 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>
)
}

View File

@ -0,0 +1,96 @@
import { useStore } from '@nanostores/react'
import { ENGINE_CONTROLS } from '../../config/parameters'
import { getComplexityLabel, getBitDepthLabel, getSampleRateLabel, getAlgorithmLabel } from '../../utils/formatters'
import type { EffectValues } from '../../types/effects'
import { Knob } from '../ui/Knob'
import { synthesisMode } from '../../stores/synthesisMode'
interface EngineControlsProps {
values: EffectValues
onChange: (parameterId: string, value: number) => void
onMapClick?: (paramId: string, lfoIndex: number) => void
getMappedLFOs?: (paramId: string) => number[]
showOnlySliders?: boolean
showOnlyKnobs?: boolean
}
const KNOB_PARAMS = ['masterVolume', 'pitch', 'a', 'b', 'c', 'd']
export function EngineControls({ values, onChange, onMapClick, getMappedLFOs, showOnlySliders, showOnlyKnobs }: EngineControlsProps) {
const mode = useStore(synthesisMode)
const formatValue = (id: string, value: number): string => {
switch (id) {
case 'sampleRate':
return getSampleRateLabel(value)
case 'complexity':
return getComplexityLabel(value)
case 'bitDepth':
return getBitDepthLabel(value)
case 'fmAlgorithm':
return getAlgorithmLabel(value)
default: {
const param = ENGINE_CONTROLS[0].parameters.find(p => p.id === id)
const formattedValue = Number.isInteger(value) ? value.toString() : value.toFixed(1)
return `${formattedValue}${param?.unit || ''}`
}
}
}
return (
<div className="flex flex-wrap items-center gap-2 md:gap-4 xl:gap-6">
{ENGINE_CONTROLS[0].parameters.map(param => {
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) {
return (
<Knob
key={param.id}
label={param.label}
value={(values[param.id] as number) ?? param.default}
min={param.min as number}
max={param.max as number}
step={param.step as number}
unit={param.unit}
onChange={(value) => onChange(param.id, value)}
formatValue={formatValue}
valueId={param.id}
size={40}
paramId={param.id}
onMapClick={onMapClick}
mappedLFOs={getMappedLFOs ? getMappedLFOs(param.id) : []}
/>
)
}
return (
<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 gap-1">
<label className="font-mono text-[7px] lg:text-[9px] tracking-[0.1em] lg:tracking-[0.15em] text-white truncate">
{param.label.toUpperCase()}
</label>
<span className="font-mono text-[7px] lg:text-[9px] text-white whitespace-nowrap">
{formatValue(param.id, (values[param.id] as number) ?? param.default)}
</span>
</div>
<input
type="range"
min={param.min}
max={param.max}
step={param.step}
value={(values[param.id] as number) ?? param.default}
onChange={(e) => onChange(param.id, Number(e.target.value))}
className="w-full h-[2px] bg-white appearance-none cursor-pointer"
/>
</div>
)
})}
</div>
)
}

View File

@ -0,0 +1,61 @@
import { useStore } from '@nanostores/react'
import { lfoSettings } from '../../stores/settings'
import { toggleMappingMode } from '../../stores/mappingMode'
import { LFOScope } from '../scopes/LFOScope'
import type { LFOConfig } from '../../stores/settings'
import type { LFOWaveform } from '../../domain/modulation/LFO'
interface LFOPanelProps {
onChange: (lfoIndex: number, config: LFOConfig) => void
onUpdateDepth: (lfoIndex: number, paramId: string, depth: number) => void
onRemoveMapping: (lfoIndex: number, paramId: string) => void
}
type LFOKey = 'lfo1' | 'lfo2' | 'lfo3' | 'lfo4'
export function LFOPanel({ onChange, onUpdateDepth, onRemoveMapping }: LFOPanelProps) {
const lfoValues = useStore(lfoSettings)
const handleLFOChange = (lfoKey: LFOKey, lfoIndex: number, frequency: number, phase: number, waveform: LFOWaveform) => {
const lfo = lfoValues[lfoKey]
const updated = { ...lfo, frequency, phase, waveform }
lfoSettings.setKey(lfoKey, updated)
onChange(lfoIndex, updated)
}
const handleMapClick = (_lfoKey: LFOKey, lfoIndex: number) => {
toggleMappingMode(lfoIndex)
}
const lfoConfigs: Array<{ key: LFOKey; index: number }> = [
{ key: 'lfo1', index: 0 },
{ key: 'lfo2', index: 1 },
{ key: 'lfo3', index: 2 },
{ key: 'lfo4', index: 3 }
]
return (
<div className="bg-black border-t-2 border-white">
<div className="grid grid-cols-2 sm:grid-cols-4 gap-[1px] bg-white p-[1px]">
{lfoConfigs.map(({ key, index }) => {
const lfo = lfoValues[key]
return (
<div key={key} className="px-2 py-2 flex items-center bg-black">
<LFOScope
lfoIndex={index}
waveform={lfo.waveform}
frequency={lfo.frequency}
phase={lfo.phase}
mappings={lfo.mappings}
onChange={(freq, phase, waveform) => handleLFOChange(key, index, freq, phase, waveform)}
onMapClick={() => handleMapClick(key, index)}
onUpdateDepth={(paramId, depth) => onUpdateDepth(index, paramId, depth)}
onRemoveMapping={(paramId) => onRemoveMapping(index, paramId)}
/>
</div>
)
})}
</div>
</div>
)
}

View File

@ -0,0 +1,81 @@
import { parameterRegistry } from '../../domain/modulation/ParameterRegistry'
interface Mapping {
targetParam: string
depth: number
}
interface MappingEditorProps {
lfoIndex: number
mappings: Mapping[]
onUpdateDepth: (paramId: string, depth: number) => void
onRemoveMapping: (paramId: string) => void
onClose: () => void
}
export function MappingEditor({ lfoIndex, mappings, onUpdateDepth, onRemoveMapping, onClose }: MappingEditorProps) {
return (
<>
<div className="fixed inset-0 z-50" onClick={onClose} />
<div className="fixed inset-0 pointer-events-none flex items-center justify-center z-50">
<div
className="bg-black border-2 border-white p-4 min-w-[300px] max-w-[400px] pointer-events-auto"
onClick={(e) => e.stopPropagation()}
>
<div className="flex justify-between items-center mb-4">
<h2 className="font-mono text-sm tracking-[0.15em] text-white">
LFO {lfoIndex + 1} MAPPINGS
</h2>
<button
onClick={onClose}
className="font-mono text-xs text-white hover:bg-white hover:text-black px-2 py-1 border-2 border-white"
>
</button>
</div>
{mappings.length === 0 ? (
<div className="font-mono text-xs text-white text-center py-4">
NO MAPPINGS
</div>
) : (
<div className="space-y-3">
{mappings.map((mapping) => {
const meta = parameterRegistry.getMetadata(mapping.targetParam)
return (
<div key={mapping.targetParam} className="border-2 border-white p-2">
<div className="flex justify-between items-center mb-2">
<span className="font-mono text-xs text-white tracking-[0.1em]">
{meta?.label ?? mapping.targetParam}
</span>
<button
onClick={() => onRemoveMapping(mapping.targetParam)}
className="font-mono text-[10px] text-white hover:bg-white hover:text-black px-1 border border-white"
>
REMOVE
</button>
</div>
<div className="flex items-center gap-2">
<input
type="range"
min="0"
max="100"
step="1"
value={mapping.depth}
onChange={(e) => onUpdateDepth(mapping.targetParam, Number(e.target.value))}
className="flex-1 h-[2px] appearance-none cursor-pointer slider bg-white"
/>
<span className="font-mono text-xs text-white w-12 text-right">
{mapping.depth}%
</span>
</div>
</div>
)
})}
</div>
)}
</div>
</div>
</>
)
}

22
src/components/index.ts Normal file
View 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'

View File

@ -0,0 +1,9 @@
import { HelpModal } from './HelpModal'
interface AudioContextWarningProps {
onDismiss: () => void
}
export function AudioContextWarning({ onDismiss }: AudioContextWarningProps) {
return <HelpModal onClose={onDismiss} showStartButton />
}

View 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>
)
}

View File

@ -0,0 +1,256 @@
import { useEffect, useRef, useState, useCallback } from 'react'
import { useStore } from '@nanostores/react'
import { LFO, type LFOWaveform } from '../../domain/modulation/LFO'
import { mappingMode } from '../../stores/mappingMode'
import { parameterRegistry } from '../../domain/modulation/ParameterRegistry'
import { MappingEditor } from '../controls/MappingEditor'
interface LFOScopeProps {
lfoIndex: number
waveform: LFOWaveform
frequency: number
phase: number
mappings: Array<{ targetParam: string; depth: number }>
onChange: (frequency: number, phase: number, waveform: LFOWaveform) => void
onMapClick: () => void
onUpdateDepth: (paramId: string, depth: number) => void
onRemoveMapping: (paramId: string) => void
}
const WAVEFORMS: LFOWaveform[] = ['sine', 'triangle', 'square', 'sawtooth', 'random']
const CANVAS_HEIGHT = 60
const MIN_FREQ = 0.01
const MAX_FREQ = 20
export function LFOScope({ lfoIndex, waveform, frequency, phase, mappings, onChange, onMapClick, onUpdateDepth, onRemoveMapping }: LFOScopeProps) {
const canvasRef = useRef<HTMLCanvasElement>(null)
const lfoRef = useRef<LFO | null>(null)
const animationRef = useRef<number | null>(null)
const [isDragging, setIsDragging] = useState(false)
const [showMappings, setShowMappings] = useState(false)
const [showEditor, setShowEditor] = useState(false)
const dragStartRef = useRef<{ x: number; y: number; freq: number; phase: number; moved: boolean } | null>(null)
const mappingModeState = useStore(mappingMode)
const [canvasWidth, setCanvasWidth] = useState(340)
const getLFOValueAtPhase = useCallback((phaseVal: number): number => {
const normalizedPhase = phaseVal % 1
switch (waveform) {
case 'sine':
return Math.sin(normalizedPhase * 2 * Math.PI)
case 'triangle':
return normalizedPhase < 0.5
? -1 + 4 * normalizedPhase
: 3 - 4 * normalizedPhase
case 'square':
return normalizedPhase < 0.5 ? 1 : -1
case 'sawtooth':
return 2 * normalizedPhase - 1
case 'random':
return Math.sin(normalizedPhase * 2 * Math.PI)
default:
return 0
}
}, [waveform])
useEffect(() => {
if (!lfoRef.current) {
lfoRef.current = new LFO(new AudioContext(), frequency, phase, waveform)
} else {
lfoRef.current.setFrequency(frequency)
lfoRef.current.setPhase(phase)
lfoRef.current.setWaveform(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(() => {
const canvas = canvasRef.current
if (!canvas) return
const ctx = canvas.getContext('2d')
if (!ctx) return
let time = 0
const render = () => {
if (!lfoRef.current) return
ctx.fillStyle = '#000000'
ctx.fillRect(0, 0, canvasWidth, CANVAS_HEIGHT)
ctx.strokeStyle = '#ffffff'
ctx.lineWidth = 2
ctx.beginPath()
const samples = canvasWidth
const centerY = CANVAS_HEIGHT / 2
for (let x = 0; x < samples; x++) {
const t = (x / samples) + time
const phase = t % 1
const value = getLFOValueAtPhase(phase)
const y = centerY - (value * (centerY - 4))
if (x === 0) {
ctx.moveTo(x, y)
} else {
ctx.lineTo(x, y)
}
}
ctx.stroke()
ctx.fillStyle = '#ffffff'
ctx.font = '9px monospace'
ctx.textAlign = 'left'
ctx.fillText(`${frequency.toFixed(2)}Hz`, 4, 12)
ctx.fillText(`${phase.toFixed(0)}°`, 4, 24)
time += frequency * 0.016
animationRef.current = requestAnimationFrame(render)
}
render()
return () => {
if (animationRef.current) {
cancelAnimationFrame(animationRef.current)
}
}
}, [frequency, waveform, phase, getLFOValueAtPhase, canvasWidth])
const handleMouseDown = (e: React.MouseEvent<HTMLCanvasElement>) => {
if (e.button === 2) return
setIsDragging(true)
dragStartRef.current = {
x: e.clientX,
y: e.clientY,
freq: frequency,
phase: phase,
moved: false
}
}
const handleMouseMove = (e: React.MouseEvent<HTMLCanvasElement>) => {
if (!isDragging || !dragStartRef.current) return
const deltaY = dragStartRef.current.y - e.clientY
const deltaX = e.clientX - dragStartRef.current.x
const distance = Math.sqrt(deltaX * deltaX + deltaY * deltaY)
if (distance > 3) {
dragStartRef.current.moved = true
}
if (!dragStartRef.current.moved) return
const freqSensitivity = 0.05
let newFreq = dragStartRef.current.freq * Math.exp(deltaY * freqSensitivity)
newFreq = Math.max(MIN_FREQ, Math.min(MAX_FREQ, newFreq))
const phaseSensitivity = 2
let newPhase = (dragStartRef.current.phase + deltaX * phaseSensitivity) % 360
if (newPhase < 0) newPhase += 360
onChange(newFreq, newPhase, waveform)
}
const handleMouseUp = () => {
if (dragStartRef.current && !dragStartRef.current.moved) {
onMapClick()
}
setIsDragging(false)
dragStartRef.current = null
}
const handleContextMenu = (e: React.MouseEvent<HTMLCanvasElement>) => {
e.preventDefault()
setShowEditor(true)
}
const handleDoubleClick = () => {
const currentIndex = WAVEFORMS.indexOf(waveform)
const nextIndex = (currentIndex + 1) % WAVEFORMS.length
const nextWaveform = WAVEFORMS[nextIndex]
onChange(frequency, phase, nextWaveform)
}
const isActive = mappingModeState.isActive && mappingModeState.activeLFO === lfoIndex
const hasMappings = mappings.length > 0
return (
<div className="flex items-center gap-1 w-full relative">
<canvas
ref={canvasRef}
height={CANVAS_HEIGHT}
className={`border-2 border-white cursor-move w-full ${
isActive ? 'animate-pulse' : ''
}`}
onMouseDown={handleMouseDown}
onMouseMove={handleMouseMove}
onMouseUp={handleMouseUp}
onMouseLeave={handleMouseUp}
onContextMenu={handleContextMenu}
onDoubleClick={handleDoubleClick}
onMouseEnter={() => setShowMappings(true)}
onMouseOut={() => setShowMappings(false)}
/>
{isActive && (
<div className="absolute top-2 right-2 bg-black border border-white px-2 py-1 pointer-events-none">
<div className="font-mono text-[9px] text-white tracking-wider">
CLICK PARAM TO MAP | ESC TO EXIT
</div>
</div>
)}
{showMappings && hasMappings && (
<div className="absolute left-2 bottom-2 bg-black border border-white p-1 z-10 pointer-events-none">
<div className="font-mono text-[8px] text-white">
{mappings.map((m, i) => {
const meta = parameterRegistry.getMetadata(m.targetParam)
return (
<div key={i} className="whitespace-nowrap">
{meta?.label ?? m.targetParam} ({m.depth}%)
</div>
)
})}
</div>
</div>
)}
{showEditor && (
<MappingEditor
lfoIndex={lfoIndex}
mappings={mappings}
onUpdateDepth={onUpdateDepth}
onRemoveMapping={onRemoveMapping}
onClose={() => setShowEditor(false)}
/>
)}
</div>
)
}

View File

@ -1,6 +1,11 @@
import { useRef, useEffect } from 'react'
import { Download, RefreshCw } from 'lucide-react'
import { generateWaveformData, drawWaveform } from '../utils/waveformGenerator'
import { useStore } from '@nanostores/react'
import { Download, Dices } from 'lucide-react'
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 {
formula: string
@ -8,16 +13,21 @@ interface BytebeatTileProps {
col: number
isPlaying: boolean
isQueued: boolean
isRegenerating: boolean
isFocused: boolean
playbackPosition: number
a: number
b: number
c: number
d: number
onPlay: (formula: string, row: number, col: number) => void
onDoubleClick: (formula: string, row: number, col: number) => void
onDownload: (formula: string, filename: string) => void
onRegenerate: (row: number, col: number) => void
}
export function BytebeatTile({ formula, row, col, isPlaying, isQueued, isRegenerating, playbackPosition, 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 mode = useStore(synthesisMode)
useEffect(() => {
const canvas = canvasRef.current
@ -27,10 +37,19 @@ export function BytebeatTile({ formula, row, col, isPlaying, isQueued, isRegener
canvas.width = rect.width * window.devicePixelRatio
canvas.height = rect.height * window.devicePixelRatio
const waveformData = generateWaveformData(formula, canvas.width)
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)'
drawWaveform(canvas, waveformData, color)
}, [formula, isPlaying, isQueued])
}, [formula, isPlaying, isQueued, a, b, c, d, mode])
const handleDownload = (e: React.MouseEvent) => {
e.stopPropagation()
@ -44,11 +63,12 @@ export function BytebeatTile({ formula, row, col, isPlaying, isQueued, isRegener
return (
<div
data-tile-id={`${row}-${col}`}
onClick={() => onPlay(formula, row, col)}
onDoubleClick={() => onDoubleClick(formula, row, col)}
className={`relative hover:scale-[0.98] transition-all duration-150 font-mono p-3 flex items-center justify-between gap-3 cursor-pointer overflow-hidden ${
isPlaying ? 'bg-white text-black' : isQueued ? 'bg-black text-white animate-pulse' : isRegenerating ? 'bg-black text-white border-2 border-white' : 'bg-black text-white'
}`}
className={`relative hover:scale-[0.98] transition-all duration-150 font-mono p-3 flex items-center justify-between gap-1 cursor-pointer overflow-hidden ${
isPlaying ? 'bg-white text-black' : isQueued ? 'bg-black text-white animate-pulse' : 'bg-black text-white'
} ${isFocused ? 'outline outline-2 outline-white outline-offset-[-4px]' : ''}`}
>
<canvas
ref={canvasRef}
@ -61,9 +81,13 @@ export function BytebeatTile({ formula, row, col, isPlaying, isQueued, isRegener
/>
)}
<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 className="flex gap-2 flex-shrink-0 relative z-10">
<div className="flex gap-1 flex-shrink-0 relative z-10">
<div
onClick={handleRegenerate}
className={`p-2 border transition-all duration-150 cursor-pointer hover:scale-105 ${
@ -72,7 +96,7 @@ export function BytebeatTile({ formula, row, col, isPlaying, isQueued, isRegener
: 'bg-white text-black border-white'
}`}
>
<RefreshCw size={14} strokeWidth={2} className={isRegenerating ? 'animate-spin' : ''} />
<Dices size={14} strokeWidth={2} />
</div>
<div
onClick={handleDownload}

View File

@ -0,0 +1,100 @@
import { useState, useEffect } from 'react'
import { Play, Square, Dices } from 'lucide-react'
interface FormulaEditorProps {
formula: string
isPlaying: boolean
isFocused: boolean
playbackPosition: number
onEvaluate: (formula: string) => void
onStop: () => void
onRandom: () => string
}
export function FormulaEditor({ formula: externalFormula, isPlaying, isFocused, playbackPosition, onEvaluate, onStop, onRandom }: FormulaEditorProps) {
const [formula, setFormula] = useState(externalFormula)
useEffect(() => {
setFormula(externalFormula)
}, [externalFormula])
const handleEvaluate = () => {
if (formula.trim()) {
onEvaluate(formula)
}
}
const handleRandom = () => {
const newFormula = onRandom()
setFormula(newFormula)
onEvaluate(newFormula)
}
const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === 'Enter') {
if (e.ctrlKey) {
e.preventDefault()
handleEvaluate()
} else {
e.preventDefault()
}
}
}
return (
<div
className={`relative font-mono p-3 flex items-center gap-3 transition-all duration-150 ${
isPlaying ? 'bg-white text-black' : 'bg-black text-white'
} ${isFocused ? 'ring-2 ring-white ring-inset' : ''}`}
>
{isPlaying && (
<div
className="absolute left-0 top-0 bottom-0 bg-black opacity-10 transition-all duration-75 ease-linear"
style={{ width: `${playbackPosition * 100}%` }}
/>
)}
<input
type="text"
value={formula}
onChange={(e) => setFormula(e.target.value)}
onKeyDown={handleKeyDown}
placeholder="Enter bytebeat formula..."
className={`flex-1 bg-transparent border-none outline-none font-mono text-xs relative z-10 ${
isPlaying ? 'text-black placeholder:text-black/50' : 'text-white placeholder:text-white/50'
}`}
/>
<div className="flex gap-2 relative z-10">
<button
onClick={handleRandom}
className={`p-2 border transition-all duration-150 cursor-pointer hover:scale-105 ${
isPlaying
? 'bg-black text-white border-black'
: 'bg-white text-black border-white'
}`}
>
<Dices size={14} strokeWidth={2} />
</button>
{isPlaying && (
<button
onClick={onStop}
className="px-4 py-2 border transition-all duration-150 cursor-pointer hover:scale-105 flex items-center gap-2 font-mono text-[10px] tracking-[0.2em] flex-shrink-0 bg-black text-white border-black"
>
<Square size={12} strokeWidth={2} fill="currentColor" />
STOP
</button>
)}
<button
onClick={handleEvaluate}
className={`px-4 py-2 border transition-all duration-150 cursor-pointer hover:scale-105 flex items-center gap-2 font-mono text-[10px] tracking-[0.2em] flex-shrink-0 ${
isPlaying
? 'bg-black text-white border-black'
: 'bg-white text-black border-white'
}`}
>
<Play size={12} strokeWidth={2} fill="currentColor" />
EVAL
</button>
</div>
</div>
)
}

View File

@ -0,0 +1,27 @@
interface DropdownProps {
label: string
value: string
options: { value: string; label: string }[]
onChange: (value: string) => void
}
export function Dropdown({ label, value, options, onChange }: DropdownProps) {
return (
<div className="flex flex-col gap-1">
<label className="font-mono text-[9px] tracking-[0.15em] text-white">
{label.toUpperCase()}
</label>
<select
value={value}
onChange={(e) => onChange(e.target.value)}
className="bg-black text-white border-2 border-white font-mono text-[10px] tracking-[0.1em] px-2 py-1 cursor-pointer hover:bg-white hover:text-black transition-colors"
>
{options.map((option) => (
<option key={option.value} value={option.value}>
{option.label.toUpperCase()}
</option>
))}
</select>
</div>
)
}

189
src/components/ui/Knob.tsx Normal file
View File

@ -0,0 +1,189 @@
import { useRef, useState, useEffect, useCallback } from 'react'
import { useStore } from '@nanostores/react'
import { mappingMode } from '../../stores/mappingMode'
interface KnobProps {
label: string
value: number
min: number
max: number
step: number
unit?: string
onChange: (value: number) => void
formatValue?: (id: string, value: number) => string
valueId?: string
size?: number
paramId?: string
onMapClick?: (paramId: string, activeLFO: number) => void
mappedLFOs?: number[]
}
export function Knob({
label,
value,
min,
max,
step,
unit,
onChange,
formatValue,
valueId,
size = 48,
paramId,
onMapClick,
mappedLFOs = []
}: KnobProps) {
const [isDragging, setIsDragging] = useState(false)
const startYRef = useRef<number>(0)
const startValueRef = useRef<number>(0)
const mappingModeState = useStore(mappingMode)
const formatNumber = (num: number) => {
if (Number.isInteger(num)) return num.toString()
return num.toFixed(1)
}
const displayValue = formatValue && valueId
? formatValue(valueId, value)
: `${formatNumber(value)}${unit || ''}`
const isInMappingMode = mappingModeState.isActive && paramId
const hasMappings = mappedLFOs.length > 0
const normalizedValue = (value - min) / (max - min)
const angle = -225 + normalizedValue * 270
const fontSize = size <= 32 ? 'text-[7px]' : size <= 36 ? 'text-[8px]' : 'text-[9px]'
const handleMouseDown = (e: React.MouseEvent) => {
if (isInMappingMode && paramId && mappingModeState.activeLFO !== null && onMapClick) {
onMapClick(paramId, mappingModeState.activeLFO)
e.preventDefault()
return
}
setIsDragging(true)
startYRef.current = e.clientY
startValueRef.current = value
e.preventDefault()
}
const handleTouchStart = (e: React.TouchEvent) => {
if (isInMappingMode && paramId && mappingModeState.activeLFO !== null && onMapClick) {
onMapClick(paramId, mappingModeState.activeLFO)
e.preventDefault()
return
}
setIsDragging(true)
startYRef.current = e.touches[0].clientY
startValueRef.current = value
e.preventDefault()
}
const handleMouseMove = useCallback((e: MouseEvent) => {
if (!isDragging) return
const deltaY = startYRef.current - e.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)
}, [isDragging, max, min, step, onChange])
const handleTouchMove = useCallback((e: TouchEvent) => {
if (!isDragging) return
const deltaY = startYRef.current - e.touches[0].clientY
const range = max - min
const sensitivity = range / 200
const newValue = Math.max(min, Math.min(max, startValueRef.current + deltaY * sensitivity))
const steppedValue = Math.round(newValue / step) * step
onChange(steppedValue)
e.preventDefault()
}, [isDragging, max, min, step, onChange])
const handleMouseUp = useCallback(() => {
setIsDragging(false)
}, [])
const handleTouchEnd = useCallback(() => {
setIsDragging(false)
}, [])
useEffect(() => {
if (isDragging) {
window.addEventListener('mousemove', handleMouseMove)
window.addEventListener('mouseup', handleMouseUp)
window.addEventListener('touchmove', handleTouchMove, { passive: false })
window.addEventListener('touchend', handleTouchEnd)
return () => {
window.removeEventListener('mousemove', handleMouseMove)
window.removeEventListener('mouseup', handleMouseUp)
window.removeEventListener('touchmove', handleTouchMove)
window.removeEventListener('touchend', handleTouchEnd)
}
}
}, [isDragging, handleMouseMove, handleMouseUp, handleTouchMove, handleTouchEnd])
return (
<div className="relative flex flex-col items-center">
<div
className={`relative select-none ${isInMappingMode ? 'cursor-pointer' : 'cursor-ns-resize'}`}
onMouseDown={handleMouseDown}
onTouchStart={handleTouchStart}
style={{ width: size, height: size }}
>
<svg
width={size}
height={size}
viewBox={`0 0 ${size} ${size}`}
>
<circle
cx={size / 2}
cy={size / 2}
r={(size - 4) / 2}
fill="none"
stroke="white"
strokeWidth="2"
className={isInMappingMode ? 'animate-pulse' : ''}
/>
<circle
cx={size / 2}
cy={size / 2}
r={(size - 8) / 2}
fill="black"
/>
<line
x1={size / 2 + Math.cos((angle * Math.PI) / 180) * ((size - 16) / 2)}
y1={size / 2 + Math.sin((angle * Math.PI) / 180) * ((size - 16) / 2)}
x2={size / 2 + Math.cos((angle * Math.PI) / 180) * ((size - 4) / 2)}
y2={size / 2 + Math.sin((angle * Math.PI) / 180) * ((size - 4) / 2)}
stroke="white"
strokeWidth="2"
strokeLinecap="square"
/>
{hasMappings && (
<circle
cx={size / 2}
cy={8}
r={2}
fill="white"
/>
)}
</svg>
<div className="absolute inset-0 flex items-center justify-center pointer-events-none">
<span className={`font-mono ${fontSize} tracking-[0.15em] text-white ${isInMappingMode ? 'animate-pulse' : ''}`}>
{isDragging ? displayValue : label.toUpperCase()}
</span>
</div>
</div>
</div>
)
}

View File

@ -0,0 +1,82 @@
import { useStore } from '@nanostores/react'
import { mappingMode } from '../../stores/mappingMode'
interface SliderProps {
label: string
value: number
min: number
max: number
step: number
unit?: string
onChange: (value: number) => void
formatValue?: (id: string, value: number) => string
valueId?: string
paramId?: string
onMapClick?: (paramId: string, activeLFO: number) => void
mappedLFOs?: number[]
}
export function Slider({
label,
value,
min,
max,
step,
unit,
onChange,
formatValue,
valueId,
paramId,
onMapClick,
mappedLFOs = []
}: SliderProps) {
const mappingModeState = useStore(mappingMode)
const formatNumber = (num: number) => {
if (Number.isInteger(num)) return num.toString()
return num.toFixed(1)
}
const displayValue = formatValue && valueId
? formatValue(valueId, value)
: `${formatNumber(value)}${unit || ''}`
const isInMappingMode = !!(mappingModeState.isActive && paramId)
const hasMappings = mappedLFOs.length > 0
const handleClick = () => {
if (isInMappingMode && paramId && mappingModeState.activeLFO !== null && onMapClick) {
onMapClick(paramId, mappingModeState.activeLFO)
}
}
return (
<div
className={`flex flex-col gap-2 ${isInMappingMode ? 'cursor-pointer' : ''}`}
onClick={handleClick}
>
<div className="flex justify-between items-baseline">
<label className={`font-mono text-[10px] tracking-[0.2em] ${
isInMappingMode ? 'text-white animate-pulse' : hasMappings ? 'text-white' : 'text-white'
}`}>
{label.toUpperCase()}
{hasMappings && <span className="ml-1 text-[8px]"></span>}
</label>
<span className="font-mono text-[10px] text-white">
{displayValue}
</span>
</div>
<input
type="range"
min={min}
max={max}
step={step}
value={value}
onChange={(e) => onChange(Number(e.target.value))}
className={`w-full h-[2px] appearance-none cursor-pointer slider ${
hasMappings ? 'bg-white opacity-80' : 'bg-white'
}`}
disabled={isInMappingMode}
/>
</div>
)
}

View 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
View File

@ -0,0 +1,2 @@
export { ENGINE_CONTROLS, EFFECTS, SAMPLE_RATES, getDefaultEngineValues, getDefaultEffectValues, getSampleRateFromIndex } from './parameters'
export { FM_ALGORITHMS, getAlgorithmById, getAlgorithmName } from './fmAlgorithms'

View File

@ -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',
name: 'Engine',
@ -10,18 +10,18 @@ export const ENGINE_CONTROLS: EffectConfig[] = [
label: 'Sample Rate',
min: 0,
max: 3,
default: 1,
default: 3,
step: 1,
unit: ''
},
{
id: 'loopDuration',
id: 'loopCount',
label: 'Loop',
min: 2,
max: 8,
default: 4,
step: 2,
unit: 's'
min: 1,
max: 10,
default: 2,
step: 1,
unit: ''
},
{
id: 'complexity',
@ -37,115 +37,227 @@ export const ENGINE_CONTROLS: EffectConfig[] = [
label: 'Bit Depth',
min: 0,
max: 2,
default: 0,
default: 2,
step: 1,
unit: ''
},
{
id: 'masterVolume',
label: 'Volume',
label: 'Vol',
min: 0,
max: 100,
default: 75,
max: 80,
default: 50,
step: 1,
unit: '%'
},
{
id: 'pitch',
label: 'Pitch',
min: 0.1,
min: 0.25,
max: 4,
default: 1,
step: 0.01,
unit: 'x'
},
{
id: 'a',
label: 'A',
min: 0,
max: 255,
default: 8,
step: 1,
unit: ''
},
{
id: 'b',
label: 'B',
min: 0,
max: 255,
default: 16,
step: 1,
unit: ''
},
{
id: 'c',
label: 'C',
min: 0,
max: 255,
default: 32,
step: 1,
unit: ''
},
{
id: 'd',
label: 'D',
min: 0,
max: 255,
default: 64,
step: 1,
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',
name: 'Filter',
bypassable: true,
parameters: [
{
id: 'hpEnable',
label: 'HP',
id: 'filterMode',
label: 'Mode',
min: 0,
max: 1,
default: 0,
max: 0,
default: 'lowpass',
step: 1,
unit: ''
unit: '',
options: [
{ value: 'lowpass', label: 'LP' },
{ value: 'highpass', label: 'HP' },
{ value: 'bandpass', label: 'BP' },
{ value: 'notch', label: 'Notch' }
]
},
{
id: 'hpFreq',
label: 'HP Freq',
min: 20,
max: 10000,
default: 1000,
step: 10,
unit: 'Hz'
},
{
id: 'hpRes',
label: 'HP Q',
min: 0.1,
max: 20,
default: 1,
step: 0.1,
unit: ''
},
{
id: 'lpEnable',
label: 'LP',
min: 0,
max: 1,
default: 0,
step: 1,
unit: ''
},
{
id: 'lpFreq',
label: 'LP Freq',
id: 'filterFreq',
label: 'Freq',
min: 20,
max: 20000,
default: 5000,
step: 10,
unit: 'Hz'
},
{
id: 'lpRes',
label: 'LP Q',
min: 0.1,
max: 20,
default: 1,
step: 0.1,
unit: ''
},
{
id: 'bpEnable',
label: 'BP',
min: 0,
max: 1,
default: 0,
step: 1,
unit: ''
},
{
id: 'bpFreq',
label: 'BP Freq',
min: 20,
max: 10000,
default: 1000,
step: 10,
unit: 'Hz'
},
{
id: 'bpRes',
label: 'BP Q',
min: 0.1,
max: 20,
default: 1,
id: 'filterRes',
label: 'Res',
min: 0.05,
max: 10,
default: 0.707,
step: 0.1,
unit: ''
}
@ -153,39 +265,55 @@ export const EFFECTS: EffectConfig[] = [
},
{
id: 'foldcrush',
name: 'Fold and Crush',
name: 'Distortion',
bypassable: true,
parameters: [
{
id: 'clipMode',
label: 'Mode',
min: 0,
max: 2,
default: 0,
max: 0,
default: 'fold',
step: 1,
unit: ''
unit: '',
options: [
{ value: 'tube', label: 'Tube' },
{ value: 'tape', label: 'Tape' },
{ value: 'fuzz', label: 'Fuzz' },
{ value: 'fold', label: 'Fold' },
{ value: 'crush', label: 'Crush' }
]
},
{
id: 'wavefolderDrive',
label: 'Drive',
min: 1,
min: 0,
max: 10,
default: 1,
default: 0,
step: 0.1,
unit: 'x'
unit: ''
},
{
id: 'bitcrushDepth',
label: 'Depth',
label: 'Bits',
min: 1,
max: 16,
default: 16,
step: 1,
unit: 'bit'
unit: ''
},
{
id: 'bitcrushRate',
label: 'Rate',
label: 'Downsample',
min: 0,
max: 100,
default: 0,
step: 1,
unit: '%'
},
{
id: 'glitchAmount',
label: 'Glitch',
min: 0,
max: 100,
default: 0,
@ -203,8 +331,8 @@ export const EFFECTS: EffectConfig[] = [
id: 'delayTime',
label: 'Time',
min: 10,
max: 2000,
default: 250,
max: 10000,
default: 500,
step: 10,
unit: 'ms'
},
@ -236,20 +364,20 @@ export const EFFECTS: EffectConfig[] = [
unit: '%'
},
{
id: 'delaySaturation',
label: 'Saturation',
id: 'delayPingPong',
label: 'Ping-Pong',
min: 0,
max: 100,
default: 20,
default: 0,
step: 1,
unit: '%'
},
{
id: 'delayFlutter',
label: 'Flutter',
id: 'delayDiffusion',
label: 'Diffusion',
min: 0,
max: 100,
default: 15,
default: 0,
step: 1,
unit: '%'
}
@ -262,7 +390,7 @@ export const EFFECTS: EffectConfig[] = [
parameters: [
{
id: 'reverbWetDry',
label: 'Amount',
label: 'Mix',
min: 0,
max: 100,
default: 0,
@ -270,17 +398,8 @@ export const EFFECTS: EffectConfig[] = [
unit: '%'
},
{
id: 'reverbDecay',
label: 'Decay',
min: 0.1,
max: 5,
default: 2,
step: 0.1,
unit: 's'
},
{
id: 'reverbDamping',
label: 'Damping',
id: 'reverbSize',
label: 'Size',
min: 0,
max: 100,
default: 50,
@ -288,17 +407,17 @@ export const EFFECTS: EffectConfig[] = [
unit: '%'
},
{
id: 'reverbPanRate',
label: 'Pan Rate',
min: 0,
max: 10,
default: 0,
step: 0.1,
unit: 'Hz'
id: 'reverbDecay',
label: 'Decay',
min: 10,
max: 95,
default: 70,
step: 1,
unit: '%'
},
{
id: 'reverbPanWidth',
label: 'Pan Width',
id: 'reverbDamping',
label: 'Damping',
min: 0,
max: 100,
default: 50,
@ -309,8 +428,8 @@ export const EFFECTS: EffectConfig[] = [
}
]
export function getDefaultEffectValues(): Record<string, number | boolean> {
const defaults: Record<string, number | boolean> = {}
export function getDefaultEffectValues(): Record<string, number | boolean | string> {
const defaults: Record<string, number | boolean | string> = {}
EFFECTS.forEach(effect => {
effect.parameters.forEach(param => {
defaults[param.id] = param.default
@ -326,7 +445,7 @@ export function getDefaultEngineValues(): Record<string, number> {
const defaults: Record<string, number> = {}
ENGINE_CONTROLS.forEach(control => {
control.parameters.forEach(param => {
defaults[param.id] = param.default
defaults[param.id] = param.default as number
})
})
return defaults

23
src/constants/defaults.ts Normal file
View File

@ -0,0 +1,23 @@
export const DEFAULT_VARIABLES = {
a: 8,
b: 16,
c: 32,
d: 64
} as const
export const PLAYBACK_ID = {
CUSTOM: 'custom'
} as const
export const TILE_GRID = {
SIZE: 100,
COLUMNS: 4
} as const
export const DEFAULT_DOWNLOAD_OPTIONS = {
SAMPLE_RATE: 44100,
DURATION: 4,
BIT_DEPTH: 24
} as const
export const LOOP_DURATION = 4

View File

@ -1,5 +1,11 @@
import { EffectsChain } from './effects/EffectsChain'
import { BytebeatSourceEffect } from './effects/BytebeatSourceEffect'
import { FMSourceEffect } from './effects/FMSourceEffect'
import { ModulationEngine } from '../modulation/ModulationEngine'
import type { LFOWaveform } from '../modulation/LFO'
import type { EffectValues } from '../../types/effects'
import type { SynthesisMode } from '../../stores/synthesisMode'
import { getAlgorithmById } from '../../config/fmAlgorithms'
export interface AudioPlayerOptions {
sampleRate: number
@ -8,16 +14,19 @@ export interface AudioPlayerOptions {
export class AudioPlayer {
private audioContext: AudioContext | null = null
private sourceNode: AudioBufferSourceNode | null = null
private bytebeatSource: BytebeatSourceEffect | null = null
private fmSource: FMSourceEffect | null = null
private effectsChain: EffectsChain | null = null
private modulationEngine: ModulationEngine | null = null
private effectValues: EffectValues = {}
private startTime: number = 0
private pauseTime: number = 0
private isLooping: boolean = true
private sampleRate: number
private duration: number
private pitch: number = 1
private workletRegistered: boolean = false
private currentPitch: number = 1.0
private currentMode: SynthesisMode = 'bytebeat'
private currentAlgorithm: number = 0
private currentFeedback: number = 0
constructor(options: AudioPlayerOptions) {
this.sampleRate = options.sampleRate
@ -40,11 +49,12 @@ export class AudioPlayer {
}
private async recreateAudioContext(): Promise<void> {
const wasPlaying = this.sourceNode !== null
const wasPlaying = this.bytebeatSource !== null
this.dispose()
this.audioContext = new AudioContext({ sampleRate: this.sampleRate })
this.workletRegistered = false
await this.registerWorklet(this.audioContext)
this.effectsChain = new EffectsChain(this.audioContext)
@ -52,19 +62,23 @@ export class AudioPlayer {
this.effectsChain.updateEffects(this.effectValues)
if (wasPlaying) {
console.warn('Audio context recreated due to sample rate change. Playback stopped.')
throw new Error('Cannot change sample rate during playback')
}
}
private async registerWorklet(context: AudioContext): Promise<void> {
if (this.workletRegistered) return
try {
await context.audioWorklet.addModule('/worklets/fold-crush-processor.js')
this.workletRegistered = true
} catch (error) {
console.error('Failed to register AudioWorklet:', error)
}
await Promise.all([
context.audioWorklet.addModule('/worklets/svf-processor.js'),
context.audioWorklet.addModule('/worklets/fold-crush-processor.js'),
context.audioWorklet.addModule('/worklets/bytebeat-processor.js'),
context.audioWorklet.addModule('/worklets/fm-processor.js'),
context.audioWorklet.addModule('/worklets/ring-mod-processor.js'),
context.audioWorklet.addModule('/worklets/chorus-processor.js'),
context.audioWorklet.addModule('/worklets/output-limiter.js')
])
this.workletRegistered = true
}
setEffects(values: EffectValues): void {
@ -72,20 +86,18 @@ export class AudioPlayer {
if (this.effectsChain) {
this.effectsChain.updateEffects(values)
}
}
setPitch(pitch: number): void {
this.pitch = pitch
if (this.sourceNode && this.audioContext) {
this.sourceNode.playbackRate.setTargetAtTime(
pitch,
this.audioContext.currentTime,
0.015
)
if (this.modulationEngine) {
const numericValues: Record<string, number> = {}
Object.entries(values).forEach(([key, value]) => {
if (typeof value === 'number') {
numericValues[key] = value
}
})
this.modulationEngine.setBaseValues(numericValues)
}
}
async play(buffer: Float32Array, onEnded?: () => void): Promise<void> {
private async ensureAudioContext(): Promise<void> {
if (!this.audioContext) {
this.audioContext = new AudioContext({ sampleRate: this.sampleRate })
await this.registerWorklet(this.audioContext)
@ -97,77 +109,172 @@ export class AudioPlayer {
this.effectsChain.updateEffects(this.effectValues)
}
if (this.sourceNode) {
this.sourceNode.stop()
if (!this.modulationEngine) {
this.modulationEngine = new ModulationEngine(this.audioContext, 4)
this.registerModulatableParams()
}
}
private registerModulatableParams(): void {
if (!this.modulationEngine || !this.effectsChain) return
const effects = this.effectsChain.getEffects()
for (const effect of effects) {
if (effect.getModulatableParams) {
const params = effect.getModulatableParams()
params.forEach((audioParam, paramId) => {
const baseValue = this.effectValues[paramId] as number ?? audioParam.value
this.modulationEngine!.registerParameter(
paramId,
{ audioParam },
baseValue
)
})
}
}
const audioBuffer = this.audioContext.createBuffer(1, buffer.length, this.sampleRate)
audioBuffer.getChannelData(0).set(buffer)
this.modulationEngine.registerParameter(
'pitch',
{ callback: (value: number) => this.applyPitch(value) },
this.currentPitch
)
}
this.sourceNode = this.audioContext.createBufferSource()
this.sourceNode.buffer = audioBuffer
this.sourceNode.loop = this.isLooping
this.sourceNode.playbackRate.value = this.pitch
setLFOConfig(lfoIndex: number, config: { frequency: number; phase: number; waveform: LFOWaveform; mappings: Array<{ targetParam: string; depth: number }> }): void {
if (!this.modulationEngine) return
if (onEnded) {
this.sourceNode.onended = onEnded
this.modulationEngine.updateLFO(lfoIndex, config.frequency, config.phase, config.waveform)
this.modulationEngine.clearMappings(lfoIndex)
for (const mapping of config.mappings) {
this.modulationEngine.addMapping(lfoIndex, mapping.targetParam, mapping.depth)
}
}
this.sourceNode.connect(this.effectsChain.getInputNode())
this.effectsChain.getOutputNode().connect(this.audioContext.destination)
setMode(mode: SynthesisMode): void {
this.currentMode = mode
}
if (this.pauseTime > 0) {
this.sourceNode.start(0, this.pauseTime)
this.startTime = this.audioContext.currentTime - this.pauseTime
this.pauseTime = 0
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> {
await this.ensureAudioContext()
if (this.currentMode === 'bytebeat') {
if (!this.bytebeatSource) {
this.bytebeatSource = new BytebeatSourceEffect(this.audioContext!)
await this.bytebeatSource.initialize(this.audioContext!)
}
this.bytebeatSource.setLoopLength(this.sampleRate, this.duration)
this.bytebeatSource.setFormula(formula)
this.bytebeatSource.setVariables(a, b, c, d)
this.bytebeatSource.setPlaybackRate(this.currentPitch)
this.bytebeatSource.reset()
this.bytebeatSource.getOutputNode().connect(this.effectsChain!.getInputNode())
} else {
this.sourceNode.start(0)
this.startTime = this.audioContext.currentTime
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)
if (this.modulationEngine) {
this.modulationEngine.start()
}
this.startTime = this.audioContext!.currentTime
}
updateRealtimeVariables(a: number, b: number, c: number, d: number): void {
if (this.currentMode === 'bytebeat' && this.bytebeatSource) {
this.bytebeatSource.setVariables(a, b, c, d)
} else if (this.currentMode === 'fm' && this.fmSource) {
this.fmSource.setOperatorLevels(a, b, c, d)
}
}
setLooping(loop: boolean): void {
this.isLooping = loop
if (this.sourceNode) {
this.sourceNode.loop = loop
private applyPitch(pitch: number): void {
if (this.currentMode === 'bytebeat' && this.bytebeatSource) {
this.bytebeatSource.setPlaybackRate(pitch)
} else if (this.currentMode === 'fm' && this.fmSource) {
this.fmSource.setBaseFrequency(220 * pitch)
}
}
scheduleNextTrack(callback: () => void): void {
if (this.sourceNode) {
this.sourceNode.loop = false
this.sourceNode.onended = callback
updatePitch(pitch: number): void {
this.currentPitch = pitch
this.applyPitch(pitch)
if (this.modulationEngine) {
this.modulationEngine.updateBaseValue('pitch', pitch)
}
}
getPlaybackPosition(): number {
if (!this.audioContext || !this.sourceNode || this.startTime === 0) {
if (!this.audioContext || this.startTime === 0) {
return 0
}
const elapsed = this.audioContext.currentTime - this.startTime
const actualDuration = this.duration / this.pitch
return (elapsed % actualDuration) / actualDuration
}
pause(): void {
if (this.sourceNode && this.audioContext) {
this.pauseTime = this.audioContext.currentTime - this.startTime
this.sourceNode.stop()
this.sourceNode = null
}
return (elapsed % this.duration) / this.duration
}
stop(): void {
if (this.sourceNode) {
this.sourceNode.stop()
this.sourceNode = null
if (this.bytebeatSource) {
this.bytebeatSource.getOutputNode().disconnect()
}
if (this.fmSource) {
this.fmSource.getOutputNode().disconnect()
}
if (this.modulationEngine) {
this.modulationEngine.stop()
}
this.startTime = 0
this.pauseTime = 0
}
dispose(): void {
this.stop()
if (this.bytebeatSource) {
this.bytebeatSource.dispose()
this.bytebeatSource = null
}
if (this.fmSource) {
this.fmSource.dispose()
this.fmSource = null
}
if (this.modulationEngine) {
this.modulationEngine.dispose()
this.modulationEngine = null
}
if (this.effectsChain) {
this.effectsChain.dispose()
this.effectsChain = null
@ -176,5 +283,7 @@ export class AudioPlayer {
this.audioContext.close()
this.audioContext = null
}
this.workletRegistered = false
this.startTime = 0
}
}

View File

@ -1,4 +1,4 @@
export type CompiledFormula = (t: number) => number
export type CompiledFormula = (t: number, a: number, b: number, c: number, d: number) => number
export interface CompilationResult {
success: boolean
@ -8,7 +8,7 @@ export interface CompilationResult {
export function compileFormula(formula: string): CompilationResult {
try {
const compiledFormula = new Function('t', `return ${formula}`) as CompiledFormula
const compiledFormula = new Function('t', 'a', 'b', 'c', 'd', `return ${formula}`) as CompiledFormula
return {
success: true,
compiledFormula
@ -28,7 +28,7 @@ export function testFormula(formula: string): boolean {
}
try {
result.compiledFormula(0)
result.compiledFormula(0, 8, 16, 32, 64)
return true
} catch {
return false

View File

@ -3,22 +3,26 @@ import type { CompiledFormula } from './BytebeatCompiler'
export interface GeneratorOptions {
sampleRate: number
duration: number
a?: number
b?: number
c?: number
d?: number
}
export function generateSamples(
compiledFormula: CompiledFormula,
options: GeneratorOptions
): Float32Array {
const { sampleRate, duration } = options
const { sampleRate, duration, a = 8, b = 16, c = 32, d = 64 } = options
const numSamples = Math.floor(sampleRate * duration)
const buffer = new Float32Array(numSamples)
for (let t = 0; t < numSamples; t++) {
try {
const value = compiledFormula(t)
const value = compiledFormula(t, a, b, c, d)
const byteValue = value & 0xFF
buffer[t] = (byteValue - 128) / 128
} catch (error) {
} catch {
buffer[t] = 0
}
}
@ -31,7 +35,7 @@ export function generateSamplesWithBitDepth(
options: GeneratorOptions,
bitDepth: 8 | 16 | 24
): Float32Array {
const { sampleRate, duration } = options
const { sampleRate, duration, a = 8, b = 16, c = 32, d = 64 } = options
const numSamples = Math.floor(sampleRate * duration)
const buffer = new Float32Array(numSamples)
@ -40,10 +44,10 @@ export function generateSamplesWithBitDepth(
for (let t = 0; t < numSamples; t++) {
try {
const value = compiledFormula(t)
const value = compiledFormula(t, a, b, c, d)
const clampedValue = value & maxValue
buffer[t] = (clampedValue - midPoint) / midPoint
} catch (error) {
} catch {
buffer[t] = 0
}
}

View File

@ -1,13 +1,70 @@
import { encodeWAV } from '../../lib/bytebeat/wavEncoder'
import type { BitDepth } from '../../lib/bytebeat/types'
export type { BitDepth }
export type BitDepth = 8 | 16 | 24
export interface ExportOptions {
sampleRate: number
bitDepth?: BitDepth
}
function encodeWAV(samples: Float32Array, sampleRate: number, bitDepth: BitDepth): Blob {
const numChannels = 1
const bytesPerSample = bitDepth / 8
const blockAlign = numChannels * bytesPerSample
const byteRate = sampleRate * blockAlign
const dataSize = samples.length * bytesPerSample
const bufferSize = 44 + dataSize
const buffer = new ArrayBuffer(bufferSize)
const view = new DataView(buffer)
const writeString = (offset: number, string: string) => {
for (let i = 0; i < string.length; i++) {
view.setUint8(offset + i, string.charCodeAt(i))
}
}
writeString(0, 'RIFF')
view.setUint32(4, 36 + dataSize, true)
writeString(8, 'WAVE')
writeString(12, 'fmt ')
view.setUint32(16, 16, true)
view.setUint16(20, 1, true)
view.setUint16(22, numChannels, true)
view.setUint32(24, sampleRate, true)
view.setUint32(28, byteRate, true)
view.setUint16(32, blockAlign, true)
view.setUint16(34, bitDepth, true)
writeString(36, 'data')
view.setUint32(40, dataSize, true)
const maxValue = Math.pow(2, bitDepth - 1) - 1
let offset = 44
for (let i = 0; i < samples.length; i++) {
const sample = Math.max(-1, Math.min(1, samples[i]))
const intSample = Math.round(sample * maxValue)
if (bitDepth === 8) {
view.setUint8(offset, intSample + 128)
offset += 1
} else if (bitDepth === 16) {
view.setInt16(offset, intSample, true)
offset += 2
} else if (bitDepth === 24) {
const bytes = [
intSample & 0xff,
(intSample >> 8) & 0xff,
(intSample >> 16) & 0xff
]
view.setUint8(offset, bytes[0])
view.setUint8(offset + 1, bytes[1])
view.setUint8(offset + 2, bytes[2])
offset += 3
}
}
return new Blob([buffer], { type: 'audio/wav' })
}
export function exportToWav(
samples: Float32Array,
options: ExportOptions

View File

@ -72,12 +72,12 @@ export class BitcrushEffect implements Effect {
}
}
updateParams(values: Record<string, number>): void {
if (values.bitcrushDepth !== undefined) {
updateParams(values: Record<string, number | string>): void {
if (values.bitcrushDepth !== undefined && typeof values.bitcrushDepth === 'number') {
this.bitDepth = values.bitcrushDepth
}
if (values.bitcrushRate !== undefined) {
if (values.bitcrushRate !== undefined && typeof values.bitcrushRate === 'number') {
this.crushAmount = values.bitcrushRate
}
}

View File

@ -0,0 +1,69 @@
import type { Effect } from './Effect.interface'
export class BytebeatSourceEffect implements Effect {
readonly id = 'bytebeat-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, 'bytebeat-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
}
setFormula(formula: string): void {
if (!this.processorNode) return
this.processorNode.port.postMessage({ type: 'formula', value: formula })
}
setVariables(a: number, b: number, c: number, d: number): void {
if (!this.processorNode) return
this.processorNode.port.postMessage({ type: 'variables', value: { a, b, c, d } })
}
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()
}
}

View 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()
}
}

View File

@ -6,71 +6,118 @@ export class DelayEffect implements Effect {
private audioContext: AudioContext
private inputNode: GainNode
private outputNode: GainNode
private delayNode: DelayNode
private feedbackNode: GainNode
private wetNode: GainNode
private dryNode: GainNode
private wetNode: GainNode
private leftDelayNode: DelayNode
private rightDelayNode: DelayNode
private leftFeedbackNode: GainNode
private rightFeedbackNode: GainNode
private filterNode: BiquadFilterNode
private saturatorNode: WaveShaperNode
private lfoNode: OscillatorNode
private lfoGainNode: GainNode
private dcBlockerNode: BiquadFilterNode
private splitterNode: ChannelSplitterNode
private mergerNode: ChannelMergerNode
private diffusionNodes: BiquadFilterNode[] = []
private diffusionMixNode: GainNode
private diffusionWetNode: GainNode
private diffusionDryNode: GainNode
private bypassed: boolean = false
private currentWetValue: number = 0.5
private currentDryValue: number = 0.5
private currentPingPong: number = 0
constructor(audioContext: AudioContext) {
this.audioContext = audioContext
this.inputNode = audioContext.createGain()
this.outputNode = audioContext.createGain()
this.delayNode = audioContext.createDelay(2.0)
this.feedbackNode = audioContext.createGain()
this.wetNode = audioContext.createGain()
this.dryNode = audioContext.createGain()
this.filterNode = audioContext.createBiquadFilter()
this.saturatorNode = audioContext.createWaveShaper()
this.wetNode = audioContext.createGain()
this.delayNode.delayTime.value = 0.25
this.splitterNode = audioContext.createChannelSplitter(2)
this.mergerNode = audioContext.createChannelMerger(2)
this.leftDelayNode = audioContext.createDelay(11)
this.rightDelayNode = audioContext.createDelay(11)
this.leftFeedbackNode = audioContext.createGain()
this.rightFeedbackNode = audioContext.createGain()
this.filterNode = audioContext.createBiquadFilter()
this.dcBlockerNode = audioContext.createBiquadFilter()
this.diffusionMixNode = audioContext.createGain()
this.diffusionWetNode = audioContext.createGain()
this.diffusionDryNode = audioContext.createGain()
this.leftDelayNode.delayTime.value = 0.5
this.rightDelayNode.delayTime.value = 0.5
this.leftFeedbackNode.gain.value = 0.5
this.rightFeedbackNode.gain.value = 0.5
this.dryNode.gain.value = 0.5
this.wetNode.gain.value = 0.5
this.feedbackNode.gain.value = 0.5
this.filterNode.type = 'lowpass'
this.filterNode.frequency.value = 5000
this.filterNode.Q.value = 0.7
this.createSaturationCurve(0.2)
this.dcBlockerNode.type = 'highpass'
this.dcBlockerNode.frequency.value = 5
this.dcBlockerNode.Q.value = 0.707
this.lfoNode = audioContext.createOscillator()
this.lfoGainNode = audioContext.createGain()
this.lfoNode.frequency.value = 2.5
this.lfoGainNode.gain.value = 0.0015
this.lfoNode.connect(this.lfoGainNode)
this.lfoGainNode.connect(this.delayNode.delayTime)
this.lfoNode.start()
this.diffusionWetNode.gain.value = 0
this.diffusionDryNode.gain.value = 1
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.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)
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.saturatorNode.curve = curve
this.saturatorNode.oversample = '2x'
this.setupRouting()
}
private setupRouting(): void {
this.inputNode.connect(this.dryNode)
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)
}
getInputNode(): AudioNode {
@ -86,88 +133,94 @@ export class DelayEffect implements Effect {
if (bypass) {
this.wetNode.gain.value = 0
this.dryNode.gain.value = 1
this.leftFeedbackNode.disconnect()
this.rightFeedbackNode.disconnect()
} else {
this.wetNode.gain.value = this.currentWetValue
this.dryNode.gain.value = this.currentDryValue
this.leftFeedbackNode.disconnect()
this.rightFeedbackNode.disconnect()
this.leftFeedbackNode.connect(this.rightDelayNode)
this.rightFeedbackNode.connect(this.leftDelayNode)
}
}
updateParams(values: Record<string, number>): void {
if (values.delayTime !== undefined) {
updateParams(values: Record<string, number | string>): void {
const now = this.audioContext.currentTime
if (values.delayTime !== undefined && typeof values.delayTime === 'number') {
const time = values.delayTime / 1000
this.delayNode.delayTime.setTargetAtTime(
time,
this.audioContext.currentTime,
0.01
)
this.leftDelayNode.delayTime.setTargetAtTime(time, now, 0.01)
this.rightDelayNode.delayTime.setTargetAtTime(time, now, 0.01)
}
if (values.delayFeedback !== undefined) {
if (values.delayFeedback !== undefined && typeof values.delayFeedback === 'number') {
const feedback = values.delayFeedback / 100
this.feedbackNode.gain.setTargetAtTime(
feedback * 0.95,
this.audioContext.currentTime,
const pingPongFactor = Math.max(0.5, this.currentPingPong / 100)
this.leftFeedbackNode.gain.setTargetAtTime(
feedback * 0.95 * pingPongFactor,
now,
0.01
)
this.rightFeedbackNode.gain.setTargetAtTime(
feedback * 0.95 * pingPongFactor,
now,
0.01
)
}
if (values.delayWetDry !== undefined) {
if (values.delayWetDry !== undefined && typeof values.delayWetDry === 'number') {
const wet = values.delayWetDry / 100
this.currentWetValue = wet
this.currentDryValue = 1 - wet
if (!this.bypassed) {
this.wetNode.gain.setTargetAtTime(
this.currentWetValue,
this.audioContext.currentTime,
0.01
)
this.dryNode.gain.setTargetAtTime(
this.currentDryValue,
this.audioContext.currentTime,
0.01
)
this.wetNode.gain.setTargetAtTime(this.currentWetValue, now, 0.01)
this.dryNode.gain.setTargetAtTime(this.currentDryValue, now, 0.01)
}
}
if (values.delayTone !== undefined) {
if (values.delayTone !== undefined && typeof values.delayTone === 'number') {
const tone = values.delayTone / 100
const freq = 200 + tone * 7800
this.filterNode.frequency.setTargetAtTime(
freq,
this.audioContext.currentTime,
0.01
)
this.filterNode.frequency.setTargetAtTime(freq, now, 0.01)
}
if (values.delaySaturation !== undefined) {
const saturation = values.delaySaturation / 100
this.createSaturationCurve(saturation)
if (values.delayPingPong !== undefined && typeof values.delayPingPong === 'number') {
this.currentPingPong = values.delayPingPong
}
if (values.delayFlutter !== undefined) {
const flutter = values.delayFlutter / 100
const baseDelay = this.delayNode.delayTime.value
const modDepth = baseDelay * flutter * 0.1
this.lfoGainNode.gain.setTargetAtTime(
modDepth,
this.audioContext.currentTime,
0.01
)
if (values.delayDiffusion !== undefined && typeof values.delayDiffusion === 'number') {
const diffusion = values.delayDiffusion / 100
this.diffusionWetNode.gain.setTargetAtTime(diffusion, now, 0.01)
this.diffusionDryNode.gain.setTargetAtTime(1 - diffusion, now, 0.01)
for (const filter of this.diffusionNodes) {
filter.Q.setTargetAtTime(0.707 + diffusion * 4, now, 0.01)
}
}
}
dispose(): void {
this.lfoNode.stop()
this.lfoNode.disconnect()
this.lfoGainNode.disconnect()
this.inputNode.disconnect()
this.outputNode.disconnect()
this.delayNode.disconnect()
this.feedbackNode.disconnect()
this.wetNode.disconnect()
this.dryNode.disconnect()
this.wetNode.disconnect()
this.leftDelayNode.disconnect()
this.rightDelayNode.disconnect()
this.leftFeedbackNode.disconnect()
this.rightFeedbackNode.disconnect()
this.filterNode.disconnect()
this.saturatorNode.disconnect()
this.dcBlockerNode.disconnect()
this.splitterNode.disconnect()
this.mergerNode.disconnect()
this.diffusionMixNode.disconnect()
this.diffusionWetNode.disconnect()
this.diffusionDryNode.disconnect()
for (const filter of this.diffusionNodes) {
filter.disconnect()
}
}
}

View File

@ -2,8 +2,9 @@ export interface Effect {
readonly id: string
getInputNode(): AudioNode
getOutputNode(): AudioNode
updateParams(values: Record<string, number>): void
updateParams(values: Record<string, number | string>): void
setBypass(bypass: boolean): void
getModulatableParams?(): Map<string, AudioParam>
dispose(): void
}

View File

@ -2,34 +2,54 @@ import type { Effect } from './Effect.interface'
import { FilterEffect } from './FilterEffect'
import { FoldCrushEffect } from './FoldCrushEffect'
import { DelayEffect } from './DelayEffect'
import { RingModEffect } from './RingModEffect'
import { ChorusEffect } from './ChorusEffect'
import { ReverbEffect } from './ReverbEffect'
import { OutputLimiter } from './OutputLimiter'
export class EffectsChain {
private inputNode: GainNode
private outputNode: GainNode
private masterGainNode: GainNode
private effects: Effect[]
private filterEffect: FilterEffect
private foldCrushEffect: FoldCrushEffect
private ringModEffect: RingModEffect
private chorusEffect: ChorusEffect
private outputLimiter: OutputLimiter
constructor(audioContext: AudioContext) {
this.inputNode = audioContext.createGain()
this.outputNode = audioContext.createGain()
this.masterGainNode = audioContext.createGain()
this.filterEffect = new FilterEffect(audioContext)
this.foldCrushEffect = new FoldCrushEffect(audioContext)
this.ringModEffect = new RingModEffect(audioContext)
this.chorusEffect = new ChorusEffect(audioContext)
this.outputLimiter = new OutputLimiter(audioContext)
this.effects = [
new FilterEffect(audioContext),
this.ringModEffect,
this.chorusEffect,
this.filterEffect,
this.foldCrushEffect,
new DelayEffect(audioContext),
new ReverbEffect(audioContext)
new ReverbEffect(audioContext),
this.outputLimiter
]
this.setupChain()
}
async initialize(audioContext: AudioContext): Promise<void> {
await this.foldCrushEffect.initialize(audioContext)
await Promise.all([
this.filterEffect.initialize(audioContext),
this.foldCrushEffect.initialize(audioContext),
this.ringModEffect.initialize(audioContext),
this.chorusEffect.initialize(audioContext),
this.outputLimiter.initialize(audioContext)
])
}
private setupChain(): void {
@ -44,7 +64,11 @@ export class EffectsChain {
this.masterGainNode.connect(this.outputNode)
}
updateEffects(values: Record<string, number | boolean>): void {
getEffects(): Effect[] {
return this.effects
}
updateEffects(values: Record<string, number | boolean | string>): void {
for (const effect of this.effects) {
const effectId = effect.id
const bypassKey = `${effectId}Bypass`
@ -53,13 +77,13 @@ export class EffectsChain {
effect.setBypass(Boolean(values[bypassKey]))
}
const numericValues: Record<string, number> = {}
const effectValues: Record<string, number | string> = {}
for (const [key, value] of Object.entries(values)) {
if (typeof value === 'number') {
numericValues[key] = value
if (typeof value === 'number' || typeof value === 'string') {
effectValues[key] = value
}
}
effect.updateParams(numericValues)
effect.updateParams(effectValues)
}
if (values.masterVolume !== undefined) {

View 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()
}
}

View File

@ -3,20 +3,13 @@ import type { Effect } from './Effect.interface'
export class FilterEffect implements Effect {
readonly id = 'filter'
private audioContext: AudioContext
private inputNode: GainNode
private outputNode: GainNode
private processorNode: AudioWorkletNode | null = null
private wetNode: GainNode
private dryNode: GainNode
private hpFilter: BiquadFilterNode
private lpFilter: BiquadFilterNode
private bpFilter: BiquadFilterNode
private hpEnabled: boolean = false
private lpEnabled: boolean = false
private bpEnabled: boolean = false
constructor(audioContext: AudioContext) {
this.audioContext = audioContext
this.inputNode = audioContext.createGain()
this.outputNode = audioContext.createGain()
this.wetNode = audioContext.createGain()
@ -25,27 +18,14 @@ export class FilterEffect implements Effect {
this.wetNode.gain.value = 0
this.dryNode.gain.value = 1
this.hpFilter = audioContext.createBiquadFilter()
this.hpFilter.type = 'highpass'
this.hpFilter.frequency.value = 1000
this.hpFilter.Q.value = 1
this.lpFilter = audioContext.createBiquadFilter()
this.lpFilter.type = 'lowpass'
this.lpFilter.frequency.value = 5000
this.lpFilter.Q.value = 1
this.bpFilter = audioContext.createBiquadFilter()
this.bpFilter.type = 'bandpass'
this.bpFilter.frequency.value = 1000
this.bpFilter.Q.value = 1
this.inputNode.connect(this.dryNode)
this.inputNode.connect(this.hpFilter)
this.hpFilter.connect(this.lpFilter)
this.lpFilter.connect(this.bpFilter)
this.bpFilter.connect(this.wetNode)
this.dryNode.connect(this.outputNode)
}
async initialize(audioContext: AudioContext): Promise<void> {
this.processorNode = new AudioWorkletNode(audioContext, 'svf-processor')
this.inputNode.connect(this.processorNode)
this.processorNode.connect(this.wetNode)
this.wetNode.connect(this.outputNode)
}
@ -57,117 +37,46 @@ export class FilterEffect implements Effect {
return this.outputNode
}
setBypass(_bypass: boolean): void {
// No global bypass for filters - each filter has individual enable switch
}
private updateBypassState(): void {
const anyEnabled = this.hpEnabled || this.lpEnabled || this.bpEnabled
if (anyEnabled) {
this.wetNode.gain.value = 1
this.dryNode.gain.value = 0
} else {
setBypass(bypass: boolean): void {
if (bypass) {
this.wetNode.gain.value = 0
this.dryNode.gain.value = 1
} else {
this.wetNode.gain.value = 1
this.dryNode.gain.value = 0
}
}
updateParams(values: Record<string, number>): void {
if (values.hpEnable !== undefined) {
this.hpEnabled = values.hpEnable === 1
this.updateBypassState()
}
getModulatableParams(): Map<string, AudioParam> {
if (!this.processorNode) return new Map()
if (values.hpFreq !== undefined) {
this.hpFilter.frequency.cancelScheduledValues(this.audioContext.currentTime)
this.hpFilter.frequency.setValueAtTime(
this.hpFilter.frequency.value,
this.audioContext.currentTime
)
this.hpFilter.frequency.linearRampToValueAtTime(
values.hpFreq,
this.audioContext.currentTime + 0.02
)
}
const params = new Map<string, AudioParam>()
params.set('filterFreq', this.processorNode.parameters.get('frequency')!)
params.set('filterRes', this.processorNode.parameters.get('resonance')!)
return params
}
if (values.hpRes !== undefined) {
this.hpFilter.Q.cancelScheduledValues(this.audioContext.currentTime)
this.hpFilter.Q.setValueAtTime(
this.hpFilter.Q.value,
this.audioContext.currentTime
)
this.hpFilter.Q.linearRampToValueAtTime(
values.hpRes,
this.audioContext.currentTime + 0.02
)
}
updateParams(values: Record<string, number | string>): void {
if (!this.processorNode) return
if (values.lpEnable !== undefined) {
this.lpEnabled = values.lpEnable === 1
this.updateBypassState()
if (values.filterMode !== undefined) {
this.processorNode.port.postMessage({ type: 'mode', value: values.filterMode })
}
if (values.lpFreq !== undefined) {
this.lpFilter.frequency.cancelScheduledValues(this.audioContext.currentTime)
this.lpFilter.frequency.setValueAtTime(
this.lpFilter.frequency.value,
this.audioContext.currentTime
)
this.lpFilter.frequency.linearRampToValueAtTime(
values.lpFreq,
this.audioContext.currentTime + 0.02
)
if (values.filterFreq !== undefined && typeof values.filterFreq === 'number') {
this.processorNode.parameters.get('frequency')!.value = values.filterFreq
}
if (values.lpRes !== undefined) {
this.lpFilter.Q.cancelScheduledValues(this.audioContext.currentTime)
this.lpFilter.Q.setValueAtTime(
this.lpFilter.Q.value,
this.audioContext.currentTime
)
this.lpFilter.Q.linearRampToValueAtTime(
values.lpRes,
this.audioContext.currentTime + 0.02
)
}
if (values.bpEnable !== undefined) {
this.bpEnabled = values.bpEnable === 1
this.updateBypassState()
}
if (values.bpFreq !== undefined) {
this.bpFilter.frequency.cancelScheduledValues(this.audioContext.currentTime)
this.bpFilter.frequency.setValueAtTime(
this.bpFilter.frequency.value,
this.audioContext.currentTime
)
this.bpFilter.frequency.linearRampToValueAtTime(
values.bpFreq,
this.audioContext.currentTime + 0.02
)
}
if (values.bpRes !== undefined) {
this.bpFilter.Q.cancelScheduledValues(this.audioContext.currentTime)
this.bpFilter.Q.setValueAtTime(
this.bpFilter.Q.value,
this.audioContext.currentTime
)
this.bpFilter.Q.linearRampToValueAtTime(
values.bpRes,
this.audioContext.currentTime + 0.02
)
if (values.filterRes !== undefined && typeof values.filterRes === 'number') {
this.processorNode.parameters.get('resonance')!.value = values.filterRes
}
}
dispose(): void {
this.inputNode.disconnect()
this.outputNode.disconnect()
if (this.processorNode) {
this.processorNode.disconnect()
}
this.wetNode.disconnect()
this.dryNode.disconnect()
this.hpFilter.disconnect()
this.lpFilter.disconnect()
this.bpFilter.disconnect()
this.inputNode.disconnect()
this.outputNode.disconnect()
}
}

View File

@ -15,22 +15,18 @@ export class FoldCrushEffect implements Effect {
this.wetNode = audioContext.createGain()
this.dryNode = audioContext.createGain()
this.wetNode.gain.value = 1
this.dryNode.gain.value = 0
this.wetNode.gain.value = 0
this.dryNode.gain.value = 1
this.inputNode.connect(this.dryNode)
this.dryNode.connect(this.outputNode)
}
async initialize(audioContext: AudioContext): Promise<void> {
try {
this.processorNode = new AudioWorkletNode(audioContext, 'fold-crush-processor')
this.inputNode.connect(this.processorNode)
this.processorNode.connect(this.wetNode)
this.wetNode.connect(this.outputNode)
} catch (error) {
console.error('Failed to initialize FoldCrushEffect worklet:', error)
}
this.processorNode = new AudioWorkletNode(audioContext, 'fold-crush-processor')
this.inputNode.connect(this.processorNode)
this.processorNode.connect(this.wetNode)
this.wetNode.connect(this.outputNode)
}
getInputNode(): AudioNode {
@ -51,13 +47,11 @@ export class FoldCrushEffect implements Effect {
}
}
updateParams(values: Record<string, number>): void {
updateParams(values: Record<string, number | string>): void {
if (!this.processorNode) return
if (values.clipMode !== undefined) {
const modeIndex = values.clipMode
const clipMode = ['wrap', 'clamp', 'fold'][modeIndex] || 'wrap'
this.processorNode.port.postMessage({ type: 'clipMode', value: clipMode })
this.processorNode.port.postMessage({ type: 'clipMode', value: values.clipMode })
}
if (values.wavefolderDrive !== undefined) {
this.processorNode.port.postMessage({ type: 'drive', value: values.wavefolderDrive })
@ -68,6 +62,9 @@ export class FoldCrushEffect implements Effect {
if (values.bitcrushRate !== undefined) {
this.processorNode.port.postMessage({ type: 'crushAmount', value: values.bitcrushRate })
}
if (values.glitchAmount !== undefined) {
this.processorNode.port.postMessage({ type: 'glitchAmount', value: values.glitchAmount })
}
}
dispose(): void {

View File

@ -0,0 +1,48 @@
import type { Effect } from './Effect.interface'
export class OutputLimiter implements Effect {
readonly id = 'limiter'
private inputNode: GainNode
private outputNode: GainNode
private processorNode: AudioWorkletNode | null = null
constructor(audioContext: AudioContext) {
this.inputNode = audioContext.createGain()
this.outputNode = audioContext.createGain()
this.inputNode.connect(this.outputNode)
}
async initialize(audioContext: AudioContext): Promise<void> {
this.processorNode = new AudioWorkletNode(audioContext, 'output-limiter')
this.inputNode.disconnect()
this.inputNode.connect(this.processorNode)
this.processorNode.connect(this.outputNode)
}
getInputNode(): AudioNode {
return this.inputNode
}
getOutputNode(): AudioNode {
return this.outputNode
}
setBypass(): void {
// Output limiter is always on
}
updateParams(): void {
// Uses default parameters from worklet
}
dispose(): void {
if (this.processorNode) {
this.processorNode.disconnect()
}
this.inputNode.disconnect()
this.outputNode.disconnect()
}
}

View File

@ -1,30 +0,0 @@
import type { Effect } from './Effect.interface'
export class PassThroughEffect implements Effect {
readonly id: string
private node: GainNode
constructor(audioContext: AudioContext, id: string) {
this.id = id
this.node = audioContext.createGain()
this.node.gain.value = 1
}
getInputNode(): AudioNode {
return this.node
}
getOutputNode(): AudioNode {
return this.node
}
setBypass(_bypass: boolean): void {
}
updateParams(_values: Record<string, number>): void {
}
dispose(): void {
this.node.disconnect()
}
}

View File

@ -6,132 +6,146 @@ export class ReverbEffect implements Effect {
private audioContext: AudioContext
private inputNode: GainNode
private outputNode: GainNode
private convolverNode: ConvolverNode
private wetNode: GainNode
private dryNode: GainNode
private mixNode: GainNode
private pannerNode: StereoPannerNode
private panLfoNode: OscillatorNode
private panLfoGainNode: GainNode
private convolverA: ConvolverNode
private convolverB: ConvolverNode
private gainA: GainNode
private gainB: GainNode
private activeConvolver: 'A' | 'B' = 'A'
private bypassed: boolean = false
private currentWetValue: number = 0
private currentDryValue: number = 1
private currentDecay: number = 2
private currentDamping: number = 50
private currentDecay: number = 0.7
private currentDamping: number = 0.5
private currentSize: number = 0.5
private pendingDecay: number = 0.7
private pendingDamping: number = 0.5
private pendingSize: number = 0.5
private debounceTimer: number | null = null
private readonly DEBOUNCE_MS = 250
constructor(audioContext: AudioContext) {
this.audioContext = audioContext
this.inputNode = audioContext.createGain()
this.outputNode = audioContext.createGain()
this.mixNode = audioContext.createGain()
this.convolverNode = audioContext.createConvolver()
this.wetNode = audioContext.createGain()
this.dryNode = audioContext.createGain()
this.pannerNode = audioContext.createStereoPanner()
this.panLfoNode = audioContext.createOscillator()
this.panLfoGainNode = audioContext.createGain()
this.convolverA = audioContext.createConvolver()
this.convolverB = audioContext.createConvolver()
this.gainA = audioContext.createGain()
this.gainB = audioContext.createGain()
this.wetNode.gain.value = 0
this.dryNode.gain.value = 1
this.panLfoNode.frequency.value = 0
this.panLfoGainNode.gain.value = 0
this.panLfoNode.connect(this.panLfoGainNode)
this.panLfoGainNode.connect(this.pannerNode.pan)
this.panLfoNode.start()
this.gainA.gain.value = 1
this.gainB.gain.value = 0
this.inputNode.connect(this.dryNode)
this.inputNode.connect(this.convolverNode)
this.convolverNode.connect(this.wetNode)
this.dryNode.connect(this.mixNode)
this.wetNode.connect(this.mixNode)
this.mixNode.connect(this.pannerNode)
this.pannerNode.connect(this.outputNode)
this.dryNode.connect(this.outputNode)
this.convolverNode.buffer = this.createDummyBuffer()
this.generateReverb(this.currentDecay, this.currentDamping)
this.inputNode.connect(this.convolverA)
this.convolverA.connect(this.gainA)
this.gainA.connect(this.wetNode)
this.inputNode.connect(this.convolverB)
this.convolverB.connect(this.gainB)
this.gainB.connect(this.wetNode)
this.wetNode.connect(this.outputNode)
this.generateImpulseResponse('A', this.currentDecay, this.currentDamping, this.currentSize)
}
private createDummyBuffer(): AudioBuffer {
const buffer = this.audioContext.createBuffer(2, this.audioContext.sampleRate * 0.1, this.audioContext.sampleRate)
for (let i = 0; i < 2; i++) {
const data = buffer.getChannelData(i)
for (let j = 0; j < data.length; j++) {
data[j] = (Math.random() * 2 - 1) * Math.exp(-j / (this.audioContext.sampleRate * 0.05))
}
}
return buffer
}
private generateReverb(decayTime: number, damping: number): void {
private generateImpulseResponse(target: 'A' | 'B', decay: number, damping: number, size: number): void {
const sampleRate = this.audioContext.sampleRate
const numChannels = 2
const totalTime = decayTime * 1.5
const decaySampleFrames = Math.round(decayTime * sampleRate)
const numSampleFrames = Math.round(totalTime * sampleRate)
const fadeInTime = 0.05
const fadeInSampleFrames = Math.round(fadeInTime * sampleRate)
const decayBase = Math.pow(1 / 1000, 1 / decaySampleFrames)
const decayTime = 0.5 + decay * 3.5
const length = Math.floor(sampleRate * decayTime)
const reverbIR = this.audioContext.createBuffer(numChannels, numSampleFrames, sampleRate)
const impulse = this.audioContext.createBuffer(2, length, sampleRate)
const leftChannel = impulse.getChannelData(0)
const rightChannel = impulse.getChannelData(1)
for (let i = 0; i < numChannels; i++) {
const chan = reverbIR.getChannelData(i)
for (let j = 0; j < numSampleFrames; j++) {
chan[j] = (Math.random() * 2 - 1) * Math.pow(decayBase, j)
}
for (let j = 0; j < fadeInSampleFrames; j++) {
chan[j] *= j / fadeInSampleFrames
}
const fadeInSamples = Math.floor(0.001 * sampleRate * (0.5 + size * 1.5))
const dampingFreq = 1000 + damping * 8000
const dampingCoeff = Math.exp(-2 * Math.PI * dampingFreq / sampleRate)
let leftLPState = 0
let rightLPState = 0
for (let i = 0; i < length; i++) {
const decayValue = Math.exp(-3 * i / length / decay)
const fadeIn = i < fadeInSamples ? i / fadeInSamples : 1.0
const leftNoise = (Math.random() * 2 - 1) * decayValue * fadeIn
const rightNoise = (Math.random() * 2 - 1) * decayValue * fadeIn
const dampingAmount = Math.min(1, i / (length * 0.3))
const currentDampingCoeff = 1 - dampingAmount * (1 - dampingCoeff)
leftLPState = leftLPState * currentDampingCoeff + leftNoise * (1 - currentDampingCoeff)
rightLPState = rightLPState * currentDampingCoeff + rightNoise * (1 - currentDampingCoeff)
leftChannel[i] = leftLPState * 0.5
rightChannel[i] = rightLPState * 0.5
}
const lpFreqStart = 10000
const lpFreqEnd = 200 + (damping / 100) * 7800
this.applyGradualLowpass(reverbIR, lpFreqStart, lpFreqEnd, decayTime, (buffer) => {
this.convolverNode.buffer = buffer
})
if (target === 'A') {
this.convolverA.buffer = impulse
} else {
this.convolverB.buffer = impulse
}
}
private applyGradualLowpass(
input: AudioBuffer,
lpFreqStart: number,
lpFreqEnd: number,
lpFreqEndAt: number,
callback: (buffer: AudioBuffer) => void
): void {
if (lpFreqStart === 0) {
callback(input)
return
private crossfadeToStandby(): void {
const now = this.audioContext.currentTime
const crossfadeDuration = 0.02
if (this.activeConvolver === 'A') {
this.gainA.gain.setValueAtTime(1, now)
this.gainA.gain.exponentialRampToValueAtTime(0.001, now + crossfadeDuration)
this.gainB.gain.setValueAtTime(0.001, now)
this.gainB.gain.exponentialRampToValueAtTime(1, now + crossfadeDuration)
this.activeConvolver = 'B'
} else {
this.gainB.gain.setValueAtTime(1, now)
this.gainB.gain.exponentialRampToValueAtTime(0.001, now + crossfadeDuration)
this.gainA.gain.setValueAtTime(0.001, now)
this.gainA.gain.exponentialRampToValueAtTime(1, now + crossfadeDuration)
this.activeConvolver = 'A'
}
}
private scheduleRegeneration(): void {
if (this.debounceTimer !== null) {
clearTimeout(this.debounceTimer)
}
const context = new OfflineAudioContext(
input.numberOfChannels,
input.length,
input.sampleRate
)
this.debounceTimer = window.setTimeout(() => {
this.currentDecay = this.pendingDecay
this.currentDamping = this.pendingDamping
this.currentSize = this.pendingSize
const player = context.createBufferSource()
player.buffer = input
const standbyConvolver = this.activeConvolver === 'A' ? 'B' : 'A'
this.generateImpulseResponse(standbyConvolver, this.currentDecay, this.currentDamping, this.currentSize)
const filter = context.createBiquadFilter()
lpFreqStart = Math.min(lpFreqStart, input.sampleRate / 2)
lpFreqEnd = Math.min(lpFreqEnd, input.sampleRate / 2)
setTimeout(() => {
this.crossfadeToStandby()
}, 10)
filter.type = 'lowpass'
filter.Q.value = 0.0001
filter.frequency.setValueAtTime(lpFreqStart, 0)
filter.frequency.linearRampToValueAtTime(lpFreqEnd, lpFreqEndAt)
player.connect(filter)
filter.connect(context.destination)
player.start()
context.oncomplete = (event) => {
callback(event.renderedBuffer)
}
context.startRendering()
this.debounceTimer = null
}, this.DEBOUNCE_MS)
}
getInputNode(): AudioNode {
@ -153,20 +167,25 @@ export class ReverbEffect implements Effect {
}
}
updateParams(values: Record<string, number>): void {
let needsRegeneration = false
updateParams(values: Record<string, number | string>): void {
let needsRegenerate = false
if (values.reverbDecay !== undefined && values.reverbDecay !== this.currentDecay) {
this.currentDecay = values.reverbDecay
needsRegeneration = true
if (values.reverbDecay !== undefined && typeof values.reverbDecay === 'number') {
this.pendingDecay = values.reverbDecay / 100
needsRegenerate = true
}
if (values.reverbDamping !== undefined && values.reverbDamping !== this.currentDamping) {
this.currentDamping = values.reverbDamping
needsRegeneration = true
if (values.reverbDamping !== undefined && typeof values.reverbDamping === 'number') {
this.pendingDamping = values.reverbDamping / 100
needsRegenerate = true
}
if (values.reverbWetDry !== undefined) {
if (values.reverbSize !== undefined && typeof values.reverbSize === 'number') {
this.pendingSize = values.reverbSize / 100
needsRegenerate = true
}
if (values.reverbWetDry !== undefined && typeof values.reverbWetDry === 'number') {
const wet = values.reverbWetDry / 100
this.currentWetValue = wet
this.currentDryValue = 1 - wet
@ -177,39 +196,23 @@ export class ReverbEffect implements Effect {
}
}
if (values.reverbPanRate !== undefined) {
const rate = values.reverbPanRate
this.panLfoNode.frequency.setTargetAtTime(
rate,
this.audioContext.currentTime,
0.01
)
}
if (values.reverbPanWidth !== undefined) {
const width = values.reverbPanWidth / 100
this.panLfoGainNode.gain.setTargetAtTime(
width,
this.audioContext.currentTime,
0.01
)
}
if (needsRegeneration) {
this.generateReverb(this.currentDecay, this.currentDamping)
if (needsRegenerate) {
this.scheduleRegeneration()
}
}
dispose(): void {
this.panLfoNode.stop()
this.panLfoNode.disconnect()
this.panLfoGainNode.disconnect()
if (this.debounceTimer !== null) {
clearTimeout(this.debounceTimer)
}
this.inputNode.disconnect()
this.outputNode.disconnect()
this.mixNode.disconnect()
this.convolverNode.disconnect()
this.wetNode.disconnect()
this.dryNode.disconnect()
this.pannerNode.disconnect()
this.convolverA.disconnect()
this.convolverB.disconnect()
this.gainA.disconnect()
this.gainB.disconnect()
}
}

View 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()
}
}

View File

@ -96,12 +96,11 @@ export class WavefolderEffect implements Effect {
}
}
updateParams(values: Record<string, number>): void {
if (values.clipMode !== undefined) {
const modeIndex = values.clipMode
this.mode = ['wrap', 'clamp', 'fold'][modeIndex] as ClipMode || 'wrap'
updateParams(values: Record<string, number | string>): void {
if (values.clipMode !== undefined && typeof values.clipMode === 'string') {
this.mode = values.clipMode as ClipMode
}
if (values.wavefolderDrive !== undefined) {
if (values.wavefolderDrive !== undefined && typeof values.wavefolderDrive === 'number') {
this.drive = values.wavefolderDrive
}
}

View File

@ -0,0 +1,66 @@
export type LFOWaveform = 'sine' | 'triangle' | 'square' | 'sawtooth' | 'random'
export class LFO {
private startTime: number
private frequency: number
private phase: number
private waveform: LFOWaveform
private audioContext: AudioContext
private lastRandomValue: number = 0
private lastRandomTime: number = 0
constructor(audioContext: AudioContext, frequency: number = 1, phase: number = 0, waveform: LFOWaveform = 'sine') {
this.audioContext = audioContext
this.frequency = frequency
this.phase = phase
this.waveform = waveform
this.startTime = audioContext.currentTime
}
setFrequency(frequency: number): void {
this.frequency = frequency
}
setPhase(phase: number): void {
this.phase = phase
}
setWaveform(waveform: LFOWaveform): void {
this.waveform = waveform
}
getValue(time?: number): number {
const currentTime = time ?? this.audioContext.currentTime
const elapsed = currentTime - this.startTime
const phaseOffset = (this.phase / 360) * (1 / this.frequency)
const phase = ((elapsed + phaseOffset) * this.frequency) % 1
switch (this.waveform) {
case 'sine':
return Math.sin(phase * 2 * Math.PI)
case 'triangle':
return phase < 0.5
? -1 + 4 * phase
: 3 - 4 * phase
case 'square':
return phase < 0.5 ? 1 : -1
case 'sawtooth':
return 2 * phase - 1
case 'random': {
const interval = 1 / this.frequency
if (currentTime - this.lastRandomTime >= interval) {
this.lastRandomValue = Math.random() * 2 - 1
this.lastRandomTime = currentTime
}
return this.lastRandomValue
}
default:
return 0
}
}
}

View File

@ -0,0 +1,159 @@
import { LFO, type LFOWaveform } from './LFO'
import { parameterRegistry } from './ParameterRegistry'
export interface LFOMapping {
lfoIndex: number
targetParam: string
depth: number
}
export interface ParameterTarget {
audioParam?: AudioParam
callback?: (value: number) => void
}
export class ModulationEngine {
private audioContext: AudioContext
private lfos: LFO[]
private mappings: LFOMapping[]
private paramTargets: Map<string, ParameterTarget>
private baseValues: Map<string, number>
private animationFrameId: number | null = null
private isRunning: boolean = false
constructor(audioContext: AudioContext, lfoCount: number = 4) {
this.audioContext = audioContext
this.lfos = []
this.mappings = []
this.paramTargets = new Map()
this.baseValues = new Map()
for (let i = 0; i < lfoCount; i++) {
this.lfos.push(new LFO(audioContext))
}
}
registerParameter(paramId: string, target: ParameterTarget, baseValue: number): void {
this.paramTargets.set(paramId, target)
this.baseValues.set(paramId, baseValue)
}
updateBaseValue(paramId: string, baseValue: number): void {
this.baseValues.set(paramId, baseValue)
}
setBaseValues(values: Record<string, number>): void {
Object.entries(values).forEach(([paramId, value]) => {
this.baseValues.set(paramId, value)
})
}
addMapping(lfoIndex: number, targetParam: string, depth: number): void {
const existingIndex = this.mappings.findIndex(
m => m.lfoIndex === lfoIndex && m.targetParam === targetParam
)
if (existingIndex >= 0) {
this.mappings[existingIndex].depth = depth
} else {
this.mappings.push({ lfoIndex, targetParam, depth })
}
}
removeMapping(lfoIndex: number, targetParam: string): void {
this.mappings = this.mappings.filter(
m => !(m.lfoIndex === lfoIndex && m.targetParam === targetParam)
)
}
clearMappings(lfoIndex?: number): void {
if (lfoIndex !== undefined) {
this.mappings = this.mappings.filter(m => m.lfoIndex !== lfoIndex)
} else {
this.mappings = []
}
}
updateLFO(lfoIndex: number, frequency: number, phase: number, waveform: LFOWaveform): void {
const lfo = this.lfos[lfoIndex]
if (!lfo) return
lfo.setFrequency(frequency)
lfo.setPhase(phase)
lfo.setWaveform(waveform)
}
private updateModulation = (): void => {
if (!this.isRunning) return
const currentTime = this.audioContext.currentTime
const modulatedValues = new Map<string, number>()
for (const [paramId, baseValue] of this.baseValues) {
modulatedValues.set(paramId, baseValue)
}
for (const mapping of this.mappings) {
const lfo = this.lfos[mapping.lfoIndex]
if (!lfo) continue
const baseValue = this.baseValues.get(mapping.targetParam)
if (baseValue === undefined) continue
const meta = parameterRegistry.getMetadata(mapping.targetParam)
if (!meta) continue
const lfoValue = lfo.getValue(currentTime)
const normalized = parameterRegistry.normalizeValue(mapping.targetParam, baseValue)
const depthNormalized = (mapping.depth / 100) * lfoValue
const modulatedNormalized = normalized + depthNormalized
const modulatedValue = parameterRegistry.denormalizeValue(mapping.targetParam, modulatedNormalized)
const clampedValue = parameterRegistry.clampValue(mapping.targetParam, modulatedValue)
modulatedValues.set(mapping.targetParam, clampedValue)
}
for (const [paramId, value] of modulatedValues) {
const target = this.paramTargets.get(paramId)
if (!target) continue
if (target.audioParam) {
target.audioParam.setTargetAtTime(value, currentTime, 0.01)
} else if (target.callback) {
target.callback(value)
}
}
this.animationFrameId = requestAnimationFrame(this.updateModulation)
}
start(): void {
if (this.isRunning) return
this.isRunning = true
this.updateModulation()
}
stop(): void {
this.isRunning = false
if (this.animationFrameId !== null) {
cancelAnimationFrame(this.animationFrameId)
this.animationFrameId = null
}
}
getMappingsForLFO(lfoIndex: number): LFOMapping[] {
return this.mappings.filter(m => m.lfoIndex === lfoIndex)
}
getMappingsForParam(paramId: string): LFOMapping[] {
return this.mappings.filter(m => m.targetParam === paramId)
}
dispose(): void {
this.stop()
this.mappings = []
this.paramTargets.clear()
this.baseValues.clear()
}
}

View File

@ -0,0 +1,133 @@
import { ENGINE_CONTROLS, EFFECTS } from '../../config/parameters'
import type { EffectParameter } from '../../types/effects'
export type ParameterScaling = 'linear' | 'exponential'
export interface ParameterMetadata {
id: string
label: string
min: number
max: number
step: number
unit?: string
scaling: ParameterScaling
isAudioParam: boolean
category: 'engine' | 'effect'
effectId?: string
}
export class ParameterRegistry {
private metadata: Map<string, ParameterMetadata> = new Map()
constructor() {
this.buildRegistry()
}
private buildRegistry(): void {
ENGINE_CONTROLS.forEach(control => {
control.parameters.forEach(param => {
if (this.isNumericParameter(param)) {
this.metadata.set(param.id, {
id: param.id,
label: param.label,
min: param.min,
max: param.max,
step: param.step,
unit: param.unit,
scaling: 'linear',
isAudioParam: false,
category: 'engine'
})
}
})
})
EFFECTS.forEach(effect => {
effect.parameters.forEach(param => {
if (this.isNumericParameter(param)) {
const isFreqParam = param.id.toLowerCase().includes('freq')
const isAudioParam = this.checkIfAudioParam(effect.id, param.id)
this.metadata.set(param.id, {
id: param.id,
label: param.label,
min: param.min,
max: param.max,
step: param.step,
unit: param.unit,
scaling: isFreqParam ? 'exponential' : 'linear',
isAudioParam,
category: 'effect',
effectId: effect.id
})
}
})
})
}
private isNumericParameter(param: EffectParameter): boolean {
return typeof param.default === 'number' && !param.options
}
private checkIfAudioParam(effectId: string, paramId: string): boolean {
if (effectId !== 'filter') return false
const audioParamIds = ['hpFreq', 'hpRes', 'lpFreq', 'lpRes', 'bpFreq', 'bpRes']
return audioParamIds.includes(paramId)
}
getMetadata(paramId: string): ParameterMetadata | undefined {
return this.metadata.get(paramId)
}
isAudioParam(paramId: string): boolean {
return this.metadata.get(paramId)?.isAudioParam ?? false
}
getAllModulatableParams(): string[] {
return Array.from(this.metadata.keys())
}
getModulatableParamsByCategory(category: 'engine' | 'effect'): string[] {
return Array.from(this.metadata.entries())
.filter(([, meta]) => meta.category === category)
.map(([id]) => id)
}
clampValue(paramId: string, value: number): number {
const meta = this.metadata.get(paramId)
if (!meta) return value
return Math.max(meta.min, Math.min(meta.max, value))
}
normalizeValue(paramId: string, value: number): number {
const meta = this.metadata.get(paramId)
if (!meta) return 0
if (meta.scaling === 'exponential') {
const logMin = Math.log(meta.min)
const logMax = Math.log(meta.max)
const logValue = Math.log(Math.max(meta.min, value))
return (logValue - logMin) / (logMax - logMin)
} else {
return (value - meta.min) / (meta.max - meta.min)
}
}
denormalizeValue(paramId: string, normalized: number): number {
const meta = this.metadata.get(paramId)
if (!meta) return 0
const clamped = Math.max(0, Math.min(1, normalized))
if (meta.scaling === 'exponential') {
const logMin = Math.log(meta.min)
const logMax = Math.log(meta.max)
return Math.exp(logMin + clamped * (logMax - logMin))
} else {
return meta.min + clamped * (meta.max - meta.min)
}
}
}
export const parameterRegistry = new ParameterRegistry()

7
src/hooks/index.ts Normal file
View 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'

View 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
}
}

View File

@ -0,0 +1,112 @@
import { useEffect, useRef } from 'react'
export interface KeyboardShortcutHandlers {
onSpace?: () => void
onArrowUp?: (shift: boolean) => void
onArrowDown?: (shift: boolean) => void
onArrowLeft?: (shift: boolean) => void
onArrowRight?: (shift: boolean) => void
onEnter?: () => void
onDoubleEnter?: () => void
onR?: () => void
onShiftR?: () => void
onC?: () => void
onShiftC?: () => void
onI?: () => void
onEscape?: () => void
}
const DOUBLE_ENTER_THRESHOLD = 300
export function useKeyboardShortcuts(handlers: KeyboardShortcutHandlers) {
const handlersRef = useRef(handlers)
useEffect(() => {
handlersRef.current = handlers
}, [handlers])
useEffect(() => {
let lastEnterTime = 0
const handleKeyDown = (e: KeyboardEvent) => {
if (e.target instanceof HTMLInputElement || e.target instanceof HTMLTextAreaElement) {
return
}
const h = handlersRef.current
switch (e.key) {
case ' ':
e.preventDefault()
h.onSpace?.()
break
case 'ArrowUp':
e.preventDefault()
h.onArrowUp?.(e.shiftKey)
break
case 'ArrowDown':
e.preventDefault()
h.onArrowDown?.(e.shiftKey)
break
case 'ArrowLeft':
e.preventDefault()
h.onArrowLeft?.(e.shiftKey)
break
case 'ArrowRight':
e.preventDefault()
h.onArrowRight?.(e.shiftKey)
break
case 'Enter': {
e.preventDefault()
const now = Date.now()
if (now - lastEnterTime < DOUBLE_ENTER_THRESHOLD) {
h.onDoubleEnter?.()
} else {
h.onEnter?.()
}
lastEnterTime = now
break
}
case 'r':
case 'R':
e.preventDefault()
if (e.shiftKey) {
h.onShiftR?.()
} else {
h.onR?.()
}
break
case 'c':
case 'C':
e.preventDefault()
if (e.shiftKey) {
h.onShiftC?.()
} else {
h.onC?.()
}
break
case 'i':
case 'I':
e.preventDefault()
h.onI?.()
break
case 'Escape':
e.preventDefault()
h.onEscape?.()
break
}
}
window.addEventListener('keydown', handleKeyDown)
return () => window.removeEventListener('keydown', handleKeyDown)
}, [])
}

100
src/hooks/useLFOMapping.ts Normal file
View 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
}
}

View 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
}
}

View 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
View 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
}
}

View File

@ -0,0 +1,33 @@
import { useCallback } from 'react'
import type { TileState } from '../types/tiles'
import { saveTileParams } from '../utils/tileState'
import { getTileFromGrid, type FocusedTile } from '../utils/tileHelpers'
interface UseTileParamsProps {
tiles: TileState[][]
setTiles: React.Dispatch<React.SetStateAction<TileState[][]>>
customTile: TileState
setCustomTile: React.Dispatch<React.SetStateAction<TileState>>
focusedTile: FocusedTile
}
export function useTileParams({ tiles, setTiles, customTile, setCustomTile, focusedTile }: UseTileParamsProps) {
const saveCurrentTileParams = 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])
return { saveCurrentTileParams }
}

View File

@ -1,109 +0,0 @@
import type { BytebeatOptions, BitDepth } from './types'
import type { EffectValues } from '../../types/effects'
import { compileFormula } from '../../domain/audio/BytebeatCompiler'
import { generateSamples } from '../../domain/audio/SampleGenerator'
import { exportToWav } from '../../domain/audio/WavExporter'
import { AudioPlayer } from '../../domain/audio/AudioPlayer'
export class BytebeatGenerator {
private sampleRate: number
private duration: number
private audioBuffer: Float32Array | null = null
private audioPlayer: AudioPlayer
constructor(options: BytebeatOptions = {}) {
this.sampleRate = options.sampleRate ?? 8000
this.duration = options.duration ?? 10
this.audioPlayer = new AudioPlayer({ sampleRate: this.sampleRate, duration: this.duration })
}
updateOptions(options: Partial<BytebeatOptions>): void {
if (options.sampleRate !== undefined) {
this.sampleRate = options.sampleRate
this.audioBuffer = null
}
if (options.duration !== undefined) {
this.duration = options.duration
this.audioBuffer = null
}
this.audioPlayer.updateOptions({ sampleRate: this.sampleRate, duration: this.duration })
}
setFormula(formula: string): void {
const result = compileFormula(formula)
if (!result.success || !result.compiledFormula) {
throw new Error(`Invalid formula: ${result.error}`)
}
this.audioBuffer = generateSamples(result.compiledFormula, {
sampleRate: this.sampleRate,
duration: this.duration
})
}
generate(): Float32Array {
if (!this.audioBuffer) {
throw new Error('No formula set. Call setFormula() first.')
}
return this.audioBuffer
}
setEffects(values: EffectValues): void {
this.audioPlayer.setEffects(values)
}
getPlaybackPosition(): number {
return this.audioPlayer.getPlaybackPosition()
}
play(): void {
if (!this.audioBuffer) {
throw new Error('No audio buffer. Call setFormula() first.')
}
this.audioPlayer.play(this.audioBuffer)
}
onLoopEnd(callback: () => void): void {
if (!this.audioBuffer) return
this.audioPlayer.setLooping(false)
this.audioPlayer.play(this.audioBuffer, callback)
}
setLooping(loop: boolean): void {
this.audioPlayer.setLooping(loop)
}
scheduleNextTrack(callback: () => void): void {
this.audioPlayer.scheduleNextTrack(callback)
}
pause(): void {
this.audioPlayer.pause()
}
stop(): void {
this.audioPlayer.stop()
}
exportWAV(bitDepth: BitDepth = 8): Blob {
if (!this.audioBuffer) {
throw new Error('No audio buffer. Call setFormula() first.')
}
return exportToWav(this.audioBuffer, { sampleRate: this.sampleRate, bitDepth })
}
downloadWAV(filename: string = 'bytebeat.wav', bitDepth: BitDepth = 8): void {
const blob = this.exportWAV(bitDepth)
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = filename
a.click()
URL.revokeObjectURL(url)
}
dispose(): void {
this.audioPlayer.dispose()
}
}

View File

@ -1,124 +0,0 @@
import type { EffectValues } from '../../types/effects'
export class EffectsChain {
private audioContext: AudioContext
private inputNode: GainNode
private outputNode: GainNode
private masterGainNode: GainNode
private delayNode: DelayNode
private delayFeedbackNode: GainNode
private delayWetNode: GainNode
private delayDryNode: GainNode
private convolverNode: ConvolverNode
private reverbWetNode: GainNode
private reverbDryNode: GainNode
private tbdNode: GainNode
constructor(audioContext: AudioContext) {
this.audioContext = audioContext
this.inputNode = audioContext.createGain()
this.masterGainNode = audioContext.createGain()
this.outputNode = audioContext.createGain()
this.delayNode = audioContext.createDelay(2.0)
this.delayFeedbackNode = audioContext.createGain()
this.delayWetNode = audioContext.createGain()
this.delayDryNode = audioContext.createGain()
this.convolverNode = audioContext.createConvolver()
this.reverbWetNode = audioContext.createGain()
this.reverbDryNode = audioContext.createGain()
this.tbdNode = audioContext.createGain()
this.setupChain()
this.generateImpulseResponse()
}
private setupChain(): void {
this.delayDryNode.gain.value = 1
this.delayWetNode.gain.value = 0
this.inputNode.connect(this.delayDryNode)
this.inputNode.connect(this.delayNode)
this.delayNode.connect(this.delayFeedbackNode)
this.delayFeedbackNode.connect(this.delayNode)
this.delayNode.connect(this.delayWetNode)
this.delayDryNode.connect(this.reverbDryNode)
this.delayWetNode.connect(this.reverbDryNode)
this.delayDryNode.connect(this.convolverNode)
this.delayWetNode.connect(this.convolverNode)
this.convolverNode.connect(this.reverbWetNode)
this.reverbDryNode.connect(this.tbdNode)
this.reverbWetNode.connect(this.tbdNode)
this.tbdNode.connect(this.masterGainNode)
this.masterGainNode.connect(this.outputNode)
}
private generateImpulseResponse(): void {
const length = this.audioContext.sampleRate * 2
const impulse = this.audioContext.createBuffer(2, length, this.audioContext.sampleRate)
const left = impulse.getChannelData(0)
const right = impulse.getChannelData(1)
for (let i = 0; i < length; i++) {
left[i] = (Math.random() * 2 - 1) * Math.pow(1 - i / length, 2)
right[i] = (Math.random() * 2 - 1) * Math.pow(1 - i / length, 2)
}
this.convolverNode.buffer = impulse
}
updateEffects(values: EffectValues): void {
if (typeof values.reverbWetDry === 'number') {
const reverbWet = values.reverbWetDry / 100
this.reverbWetNode.gain.value = reverbWet
this.reverbDryNode.gain.value = 1 - reverbWet
}
if (typeof values.delayTime === 'number') {
this.delayNode.delayTime.value = values.delayTime / 1000
const delayAmount = Math.min(values.delayTime / 1000, 0.5)
this.delayWetNode.gain.value = delayAmount
this.delayDryNode.gain.value = 1 - delayAmount
}
if (typeof values.delayFeedback === 'number') {
this.delayFeedbackNode.gain.value = values.delayFeedback / 100
}
if (typeof values.masterVolume === 'number') {
this.masterGainNode.gain.value = values.masterVolume / 100
}
}
getInputNode(): AudioNode {
return this.inputNode
}
getOutputNode(): AudioNode {
return this.outputNode
}
dispose(): void {
this.inputNode.disconnect()
this.outputNode.disconnect()
this.masterGainNode.disconnect()
this.delayNode.disconnect()
this.delayFeedbackNode.disconnect()
this.delayWetNode.disconnect()
this.delayDryNode.disconnect()
this.convolverNode.disconnect()
this.reverbWetNode.disconnect()
this.reverbDryNode.disconnect()
this.tbdNode.disconnect()
}
}

View File

@ -1,15 +0,0 @@
export { BytebeatGenerator } from './BytebeatGenerator'
export type { BytebeatOptions, BytebeatState, BitDepth } from './types'
export const EXAMPLE_FORMULAS = {
classic: 't * ((t>>12)|(t>>8))&(63&(t>>4))',
melody: 't>>6^t&0x25|t+(t^t>>11)',
simple: 't & (t>>4)|(t>>8)',
harmony: '(t>>10&42)*t',
glitch: 't*(t>>8*((t>>15)|(t>>8))&(20|(t>>19)*5>>t|(t>>3)))',
drums: '((t>>10)&42)*(t>>8)',
ambient: '(t*5&t>>7)|(t*3&t>>10)',
noise: 't>>6&1?t>>5:-t>>4',
arpeggio: 't*(((t>>9)|(t>>13))&25&t>>6)',
chaos: 't*(t^t+(t>>15|1))',
} as const

View File

@ -1,12 +0,0 @@
export interface BytebeatOptions {
sampleRate?: number
duration?: number
}
export interface BytebeatState {
isPlaying: boolean
isPaused: boolean
currentTime: number
}
export type BitDepth = 8 | 16

View File

@ -1,49 +0,0 @@
import type { BitDepth } from './types'
function writeString(view: DataView, offset: number, str: string): void {
for (let i = 0; i < str.length; i++) {
view.setUint8(offset + i, str.charCodeAt(i))
}
}
export function encodeWAV(samples: Float32Array, sampleRate: number, bitDepth: BitDepth): Blob {
const numChannels = 1
const bytesPerSample = bitDepth / 8
const blockAlign = numChannels * bytesPerSample
const dataSize = samples.length * bytesPerSample
const buffer = new ArrayBuffer(44 + dataSize)
const view = new DataView(buffer)
writeString(view, 0, 'RIFF')
view.setUint32(4, 36 + dataSize, true)
writeString(view, 8, 'WAVE')
writeString(view, 12, 'fmt ')
view.setUint32(16, 16, true)
view.setUint16(20, 1, true)
view.setUint16(22, numChannels, true)
view.setUint32(24, sampleRate, true)
view.setUint32(28, sampleRate * blockAlign, true)
view.setUint16(32, blockAlign, true)
view.setUint16(34, bitDepth, true)
writeString(view, 36, 'data')
view.setUint32(40, dataSize, true)
let offset = 44
for (let i = 0; i < samples.length; i++) {
const sample = Math.max(-1, Math.min(1, samples[i]))
if (bitDepth === 8) {
const value = Math.floor((sample + 1) * 127.5)
view.setUint8(offset, value)
offset += 1
} else {
const value = Math.floor(sample * 32767)
view.setInt16(offset, value, true)
offset += 2
}
}
return new Blob([buffer], { type: 'audio/wav' })
}

View File

@ -3,6 +3,7 @@ import { compileFormula } from '../domain/audio/BytebeatCompiler'
import { generateSamples } from '../domain/audio/SampleGenerator'
import { exportToWav } from '../domain/audio/WavExporter'
import type { BitDepth } from '../domain/audio/WavExporter'
import { DEFAULT_DOWNLOAD_OPTIONS } from '../constants/defaults'
export interface DownloadOptions {
sampleRate?: number
@ -24,29 +25,22 @@ export class DownloadService {
formula: string,
filename: string,
options: DownloadOptions = {}
): boolean {
): void {
const {
sampleRate = 8000,
duration = 10,
bitDepth = 8
sampleRate = DEFAULT_DOWNLOAD_OPTIONS.SAMPLE_RATE,
duration = DEFAULT_DOWNLOAD_OPTIONS.DURATION,
bitDepth = DEFAULT_DOWNLOAD_OPTIONS.BIT_DEPTH
} = options
const result = compileFormula(formula)
if (!result.success || !result.compiledFormula) {
console.error('Failed to compile formula:', result.error)
return false
throw new Error(`Failed to compile formula: ${result.error}`)
}
try {
const buffer = generateSamples(result.compiledFormula, { sampleRate, duration })
const blob = exportToWav(buffer, { sampleRate, bitDepth })
this.downloadBlob(blob, filename)
return true
} catch (error) {
console.error('Failed to download formula:', error)
return false
}
const buffer = generateSamples(result.compiledFormula, { sampleRate, duration })
const blob = exportToWav(buffer, { sampleRate, bitDepth })
this.downloadBlob(blob, filename)
}
async downloadAll(
@ -54,9 +48,9 @@ export class DownloadService {
options: DownloadOptions = {}
): Promise<void> {
const {
sampleRate = 8000,
duration = 10,
bitDepth = 8
sampleRate = DEFAULT_DOWNLOAD_OPTIONS.SAMPLE_RATE,
duration = DEFAULT_DOWNLOAD_OPTIONS.DURATION,
bitDepth = DEFAULT_DOWNLOAD_OPTIONS.BIT_DEPTH
} = options
const zip = new JSZip()
@ -66,17 +60,12 @@ export class DownloadService {
const result = compileFormula(formula)
if (!result.success || !result.compiledFormula) {
console.error(`Failed to compile ${i}_${j}:`, result.error)
return
throw new Error(`Failed to compile ${i}_${j}: ${result.error}`)
}
try {
const buffer = generateSamples(result.compiledFormula, { sampleRate, duration })
const blob = exportToWav(buffer, { sampleRate, bitDepth })
zip.file(`bytebeat_${i}_${j}.wav`, blob)
} catch (error) {
console.error(`Failed to generate ${i}_${j}:`, error)
}
const buffer = generateSamples(result.compiledFormula, { sampleRate, duration })
const blob = exportToWav(buffer, { sampleRate, bitDepth })
zip.file(`bytebeat_${i}_${j}.wav`, blob)
})
})

View 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()

View File

@ -1,7 +1,8 @@
import { compileFormula } from '../domain/audio/BytebeatCompiler'
import { generateSamples } from '../domain/audio/SampleGenerator'
import { AudioPlayer } from '../domain/audio/AudioPlayer'
import type { LFOWaveform } from '../domain/modulation/LFO'
import type { EffectValues } from '../types/effects'
import type { SynthesisMode } from '../stores/synthesisMode'
import { DEFAULT_VARIABLES } from '../constants/defaults'
export interface PlaybackOptions {
sampleRate: number
@ -11,8 +12,9 @@ export interface PlaybackOptions {
export class PlaybackManager {
private player: AudioPlayer
private currentFormula: string | null = null
private currentBuffer: Float32Array | null = null
private queuedCallback: (() => void) | null = null
private variables: { a: number; b: number; c: number; d: number } = { ...DEFAULT_VARIABLES }
private playbackPositionCallback: ((position: number) => void) | null = null
private animationFrameId: number | null = null
constructor(options: PlaybackOptions) {
this.player = new AudioPlayer(options)
@ -20,51 +22,79 @@ export class PlaybackManager {
async updateOptions(options: Partial<PlaybackOptions>): Promise<void> {
await this.player.updateOptions(options)
this.currentBuffer = null
}
setEffects(values: EffectValues): void {
this.player.setEffects(values)
}
setPitch(pitch: number): void {
this.player.setPitch(pitch)
setVariables(a: number, b: number, c: number, d: number): void {
this.variables = { a, b, c, d }
this.player.updateRealtimeVariables(a, b, c, d)
}
async play(formula: string, sampleRate: number, duration: number): Promise<boolean> {
const result = compileFormula(formula)
setLFOConfig(lfoIndex: number, config: { frequency: number; phase: number; waveform: LFOWaveform; mappings: Array<{ targetParam: string; depth: number }> }): void {
this.player.setLFOConfig(lfoIndex, config)
}
if (!result.success || !result.compiledFormula) {
console.error('Failed to compile formula:', result.error)
return false
setPitch(pitch: number): void {
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 {
this.playbackPositionCallback = callback
}
private startPlaybackTracking(): void {
if (this.animationFrameId) {
cancelAnimationFrame(this.animationFrameId)
}
try {
this.currentBuffer = generateSamples(result.compiledFormula, { sampleRate, duration })
this.currentFormula = formula
this.player.setLooping(true)
await this.player.play(this.currentBuffer)
return true
} catch (error) {
console.error('Failed to generate samples:', error)
return false
const updatePosition = () => {
const position = this.player.getPlaybackPosition()
if (this.playbackPositionCallback) {
this.playbackPositionCallback(position)
}
this.animationFrameId = requestAnimationFrame(updatePosition)
}
updatePosition()
}
private stopPlaybackTracking(): void {
if (this.animationFrameId) {
cancelAnimationFrame(this.animationFrameId)
this.animationFrameId = null
}
}
async play(formula: string): Promise<void> {
this.currentFormula = formula
await this.player.playRealtime(
formula,
this.variables.a,
this.variables.b,
this.variables.c,
this.variables.d
)
this.startPlaybackTracking()
}
stop(): void {
this.stopPlaybackTracking()
this.player.stop()
this.currentFormula = null
this.queuedCallback = null
}
scheduleNextTrack(callback: () => void): void {
this.queuedCallback = callback
this.player.scheduleNextTrack(() => {
if (this.queuedCallback) {
this.queuedCallback()
this.queuedCallback = null
}
})
}
getPlaybackPosition(): number {

5
src/stores/index.ts Normal file
View 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'

34
src/stores/mappingMode.ts Normal file
View File

@ -0,0 +1,34 @@
import { atom } from 'nanostores'
export interface MappingModeState {
isActive: boolean
activeLFO: number | null
}
export const mappingMode = atom<MappingModeState>({
isActive: false,
activeLFO: null
})
export function enterMappingMode(lfoIndex: number): void {
mappingMode.set({
isActive: true,
activeLFO: lfoIndex
})
}
export function exitMappingMode(): void {
mappingMode.set({
isActive: false,
activeLFO: null
})
}
export function toggleMappingMode(lfoIndex: number): void {
const current = mappingMode.get()
if (current.isActive && current.activeLFO === lfoIndex) {
exitMappingMode()
} else {
enterMappingMode(lfoIndex)
}
}

View File

@ -1,15 +1,87 @@
import { persistentMap } from '@nanostores/persistent'
import { getDefaultEngineValues, getDefaultEffectValues } from '../config/effects'
import { map } from 'nanostores'
import { getDefaultEngineValues, getDefaultEffectValues } from '../config/parameters'
import type { LFOWaveform } from '../domain/modulation/LFO'
export const engineSettings = persistentMap('engine:', getDefaultEngineValues(), {
encode: JSON.stringify,
decode: JSON.parse
})
const STORAGE_KEY_ENGINE = 'engine:'
const STORAGE_KEY_EFFECTS = 'effects:'
const STORAGE_KEY_LFO = 'lfo:'
export const effectSettings = persistentMap('effects:', {
export interface LFOMapping {
targetParam: string
depth: number
}
export interface LFOConfig {
waveform: LFOWaveform
frequency: number
phase: number
mappings: LFOMapping[]
}
export interface LFOSettings {
lfo1: LFOConfig
lfo2: LFOConfig
lfo3: LFOConfig
lfo4: LFOConfig
}
export function getDefaultLFOConfig(): LFOConfig {
return {
waveform: 'sine',
frequency: 1,
phase: 0,
mappings: []
}
}
export function getDefaultLFOValues(): LFOSettings {
return {
lfo1: getDefaultLFOConfig(),
lfo2: getDefaultLFOConfig(),
lfo3: getDefaultLFOConfig(),
lfo4: getDefaultLFOConfig()
}
}
function loadFromStorage<T>(key: string, defaults: T): T {
try {
const stored = localStorage.getItem(key)
return stored ? { ...defaults, ...JSON.parse(stored) } : defaults
} catch {
return defaults
}
}
export const engineSettings = map(loadFromStorage(STORAGE_KEY_ENGINE, getDefaultEngineValues()))
export const effectSettings = map(loadFromStorage(STORAGE_KEY_EFFECTS, {
...getDefaultEffectValues(),
masterVolume: 75
}, {
encode: JSON.stringify,
decode: JSON.parse
})
}))
export const lfoSettings = map(loadFromStorage(STORAGE_KEY_LFO, getDefaultLFOValues()))
function saveToStorage() {
try {
localStorage.setItem(STORAGE_KEY_ENGINE, JSON.stringify(engineSettings.get()))
localStorage.setItem(STORAGE_KEY_EFFECTS, JSON.stringify(effectSettings.get()))
localStorage.setItem(STORAGE_KEY_LFO, JSON.stringify(lfoSettings.get()))
} catch {
// Silently fail on storage errors (quota exceeded, private browsing, etc.)
}
}
// Save incrementally on changes (debounced to avoid excessive writes)
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)
}

View 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
}
}

View File

@ -1,18 +1,2 @@
export interface EffectParameter {
id: string
label: string
min: number
max: number
default: number
step: number
unit?: string
}
export interface EffectConfig {
id: string
name: string
parameters: EffectParameter[]
bypassable?: boolean
}
export type EffectValues = Record<string, number | boolean>
// Re-export from parameters for backward compatibility
export type { ParameterConfig as EffectParameter, ParameterGroup as EffectConfig, EffectValues } from './parameters'

2
src/types/index.ts Normal file
View 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
View 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

8
src/types/tiles.ts Normal file
View File

@ -0,0 +1,8 @@
import type { LFOSettings } from '../stores/settings'
export interface TileState {
formula: string
engineParams: Record<string, number>
effectParams: Record<string, number | boolean | string>
lfoConfigs: LFOSettings
}

View File

@ -1,3 +1,6 @@
import type { TileState } from '../types/tiles'
import { createTileState } from './tileState'
interface Template {
pattern: string
weight: number
@ -69,7 +72,71 @@ const TEMPLATES: Template[] = [
{ pattern: "((t*t)/(t^t>>S))&N", weight: 5 },
{ pattern: "(t*(t>>S1))^(t*(t>>S2))", weight: 6 },
{ pattern: "((t>>S1)*(t>>S2))&((t>>S3)|(t>>S4))", weight: 6 },
{ pattern: "(t&(t>>S1))^((t>>S2)&(t>>S3))", weight: 5 }
{ pattern: "(t&(t>>S1))^((t>>S2)&(t>>S3))", weight: 5 },
{ pattern: "t*(a&t>>b)", weight: 6 },
{ pattern: "(t>>a)|(t>>b)", weight: 6 },
{ pattern: "t&(t>>a)&(t>>c)", weight: 5 },
{ pattern: "(t*a)&(t>>b)", weight: 5 },
{ pattern: "t%(d)+(t>>a)", weight: 5 },
{ pattern: "(t>>a)^(t>>c)", weight: 5 },
{ pattern: "t*((t>>a)|(t>>b))&c", weight: 5 },
{ pattern: "((t>>a)&b)*(t>>c)", weight: 5 },
{ pattern: "(t&(t>>a))^(t>>d)", weight: 4 },
{ pattern: "t/(b+(t>>a|t>>c))", weight: 4 },
{ pattern: "t&t>>a", weight: 7 },
{ pattern: "t&t>>b", weight: 7 },
{ pattern: "(t*a&t>>b)|(t*c&t>>d)", weight: 9 },
{ pattern: "(t>>a)&(t>>b)", weight: 6 },
{ pattern: "t*(a&t>>b)|(t>>c)", weight: 7 },
{ pattern: "(t*a&t>>S1)|(t*b&t>>S2)", weight: 8 },
{ pattern: "t&(t>>a)|(t>>b)", weight: 6 },
{ pattern: "(t*c&t>>a)&(t>>b)", weight: 6 },
{ pattern: "t*(t>>a&t>>b)", weight: 6 },
{ pattern: "((t>>a)&N)|(t*b&t>>c)", weight: 7 },
{ pattern: "t&N?(t*a&t>>b):(t>>c)", weight: 7 },
{ pattern: "(t>>a)&N?(t*b):(t*c)", weight: 7 },
{ pattern: "t&M?(t>>a|t>>b):(t>>c&t>>d)", weight: 6 },
{ pattern: "(t>>S)&N?(t*a):(t>>b)", weight: 6 },
{ pattern: "t%(M)?(t>>a):(t*b&t>>c)", weight: 6 },
{ pattern: "t&d?(t*a&t>>S):(t>>b)", weight: 6 },
{ pattern: "(t>>a)&(t>>b)?(t*c):(t>>S)", weight: 5 },
{ pattern: "t&M?(t>>a)^(t>>b):(t*c)", weight: 5 },
{ pattern: "t*a%(M)", weight: 6 },
{ pattern: "(t*a)%(M1)+(t*b)%(M2)", weight: 7 },
{ pattern: "t*a&(t>>b)%(M)", weight: 6 },
{ pattern: "(t*c%(M))&(t>>a)", weight: 6 },
{ pattern: "t*a+(t*b&t>>c)", weight: 6 },
{ pattern: "(t*a&t>>S)+(t*b%(M))", weight: 7 },
{ pattern: "t*b%(M)*(t>>a)", weight: 6 },
{ pattern: "(t*a|t*b)&(t>>c)", weight: 6 },
{ pattern: "t*c&((t>>a)|(t>>b))", weight: 6 },
{ pattern: "(t*a%(M1))^(t*b%(M2))", weight: 6 },
{ pattern: "(t>>a)^(t>>b)^(t>>c)", weight: 6 },
{ pattern: "t^(t>>a)^(t*b)", weight: 6 },
{ pattern: "((t>>a)^(t>>b))&(t*c)", weight: 6 },
{ pattern: "(t^t>>a)*(t^t>>b)", weight: 6 },
{ pattern: "t^(t>>a)&(t>>b)&(t>>c)", weight: 5 },
{ pattern: "((t>>a)^N)*((t>>b)^M)", weight: 6 },
{ pattern: "(t*a)^(t>>b)^(t>>c)", weight: 6 },
{ pattern: "t^(t*a>>b)^(t>>c)", weight: 5 },
{ pattern: "((t^t>>a)&N)|(t>>b)", weight: 5 },
{ pattern: "(t>>a)^(t*b&t>>c)", weight: 6 },
{ pattern: "((t>>a)&(t>>b))*((t>>c)|(t*d))", weight: 7 },
{ pattern: "t*((t>>a|t>>b)&(t>>c|t*d))", weight: 7 },
{ pattern: "(t&(t>>a))*(t%(M))", weight: 6 },
{ pattern: "t/(D+(t>>a)&(t>>b))", weight: 5 },
{ pattern: "((t*a)&(t>>b))^((t*c)%(M))", weight: 6 },
{ pattern: "(t>>a|t*b)&((t>>c)^(t*d))", weight: 6 },
{ pattern: "t*(t>>a)%(M1)+(t>>b)%(M2)", weight: 7 },
{ pattern: "((t>>a)%(M))*((t*b)&(t>>c))", weight: 6 },
{ pattern: "t&((t>>a)|(t*b))^(t>>c)", weight: 6 },
{ pattern: "(t*a&N)|(t>>b&M)|(t*c)", weight: 7 }
]
function randomElement<T>(arr: T[]): T {
@ -119,9 +186,9 @@ function fillTemplate(pattern: string): string {
function applyParenthesizationRandomization(formula: string): string {
if (Math.random() < 0.2) {
const operators = formula.match(/[\+\-\*\/\&\|\^]/g)
const operators = formula.match(/[+\-*/&|^]/g)
if (operators && operators.length > 0) {
const parts = formula.split(/([+\-*\/&|^])/)
const parts = formula.split(/([+\-*/&|^])/)
if (parts.length >= 3) {
const idx = Math.floor(Math.random() * (parts.length - 2) / 2) * 2
parts[idx] = `(${parts[idx]})`
@ -216,4 +283,23 @@ export function generateFormulaGrid(rows: number, cols: number, complexity: numb
grid.push(row)
}
return grid
}
export function generateTileGrid(rows: number, cols: number, complexity: number = 1): TileState[][] {
const grid: TileState[][] = []
for (let i = 0; i < rows; i++) {
const row: TileState[] = []
for (let j = 0; j < cols; j++) {
const formula = generateRandomFormula(complexity)
const tile = createTileState(formula)
tile.engineParams.a = Math.floor(Math.random() * 256)
tile.engineParams.b = Math.floor(Math.random() * 256)
tile.engineParams.c = Math.floor(Math.random() * 256)
tile.engineParams.d = Math.floor(Math.random() * 256)
tile.engineParams.pitch = 0.1 + Math.random() * 0.9
row.push(tile)
}
grid.push(row)
}
return grid
}

100
src/utils/fmPatches.ts Normal file
View 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
}

View 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
}

View File

@ -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 {
const labels = ['Simple', 'Medium', 'Complex']
@ -10,11 +11,10 @@ export function getBitDepthLabel(index: number): string {
return labels[index] || '8bit'
}
export function getClipModeLabel(index: number): string {
const labels = ['Wrap', 'Clamp', 'Fold']
return labels[index] || 'Wrap'
}
export function getSampleRateLabel(index: number): string {
return `${SAMPLE_RATES[index]}Hz`
}
export function getAlgorithmLabel(index: number): string {
return getAlgorithmName(index)
}

8
src/utils/index.ts Normal file
View 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'

20
src/utils/tileHelpers.ts Normal file
View File

@ -0,0 +1,20 @@
import type { TileState } from '../types/tiles'
export type FocusedTile = { row: number; col: number } | 'custom'
export function getTileId(row: number, col: number): string {
return `${row}-${col}`
}
export function isCustomTileFocused(focusedTile: FocusedTile): boolean {
return focusedTile === 'custom'
}
export function isTileFocused(focusedTile: FocusedTile, row: number, col: number): boolean {
if (focusedTile === 'custom') return false
return focusedTile.row === row && focusedTile.col === col
}
export function getTileFromGrid(tiles: TileState[][], row: number, col: number): TileState | undefined {
return tiles[row]?.[col]
}

164
src/utils/tileState.ts Normal file
View File

@ -0,0 +1,164 @@
import type { TileState } from '../types/tiles'
import { getDefaultLFOValues } from '../stores/settings'
import { getDefaultEngineValues, getDefaultEffectValues, ENGINE_CONTROLS, EFFECTS } from '../config/parameters'
import type { LFOSettings } from '../stores/settings'
import { parameterManager } from '../services/ParameterManager'
export function createTileState(
formula: string,
engineParams?: Record<string, number>,
effectParams?: Record<string, number | boolean | string>,
lfoConfigs?: LFOSettings
): TileState {
return {
formula,
engineParams: engineParams ?? { ...getDefaultEngineValues() },
effectParams: effectParams ?? { ...getDefaultEffectValues(), masterVolume: 75 },
lfoConfigs: lfoConfigs ?? getDefaultLFOValues()
}
}
export function createTileStateFromCurrent(formula: string): TileState {
return {
formula,
engineParams: { ...parameterManager.getEngineParams() },
effectParams: { ...parameterManager.getEffectParams() },
lfoConfigs: JSON.parse(JSON.stringify(parameterManager.getLFOConfigs()))
}
}
export function loadTileParams(tile: TileState): void {
parameterManager.loadTileParams(tile)
}
export function saveTileParams(tile: TileState): TileState {
return parameterManager.saveTileParams(tile)
}
export function cloneTileState(tile: TileState): TileState {
return {
formula: tile.formula,
engineParams: { ...tile.engineParams },
effectParams: { ...tile.effectParams },
lfoConfigs: JSON.parse(JSON.stringify(tile.lfoConfigs))
}
}
function randomInRange(min: number, max: number, step: number): number {
const steps = Math.floor((max - min) / step)
const randomStep = Math.floor(Math.random() * (steps + 1))
return min + randomStep * step
}
export function randomizeTileParams(tile: TileState): TileState {
const randomEngineParams: Record<string, number> = {}
const randomEffectParams: Record<string, number | boolean | string> = {}
ENGINE_CONTROLS.forEach(control => {
control.parameters.forEach(param => {
if (param.id === 'sampleRate') {
randomEngineParams[param.id] = param.max as number
} else if (param.id === 'bitDepth') {
randomEngineParams[param.id] = param.max as number
} else if (param.id === 'pitch') {
randomEngineParams[param.id] = 0.1 + Math.random() * 1.4
} else if (param.id === 'a' || param.id === 'b' || param.id === 'c' || param.id === 'd') {
randomEngineParams[param.id] = Math.floor(Math.random() * 256)
} else {
randomEngineParams[param.id] = randomInRange(
param.min as number,
param.max as number,
param.step as number
)
}
})
})
const filterModes = ['lowpass', 'highpass']
const selectedFilterMode = filterModes[Math.floor(Math.random() * filterModes.length)]
const filterFreq = selectedFilterMode === 'lowpass'
? 800 + Math.random() * 4200
: 100 + Math.random() * 700
randomEffectParams['filterMode'] = selectedFilterMode
randomEffectParams['filterFreq'] = filterFreq
EFFECTS.forEach(effect => {
effect.parameters.forEach(param => {
if (param.id === 'filterMode' || param.id === 'filterFreq') {
return
}
if (param.id === 'delayWetDry') {
randomEffectParams[param.id] = Math.random() * 50
} else if (param.id === 'delayFeedback') {
randomEffectParams[param.id] = Math.random() * 90
} else if (param.id === 'bitcrushDepth') {
randomEffectParams[param.id] = 12 + Math.floor(Math.random() * 5)
} else if (param.id === 'bitcrushRate') {
randomEffectParams[param.id] = Math.random() * 30
} else if (param.options) {
const options = param.options
const randomOption = options[Math.floor(Math.random() * options.length)]
randomEffectParams[param.id] = randomOption.value
} else if (typeof param.default === 'boolean') {
randomEffectParams[param.id] = Math.random() > 0.5
} else {
randomEffectParams[param.id] = randomInRange(
param.min as number,
param.max as number,
param.step as number
)
}
})
if (effect.bypassable) {
randomEffectParams[`${effect.id}Bypass`] = Math.random() > 0.5
}
})
const modulatableParams = [
'filterFreq', 'filterRes',
'wavefolderDrive', 'bitcrushDepth', 'bitcrushRate',
'delayTime', 'delayFeedback', 'delayWetDry',
'reverbWetDry', 'reverbDecay', 'reverbDamping'
]
const randomLFOConfigs = getDefaultLFOValues()
const waveforms: Array<'sine' | 'triangle' | 'square' | 'sawtooth'> = ['sine', 'triangle', 'square', 'sawtooth']
randomLFOConfigs.lfo1 = {
waveform: waveforms[Math.floor(Math.random() * waveforms.length)],
frequency: Math.random() * 10,
phase: Math.random() * 360,
mappings: [{
targetParam: 'filterFreq',
depth: 20 + Math.random() * 60
}]
}
const availableParams = modulatableParams.filter(p => p !== 'filterFreq')
const lfoConfigs = [randomLFOConfigs.lfo2, randomLFOConfigs.lfo3, randomLFOConfigs.lfo4]
lfoConfigs.forEach(config => {
if (availableParams.length > 0 && Math.random() > 0.3) {
const paramIndex = Math.floor(Math.random() * availableParams.length)
const targetParam = availableParams.splice(paramIndex, 1)[0]
config.waveform = waveforms[Math.floor(Math.random() * waveforms.length)]
config.frequency = Math.random() * 10
config.phase = Math.random() * 360
config.mappings = [{
targetParam,
depth: 20 + Math.random() * 60
}]
}
})
return {
formula: tile.formula,
engineParams: randomEngineParams,
effectParams: randomEffectParams,
lfoConfigs: randomLFOConfigs
}
}

View File

@ -1,6 +1,6 @@
export function generateWaveformData(formula: string, width: number, sampleRate: number = 8000, duration: number = 0.5): number[] {
export function generateWaveformData(formula: string, width: number, sampleRate: number = 8000, duration: number = 0.5, a: number = 8, b: number = 16, c: number = 32, d: number = 64): number[] {
try {
const compiledFormula = new Function('t', `return ${formula}`) as (t: number) => number
const compiledFormula = new Function('t', 'a', 'b', 'c', 'd', `return ${formula}`) as (t: number, a: number, b: number, c: number, d: number) => number
const samplesPerPixel = Math.floor((sampleRate * duration) / width)
const waveform: number[] = []
@ -10,16 +10,11 @@ export function generateWaveformData(formula: string, width: number, sampleRate:
for (let s = 0; s < samplesPerPixel; s++) {
const t = x * samplesPerPixel + s
try {
const value = compiledFormula(t)
const byteValue = value & 0xFF
const normalized = (byteValue - 128) / 128
min = Math.min(min, normalized)
max = Math.max(max, normalized)
} catch {
min = Math.min(min, 0)
max = Math.max(max, 0)
}
const value = compiledFormula(t, a, b, c, d)
const byteValue = value & 0xFF
const normalized = (byteValue - 128) / 128
min = Math.min(min, normalized)
max = Math.max(max, normalized)
}
waveform.push(min, max)
@ -27,7 +22,7 @@ export function generateWaveformData(formula: string, width: number, sampleRate:
return waveform
} catch {
return new Array(width * 2).fill(0)
return Array(width * 2).fill(0)
}
}