650 lines
22 KiB
TypeScript
650 lines
22 KiB
TypeScript
import { useState, useRef, useEffect } 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 { 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 } 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, 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'
|
|
|
|
function App() {
|
|
const engineValues = useStore(engineSettings)
|
|
const effectValues = useStore(effectSettings)
|
|
|
|
const [tiles, setTiles] = useState<TileState[][]>(() =>
|
|
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 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 })
|
|
|
|
useEffect(() => {
|
|
if (playbackManagerRef.current) {
|
|
playbackManagerRef.current.setPlaybackPositionCallback(setPlaybackPosition)
|
|
}
|
|
}, [])
|
|
|
|
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)
|
|
}
|
|
}, engineValues.loopCount * 1000)
|
|
}
|
|
|
|
useEffect(() => {
|
|
return () => clearSwitchTimer()
|
|
}, [])
|
|
|
|
const handleRandom = () => {
|
|
clearSwitchTimer()
|
|
setTiles(generateTileGrid(TILE_GRID.SIZE, TILE_GRID.COLUMNS, engineValues.complexity))
|
|
setQueued(null)
|
|
}
|
|
|
|
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 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 = LOOP_DURATION
|
|
|
|
if (!playbackManagerRef.current) {
|
|
playbackManagerRef.current = new PlaybackManager({ sampleRate, duration })
|
|
} 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)
|
|
|
|
await playbackManagerRef.current.play(formula)
|
|
setPlaying(id)
|
|
setQueued(null)
|
|
return true
|
|
}
|
|
|
|
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
|
|
|
|
if (focusedTile === 'custom' || (focusedTile.row !== row || focusedTile.col !== col)) {
|
|
saveCurrentTileParams()
|
|
}
|
|
|
|
if (tile) {
|
|
loadTileParams(tile)
|
|
}
|
|
setFocusedTile({ row, col })
|
|
|
|
if (playing === id) {
|
|
handleStop()
|
|
return
|
|
}
|
|
|
|
if (isDoubleClick || playing === null) {
|
|
clearSwitchTimer()
|
|
playFormula(tile.formula, id)
|
|
} else {
|
|
setQueued(id)
|
|
startSwitchTimer(id)
|
|
}
|
|
}
|
|
|
|
const handleTileDoubleClick = (formula: string, row: number, col: number) => {
|
|
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 (['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 any, value as any)
|
|
saveCurrentTileParams()
|
|
|
|
if (playbackManagerRef.current) {
|
|
playbackManagerRef.current.setEffects(effectValues)
|
|
}
|
|
}
|
|
|
|
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))
|
|
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 handleRegenerate = (row: number, col: number) => {
|
|
const newFormula = generateRandomFormula(engineValues.complexity)
|
|
const 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')
|
|
setCustomTile({ ...customTile, formula })
|
|
playFormula(formula, PLAYBACK_ID.CUSTOM)
|
|
}
|
|
|
|
const handleCustomStop = () => {
|
|
if (playing === PLAYBACK_ID.CUSTOM) {
|
|
handleStop()
|
|
}
|
|
}
|
|
|
|
const handleCustomRandom = () => {
|
|
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()
|
|
} 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 handleKeyboardR = () => {
|
|
if (focusedTile !== 'custom') {
|
|
handleRegenerate(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 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),
|
|
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: handleKeyboardShiftR,
|
|
onC: handleKeyboardC,
|
|
onShiftC: handleKeyboardShiftC,
|
|
onEscape: handleEscape
|
|
})
|
|
|
|
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} />}
|
|
{showHelp && <HelpModal onClose={() => setShowHelp(false)} />}
|
|
<header className="bg-black border-b-2 border-white px-6 py-3">
|
|
<div className="flex items-center justify-between gap-6">
|
|
<h1
|
|
onClick={() => setShowHelp(true)}
|
|
className="font-mono text-sm tracking-[0.3em] text-white flex-shrink-0 cursor-pointer hover:opacity-70 transition-opacity"
|
|
>
|
|
BRUITISTE
|
|
</h1>
|
|
<EngineControls
|
|
values={engineValues}
|
|
onChange={handleEngineChange}
|
|
onMapClick={handleParameterMapClick}
|
|
getMappedLFOs={getMappedLFOs}
|
|
/>
|
|
<div className="flex gap-4 flex-shrink-0">
|
|
<button
|
|
onClick={handleStop}
|
|
disabled={!playing}
|
|
className="px-6 py-2 bg-black text-white border-2 border-white font-mono text-[11px] tracking-[0.2em] hover:bg-white hover:text-black transition-all disabled:opacity-30 disabled:cursor-not-allowed flex items-center gap-2"
|
|
>
|
|
<Square size={14} strokeWidth={2} fill="currentColor" />
|
|
STOP
|
|
</button>
|
|
<button
|
|
onClick={handleRandom}
|
|
className="px-6 py-2 bg-white text-black font-mono text-[11px] tracking-[0.2em] hover:bg-black hover:text-white border-2 border-white transition-all flex items-center gap-2"
|
|
>
|
|
<Dices size={14} strokeWidth={2} />
|
|
RANDOM
|
|
</button>
|
|
<button
|
|
onClick={handleRandomizeAllParams}
|
|
className="px-6 py-2 bg-white text-black font-mono text-[11px] tracking-[0.2em] hover:bg-black hover:text-white border-2 border-white transition-all flex items-center gap-2"
|
|
>
|
|
<Sparkles size={14} strokeWidth={2} />
|
|
CHAOS
|
|
</button>
|
|
<button
|
|
onClick={handleDownloadAll}
|
|
disabled={downloading}
|
|
className="px-6 py-2 bg-black text-white border-2 border-white font-mono text-[11px] tracking-[0.2em] hover:bg-white hover:text-black transition-all disabled:opacity-30 disabled:cursor-not-allowed flex items-center gap-2"
|
|
>
|
|
<Archive size={14} 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">
|
|
<div className="grid grid-cols-4 gap-[1px] bg-white p-[1px]">
|
|
<div className="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-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>
|
|
|
|
<EffectsBar
|
|
values={effectValues}
|
|
onChange={handleEffectChange}
|
|
onMapClick={handleParameterMapClick}
|
|
getMappedLFOs={getMappedLFOs}
|
|
/>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
export default App
|