Enhance FM synthesis + cleaning code architecture

This commit is contained in:
2025-10-06 13:48:14 +02:00
parent 324cf9d2ed
commit ff5add97e8
38 changed files with 893 additions and 548 deletions

View File

@ -1,200 +1,88 @@
import { useState, useRef, useEffect } from 'react'
import { useState, useRef } from 'react'
import { useStore } from '@nanostores/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'
import { generateFMTileGrid, generateRandomFMPatch, createFMTileState } from './utils/fmPatches'
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, lfoSettings, type LFOConfig } from './stores/settings'
import { synthesisMode, setSynthesisMode } from './stores/synthesisMode'
import { generateRandomFormula } from './utils/bytebeatFormulas'
import { BytebeatTile } from './components/tile/BytebeatTile'
import { EffectsBar } from './components/controls/EffectsBar'
import { EngineControls } from './components/controls/EngineControls'
import { FormulaEditor } from './components/tile/FormulaEditor'
import { LFOPanel } from './components/controls/LFOPanel'
import { AudioContextWarning } from './components/modals/AudioContextWarning'
import { HelpModal } from './components/modals/HelpModal'
import { engineSettings, effectSettings } from './stores/settings'
import { exitMappingMode } from './stores/mappingMode'
import { useKeyboardShortcuts } from './hooks/useKeyboardShortcuts'
import { useTileParams } from './hooks/useTileParams'
import { useTileGrid } from './hooks/useTileGrid'
import { usePlaybackControl } from './hooks/usePlaybackControl'
import { useFocusNavigation } from './hooks/useFocusNavigation'
import { useParameterSync } from './hooks/useParameterSync'
import { useLFOMapping } from './hooks/useLFOMapping'
import type { TileState } from './types/tiles'
import { createTileStateFromCurrent, loadTileParams, randomizeTileParams } from './utils/tileState'
import { DEFAULT_VARIABLES, PLAYBACK_ID, TILE_GRID, DEFAULT_DOWNLOAD_OPTIONS, LOOP_DURATION } from './constants/defaults'
import { getTileId, getTileFromGrid, type FocusedTile } from './utils/tileHelpers'
import { createTileStateFromCurrent } from './utils/tileState'
import { DEFAULT_DOWNLOAD_OPTIONS, PLAYBACK_ID } from './constants/defaults'
import { getTileId, getTileFromGrid } from './utils/tileHelpers'
function App() {
const engineValues = useStore(engineSettings)
const effectValues = useStore(effectSettings)
const mode = useStore(synthesisMode)
const [tiles, setTiles] = useState<TileState[][]>(() =>
mode === 'fm'
? generateFMTileGrid(TILE_GRID.SIZE, TILE_GRID.COLUMNS, engineValues.complexity)
: generateTileGrid(TILE_GRID.SIZE, TILE_GRID.COLUMNS, engineValues.complexity)
)
const [playing, setPlaying] = useState<string | null>(null)
const [queued, setQueued] = useState<string | null>(null)
const [playbackPosition, setPlaybackPosition] = useState<number>(0)
const [downloading, setDownloading] = useState(false)
const [focusedTile, setFocusedTile] = useState<FocusedTile>({ row: 0, col: 0 })
const [customTile, setCustomTile] = useState<TileState>(() => createTileStateFromCurrent('t*(8&t>>9)'))
const [showWarning, setShowWarning] = useState(true)
const [showHelp, setShowHelp] = useState(false)
const [mobileHeaderTab, setMobileHeaderTab] = useState<'global' | 'options' | 'modulate'>('global')
const playbackManagerRef = useRef<PlaybackManager | null>(null)
const downloadServiceRef = useRef<DownloadService>(new DownloadService())
const switchTimerRef = useRef<number | null>(null)
const { saveCurrentTileParams } = useTileParams({ tiles, setTiles, customTile, setCustomTile, focusedTile })
const { tiles, setTiles, mode, regenerateAll, regenerateTile, switchMode } = useTileGrid()
useEffect(() => {
if (playbackManagerRef.current) {
playbackManagerRef.current.setPlaybackPositionCallback(setPlaybackPosition)
}
}, [])
const { playing, queued, playbackPosition, playbackManager, play, stop, queue, cancelQueue, updateMode } =
usePlaybackControl({ mode })
useEffect(() => {
effectSettings.setKey('masterVolume', engineValues.masterVolume)
}, [engineValues.masterVolume])
const clearSwitchTimer = () => {
if (switchTimerRef.current !== null) {
clearTimeout(switchTimerRef.current)
switchTimerRef.current = null
}
}
const startSwitchTimer = (queuedId: string) => {
clearSwitchTimer()
switchTimerRef.current = window.setTimeout(() => {
const [rowStr, colStr] = queuedId.split('-')
const row = parseInt(rowStr, 10)
const col = parseInt(colStr, 10)
const tile = getTileFromGrid(tiles, row, col)
if (tile) {
playFormula(tile.formula, queuedId)
const { focusedTile, setFocus, moveFocus } = useFocusNavigation({
tiles,
onFocusChange: (tile) => {
if (tile !== 'custom') {
const tileData = getTileFromGrid(tiles, tile.row, tile.col)
if (tileData) {
saveCurrentParams()
loadParams(tileData)
}
} else {
saveCurrentParams()
loadParams(customTile)
}
}, engineValues.loopCount * 1000)
}
}
})
useEffect(() => {
return () => clearSwitchTimer()
}, [])
const { saveCurrentParams, loadParams, handleEngineChange, handleEffectChange, randomizeParams, randomizeAllParams } =
useParameterSync({
tiles,
setTiles,
customTile,
setCustomTile,
focusedTile,
playbackManager,
playing,
playbackId: PLAYBACK_ID.CUSTOM
})
const { handleLFOChange, handleParameterMapClick, handleUpdateMappingDepth, handleRemoveMapping, getMappedLFOs } =
useLFOMapping({
playbackManager,
saveCurrentParams
})
const handleRandom = () => {
clearSwitchTimer()
if (mode === 'fm') {
setTiles(generateFMTileGrid(TILE_GRID.SIZE, TILE_GRID.COLUMNS, engineValues.complexity))
} else {
setTiles(generateTileGrid(TILE_GRID.SIZE, TILE_GRID.COLUMNS, engineValues.complexity))
}
setQueued(null)
cancelQueue()
regenerateAll()
}
const handleModeToggle = () => {
const newMode = mode === 'bytebeat' ? 'fm' : 'bytebeat'
handleStop()
setSynthesisMode(newMode)
if (playbackManagerRef.current) {
playbackManagerRef.current.setMode(newMode)
playbackManagerRef.current.setAlgorithm(engineValues.fmAlgorithm ?? 0)
playbackManagerRef.current.setFeedback(engineValues.fmFeedback ?? 0)
}
if (newMode === 'fm') {
setTiles(generateFMTileGrid(TILE_GRID.SIZE, TILE_GRID.COLUMNS, engineValues.complexity))
} else {
setTiles(generateTileGrid(TILE_GRID.SIZE, TILE_GRID.COLUMNS, engineValues.complexity))
}
}
const handleRandomizeAllParams = () => {
clearSwitchTimer()
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)
playbackManagerRef.current.setVariables(
params.engineParams.a ?? DEFAULT_VARIABLES.a,
params.engineParams.b ?? DEFAULT_VARIABLES.b,
params.engineParams.c ?? DEFAULT_VARIABLES.c,
params.engineParams.d ?? DEFAULT_VARIABLES.d
)
playbackManagerRef.current.setPitch(params.engineParams.pitch ?? 1.0)
if (params.lfoConfigs) {
playbackManagerRef.current.setLFOConfig(0, params.lfoConfigs.lfo1)
playbackManagerRef.current.setLFOConfig(1, params.lfoConfigs.lfo2)
playbackManagerRef.current.setLFOConfig(2, params.lfoConfigs.lfo3)
playbackManagerRef.current.setLFOConfig(3, params.lfoConfigs.lfo4)
}
}
setQueued(null)
}
const playFormula = async (formula: string, id: string) => {
const sampleRate = getSampleRateFromIndex(engineValues.sampleRate)
const duration = LOOP_DURATION
if (!playbackManagerRef.current) {
playbackManagerRef.current = new PlaybackManager({ sampleRate, duration })
playbackManagerRef.current.setMode(mode)
} else {
await playbackManagerRef.current.updateOptions({ sampleRate, duration })
}
playbackManagerRef.current.stop()
playbackManagerRef.current.setEffects(effectValues)
playbackManagerRef.current.setVariables(
engineValues.a ?? DEFAULT_VARIABLES.a,
engineValues.b ?? DEFAULT_VARIABLES.b,
engineValues.c ?? DEFAULT_VARIABLES.c,
engineValues.d ?? DEFAULT_VARIABLES.d
)
playbackManagerRef.current.setPitch(engineValues.pitch ?? 1.0)
const fmPatch = mode === 'fm' ? JSON.parse(formula) : null
const lfoRates = fmPatch?.lfoRates || undefined
playbackManagerRef.current.setAlgorithm(engineValues.fmAlgorithm ?? 0, lfoRates)
playbackManagerRef.current.setFeedback(engineValues.fmFeedback ?? 0)
await playbackManagerRef.current.play(formula)
setPlaying(id)
setQueued(null)
return true
stop()
switchMode(newMode)
updateMode(newMode)
}
const handleTileClick = (_formula: string, row: number, col: number, isDoubleClick: boolean = false) => {
@ -203,21 +91,17 @@ function App() {
if (!tile) return
if (focusedTile === 'custom' || (focusedTile.row !== row || focusedTile.col !== col)) {
saveCurrentTileParams()
}
if (tile) {
loadTileParams(tile)
}
setFocusedTile({ row, col })
setFocus({ row, col })
if (isDoubleClick || playing === null) {
clearSwitchTimer()
playFormula(tile.formula, id)
play(tile.formula, id, tile)
} else {
setQueued(id)
startSwitchTimer(id)
queue(id, () => {
const queuedTile = getTileFromGrid(tiles, row, col)
if (queuedTile) {
play(queuedTile.formula, id, queuedTile)
}
})
}
}
@ -225,127 +109,6 @@ function App() {
handleTileClick(formula, row, col, true)
}
const handleEngineChange = async (parameterId: string, value: number) => {
engineSettings.setKey(parameterId as keyof typeof engineValues, value)
saveCurrentTileParams()
if (parameterId === 'masterVolume' && playbackManagerRef.current) {
playbackManagerRef.current.setEffects({ ...effectValues, masterVolume: value })
}
if (parameterId === 'pitch' && playbackManagerRef.current && playing) {
playbackManagerRef.current.setPitch(value)
}
if (parameterId === 'fmAlgorithm' && playbackManagerRef.current) {
playbackManagerRef.current.setAlgorithm(value)
}
if (parameterId === 'fmFeedback' && playbackManagerRef.current) {
playbackManagerRef.current.setFeedback(value)
}
if (['a', 'b', 'c', 'd'].includes(parameterId) && playbackManagerRef.current && playing) {
const updatedValues = { ...engineValues, [parameterId]: value }
playbackManagerRef.current.setVariables(
updatedValues.a ?? DEFAULT_VARIABLES.a,
updatedValues.b ?? DEFAULT_VARIABLES.b,
updatedValues.c ?? DEFAULT_VARIABLES.c,
updatedValues.d ?? DEFAULT_VARIABLES.d
)
}
}
const handleEffectChange = (parameterId: string, value: number | boolean | string) => {
effectSettings.setKey(parameterId as keyof typeof effectValues, value as never)
saveCurrentTileParams()
if (playbackManagerRef.current) {
playbackManagerRef.current.setEffects(effectValues)
}
}
const handleLFOChange = (lfoIndex: number, config: LFOConfig) => {
if (playbackManagerRef.current) {
playbackManagerRef.current.setLFOConfig(lfoIndex, config)
}
}
const handleParameterMapClick = (paramId: string, lfoIndex: number) => {
const lfoKey = `lfo${lfoIndex + 1}` as 'lfo1' | 'lfo2' | 'lfo3' | 'lfo4'
const currentLFO = lfoSettings.get()[lfoKey]
const existingMappingIndex = currentLFO.mappings.findIndex(m => m.targetParam === paramId)
let updatedMappings
if (existingMappingIndex >= 0) {
updatedMappings = currentLFO.mappings.filter((_, i) => i !== existingMappingIndex)
} else {
updatedMappings = [...currentLFO.mappings, { targetParam: paramId, depth: 50 }]
}
const updatedLFO = { ...currentLFO, mappings: updatedMappings }
lfoSettings.setKey(lfoKey, updatedLFO)
if (playbackManagerRef.current) {
playbackManagerRef.current.setLFOConfig(lfoIndex, updatedLFO)
}
saveCurrentTileParams()
if (updatedMappings.length === 0 || existingMappingIndex >= 0) {
exitMappingMode()
}
}
const handleUpdateMappingDepth = (lfoIndex: number, paramId: string, depth: number) => {
const lfoKey = `lfo${lfoIndex + 1}` as 'lfo1' | 'lfo2' | 'lfo3' | 'lfo4'
const currentLFO = lfoSettings.get()[lfoKey]
const updatedMappings = currentLFO.mappings.map(m =>
m.targetParam === paramId ? { ...m, depth } : m
)
const updatedLFO = { ...currentLFO, mappings: updatedMappings }
lfoSettings.setKey(lfoKey, updatedLFO)
if (playbackManagerRef.current) {
playbackManagerRef.current.setLFOConfig(lfoIndex, updatedLFO)
}
saveCurrentTileParams()
}
const handleRemoveMapping = (lfoIndex: number, paramId: string) => {
const lfoKey = `lfo${lfoIndex + 1}` as 'lfo1' | 'lfo2' | 'lfo3' | 'lfo4'
const currentLFO = lfoSettings.get()[lfoKey]
const updatedMappings = currentLFO.mappings.filter(m => m.targetParam !== paramId)
const updatedLFO = { ...currentLFO, mappings: updatedMappings }
lfoSettings.setKey(lfoKey, updatedLFO)
if (playbackManagerRef.current) {
playbackManagerRef.current.setLFOConfig(lfoIndex, updatedLFO)
}
saveCurrentTileParams()
}
const getMappedLFOs = (paramId: string): number[] => {
const lfos = lfoSettings.get()
const mapped: number[] = []
Object.entries(lfos).forEach(([, lfo], index) => {
if (lfo.mappings.some((m: { targetParam: string }) => m.targetParam === paramId)) {
mapped.push(index)
}
})
return mapped
}
const handleDownloadAll = async () => {
setDownloading(true)
const formulas = tiles.map(row => row.map(tile => tile.formula))
@ -363,47 +126,15 @@ function App() {
})
}
const handleRegenerate = (row: number, col: number) => {
let newTile: TileState
if (mode === 'fm') {
const patch = generateRandomFMPatch(engineValues.complexity)
newTile = createFMTileState(patch)
} else {
const newFormula = generateRandomFormula(engineValues.complexity)
newTile = createTileStateFromCurrent(newFormula)
}
setTiles(prevTiles => {
const newTiles = [...prevTiles]
newTiles[row] = [...newTiles[row]]
newTiles[row][col] = newTile
return newTiles
})
}
const handleStop = () => {
clearSwitchTimer()
playbackManagerRef.current?.stop()
setPlaying(null)
setQueued(null)
setPlaybackPosition(0)
}
const handleCustomEvaluate = (formula: string) => {
if (focusedTile !== 'custom') {
saveCurrentTileParams()
loadTileParams(customTile)
}
setFocusedTile('custom')
setFocus('custom')
setCustomTile({ ...customTile, formula })
playFormula(formula, PLAYBACK_ID.CUSTOM)
play(formula, PLAYBACK_ID.CUSTOM, { ...customTile, formula })
}
const handleCustomStop = () => {
if (playing === PLAYBACK_ID.CUSTOM) {
handleStop()
stop()
}
}
@ -411,44 +142,9 @@ function App() {
return generateRandomFormula(engineValues.complexity)
}
const moveFocus = (direction: 'up' | 'down' | 'left' | 'right', step: number = 1) => {
saveCurrentTileParams()
setFocusedTile(prev => {
if (prev === 'custom') return prev
let { row, col } = prev
const maxRow = tiles.length - 1
const maxCol = (tiles[row]?.length || 1) - 1
switch (direction) {
case 'up':
row = Math.max(0, row - step)
break
case 'down':
row = Math.min(maxRow, row + step)
break
case 'left':
col = Math.max(0, col - step)
break
case 'right':
col = Math.min(maxCol, col + step)
break
}
const newTile = tiles[row]?.[col]
if (newTile) {
loadTileParams(newTile)
return { row, col }
}
return prev
})
}
const handleKeyboardSpace = () => {
if (playing) {
handleStop()
stop()
} else if (focusedTile !== 'custom') {
const tile = tiles[focusedTile.row]?.[focusedTile.col]
if (tile) {
@ -477,79 +173,15 @@ function App() {
const handleKeyboardR = () => {
if (focusedTile !== 'custom') {
handleRegenerate(focusedTile.row, focusedTile.col)
regenerateTile(focusedTile.row, focusedTile.col)
}
}
const handleKeyboardShiftR = () => {
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)
playbackManagerRef.current.setVariables(
randomized.engineParams.a ?? DEFAULT_VARIABLES.a,
randomized.engineParams.b ?? DEFAULT_VARIABLES.b,
randomized.engineParams.c ?? DEFAULT_VARIABLES.c,
randomized.engineParams.d ?? DEFAULT_VARIABLES.d
)
playbackManagerRef.current.setPitch(randomized.engineParams.pitch ?? 1.0)
if (randomized.lfoConfigs) {
playbackManagerRef.current.setLFOConfig(0, randomized.lfoConfigs.lfo1)
playbackManagerRef.current.setLFOConfig(1, randomized.lfoConfigs.lfo2)
playbackManagerRef.current.setLFOConfig(2, randomized.lfoConfigs.lfo3)
playbackManagerRef.current.setLFOConfig(3, randomized.lfoConfigs.lfo4)
}
}
return randomized
})
} else {
const tileId = getTileId(focusedTile.row, focusedTile.col)
setTiles(prevTiles => {
const newTiles = [...prevTiles]
newTiles[focusedTile.row] = [...newTiles[focusedTile.row]]
const randomized = randomizeTileParams(newTiles[focusedTile.row][focusedTile.col])
newTiles[focusedTile.row][focusedTile.col] = randomized
loadTileParams(randomized)
if (playing === tileId && playbackManagerRef.current) {
playbackManagerRef.current.setEffects(randomized.effectParams)
playbackManagerRef.current.setVariables(
randomized.engineParams.a ?? DEFAULT_VARIABLES.a,
randomized.engineParams.b ?? DEFAULT_VARIABLES.b,
randomized.engineParams.c ?? DEFAULT_VARIABLES.c,
randomized.engineParams.d ?? DEFAULT_VARIABLES.d
)
playbackManagerRef.current.setPitch(randomized.engineParams.pitch ?? 1.0)
if (randomized.lfoConfigs) {
playbackManagerRef.current.setLFOConfig(0, randomized.lfoConfigs.lfo1)
playbackManagerRef.current.setLFOConfig(1, randomized.lfoConfigs.lfo2)
playbackManagerRef.current.setLFOConfig(2, randomized.lfoConfigs.lfo3)
playbackManagerRef.current.setLFOConfig(3, randomized.lfoConfigs.lfo4)
}
}
return newTiles
})
}
}
const handleKeyboardShiftC = () => {
handleRandomizeAllParams()
const tileId = focusedTile === 'custom'
? PLAYBACK_ID.CUSTOM
: getTileId(focusedTile.row, focusedTile.col)
randomizeParams(tileId)
}
const handleDismissWarning = () => {
@ -565,21 +197,12 @@ function App() {
onEnter: handleKeyboardEnter,
onDoubleEnter: handleKeyboardDoubleEnter,
onR: handleKeyboardR,
onShiftR: handleKeyboardShiftR,
onShiftR: handleRandom,
onC: handleKeyboardC,
onShiftC: handleKeyboardShiftC,
onEscape: handleEscape
onShiftC: randomizeAllParams,
onEscape: exitMappingMode
})
useEffect(() => {
if (focusedTile !== 'custom') {
const element = document.querySelector(`[data-tile-id="${focusedTile.row}-${focusedTile.col}"]`)
if (element) {
element.scrollIntoView({ behavior: 'smooth', block: 'nearest' })
}
}
}, [focusedTile])
return (
<div className="w-screen h-screen flex flex-col bg-black overflow-hidden">
{showWarning && <AudioContextWarning onDismiss={handleDismissWarning} />}
@ -655,7 +278,7 @@ function App() {
{mobileHeaderTab === 'global' && (
<div className="flex gap-2">
<button
onClick={handleStop}
onClick={stop}
disabled={!playing}
className="flex-1 px-2 py-2 bg-black text-white border-2 border-white font-mono text-[9px] tracking-[0.2em] hover:bg-white hover:text-black transition-all disabled:opacity-30 disabled:cursor-not-allowed"
>
@ -668,7 +291,7 @@ function App() {
<Dices size={12} strokeWidth={2} className="mx-auto" />
</button>
<button
onClick={handleRandomizeAllParams}
onClick={randomizeAllParams}
className="flex-1 px-2 py-2 bg-white text-black font-mono text-[9px] tracking-[0.2em] hover:bg-black hover:text-white border-2 border-white transition-all"
>
<Sparkles size={12} strokeWidth={2} className="mx-auto" />
@ -746,7 +369,7 @@ function App() {
</div>
<div className="flex gap-3 flex-shrink-0">
<button
onClick={handleStop}
onClick={stop}
disabled={!playing}
className="px-4 py-2 bg-black text-white border-2 border-white font-mono text-[10px] tracking-[0.2em] hover:bg-white hover:text-black transition-all disabled:opacity-30 disabled:cursor-not-allowed flex items-center gap-1"
>
@ -761,7 +384,7 @@ function App() {
RANDOM
</button>
<button
onClick={handleRandomizeAllParams}
onClick={randomizeAllParams}
className="px-4 py-2 bg-white text-black font-mono text-[10px] tracking-[0.2em] hover:bg-black hover:text-white border-2 border-white transition-all flex items-center gap-1"
>
<Sparkles size={12} strokeWidth={2} />
@ -819,7 +442,7 @@ function App() {
onPlay={handleTileClick}
onDoubleClick={handleTileDoubleClick}
onDownload={handleDownloadFormula}
onRegenerate={handleRegenerate}
onRegenerate={regenerateTile}
/>
)
})