diff --git a/src/App.tsx b/src/App.tsx index 6143cf41..8fc8190a 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -2,7 +2,7 @@ import { useState, useRef, useEffect } from 'react' import { useStore } from '@nanostores/react' import { PlaybackManager } from './services/PlaybackManager' import { DownloadService } from './services/DownloadService' -import { generateFormulaGrid } from './utils/bytebeatFormulas' +import { generateFormulaGrid, generateRandomFormula } from './utils/bytebeatFormulas' import { BytebeatTile } from './components/BytebeatTile' import { EffectsBar } from './components/EffectsBar' import { EngineControls } from './components/EngineControls' @@ -18,6 +18,7 @@ function App() { ) const [playing, setPlaying] = useState(null) const [queued, setQueued] = useState(null) + const [regenerating, setRegenerating] = useState(null) const [playbackPosition, setPlaybackPosition] = useState(0) const [downloading, setDownloading] = useState(false) const playbackManagerRef = useRef(null) @@ -136,6 +137,26 @@ function App() { downloadServiceRef.current.downloadFormula(formula, filename, { duration: 10, bitDepth: 8 }) } + const handleRegenerate = (row: number, col: number) => { + const id = `${row}-${col}` + const newFormula = generateRandomFormula(engineValues.complexity) + + setFormulas(prevFormulas => { + const newFormulas = [...prevFormulas] + newFormulas[row] = [...newFormulas[row]] + newFormulas[row][col] = newFormula + return newFormulas + }) + + if (playing === id && playbackManagerRef.current) { + setRegenerating(id) + playbackManagerRef.current.scheduleNextTrack(() => { + playFormula(newFormula, id) + setRegenerating(null) + }) + } + } + return (
@@ -172,10 +193,12 @@ function App() { col={j} isPlaying={playing === id} isQueued={queued === id} + isRegenerating={regenerating === id} playbackPosition={playing === id ? playbackPosition : 0} onPlay={handleTileClick} onDoubleClick={handleTileDoubleClick} onDownload={handleDownloadFormula} + onRegenerate={handleRegenerate} /> ) }) diff --git a/src/components/BytebeatTile.tsx b/src/components/BytebeatTile.tsx index 43649f73..4a70a769 100644 --- a/src/components/BytebeatTile.tsx +++ b/src/components/BytebeatTile.tsx @@ -1,5 +1,5 @@ import { useRef, useEffect } from 'react' -import { Download } from 'lucide-react' +import { Download, RefreshCw } from 'lucide-react' import { generateWaveformData, drawWaveform } from '../utils/waveformGenerator' interface BytebeatTileProps { @@ -8,13 +8,15 @@ interface BytebeatTileProps { col: number isPlaying: boolean isQueued: boolean + isRegenerating: boolean playbackPosition: 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, playbackPosition, onPlay, onDoubleClick, onDownload }: BytebeatTileProps) { +export function BytebeatTile({ formula, row, col, isPlaying, isQueued, isRegenerating, playbackPosition, onPlay, onDoubleClick, onDownload, onRegenerate }: BytebeatTileProps) { const canvasRef = useRef(null) useEffect(() => { @@ -35,12 +37,17 @@ export function BytebeatTile({ formula, row, col, isPlaying, isQueued, playbackP onDownload(formula, `bytebeat_${row}_${col}.wav`) } + const handleRegenerate = (e: React.MouseEvent) => { + e.stopPropagation() + onRegenerate(row, col) + } + return (
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' : 'bg-black text-white' + 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' }`} > {formula}
-
- +
+
+ +
+
+ +
) diff --git a/src/components/EffectsBar.tsx b/src/components/EffectsBar.tsx index 7207c1f2..acfd6efa 100644 --- a/src/components/EffectsBar.tsx +++ b/src/components/EffectsBar.tsx @@ -35,20 +35,41 @@ export function EffectsBar({ values, onChange }: EffectsBarProps) { )}
- {effect.parameters.map(param => ( - onChange(param.id, value)} - formatValue={param.id === 'clipMode' ? formatValue : undefined} - valueId={param.id} - /> - ))} + {effect.parameters.map(param => { + const isSwitch = param.min === 0 && param.max === 1 && param.step === 1 + + if (isSwitch) { + return ( +
+
+ + {param.label.toUpperCase()} + + onChange(param.id, checked ? 1 : 0)} + label={Boolean(values[param.id]) ? 'ON' : 'OFF'} + /> +
+
+ ) + } + + return ( + onChange(param.id, value)} + formatValue={param.id === 'clipMode' ? formatValue : undefined} + valueId={param.id} + /> + ) + })}
))} diff --git a/src/config/effects.ts b/src/config/effects.ts index 29cbc4bc..a6b78bc4 100644 --- a/src/config/effects.ts +++ b/src/config/effects.ts @@ -65,8 +65,95 @@ export const ENGINE_CONTROLS: EffectConfig[] = [ export const EFFECTS: EffectConfig[] = [ { - id: 'wavefolder', - name: 'Wavefolder', + id: 'filter', + name: 'Filter', + parameters: [ + { + id: 'hpEnable', + label: 'HP', + min: 0, + max: 1, + default: 0, + step: 1, + unit: '' + }, + { + 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', + 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, + step: 0.1, + unit: '' + } + ] + }, + { + id: 'foldcrush', + name: 'Fold and Crush', bypassable: true, parameters: [ { @@ -86,14 +173,7 @@ export const EFFECTS: EffectConfig[] = [ default: 1, step: 0.1, unit: 'x' - } - ] - }, - { - id: 'bitcrush', - name: 'Bitcrush', - bypassable: true, - parameters: [ + }, { id: 'bitcrushDepth', label: 'Depth', diff --git a/src/domain/audio/effects/EffectsChain.ts b/src/domain/audio/effects/EffectsChain.ts index 4e0a59ed..e81ee5c2 100644 --- a/src/domain/audio/effects/EffectsChain.ts +++ b/src/domain/audio/effects/EffectsChain.ts @@ -1,8 +1,8 @@ import type { Effect } from './Effect.interface' +import { FilterEffect } from './FilterEffect' +import { FoldCrushEffect } from './FoldCrushEffect' import { DelayEffect } from './DelayEffect' import { ReverbEffect } from './ReverbEffect' -import { BitcrushEffect } from './BitcrushEffect' -import { WavefolderEffect } from './WavefolderEffect' export class EffectsChain { private inputNode: GainNode @@ -16,8 +16,8 @@ export class EffectsChain { this.masterGainNode = audioContext.createGain() this.effects = [ - new WavefolderEffect(audioContext), - new BitcrushEffect(audioContext), + new FilterEffect(audioContext), + new FoldCrushEffect(audioContext), new DelayEffect(audioContext), new ReverbEffect(audioContext) ] diff --git a/src/domain/audio/effects/FilterEffect.ts b/src/domain/audio/effects/FilterEffect.ts new file mode 100644 index 00000000..f9869461 --- /dev/null +++ b/src/domain/audio/effects/FilterEffect.ts @@ -0,0 +1,169 @@ +import type { Effect } from './Effect.interface' + +export class FilterEffect implements Effect { + readonly id = 'filter' + + private audioContext: AudioContext + private inputNode: GainNode + private outputNode: 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.hpFilter = audioContext.createBiquadFilter() + this.hpFilter.type = 'highpass' + this.hpFilter.frequency.value = 20 + this.hpFilter.Q.value = 1 + + this.lpFilter = audioContext.createBiquadFilter() + this.lpFilter.type = 'lowpass' + this.lpFilter.frequency.value = 20000 + this.lpFilter.Q.value = 1 + + this.bpFilter = audioContext.createBiquadFilter() + this.bpFilter.type = 'allpass' + this.bpFilter.frequency.value = 1000 + this.bpFilter.Q.value = 1 + + this.inputNode.connect(this.hpFilter) + this.hpFilter.connect(this.lpFilter) + this.lpFilter.connect(this.bpFilter) + this.bpFilter.connect(this.outputNode) + } + + getInputNode(): AudioNode { + return this.inputNode + } + + getOutputNode(): AudioNode { + return this.outputNode + } + + setBypass(bypass: boolean): void { + // No global bypass for filters - each filter has individual enable switch + } + + updateParams(values: Record): void { + + if (values.hpEnable !== undefined) { + const enable = values.hpEnable === 1 + if (enable && !this.hpEnabled) { + this.hpFilter.type = 'highpass' + this.hpEnabled = true + } else if (!enable && this.hpEnabled) { + this.hpFilter.type = 'allpass' + this.hpEnabled = false + } + } + + if (values.hpFreq !== undefined && this.hpEnabled) { + 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 + ) + } + + if (values.hpRes !== undefined && this.hpEnabled) { + 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 + ) + } + + if (values.lpEnable !== undefined) { + const enable = values.lpEnable === 1 + if (enable && !this.lpEnabled) { + this.lpFilter.type = 'lowpass' + this.lpEnabled = true + } else if (!enable && this.lpEnabled) { + this.lpFilter.type = 'allpass' + this.lpEnabled = false + } + } + + if (values.lpFreq !== undefined && this.lpEnabled) { + 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.lpRes !== undefined && this.lpEnabled) { + 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) { + const enable = values.bpEnable === 1 + if (enable && !this.bpEnabled) { + this.bpFilter.type = 'bandpass' + this.bpEnabled = true + } else if (!enable && this.bpEnabled) { + this.bpFilter.type = 'allpass' + this.bpEnabled = false + } + } + + if (values.bpFreq !== undefined && this.bpEnabled) { + 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.bpEnabled) { + 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 + ) + } + } + + dispose(): void { + this.inputNode.disconnect() + this.outputNode.disconnect() + this.hpFilter.disconnect() + this.lpFilter.disconnect() + this.bpFilter.disconnect() + } +} diff --git a/src/domain/audio/effects/FoldCrushEffect.ts b/src/domain/audio/effects/FoldCrushEffect.ts new file mode 100644 index 00000000..7b4b9b17 --- /dev/null +++ b/src/domain/audio/effects/FoldCrushEffect.ts @@ -0,0 +1,149 @@ +import type { Effect } from './Effect.interface' + +type ClipMode = 'wrap' | 'clamp' | 'fold' + +export class FoldCrushEffect implements Effect { + readonly id = 'foldcrush' + + private inputNode: GainNode + private outputNode: GainNode + private processorNode: ScriptProcessorNode + private wetNode: GainNode + private dryNode: GainNode + private clipMode: ClipMode = 'wrap' + private drive: number = 1 + private bitDepth: number = 16 + private crushAmount: number = 0 + + constructor(audioContext: AudioContext) { + this.inputNode = audioContext.createGain() + this.outputNode = audioContext.createGain() + this.processorNode = audioContext.createScriptProcessor(4096, 1, 1) + this.wetNode = audioContext.createGain() + this.dryNode = audioContext.createGain() + + this.wetNode.gain.value = 1 + this.dryNode.gain.value = 0 + + this.processorNode.onaudioprocess = (e) => { + const input = e.inputBuffer.getChannelData(0) + const output = e.outputBuffer.getChannelData(0) + + for (let i = 0; i < input.length; i++) { + const driven = input[i] * this.drive + let processed = this.processWavefolder(driven) + processed = this.processBitcrush(processed, i, output) + output[i] = processed + } + } + + this.inputNode.connect(this.dryNode) + this.inputNode.connect(this.processorNode) + this.processorNode.connect(this.wetNode) + this.dryNode.connect(this.outputNode) + this.wetNode.connect(this.outputNode) + } + + private processWavefolder(sample: number): number { + switch (this.clipMode) { + case 'wrap': + return this.wrap(sample) + case 'clamp': + return this.clamp(sample) + case 'fold': + return this.fold(sample) + default: + return sample + } + } + + private wrap(sample: number): number { + const range = 2.0 + let wrapped = sample + while (wrapped > 1.0) wrapped -= range + while (wrapped < -1.0) wrapped += range + return wrapped + } + + private clamp(sample: number): number { + return Math.max(-1.0, Math.min(1.0, sample)) + } + + private fold(sample: number): number { + 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 + } + } + return folded + } + + private bitcrushPhase: number = 0 + private lastCrushedValue: number = 0 + + private processBitcrush(sample: number, index: number, output: Float32Array): number { + if (this.crushAmount === 0 && this.bitDepth === 16) { + return sample + } + + const step = Math.pow(0.5, this.bitDepth) + const phaseIncrement = 1 - (this.crushAmount / 100) + + this.bitcrushPhase += phaseIncrement + + 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)) + return this.lastCrushedValue + } else { + return this.lastCrushedValue + } + } + + getInputNode(): AudioNode { + return this.inputNode + } + + getOutputNode(): AudioNode { + return this.outputNode + } + + 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): void { + if (values.clipMode !== undefined) { + const modeIndex = values.clipMode + this.clipMode = ['wrap', 'clamp', 'fold'][modeIndex] as ClipMode || 'wrap' + } + if (values.wavefolderDrive !== undefined) { + this.drive = values.wavefolderDrive + } + if (values.bitcrushDepth !== undefined) { + this.bitDepth = values.bitcrushDepth + } + if (values.bitcrushRate !== undefined) { + this.crushAmount = values.bitcrushRate + } + } + + dispose(): void { + this.processorNode.disconnect() + this.wetNode.disconnect() + this.dryNode.disconnect() + this.inputNode.disconnect() + this.outputNode.disconnect() + } +}