Better code quality

This commit is contained in:
2025-10-04 14:52:20 +02:00
parent c6cc1a47c0
commit ba37b94908
25 changed files with 904 additions and 588 deletions

View File

@ -0,0 +1,85 @@
class BytebeatProcessor extends AudioWorkletProcessor {
constructor() {
super()
this.t = 0
this.a = 8
this.b = 16
this.c = 32
this.d = 64
this.formula = null
this.compiledFormula = null
this.sampleRate = 8000
this.duration = 4
this.loopLength = this.sampleRate * this.duration
this.error = false
this.port.onmessage = (event) => {
const { type, value } = event.data
switch (type) {
case 'formula':
this.setFormula(value)
break
case 'variables':
this.a = value.a ?? this.a
this.b = value.b ?? this.b
this.c = value.c ?? this.c
this.d = value.d ?? this.d
break
case 'reset':
this.t = 0
break
case 'loopLength':
this.loopLength = value
break
}
}
}
setFormula(formulaString) {
try {
this.compiledFormula = new Function('t', 'a', 'b', 'c', 'd', `return ${formulaString}`)
this.formula = formulaString
this.error = false
} catch (e) {
console.error('Failed to compile bytebeat formula:', e)
this.error = true
this.compiledFormula = null
}
}
process(inputs, outputs) {
const output = outputs[0]
if (output.length > 0) {
const outputChannel = output[0]
for (let i = 0; i < outputChannel.length; i++) {
if (!this.compiledFormula || this.error) {
outputChannel[i] = 0
} else {
try {
const value = this.compiledFormula(this.t, this.a, this.b, this.c, this.d)
const byteValue = value & 0xFF
outputChannel[i] = (byteValue - 128) / 128
} catch (e) {
outputChannel[i] = 0
if (!this.error) {
console.error('Bytebeat runtime error:', e)
this.error = true
}
}
}
this.t++
if (this.loopLength > 0 && this.t >= this.loopLength) {
this.t = 0
}
}
}
return true
}
}
registerProcessor('bytebeat-processor', BytebeatProcessor)

View File

@ -7,40 +7,46 @@ import { generateTileGrid, generateRandomFormula } from './utils/bytebeatFormula
import { BytebeatTile } from './components/BytebeatTile'
import { EffectsBar } from './components/EffectsBar'
import { EngineControls } from './components/EngineControls'
import { FormulaEditor } from './components/FormulaEditor'
import { getSampleRateFromIndex } from './config/effects'
import { engineSettings, effectSettings } from './stores/settings'
import { useKeyboardShortcuts } from './hooks/useKeyboardShortcuts'
import { useTileParams } from './hooks/useTileParams'
import type { TileState } from './types/tiles'
import { createTileStateFromCurrent, loadTileParams, saveTileParams } from './utils/tileState'
import { createTileStateFromCurrent, loadTileParams } from './utils/tileState'
import { DEFAULT_VARIABLES, PLAYBACK_ID, TILE_GRID, DEFAULT_DOWNLOAD_OPTIONS } 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(100, 2, 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 [regenerating, setRegenerating] = useState<string | null>(null)
const [playbackPosition, setPlaybackPosition] = useState<number>(0)
const [downloading, setDownloading] = useState(false)
const [focusedTile, setFocusedTile] = useState<{ row: number; col: number }>({ row: 0, col: 0 })
const [focusedTile, setFocusedTile] = useState<FocusedTile>({ row: 0, col: 0 })
const [customTile, setCustomTile] = useState<TileState>(() => createTileStateFromCurrent('t*(8&t>>9)'))
const playbackManagerRef = useRef<PlaybackManager | null>(null)
const downloadServiceRef = useRef<DownloadService>(new DownloadService())
const animationFrameRef = useRef<number | null>(null)
const tilesRef = useRef<TileState[][]>(tiles)
const { saveCurrentTileParams } = useTileParams({ tiles, setTiles, customTile, setCustomTile, focusedTile })
useEffect(() => {
tilesRef.current = tiles
}, [tiles])
if (playbackManagerRef.current) {
playbackManagerRef.current.setPlaybackPositionCallback(setPlaybackPosition)
}
}, [])
useEffect(() => {
effectSettings.setKey('masterVolume', engineValues.masterVolume)
}, [engineValues.masterVolume])
const handleRandom = () => {
setTiles(generateTileGrid(100, 2, engineValues.complexity))
setTiles(generateTileGrid(TILE_GRID.SIZE, TILE_GRID.COLUMNS, engineValues.complexity))
setQueued(null)
}
@ -56,58 +62,42 @@ function App() {
playbackManagerRef.current.stop()
playbackManagerRef.current.setEffects(effectValues)
playbackManagerRef.current.setPitch(engineValues.pitch ?? 1)
playbackManagerRef.current.setVariables(
engineValues.a ?? DEFAULT_VARIABLES.a,
engineValues.b ?? DEFAULT_VARIABLES.b,
engineValues.c ?? DEFAULT_VARIABLES.c,
engineValues.d ?? DEFAULT_VARIABLES.d
)
const success = await playbackManagerRef.current.play(formula, sampleRate, duration)
const success = await playbackManagerRef.current.play(formula)
if (success) {
setPlaying(id)
setQueued(null)
startPlaybackTracking()
return true
} else {
console.error('Failed to play formula')
return false
}
}
const startPlaybackTracking = () => {
if (animationFrameRef.current) {
cancelAnimationFrame(animationFrameRef.current)
}
const updatePosition = () => {
if (playbackManagerRef.current) {
const position = playbackManagerRef.current.getPlaybackPosition()
setPlaybackPosition(position)
animationFrameRef.current = requestAnimationFrame(updatePosition)
}
}
updatePosition()
}
const handleTileClick = (_formula: string, row: number, col: number, isDoubleClick: boolean = false) => {
const id = `${row}-${col}`
const tile = tiles[row]?.[col]
const id = getTileId(row, col)
const tile = getTileFromGrid(tiles, row, col)
if (!tile) return
if (focusedTile.row !== row || focusedTile.col !== col) {
const currentTile = tiles[focusedTile.row]?.[focusedTile.col]
if (currentTile) {
saveTileParams(currentTile)
}
loadTileParams(tile)
if (focusedTile === 'custom' || (focusedTile.row !== row || focusedTile.col !== col)) {
saveCurrentTileParams()
}
if (tile) {
loadTileParams(tile)
}
setFocusedTile({ row, col })
if (playing === id) {
playbackManagerRef.current?.stop()
setPlaying(null)
setQueued(null)
if (animationFrameRef.current) {
cancelAnimationFrame(animationFrameRef.current)
animationFrameRef.current = null
}
handleStop()
return
}
@ -115,15 +105,6 @@ function App() {
playFormula(tile.formula, id)
} else {
setQueued(id)
if (playbackManagerRef.current) {
playbackManagerRef.current.scheduleNextTrack(() => {
const queuedTile = tilesRef.current[row]?.[col]
if (queuedTile) {
loadTileParams(queuedTile)
playFormula(queuedTile.formula, id)
}
})
}
}
}
@ -132,13 +113,9 @@ function App() {
}
const handleEngineChange = (parameterId: string, value: number) => {
const handleEngineChange = async (parameterId: string, value: number) => {
engineSettings.setKey(parameterId as keyof typeof engineValues, value)
const currentTile = tiles[focusedTile.row]?.[focusedTile.col]
if (currentTile) {
saveTileParams(currentTile)
}
saveCurrentTileParams()
if (parameterId === 'masterVolume' && playbackManagerRef.current) {
playbackManagerRef.current.setEffects({ ...effectValues, masterVolume: value })
@ -147,15 +124,21 @@ function App() {
if (parameterId === 'pitch' && playbackManagerRef.current) {
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)
const currentTile = tiles[focusedTile.row]?.[focusedTile.col]
if (currentTile) {
saveTileParams(currentTile)
}
saveCurrentTileParams()
if (playbackManagerRef.current) {
playbackManagerRef.current.setEffects(effectValues)
@ -165,59 +148,66 @@ function App() {
const handleDownloadAll = async () => {
setDownloading(true)
const formulas = tiles.map(row => row.map(tile => tile.formula))
await downloadServiceRef.current.downloadAll(formulas, { duration: 10, bitDepth: 8 })
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: 10, bitDepth: 8 })
downloadServiceRef.current.downloadFormula(formula, filename, {
duration: DEFAULT_DOWNLOAD_OPTIONS.DURATION,
bitDepth: DEFAULT_DOWNLOAD_OPTIONS.BIT_DEPTH
})
}
const handleRegenerate = (row: number, col: number) => {
const id = `${row}-${col}`
const newFormula = generateRandomFormula(engineValues.complexity)
const newTile = createTileStateFromCurrent(newFormula)
if (playing === id && playbackManagerRef.current) {
setRegenerating(id)
playbackManagerRef.current.scheduleNextTrack(() => {
setTiles(prevTiles => {
const newTiles = [...prevTiles]
newTiles[row] = [...newTiles[row]]
newTiles[row][col] = newTile
return newTiles
})
loadTileParams(newTile)
playFormula(newTile.formula, id)
setRegenerating(null)
})
} else {
setTiles(prevTiles => {
const newTiles = [...prevTiles]
newTiles[row] = [...newTiles[row]]
newTiles[row][col] = newTile
return newTiles
})
}
setTiles(prevTiles => {
const newTiles = [...prevTiles]
newTiles[row] = [...newTiles[row]]
newTiles[row][col] = newTile
return newTiles
})
}
const handleStop = () => {
playbackManagerRef.current?.stop()
setPlaying(null)
setQueued(null)
if (animationFrameRef.current) {
cancelAnimationFrame(animationFrameRef.current)
animationFrameRef.current = 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) => {
const currentTile = tiles[focusedTile.row]?.[focusedTile.col]
if (currentTile) {
saveTileParams(currentTile)
}
saveCurrentTileParams()
setFocusedTile(prev => {
if (prev === 'custom') return prev
let { row, col } = prev
const maxRow = tiles.length - 1
const maxCol = (tiles[row]?.length || 1) - 1
@ -240,16 +230,17 @@ function App() {
const newTile = tiles[row]?.[col]
if (newTile) {
loadTileParams(newTile)
return { row, col }
}
return { row, col }
return prev
})
}
const handleKeyboardSpace = () => {
if (playing) {
handleStop()
} else {
} else if (focusedTile !== 'custom') {
const tile = tiles[focusedTile.row]?.[focusedTile.col]
if (tile) {
handleTileClick(tile.formula, focusedTile.row, focusedTile.col, true)
@ -258,21 +249,27 @@ function App() {
}
const handleKeyboardEnter = () => {
const tile = tiles[focusedTile.row]?.[focusedTile.col]
if (tile) {
handleTileClick(tile.formula, focusedTile.row, focusedTile.col, false)
if (focusedTile !== 'custom') {
const tile = tiles[focusedTile.row]?.[focusedTile.col]
if (tile) {
handleTileClick(tile.formula, focusedTile.row, focusedTile.col, false)
}
}
}
const handleKeyboardDoubleEnter = () => {
const tile = tiles[focusedTile.row]?.[focusedTile.col]
if (tile) {
handleTileClick(tile.formula, focusedTile.row, focusedTile.col, true)
if (focusedTile !== 'custom') {
const tile = tiles[focusedTile.row]?.[focusedTile.col]
if (tile) {
handleTileClick(tile.formula, focusedTile.row, focusedTile.col, true)
}
}
}
const handleKeyboardR = () => {
handleRegenerate(focusedTile.row, focusedTile.col)
if (focusedTile !== 'custom') {
handleRegenerate(focusedTile.row, focusedTile.col)
}
}
const handleKeyboardShiftR = () => {
@ -292,9 +289,11 @@ function App() {
})
useEffect(() => {
const element = document.querySelector(`[data-tile-id="${focusedTile.row}-${focusedTile.col}"]`)
if (element) {
element.scrollIntoView({ behavior: 'smooth', block: 'nearest' })
if (focusedTile !== 'custom') {
const element = document.querySelector(`[data-tile-id="${focusedTile.row}-${focusedTile.col}"]`)
if (element) {
element.scrollIntoView({ behavior: 'smooth', block: 'nearest' })
}
}
}, [focusedTile])
@ -326,35 +325,50 @@ function App() {
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...' : 'DOWNLOAD ALL'}
{downloading ? 'DOWNLOADING...' : 'PACK'}
</button>
</div>
</div>
</header>
<div className="flex-1 grid grid-cols-2 auto-rows-min gap-[1px] bg-white p-[1px] overflow-auto">
{tiles.map((row, i) =>
row.map((tile, j) => {
const id = `${i}-${j}`
return (
<BytebeatTile
key={id}
formula={tile.formula}
row={i}
col={j}
isPlaying={playing === id}
isQueued={queued === id}
isRegenerating={regenerating === id}
isFocused={focusedTile.row === i && focusedTile.col === j}
playbackPosition={playing === id ? playbackPosition : 0}
onPlay={handleTileClick}
onDoubleClick={handleTileDoubleClick}
onDownload={handleDownloadFormula}
onRegenerate={handleRegenerate}
/>
)
})
)}
<div className="flex-1 flex flex-col overflow-auto bg-white">
<div className="grid grid-cols-2 gap-[1px] bg-white p-[1px]">
<div className="col-span-2">
<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-2 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}
onPlay={handleTileClick}
onDoubleClick={handleTileDoubleClick}
onDownload={handleDownloadFormula}
onRegenerate={handleRegenerate}
/>
)
})
)}
</div>
</div>
<EffectsBar values={effectValues} onChange={handleEffectChange} />

View File

@ -8,7 +8,6 @@ interface BytebeatTileProps {
col: number
isPlaying: boolean
isQueued: boolean
isRegenerating: boolean
isFocused: boolean
playbackPosition: number
onPlay: (formula: string, row: number, col: number) => void
@ -17,7 +16,7 @@ interface BytebeatTileProps {
onRegenerate: (row: number, col: number) => void
}
export function BytebeatTile({ formula, row, col, isPlaying, isQueued, isRegenerating, isFocused, playbackPosition, onPlay, onDoubleClick, onDownload, onRegenerate }: BytebeatTileProps) {
export function BytebeatTile({ formula, row, col, isPlaying, isQueued, isFocused, playbackPosition, onPlay, onDoubleClick, onDownload, onRegenerate }: BytebeatTileProps) {
const canvasRef = useRef<HTMLCanvasElement>(null)
useEffect(() => {
@ -49,7 +48,7 @@ export function BytebeatTile({ formula, row, col, isPlaying, isQueued, isRegener
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 ${
isPlaying ? 'bg-white text-black' : isQueued ? 'bg-black text-white animate-pulse' : isRegenerating ? 'bg-black text-white border-2 border-white' : 'bg-black text-white'
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]' : ''}`}
>
<canvas
@ -74,7 +73,7 @@ export function BytebeatTile({ formula, row, col, isPlaying, isQueued, isRegener
: 'bg-white text-black border-white'
}`}
>
<Dices size={14} strokeWidth={2} className={isRegenerating ? 'animate-spin' : ''} />
<Dices size={14} strokeWidth={2} />
</div>
<div
onClick={handleDownload}

View File

@ -1,12 +1,15 @@
import { ENGINE_CONTROLS } from '../config/effects'
import { getComplexityLabel, getBitDepthLabel, getSampleRateLabel } from '../utils/formatters'
import type { EffectValues } from '../types/effects'
import { Knob } from './Knob'
interface EngineControlsProps {
values: EffectValues
onChange: (parameterId: string, value: number) => void
}
const KNOB_PARAMS = ['masterVolume', 'a', 'b', 'c', 'd']
export function EngineControls({ values, onChange }: EngineControlsProps) {
const formatValue = (id: string, value: number): string => {
switch (id) {
@ -24,27 +27,48 @@ export function EngineControls({ values, onChange }: EngineControlsProps) {
return (
<div className="flex items-center gap-6">
{ENGINE_CONTROLS[0].parameters.map(param => (
<div key={param.id} className="flex flex-col gap-1 min-w-[100px]">
<div className="flex justify-between items-baseline">
<label className="font-mono text-[9px] tracking-[0.15em] text-white">
{param.label.toUpperCase()}
</label>
<span className="font-mono text-[9px] text-white">
{formatValue(param.id, (values[param.id] as number) ?? param.default)}
</span>
{ENGINE_CONTROLS[0].parameters.map(param => {
const useKnob = KNOB_PARAMS.includes(param.id)
if (useKnob) {
return (
<Knob
key={param.id}
label={param.label}
value={(values[param.id] as number) ?? param.default}
min={param.min as number}
max={param.max as number}
step={param.step as number}
unit={param.unit}
onChange={(value) => onChange(param.id, value)}
formatValue={formatValue}
valueId={param.id}
/>
)
}
return (
<div key={param.id} className="flex flex-col gap-1 min-w-[100px]">
<div className="flex justify-between items-baseline">
<label className="font-mono text-[9px] tracking-[0.15em] text-white">
{param.label.toUpperCase()}
</label>
<span className="font-mono text-[9px] text-white">
{formatValue(param.id, (values[param.id] as number) ?? param.default)}
</span>
</div>
<input
type="range"
min={param.min}
max={param.max}
step={param.step}
value={(values[param.id] as number) ?? param.default}
onChange={(e) => onChange(param.id, Number(e.target.value))}
className="w-full h-[2px] bg-white appearance-none cursor-pointer"
/>
</div>
<input
type="range"
min={param.min}
max={param.max}
step={param.step}
value={(values[param.id] as number) ?? param.default}
onChange={(e) => onChange(param.id, Number(e.target.value))}
className="w-full h-[2px] bg-white appearance-none cursor-pointer"
/>
</div>
))}
)
})}
</div>
)
}

View File

@ -0,0 +1,100 @@
import { useState, useEffect } from 'react'
import { Play, Square, Dices } from 'lucide-react'
interface FormulaEditorProps {
formula: string
isPlaying: boolean
isFocused: boolean
playbackPosition: number
onEvaluate: (formula: string) => void
onStop: () => void
onRandom: () => string
}
export function FormulaEditor({ formula: externalFormula, isPlaying, isFocused, playbackPosition, onEvaluate, onStop, onRandom }: FormulaEditorProps) {
const [formula, setFormula] = useState(externalFormula)
useEffect(() => {
setFormula(externalFormula)
}, [externalFormula])
const handleEvaluate = () => {
if (formula.trim()) {
onEvaluate(formula)
}
}
const handleRandom = () => {
const newFormula = onRandom()
setFormula(newFormula)
onEvaluate(newFormula)
}
const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === 'Enter') {
if (e.ctrlKey) {
e.preventDefault()
handleEvaluate()
} else {
e.preventDefault()
}
}
}
return (
<div
className={`relative font-mono p-3 flex items-center gap-3 transition-all duration-150 ${
isPlaying ? 'bg-white text-black' : 'bg-black text-white'
} ${isFocused ? 'ring-2 ring-white ring-inset' : ''}`}
>
{isPlaying && (
<div
className="absolute left-0 top-0 bottom-0 bg-black opacity-10 transition-all duration-75 ease-linear"
style={{ width: `${playbackPosition * 100}%` }}
/>
)}
<input
type="text"
value={formula}
onChange={(e) => setFormula(e.target.value)}
onKeyDown={handleKeyDown}
placeholder="Enter bytebeat formula..."
className={`flex-1 bg-transparent border-none outline-none font-mono text-xs relative z-10 ${
isPlaying ? 'text-black placeholder:text-black/50' : 'text-white placeholder:text-white/50'
}`}
/>
<div className="flex gap-2 relative z-10">
<button
onClick={handleRandom}
className={`p-2 border transition-all duration-150 cursor-pointer hover:scale-105 ${
isPlaying
? 'bg-black text-white border-black'
: 'bg-white text-black border-white'
}`}
>
<Dices size={14} strokeWidth={2} />
</button>
{isPlaying && (
<button
onClick={onStop}
className="px-4 py-2 border transition-all duration-150 cursor-pointer hover:scale-105 flex items-center gap-2 font-mono text-[10px] tracking-[0.2em] flex-shrink-0 bg-black text-white border-black"
>
<Square size={12} strokeWidth={2} fill="currentColor" />
STOP
</button>
)}
<button
onClick={handleEvaluate}
className={`px-4 py-2 border transition-all duration-150 cursor-pointer hover:scale-105 flex items-center gap-2 font-mono text-[10px] tracking-[0.2em] flex-shrink-0 ${
isPlaying
? 'bg-black text-white border-black'
: 'bg-white text-black border-white'
}`}
>
<Play size={12} strokeWidth={2} fill="currentColor" />
EVAL
</button>
</div>
</div>
)
}

118
src/components/Knob.tsx Normal file
View File

@ -0,0 +1,118 @@
import { useRef, useState, useEffect } from 'react'
interface KnobProps {
label: string
value: number
min: number
max: number
step: number
unit?: string
onChange: (value: number) => void
formatValue?: (id: string, value: number) => string
valueId?: string
size?: number
}
export function Knob({
label,
value,
min,
max,
step,
unit,
onChange,
formatValue,
valueId,
size = 48
}: KnobProps) {
const [isDragging, setIsDragging] = useState(false)
const startYRef = useRef<number>(0)
const startValueRef = useRef<number>(0)
const displayValue = formatValue && valueId ? formatValue(valueId, value) : `${value}${unit || ''}`
const normalizedValue = (value - min) / (max - min)
const angle = -225 + normalizedValue * 270
const handleMouseDown = (e: React.MouseEvent) => {
setIsDragging(true)
startYRef.current = e.clientY
startValueRef.current = value
e.preventDefault()
}
const handleMouseMove = (e: MouseEvent) => {
if (!isDragging) return
const deltaY = startYRef.current - e.clientY
const range = max - min
const sensitivity = range / 200
const newValue = Math.max(min, Math.min(max, startValueRef.current + deltaY * sensitivity))
const steppedValue = Math.round(newValue / step) * step
onChange(steppedValue)
}
const handleMouseUp = () => {
setIsDragging(false)
}
useEffect(() => {
if (isDragging) {
window.addEventListener('mousemove', handleMouseMove)
window.addEventListener('mouseup', handleMouseUp)
return () => {
window.removeEventListener('mousemove', handleMouseMove)
window.removeEventListener('mouseup', handleMouseUp)
}
}
}, [isDragging])
return (
<div className="relative flex flex-col items-center">
<div
className="relative cursor-ns-resize select-none"
onMouseDown={handleMouseDown}
style={{ width: size, height: size }}
>
<svg
width={size}
height={size}
viewBox={`0 0 ${size} ${size}`}
>
<circle
cx={size / 2}
cy={size / 2}
r={(size - 4) / 2}
fill="none"
stroke="white"
strokeWidth="2"
/>
<circle
cx={size / 2}
cy={size / 2}
r={(size - 8) / 2}
fill="black"
/>
<line
x1={size / 2 + Math.cos((angle * Math.PI) / 180) * ((size - 16) / 2)}
y1={size / 2 + Math.sin((angle * Math.PI) / 180) * ((size - 16) / 2)}
x2={size / 2 + Math.cos((angle * Math.PI) / 180) * ((size - 4) / 2)}
y2={size / 2 + Math.sin((angle * Math.PI) / 180) * ((size - 4) / 2)}
stroke="white"
strokeWidth="2"
strokeLinecap="square"
/>
</svg>
<div className="absolute inset-0 flex items-center justify-center pointer-events-none">
<span className="font-mono text-[9px] tracking-[0.15em] text-white">
{isDragging ? displayValue : label.toUpperCase()}
</span>
</div>
</div>
</div>
)
}

View File

@ -43,7 +43,7 @@ export const ENGINE_CONTROLS: EffectConfig[] = [
},
{
id: 'masterVolume',
label: 'Volume',
label: 'Vol',
min: 0,
max: 100,
default: 75,
@ -51,13 +51,40 @@ export const ENGINE_CONTROLS: EffectConfig[] = [
unit: '%'
},
{
id: 'pitch',
label: 'Pitch',
min: 0.1,
max: 4,
default: 1,
step: 0.01,
unit: 'x'
id: 'a',
label: 'A',
min: 0,
max: 255,
default: 8,
step: 1,
unit: ''
},
{
id: 'b',
label: 'B',
min: 0,
max: 255,
default: 16,
step: 1,
unit: ''
},
{
id: 'c',
label: 'C',
min: 0,
max: 255,
default: 32,
step: 1,
unit: ''
},
{
id: 'd',
label: 'D',
min: 0,
max: 255,
default: 64,
step: 1,
unit: ''
}
]
}

21
src/constants/defaults.ts Normal file
View File

@ -0,0 +1,21 @@
export const DEFAULT_VARIABLES = {
a: 8,
b: 16,
c: 32,
d: 64
} as const
export const PLAYBACK_ID = {
CUSTOM: 'custom'
} as const
export const TILE_GRID = {
SIZE: 100,
COLUMNS: 2
} as const
export const DEFAULT_DOWNLOAD_OPTIONS = {
SAMPLE_RATE: 44100,
DURATION: 4,
BIT_DEPTH: 24
} as const

View File

@ -1,4 +1,5 @@
import { EffectsChain } from './effects/EffectsChain'
import { BytebeatSourceEffect } from './effects/BytebeatSourceEffect'
import type { EffectValues } from '../../types/effects'
export interface AudioPlayerOptions {
@ -8,15 +9,12 @@ export interface AudioPlayerOptions {
export class AudioPlayer {
private audioContext: AudioContext | null = null
private sourceNode: AudioBufferSourceNode | null = null
private bytebeatSource: BytebeatSourceEffect | null = null
private effectsChain: EffectsChain | null = null
private effectValues: EffectValues = {}
private startTime: number = 0
private pauseTime: number = 0
private isLooping: boolean = true
private sampleRate: number
private duration: number
private pitch: number = 1
private workletRegistered: boolean = false
constructor(options: AudioPlayerOptions) {
@ -40,7 +38,7 @@ export class AudioPlayer {
}
private async recreateAudioContext(): Promise<void> {
const wasPlaying = this.sourceNode !== null
const wasPlaying = this.bytebeatSource !== null
this.dispose()
@ -61,7 +59,10 @@ export class AudioPlayer {
if (this.workletRegistered) return
try {
await context.audioWorklet.addModule('/worklets/fold-crush-processor.js')
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)
@ -75,18 +76,7 @@ export class AudioPlayer {
}
}
setPitch(pitch: number): void {
this.pitch = pitch
if (this.sourceNode && this.audioContext) {
this.sourceNode.playbackRate.setTargetAtTime(
pitch,
this.audioContext.currentTime,
0.015
)
}
}
async play(buffer: Float32Array, onEnded?: () => void): Promise<void> {
private async ensureAudioContext(): Promise<void> {
if (!this.audioContext) {
this.audioContext = new AudioContext({ sampleRate: this.sampleRate })
await this.registerWorklet(this.audioContext)
@ -97,78 +87,54 @@ export class AudioPlayer {
await this.effectsChain.initialize(this.audioContext)
this.effectsChain.updateEffects(this.effectValues)
}
if (this.sourceNode) {
this.sourceNode.stop()
}
const audioBuffer = this.audioContext.createBuffer(1, buffer.length, this.sampleRate)
audioBuffer.getChannelData(0).set(buffer)
this.sourceNode = this.audioContext.createBufferSource()
this.sourceNode.buffer = audioBuffer
this.sourceNode.loop = this.isLooping
this.sourceNode.playbackRate.value = this.pitch
if (onEnded) {
this.sourceNode.onended = onEnded
}
this.sourceNode.connect(this.effectsChain.getInputNode())
this.effectsChain.getOutputNode().connect(this.audioContext.destination)
if (this.pauseTime > 0) {
this.sourceNode.start(0, this.pauseTime)
this.startTime = this.audioContext.currentTime - this.pauseTime
this.pauseTime = 0
} else {
this.sourceNode.start(0)
this.startTime = this.audioContext.currentTime
}
}
setLooping(loop: boolean): void {
this.isLooping = loop
if (this.sourceNode) {
this.sourceNode.loop = loop
async playRealtime(formula: string, a: number, b: number, c: number, d: number): Promise<void> {
await this.ensureAudioContext()
if (!this.bytebeatSource) {
this.bytebeatSource = new BytebeatSourceEffect(this.audioContext!)
await this.bytebeatSource.initialize(this.audioContext!)
}
this.bytebeatSource.setLoopLength(this.sampleRate, this.duration)
this.bytebeatSource.setFormula(formula)
this.bytebeatSource.setVariables(a, b, c, d)
this.bytebeatSource.reset()
this.bytebeatSource.getOutputNode().connect(this.effectsChain!.getInputNode())
this.effectsChain!.getOutputNode().connect(this.audioContext!.destination)
this.startTime = this.audioContext!.currentTime
}
scheduleNextTrack(callback: () => void): void {
if (this.sourceNode) {
this.sourceNode.loop = false
this.sourceNode.onended = callback
updateRealtimeVariables(a: number, b: number, c: number, d: number): void {
if (this.bytebeatSource) {
this.bytebeatSource.setVariables(a, b, c, d)
}
}
getPlaybackPosition(): number {
if (!this.audioContext || !this.sourceNode || this.startTime === 0) {
if (!this.audioContext || this.startTime === 0) {
return 0
}
const elapsed = this.audioContext.currentTime - this.startTime
const actualDuration = this.duration / this.pitch
return (elapsed % actualDuration) / actualDuration
}
pause(): void {
if (this.sourceNode && this.audioContext) {
this.pauseTime = this.audioContext.currentTime - this.startTime
this.sourceNode.stop()
this.sourceNode = null
}
return (elapsed % this.duration) / this.duration
}
stop(): void {
if (this.sourceNode) {
this.sourceNode.stop()
this.sourceNode = null
if (this.bytebeatSource) {
this.bytebeatSource.getOutputNode().disconnect()
}
this.startTime = 0
this.pauseTime = 0
}
dispose(): void {
this.stop()
if (this.bytebeatSource) {
this.bytebeatSource.dispose()
this.bytebeatSource = null
}
if (this.effectsChain) {
this.effectsChain.dispose()
this.effectsChain = null
@ -178,5 +144,6 @@ export class AudioPlayer {
this.audioContext = null
}
this.workletRegistered = false
this.startTime = 0
}
}

View File

@ -1,4 +1,4 @@
export type CompiledFormula = (t: number) => number
export type CompiledFormula = (t: number, a: number, b: number, c: number, d: number) => number
export interface CompilationResult {
success: boolean
@ -8,7 +8,7 @@ export interface CompilationResult {
export function compileFormula(formula: string): CompilationResult {
try {
const compiledFormula = new Function('t', `return ${formula}`) as CompiledFormula
const compiledFormula = new Function('t', 'a', 'b', 'c', 'd', `return ${formula}`) as CompiledFormula
return {
success: true,
compiledFormula
@ -28,7 +28,7 @@ export function testFormula(formula: string): boolean {
}
try {
result.compiledFormula(0)
result.compiledFormula(0, 8, 16, 32, 64)
return true
} catch {
return false

View File

@ -3,19 +3,23 @@ import type { CompiledFormula } from './BytebeatCompiler'
export interface GeneratorOptions {
sampleRate: number
duration: number
a?: number
b?: number
c?: number
d?: number
}
export function generateSamples(
compiledFormula: CompiledFormula,
options: GeneratorOptions
): Float32Array {
const { sampleRate, duration } = options
const { sampleRate, duration, a = 8, b = 16, c = 32, d = 64 } = options
const numSamples = Math.floor(sampleRate * duration)
const buffer = new Float32Array(numSamples)
for (let t = 0; t < numSamples; t++) {
try {
const value = compiledFormula(t)
const value = compiledFormula(t, a, b, c, d)
const byteValue = value & 0xFF
buffer[t] = (byteValue - 128) / 128
} catch (error) {
@ -31,7 +35,7 @@ export function generateSamplesWithBitDepth(
options: GeneratorOptions,
bitDepth: 8 | 16 | 24
): Float32Array {
const { sampleRate, duration } = options
const { sampleRate, duration, a = 8, b = 16, c = 32, d = 64 } = options
const numSamples = Math.floor(sampleRate * duration)
const buffer = new Float32Array(numSamples)
@ -40,7 +44,7 @@ export function generateSamplesWithBitDepth(
for (let t = 0; t < numSamples; t++) {
try {
const value = compiledFormula(t)
const value = compiledFormula(t, a, b, c, d)
const clampedValue = value & maxValue
buffer[t] = (clampedValue - midPoint) / midPoint
} catch (error) {

View File

@ -1,13 +1,70 @@
import { encodeWAV } from '../../lib/bytebeat/wavEncoder'
import type { BitDepth } from '../../lib/bytebeat/types'
export type { BitDepth }
export type BitDepth = 8 | 16 | 24
export interface ExportOptions {
sampleRate: number
bitDepth?: BitDepth
}
function encodeWAV(samples: Float32Array, sampleRate: number, bitDepth: BitDepth): Blob {
const numChannels = 1
const bytesPerSample = bitDepth / 8
const blockAlign = numChannels * bytesPerSample
const byteRate = sampleRate * blockAlign
const dataSize = samples.length * bytesPerSample
const bufferSize = 44 + dataSize
const buffer = new ArrayBuffer(bufferSize)
const view = new DataView(buffer)
const writeString = (offset: number, string: string) => {
for (let i = 0; i < string.length; i++) {
view.setUint8(offset + i, string.charCodeAt(i))
}
}
writeString(0, 'RIFF')
view.setUint32(4, 36 + dataSize, true)
writeString(8, 'WAVE')
writeString(12, 'fmt ')
view.setUint32(16, 16, true)
view.setUint16(20, 1, true)
view.setUint16(22, numChannels, true)
view.setUint32(24, sampleRate, true)
view.setUint32(28, byteRate, true)
view.setUint16(32, blockAlign, true)
view.setUint16(34, bitDepth, true)
writeString(36, 'data')
view.setUint32(40, dataSize, true)
const maxValue = Math.pow(2, bitDepth - 1) - 1
let offset = 44
for (let i = 0; i < samples.length; i++) {
const sample = Math.max(-1, Math.min(1, samples[i]))
const intSample = Math.round(sample * maxValue)
if (bitDepth === 8) {
view.setUint8(offset, intSample + 128)
offset += 1
} else if (bitDepth === 16) {
view.setInt16(offset, intSample, true)
offset += 2
} else if (bitDepth === 24) {
const bytes = [
intSample & 0xff,
(intSample >> 8) & 0xff,
(intSample >> 16) & 0xff
]
view.setUint8(offset, bytes[0])
view.setUint8(offset + 1, bytes[1])
view.setUint8(offset + 2, bytes[2])
offset += 3
}
}
return new Blob([buffer], { type: 'audio/wav' })
}
export function exportToWav(
samples: Float32Array,
options: ExportOptions

View File

@ -0,0 +1,68 @@
import type { Effect } from './Effect.interface'
export class BytebeatSourceEffect implements Effect {
readonly id = 'bytebeat-source'
private inputNode: GainNode
private outputNode: GainNode
private processorNode: AudioWorkletNode | null = null
constructor(audioContext: AudioContext) {
this.inputNode = audioContext.createGain()
this.outputNode = audioContext.createGain()
}
async initialize(audioContext: AudioContext): Promise<void> {
try {
this.processorNode = new AudioWorkletNode(audioContext, 'bytebeat-processor')
this.processorNode.connect(this.outputNode)
} catch (error) {
console.error('Failed to initialize BytebeatSourceEffect worklet:', error)
}
}
getInputNode(): AudioNode {
return this.inputNode
}
getOutputNode(): AudioNode {
return this.outputNode
}
setBypass(_bypass: boolean): void {
// Source node doesn't support bypass
}
updateParams(_values: Record<string, number | string>): void {
// Parameters handled via specific methods
}
setFormula(formula: string): void {
if (!this.processorNode) return
this.processorNode.port.postMessage({ type: 'formula', value: formula })
}
setVariables(a: number, b: number, c: number, d: number): void {
if (!this.processorNode) return
this.processorNode.port.postMessage({ type: 'variables', value: { a, b, c, d } })
}
setLoopLength(sampleRate: number, duration: number): void {
if (!this.processorNode) return
const loopLength = sampleRate * duration
this.processorNode.port.postMessage({ type: 'loopLength', value: loopLength })
}
reset(): void {
if (!this.processorNode) return
this.processorNode.port.postMessage({ type: 'reset' })
}
dispose(): void {
if (this.processorNode) {
this.processorNode.disconnect()
}
this.inputNode.disconnect()
this.outputNode.disconnect()
}
}

View File

@ -0,0 +1,33 @@
import { useCallback } from 'react'
import type { TileState } from '../types/tiles'
import { saveTileParams } from '../utils/tileState'
import { getTileFromGrid, type FocusedTile } from '../utils/tileHelpers'
interface UseTileParamsProps {
tiles: TileState[][]
setTiles: React.Dispatch<React.SetStateAction<TileState[][]>>
customTile: TileState
setCustomTile: React.Dispatch<React.SetStateAction<TileState>>
focusedTile: FocusedTile
}
export function useTileParams({ tiles, setTiles, customTile, setCustomTile, focusedTile }: UseTileParamsProps) {
const saveCurrentTileParams = useCallback(() => {
if (focusedTile === 'custom') {
setCustomTile(saveTileParams(customTile))
} else {
const currentTile = getTileFromGrid(tiles, focusedTile.row, focusedTile.col)
if (currentTile) {
const updatedTile = saveTileParams(currentTile)
setTiles(prevTiles => {
const newTiles = [...prevTiles]
newTiles[focusedTile.row] = [...newTiles[focusedTile.row]]
newTiles[focusedTile.row][focusedTile.col] = updatedTile
return newTiles
})
}
}
}, [focusedTile, tiles, setTiles, customTile, setCustomTile])
return { saveCurrentTileParams }
}

View File

@ -1,109 +0,0 @@
import type { BytebeatOptions, BitDepth } from './types'
import type { EffectValues } from '../../types/effects'
import { compileFormula } from '../../domain/audio/BytebeatCompiler'
import { generateSamples } from '../../domain/audio/SampleGenerator'
import { exportToWav } from '../../domain/audio/WavExporter'
import { AudioPlayer } from '../../domain/audio/AudioPlayer'
export class BytebeatGenerator {
private sampleRate: number
private duration: number
private audioBuffer: Float32Array | null = null
private audioPlayer: AudioPlayer
constructor(options: BytebeatOptions = {}) {
this.sampleRate = options.sampleRate ?? 8000
this.duration = options.duration ?? 10
this.audioPlayer = new AudioPlayer({ sampleRate: this.sampleRate, duration: this.duration })
}
updateOptions(options: Partial<BytebeatOptions>): void {
if (options.sampleRate !== undefined) {
this.sampleRate = options.sampleRate
this.audioBuffer = null
}
if (options.duration !== undefined) {
this.duration = options.duration
this.audioBuffer = null
}
this.audioPlayer.updateOptions({ sampleRate: this.sampleRate, duration: this.duration })
}
setFormula(formula: string): void {
const result = compileFormula(formula)
if (!result.success || !result.compiledFormula) {
throw new Error(`Invalid formula: ${result.error}`)
}
this.audioBuffer = generateSamples(result.compiledFormula, {
sampleRate: this.sampleRate,
duration: this.duration
})
}
generate(): Float32Array {
if (!this.audioBuffer) {
throw new Error('No formula set. Call setFormula() first.')
}
return this.audioBuffer
}
setEffects(values: EffectValues): void {
this.audioPlayer.setEffects(values)
}
getPlaybackPosition(): number {
return this.audioPlayer.getPlaybackPosition()
}
play(): void {
if (!this.audioBuffer) {
throw new Error('No audio buffer. Call setFormula() first.')
}
this.audioPlayer.play(this.audioBuffer)
}
onLoopEnd(callback: () => void): void {
if (!this.audioBuffer) return
this.audioPlayer.setLooping(false)
this.audioPlayer.play(this.audioBuffer, callback)
}
setLooping(loop: boolean): void {
this.audioPlayer.setLooping(loop)
}
scheduleNextTrack(callback: () => void): void {
this.audioPlayer.scheduleNextTrack(callback)
}
pause(): void {
this.audioPlayer.pause()
}
stop(): void {
this.audioPlayer.stop()
}
exportWAV(bitDepth: BitDepth = 8): Blob {
if (!this.audioBuffer) {
throw new Error('No audio buffer. Call setFormula() first.')
}
return exportToWav(this.audioBuffer, { sampleRate: this.sampleRate, bitDepth })
}
downloadWAV(filename: string = 'bytebeat.wav', bitDepth: BitDepth = 8): void {
const blob = this.exportWAV(bitDepth)
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = filename
a.click()
URL.revokeObjectURL(url)
}
dispose(): void {
this.audioPlayer.dispose()
}
}

View File

@ -1,124 +0,0 @@
import type { EffectValues } from '../../types/effects'
export class EffectsChain {
private audioContext: AudioContext
private inputNode: GainNode
private outputNode: GainNode
private masterGainNode: GainNode
private delayNode: DelayNode
private delayFeedbackNode: GainNode
private delayWetNode: GainNode
private delayDryNode: GainNode
private convolverNode: ConvolverNode
private reverbWetNode: GainNode
private reverbDryNode: GainNode
private tbdNode: GainNode
constructor(audioContext: AudioContext) {
this.audioContext = audioContext
this.inputNode = audioContext.createGain()
this.masterGainNode = audioContext.createGain()
this.outputNode = audioContext.createGain()
this.delayNode = audioContext.createDelay(2.0)
this.delayFeedbackNode = audioContext.createGain()
this.delayWetNode = audioContext.createGain()
this.delayDryNode = audioContext.createGain()
this.convolverNode = audioContext.createConvolver()
this.reverbWetNode = audioContext.createGain()
this.reverbDryNode = audioContext.createGain()
this.tbdNode = audioContext.createGain()
this.setupChain()
this.generateImpulseResponse()
}
private setupChain(): void {
this.delayDryNode.gain.value = 1
this.delayWetNode.gain.value = 0
this.inputNode.connect(this.delayDryNode)
this.inputNode.connect(this.delayNode)
this.delayNode.connect(this.delayFeedbackNode)
this.delayFeedbackNode.connect(this.delayNode)
this.delayNode.connect(this.delayWetNode)
this.delayDryNode.connect(this.reverbDryNode)
this.delayWetNode.connect(this.reverbDryNode)
this.delayDryNode.connect(this.convolverNode)
this.delayWetNode.connect(this.convolverNode)
this.convolverNode.connect(this.reverbWetNode)
this.reverbDryNode.connect(this.tbdNode)
this.reverbWetNode.connect(this.tbdNode)
this.tbdNode.connect(this.masterGainNode)
this.masterGainNode.connect(this.outputNode)
}
private generateImpulseResponse(): void {
const length = this.audioContext.sampleRate * 2
const impulse = this.audioContext.createBuffer(2, length, this.audioContext.sampleRate)
const left = impulse.getChannelData(0)
const right = impulse.getChannelData(1)
for (let i = 0; i < length; i++) {
left[i] = (Math.random() * 2 - 1) * Math.pow(1 - i / length, 2)
right[i] = (Math.random() * 2 - 1) * Math.pow(1 - i / length, 2)
}
this.convolverNode.buffer = impulse
}
updateEffects(values: EffectValues): void {
if (typeof values.reverbWetDry === 'number') {
const reverbWet = values.reverbWetDry / 100
this.reverbWetNode.gain.value = reverbWet
this.reverbDryNode.gain.value = 1 - reverbWet
}
if (typeof values.delayTime === 'number') {
this.delayNode.delayTime.value = values.delayTime / 1000
const delayAmount = Math.min(values.delayTime / 1000, 0.5)
this.delayWetNode.gain.value = delayAmount
this.delayDryNode.gain.value = 1 - delayAmount
}
if (typeof values.delayFeedback === 'number') {
this.delayFeedbackNode.gain.value = values.delayFeedback / 100
}
if (typeof values.masterVolume === 'number') {
this.masterGainNode.gain.value = values.masterVolume / 100
}
}
getInputNode(): AudioNode {
return this.inputNode
}
getOutputNode(): AudioNode {
return this.outputNode
}
dispose(): void {
this.inputNode.disconnect()
this.outputNode.disconnect()
this.masterGainNode.disconnect()
this.delayNode.disconnect()
this.delayFeedbackNode.disconnect()
this.delayWetNode.disconnect()
this.delayDryNode.disconnect()
this.convolverNode.disconnect()
this.reverbWetNode.disconnect()
this.reverbDryNode.disconnect()
this.tbdNode.disconnect()
}
}

View File

@ -1,15 +0,0 @@
export { BytebeatGenerator } from './BytebeatGenerator'
export type { BytebeatOptions, BytebeatState, BitDepth } from './types'
export const EXAMPLE_FORMULAS = {
classic: 't * ((t>>12)|(t>>8))&(63&(t>>4))',
melody: 't>>6^t&0x25|t+(t^t>>11)',
simple: 't & (t>>4)|(t>>8)',
harmony: '(t>>10&42)*t',
glitch: 't*(t>>8*((t>>15)|(t>>8))&(20|(t>>19)*5>>t|(t>>3)))',
drums: '((t>>10)&42)*(t>>8)',
ambient: '(t*5&t>>7)|(t*3&t>>10)',
noise: 't>>6&1?t>>5:-t>>4',
arpeggio: 't*(((t>>9)|(t>>13))&25&t>>6)',
chaos: 't*(t^t+(t>>15|1))',
} as const

View File

@ -1,12 +0,0 @@
export interface BytebeatOptions {
sampleRate?: number
duration?: number
}
export interface BytebeatState {
isPlaying: boolean
isPaused: boolean
currentTime: number
}
export type BitDepth = 8 | 16

View File

@ -1,49 +0,0 @@
import type { BitDepth } from './types'
function writeString(view: DataView, offset: number, str: string): void {
for (let i = 0; i < str.length; i++) {
view.setUint8(offset + i, str.charCodeAt(i))
}
}
export function encodeWAV(samples: Float32Array, sampleRate: number, bitDepth: BitDepth): Blob {
const numChannels = 1
const bytesPerSample = bitDepth / 8
const blockAlign = numChannels * bytesPerSample
const dataSize = samples.length * bytesPerSample
const buffer = new ArrayBuffer(44 + dataSize)
const view = new DataView(buffer)
writeString(view, 0, 'RIFF')
view.setUint32(4, 36 + dataSize, true)
writeString(view, 8, 'WAVE')
writeString(view, 12, 'fmt ')
view.setUint32(16, 16, true)
view.setUint16(20, 1, true)
view.setUint16(22, numChannels, true)
view.setUint32(24, sampleRate, true)
view.setUint32(28, sampleRate * blockAlign, true)
view.setUint16(32, blockAlign, true)
view.setUint16(34, bitDepth, true)
writeString(view, 36, 'data')
view.setUint32(40, dataSize, true)
let offset = 44
for (let i = 0; i < samples.length; i++) {
const sample = Math.max(-1, Math.min(1, samples[i]))
if (bitDepth === 8) {
const value = Math.floor((sample + 1) * 127.5)
view.setUint8(offset, value)
offset += 1
} else {
const value = Math.floor(sample * 32767)
view.setInt16(offset, value, true)
offset += 2
}
}
return new Blob([buffer], { type: 'audio/wav' })
}

View File

@ -3,6 +3,7 @@ import { compileFormula } from '../domain/audio/BytebeatCompiler'
import { generateSamples } from '../domain/audio/SampleGenerator'
import { exportToWav } from '../domain/audio/WavExporter'
import type { BitDepth } from '../domain/audio/WavExporter'
import { DEFAULT_DOWNLOAD_OPTIONS } from '../constants/defaults'
export interface DownloadOptions {
sampleRate?: number
@ -26,9 +27,9 @@ export class DownloadService {
options: DownloadOptions = {}
): boolean {
const {
sampleRate = 8000,
duration = 10,
bitDepth = 8
sampleRate = DEFAULT_DOWNLOAD_OPTIONS.SAMPLE_RATE,
duration = DEFAULT_DOWNLOAD_OPTIONS.DURATION,
bitDepth = DEFAULT_DOWNLOAD_OPTIONS.BIT_DEPTH
} = options
const result = compileFormula(formula)
@ -54,9 +55,9 @@ export class DownloadService {
options: DownloadOptions = {}
): Promise<void> {
const {
sampleRate = 8000,
duration = 10,
bitDepth = 8
sampleRate = DEFAULT_DOWNLOAD_OPTIONS.SAMPLE_RATE,
duration = DEFAULT_DOWNLOAD_OPTIONS.DURATION,
bitDepth = DEFAULT_DOWNLOAD_OPTIONS.BIT_DEPTH
} = options
const zip = new JSZip()

View File

@ -1,7 +1,6 @@
import { compileFormula } from '../domain/audio/BytebeatCompiler'
import { generateSamples } from '../domain/audio/SampleGenerator'
import { AudioPlayer } from '../domain/audio/AudioPlayer'
import type { EffectValues } from '../types/effects'
import { DEFAULT_VARIABLES } from '../constants/defaults'
export interface PlaybackOptions {
sampleRate: number
@ -11,8 +10,10 @@ export interface PlaybackOptions {
export class PlaybackManager {
private player: AudioPlayer
private currentFormula: string | null = null
private currentBuffer: Float32Array | null = null
private queuedCallback: (() => void) | null = null
private variables = { ...DEFAULT_VARIABLES }
private playbackPositionCallback: ((position: number) => void) | null = null
private animationFrameId: number | null = null
constructor(options: PlaybackOptions) {
this.player = new AudioPlayer(options)
@ -20,53 +21,72 @@ export class PlaybackManager {
async updateOptions(options: Partial<PlaybackOptions>): Promise<void> {
await this.player.updateOptions(options)
this.currentBuffer = null
}
setEffects(values: EffectValues): void {
this.player.setEffects(values)
}
setPitch(pitch: number): void {
this.player.setPitch(pitch)
setVariables(a: number, b: number, c: number, d: number): void {
this.variables = { a, b, c, d }
this.player.updateRealtimeVariables(a, b, c, d)
}
async play(formula: string, sampleRate: number, duration: number): Promise<boolean> {
const result = compileFormula(formula)
setPitch(pitch: number): void {
// Pitch is already handled via setEffects, but we could add specific handling here if needed
}
if (!result.success || !result.compiledFormula) {
console.error('Failed to compile formula:', result.error)
return false
setPlaybackPositionCallback(callback: (position: number) => void): void {
this.playbackPositionCallback = callback
}
private startPlaybackTracking(): void {
if (this.animationFrameId) {
cancelAnimationFrame(this.animationFrameId)
}
const updatePosition = () => {
const position = this.player.getPlaybackPosition()
if (this.playbackPositionCallback) {
this.playbackPositionCallback(position)
}
this.animationFrameId = requestAnimationFrame(updatePosition)
}
updatePosition()
}
private stopPlaybackTracking(): void {
if (this.animationFrameId) {
cancelAnimationFrame(this.animationFrameId)
this.animationFrameId = null
}
}
async play(formula: string): Promise<boolean> {
try {
this.currentBuffer = generateSamples(result.compiledFormula, { sampleRate, duration })
this.currentFormula = formula
this.player.setLooping(true)
await this.player.play(this.currentBuffer)
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 generate samples:', error)
console.error('Failed to start realtime playback:', error)
return false
}
}
stop(): void {
this.stopPlaybackTracking()
this.player.stop()
this.currentFormula = null
this.queuedCallback = null
}
scheduleNextTrack(callback: () => void): void {
this.queuedCallback = callback
this.player.scheduleNextTrack(() => {
if (this.queuedCallback) {
this.queuedCallback()
this.queuedCallback = null
}
})
}
getPlaybackPosition(): number {
return this.player.getPlaybackPosition()
}

View File

@ -72,7 +72,71 @@ const TEMPLATES: Template[] = [
{ pattern: "((t*t)/(t^t>>S))&N", weight: 5 },
{ pattern: "(t*(t>>S1))^(t*(t>>S2))", weight: 6 },
{ pattern: "((t>>S1)*(t>>S2))&((t>>S3)|(t>>S4))", weight: 6 },
{ pattern: "(t&(t>>S1))^((t>>S2)&(t>>S3))", weight: 5 }
{ pattern: "(t&(t>>S1))^((t>>S2)&(t>>S3))", weight: 5 },
{ pattern: "t*(a&t>>b)", weight: 6 },
{ pattern: "(t>>a)|(t>>b)", weight: 6 },
{ pattern: "t&(t>>a)&(t>>c)", weight: 5 },
{ pattern: "(t*a)&(t>>b)", weight: 5 },
{ pattern: "t%(d)+(t>>a)", weight: 5 },
{ pattern: "(t>>a)^(t>>c)", weight: 5 },
{ pattern: "t*((t>>a)|(t>>b))&c", weight: 5 },
{ pattern: "((t>>a)&b)*(t>>c)", weight: 5 },
{ pattern: "(t&(t>>a))^(t>>d)", weight: 4 },
{ pattern: "t/(b+(t>>a|t>>c))", weight: 4 },
{ pattern: "t&t>>a", weight: 7 },
{ pattern: "t&t>>b", weight: 7 },
{ pattern: "(t*a&t>>b)|(t*c&t>>d)", weight: 9 },
{ pattern: "(t>>a)&(t>>b)", weight: 6 },
{ pattern: "t*(a&t>>b)|(t>>c)", weight: 7 },
{ pattern: "(t*a&t>>S1)|(t*b&t>>S2)", weight: 8 },
{ pattern: "t&(t>>a)|(t>>b)", weight: 6 },
{ pattern: "(t*c&t>>a)&(t>>b)", weight: 6 },
{ pattern: "t*(t>>a&t>>b)", weight: 6 },
{ pattern: "((t>>a)&N)|(t*b&t>>c)", weight: 7 },
{ pattern: "t&N?(t*a&t>>b):(t>>c)", weight: 7 },
{ pattern: "(t>>a)&N?(t*b):(t*c)", weight: 7 },
{ pattern: "t&M?(t>>a|t>>b):(t>>c&t>>d)", weight: 6 },
{ pattern: "(t>>S)&N?(t*a):(t>>b)", weight: 6 },
{ pattern: "t%(M)?(t>>a):(t*b&t>>c)", weight: 6 },
{ pattern: "t&d?(t*a&t>>S):(t>>b)", weight: 6 },
{ pattern: "(t>>a)&(t>>b)?(t*c):(t>>S)", weight: 5 },
{ pattern: "t&M?(t>>a)^(t>>b):(t*c)", weight: 5 },
{ pattern: "t*a%(M)", weight: 6 },
{ pattern: "(t*a)%(M1)+(t*b)%(M2)", weight: 7 },
{ pattern: "t*a&(t>>b)%(M)", weight: 6 },
{ pattern: "(t*c%(M))&(t>>a)", weight: 6 },
{ pattern: "t*a+(t*b&t>>c)", weight: 6 },
{ pattern: "(t*a&t>>S)+(t*b%(M))", weight: 7 },
{ pattern: "t*b%(M)*(t>>a)", weight: 6 },
{ pattern: "(t*a|t*b)&(t>>c)", weight: 6 },
{ pattern: "t*c&((t>>a)|(t>>b))", weight: 6 },
{ pattern: "(t*a%(M1))^(t*b%(M2))", weight: 6 },
{ pattern: "(t>>a)^(t>>b)^(t>>c)", weight: 6 },
{ pattern: "t^(t>>a)^(t*b)", weight: 6 },
{ pattern: "((t>>a)^(t>>b))&(t*c)", weight: 6 },
{ pattern: "(t^t>>a)*(t^t>>b)", weight: 6 },
{ pattern: "t^(t>>a)&(t>>b)&(t>>c)", weight: 5 },
{ pattern: "((t>>a)^N)*((t>>b)^M)", weight: 6 },
{ pattern: "(t*a)^(t>>b)^(t>>c)", weight: 6 },
{ pattern: "t^(t*a>>b)^(t>>c)", weight: 5 },
{ pattern: "((t^t>>a)&N)|(t>>b)", weight: 5 },
{ pattern: "(t>>a)^(t*b&t>>c)", weight: 6 },
{ pattern: "((t>>a)&(t>>b))*((t>>c)|(t*d))", weight: 7 },
{ pattern: "t*((t>>a|t>>b)&(t>>c|t*d))", weight: 7 },
{ pattern: "(t&(t>>a))*(t%(M))", weight: 6 },
{ pattern: "t/(D+(t>>a)&(t>>b))", weight: 5 },
{ pattern: "((t*a)&(t>>b))^((t*c)%(M))", weight: 6 },
{ pattern: "(t>>a|t*b)&((t>>c)^(t*d))", weight: 6 },
{ pattern: "t*(t>>a)%(M1)+(t>>b)%(M2)", weight: 7 },
{ pattern: "((t>>a)%(M))*((t*b)&(t>>c))", weight: 6 },
{ pattern: "t&((t>>a)|(t*b))^(t>>c)", weight: 6 },
{ pattern: "(t*a&N)|(t>>b&M)|(t*c)", weight: 7 }
]
function randomElement<T>(arr: T[]): T {

20
src/utils/tileHelpers.ts Normal file
View File

@ -0,0 +1,20 @@
import type { TileState } from '../types/tiles'
export type FocusedTile = { row: number; col: number } | 'custom'
export function getTileId(row: number, col: number): string {
return `${row}-${col}`
}
export function isCustomTileFocused(focusedTile: FocusedTile): boolean {
return focusedTile === 'custom'
}
export function isTileFocused(focusedTile: FocusedTile, row: number, col: number): boolean {
if (focusedTile === 'custom') return false
return focusedTile.row === row && focusedTile.col === col
}
export function getTileFromGrid(tiles: TileState[][], row: number, col: number): TileState | undefined {
return tiles[row]?.[col]
}

View File

@ -32,9 +32,12 @@ export function loadTileParams(tile: TileState): void {
})
}
export function saveTileParams(tile: TileState): void {
tile.engineParams = { ...engineSettings.get() }
tile.effectParams = { ...effectSettings.get() }
export function saveTileParams(tile: TileState): TileState {
return {
...tile,
engineParams: { ...engineSettings.get() },
effectParams: { ...effectSettings.get() }
}
}
export function cloneTileState(tile: TileState): TileState {

View File

@ -1,6 +1,6 @@
export function generateWaveformData(formula: string, width: number, sampleRate: number = 8000, duration: number = 0.5): number[] {
export function generateWaveformData(formula: string, width: number, sampleRate: number = 8000, duration: number = 0.5, a: number = 8, b: number = 16, c: number = 32, d: number = 64): number[] {
try {
const compiledFormula = new Function('t', `return ${formula}`) as (t: number) => number
const compiledFormula = new Function('t', 'a', 'b', 'c', 'd', `return ${formula}`) as (t: number, a: number, b: number, c: number, d: number) => number
const samplesPerPixel = Math.floor((sampleRate * duration) / width)
const waveform: number[] = []
@ -11,7 +11,7 @@ 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)
const value = compiledFormula(t, a, b, c, d)
const byteValue = value & 0xFF
const normalized = (byteValue - 128) / 128
min = Math.min(min, normalized)