489 lines
18 KiB
TypeScript
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
|