From ac772054c9f3784598ea3a24c0df26cf3004ff06 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rapha=C3=ABl=20Forment?= Date: Mon, 6 Oct 2025 02:16:23 +0200 Subject: [PATCH] slightly better --- public/worklets/bytebeat-processor.js | 6 +- public/worklets/output-limiter.js | 60 ++++ public/worklets/svf-processor.js | 103 +++++++ src/App.tsx | 280 ++++++++++++++++-- src/components/AudioContextWarning.tsx | 9 + src/components/BytebeatTile.tsx | 14 +- src/components/EffectsBar.tsx | 79 +---- src/components/EngineControls.tsx | 9 +- src/components/HelpModal.tsx | 105 +++++++ src/components/Knob.tsx | 33 ++- src/components/LFOPanel.tsx | 61 ++++ src/components/LFOScope.tsx | 238 +++++++++++++++ src/components/MappingEditor.tsx | 81 +++++ src/components/Slider.tsx | 45 ++- src/config/effects.ts | 94 ++---- src/constants/defaults.ts | 2 +- src/domain/audio/AudioPlayer.ts | 97 +++++- .../audio/effects/BytebeatSourceEffect.ts | 13 +- src/domain/audio/effects/Effect.interface.ts | 1 + src/domain/audio/effects/EffectsChain.ts | 20 +- src/domain/audio/effects/FilterEffect.ts | 157 +++------- src/domain/audio/effects/FoldCrushEffect.ts | 12 +- src/domain/audio/effects/OutputLimiter.ts | 48 +++ src/domain/modulation/LFO.ts | 66 +++++ src/domain/modulation/ModulationEngine.ts | 159 ++++++++++ src/domain/modulation/ParameterRegistry.ts | 133 +++++++++ src/hooks/useKeyboardShortcuts.ts | 18 ++ src/services/DownloadService.ts | 30 +- src/services/PlaybackManager.ts | 36 +-- src/stores/mappingMode.ts | 34 +++ src/stores/settings.ts | 46 ++- src/types/tiles.ts | 3 + src/utils/bytebeatFormulas.ts | 8 +- src/utils/tileState.ts | 145 ++++++++- src/utils/waveformGenerator.ts | 19 +- 35 files changed, 1874 insertions(+), 390 deletions(-) create mode 100644 public/worklets/output-limiter.js create mode 100644 public/worklets/svf-processor.js create mode 100644 src/components/AudioContextWarning.tsx create mode 100644 src/components/HelpModal.tsx create mode 100644 src/components/LFOPanel.tsx create mode 100644 src/components/LFOScope.tsx create mode 100644 src/components/MappingEditor.tsx create mode 100644 src/domain/audio/effects/OutputLimiter.ts create mode 100644 src/domain/modulation/LFO.ts create mode 100644 src/domain/modulation/ModulationEngine.ts create mode 100644 src/domain/modulation/ParameterRegistry.ts create mode 100644 src/stores/mappingMode.ts diff --git a/public/worklets/bytebeat-processor.js b/public/worklets/bytebeat-processor.js index 49296721..9d4abc0d 100644 --- a/public/worklets/bytebeat-processor.js +++ b/public/worklets/bytebeat-processor.js @@ -12,6 +12,7 @@ class BytebeatProcessor extends AudioWorkletProcessor { this.sampleRate = 8000 this.duration = 4 this.loopLength = this.sampleRate * this.duration + this.playbackRate = 1.0 this.error = false this.port.onmessage = (event) => { @@ -32,6 +33,9 @@ class BytebeatProcessor extends AudioWorkletProcessor { case 'loopLength': this.loopLength = value break + case 'playbackRate': + this.playbackRate = value + break } } } @@ -71,7 +75,7 @@ class BytebeatProcessor extends AudioWorkletProcessor { } } - this.t++ + this.t += this.playbackRate if (this.loopLength > 0 && this.t >= this.loopLength) { this.t = 0 } diff --git a/public/worklets/output-limiter.js b/public/worklets/output-limiter.js new file mode 100644 index 00000000..6e35ee09 --- /dev/null +++ b/public/worklets/output-limiter.js @@ -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: 1.5, + minValue: 1.0, + maxValue: 3.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 = inputChannel[i] * makeup + sample = this.softClip(sample, threshold) + outputChannel[i] = sample + } + } + + return true + } +} + +registerProcessor('output-limiter', OutputLimiter) diff --git a/public/worklets/svf-processor.js b/public/worklets/svf-processor.js new file mode 100644 index 00000000..ba815ae4 --- /dev/null +++ b/public/worklets/svf-processor.js @@ -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) diff --git a/src/App.tsx b/src/App.tsx index eece4361..a2fda63e 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,6 +1,6 @@ import { useState, useRef, useEffect } from 'react' import { useStore } from '@nanostores/react' -import { Square, Archive, Dices } from 'lucide-react' +import { Square, Archive, Dices, Sparkles } from 'lucide-react' import { PlaybackManager } from './services/PlaybackManager' import { DownloadService } from './services/DownloadService' import { generateTileGrid, generateRandomFormula } from './utils/bytebeatFormulas' @@ -8,12 +8,16 @@ import { BytebeatTile } from './components/BytebeatTile' import { EffectsBar } from './components/EffectsBar' import { EngineControls } from './components/EngineControls' import { FormulaEditor } from './components/FormulaEditor' +import { LFOPanel } from './components/LFOPanel' +import { AudioContextWarning } from './components/AudioContextWarning' +import { HelpModal } from './components/HelpModal' import { getSampleRateFromIndex } from './config/effects' -import { engineSettings, effectSettings } from './stores/settings' +import { engineSettings, effectSettings, lfoSettings } from './stores/settings' +import { exitMappingMode } from './stores/mappingMode' import { useKeyboardShortcuts } from './hooks/useKeyboardShortcuts' import { useTileParams } from './hooks/useTileParams' import type { TileState } from './types/tiles' -import { createTileStateFromCurrent, loadTileParams } from './utils/tileState' +import { createTileStateFromCurrent, loadTileParams, randomizeTileParams } from './utils/tileState' import { DEFAULT_VARIABLES, PLAYBACK_ID, TILE_GRID, DEFAULT_DOWNLOAD_OPTIONS } from './constants/defaults' import { getTileId, getTileFromGrid, type FocusedTile } from './utils/tileHelpers' @@ -30,6 +34,8 @@ function App() { const [downloading, setDownloading] = useState(false) const [focusedTile, setFocusedTile] = useState({ row: 0, col: 0 }) const [customTile, setCustomTile] = useState(() => createTileStateFromCurrent('t*(8&t>>9)')) + const [showWarning, setShowWarning] = useState(true) + const [showHelp, setShowHelp] = useState(false) const playbackManagerRef = useRef(null) const downloadServiceRef = useRef(new DownloadService()) @@ -50,6 +56,59 @@ function App() { setQueued(null) } + const handleRandomizeAllParams = () => { + let newRandomized: TileState | null = null + + if (playing === PLAYBACK_ID.CUSTOM) { + setCustomTile(prev => { + const randomized = randomizeTileParams(prev) + newRandomized = randomized + return randomized + }) + } else { + setTiles(prevTiles => { + const newTiles = prevTiles.map((row, rowIdx) => + row.map((tile, colIdx) => { + const randomized = randomizeTileParams(tile) + if (playing && focusedTile !== 'custom') { + const tileId = getTileId(focusedTile.row, focusedTile.col) + if (playing === tileId && rowIdx === focusedTile.row && colIdx === focusedTile.col) { + newRandomized = randomized + } + } + return randomized + }) + ) + return newTiles + }) + + setCustomTile(prev => randomizeTileParams(prev)) + } + + if (newRandomized && playbackManagerRef.current) { + const params = newRandomized as TileState + loadTileParams(params) + + playbackManagerRef.current.setEffects(params.effectParams as any) + playbackManagerRef.current.setVariables( + params.engineParams.a ?? DEFAULT_VARIABLES.a, + params.engineParams.b ?? DEFAULT_VARIABLES.b, + params.engineParams.c ?? DEFAULT_VARIABLES.c, + params.engineParams.d ?? DEFAULT_VARIABLES.d + ) + playbackManagerRef.current.setPitch(params.engineParams.pitch ?? 1.0) + + if (params.lfoConfigs) { + playbackManagerRef.current.setLFOConfig(0, params.lfoConfigs.lfo1) + playbackManagerRef.current.setLFOConfig(1, params.lfoConfigs.lfo2) + playbackManagerRef.current.setLFOConfig(2, params.lfoConfigs.lfo3) + playbackManagerRef.current.setLFOConfig(3, params.lfoConfigs.lfo4) + } + } + + setQueued(null) + } + const playFormula = async (formula: string, id: string) => { const sampleRate = getSampleRateFromIndex(engineValues.sampleRate) const duration = engineValues.loopDuration @@ -68,17 +127,12 @@ function App() { engineValues.c ?? DEFAULT_VARIABLES.c, engineValues.d ?? DEFAULT_VARIABLES.d ) + playbackManagerRef.current.setPitch(engineValues.pitch ?? 1.0) - const success = await playbackManagerRef.current.play(formula) - - if (success) { - setPlaying(id) - setQueued(null) - return true - } else { - console.error('Failed to play formula') - return false - } + await playbackManagerRef.current.play(formula) + setPlaying(id) + setQueued(null) + return true } const handleTileClick = (_formula: string, row: number, col: number, isDoubleClick: boolean = false) => { @@ -121,7 +175,7 @@ function App() { playbackManagerRef.current.setEffects({ ...effectValues, masterVolume: value }) } - if (parameterId === 'pitch' && playbackManagerRef.current) { + if (parameterId === 'pitch' && playbackManagerRef.current && playing) { playbackManagerRef.current.setPitch(value) } @@ -145,6 +199,86 @@ function App() { } } + const handleLFOChange = (lfoIndex: number, config: any) => { + if (playbackManagerRef.current) { + playbackManagerRef.current.setLFOConfig(lfoIndex, config) + } + } + + const handleParameterMapClick = (paramId: string, lfoIndex: number) => { + const lfoKey = `lfo${lfoIndex + 1}` as 'lfo1' | 'lfo2' | 'lfo3' | 'lfo4' + const currentLFO = lfoSettings.get()[lfoKey] + + const existingMappingIndex = currentLFO.mappings.findIndex(m => m.targetParam === paramId) + + let updatedMappings + if (existingMappingIndex >= 0) { + updatedMappings = currentLFO.mappings.filter((_, i) => i !== existingMappingIndex) + } else { + updatedMappings = [...currentLFO.mappings, { targetParam: paramId, depth: 50 }] + } + + const updatedLFO = { ...currentLFO, mappings: updatedMappings } + lfoSettings.setKey(lfoKey, updatedLFO) + + if (playbackManagerRef.current) { + playbackManagerRef.current.setLFOConfig(lfoIndex, updatedLFO) + } + + saveCurrentTileParams() + + if (updatedMappings.length === 0 || existingMappingIndex >= 0) { + exitMappingMode() + } + } + + const handleUpdateMappingDepth = (lfoIndex: number, paramId: string, depth: number) => { + const lfoKey = `lfo${lfoIndex + 1}` as 'lfo1' | 'lfo2' | 'lfo3' | 'lfo4' + const currentLFO = lfoSettings.get()[lfoKey] + + const updatedMappings = currentLFO.mappings.map(m => + m.targetParam === paramId ? { ...m, depth } : m + ) + + const updatedLFO = { ...currentLFO, mappings: updatedMappings } + lfoSettings.setKey(lfoKey, updatedLFO) + + if (playbackManagerRef.current) { + playbackManagerRef.current.setLFOConfig(lfoIndex, updatedLFO) + } + + saveCurrentTileParams() + } + + const handleRemoveMapping = (lfoIndex: number, paramId: string) => { + const lfoKey = `lfo${lfoIndex + 1}` as 'lfo1' | 'lfo2' | 'lfo3' | 'lfo4' + const currentLFO = lfoSettings.get()[lfoKey] + + const updatedMappings = currentLFO.mappings.filter(m => m.targetParam !== paramId) + + const updatedLFO = { ...currentLFO, mappings: updatedMappings } + lfoSettings.setKey(lfoKey, updatedLFO) + + if (playbackManagerRef.current) { + playbackManagerRef.current.setLFOConfig(lfoIndex, updatedLFO) + } + + saveCurrentTileParams() + } + + const getMappedLFOs = (paramId: string): number[] => { + const lfos = lfoSettings.get() + const mapped: number[] = [] + + Object.entries(lfos).forEach(([, lfo], index) => { + if (lfo.mappings.some((m: { targetParam: string }) => m.targetParam === paramId)) { + mapped.push(index) + } + }) + + return mapped + } + const handleDownloadAll = async () => { setDownloading(true) const formulas = tiles.map(row => row.map(tile => tile.formula)) @@ -276,6 +410,77 @@ function App() { handleRandom() } + const handleEscape = () => { + exitMappingMode() + } + + const handleKeyboardC = () => { + if (focusedTile === 'custom') { + setCustomTile(prev => { + const randomized = randomizeTileParams(prev) + loadTileParams(randomized) + + if (playing === PLAYBACK_ID.CUSTOM && playbackManagerRef.current) { + playbackManagerRef.current.setEffects(randomized.effectParams as any) + playbackManagerRef.current.setVariables( + randomized.engineParams.a ?? DEFAULT_VARIABLES.a, + randomized.engineParams.b ?? DEFAULT_VARIABLES.b, + randomized.engineParams.c ?? DEFAULT_VARIABLES.c, + randomized.engineParams.d ?? DEFAULT_VARIABLES.d + ) + playbackManagerRef.current.setPitch(randomized.engineParams.pitch ?? 1.0) + + if (randomized.lfoConfigs) { + playbackManagerRef.current.setLFOConfig(0, randomized.lfoConfigs.lfo1) + playbackManagerRef.current.setLFOConfig(1, randomized.lfoConfigs.lfo2) + playbackManagerRef.current.setLFOConfig(2, randomized.lfoConfigs.lfo3) + playbackManagerRef.current.setLFOConfig(3, randomized.lfoConfigs.lfo4) + } + } + + return randomized + }) + } else { + const tileId = getTileId(focusedTile.row, focusedTile.col) + setTiles(prevTiles => { + const newTiles = [...prevTiles] + newTiles[focusedTile.row] = [...newTiles[focusedTile.row]] + const randomized = randomizeTileParams(newTiles[focusedTile.row][focusedTile.col]) + newTiles[focusedTile.row][focusedTile.col] = randomized + + loadTileParams(randomized) + + if (playing === tileId && playbackManagerRef.current) { + playbackManagerRef.current.setEffects(randomized.effectParams as any) + playbackManagerRef.current.setVariables( + randomized.engineParams.a ?? DEFAULT_VARIABLES.a, + randomized.engineParams.b ?? DEFAULT_VARIABLES.b, + randomized.engineParams.c ?? DEFAULT_VARIABLES.c, + randomized.engineParams.d ?? DEFAULT_VARIABLES.d + ) + playbackManagerRef.current.setPitch(randomized.engineParams.pitch ?? 1.0) + + if (randomized.lfoConfigs) { + playbackManagerRef.current.setLFOConfig(0, randomized.lfoConfigs.lfo1) + playbackManagerRef.current.setLFOConfig(1, randomized.lfoConfigs.lfo2) + playbackManagerRef.current.setLFOConfig(2, randomized.lfoConfigs.lfo3) + playbackManagerRef.current.setLFOConfig(3, randomized.lfoConfigs.lfo4) + } + } + + return newTiles + }) + } + } + + const handleKeyboardShiftC = () => { + handleRandomizeAllParams() + } + + const handleDismissWarning = () => { + setShowWarning(false) + } + useKeyboardShortcuts({ onSpace: handleKeyboardSpace, onArrowUp: (shift) => moveFocus('up', shift ? 10 : 1), @@ -285,7 +490,10 @@ function App() { onEnter: handleKeyboardEnter, onDoubleEnter: handleKeyboardDoubleEnter, onR: handleKeyboardR, - onShiftR: handleKeyboardShiftR + onShiftR: handleKeyboardShiftR, + onC: handleKeyboardC, + onShiftC: handleKeyboardShiftC, + onEscape: handleEscape }) useEffect(() => { @@ -299,10 +507,22 @@ function App() { return (
+ {showWarning && } + {showHelp && setShowHelp(false)} />}
-

BRUITISTE

- +

setShowHelp(true)} + className="font-mono text-sm tracking-[0.3em] text-white flex-shrink-0 cursor-pointer hover:opacity-70 transition-opacity" + > + BRUITISTE +

+
+
+ +
-
-
+
+
-
+
{tiles.map((row, i) => row.map((tile, j) => { const id = getTileId(i, j) @@ -360,6 +589,10 @@ function App() { 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} @@ -371,7 +604,12 @@ function App() {
- +
) } diff --git a/src/components/AudioContextWarning.tsx b/src/components/AudioContextWarning.tsx new file mode 100644 index 00000000..5ddd3c71 --- /dev/null +++ b/src/components/AudioContextWarning.tsx @@ -0,0 +1,9 @@ +import { HelpModal } from './HelpModal' + +interface AudioContextWarningProps { + onDismiss: () => void +} + +export function AudioContextWarning({ onDismiss }: AudioContextWarningProps) { + return +} diff --git a/src/components/BytebeatTile.tsx b/src/components/BytebeatTile.tsx index 0a4d4e9c..bef8417a 100644 --- a/src/components/BytebeatTile.tsx +++ b/src/components/BytebeatTile.tsx @@ -10,13 +10,17 @@ interface BytebeatTileProps { isQueued: 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, isFocused, 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(null) useEffect(() => { @@ -27,10 +31,10 @@ export function BytebeatTile({ formula, row, col, isPlaying, isQueued, isFocused canvas.width = rect.width * window.devicePixelRatio canvas.height = rect.height * window.devicePixelRatio - const waveformData = generateWaveformData(formula, canvas.width) + const 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]) const handleDownload = (e: React.MouseEvent) => { e.stopPropagation() @@ -47,7 +51,7 @@ export function BytebeatTile({ formula, row, col, isPlaying, isQueued, isFocused 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 ${ + 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]' : ''}`} > @@ -64,7 +68,7 @@ export function BytebeatTile({ formula, row, col, isPlaying, isQueued, isFocused
{formula}
-
+
void + onMapClick?: (paramId: string, lfoIndex: number) => void + getMappedLFOs?: (paramId: string) => number[] } -export function EffectsBar({ values, onChange }: EffectsBarProps) { +export function EffectsBar({ values, onChange, onMapClick, getMappedLFOs }: EffectsBarProps) { const randomizeEffect = (effect: typeof EFFECTS[number]) => { effect.parameters.forEach(param => { if (param.id.endsWith('Enable')) return @@ -28,82 +30,10 @@ export function EffectsBar({ values, onChange }: EffectsBarProps) { }) } - const renderFilterEffect = (effect: typeof EFFECTS[number]) => { - const filterGroups = [ - { prefix: 'hp', label: 'HP' }, - { prefix: 'lp', label: 'LP' }, - { prefix: 'bp', label: 'BP' } - ] - - return ( -
-
-

- {effect.name.toUpperCase()} -

- -
-
- {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 ( -
- -
- onChange(freqParam.id, value)} - valueId={freqParam.id} - /> - onChange(resParam.id, value)} - valueId={resParam.id} - /> -
-
- ) - })} -
-
- ) - } - return (
{EFFECTS.map(effect => { - if (effect.id === 'filter') { - return renderFilterEffect(effect) - } - return (
@@ -170,6 +100,9 @@ export function EffectsBar({ values, onChange }: EffectsBarProps) { unit={param.unit} onChange={(value) => onChange(param.id, value)} valueId={param.id} + paramId={param.id} + onMapClick={onMapClick} + mappedLFOs={getMappedLFOs ? getMappedLFOs(param.id) : []} /> ) })} diff --git a/src/components/EngineControls.tsx b/src/components/EngineControls.tsx index 42ec6376..5618bebd 100644 --- a/src/components/EngineControls.tsx +++ b/src/components/EngineControls.tsx @@ -6,11 +6,13 @@ import { Knob } from './Knob' interface EngineControlsProps { values: EffectValues onChange: (parameterId: string, value: number) => void + onMapClick?: (paramId: string, lfoIndex: number) => void + getMappedLFOs?: (paramId: string) => number[] } -const KNOB_PARAMS = ['masterVolume', 'a', 'b', 'c', 'd'] +const KNOB_PARAMS = ['masterVolume', 'pitch', 'a', 'b', 'c', 'd'] -export function EngineControls({ values, onChange }: EngineControlsProps) { +export function EngineControls({ values, onChange, onMapClick, getMappedLFOs }: EngineControlsProps) { const formatValue = (id: string, value: number): string => { switch (id) { case 'sampleRate': @@ -43,6 +45,9 @@ export function EngineControls({ values, onChange }: EngineControlsProps) { onChange={(value) => onChange(param.id, value)} formatValue={formatValue} valueId={param.id} + paramId={param.id} + onMapClick={onMapClick} + mappedLFOs={getMappedLFOs ? getMappedLFOs(param.id) : []} /> ) } diff --git a/src/components/HelpModal.tsx b/src/components/HelpModal.tsx new file mode 100644 index 00000000..5cbec738 --- /dev/null +++ b/src/components/HelpModal.tsx @@ -0,0 +1,105 @@ +interface HelpModalProps { + onClose: () => void + showStartButton?: boolean +} + +export function HelpModal({ onClose, showStartButton = false }: HelpModalProps) { + return ( +
{ + if (e.key === 'Escape' || e.key === 'Enter' || e.key === ' ') { + onClose() + } + }} + tabIndex={0} + > +
e.stopPropagation()} + > +

+ BRUITISTE +

+

+ A harsh noise soundbox +

+

+ Made by Raphaël Forment (BuboBubo) — raphaelforment.fr +

+ +
+

KEYBOARD SHORTCUTS

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
KEYACTION
SPACEPlay/Stop current tile
ARROWSNavigate tiles
SHIFT + ARROWSJump 10 tiles
ENTERQueue tile (play after current)
DOUBLE ENTERPlay immediately
RRegenerate current tile
SHIFT + RRandomize all tiles
CRandomize current tile params
SHIFT + CRandomize all params (CHAOS)
ESCExit mapping mode
+
+ + {showStartButton ? ( + + ) : ( + + )} +
+
+ ) +} diff --git a/src/components/Knob.tsx b/src/components/Knob.tsx index f54df799..ecd5ce21 100644 --- a/src/components/Knob.tsx +++ b/src/components/Knob.tsx @@ -1,4 +1,6 @@ import { useRef, useState, useEffect } from 'react' +import { useStore } from '@nanostores/react' +import { mappingMode } from '../stores/mappingMode' interface KnobProps { label: string @@ -11,6 +13,9 @@ interface KnobProps { formatValue?: (id: string, value: number) => string valueId?: string size?: number + paramId?: string + onMapClick?: (paramId: string, activeLFO: number) => void + mappedLFOs?: number[] } export function Knob({ @@ -23,18 +28,30 @@ export function Knob({ onChange, formatValue, valueId, - size = 48 + size = 48, + paramId, + onMapClick, + mappedLFOs = [] }: KnobProps) { const [isDragging, setIsDragging] = useState(false) const startYRef = useRef(0) const startValueRef = useRef(0) + const mappingModeState = useStore(mappingMode) const displayValue = formatValue && valueId ? formatValue(valueId, value) : `${value}${unit || ''}` + const isInMappingMode = mappingModeState.isActive && paramId + const hasMappings = mappedLFOs.length > 0 const normalizedValue = (value - min) / (max - min) const angle = -225 + normalizedValue * 270 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 @@ -71,7 +88,7 @@ export function Knob({ return (
@@ -87,6 +104,7 @@ export function Knob({ fill="none" stroke="white" strokeWidth="2" + className={isInMappingMode ? 'animate-pulse' : ''} /> + + {hasMappings && ( + + )}
- + {isDragging ? displayValue : label.toUpperCase()}
diff --git a/src/components/LFOPanel.tsx b/src/components/LFOPanel.tsx new file mode 100644 index 00000000..58fd3aa3 --- /dev/null +++ b/src/components/LFOPanel.tsx @@ -0,0 +1,61 @@ +import { useStore } from '@nanostores/react' +import { lfoSettings } from '../stores/settings' +import { toggleMappingMode } from '../stores/mappingMode' +import { LFOScope } from './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 ( +
+
+ {lfoConfigs.map(({ key, index }) => { + const lfo = lfoValues[key] + return ( +
+ handleLFOChange(key, index, freq, phase, waveform)} + onMapClick={() => handleMapClick(key, index)} + onUpdateDepth={(paramId, depth) => onUpdateDepth(index, paramId, depth)} + onRemoveMapping={(paramId) => onRemoveMapping(index, paramId)} + /> +
+ ) + })} +
+
+ ) +} diff --git a/src/components/LFOScope.tsx b/src/components/LFOScope.tsx new file mode 100644 index 00000000..ac2550ac --- /dev/null +++ b/src/components/LFOScope.tsx @@ -0,0 +1,238 @@ +import { useEffect, useRef, useState } 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 './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_WIDTH = 340 +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(null) + const lfoRef = useRef(null) + const animationRef = useRef(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) + + 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 ctx = canvas.getContext('2d') + if (!ctx) return + + let time = 0 + + const render = () => { + if (!lfoRef.current) return + + ctx.fillStyle = '#000000' + ctx.fillRect(0, 0, CANVAS_WIDTH, CANVAS_HEIGHT) + + ctx.strokeStyle = '#ffffff' + ctx.lineWidth = 2 + ctx.beginPath() + + const samples = CANVAS_WIDTH + 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]) + + const getLFOValueAtPhase = (phase: number): number => { + const normalizedPhase = phase % 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 + } + } + + const handleMouseDown = (e: React.MouseEvent) => { + 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) => { + 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) => { + 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 ( +
+ setShowMappings(true)} + onMouseOut={() => setShowMappings(false)} + /> + + {isActive && ( +
+
+ CLICK PARAM TO MAP | ESC TO EXIT +
+
+ )} + + {showMappings && hasMappings && ( +
+
+ {mappings.map((m, i) => { + const meta = parameterRegistry.getMetadata(m.targetParam) + return ( +
+ {meta?.label ?? m.targetParam} ({m.depth}%) +
+ ) + })} +
+
+ )} + + {showEditor && ( + setShowEditor(false)} + /> + )} +
+ ) +} diff --git a/src/components/MappingEditor.tsx b/src/components/MappingEditor.tsx new file mode 100644 index 00000000..971ef70f --- /dev/null +++ b/src/components/MappingEditor.tsx @@ -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 ( + <> +
+
+
e.stopPropagation()} + > +
+

+ LFO {lfoIndex + 1} MAPPINGS +

+ +
+ + {mappings.length === 0 ? ( +
+ NO MAPPINGS +
+ ) : ( +
+ {mappings.map((mapping) => { + const meta = parameterRegistry.getMetadata(mapping.targetParam) + return ( +
+
+ + {meta?.label ?? mapping.targetParam} + + +
+
+ onUpdateDepth(mapping.targetParam, Number(e.target.value))} + className="flex-1 h-[2px] appearance-none cursor-pointer slider bg-white" + /> + + {mapping.depth}% + +
+
+ ) + })} +
+ )} +
+
+ + ) +} diff --git a/src/components/Slider.tsx b/src/components/Slider.tsx index ac49990d..dc7408bb 100644 --- a/src/components/Slider.tsx +++ b/src/components/Slider.tsx @@ -1,3 +1,6 @@ +import { useStore } from '@nanostores/react' +import { mappingMode } from '../stores/mappingMode' + interface SliderProps { label: string value: number @@ -8,16 +11,47 @@ interface SliderProps { 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 }: SliderProps) { +export function Slider({ + label, + value, + min, + max, + step, + unit, + onChange, + formatValue, + valueId, + paramId, + onMapClick, + mappedLFOs = [] +}: SliderProps) { + const mappingModeState = useStore(mappingMode) const displayValue = formatValue && valueId ? formatValue(valueId, value) : `${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 ( -
+
-
) diff --git a/src/config/effects.ts b/src/config/effects.ts index 172fdcfe..e87da3ab 100644 --- a/src/config/effects.ts +++ b/src/config/effects.ts @@ -50,6 +50,15 @@ export const ENGINE_CONTROLS: EffectConfig[] = [ step: 1, unit: '%' }, + { + id: 'pitch', + label: 'Pitch', + min: 0.25, + max: 4, + default: 1, + step: 0.01, + unit: 'x' + }, { id: 'a', label: 'A', @@ -94,85 +103,38 @@ export const EFFECTS: EffectConfig[] = [ { 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.5, + max: 10, + default: 0.707, step: 0.1, unit: '' } diff --git a/src/constants/defaults.ts b/src/constants/defaults.ts index 4191dfdc..9e006b2c 100644 --- a/src/constants/defaults.ts +++ b/src/constants/defaults.ts @@ -11,7 +11,7 @@ export const PLAYBACK_ID = { export const TILE_GRID = { SIZE: 100, - COLUMNS: 2 + COLUMNS: 4 } as const export const DEFAULT_DOWNLOAD_OPTIONS = { diff --git a/src/domain/audio/AudioPlayer.ts b/src/domain/audio/AudioPlayer.ts index 72a32e55..30adc465 100644 --- a/src/domain/audio/AudioPlayer.ts +++ b/src/domain/audio/AudioPlayer.ts @@ -1,5 +1,6 @@ import { EffectsChain } from './effects/EffectsChain' import { BytebeatSourceEffect } from './effects/BytebeatSourceEffect' +import { ModulationEngine } from '../modulation/ModulationEngine' import type { EffectValues } from '../../types/effects' export interface AudioPlayerOptions { @@ -11,11 +12,13 @@ export class AudioPlayer { private audioContext: AudioContext | null = null private bytebeatSource: BytebeatSourceEffect | null = null private effectsChain: EffectsChain | null = null + private modulationEngine: ModulationEngine | null = null private effectValues: EffectValues = {} private startTime: number = 0 private sampleRate: number private duration: number private workletRegistered: boolean = false + private currentPitch: number = 1.0 constructor(options: AudioPlayerOptions) { this.sampleRate = options.sampleRate @@ -51,22 +54,20 @@ 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 { if (this.workletRegistered) return - try { - await Promise.all([ - context.audioWorklet.addModule('/worklets/fold-crush-processor.js'), - context.audioWorklet.addModule('/worklets/bytebeat-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/output-limiter.js') + ]) + this.workletRegistered = true } setEffects(values: EffectValues): void { @@ -74,6 +75,15 @@ export class AudioPlayer { if (this.effectsChain) { this.effectsChain.updateEffects(values) } + if (this.modulationEngine) { + const numericValues: Record = {} + Object.entries(values).forEach(([key, value]) => { + if (typeof value === 'number') { + numericValues[key] = value + } + }) + this.modulationEngine.setBaseValues(numericValues) + } } private async ensureAudioContext(): Promise { @@ -87,6 +97,47 @@ export class AudioPlayer { await this.effectsChain.initialize(this.audioContext) this.effectsChain.updateEffects(this.effectValues) } + + 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 + ) + }) + } + } + + this.modulationEngine.registerParameter( + 'pitch', + { callback: (value: number) => this.applyPitch(value) }, + this.currentPitch + ) + } + + setLFOConfig(lfoIndex: number, config: { frequency: number; phase: number; waveform: string; mappings: Array<{ targetParam: string; depth: number }> }): void { + if (!this.modulationEngine) return + + this.modulationEngine.updateLFO(lfoIndex, config.frequency, config.phase, config.waveform as any) + this.modulationEngine.clearMappings(lfoIndex) + + for (const mapping of config.mappings) { + this.modulationEngine.addMapping(lfoIndex, mapping.targetParam, mapping.depth) + } } async playRealtime(formula: string, a: number, b: number, c: number, d: number): Promise { @@ -100,11 +151,16 @@ export class AudioPlayer { 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()) this.effectsChain!.getOutputNode().connect(this.audioContext!.destination) + if (this.modulationEngine) { + this.modulationEngine.start() + } + this.startTime = this.audioContext!.currentTime } @@ -114,6 +170,20 @@ export class AudioPlayer { } } + private applyPitch(pitch: number): void { + if (this.bytebeatSource) { + this.bytebeatSource.setPlaybackRate(pitch) + } + } + + updatePitch(pitch: number): void { + this.currentPitch = pitch + this.applyPitch(pitch) + if (this.modulationEngine) { + this.modulationEngine.updateBaseValue('pitch', pitch) + } + } + getPlaybackPosition(): number { if (!this.audioContext || this.startTime === 0) { return 0 @@ -126,6 +196,9 @@ export class AudioPlayer { if (this.bytebeatSource) { this.bytebeatSource.getOutputNode().disconnect() } + if (this.modulationEngine) { + this.modulationEngine.stop() + } this.startTime = 0 } @@ -135,6 +208,10 @@ export class AudioPlayer { this.bytebeatSource.dispose() this.bytebeatSource = null } + if (this.modulationEngine) { + this.modulationEngine.dispose() + this.modulationEngine = null + } if (this.effectsChain) { this.effectsChain.dispose() this.effectsChain = null diff --git a/src/domain/audio/effects/BytebeatSourceEffect.ts b/src/domain/audio/effects/BytebeatSourceEffect.ts index a5705ba3..4e6d0f2c 100644 --- a/src/domain/audio/effects/BytebeatSourceEffect.ts +++ b/src/domain/audio/effects/BytebeatSourceEffect.ts @@ -13,12 +13,8 @@ export class BytebeatSourceEffect implements Effect { } async initialize(audioContext: AudioContext): Promise { - try { - this.processorNode = new AudioWorkletNode(audioContext, 'bytebeat-processor') - this.processorNode.connect(this.outputNode) - } catch (error) { - console.error('Failed to initialize BytebeatSourceEffect worklet:', error) - } + this.processorNode = new AudioWorkletNode(audioContext, 'bytebeat-processor') + this.processorNode.connect(this.outputNode) } getInputNode(): AudioNode { @@ -53,6 +49,11 @@ export class BytebeatSourceEffect implements Effect { 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' }) diff --git a/src/domain/audio/effects/Effect.interface.ts b/src/domain/audio/effects/Effect.interface.ts index b60b8ea3..ed816c79 100644 --- a/src/domain/audio/effects/Effect.interface.ts +++ b/src/domain/audio/effects/Effect.interface.ts @@ -4,6 +4,7 @@ export interface Effect { getOutputNode(): AudioNode updateParams(values: Record): void setBypass(bypass: boolean): void + getModulatableParams?(): Map dispose(): void } diff --git a/src/domain/audio/effects/EffectsChain.ts b/src/domain/audio/effects/EffectsChain.ts index 720b55a2..95155795 100644 --- a/src/domain/audio/effects/EffectsChain.ts +++ b/src/domain/audio/effects/EffectsChain.ts @@ -3,33 +3,43 @@ import { FilterEffect } from './FilterEffect' import { FoldCrushEffect } from './FoldCrushEffect' import { DelayEffect } from './DelayEffect' 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 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.outputLimiter = new OutputLimiter(audioContext) this.effects = [ - new FilterEffect(audioContext), + this.filterEffect, this.foldCrushEffect, new DelayEffect(audioContext), - new ReverbEffect(audioContext) + new ReverbEffect(audioContext), + this.outputLimiter ] this.setupChain() } async initialize(audioContext: AudioContext): Promise { - await this.foldCrushEffect.initialize(audioContext) + await Promise.all([ + this.filterEffect.initialize(audioContext), + this.foldCrushEffect.initialize(audioContext), + this.outputLimiter.initialize(audioContext) + ]) } private setupChain(): void { @@ -44,6 +54,10 @@ export class EffectsChain { this.masterGainNode.connect(this.outputNode) } + getEffects(): Effect[] { + return this.effects + } + updateEffects(values: Record): void { for (const effect of this.effects) { const effectId = effect.id diff --git a/src/domain/audio/effects/FilterEffect.ts b/src/domain/audio/effects/FilterEffect.ts index cb716967..1294a7d4 100644 --- a/src/domain/audio/effects/FilterEffect.ts +++ b/src/domain/audio/effects/FilterEffect.ts @@ -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 { + 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 } } + getModulatableParams(): Map { + if (!this.processorNode) return new Map() + + const params = new Map() + params.set('filterFreq', this.processorNode.parameters.get('frequency')!) + params.set('filterRes', this.processorNode.parameters.get('resonance')!) + return params + } + updateParams(values: Record): void { - if (values.hpEnable !== undefined) { - this.hpEnabled = values.hpEnable === 1 - this.updateBypassState() - } + if (!this.processorNode) return - if (values.hpFreq !== undefined && typeof values.hpFreq === 'number') { - 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.filterMode !== undefined) { + this.processorNode.port.postMessage({ type: 'mode', value: values.filterMode }) } - - if (values.hpRes !== undefined && typeof values.hpRes === 'number') { - 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.filterFreq !== undefined && typeof values.filterFreq === 'number') { + this.processorNode.parameters.get('frequency')!.value = values.filterFreq } - - if (values.lpEnable !== undefined) { - this.lpEnabled = values.lpEnable === 1 - this.updateBypassState() - } - - if (values.lpFreq !== undefined && typeof values.lpFreq === 'number') { - 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 && typeof values.lpRes === 'number') { - 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 && typeof values.bpFreq === 'number') { - 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 && typeof values.bpRes === 'number') { - 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() } } diff --git a/src/domain/audio/effects/FoldCrushEffect.ts b/src/domain/audio/effects/FoldCrushEffect.ts index d9fcc308..ae5abcbe 100644 --- a/src/domain/audio/effects/FoldCrushEffect.ts +++ b/src/domain/audio/effects/FoldCrushEffect.ts @@ -23,14 +23,10 @@ export class FoldCrushEffect implements Effect { } async initialize(audioContext: AudioContext): Promise { - 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 { diff --git a/src/domain/audio/effects/OutputLimiter.ts b/src/domain/audio/effects/OutputLimiter.ts new file mode 100644 index 00000000..36f18fad --- /dev/null +++ b/src/domain/audio/effects/OutputLimiter.ts @@ -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 { + 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(_bypass: boolean): void { + // Output limiter is always on + } + + updateParams(_values: Record): void { + // Uses default parameters from worklet + } + + dispose(): void { + if (this.processorNode) { + this.processorNode.disconnect() + } + this.inputNode.disconnect() + this.outputNode.disconnect() + } +} diff --git a/src/domain/modulation/LFO.ts b/src/domain/modulation/LFO.ts new file mode 100644 index 00000000..561f1fc6 --- /dev/null +++ b/src/domain/modulation/LFO.ts @@ -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 + } + } +} diff --git a/src/domain/modulation/ModulationEngine.ts b/src/domain/modulation/ModulationEngine.ts new file mode 100644 index 00000000..ebb0218b --- /dev/null +++ b/src/domain/modulation/ModulationEngine.ts @@ -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 + private baseValues: Map + 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): 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() + + 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() + } +} diff --git a/src/domain/modulation/ParameterRegistry.ts b/src/domain/modulation/ParameterRegistry.ts new file mode 100644 index 00000000..85ffddc8 --- /dev/null +++ b/src/domain/modulation/ParameterRegistry.ts @@ -0,0 +1,133 @@ +import { ENGINE_CONTROLS, EFFECTS } from '../../config/effects' +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 = 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() diff --git a/src/hooks/useKeyboardShortcuts.ts b/src/hooks/useKeyboardShortcuts.ts index ce9698b0..ad99a852 100644 --- a/src/hooks/useKeyboardShortcuts.ts +++ b/src/hooks/useKeyboardShortcuts.ts @@ -10,6 +10,9 @@ export interface KeyboardShortcutHandlers { onDoubleEnter?: () => void onR?: () => void onShiftR?: () => void + onC?: () => void + onShiftC?: () => void + onEscape?: () => void } const DOUBLE_ENTER_THRESHOLD = 300 @@ -77,6 +80,21 @@ export function useKeyboardShortcuts(handlers: KeyboardShortcutHandlers) { h.onR?.() } break + + case 'c': + case 'C': + e.preventDefault() + if (e.shiftKey) { + h.onShiftC?.() + } else { + h.onC?.() + } + break + + case 'Escape': + e.preventDefault() + h.onEscape?.() + break } } diff --git a/src/services/DownloadService.ts b/src/services/DownloadService.ts index 86f5ef3e..34c1daf4 100644 --- a/src/services/DownloadService.ts +++ b/src/services/DownloadService.ts @@ -25,7 +25,7 @@ export class DownloadService { formula: string, filename: string, options: DownloadOptions = {} - ): boolean { + ): void { const { sampleRate = DEFAULT_DOWNLOAD_OPTIONS.SAMPLE_RATE, duration = DEFAULT_DOWNLOAD_OPTIONS.DURATION, @@ -35,19 +35,12 @@ export class DownloadService { 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( @@ -67,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) }) }) diff --git a/src/services/PlaybackManager.ts b/src/services/PlaybackManager.ts index 51ba8850..03004d55 100644 --- a/src/services/PlaybackManager.ts +++ b/src/services/PlaybackManager.ts @@ -10,8 +10,7 @@ export interface PlaybackOptions { export class PlaybackManager { private player: AudioPlayer private currentFormula: string | null = null - private queuedCallback: (() => void) | null = null - private variables = { ...DEFAULT_VARIABLES } + private variables: { a: number; b: number; c: number; d: number } = { ...DEFAULT_VARIABLES } private playbackPositionCallback: ((position: number) => void) | null = null private animationFrameId: number | null = null @@ -32,8 +31,12 @@ export class PlaybackManager { this.player.updateRealtimeVariables(a, b, c, d) } + setLFOConfig(lfoIndex: number, config: { frequency: number; phase: number; waveform: string; mappings: Array<{ targetParam: string; depth: number }> }): void { + this.player.setLFOConfig(lfoIndex, config) + } + setPitch(pitch: number): void { - // Pitch is already handled via setEffects, but we could add specific handling here if needed + this.player.updatePitch(pitch) } setPlaybackPositionCallback(callback: (position: number) => void): void { @@ -62,29 +65,22 @@ export class PlaybackManager { } } - async play(formula: string): Promise { - try { - this.currentFormula = formula - await this.player.playRealtime( - formula, - this.variables.a, - this.variables.b, - this.variables.c, - this.variables.d - ) - this.startPlaybackTracking() - return true - } catch (error) { - console.error('Failed to start realtime playback:', error) - return false - } + async play(formula: string): Promise { + 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 } getPlaybackPosition(): number { diff --git a/src/stores/mappingMode.ts b/src/stores/mappingMode.ts new file mode 100644 index 00000000..d294ab42 --- /dev/null +++ b/src/stores/mappingMode.ts @@ -0,0 +1,34 @@ +import { atom } from 'nanostores' + +export interface MappingModeState { + isActive: boolean + activeLFO: number | null +} + +export const mappingMode = atom({ + 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) + } +} diff --git a/src/stores/settings.ts b/src/stores/settings.ts index cc66b8a5..b9910926 100644 --- a/src/stores/settings.ts +++ b/src/stores/settings.ts @@ -1,8 +1,47 @@ import { map } from 'nanostores' import { getDefaultEngineValues, getDefaultEffectValues } from '../config/effects' +import type { LFOWaveform } from '../domain/modulation/LFO' const STORAGE_KEY_ENGINE = 'engine:' const STORAGE_KEY_EFFECTS = 'effects:' +const STORAGE_KEY_LFO = 'lfo:' + +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(key: string, defaults: T): T { try { @@ -20,12 +59,15 @@ export const effectSettings = map(loadFromStorage(STORAGE_KEY_EFFECTS, { masterVolume: 75 })) +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())) - } catch (e) { - console.error('Failed to save settings:', e) + localStorage.setItem(STORAGE_KEY_LFO, JSON.stringify(lfoSettings.get())) + } catch { + // Silently fail on storage errors (quota exceeded, private browsing, etc.) } } diff --git a/src/types/tiles.ts b/src/types/tiles.ts index 3a952693..ce64730a 100644 --- a/src/types/tiles.ts +++ b/src/types/tiles.ts @@ -1,5 +1,8 @@ +import type { LFOSettings } from '../stores/settings' + export interface TileState { formula: string engineParams: Record effectParams: Record + lfoConfigs: LFOSettings } diff --git a/src/utils/bytebeatFormulas.ts b/src/utils/bytebeatFormulas.ts index 5e4f4193..f502f5e2 100644 --- a/src/utils/bytebeatFormulas.ts +++ b/src/utils/bytebeatFormulas.ts @@ -291,7 +291,13 @@ export function generateTileGrid(rows: number, cols: number, complexity: number const row: TileState[] = [] for (let j = 0; j < cols; j++) { const formula = generateRandomFormula(complexity) - row.push(createTileState(formula)) + 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) } diff --git a/src/utils/tileState.ts b/src/utils/tileState.ts index a6dc924e..b3c00adb 100644 --- a/src/utils/tileState.ts +++ b/src/utils/tileState.ts @@ -1,16 +1,19 @@ import type { TileState } from '../types/tiles' -import { engineSettings, effectSettings } from '../stores/settings' -import { getDefaultEngineValues, getDefaultEffectValues } from '../config/effects' +import { engineSettings, effectSettings, lfoSettings, getDefaultLFOValues } from '../stores/settings' +import { getDefaultEngineValues, getDefaultEffectValues, ENGINE_CONTROLS, EFFECTS } from '../config/effects' +import type { LFOSettings } from '../stores/settings' export function createTileState( formula: string, engineParams?: Record, - effectParams?: Record + effectParams?: Record, + lfoConfigs?: LFOSettings ): TileState { return { formula, engineParams: engineParams ?? { ...getDefaultEngineValues() }, - effectParams: effectParams ?? { ...getDefaultEffectValues(), masterVolume: 75 } + effectParams: effectParams ?? { ...getDefaultEffectValues(), masterVolume: 75 }, + lfoConfigs: lfoConfigs ?? getDefaultLFOValues() } } @@ -18,7 +21,8 @@ export function createTileStateFromCurrent(formula: string): TileState { return { formula, engineParams: { ...engineSettings.get() }, - effectParams: { ...effectSettings.get() } + effectParams: { ...effectSettings.get() }, + lfoConfigs: JSON.parse(JSON.stringify(lfoSettings.get())) } } @@ -30,13 +34,20 @@ export function loadTileParams(tile: TileState): void { 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 any, value) + }) + } } export function saveTileParams(tile: TileState): TileState { return { ...tile, engineParams: { ...engineSettings.get() }, - effectParams: { ...effectSettings.get() } + effectParams: { ...effectSettings.get() }, + lfoConfigs: JSON.parse(JSON.stringify(lfoSettings.get())) } } @@ -44,6 +55,126 @@ export function cloneTileState(tile: TileState): TileState { return { formula: tile.formula, engineParams: { ...tile.engineParams }, - effectParams: { ...tile.effectParams } + 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 = {} + const randomEffectParams: Record = {} + + 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 } } diff --git a/src/utils/waveformGenerator.ts b/src/utils/waveformGenerator.ts index c56fb09a..49f51a37 100644 --- a/src/utils/waveformGenerator.ts +++ b/src/utils/waveformGenerator.ts @@ -10,24 +10,19 @@ 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, a, b, c, d) - 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) } return waveform - } catch { - return new Array(width * 2).fill(0) + } catch (error) { + return Array(width * 2).fill(0) } }