Better code quality
This commit is contained in:
85
public/worklets/bytebeat-processor.js
Normal file
85
public/worklets/bytebeat-processor.js
Normal file
@ -0,0 +1,85 @@
|
|||||||
|
class BytebeatProcessor extends AudioWorkletProcessor {
|
||||||
|
constructor() {
|
||||||
|
super()
|
||||||
|
|
||||||
|
this.t = 0
|
||||||
|
this.a = 8
|
||||||
|
this.b = 16
|
||||||
|
this.c = 32
|
||||||
|
this.d = 64
|
||||||
|
this.formula = null
|
||||||
|
this.compiledFormula = null
|
||||||
|
this.sampleRate = 8000
|
||||||
|
this.duration = 4
|
||||||
|
this.loopLength = this.sampleRate * this.duration
|
||||||
|
this.error = false
|
||||||
|
|
||||||
|
this.port.onmessage = (event) => {
|
||||||
|
const { type, value } = event.data
|
||||||
|
switch (type) {
|
||||||
|
case 'formula':
|
||||||
|
this.setFormula(value)
|
||||||
|
break
|
||||||
|
case 'variables':
|
||||||
|
this.a = value.a ?? this.a
|
||||||
|
this.b = value.b ?? this.b
|
||||||
|
this.c = value.c ?? this.c
|
||||||
|
this.d = value.d ?? this.d
|
||||||
|
break
|
||||||
|
case 'reset':
|
||||||
|
this.t = 0
|
||||||
|
break
|
||||||
|
case 'loopLength':
|
||||||
|
this.loopLength = value
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
setFormula(formulaString) {
|
||||||
|
try {
|
||||||
|
this.compiledFormula = new Function('t', 'a', 'b', 'c', 'd', `return ${formulaString}`)
|
||||||
|
this.formula = formulaString
|
||||||
|
this.error = false
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Failed to compile bytebeat formula:', e)
|
||||||
|
this.error = true
|
||||||
|
this.compiledFormula = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
process(inputs, outputs) {
|
||||||
|
const output = outputs[0]
|
||||||
|
|
||||||
|
if (output.length > 0) {
|
||||||
|
const outputChannel = output[0]
|
||||||
|
|
||||||
|
for (let i = 0; i < outputChannel.length; i++) {
|
||||||
|
if (!this.compiledFormula || this.error) {
|
||||||
|
outputChannel[i] = 0
|
||||||
|
} else {
|
||||||
|
try {
|
||||||
|
const value = this.compiledFormula(this.t, this.a, this.b, this.c, this.d)
|
||||||
|
const byteValue = value & 0xFF
|
||||||
|
outputChannel[i] = (byteValue - 128) / 128
|
||||||
|
} catch (e) {
|
||||||
|
outputChannel[i] = 0
|
||||||
|
if (!this.error) {
|
||||||
|
console.error('Bytebeat runtime error:', e)
|
||||||
|
this.error = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
this.t++
|
||||||
|
if (this.loopLength > 0 && this.t >= this.loopLength) {
|
||||||
|
this.t = 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
registerProcessor('bytebeat-processor', BytebeatProcessor)
|
||||||
272
src/App.tsx
272
src/App.tsx
@ -7,40 +7,46 @@ import { generateTileGrid, generateRandomFormula } from './utils/bytebeatFormula
|
|||||||
import { BytebeatTile } from './components/BytebeatTile'
|
import { BytebeatTile } from './components/BytebeatTile'
|
||||||
import { EffectsBar } from './components/EffectsBar'
|
import { EffectsBar } from './components/EffectsBar'
|
||||||
import { EngineControls } from './components/EngineControls'
|
import { EngineControls } from './components/EngineControls'
|
||||||
|
import { FormulaEditor } from './components/FormulaEditor'
|
||||||
import { getSampleRateFromIndex } from './config/effects'
|
import { getSampleRateFromIndex } from './config/effects'
|
||||||
import { engineSettings, effectSettings } from './stores/settings'
|
import { engineSettings, effectSettings } from './stores/settings'
|
||||||
import { useKeyboardShortcuts } from './hooks/useKeyboardShortcuts'
|
import { useKeyboardShortcuts } from './hooks/useKeyboardShortcuts'
|
||||||
|
import { useTileParams } from './hooks/useTileParams'
|
||||||
import type { TileState } from './types/tiles'
|
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() {
|
function App() {
|
||||||
const engineValues = useStore(engineSettings)
|
const engineValues = useStore(engineSettings)
|
||||||
const effectValues = useStore(effectSettings)
|
const effectValues = useStore(effectSettings)
|
||||||
|
|
||||||
const [tiles, setTiles] = useState<TileState[][]>(() =>
|
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 [playing, setPlaying] = useState<string | null>(null)
|
||||||
const [queued, setQueued] = 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 [playbackPosition, setPlaybackPosition] = useState<number>(0)
|
||||||
const [downloading, setDownloading] = useState(false)
|
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 playbackManagerRef = useRef<PlaybackManager | null>(null)
|
||||||
const downloadServiceRef = useRef<DownloadService>(new DownloadService())
|
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(() => {
|
useEffect(() => {
|
||||||
tilesRef.current = tiles
|
if (playbackManagerRef.current) {
|
||||||
}, [tiles])
|
playbackManagerRef.current.setPlaybackPositionCallback(setPlaybackPosition)
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
effectSettings.setKey('masterVolume', engineValues.masterVolume)
|
effectSettings.setKey('masterVolume', engineValues.masterVolume)
|
||||||
}, [engineValues.masterVolume])
|
}, [engineValues.masterVolume])
|
||||||
|
|
||||||
const handleRandom = () => {
|
const handleRandom = () => {
|
||||||
setTiles(generateTileGrid(100, 2, engineValues.complexity))
|
setTiles(generateTileGrid(TILE_GRID.SIZE, TILE_GRID.COLUMNS, engineValues.complexity))
|
||||||
setQueued(null)
|
setQueued(null)
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -56,58 +62,42 @@ function App() {
|
|||||||
|
|
||||||
playbackManagerRef.current.stop()
|
playbackManagerRef.current.stop()
|
||||||
playbackManagerRef.current.setEffects(effectValues)
|
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) {
|
if (success) {
|
||||||
setPlaying(id)
|
setPlaying(id)
|
||||||
setQueued(null)
|
setQueued(null)
|
||||||
startPlaybackTracking()
|
return true
|
||||||
} else {
|
} else {
|
||||||
console.error('Failed to play formula')
|
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 handleTileClick = (_formula: string, row: number, col: number, isDoubleClick: boolean = false) => {
|
||||||
const id = `${row}-${col}`
|
const id = getTileId(row, col)
|
||||||
const tile = tiles[row]?.[col]
|
const tile = getTileFromGrid(tiles, row, col)
|
||||||
|
|
||||||
if (!tile) return
|
if (!tile) return
|
||||||
|
|
||||||
if (focusedTile.row !== row || focusedTile.col !== col) {
|
if (focusedTile === 'custom' || (focusedTile.row !== row || focusedTile.col !== col)) {
|
||||||
const currentTile = tiles[focusedTile.row]?.[focusedTile.col]
|
saveCurrentTileParams()
|
||||||
if (currentTile) {
|
|
||||||
saveTileParams(currentTile)
|
|
||||||
}
|
|
||||||
loadTileParams(tile)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (tile) {
|
||||||
|
loadTileParams(tile)
|
||||||
|
}
|
||||||
setFocusedTile({ row, col })
|
setFocusedTile({ row, col })
|
||||||
|
|
||||||
if (playing === id) {
|
if (playing === id) {
|
||||||
playbackManagerRef.current?.stop()
|
handleStop()
|
||||||
setPlaying(null)
|
|
||||||
setQueued(null)
|
|
||||||
if (animationFrameRef.current) {
|
|
||||||
cancelAnimationFrame(animationFrameRef.current)
|
|
||||||
animationFrameRef.current = null
|
|
||||||
}
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -115,15 +105,6 @@ function App() {
|
|||||||
playFormula(tile.formula, id)
|
playFormula(tile.formula, id)
|
||||||
} else {
|
} else {
|
||||||
setQueued(id)
|
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)
|
engineSettings.setKey(parameterId as keyof typeof engineValues, value)
|
||||||
|
saveCurrentTileParams()
|
||||||
const currentTile = tiles[focusedTile.row]?.[focusedTile.col]
|
|
||||||
if (currentTile) {
|
|
||||||
saveTileParams(currentTile)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (parameterId === 'masterVolume' && playbackManagerRef.current) {
|
if (parameterId === 'masterVolume' && playbackManagerRef.current) {
|
||||||
playbackManagerRef.current.setEffects({ ...effectValues, masterVolume: value })
|
playbackManagerRef.current.setEffects({ ...effectValues, masterVolume: value })
|
||||||
@ -147,15 +124,21 @@ function App() {
|
|||||||
if (parameterId === 'pitch' && playbackManagerRef.current) {
|
if (parameterId === 'pitch' && playbackManagerRef.current) {
|
||||||
playbackManagerRef.current.setPitch(value)
|
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) => {
|
const handleEffectChange = (parameterId: string, value: number | boolean | string) => {
|
||||||
effectSettings.setKey(parameterId as any, value as any)
|
effectSettings.setKey(parameterId as any, value as any)
|
||||||
|
saveCurrentTileParams()
|
||||||
const currentTile = tiles[focusedTile.row]?.[focusedTile.col]
|
|
||||||
if (currentTile) {
|
|
||||||
saveTileParams(currentTile)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (playbackManagerRef.current) {
|
if (playbackManagerRef.current) {
|
||||||
playbackManagerRef.current.setEffects(effectValues)
|
playbackManagerRef.current.setEffects(effectValues)
|
||||||
@ -165,59 +148,66 @@ function App() {
|
|||||||
const handleDownloadAll = async () => {
|
const handleDownloadAll = async () => {
|
||||||
setDownloading(true)
|
setDownloading(true)
|
||||||
const formulas = tiles.map(row => row.map(tile => tile.formula))
|
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)
|
setDownloading(false)
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleDownloadFormula = (formula: string, filename: string) => {
|
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 handleRegenerate = (row: number, col: number) => {
|
||||||
const id = `${row}-${col}`
|
|
||||||
const newFormula = generateRandomFormula(engineValues.complexity)
|
const newFormula = generateRandomFormula(engineValues.complexity)
|
||||||
const newTile = createTileStateFromCurrent(newFormula)
|
const newTile = createTileStateFromCurrent(newFormula)
|
||||||
|
|
||||||
if (playing === id && playbackManagerRef.current) {
|
setTiles(prevTiles => {
|
||||||
setRegenerating(id)
|
const newTiles = [...prevTiles]
|
||||||
playbackManagerRef.current.scheduleNextTrack(() => {
|
newTiles[row] = [...newTiles[row]]
|
||||||
setTiles(prevTiles => {
|
newTiles[row][col] = newTile
|
||||||
const newTiles = [...prevTiles]
|
return newTiles
|
||||||
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
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleStop = () => {
|
const handleStop = () => {
|
||||||
playbackManagerRef.current?.stop()
|
playbackManagerRef.current?.stop()
|
||||||
setPlaying(null)
|
setPlaying(null)
|
||||||
setQueued(null)
|
setQueued(null)
|
||||||
if (animationFrameRef.current) {
|
setPlaybackPosition(0)
|
||||||
cancelAnimationFrame(animationFrameRef.current)
|
}
|
||||||
animationFrameRef.current = null
|
|
||||||
|
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 moveFocus = (direction: 'up' | 'down' | 'left' | 'right', step: number = 1) => {
|
||||||
const currentTile = tiles[focusedTile.row]?.[focusedTile.col]
|
saveCurrentTileParams()
|
||||||
if (currentTile) {
|
|
||||||
saveTileParams(currentTile)
|
|
||||||
}
|
|
||||||
|
|
||||||
setFocusedTile(prev => {
|
setFocusedTile(prev => {
|
||||||
|
if (prev === 'custom') return prev
|
||||||
|
|
||||||
let { row, col } = prev
|
let { row, col } = prev
|
||||||
const maxRow = tiles.length - 1
|
const maxRow = tiles.length - 1
|
||||||
const maxCol = (tiles[row]?.length || 1) - 1
|
const maxCol = (tiles[row]?.length || 1) - 1
|
||||||
@ -240,16 +230,17 @@ function App() {
|
|||||||
const newTile = tiles[row]?.[col]
|
const newTile = tiles[row]?.[col]
|
||||||
if (newTile) {
|
if (newTile) {
|
||||||
loadTileParams(newTile)
|
loadTileParams(newTile)
|
||||||
|
return { row, col }
|
||||||
}
|
}
|
||||||
|
|
||||||
return { row, col }
|
return prev
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleKeyboardSpace = () => {
|
const handleKeyboardSpace = () => {
|
||||||
if (playing) {
|
if (playing) {
|
||||||
handleStop()
|
handleStop()
|
||||||
} else {
|
} else if (focusedTile !== 'custom') {
|
||||||
const tile = tiles[focusedTile.row]?.[focusedTile.col]
|
const tile = tiles[focusedTile.row]?.[focusedTile.col]
|
||||||
if (tile) {
|
if (tile) {
|
||||||
handleTileClick(tile.formula, focusedTile.row, focusedTile.col, true)
|
handleTileClick(tile.formula, focusedTile.row, focusedTile.col, true)
|
||||||
@ -258,21 +249,27 @@ function App() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const handleKeyboardEnter = () => {
|
const handleKeyboardEnter = () => {
|
||||||
const tile = tiles[focusedTile.row]?.[focusedTile.col]
|
if (focusedTile !== 'custom') {
|
||||||
if (tile) {
|
const tile = tiles[focusedTile.row]?.[focusedTile.col]
|
||||||
handleTileClick(tile.formula, focusedTile.row, focusedTile.col, false)
|
if (tile) {
|
||||||
|
handleTileClick(tile.formula, focusedTile.row, focusedTile.col, false)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleKeyboardDoubleEnter = () => {
|
const handleKeyboardDoubleEnter = () => {
|
||||||
const tile = tiles[focusedTile.row]?.[focusedTile.col]
|
if (focusedTile !== 'custom') {
|
||||||
if (tile) {
|
const tile = tiles[focusedTile.row]?.[focusedTile.col]
|
||||||
handleTileClick(tile.formula, focusedTile.row, focusedTile.col, true)
|
if (tile) {
|
||||||
|
handleTileClick(tile.formula, focusedTile.row, focusedTile.col, true)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleKeyboardR = () => {
|
const handleKeyboardR = () => {
|
||||||
handleRegenerate(focusedTile.row, focusedTile.col)
|
if (focusedTile !== 'custom') {
|
||||||
|
handleRegenerate(focusedTile.row, focusedTile.col)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleKeyboardShiftR = () => {
|
const handleKeyboardShiftR = () => {
|
||||||
@ -292,9 +289,11 @@ function App() {
|
|||||||
})
|
})
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const element = document.querySelector(`[data-tile-id="${focusedTile.row}-${focusedTile.col}"]`)
|
if (focusedTile !== 'custom') {
|
||||||
if (element) {
|
const element = document.querySelector(`[data-tile-id="${focusedTile.row}-${focusedTile.col}"]`)
|
||||||
element.scrollIntoView({ behavior: 'smooth', block: 'nearest' })
|
if (element) {
|
||||||
|
element.scrollIntoView({ behavior: 'smooth', block: 'nearest' })
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}, [focusedTile])
|
}, [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"
|
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} />
|
<Archive size={14} strokeWidth={2} />
|
||||||
{downloading ? 'DOWNLOADING...' : 'DOWNLOAD ALL'}
|
{downloading ? 'DOWNLOADING...' : 'PACK'}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<div className="flex-1 grid grid-cols-2 auto-rows-min gap-[1px] bg-white p-[1px] overflow-auto">
|
<div className="flex-1 flex flex-col overflow-auto bg-white">
|
||||||
{tiles.map((row, i) =>
|
<div className="grid grid-cols-2 gap-[1px] bg-white p-[1px]">
|
||||||
row.map((tile, j) => {
|
<div className="col-span-2">
|
||||||
const id = `${i}-${j}`
|
<FormulaEditor
|
||||||
return (
|
formula={customTile.formula}
|
||||||
<BytebeatTile
|
isPlaying={playing === PLAYBACK_ID.CUSTOM}
|
||||||
key={id}
|
isFocused={focusedTile === 'custom'}
|
||||||
formula={tile.formula}
|
playbackPosition={playing === PLAYBACK_ID.CUSTOM ? playbackPosition : 0}
|
||||||
row={i}
|
onEvaluate={handleCustomEvaluate}
|
||||||
col={j}
|
onStop={handleCustomStop}
|
||||||
isPlaying={playing === id}
|
onRandom={handleCustomRandom}
|
||||||
isQueued={queued === id}
|
/>
|
||||||
isRegenerating={regenerating === id}
|
</div>
|
||||||
isFocused={focusedTile.row === i && focusedTile.col === j}
|
</div>
|
||||||
playbackPosition={playing === id ? playbackPosition : 0}
|
|
||||||
onPlay={handleTileClick}
|
<div className="flex-1 grid grid-cols-2 auto-rows-min gap-[1px] bg-white p-[1px]">
|
||||||
onDoubleClick={handleTileDoubleClick}
|
{tiles.map((row, i) =>
|
||||||
onDownload={handleDownloadFormula}
|
row.map((tile, j) => {
|
||||||
onRegenerate={handleRegenerate}
|
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>
|
</div>
|
||||||
|
|
||||||
<EffectsBar values={effectValues} onChange={handleEffectChange} />
|
<EffectsBar values={effectValues} onChange={handleEffectChange} />
|
||||||
|
|||||||
@ -8,7 +8,6 @@ interface BytebeatTileProps {
|
|||||||
col: number
|
col: number
|
||||||
isPlaying: boolean
|
isPlaying: boolean
|
||||||
isQueued: boolean
|
isQueued: boolean
|
||||||
isRegenerating: boolean
|
|
||||||
isFocused: boolean
|
isFocused: boolean
|
||||||
playbackPosition: number
|
playbackPosition: number
|
||||||
onPlay: (formula: string, row: number, col: number) => void
|
onPlay: (formula: string, row: number, col: number) => void
|
||||||
@ -17,7 +16,7 @@ interface BytebeatTileProps {
|
|||||||
onRegenerate: (row: number, col: number) => void
|
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)
|
const canvasRef = useRef<HTMLCanvasElement>(null)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@ -49,7 +48,7 @@ export function BytebeatTile({ formula, row, col, isPlaying, isQueued, isRegener
|
|||||||
onClick={() => onPlay(formula, row, col)}
|
onClick={() => onPlay(formula, row, col)}
|
||||||
onDoubleClick={() => onDoubleClick(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 ${
|
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]' : ''}`}
|
} ${isFocused ? 'outline outline-2 outline-white outline-offset-[-4px]' : ''}`}
|
||||||
>
|
>
|
||||||
<canvas
|
<canvas
|
||||||
@ -74,7 +73,7 @@ export function BytebeatTile({ formula, row, col, isPlaying, isQueued, isRegener
|
|||||||
: 'bg-white text-black border-white'
|
: 'bg-white text-black border-white'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<Dices size={14} strokeWidth={2} className={isRegenerating ? 'animate-spin' : ''} />
|
<Dices size={14} strokeWidth={2} />
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
onClick={handleDownload}
|
onClick={handleDownload}
|
||||||
|
|||||||
@ -1,12 +1,15 @@
|
|||||||
import { ENGINE_CONTROLS } from '../config/effects'
|
import { ENGINE_CONTROLS } from '../config/effects'
|
||||||
import { getComplexityLabel, getBitDepthLabel, getSampleRateLabel } from '../utils/formatters'
|
import { getComplexityLabel, getBitDepthLabel, getSampleRateLabel } from '../utils/formatters'
|
||||||
import type { EffectValues } from '../types/effects'
|
import type { EffectValues } from '../types/effects'
|
||||||
|
import { Knob } from './Knob'
|
||||||
|
|
||||||
interface EngineControlsProps {
|
interface EngineControlsProps {
|
||||||
values: EffectValues
|
values: EffectValues
|
||||||
onChange: (parameterId: string, value: number) => void
|
onChange: (parameterId: string, value: number) => void
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const KNOB_PARAMS = ['masterVolume', 'a', 'b', 'c', 'd']
|
||||||
|
|
||||||
export function EngineControls({ values, onChange }: EngineControlsProps) {
|
export function EngineControls({ values, onChange }: EngineControlsProps) {
|
||||||
const formatValue = (id: string, value: number): string => {
|
const formatValue = (id: string, value: number): string => {
|
||||||
switch (id) {
|
switch (id) {
|
||||||
@ -24,27 +27,48 @@ export function EngineControls({ values, onChange }: EngineControlsProps) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center gap-6">
|
<div className="flex items-center gap-6">
|
||||||
{ENGINE_CONTROLS[0].parameters.map(param => (
|
{ENGINE_CONTROLS[0].parameters.map(param => {
|
||||||
<div key={param.id} className="flex flex-col gap-1 min-w-[100px]">
|
const useKnob = KNOB_PARAMS.includes(param.id)
|
||||||
<div className="flex justify-between items-baseline">
|
|
||||||
<label className="font-mono text-[9px] tracking-[0.15em] text-white">
|
if (useKnob) {
|
||||||
{param.label.toUpperCase()}
|
return (
|
||||||
</label>
|
<Knob
|
||||||
<span className="font-mono text-[9px] text-white">
|
key={param.id}
|
||||||
{formatValue(param.id, (values[param.id] as number) ?? param.default)}
|
label={param.label}
|
||||||
</span>
|
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>
|
</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>
|
</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',
|
id: 'masterVolume',
|
||||||
label: 'Volume',
|
label: 'Vol',
|
||||||
min: 0,
|
min: 0,
|
||||||
max: 100,
|
max: 100,
|
||||||
default: 75,
|
default: 75,
|
||||||
@ -51,13 +51,40 @@ export const ENGINE_CONTROLS: EffectConfig[] = [
|
|||||||
unit: '%'
|
unit: '%'
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'pitch',
|
id: 'a',
|
||||||
label: 'Pitch',
|
label: 'A',
|
||||||
min: 0.1,
|
min: 0,
|
||||||
max: 4,
|
max: 255,
|
||||||
default: 1,
|
default: 8,
|
||||||
step: 0.01,
|
step: 1,
|
||||||
unit: 'x'
|
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 { EffectsChain } from './effects/EffectsChain'
|
||||||
|
import { BytebeatSourceEffect } from './effects/BytebeatSourceEffect'
|
||||||
import type { EffectValues } from '../../types/effects'
|
import type { EffectValues } from '../../types/effects'
|
||||||
|
|
||||||
export interface AudioPlayerOptions {
|
export interface AudioPlayerOptions {
|
||||||
@ -8,15 +9,12 @@ export interface AudioPlayerOptions {
|
|||||||
|
|
||||||
export class AudioPlayer {
|
export class AudioPlayer {
|
||||||
private audioContext: AudioContext | null = null
|
private audioContext: AudioContext | null = null
|
||||||
private sourceNode: AudioBufferSourceNode | null = null
|
private bytebeatSource: BytebeatSourceEffect | null = null
|
||||||
private effectsChain: EffectsChain | null = null
|
private effectsChain: EffectsChain | null = null
|
||||||
private effectValues: EffectValues = {}
|
private effectValues: EffectValues = {}
|
||||||
private startTime: number = 0
|
private startTime: number = 0
|
||||||
private pauseTime: number = 0
|
|
||||||
private isLooping: boolean = true
|
|
||||||
private sampleRate: number
|
private sampleRate: number
|
||||||
private duration: number
|
private duration: number
|
||||||
private pitch: number = 1
|
|
||||||
private workletRegistered: boolean = false
|
private workletRegistered: boolean = false
|
||||||
|
|
||||||
constructor(options: AudioPlayerOptions) {
|
constructor(options: AudioPlayerOptions) {
|
||||||
@ -40,7 +38,7 @@ export class AudioPlayer {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private async recreateAudioContext(): Promise<void> {
|
private async recreateAudioContext(): Promise<void> {
|
||||||
const wasPlaying = this.sourceNode !== null
|
const wasPlaying = this.bytebeatSource !== null
|
||||||
|
|
||||||
this.dispose()
|
this.dispose()
|
||||||
|
|
||||||
@ -61,7 +59,10 @@ export class AudioPlayer {
|
|||||||
if (this.workletRegistered) return
|
if (this.workletRegistered) return
|
||||||
|
|
||||||
try {
|
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
|
this.workletRegistered = true
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to register AudioWorklet:', error)
|
console.error('Failed to register AudioWorklet:', error)
|
||||||
@ -75,18 +76,7 @@ export class AudioPlayer {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
setPitch(pitch: number): void {
|
private async ensureAudioContext(): Promise<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> {
|
|
||||||
if (!this.audioContext) {
|
if (!this.audioContext) {
|
||||||
this.audioContext = new AudioContext({ sampleRate: this.sampleRate })
|
this.audioContext = new AudioContext({ sampleRate: this.sampleRate })
|
||||||
await this.registerWorklet(this.audioContext)
|
await this.registerWorklet(this.audioContext)
|
||||||
@ -97,78 +87,54 @@ export class AudioPlayer {
|
|||||||
await this.effectsChain.initialize(this.audioContext)
|
await this.effectsChain.initialize(this.audioContext)
|
||||||
this.effectsChain.updateEffects(this.effectValues)
|
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 {
|
async playRealtime(formula: string, a: number, b: number, c: number, d: number): Promise<void> {
|
||||||
this.isLooping = loop
|
await this.ensureAudioContext()
|
||||||
if (this.sourceNode) {
|
|
||||||
this.sourceNode.loop = loop
|
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 {
|
updateRealtimeVariables(a: number, b: number, c: number, d: number): void {
|
||||||
if (this.sourceNode) {
|
if (this.bytebeatSource) {
|
||||||
this.sourceNode.loop = false
|
this.bytebeatSource.setVariables(a, b, c, d)
|
||||||
this.sourceNode.onended = callback
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
getPlaybackPosition(): number {
|
getPlaybackPosition(): number {
|
||||||
if (!this.audioContext || !this.sourceNode || this.startTime === 0) {
|
if (!this.audioContext || this.startTime === 0) {
|
||||||
return 0
|
return 0
|
||||||
}
|
}
|
||||||
const elapsed = this.audioContext.currentTime - this.startTime
|
const elapsed = this.audioContext.currentTime - this.startTime
|
||||||
const actualDuration = this.duration / this.pitch
|
return (elapsed % this.duration) / this.duration
|
||||||
return (elapsed % actualDuration) / actualDuration
|
|
||||||
}
|
|
||||||
|
|
||||||
pause(): void {
|
|
||||||
if (this.sourceNode && this.audioContext) {
|
|
||||||
this.pauseTime = this.audioContext.currentTime - this.startTime
|
|
||||||
this.sourceNode.stop()
|
|
||||||
this.sourceNode = null
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
stop(): void {
|
stop(): void {
|
||||||
if (this.sourceNode) {
|
if (this.bytebeatSource) {
|
||||||
this.sourceNode.stop()
|
this.bytebeatSource.getOutputNode().disconnect()
|
||||||
this.sourceNode = null
|
|
||||||
}
|
}
|
||||||
this.startTime = 0
|
this.startTime = 0
|
||||||
this.pauseTime = 0
|
|
||||||
}
|
}
|
||||||
|
|
||||||
dispose(): void {
|
dispose(): void {
|
||||||
this.stop()
|
this.stop()
|
||||||
|
if (this.bytebeatSource) {
|
||||||
|
this.bytebeatSource.dispose()
|
||||||
|
this.bytebeatSource = null
|
||||||
|
}
|
||||||
if (this.effectsChain) {
|
if (this.effectsChain) {
|
||||||
this.effectsChain.dispose()
|
this.effectsChain.dispose()
|
||||||
this.effectsChain = null
|
this.effectsChain = null
|
||||||
@ -178,5 +144,6 @@ export class AudioPlayer {
|
|||||||
this.audioContext = null
|
this.audioContext = null
|
||||||
}
|
}
|
||||||
this.workletRegistered = false
|
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 {
|
export interface CompilationResult {
|
||||||
success: boolean
|
success: boolean
|
||||||
@ -8,7 +8,7 @@ export interface CompilationResult {
|
|||||||
|
|
||||||
export function compileFormula(formula: string): CompilationResult {
|
export function compileFormula(formula: string): CompilationResult {
|
||||||
try {
|
try {
|
||||||
const compiledFormula = new Function('t', `return ${formula}`) as CompiledFormula
|
const compiledFormula = new Function('t', 'a', 'b', 'c', 'd', `return ${formula}`) as CompiledFormula
|
||||||
return {
|
return {
|
||||||
success: true,
|
success: true,
|
||||||
compiledFormula
|
compiledFormula
|
||||||
@ -28,7 +28,7 @@ export function testFormula(formula: string): boolean {
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
result.compiledFormula(0)
|
result.compiledFormula(0, 8, 16, 32, 64)
|
||||||
return true
|
return true
|
||||||
} catch {
|
} catch {
|
||||||
return false
|
return false
|
||||||
|
|||||||
@ -3,19 +3,23 @@ import type { CompiledFormula } from './BytebeatCompiler'
|
|||||||
export interface GeneratorOptions {
|
export interface GeneratorOptions {
|
||||||
sampleRate: number
|
sampleRate: number
|
||||||
duration: number
|
duration: number
|
||||||
|
a?: number
|
||||||
|
b?: number
|
||||||
|
c?: number
|
||||||
|
d?: number
|
||||||
}
|
}
|
||||||
|
|
||||||
export function generateSamples(
|
export function generateSamples(
|
||||||
compiledFormula: CompiledFormula,
|
compiledFormula: CompiledFormula,
|
||||||
options: GeneratorOptions
|
options: GeneratorOptions
|
||||||
): Float32Array {
|
): Float32Array {
|
||||||
const { sampleRate, duration } = options
|
const { sampleRate, duration, a = 8, b = 16, c = 32, d = 64 } = options
|
||||||
const numSamples = Math.floor(sampleRate * duration)
|
const numSamples = Math.floor(sampleRate * duration)
|
||||||
const buffer = new Float32Array(numSamples)
|
const buffer = new Float32Array(numSamples)
|
||||||
|
|
||||||
for (let t = 0; t < numSamples; t++) {
|
for (let t = 0; t < numSamples; t++) {
|
||||||
try {
|
try {
|
||||||
const value = compiledFormula(t)
|
const value = compiledFormula(t, a, b, c, d)
|
||||||
const byteValue = value & 0xFF
|
const byteValue = value & 0xFF
|
||||||
buffer[t] = (byteValue - 128) / 128
|
buffer[t] = (byteValue - 128) / 128
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@ -31,7 +35,7 @@ export function generateSamplesWithBitDepth(
|
|||||||
options: GeneratorOptions,
|
options: GeneratorOptions,
|
||||||
bitDepth: 8 | 16 | 24
|
bitDepth: 8 | 16 | 24
|
||||||
): Float32Array {
|
): Float32Array {
|
||||||
const { sampleRate, duration } = options
|
const { sampleRate, duration, a = 8, b = 16, c = 32, d = 64 } = options
|
||||||
const numSamples = Math.floor(sampleRate * duration)
|
const numSamples = Math.floor(sampleRate * duration)
|
||||||
const buffer = new Float32Array(numSamples)
|
const buffer = new Float32Array(numSamples)
|
||||||
|
|
||||||
@ -40,7 +44,7 @@ export function generateSamplesWithBitDepth(
|
|||||||
|
|
||||||
for (let t = 0; t < numSamples; t++) {
|
for (let t = 0; t < numSamples; t++) {
|
||||||
try {
|
try {
|
||||||
const value = compiledFormula(t)
|
const value = compiledFormula(t, a, b, c, d)
|
||||||
const clampedValue = value & maxValue
|
const clampedValue = value & maxValue
|
||||||
buffer[t] = (clampedValue - midPoint) / midPoint
|
buffer[t] = (clampedValue - midPoint) / midPoint
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|||||||
@ -1,13 +1,70 @@
|
|||||||
import { encodeWAV } from '../../lib/bytebeat/wavEncoder'
|
export type BitDepth = 8 | 16 | 24
|
||||||
import type { BitDepth } from '../../lib/bytebeat/types'
|
|
||||||
|
|
||||||
export type { BitDepth }
|
|
||||||
|
|
||||||
export interface ExportOptions {
|
export interface ExportOptions {
|
||||||
sampleRate: number
|
sampleRate: number
|
||||||
bitDepth?: BitDepth
|
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(
|
export function exportToWav(
|
||||||
samples: Float32Array,
|
samples: Float32Array,
|
||||||
options: ExportOptions
|
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 { generateSamples } from '../domain/audio/SampleGenerator'
|
||||||
import { exportToWav } from '../domain/audio/WavExporter'
|
import { exportToWav } from '../domain/audio/WavExporter'
|
||||||
import type { BitDepth } from '../domain/audio/WavExporter'
|
import type { BitDepth } from '../domain/audio/WavExporter'
|
||||||
|
import { DEFAULT_DOWNLOAD_OPTIONS } from '../constants/defaults'
|
||||||
|
|
||||||
export interface DownloadOptions {
|
export interface DownloadOptions {
|
||||||
sampleRate?: number
|
sampleRate?: number
|
||||||
@ -26,9 +27,9 @@ export class DownloadService {
|
|||||||
options: DownloadOptions = {}
|
options: DownloadOptions = {}
|
||||||
): boolean {
|
): boolean {
|
||||||
const {
|
const {
|
||||||
sampleRate = 8000,
|
sampleRate = DEFAULT_DOWNLOAD_OPTIONS.SAMPLE_RATE,
|
||||||
duration = 10,
|
duration = DEFAULT_DOWNLOAD_OPTIONS.DURATION,
|
||||||
bitDepth = 8
|
bitDepth = DEFAULT_DOWNLOAD_OPTIONS.BIT_DEPTH
|
||||||
} = options
|
} = options
|
||||||
|
|
||||||
const result = compileFormula(formula)
|
const result = compileFormula(formula)
|
||||||
@ -54,9 +55,9 @@ export class DownloadService {
|
|||||||
options: DownloadOptions = {}
|
options: DownloadOptions = {}
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const {
|
const {
|
||||||
sampleRate = 8000,
|
sampleRate = DEFAULT_DOWNLOAD_OPTIONS.SAMPLE_RATE,
|
||||||
duration = 10,
|
duration = DEFAULT_DOWNLOAD_OPTIONS.DURATION,
|
||||||
bitDepth = 8
|
bitDepth = DEFAULT_DOWNLOAD_OPTIONS.BIT_DEPTH
|
||||||
} = options
|
} = options
|
||||||
|
|
||||||
const zip = new JSZip()
|
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 { AudioPlayer } from '../domain/audio/AudioPlayer'
|
||||||
import type { EffectValues } from '../types/effects'
|
import type { EffectValues } from '../types/effects'
|
||||||
|
import { DEFAULT_VARIABLES } from '../constants/defaults'
|
||||||
|
|
||||||
export interface PlaybackOptions {
|
export interface PlaybackOptions {
|
||||||
sampleRate: number
|
sampleRate: number
|
||||||
@ -11,8 +10,10 @@ export interface PlaybackOptions {
|
|||||||
export class PlaybackManager {
|
export class PlaybackManager {
|
||||||
private player: AudioPlayer
|
private player: AudioPlayer
|
||||||
private currentFormula: string | null = null
|
private currentFormula: string | null = null
|
||||||
private currentBuffer: Float32Array | null = null
|
|
||||||
private queuedCallback: (() => void) | 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) {
|
constructor(options: PlaybackOptions) {
|
||||||
this.player = new AudioPlayer(options)
|
this.player = new AudioPlayer(options)
|
||||||
@ -20,53 +21,72 @@ export class PlaybackManager {
|
|||||||
|
|
||||||
async updateOptions(options: Partial<PlaybackOptions>): Promise<void> {
|
async updateOptions(options: Partial<PlaybackOptions>): Promise<void> {
|
||||||
await this.player.updateOptions(options)
|
await this.player.updateOptions(options)
|
||||||
this.currentBuffer = null
|
|
||||||
}
|
}
|
||||||
|
|
||||||
setEffects(values: EffectValues): void {
|
setEffects(values: EffectValues): void {
|
||||||
this.player.setEffects(values)
|
this.player.setEffects(values)
|
||||||
}
|
}
|
||||||
|
|
||||||
setPitch(pitch: number): void {
|
setVariables(a: number, b: number, c: number, d: number): void {
|
||||||
this.player.setPitch(pitch)
|
this.variables = { a, b, c, d }
|
||||||
|
this.player.updateRealtimeVariables(a, b, c, d)
|
||||||
}
|
}
|
||||||
|
|
||||||
async play(formula: string, sampleRate: number, duration: number): Promise<boolean> {
|
setPitch(pitch: number): void {
|
||||||
const result = compileFormula(formula)
|
// Pitch is already handled via setEffects, but we could add specific handling here if needed
|
||||||
|
}
|
||||||
|
|
||||||
if (!result.success || !result.compiledFormula) {
|
setPlaybackPositionCallback(callback: (position: number) => void): void {
|
||||||
console.error('Failed to compile formula:', result.error)
|
this.playbackPositionCallback = callback
|
||||||
return false
|
}
|
||||||
|
|
||||||
|
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 {
|
try {
|
||||||
this.currentBuffer = generateSamples(result.compiledFormula, { sampleRate, duration })
|
|
||||||
this.currentFormula = formula
|
this.currentFormula = formula
|
||||||
this.player.setLooping(true)
|
await this.player.playRealtime(
|
||||||
await this.player.play(this.currentBuffer)
|
formula,
|
||||||
|
this.variables.a,
|
||||||
|
this.variables.b,
|
||||||
|
this.variables.c,
|
||||||
|
this.variables.d
|
||||||
|
)
|
||||||
|
this.startPlaybackTracking()
|
||||||
return true
|
return true
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to generate samples:', error)
|
console.error('Failed to start realtime playback:', error)
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
stop(): void {
|
stop(): void {
|
||||||
|
this.stopPlaybackTracking()
|
||||||
this.player.stop()
|
this.player.stop()
|
||||||
this.currentFormula = null
|
this.currentFormula = null
|
||||||
this.queuedCallback = null
|
this.queuedCallback = null
|
||||||
}
|
}
|
||||||
|
|
||||||
scheduleNextTrack(callback: () => void): void {
|
|
||||||
this.queuedCallback = callback
|
|
||||||
this.player.scheduleNextTrack(() => {
|
|
||||||
if (this.queuedCallback) {
|
|
||||||
this.queuedCallback()
|
|
||||||
this.queuedCallback = null
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
getPlaybackPosition(): number {
|
getPlaybackPosition(): number {
|
||||||
return this.player.getPlaybackPosition()
|
return this.player.getPlaybackPosition()
|
||||||
}
|
}
|
||||||
|
|||||||
@ -72,7 +72,71 @@ const TEMPLATES: Template[] = [
|
|||||||
{ pattern: "((t*t)/(t^t>>S))&N", weight: 5 },
|
{ pattern: "((t*t)/(t^t>>S))&N", weight: 5 },
|
||||||
{ pattern: "(t*(t>>S1))^(t*(t>>S2))", weight: 6 },
|
{ pattern: "(t*(t>>S1))^(t*(t>>S2))", weight: 6 },
|
||||||
{ pattern: "((t>>S1)*(t>>S2))&((t>>S3)|(t>>S4))", 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 {
|
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 {
|
export function saveTileParams(tile: TileState): TileState {
|
||||||
tile.engineParams = { ...engineSettings.get() }
|
return {
|
||||||
tile.effectParams = { ...effectSettings.get() }
|
...tile,
|
||||||
|
engineParams: { ...engineSettings.get() },
|
||||||
|
effectParams: { ...effectSettings.get() }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function cloneTileState(tile: TileState): TileState {
|
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 {
|
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 samplesPerPixel = Math.floor((sampleRate * duration) / width)
|
||||||
const waveform: number[] = []
|
const waveform: number[] = []
|
||||||
|
|
||||||
@ -11,7 +11,7 @@ export function generateWaveformData(formula: string, width: number, sampleRate:
|
|||||||
for (let s = 0; s < samplesPerPixel; s++) {
|
for (let s = 0; s < samplesPerPixel; s++) {
|
||||||
const t = x * samplesPerPixel + s
|
const t = x * samplesPerPixel + s
|
||||||
try {
|
try {
|
||||||
const value = compiledFormula(t)
|
const value = compiledFormula(t, a, b, c, d)
|
||||||
const byteValue = value & 0xFF
|
const byteValue = value & 0xFF
|
||||||
const normalized = (byteValue - 128) / 128
|
const normalized = (byteValue - 128) / 128
|
||||||
min = Math.min(min, normalized)
|
min = Math.min(min, normalized)
|
||||||
|
|||||||
Reference in New Issue
Block a user