Better code quality
This commit is contained in:
272
src/App.tsx
272
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<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} />
|
||||
|
||||
@ -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}
|
||||
|
||||
@ -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>
|
||||
)
|
||||
}
|
||||
100
src/components/FormulaEditor.tsx
Normal file
100
src/components/FormulaEditor.tsx
Normal 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
118
src/components/Knob.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@ -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
21
src/constants/defaults.ts
Normal 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
|
||||
@ -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
|
||||
}
|
||||
}
|
||||
@ -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
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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
|
||||
|
||||
68
src/domain/audio/effects/BytebeatSourceEffect.ts
Normal file
68
src/domain/audio/effects/BytebeatSourceEffect.ts
Normal 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()
|
||||
}
|
||||
}
|
||||
33
src/hooks/useTileParams.ts
Normal file
33
src/hooks/useTileParams.ts
Normal 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 }
|
||||
}
|
||||
@ -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()
|
||||
}
|
||||
}
|
||||
@ -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()
|
||||
}
|
||||
}
|
||||
@ -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
|
||||
@ -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
|
||||
@ -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' })
|
||||
}
|
||||
@ -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()
|
||||
|
||||
@ -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()
|
||||
}
|
||||
|
||||
@ -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
20
src/utils/tileHelpers.ts
Normal 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]
|
||||
}
|
||||
@ -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 {
|
||||
|
||||
@ -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)
|
||||
|
||||
Reference in New Issue
Block a user