diff --git a/public/worklets/bytebeat-processor.js b/public/worklets/bytebeat-processor.js new file mode 100644 index 00000000..49296721 --- /dev/null +++ b/public/worklets/bytebeat-processor.js @@ -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) diff --git a/src/App.tsx b/src/App.tsx index 42aededb..eece4361 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -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(() => - generateTileGrid(100, 2, engineValues.complexity) + generateTileGrid(TILE_GRID.SIZE, TILE_GRID.COLUMNS, engineValues.complexity) ) const [playing, setPlaying] = useState(null) const [queued, setQueued] = useState(null) - const [regenerating, setRegenerating] = useState(null) const [playbackPosition, setPlaybackPosition] = useState(0) const [downloading, setDownloading] = useState(false) - const [focusedTile, setFocusedTile] = useState<{ row: number; col: number }>({ row: 0, col: 0 }) + const [focusedTile, setFocusedTile] = useState({ row: 0, col: 0 }) + const [customTile, setCustomTile] = useState(() => createTileStateFromCurrent('t*(8&t>>9)')) const playbackManagerRef = useRef(null) const downloadServiceRef = useRef(new DownloadService()) - const animationFrameRef = useRef(null) - const tilesRef = useRef(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" > - {downloading ? 'DOWNLOADING...' : 'DOWNLOAD ALL'} + {downloading ? 'DOWNLOADING...' : 'PACK'} -
- {tiles.map((row, i) => - row.map((tile, j) => { - const id = `${i}-${j}` - return ( - - ) - }) - )} +
+
+
+ +
+
+ +
+ {tiles.map((row, i) => + row.map((tile, j) => { + const id = getTileId(i, j) + return ( + + ) + }) + )} +
diff --git a/src/components/BytebeatTile.tsx b/src/components/BytebeatTile.tsx index e571212d..0a4d4e9c 100644 --- a/src/components/BytebeatTile.tsx +++ b/src/components/BytebeatTile.tsx @@ -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(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]' : ''}`} > - +
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 (
- {ENGINE_CONTROLS[0].parameters.map(param => ( -
-
- - - {formatValue(param.id, (values[param.id] as number) ?? param.default)} - + {ENGINE_CONTROLS[0].parameters.map(param => { + const useKnob = KNOB_PARAMS.includes(param.id) + + if (useKnob) { + return ( + onChange(param.id, value)} + formatValue={formatValue} + valueId={param.id} + /> + ) + } + + return ( +
+
+ + + {formatValue(param.id, (values[param.id] as number) ?? param.default)} + +
+ onChange(param.id, Number(e.target.value))} + className="w-full h-[2px] bg-white appearance-none cursor-pointer" + />
- onChange(param.id, Number(e.target.value))} - className="w-full h-[2px] bg-white appearance-none cursor-pointer" - /> -
- ))} + ) + })}
) } \ No newline at end of file diff --git a/src/components/FormulaEditor.tsx b/src/components/FormulaEditor.tsx new file mode 100644 index 00000000..2b3e53db --- /dev/null +++ b/src/components/FormulaEditor.tsx @@ -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 ( +
+ {isPlaying && ( +
+ )} + 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' + }`} + /> +
+ + {isPlaying && ( + + )} + +
+
+ ) +} diff --git a/src/components/Knob.tsx b/src/components/Knob.tsx new file mode 100644 index 00000000..f54df799 --- /dev/null +++ b/src/components/Knob.tsx @@ -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(0) + const startValueRef = useRef(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 ( +
+
+ + + + + + + + +
+ + {isDragging ? displayValue : label.toUpperCase()} + +
+
+
+ ) +} diff --git a/src/config/effects.ts b/src/config/effects.ts index 58fb1ce0..172fdcfe 100644 --- a/src/config/effects.ts +++ b/src/config/effects.ts @@ -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: '' } ] } diff --git a/src/constants/defaults.ts b/src/constants/defaults.ts new file mode 100644 index 00000000..4191dfdc --- /dev/null +++ b/src/constants/defaults.ts @@ -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 diff --git a/src/domain/audio/AudioPlayer.ts b/src/domain/audio/AudioPlayer.ts index 7dbf8f32..72a32e55 100644 --- a/src/domain/audio/AudioPlayer.ts +++ b/src/domain/audio/AudioPlayer.ts @@ -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 { - 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 { + private async ensureAudioContext(): Promise { 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 { + 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 } } \ No newline at end of file diff --git a/src/domain/audio/BytebeatCompiler.ts b/src/domain/audio/BytebeatCompiler.ts index 6903c7b6..e327dda1 100644 --- a/src/domain/audio/BytebeatCompiler.ts +++ b/src/domain/audio/BytebeatCompiler.ts @@ -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 diff --git a/src/domain/audio/SampleGenerator.ts b/src/domain/audio/SampleGenerator.ts index 47d718e0..c4a2dc39 100644 --- a/src/domain/audio/SampleGenerator.ts +++ b/src/domain/audio/SampleGenerator.ts @@ -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) { diff --git a/src/domain/audio/WavExporter.ts b/src/domain/audio/WavExporter.ts index 22592338..f4ef3eed 100644 --- a/src/domain/audio/WavExporter.ts +++ b/src/domain/audio/WavExporter.ts @@ -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 diff --git a/src/domain/audio/effects/BytebeatSourceEffect.ts b/src/domain/audio/effects/BytebeatSourceEffect.ts new file mode 100644 index 00000000..a5705ba3 --- /dev/null +++ b/src/domain/audio/effects/BytebeatSourceEffect.ts @@ -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 { + 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): 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() + } +} diff --git a/src/hooks/useTileParams.ts b/src/hooks/useTileParams.ts new file mode 100644 index 00000000..b18073fa --- /dev/null +++ b/src/hooks/useTileParams.ts @@ -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> + customTile: TileState + setCustomTile: React.Dispatch> + 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 } +} diff --git a/src/lib/bytebeat/BytebeatGenerator.ts b/src/lib/bytebeat/BytebeatGenerator.ts deleted file mode 100644 index b404b459..00000000 --- a/src/lib/bytebeat/BytebeatGenerator.ts +++ /dev/null @@ -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): 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() - } -} \ No newline at end of file diff --git a/src/lib/bytebeat/EffectsChain.ts b/src/lib/bytebeat/EffectsChain.ts deleted file mode 100644 index efd5593a..00000000 --- a/src/lib/bytebeat/EffectsChain.ts +++ /dev/null @@ -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() - } -} \ No newline at end of file diff --git a/src/lib/bytebeat/index.ts b/src/lib/bytebeat/index.ts deleted file mode 100644 index 1f0d604b..00000000 --- a/src/lib/bytebeat/index.ts +++ /dev/null @@ -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 \ No newline at end of file diff --git a/src/lib/bytebeat/types.ts b/src/lib/bytebeat/types.ts deleted file mode 100644 index 2f647bc5..00000000 --- a/src/lib/bytebeat/types.ts +++ /dev/null @@ -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 \ No newline at end of file diff --git a/src/lib/bytebeat/wavEncoder.ts b/src/lib/bytebeat/wavEncoder.ts deleted file mode 100644 index a36faf41..00000000 --- a/src/lib/bytebeat/wavEncoder.ts +++ /dev/null @@ -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' }) -} \ No newline at end of file diff --git a/src/services/DownloadService.ts b/src/services/DownloadService.ts index c5388cdc..86f5ef3e 100644 --- a/src/services/DownloadService.ts +++ b/src/services/DownloadService.ts @@ -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 { 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() diff --git a/src/services/PlaybackManager.ts b/src/services/PlaybackManager.ts index ea27589b..51ba8850 100644 --- a/src/services/PlaybackManager.ts +++ b/src/services/PlaybackManager.ts @@ -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): Promise { 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 { - 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 { 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() } diff --git a/src/utils/bytebeatFormulas.ts b/src/utils/bytebeatFormulas.ts index 80f10413..5e4f4193 100644 --- a/src/utils/bytebeatFormulas.ts +++ b/src/utils/bytebeatFormulas.ts @@ -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(arr: T[]): T { diff --git a/src/utils/tileHelpers.ts b/src/utils/tileHelpers.ts new file mode 100644 index 00000000..d7f7391b --- /dev/null +++ b/src/utils/tileHelpers.ts @@ -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] +} diff --git a/src/utils/tileState.ts b/src/utils/tileState.ts index 106c4d81..a6dc924e 100644 --- a/src/utils/tileState.ts +++ b/src/utils/tileState.ts @@ -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 { diff --git a/src/utils/waveformGenerator.ts b/src/utils/waveformGenerator.ts index 5b6ea35e..c56fb09a 100644 --- a/src/utils/waveformGenerator.ts +++ b/src/utils/waveformGenerator.ts @@ -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)