Files
bruitiste/src/App.tsx
2025-10-06 14:31:05 +02:00

489 lines
18 KiB
TypeScript

import { useState, useRef } from 'react'
import { useStore } from '@nanostores/react'
import { Square, Archive, Dices, Sparkles, Blend } from 'lucide-react'
import { DownloadService } from './services/DownloadService'
import { generateRandomFormula } from './utils/bytebeatFormulas'
import { BytebeatTile } from './components/tile/BytebeatTile'
import { EffectsBar } from './components/controls/EffectsBar'
import { EngineControls } from './components/controls/EngineControls'
import { FormulaEditor } from './components/tile/FormulaEditor'
import { LFOPanel } from './components/controls/LFOPanel'
import { AudioContextWarning } from './components/modals/AudioContextWarning'
import { HelpModal } from './components/modals/HelpModal'
import { engineSettings, effectSettings } from './stores/settings'
import { exitMappingMode } from './stores/mappingMode'
import { useKeyboardShortcuts } from './hooks/useKeyboardShortcuts'
import { useTileGrid } from './hooks/useTileGrid'
import { usePlaybackControl } from './hooks/usePlaybackControl'
import { useFocusNavigation } from './hooks/useFocusNavigation'
import { useParameterSync } from './hooks/useParameterSync'
import { useLFOMapping } from './hooks/useLFOMapping'
import type { TileState } from './types/tiles'
import { createTileStateFromCurrent } from './utils/tileState'
import { DEFAULT_DOWNLOAD_OPTIONS, PLAYBACK_ID } from './constants/defaults'
import { getTileId, getTileFromGrid } from './utils/tileHelpers'
function App() {
const engineValues = useStore(engineSettings)
const effectValues = useStore(effectSettings)
const [downloading, setDownloading] = useState(false)
const [customTile, setCustomTile] = useState<TileState>(() => createTileStateFromCurrent('t*(8&t>>9)'))
const [showWarning, setShowWarning] = useState(true)
const [showHelp, setShowHelp] = useState(false)
const [mobileHeaderTab, setMobileHeaderTab] = useState<'global' | 'options' | 'modulate'>('global')
const downloadServiceRef = useRef<DownloadService>(new DownloadService())
const { tiles, setTiles, mode, regenerateAll, regenerateTile, switchMode } = useTileGrid()
const { playing, queued, playbackPosition, playbackManager, play, stop, queue, cancelQueue, updateMode } =
usePlaybackControl({ mode })
const { focusedTile, setFocus, moveFocus } = useFocusNavigation({
tiles,
onFocusChange: (tile) => {
if (tile !== 'custom') {
const tileData = getTileFromGrid(tiles, tile.row, tile.col)
if (tileData) {
saveCurrentParams()
loadParams(tileData)
}
} else {
saveCurrentParams()
loadParams(customTile)
}
}
})
const { saveCurrentParams, loadParams, handleEngineChange, handleEffectChange, randomizeParams, randomizeAllParams, interpolateParams } =
useParameterSync({
tiles,
setTiles,
customTile,
setCustomTile,
focusedTile,
playbackManager,
playing,
playbackId: PLAYBACK_ID.CUSTOM
})
const { handleLFOChange, handleParameterMapClick, handleUpdateMappingDepth, handleRemoveMapping, getMappedLFOs } =
useLFOMapping({
playbackManager,
saveCurrentParams
})
const handleRandom = () => {
cancelQueue()
regenerateAll()
}
const handleModeToggle = () => {
const newMode = mode === 'bytebeat' ? 'fm' : 'bytebeat'
stop()
switchMode(newMode)
updateMode(newMode)
}
const handleTileClick = (_formula: string, row: number, col: number, isDoubleClick: boolean = false) => {
const id = getTileId(row, col)
const tile = getTileFromGrid(tiles, row, col)
if (!tile) return
setFocus({ row, col })
if (isDoubleClick || playing === null) {
play(tile.formula, id, tile)
} else {
queue(id, () => {
const queuedTile = getTileFromGrid(tiles, row, col)
if (queuedTile) {
play(queuedTile.formula, id, queuedTile)
}
})
}
}
const handleTileDoubleClick = (formula: string, row: number, col: number) => {
handleTileClick(formula, row, col, true)
}
const handleDownloadAll = async () => {
setDownloading(true)
const formulas = tiles.map(row => row.map(tile => tile.formula))
await downloadServiceRef.current.downloadAll(formulas, {
duration: DEFAULT_DOWNLOAD_OPTIONS.DURATION,
bitDepth: DEFAULT_DOWNLOAD_OPTIONS.BIT_DEPTH
})
setDownloading(false)
}
const handleDownloadFormula = (formula: string, filename: string) => {
downloadServiceRef.current.downloadFormula(formula, filename, {
duration: DEFAULT_DOWNLOAD_OPTIONS.DURATION,
bitDepth: DEFAULT_DOWNLOAD_OPTIONS.BIT_DEPTH
})
}
const handleCustomEvaluate = (formula: string) => {
setFocus('custom')
setCustomTile({ ...customTile, formula })
play(formula, PLAYBACK_ID.CUSTOM, { ...customTile, formula })
}
const handleCustomStop = () => {
if (playing === PLAYBACK_ID.CUSTOM) {
stop()
}
}
const handleCustomRandom = () => {
return generateRandomFormula(engineValues.complexity)
}
const handleKeyboardSpace = () => {
if (playing) {
stop()
} else if (focusedTile !== 'custom') {
const tile = tiles[focusedTile.row]?.[focusedTile.col]
if (tile) {
handleTileClick(tile.formula, focusedTile.row, focusedTile.col, true)
}
}
}
const handleKeyboardEnter = () => {
if (focusedTile !== 'custom') {
const tile = tiles[focusedTile.row]?.[focusedTile.col]
if (tile) {
handleTileClick(tile.formula, focusedTile.row, focusedTile.col, false)
}
}
}
const handleKeyboardDoubleEnter = () => {
if (focusedTile !== 'custom') {
const tile = tiles[focusedTile.row]?.[focusedTile.col]
if (tile) {
handleTileClick(tile.formula, focusedTile.row, focusedTile.col, true)
}
}
}
const handleRegenerate = (row: number, col: number) => {
const newTile = regenerateTile(row, col)
const tileId = getTileId(row, col)
if (playing === tileId) {
play(newTile.formula, tileId, newTile)
}
}
const handleKeyboardR = () => {
if (focusedTile !== 'custom') {
handleRegenerate(focusedTile.row, focusedTile.col)
}
}
const handleKeyboardC = () => {
const tileId = focusedTile === 'custom'
? PLAYBACK_ID.CUSTOM
: getTileId(focusedTile.row, focusedTile.col)
randomizeParams(tileId)
}
const handleDismissWarning = () => {
setShowWarning(false)
}
useKeyboardShortcuts({
onSpace: handleKeyboardSpace,
onArrowUp: (shift) => moveFocus('up', shift ? 10 : 1),
onArrowDown: (shift) => moveFocus('down', shift ? 10 : 1),
onArrowLeft: (shift) => moveFocus('left', shift ? 10 : 1),
onArrowRight: (shift) => moveFocus('right', shift ? 10 : 1),
onEnter: handleKeyboardEnter,
onDoubleEnter: handleKeyboardDoubleEnter,
onR: handleKeyboardR,
onShiftR: handleRandom,
onC: handleKeyboardC,
onShiftC: randomizeAllParams,
onI: interpolateParams,
onEscape: exitMappingMode
})
return (
<div className="w-screen h-screen flex flex-col bg-black overflow-hidden">
{showWarning && <AudioContextWarning onDismiss={handleDismissWarning} />}
{showHelp && <HelpModal onClose={() => setShowHelp(false)} />}
<header className="bg-black border-b-2 border-white px-2 lg:px-6 py-2 lg:py-3">
{/* Mobile header */}
<div className="lg:hidden">
<div className="flex items-center justify-between mb-2">
<h1
onClick={() => setShowHelp(true)}
className="font-mono text-[10px] tracking-[0.3em] text-white cursor-pointer hover:opacity-70 transition-opacity"
>
BRUITISTE
</h1>
<div className="flex gap-1">
<div className="flex border-2 border-white">
<button
onClick={handleModeToggle}
className={`px-2 py-1 font-mono text-[7px] tracking-[0.15em] transition-colors ${
mode === 'bytebeat'
? 'bg-white text-black'
: 'bg-black text-white hover:bg-white/10'
}`}
>
1-BIT
</button>
<button
onClick={handleModeToggle}
className={`px-2 py-1 font-mono text-[7px] tracking-[0.15em] transition-colors ${
mode === 'fm'
? 'bg-white text-black'
: 'bg-black text-white hover:bg-white/10'
}`}
>
4OP-FM
</button>
</div>
<div className="flex border-2 border-white">
<button
onClick={() => setMobileHeaderTab('global')}
className={`px-2 py-1 font-mono text-[8px] tracking-[0.15em] transition-colors ${
mobileHeaderTab === 'global'
? 'bg-white text-black'
: 'bg-black text-white hover:bg-white/10'
}`}
>
GLOBAL
</button>
<button
onClick={() => setMobileHeaderTab('options')}
className={`px-2 py-1 font-mono text-[8px] tracking-[0.15em] transition-colors ${
mobileHeaderTab === 'options'
? 'bg-white text-black'
: 'bg-black text-white hover:bg-white/10'
}`}
>
OPTIONS
</button>
<button
onClick={() => setMobileHeaderTab('modulate')}
className={`px-2 py-1 font-mono text-[8px] tracking-[0.15em] transition-colors ${
mobileHeaderTab === 'modulate'
? 'bg-white text-black'
: 'bg-black text-white hover:bg-white/10'
}`}
>
MODULATE
</button>
</div>
</div>
</div>
{mobileHeaderTab === 'global' && (
<div className="flex gap-2">
<button
onClick={stop}
disabled={!playing}
className="flex-1 px-2 py-2 bg-black text-white border-2 border-white font-mono text-[9px] tracking-[0.2em] hover:bg-white hover:text-black transition-all disabled:opacity-30 disabled:cursor-not-allowed"
>
<Square size={12} strokeWidth={2} fill="currentColor" className="mx-auto" />
</button>
<button
onClick={handleRandom}
className="flex-1 px-2 py-2 bg-white text-black font-mono text-[9px] tracking-[0.2em] hover:bg-black hover:text-white border-2 border-white transition-all"
>
<Dices size={12} strokeWidth={2} className="mx-auto" />
</button>
<button
onClick={randomizeAllParams}
className="flex-1 px-2 py-2 bg-white text-black font-mono text-[9px] tracking-[0.2em] hover:bg-black hover:text-white border-2 border-white transition-all"
>
<Sparkles size={12} strokeWidth={2} className="mx-auto" />
</button>
<button
onClick={interpolateParams}
className="flex-1 px-2 py-2 bg-white text-black font-mono text-[9px] tracking-[0.2em] hover:bg-black hover:text-white border-2 border-white transition-all"
>
<Blend size={12} strokeWidth={2} className="mx-auto" />
</button>
<button
onClick={handleDownloadAll}
disabled={downloading}
className="flex-1 px-2 py-2 bg-black text-white border-2 border-white font-mono text-[9px] tracking-[0.2em] hover:bg-white hover:text-black transition-all disabled:opacity-30 disabled:cursor-not-allowed"
>
<Archive size={12} strokeWidth={2} className="mx-auto" />
</button>
</div>
)}
{mobileHeaderTab === 'options' && (
<EngineControls
values={engineValues}
onChange={handleEngineChange}
onMapClick={handleParameterMapClick}
getMappedLFOs={getMappedLFOs}
showOnlySliders
/>
)}
{mobileHeaderTab === 'modulate' && (
<EngineControls
values={engineValues}
onChange={handleEngineChange}
onMapClick={handleParameterMapClick}
getMappedLFOs={getMappedLFOs}
showOnlyKnobs
/>
)}
</div>
{/* Desktop header */}
<div className="hidden lg:flex items-center gap-4">
<div className="flex flex-col gap-1 flex-shrink-0">
<h1
onClick={() => setShowHelp(true)}
className="font-mono text-sm tracking-[0.3em] text-white cursor-pointer hover:opacity-70 transition-opacity"
>
BRUITISTE
</h1>
<div className="flex border-2 border-white w-fit">
<button
onClick={handleModeToggle}
className={`px-2 py-0.5 font-mono text-[7px] tracking-[0.15em] transition-colors whitespace-nowrap ${
mode === 'bytebeat'
? 'bg-white text-black'
: 'bg-black text-white hover:bg-white/10'
}`}
>
1-BIT
</button>
<button
onClick={handleModeToggle}
className={`px-2 py-0.5 font-mono text-[7px] tracking-[0.15em] transition-colors whitespace-nowrap ${
mode === 'fm'
? 'bg-white text-black'
: 'bg-black text-white hover:bg-white/10'
}`}
>
4OP-FM
</button>
</div>
</div>
<div className="flex-1">
<EngineControls
values={engineValues}
onChange={handleEngineChange}
onMapClick={handleParameterMapClick}
getMappedLFOs={getMappedLFOs}
/>
</div>
<div className="flex gap-3 flex-shrink-0">
<button
onClick={stop}
disabled={!playing}
className="px-4 py-2 bg-black text-white border-2 border-white font-mono text-[10px] tracking-[0.2em] hover:bg-white hover:text-black transition-all disabled:opacity-30 disabled:cursor-not-allowed flex items-center gap-1"
>
<Square size={12} strokeWidth={2} fill="currentColor" />
STOP
</button>
<button
onClick={handleRandom}
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"
>
<Dices size={12} strokeWidth={2} />
RANDOM
</button>
<button
onClick={randomizeAllParams}
className="px-4 py-2 bg-white text-black font-mono text-[10px] tracking-[0.2em] hover:bg-black hover:text-white border-2 border-white transition-all flex items-center gap-1"
>
<Sparkles size={12} strokeWidth={2} />
CHAOS
</button>
<button
onClick={interpolateParams}
className="px-4 py-2 bg-white text-black font-mono text-[10px] tracking-[0.2em] hover:bg-black hover:text-white border-2 border-white transition-all flex items-center gap-1"
>
<Blend size={12} strokeWidth={2} />
MORPH
</button>
<button
onClick={handleDownloadAll}
disabled={downloading}
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"
>
<Archive size={12} strokeWidth={2} />
{downloading ? 'DOWNLOADING...' : 'PACK'}
</button>
</div>
</div>
</header>
<LFOPanel onChange={handleLFOChange} onUpdateDepth={handleUpdateMappingDepth} onRemoveMapping={handleRemoveMapping} />
<div className="flex-1 flex flex-col overflow-auto bg-white">
{mode === 'bytebeat' && (
<div className="grid grid-cols-1 lg:grid-cols-4 gap-[1px] bg-white p-[1px]">
<div className="col-span-1 lg:col-span-4">
<FormulaEditor
formula={customTile.formula}
isPlaying={playing === PLAYBACK_ID.CUSTOM}
isFocused={focusedTile === 'custom'}
playbackPosition={playing === PLAYBACK_ID.CUSTOM ? playbackPosition : 0}
onEvaluate={handleCustomEvaluate}
onStop={handleCustomStop}
onRandom={handleCustomRandom}
/>
</div>
</div>
)}
<div className="flex-1 grid grid-cols-1 lg:grid-cols-4 auto-rows-min gap-[1px] bg-white p-[1px]">
{tiles.map((row, i) =>
row.map((tile, j) => {
const id = getTileId(i, j)
return (
<BytebeatTile
key={id}
formula={tile.formula}
row={i}
col={j}
isPlaying={playing === id}
isQueued={queued === id}
isFocused={focusedTile !== 'custom' && focusedTile.row === i && focusedTile.col === j}
playbackPosition={playing === id ? playbackPosition : 0}
a={tile.engineParams.a ?? 8}
b={tile.engineParams.b ?? 16}
c={tile.engineParams.c ?? 32}
d={tile.engineParams.d ?? 64}
onPlay={handleTileClick}
onDoubleClick={handleTileDoubleClick}
onDownload={handleDownloadFormula}
onRegenerate={handleRegenerate}
/>
)
})
)}
</div>
</div>
<div className="flex-shrink-0">
<EffectsBar
values={effectValues}
onChange={handleEffectChange}
onMapClick={handleParameterMapClick}
getMappedLFOs={getMappedLFOs}
/>
</div>
</div>
)
}
export default App