Enhance FM synthesis + cleaning code architecture
This commit is contained in:
@ -30,6 +30,11 @@ class FMProcessor extends AudioWorkletProcessor {
|
||||
this.lfoRate4 = 0.43
|
||||
this.lfoDepth = 0.3
|
||||
|
||||
this.pitchLFOPhase = 0
|
||||
this.pitchLFOWaveform = 0
|
||||
this.pitchLFODepth = 0.1
|
||||
this.pitchLFOBaseRate = 2.0
|
||||
|
||||
this.port.onmessage = (event) => {
|
||||
const { type, value } = event.data
|
||||
switch (type) {
|
||||
@ -42,6 +47,11 @@ class FMProcessor extends AudioWorkletProcessor {
|
||||
this.lfoRate3 = value.lfoRates[2]
|
||||
this.lfoRate4 = value.lfoRates[3]
|
||||
}
|
||||
if (value.pitchLFO) {
|
||||
this.pitchLFOWaveform = value.pitchLFO.waveform
|
||||
this.pitchLFODepth = value.pitchLFO.depth
|
||||
this.pitchLFOBaseRate = value.pitchLFO.baseRate
|
||||
}
|
||||
break
|
||||
case 'operatorLevels':
|
||||
this.opLevel1 = value.a
|
||||
@ -73,14 +83,41 @@ class FMProcessor extends AudioWorkletProcessor {
|
||||
}
|
||||
}
|
||||
|
||||
generatePitchLFO(phase, waveform) {
|
||||
const TWO_PI = Math.PI * 2
|
||||
const normalizedPhase = phase / TWO_PI
|
||||
|
||||
switch (waveform) {
|
||||
case 0: // sine
|
||||
return Math.sin(phase)
|
||||
case 1: // triangle
|
||||
return 2 * Math.abs(2 * (normalizedPhase % 1 - 0.5)) - 1
|
||||
case 2: // square
|
||||
return normalizedPhase % 1 < 0.5 ? 1 : -1
|
||||
case 3: // sawtooth
|
||||
return 2 * (normalizedPhase % 1) - 1
|
||||
default:
|
||||
return 0
|
||||
}
|
||||
}
|
||||
|
||||
synthesize(algorithm) {
|
||||
const TWO_PI = Math.PI * 2
|
||||
const sampleRate = 44100
|
||||
|
||||
const freq1 = (this.baseFreq * this.frequencyRatios[0] * TWO_PI) / sampleRate
|
||||
const freq2 = (this.baseFreq * this.frequencyRatios[1] * TWO_PI) / sampleRate
|
||||
const freq3 = (this.baseFreq * this.frequencyRatios[2] * TWO_PI) / sampleRate
|
||||
const freq4 = (this.baseFreq * this.frequencyRatios[3] * TWO_PI) / sampleRate
|
||||
const avgDiff = (Math.abs(this.opLevel1 - this.opLevel3) + Math.abs(this.opLevel2 - this.opLevel4)) / (2 * 255)
|
||||
const pitchLFORate = this.pitchLFOBaseRate * (0.3 + avgDiff * 1.4)
|
||||
const pitchLFOValue = this.generatePitchLFO(this.pitchLFOPhase, this.pitchLFOWaveform)
|
||||
const pitchMod = 1 + pitchLFOValue * this.pitchLFODepth
|
||||
const modulatedBaseFreq = this.baseFreq * pitchMod
|
||||
|
||||
this.pitchLFOPhase += (pitchLFORate * TWO_PI) / sampleRate
|
||||
if (this.pitchLFOPhase > TWO_PI) this.pitchLFOPhase -= TWO_PI
|
||||
|
||||
const freq1 = (modulatedBaseFreq * this.frequencyRatios[0] * TWO_PI) / sampleRate
|
||||
const freq2 = (modulatedBaseFreq * this.frequencyRatios[1] * TWO_PI) / sampleRate
|
||||
const freq3 = (modulatedBaseFreq * this.frequencyRatios[2] * TWO_PI) / sampleRate
|
||||
const freq4 = (modulatedBaseFreq * this.frequencyRatios[3] * TWO_PI) / sampleRate
|
||||
|
||||
const lfo1 = Math.sin(this.lfoPhase1)
|
||||
const lfo2 = Math.sin(this.lfoPhase2)
|
||||
|
||||
541
src/App.tsx
541
src/App.tsx
@ -1,200 +1,88 @@
|
||||
import { useState, useRef, useEffect } from 'react'
|
||||
import { useState, useRef } from 'react'
|
||||
import { useStore } from '@nanostores/react'
|
||||
import { Square, Archive, Dices, Sparkles } from 'lucide-react'
|
||||
import { PlaybackManager } from './services/PlaybackManager'
|
||||
import { DownloadService } from './services/DownloadService'
|
||||
import { generateTileGrid, generateRandomFormula } from './utils/bytebeatFormulas'
|
||||
import { generateFMTileGrid, generateRandomFMPatch, createFMTileState } from './utils/fmPatches'
|
||||
import { BytebeatTile } from './components/BytebeatTile'
|
||||
import { EffectsBar } from './components/EffectsBar'
|
||||
import { EngineControls } from './components/EngineControls'
|
||||
import { FormulaEditor } from './components/FormulaEditor'
|
||||
import { LFOPanel } from './components/LFOPanel'
|
||||
import { AudioContextWarning } from './components/AudioContextWarning'
|
||||
import { HelpModal } from './components/HelpModal'
|
||||
import { getSampleRateFromIndex } from './config/effects'
|
||||
import { engineSettings, effectSettings, lfoSettings, type LFOConfig } from './stores/settings'
|
||||
import { synthesisMode, setSynthesisMode } from './stores/synthesisMode'
|
||||
import { generateRandomFormula } from './utils/bytebeatFormulas'
|
||||
import { BytebeatTile } from './components/tile/BytebeatTile'
|
||||
import { EffectsBar } from './components/controls/EffectsBar'
|
||||
import { EngineControls } from './components/controls/EngineControls'
|
||||
import { FormulaEditor } from './components/tile/FormulaEditor'
|
||||
import { LFOPanel } from './components/controls/LFOPanel'
|
||||
import { AudioContextWarning } from './components/modals/AudioContextWarning'
|
||||
import { HelpModal } from './components/modals/HelpModal'
|
||||
import { engineSettings, effectSettings } from './stores/settings'
|
||||
import { exitMappingMode } from './stores/mappingMode'
|
||||
import { useKeyboardShortcuts } from './hooks/useKeyboardShortcuts'
|
||||
import { useTileParams } from './hooks/useTileParams'
|
||||
import { useTileGrid } from './hooks/useTileGrid'
|
||||
import { usePlaybackControl } from './hooks/usePlaybackControl'
|
||||
import { useFocusNavigation } from './hooks/useFocusNavigation'
|
||||
import { useParameterSync } from './hooks/useParameterSync'
|
||||
import { useLFOMapping } from './hooks/useLFOMapping'
|
||||
import type { TileState } from './types/tiles'
|
||||
import { createTileStateFromCurrent, loadTileParams, randomizeTileParams } from './utils/tileState'
|
||||
import { DEFAULT_VARIABLES, PLAYBACK_ID, TILE_GRID, DEFAULT_DOWNLOAD_OPTIONS, LOOP_DURATION } from './constants/defaults'
|
||||
import { getTileId, getTileFromGrid, type FocusedTile } from './utils/tileHelpers'
|
||||
import { createTileStateFromCurrent } from './utils/tileState'
|
||||
import { DEFAULT_DOWNLOAD_OPTIONS, PLAYBACK_ID } from './constants/defaults'
|
||||
import { getTileId, getTileFromGrid } from './utils/tileHelpers'
|
||||
|
||||
function App() {
|
||||
const engineValues = useStore(engineSettings)
|
||||
const effectValues = useStore(effectSettings)
|
||||
const mode = useStore(synthesisMode)
|
||||
|
||||
const [tiles, setTiles] = useState<TileState[][]>(() =>
|
||||
mode === 'fm'
|
||||
? generateFMTileGrid(TILE_GRID.SIZE, TILE_GRID.COLUMNS, 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 [playbackPosition, setPlaybackPosition] = useState<number>(0)
|
||||
const [downloading, setDownloading] = useState(false)
|
||||
const [focusedTile, setFocusedTile] = useState<FocusedTile>({ row: 0, col: 0 })
|
||||
const [customTile, setCustomTile] = useState<TileState>(() => createTileStateFromCurrent('t*(8&t>>9)'))
|
||||
const [showWarning, setShowWarning] = useState(true)
|
||||
const [showHelp, setShowHelp] = useState(false)
|
||||
const [mobileHeaderTab, setMobileHeaderTab] = useState<'global' | 'options' | 'modulate'>('global')
|
||||
const playbackManagerRef = useRef<PlaybackManager | null>(null)
|
||||
const downloadServiceRef = useRef<DownloadService>(new DownloadService())
|
||||
const switchTimerRef = useRef<number | null>(null)
|
||||
|
||||
const { saveCurrentTileParams } = useTileParams({ tiles, setTiles, customTile, setCustomTile, focusedTile })
|
||||
const { tiles, setTiles, mode, regenerateAll, regenerateTile, switchMode } = useTileGrid()
|
||||
|
||||
useEffect(() => {
|
||||
if (playbackManagerRef.current) {
|
||||
playbackManagerRef.current.setPlaybackPositionCallback(setPlaybackPosition)
|
||||
}
|
||||
}, [])
|
||||
const { playing, queued, playbackPosition, playbackManager, play, stop, queue, cancelQueue, updateMode } =
|
||||
usePlaybackControl({ mode })
|
||||
|
||||
useEffect(() => {
|
||||
effectSettings.setKey('masterVolume', engineValues.masterVolume)
|
||||
}, [engineValues.masterVolume])
|
||||
|
||||
const clearSwitchTimer = () => {
|
||||
if (switchTimerRef.current !== null) {
|
||||
clearTimeout(switchTimerRef.current)
|
||||
switchTimerRef.current = null
|
||||
}
|
||||
}
|
||||
|
||||
const startSwitchTimer = (queuedId: string) => {
|
||||
clearSwitchTimer()
|
||||
|
||||
switchTimerRef.current = window.setTimeout(() => {
|
||||
const [rowStr, colStr] = queuedId.split('-')
|
||||
const row = parseInt(rowStr, 10)
|
||||
const col = parseInt(colStr, 10)
|
||||
const tile = getTileFromGrid(tiles, row, col)
|
||||
|
||||
if (tile) {
|
||||
playFormula(tile.formula, queuedId)
|
||||
const { focusedTile, setFocus, moveFocus } = useFocusNavigation({
|
||||
tiles,
|
||||
onFocusChange: (tile) => {
|
||||
if (tile !== 'custom') {
|
||||
const tileData = getTileFromGrid(tiles, tile.row, tile.col)
|
||||
if (tileData) {
|
||||
saveCurrentParams()
|
||||
loadParams(tileData)
|
||||
}
|
||||
} else {
|
||||
saveCurrentParams()
|
||||
loadParams(customTile)
|
||||
}
|
||||
}, engineValues.loopCount * 1000)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
return () => clearSwitchTimer()
|
||||
}, [])
|
||||
const { saveCurrentParams, loadParams, handleEngineChange, handleEffectChange, randomizeParams, randomizeAllParams } =
|
||||
useParameterSync({
|
||||
tiles,
|
||||
setTiles,
|
||||
customTile,
|
||||
setCustomTile,
|
||||
focusedTile,
|
||||
playbackManager,
|
||||
playing,
|
||||
playbackId: PLAYBACK_ID.CUSTOM
|
||||
})
|
||||
|
||||
const { handleLFOChange, handleParameterMapClick, handleUpdateMappingDepth, handleRemoveMapping, getMappedLFOs } =
|
||||
useLFOMapping({
|
||||
playbackManager,
|
||||
saveCurrentParams
|
||||
})
|
||||
|
||||
const handleRandom = () => {
|
||||
clearSwitchTimer()
|
||||
if (mode === 'fm') {
|
||||
setTiles(generateFMTileGrid(TILE_GRID.SIZE, TILE_GRID.COLUMNS, engineValues.complexity))
|
||||
} else {
|
||||
setTiles(generateTileGrid(TILE_GRID.SIZE, TILE_GRID.COLUMNS, engineValues.complexity))
|
||||
}
|
||||
setQueued(null)
|
||||
cancelQueue()
|
||||
regenerateAll()
|
||||
}
|
||||
|
||||
const handleModeToggle = () => {
|
||||
const newMode = mode === 'bytebeat' ? 'fm' : 'bytebeat'
|
||||
handleStop()
|
||||
setSynthesisMode(newMode)
|
||||
|
||||
if (playbackManagerRef.current) {
|
||||
playbackManagerRef.current.setMode(newMode)
|
||||
playbackManagerRef.current.setAlgorithm(engineValues.fmAlgorithm ?? 0)
|
||||
playbackManagerRef.current.setFeedback(engineValues.fmFeedback ?? 0)
|
||||
}
|
||||
|
||||
if (newMode === 'fm') {
|
||||
setTiles(generateFMTileGrid(TILE_GRID.SIZE, TILE_GRID.COLUMNS, engineValues.complexity))
|
||||
} else {
|
||||
setTiles(generateTileGrid(TILE_GRID.SIZE, TILE_GRID.COLUMNS, engineValues.complexity))
|
||||
}
|
||||
}
|
||||
|
||||
const handleRandomizeAllParams = () => {
|
||||
clearSwitchTimer()
|
||||
let newRandomized: TileState | null = null
|
||||
|
||||
if (playing === PLAYBACK_ID.CUSTOM) {
|
||||
setCustomTile(prev => {
|
||||
const randomized = randomizeTileParams(prev)
|
||||
newRandomized = randomized
|
||||
return randomized
|
||||
})
|
||||
} else {
|
||||
setTiles(prevTiles => {
|
||||
const newTiles = prevTiles.map((row, rowIdx) =>
|
||||
row.map((tile, colIdx) => {
|
||||
const randomized = randomizeTileParams(tile)
|
||||
if (playing && focusedTile !== 'custom') {
|
||||
const tileId = getTileId(focusedTile.row, focusedTile.col)
|
||||
if (playing === tileId && rowIdx === focusedTile.row && colIdx === focusedTile.col) {
|
||||
newRandomized = randomized
|
||||
}
|
||||
}
|
||||
return randomized
|
||||
})
|
||||
)
|
||||
return newTiles
|
||||
})
|
||||
|
||||
setCustomTile(prev => randomizeTileParams(prev))
|
||||
}
|
||||
|
||||
if (newRandomized && playbackManagerRef.current) {
|
||||
const params = newRandomized as TileState
|
||||
loadTileParams(params)
|
||||
|
||||
playbackManagerRef.current.setEffects(params.effectParams)
|
||||
playbackManagerRef.current.setVariables(
|
||||
params.engineParams.a ?? DEFAULT_VARIABLES.a,
|
||||
params.engineParams.b ?? DEFAULT_VARIABLES.b,
|
||||
params.engineParams.c ?? DEFAULT_VARIABLES.c,
|
||||
params.engineParams.d ?? DEFAULT_VARIABLES.d
|
||||
)
|
||||
playbackManagerRef.current.setPitch(params.engineParams.pitch ?? 1.0)
|
||||
|
||||
if (params.lfoConfigs) {
|
||||
playbackManagerRef.current.setLFOConfig(0, params.lfoConfigs.lfo1)
|
||||
playbackManagerRef.current.setLFOConfig(1, params.lfoConfigs.lfo2)
|
||||
playbackManagerRef.current.setLFOConfig(2, params.lfoConfigs.lfo3)
|
||||
playbackManagerRef.current.setLFOConfig(3, params.lfoConfigs.lfo4)
|
||||
}
|
||||
}
|
||||
|
||||
setQueued(null)
|
||||
}
|
||||
|
||||
const playFormula = async (formula: string, id: string) => {
|
||||
const sampleRate = getSampleRateFromIndex(engineValues.sampleRate)
|
||||
const duration = LOOP_DURATION
|
||||
|
||||
if (!playbackManagerRef.current) {
|
||||
playbackManagerRef.current = new PlaybackManager({ sampleRate, duration })
|
||||
playbackManagerRef.current.setMode(mode)
|
||||
} else {
|
||||
await playbackManagerRef.current.updateOptions({ sampleRate, duration })
|
||||
}
|
||||
|
||||
playbackManagerRef.current.stop()
|
||||
playbackManagerRef.current.setEffects(effectValues)
|
||||
playbackManagerRef.current.setVariables(
|
||||
engineValues.a ?? DEFAULT_VARIABLES.a,
|
||||
engineValues.b ?? DEFAULT_VARIABLES.b,
|
||||
engineValues.c ?? DEFAULT_VARIABLES.c,
|
||||
engineValues.d ?? DEFAULT_VARIABLES.d
|
||||
)
|
||||
playbackManagerRef.current.setPitch(engineValues.pitch ?? 1.0)
|
||||
const fmPatch = mode === 'fm' ? JSON.parse(formula) : null
|
||||
const lfoRates = fmPatch?.lfoRates || undefined
|
||||
playbackManagerRef.current.setAlgorithm(engineValues.fmAlgorithm ?? 0, lfoRates)
|
||||
playbackManagerRef.current.setFeedback(engineValues.fmFeedback ?? 0)
|
||||
|
||||
await playbackManagerRef.current.play(formula)
|
||||
setPlaying(id)
|
||||
setQueued(null)
|
||||
return true
|
||||
stop()
|
||||
switchMode(newMode)
|
||||
updateMode(newMode)
|
||||
}
|
||||
|
||||
const handleTileClick = (_formula: string, row: number, col: number, isDoubleClick: boolean = false) => {
|
||||
@ -203,21 +91,17 @@ function App() {
|
||||
|
||||
if (!tile) return
|
||||
|
||||
if (focusedTile === 'custom' || (focusedTile.row !== row || focusedTile.col !== col)) {
|
||||
saveCurrentTileParams()
|
||||
}
|
||||
|
||||
if (tile) {
|
||||
loadTileParams(tile)
|
||||
}
|
||||
setFocusedTile({ row, col })
|
||||
setFocus({ row, col })
|
||||
|
||||
if (isDoubleClick || playing === null) {
|
||||
clearSwitchTimer()
|
||||
playFormula(tile.formula, id)
|
||||
play(tile.formula, id, tile)
|
||||
} else {
|
||||
setQueued(id)
|
||||
startSwitchTimer(id)
|
||||
queue(id, () => {
|
||||
const queuedTile = getTileFromGrid(tiles, row, col)
|
||||
if (queuedTile) {
|
||||
play(queuedTile.formula, id, queuedTile)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@ -225,127 +109,6 @@ function App() {
|
||||
handleTileClick(formula, row, col, true)
|
||||
}
|
||||
|
||||
|
||||
const handleEngineChange = async (parameterId: string, value: number) => {
|
||||
engineSettings.setKey(parameterId as keyof typeof engineValues, value)
|
||||
saveCurrentTileParams()
|
||||
|
||||
if (parameterId === 'masterVolume' && playbackManagerRef.current) {
|
||||
playbackManagerRef.current.setEffects({ ...effectValues, masterVolume: value })
|
||||
}
|
||||
|
||||
if (parameterId === 'pitch' && playbackManagerRef.current && playing) {
|
||||
playbackManagerRef.current.setPitch(value)
|
||||
}
|
||||
|
||||
if (parameterId === 'fmAlgorithm' && playbackManagerRef.current) {
|
||||
playbackManagerRef.current.setAlgorithm(value)
|
||||
}
|
||||
|
||||
if (parameterId === 'fmFeedback' && playbackManagerRef.current) {
|
||||
playbackManagerRef.current.setFeedback(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 keyof typeof effectValues, value as never)
|
||||
saveCurrentTileParams()
|
||||
|
||||
if (playbackManagerRef.current) {
|
||||
playbackManagerRef.current.setEffects(effectValues)
|
||||
}
|
||||
}
|
||||
|
||||
const handleLFOChange = (lfoIndex: number, config: LFOConfig) => {
|
||||
if (playbackManagerRef.current) {
|
||||
playbackManagerRef.current.setLFOConfig(lfoIndex, config)
|
||||
}
|
||||
}
|
||||
|
||||
const handleParameterMapClick = (paramId: string, lfoIndex: number) => {
|
||||
const lfoKey = `lfo${lfoIndex + 1}` as 'lfo1' | 'lfo2' | 'lfo3' | 'lfo4'
|
||||
const currentLFO = lfoSettings.get()[lfoKey]
|
||||
|
||||
const existingMappingIndex = currentLFO.mappings.findIndex(m => m.targetParam === paramId)
|
||||
|
||||
let updatedMappings
|
||||
if (existingMappingIndex >= 0) {
|
||||
updatedMappings = currentLFO.mappings.filter((_, i) => i !== existingMappingIndex)
|
||||
} else {
|
||||
updatedMappings = [...currentLFO.mappings, { targetParam: paramId, depth: 50 }]
|
||||
}
|
||||
|
||||
const updatedLFO = { ...currentLFO, mappings: updatedMappings }
|
||||
lfoSettings.setKey(lfoKey, updatedLFO)
|
||||
|
||||
if (playbackManagerRef.current) {
|
||||
playbackManagerRef.current.setLFOConfig(lfoIndex, updatedLFO)
|
||||
}
|
||||
|
||||
saveCurrentTileParams()
|
||||
|
||||
if (updatedMappings.length === 0 || existingMappingIndex >= 0) {
|
||||
exitMappingMode()
|
||||
}
|
||||
}
|
||||
|
||||
const handleUpdateMappingDepth = (lfoIndex: number, paramId: string, depth: number) => {
|
||||
const lfoKey = `lfo${lfoIndex + 1}` as 'lfo1' | 'lfo2' | 'lfo3' | 'lfo4'
|
||||
const currentLFO = lfoSettings.get()[lfoKey]
|
||||
|
||||
const updatedMappings = currentLFO.mappings.map(m =>
|
||||
m.targetParam === paramId ? { ...m, depth } : m
|
||||
)
|
||||
|
||||
const updatedLFO = { ...currentLFO, mappings: updatedMappings }
|
||||
lfoSettings.setKey(lfoKey, updatedLFO)
|
||||
|
||||
if (playbackManagerRef.current) {
|
||||
playbackManagerRef.current.setLFOConfig(lfoIndex, updatedLFO)
|
||||
}
|
||||
|
||||
saveCurrentTileParams()
|
||||
}
|
||||
|
||||
const handleRemoveMapping = (lfoIndex: number, paramId: string) => {
|
||||
const lfoKey = `lfo${lfoIndex + 1}` as 'lfo1' | 'lfo2' | 'lfo3' | 'lfo4'
|
||||
const currentLFO = lfoSettings.get()[lfoKey]
|
||||
|
||||
const updatedMappings = currentLFO.mappings.filter(m => m.targetParam !== paramId)
|
||||
|
||||
const updatedLFO = { ...currentLFO, mappings: updatedMappings }
|
||||
lfoSettings.setKey(lfoKey, updatedLFO)
|
||||
|
||||
if (playbackManagerRef.current) {
|
||||
playbackManagerRef.current.setLFOConfig(lfoIndex, updatedLFO)
|
||||
}
|
||||
|
||||
saveCurrentTileParams()
|
||||
}
|
||||
|
||||
const getMappedLFOs = (paramId: string): number[] => {
|
||||
const lfos = lfoSettings.get()
|
||||
const mapped: number[] = []
|
||||
|
||||
Object.entries(lfos).forEach(([, lfo], index) => {
|
||||
if (lfo.mappings.some((m: { targetParam: string }) => m.targetParam === paramId)) {
|
||||
mapped.push(index)
|
||||
}
|
||||
})
|
||||
|
||||
return mapped
|
||||
}
|
||||
|
||||
const handleDownloadAll = async () => {
|
||||
setDownloading(true)
|
||||
const formulas = tiles.map(row => row.map(tile => tile.formula))
|
||||
@ -363,47 +126,15 @@ function App() {
|
||||
})
|
||||
}
|
||||
|
||||
const handleRegenerate = (row: number, col: number) => {
|
||||
let newTile: TileState
|
||||
|
||||
if (mode === 'fm') {
|
||||
const patch = generateRandomFMPatch(engineValues.complexity)
|
||||
newTile = createFMTileState(patch)
|
||||
} else {
|
||||
const newFormula = generateRandomFormula(engineValues.complexity)
|
||||
newTile = createTileStateFromCurrent(newFormula)
|
||||
}
|
||||
|
||||
setTiles(prevTiles => {
|
||||
const newTiles = [...prevTiles]
|
||||
newTiles[row] = [...newTiles[row]]
|
||||
newTiles[row][col] = newTile
|
||||
return newTiles
|
||||
})
|
||||
}
|
||||
|
||||
const handleStop = () => {
|
||||
clearSwitchTimer()
|
||||
playbackManagerRef.current?.stop()
|
||||
setPlaying(null)
|
||||
setQueued(null)
|
||||
setPlaybackPosition(0)
|
||||
}
|
||||
|
||||
const handleCustomEvaluate = (formula: string) => {
|
||||
if (focusedTile !== 'custom') {
|
||||
saveCurrentTileParams()
|
||||
loadTileParams(customTile)
|
||||
}
|
||||
|
||||
setFocusedTile('custom')
|
||||
setFocus('custom')
|
||||
setCustomTile({ ...customTile, formula })
|
||||
playFormula(formula, PLAYBACK_ID.CUSTOM)
|
||||
play(formula, PLAYBACK_ID.CUSTOM, { ...customTile, formula })
|
||||
}
|
||||
|
||||
const handleCustomStop = () => {
|
||||
if (playing === PLAYBACK_ID.CUSTOM) {
|
||||
handleStop()
|
||||
stop()
|
||||
}
|
||||
}
|
||||
|
||||
@ -411,44 +142,9 @@ function App() {
|
||||
return generateRandomFormula(engineValues.complexity)
|
||||
}
|
||||
|
||||
const moveFocus = (direction: 'up' | 'down' | 'left' | 'right', step: number = 1) => {
|
||||
saveCurrentTileParams()
|
||||
|
||||
setFocusedTile(prev => {
|
||||
if (prev === 'custom') return prev
|
||||
|
||||
let { row, col } = prev
|
||||
const maxRow = tiles.length - 1
|
||||
const maxCol = (tiles[row]?.length || 1) - 1
|
||||
|
||||
switch (direction) {
|
||||
case 'up':
|
||||
row = Math.max(0, row - step)
|
||||
break
|
||||
case 'down':
|
||||
row = Math.min(maxRow, row + step)
|
||||
break
|
||||
case 'left':
|
||||
col = Math.max(0, col - step)
|
||||
break
|
||||
case 'right':
|
||||
col = Math.min(maxCol, col + step)
|
||||
break
|
||||
}
|
||||
|
||||
const newTile = tiles[row]?.[col]
|
||||
if (newTile) {
|
||||
loadTileParams(newTile)
|
||||
return { row, col }
|
||||
}
|
||||
|
||||
return prev
|
||||
})
|
||||
}
|
||||
|
||||
const handleKeyboardSpace = () => {
|
||||
if (playing) {
|
||||
handleStop()
|
||||
stop()
|
||||
} else if (focusedTile !== 'custom') {
|
||||
const tile = tiles[focusedTile.row]?.[focusedTile.col]
|
||||
if (tile) {
|
||||
@ -477,79 +173,15 @@ function App() {
|
||||
|
||||
const handleKeyboardR = () => {
|
||||
if (focusedTile !== 'custom') {
|
||||
handleRegenerate(focusedTile.row, focusedTile.col)
|
||||
regenerateTile(focusedTile.row, focusedTile.col)
|
||||
}
|
||||
}
|
||||
|
||||
const handleKeyboardShiftR = () => {
|
||||
handleRandom()
|
||||
}
|
||||
|
||||
const handleEscape = () => {
|
||||
exitMappingMode()
|
||||
}
|
||||
|
||||
const handleKeyboardC = () => {
|
||||
if (focusedTile === 'custom') {
|
||||
setCustomTile(prev => {
|
||||
const randomized = randomizeTileParams(prev)
|
||||
loadTileParams(randomized)
|
||||
|
||||
if (playing === PLAYBACK_ID.CUSTOM && playbackManagerRef.current) {
|
||||
playbackManagerRef.current.setEffects(randomized.effectParams)
|
||||
playbackManagerRef.current.setVariables(
|
||||
randomized.engineParams.a ?? DEFAULT_VARIABLES.a,
|
||||
randomized.engineParams.b ?? DEFAULT_VARIABLES.b,
|
||||
randomized.engineParams.c ?? DEFAULT_VARIABLES.c,
|
||||
randomized.engineParams.d ?? DEFAULT_VARIABLES.d
|
||||
)
|
||||
playbackManagerRef.current.setPitch(randomized.engineParams.pitch ?? 1.0)
|
||||
|
||||
if (randomized.lfoConfigs) {
|
||||
playbackManagerRef.current.setLFOConfig(0, randomized.lfoConfigs.lfo1)
|
||||
playbackManagerRef.current.setLFOConfig(1, randomized.lfoConfigs.lfo2)
|
||||
playbackManagerRef.current.setLFOConfig(2, randomized.lfoConfigs.lfo3)
|
||||
playbackManagerRef.current.setLFOConfig(3, randomized.lfoConfigs.lfo4)
|
||||
}
|
||||
}
|
||||
|
||||
return randomized
|
||||
})
|
||||
} else {
|
||||
const tileId = getTileId(focusedTile.row, focusedTile.col)
|
||||
setTiles(prevTiles => {
|
||||
const newTiles = [...prevTiles]
|
||||
newTiles[focusedTile.row] = [...newTiles[focusedTile.row]]
|
||||
const randomized = randomizeTileParams(newTiles[focusedTile.row][focusedTile.col])
|
||||
newTiles[focusedTile.row][focusedTile.col] = randomized
|
||||
|
||||
loadTileParams(randomized)
|
||||
|
||||
if (playing === tileId && playbackManagerRef.current) {
|
||||
playbackManagerRef.current.setEffects(randomized.effectParams)
|
||||
playbackManagerRef.current.setVariables(
|
||||
randomized.engineParams.a ?? DEFAULT_VARIABLES.a,
|
||||
randomized.engineParams.b ?? DEFAULT_VARIABLES.b,
|
||||
randomized.engineParams.c ?? DEFAULT_VARIABLES.c,
|
||||
randomized.engineParams.d ?? DEFAULT_VARIABLES.d
|
||||
)
|
||||
playbackManagerRef.current.setPitch(randomized.engineParams.pitch ?? 1.0)
|
||||
|
||||
if (randomized.lfoConfigs) {
|
||||
playbackManagerRef.current.setLFOConfig(0, randomized.lfoConfigs.lfo1)
|
||||
playbackManagerRef.current.setLFOConfig(1, randomized.lfoConfigs.lfo2)
|
||||
playbackManagerRef.current.setLFOConfig(2, randomized.lfoConfigs.lfo3)
|
||||
playbackManagerRef.current.setLFOConfig(3, randomized.lfoConfigs.lfo4)
|
||||
}
|
||||
}
|
||||
|
||||
return newTiles
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const handleKeyboardShiftC = () => {
|
||||
handleRandomizeAllParams()
|
||||
const tileId = focusedTile === 'custom'
|
||||
? PLAYBACK_ID.CUSTOM
|
||||
: getTileId(focusedTile.row, focusedTile.col)
|
||||
randomizeParams(tileId)
|
||||
}
|
||||
|
||||
const handleDismissWarning = () => {
|
||||
@ -565,21 +197,12 @@ function App() {
|
||||
onEnter: handleKeyboardEnter,
|
||||
onDoubleEnter: handleKeyboardDoubleEnter,
|
||||
onR: handleKeyboardR,
|
||||
onShiftR: handleKeyboardShiftR,
|
||||
onShiftR: handleRandom,
|
||||
onC: handleKeyboardC,
|
||||
onShiftC: handleKeyboardShiftC,
|
||||
onEscape: handleEscape
|
||||
onShiftC: randomizeAllParams,
|
||||
onEscape: exitMappingMode
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
if (focusedTile !== 'custom') {
|
||||
const element = document.querySelector(`[data-tile-id="${focusedTile.row}-${focusedTile.col}"]`)
|
||||
if (element) {
|
||||
element.scrollIntoView({ behavior: 'smooth', block: 'nearest' })
|
||||
}
|
||||
}
|
||||
}, [focusedTile])
|
||||
|
||||
return (
|
||||
<div className="w-screen h-screen flex flex-col bg-black overflow-hidden">
|
||||
{showWarning && <AudioContextWarning onDismiss={handleDismissWarning} />}
|
||||
@ -655,7 +278,7 @@ function App() {
|
||||
{mobileHeaderTab === 'global' && (
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={handleStop}
|
||||
onClick={stop}
|
||||
disabled={!playing}
|
||||
className="flex-1 px-2 py-2 bg-black text-white border-2 border-white font-mono text-[9px] tracking-[0.2em] hover:bg-white hover:text-black transition-all disabled:opacity-30 disabled:cursor-not-allowed"
|
||||
>
|
||||
@ -668,7 +291,7 @@ function App() {
|
||||
<Dices size={12} strokeWidth={2} className="mx-auto" />
|
||||
</button>
|
||||
<button
|
||||
onClick={handleRandomizeAllParams}
|
||||
onClick={randomizeAllParams}
|
||||
className="flex-1 px-2 py-2 bg-white text-black font-mono text-[9px] tracking-[0.2em] hover:bg-black hover:text-white border-2 border-white transition-all"
|
||||
>
|
||||
<Sparkles size={12} strokeWidth={2} className="mx-auto" />
|
||||
@ -746,7 +369,7 @@ function App() {
|
||||
</div>
|
||||
<div className="flex gap-3 flex-shrink-0">
|
||||
<button
|
||||
onClick={handleStop}
|
||||
onClick={stop}
|
||||
disabled={!playing}
|
||||
className="px-4 py-2 bg-black text-white border-2 border-white font-mono text-[10px] tracking-[0.2em] hover:bg-white hover:text-black transition-all disabled:opacity-30 disabled:cursor-not-allowed flex items-center gap-1"
|
||||
>
|
||||
@ -761,7 +384,7 @@ function App() {
|
||||
RANDOM
|
||||
</button>
|
||||
<button
|
||||
onClick={handleRandomizeAllParams}
|
||||
onClick={randomizeAllParams}
|
||||
className="px-4 py-2 bg-white text-black font-mono text-[10px] tracking-[0.2em] hover:bg-black hover:text-white border-2 border-white transition-all flex items-center gap-1"
|
||||
>
|
||||
<Sparkles size={12} strokeWidth={2} />
|
||||
@ -819,7 +442,7 @@ function App() {
|
||||
onPlay={handleTileClick}
|
||||
onDoubleClick={handleTileDoubleClick}
|
||||
onDownload={handleDownloadFormula}
|
||||
onRegenerate={handleRegenerate}
|
||||
onRegenerate={regenerateTile}
|
||||
/>
|
||||
)
|
||||
})
|
||||
|
||||
@ -1,10 +1,10 @@
|
||||
import { useState } from 'react'
|
||||
import { Dices } from 'lucide-react'
|
||||
import { Slider } from './Slider'
|
||||
import { Switch } from './Switch'
|
||||
import { Dropdown } from './Dropdown'
|
||||
import { EFFECTS } from '../config/effects'
|
||||
import type { EffectValues } from '../types/effects'
|
||||
import { Slider } from '../ui/Slider'
|
||||
import { Switch } from '../ui/Switch'
|
||||
import { Dropdown } from '../ui/Dropdown'
|
||||
import { EFFECTS } from '../../config/parameters'
|
||||
import type { EffectValues } from '../../types/effects'
|
||||
|
||||
interface EffectsBarProps {
|
||||
values: EffectValues
|
||||
@ -1,9 +1,9 @@
|
||||
import { useStore } from '@nanostores/react'
|
||||
import { ENGINE_CONTROLS } from '../config/effects'
|
||||
import { getComplexityLabel, getBitDepthLabel, getSampleRateLabel, getAlgorithmLabel } from '../utils/formatters'
|
||||
import type { EffectValues } from '../types/effects'
|
||||
import { Knob } from './Knob'
|
||||
import { synthesisMode } from '../stores/synthesisMode'
|
||||
import { ENGINE_CONTROLS } from '../../config/parameters'
|
||||
import { getComplexityLabel, getBitDepthLabel, getSampleRateLabel, getAlgorithmLabel } from '../../utils/formatters'
|
||||
import type { EffectValues } from '../../types/effects'
|
||||
import { Knob } from '../ui/Knob'
|
||||
import { synthesisMode } from '../../stores/synthesisMode'
|
||||
|
||||
interface EngineControlsProps {
|
||||
values: EffectValues
|
||||
@ -1,9 +1,9 @@
|
||||
import { useStore } from '@nanostores/react'
|
||||
import { lfoSettings } from '../stores/settings'
|
||||
import { toggleMappingMode } from '../stores/mappingMode'
|
||||
import { LFOScope } from './LFOScope'
|
||||
import type { LFOConfig } from '../stores/settings'
|
||||
import type { LFOWaveform } from '../domain/modulation/LFO'
|
||||
import { lfoSettings } from '../../stores/settings'
|
||||
import { toggleMappingMode } from '../../stores/mappingMode'
|
||||
import { LFOScope } from '../scopes/LFOScope'
|
||||
import type { LFOConfig } from '../../stores/settings'
|
||||
import type { LFOWaveform } from '../../domain/modulation/LFO'
|
||||
|
||||
interface LFOPanelProps {
|
||||
onChange: (lfoIndex: number, config: LFOConfig) => void
|
||||
@ -1,4 +1,4 @@
|
||||
import { parameterRegistry } from '../domain/modulation/ParameterRegistry'
|
||||
import { parameterRegistry } from '../../domain/modulation/ParameterRegistry'
|
||||
|
||||
interface Mapping {
|
||||
targetParam: string
|
||||
22
src/components/index.ts
Normal file
22
src/components/index.ts
Normal file
@ -0,0 +1,22 @@
|
||||
// UI Components
|
||||
export { Knob } from './ui/Knob'
|
||||
export { Slider } from './ui/Slider'
|
||||
export { Switch } from './ui/Switch'
|
||||
export { Dropdown } from './ui/Dropdown'
|
||||
|
||||
// Tile Components
|
||||
export { BytebeatTile } from './tile/BytebeatTile'
|
||||
export { FormulaEditor } from './tile/FormulaEditor'
|
||||
|
||||
// Control Components
|
||||
export { EngineControls } from './controls/EngineControls'
|
||||
export { EffectsBar } from './controls/EffectsBar'
|
||||
export { LFOPanel } from './controls/LFOPanel'
|
||||
export { MappingEditor } from './controls/MappingEditor'
|
||||
|
||||
// Modal Components
|
||||
export { AudioContextWarning } from './modals/AudioContextWarning'
|
||||
export { HelpModal } from './modals/HelpModal'
|
||||
|
||||
// Scope Components
|
||||
export { LFOScope } from './scopes/LFOScope'
|
||||
@ -1,9 +1,9 @@
|
||||
import { useEffect, useRef, useState, useCallback } from 'react'
|
||||
import { useStore } from '@nanostores/react'
|
||||
import { LFO, type LFOWaveform } from '../domain/modulation/LFO'
|
||||
import { mappingMode } from '../stores/mappingMode'
|
||||
import { parameterRegistry } from '../domain/modulation/ParameterRegistry'
|
||||
import { MappingEditor } from './MappingEditor'
|
||||
import { LFO, type LFOWaveform } from '../../domain/modulation/LFO'
|
||||
import { mappingMode } from '../../stores/mappingMode'
|
||||
import { parameterRegistry } from '../../domain/modulation/ParameterRegistry'
|
||||
import { MappingEditor } from '../controls/MappingEditor'
|
||||
|
||||
interface LFOScopeProps {
|
||||
lfoIndex: number
|
||||
@ -1,11 +1,11 @@
|
||||
import { useRef, useEffect } from 'react'
|
||||
import { useStore } from '@nanostores/react'
|
||||
import { Download, Dices } from 'lucide-react'
|
||||
import { generateWaveformData, drawWaveform } from '../utils/waveformGenerator'
|
||||
import { generateFMWaveformData } from '../utils/fmWaveformGenerator'
|
||||
import { synthesisMode } from '../stores/synthesisMode'
|
||||
import { parseFMPatch } from '../utils/fmPatches'
|
||||
import { getAlgorithmName } from '../config/fmAlgorithms'
|
||||
import { generateWaveformData, drawWaveform } from '../../utils/waveformGenerator'
|
||||
import { generateFMWaveformData } from '../../utils/fmWaveformGenerator'
|
||||
import { synthesisMode } from '../../stores/synthesisMode'
|
||||
import { parseFMPatch } from '../../utils/fmPatches'
|
||||
import { getAlgorithmName } from '../../config/fmAlgorithms'
|
||||
|
||||
interface BytebeatTileProps {
|
||||
formula: string
|
||||
@ -1,6 +1,6 @@
|
||||
import { useRef, useState, useEffect, useCallback } from 'react'
|
||||
import { useStore } from '@nanostores/react'
|
||||
import { mappingMode } from '../stores/mappingMode'
|
||||
import { mappingMode } from '../../stores/mappingMode'
|
||||
|
||||
interface KnobProps {
|
||||
label: string
|
||||
@ -1,5 +1,5 @@
|
||||
import { useStore } from '@nanostores/react'
|
||||
import { mappingMode } from '../stores/mappingMode'
|
||||
import { mappingMode } from '../../stores/mappingMode'
|
||||
|
||||
interface SliderProps {
|
||||
label: string
|
||||
2
src/config/index.ts
Normal file
2
src/config/index.ts
Normal file
@ -0,0 +1,2 @@
|
||||
export { ENGINE_CONTROLS, EFFECTS, SAMPLE_RATES, getDefaultEngineValues, getDefaultEffectValues, getSampleRateFromIndex } from './parameters'
|
||||
export { FM_ALGORITHMS, getAlgorithmById, getAlgorithmName } from './fmAlgorithms'
|
||||
@ -1,6 +1,6 @@
|
||||
import type { EffectConfig } from '../types/effects'
|
||||
import type { ParameterGroup } from '../types/parameters'
|
||||
|
||||
export const ENGINE_CONTROLS: EffectConfig[] = [
|
||||
export const ENGINE_CONTROLS: ParameterGroup[] = [
|
||||
{
|
||||
id: 'engine',
|
||||
name: 'Engine',
|
||||
@ -117,7 +117,7 @@ export const ENGINE_CONTROLS: EffectConfig[] = [
|
||||
}
|
||||
]
|
||||
|
||||
export const EFFECTS: EffectConfig[] = [
|
||||
export const EFFECTS: ParameterGroup[] = [
|
||||
{
|
||||
id: 'filter',
|
||||
name: 'Filter',
|
||||
@ -153,11 +153,11 @@ export class AudioPlayer {
|
||||
this.currentMode = mode
|
||||
}
|
||||
|
||||
setAlgorithm(algorithmId: number, lfoRates?: number[]): void {
|
||||
setAlgorithm(algorithmId: number, lfoRates?: number[], pitchLFO?: { waveform: number; depth: number; baseRate: number }): void {
|
||||
this.currentAlgorithm = algorithmId
|
||||
if (this.fmSource) {
|
||||
const algorithm = getAlgorithmById(algorithmId)
|
||||
this.fmSource.setAlgorithm(algorithm, lfoRates)
|
||||
this.fmSource.setAlgorithm(algorithm, lfoRates, pitchLFO)
|
||||
}
|
||||
}
|
||||
|
||||
@ -194,7 +194,8 @@ export class AudioPlayer {
|
||||
const algorithm = getAlgorithmById(this.currentAlgorithm)
|
||||
const patch = formula ? JSON.parse(formula) : null
|
||||
const lfoRates = patch?.lfoRates || undefined
|
||||
this.fmSource.setAlgorithm(algorithm, lfoRates)
|
||||
const pitchLFO = patch?.pitchLFO || undefined
|
||||
this.fmSource.setAlgorithm(algorithm, lfoRates, pitchLFO)
|
||||
this.fmSource.setOperatorLevels(a, b, c, d)
|
||||
this.fmSource.setBaseFrequency(220 * this.currentPitch)
|
||||
this.fmSource.setFeedback(this.currentFeedback)
|
||||
|
||||
@ -34,14 +34,15 @@ export class FMSourceEffect implements Effect {
|
||||
// Parameters handled via specific methods
|
||||
}
|
||||
|
||||
setAlgorithm(algorithm: FMAlgorithm, lfoRates?: number[]): void {
|
||||
setAlgorithm(algorithm: FMAlgorithm, lfoRates?: number[], pitchLFO?: { waveform: number; depth: number; baseRate: number }): void {
|
||||
if (!this.processorNode) return
|
||||
this.processorNode.port.postMessage({
|
||||
type: 'algorithm',
|
||||
value: {
|
||||
id: algorithm.id,
|
||||
ratios: algorithm.frequencyRatios,
|
||||
lfoRates: lfoRates || [0.37, 0.53, 0.71, 0.43]
|
||||
lfoRates: lfoRates || [0.37, 0.53, 0.71, 0.43],
|
||||
pitchLFO: pitchLFO || { waveform: 0, depth: 0.1, baseRate: 2.0 }
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
import { ENGINE_CONTROLS, EFFECTS } from '../../config/effects'
|
||||
import { ENGINE_CONTROLS, EFFECTS } from '../../config/parameters'
|
||||
import type { EffectParameter } from '../../types/effects'
|
||||
|
||||
export type ParameterScaling = 'linear' | 'exponential'
|
||||
|
||||
7
src/hooks/index.ts
Normal file
7
src/hooks/index.ts
Normal file
@ -0,0 +1,7 @@
|
||||
export { useKeyboardShortcuts } from './useKeyboardShortcuts'
|
||||
export { useTileParams } from './useTileParams'
|
||||
export { useTileGrid } from './useTileGrid'
|
||||
export { usePlaybackControl } from './usePlaybackControl'
|
||||
export { useFocusNavigation } from './useFocusNavigation'
|
||||
export { useParameterSync } from './useParameterSync'
|
||||
export { useLFOMapping } from './useLFOMapping'
|
||||
66
src/hooks/useFocusNavigation.ts
Normal file
66
src/hooks/useFocusNavigation.ts
Normal file
@ -0,0 +1,66 @@
|
||||
import { useState, useEffect, useCallback } from 'react'
|
||||
import type { TileState } from '../types/tiles'
|
||||
import type { FocusedTile } from '../utils/tileHelpers'
|
||||
|
||||
interface UseFocusNavigationProps {
|
||||
tiles: TileState[][]
|
||||
onFocusChange?: (tile: FocusedTile) => void
|
||||
}
|
||||
|
||||
export function useFocusNavigation({ tiles, onFocusChange }: UseFocusNavigationProps) {
|
||||
const [focusedTile, setFocusedTile] = useState<FocusedTile>({ row: 0, col: 0 })
|
||||
|
||||
const moveFocus = useCallback((direction: 'up' | 'down' | 'left' | 'right', step: number = 1) => {
|
||||
setFocusedTile(prev => {
|
||||
if (prev === 'custom') return prev
|
||||
|
||||
let { row, col } = prev
|
||||
const maxRow = tiles.length - 1
|
||||
const maxCol = (tiles[row]?.length || 1) - 1
|
||||
|
||||
switch (direction) {
|
||||
case 'up':
|
||||
row = Math.max(0, row - step)
|
||||
break
|
||||
case 'down':
|
||||
row = Math.min(maxRow, row + step)
|
||||
break
|
||||
case 'left':
|
||||
col = Math.max(0, col - step)
|
||||
break
|
||||
case 'right':
|
||||
col = Math.min(maxCol, col + step)
|
||||
break
|
||||
}
|
||||
|
||||
const newTile = tiles[row]?.[col]
|
||||
if (newTile) {
|
||||
const newFocus = { row, col }
|
||||
onFocusChange?.(newFocus)
|
||||
return newFocus
|
||||
}
|
||||
|
||||
return prev
|
||||
})
|
||||
}, [tiles, onFocusChange])
|
||||
|
||||
const setFocus = useCallback((tile: FocusedTile) => {
|
||||
setFocusedTile(tile)
|
||||
onFocusChange?.(tile)
|
||||
}, [onFocusChange])
|
||||
|
||||
useEffect(() => {
|
||||
if (focusedTile !== 'custom') {
|
||||
const element = document.querySelector(`[data-tile-id="${focusedTile.row}-${focusedTile.col}"]`)
|
||||
if (element) {
|
||||
element.scrollIntoView({ behavior: 'smooth', block: 'nearest' })
|
||||
}
|
||||
}
|
||||
}, [focusedTile])
|
||||
|
||||
return {
|
||||
focusedTile,
|
||||
setFocus,
|
||||
moveFocus
|
||||
}
|
||||
}
|
||||
100
src/hooks/useLFOMapping.ts
Normal file
100
src/hooks/useLFOMapping.ts
Normal file
@ -0,0 +1,100 @@
|
||||
import { useCallback } from 'react'
|
||||
import { lfoSettings, type LFOConfig } from '../stores/settings'
|
||||
import { exitMappingMode } from '../stores/mappingMode'
|
||||
import type { PlaybackManager } from '../services/PlaybackManager'
|
||||
|
||||
interface UseLFOMappingProps {
|
||||
playbackManager: React.MutableRefObject<PlaybackManager | null>
|
||||
saveCurrentParams: () => void
|
||||
}
|
||||
|
||||
export function useLFOMapping({ playbackManager, saveCurrentParams }: UseLFOMappingProps) {
|
||||
|
||||
const handleLFOChange = useCallback((lfoIndex: number, config: LFOConfig) => {
|
||||
if (playbackManager.current) {
|
||||
playbackManager.current.setLFOConfig(lfoIndex, config)
|
||||
}
|
||||
}, [playbackManager])
|
||||
|
||||
const handleParameterMapClick = useCallback((paramId: string, lfoIndex: number) => {
|
||||
const lfoKey = `lfo${lfoIndex + 1}` as 'lfo1' | 'lfo2' | 'lfo3' | 'lfo4'
|
||||
const currentLFO = lfoSettings.get()[lfoKey]
|
||||
|
||||
const existingMappingIndex = currentLFO.mappings.findIndex(m => m.targetParam === paramId)
|
||||
|
||||
let updatedMappings
|
||||
if (existingMappingIndex >= 0) {
|
||||
updatedMappings = currentLFO.mappings.filter((_, i) => i !== existingMappingIndex)
|
||||
} else {
|
||||
updatedMappings = [...currentLFO.mappings, { targetParam: paramId, depth: 50 }]
|
||||
}
|
||||
|
||||
const updatedLFO = { ...currentLFO, mappings: updatedMappings }
|
||||
lfoSettings.setKey(lfoKey, updatedLFO)
|
||||
|
||||
if (playbackManager.current) {
|
||||
playbackManager.current.setLFOConfig(lfoIndex, updatedLFO)
|
||||
}
|
||||
|
||||
saveCurrentParams()
|
||||
|
||||
if (updatedMappings.length === 0 || existingMappingIndex >= 0) {
|
||||
exitMappingMode()
|
||||
}
|
||||
}, [playbackManager, saveCurrentParams])
|
||||
|
||||
const handleUpdateMappingDepth = useCallback((lfoIndex: number, paramId: string, depth: number) => {
|
||||
const lfoKey = `lfo${lfoIndex + 1}` as 'lfo1' | 'lfo2' | 'lfo3' | 'lfo4'
|
||||
const currentLFO = lfoSettings.get()[lfoKey]
|
||||
|
||||
const updatedMappings = currentLFO.mappings.map(m =>
|
||||
m.targetParam === paramId ? { ...m, depth } : m
|
||||
)
|
||||
|
||||
const updatedLFO = { ...currentLFO, mappings: updatedMappings }
|
||||
lfoSettings.setKey(lfoKey, updatedLFO)
|
||||
|
||||
if (playbackManager.current) {
|
||||
playbackManager.current.setLFOConfig(lfoIndex, updatedLFO)
|
||||
}
|
||||
|
||||
saveCurrentParams()
|
||||
}, [playbackManager, saveCurrentParams])
|
||||
|
||||
const handleRemoveMapping = useCallback((lfoIndex: number, paramId: string) => {
|
||||
const lfoKey = `lfo${lfoIndex + 1}` as 'lfo1' | 'lfo2' | 'lfo3' | 'lfo4'
|
||||
const currentLFO = lfoSettings.get()[lfoKey]
|
||||
|
||||
const updatedMappings = currentLFO.mappings.filter(m => m.targetParam !== paramId)
|
||||
|
||||
const updatedLFO = { ...currentLFO, mappings: updatedMappings }
|
||||
lfoSettings.setKey(lfoKey, updatedLFO)
|
||||
|
||||
if (playbackManager.current) {
|
||||
playbackManager.current.setLFOConfig(lfoIndex, updatedLFO)
|
||||
}
|
||||
|
||||
saveCurrentParams()
|
||||
}, [playbackManager, saveCurrentParams])
|
||||
|
||||
const getMappedLFOs = useCallback((paramId: string): number[] => {
|
||||
const lfos = lfoSettings.get()
|
||||
const mapped: number[] = []
|
||||
|
||||
Object.entries(lfos).forEach(([, lfo], index) => {
|
||||
if (lfo.mappings.some((m: { targetParam: string }) => m.targetParam === paramId)) {
|
||||
mapped.push(index)
|
||||
}
|
||||
})
|
||||
|
||||
return mapped
|
||||
}, [])
|
||||
|
||||
return {
|
||||
handleLFOChange,
|
||||
handleParameterMapClick,
|
||||
handleUpdateMappingDepth,
|
||||
handleRemoveMapping,
|
||||
getMappedLFOs
|
||||
}
|
||||
}
|
||||
212
src/hooks/useParameterSync.ts
Normal file
212
src/hooks/useParameterSync.ts
Normal file
@ -0,0 +1,212 @@
|
||||
import { useCallback } from 'react'
|
||||
import type { TileState } from '../types/tiles'
|
||||
import type { FocusedTile } from '../utils/tileHelpers'
|
||||
import { engineSettings, effectSettings } from '../stores/settings'
|
||||
import { loadTileParams, saveTileParams, randomizeTileParams } from '../utils/tileState'
|
||||
import { getTileFromGrid } from '../utils/tileHelpers'
|
||||
import { DEFAULT_VARIABLES } from '../constants/defaults'
|
||||
import type { PlaybackManager } from '../services/PlaybackManager'
|
||||
|
||||
interface UseParameterSyncProps {
|
||||
tiles: TileState[][]
|
||||
setTiles: React.Dispatch<React.SetStateAction<TileState[][]>>
|
||||
customTile: TileState
|
||||
setCustomTile: React.Dispatch<React.SetStateAction<TileState>>
|
||||
focusedTile: FocusedTile
|
||||
playbackManager: React.MutableRefObject<PlaybackManager | null>
|
||||
playing: string | null
|
||||
playbackId: string
|
||||
}
|
||||
|
||||
export function useParameterSync({
|
||||
tiles,
|
||||
setTiles,
|
||||
customTile,
|
||||
setCustomTile,
|
||||
focusedTile,
|
||||
playbackManager,
|
||||
playing,
|
||||
playbackId
|
||||
}: UseParameterSyncProps) {
|
||||
|
||||
const saveCurrentParams = 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])
|
||||
|
||||
const loadParams = useCallback((tile: TileState) => {
|
||||
loadTileParams(tile)
|
||||
}, [])
|
||||
|
||||
const handleEngineChange = useCallback((parameterId: string, value: number) => {
|
||||
engineSettings.setKey(parameterId as any, value)
|
||||
saveCurrentParams()
|
||||
|
||||
if (parameterId === 'masterVolume' && playbackManager.current) {
|
||||
const effectValues = effectSettings.get()
|
||||
playbackManager.current.setEffects({ ...effectValues, masterVolume: value })
|
||||
}
|
||||
|
||||
if (parameterId === 'pitch' && playbackManager.current && playing) {
|
||||
playbackManager.current.setPitch(value)
|
||||
}
|
||||
|
||||
if (parameterId === 'fmAlgorithm' && playbackManager.current) {
|
||||
playbackManager.current.setAlgorithm(value)
|
||||
}
|
||||
|
||||
if (parameterId === 'fmFeedback' && playbackManager.current) {
|
||||
playbackManager.current.setFeedback(value)
|
||||
}
|
||||
|
||||
if (['a', 'b', 'c', 'd'].includes(parameterId) && playbackManager.current && playing) {
|
||||
const engineValues = engineSettings.get()
|
||||
const updatedValues = { ...engineValues, [parameterId]: value }
|
||||
playbackManager.current.setVariables(
|
||||
updatedValues.a ?? DEFAULT_VARIABLES.a,
|
||||
updatedValues.b ?? DEFAULT_VARIABLES.b,
|
||||
updatedValues.c ?? DEFAULT_VARIABLES.c,
|
||||
updatedValues.d ?? DEFAULT_VARIABLES.d
|
||||
)
|
||||
}
|
||||
}, [saveCurrentParams, playbackManager, playing])
|
||||
|
||||
const handleEffectChange = useCallback((parameterId: string, value: number | boolean | string) => {
|
||||
effectSettings.setKey(parameterId as any, value as never)
|
||||
saveCurrentParams()
|
||||
|
||||
if (playbackManager.current) {
|
||||
playbackManager.current.setEffects(effectSettings.get())
|
||||
}
|
||||
}, [saveCurrentParams, playbackManager])
|
||||
|
||||
const randomizeParams = useCallback((tileId: string) => {
|
||||
if (focusedTile === 'custom') {
|
||||
setCustomTile(prev => {
|
||||
const randomized = randomizeTileParams(prev)
|
||||
loadTileParams(randomized)
|
||||
|
||||
if (playing === playbackId && playbackManager.current) {
|
||||
playbackManager.current.setEffects(randomized.effectParams)
|
||||
playbackManager.current.setVariables(
|
||||
randomized.engineParams.a ?? DEFAULT_VARIABLES.a,
|
||||
randomized.engineParams.b ?? DEFAULT_VARIABLES.b,
|
||||
randomized.engineParams.c ?? DEFAULT_VARIABLES.c,
|
||||
randomized.engineParams.d ?? DEFAULT_VARIABLES.d
|
||||
)
|
||||
playbackManager.current.setPitch(randomized.engineParams.pitch ?? 1.0)
|
||||
|
||||
if (randomized.lfoConfigs) {
|
||||
playbackManager.current.setLFOConfig(0, randomized.lfoConfigs.lfo1)
|
||||
playbackManager.current.setLFOConfig(1, randomized.lfoConfigs.lfo2)
|
||||
playbackManager.current.setLFOConfig(2, randomized.lfoConfigs.lfo3)
|
||||
playbackManager.current.setLFOConfig(3, randomized.lfoConfigs.lfo4)
|
||||
}
|
||||
}
|
||||
|
||||
return randomized
|
||||
})
|
||||
} else {
|
||||
setTiles(prevTiles => {
|
||||
const newTiles = [...prevTiles]
|
||||
newTiles[focusedTile.row] = [...newTiles[focusedTile.row]]
|
||||
const randomized = randomizeTileParams(newTiles[focusedTile.row][focusedTile.col])
|
||||
newTiles[focusedTile.row][focusedTile.col] = randomized
|
||||
|
||||
loadTileParams(randomized)
|
||||
|
||||
if (playing === tileId && playbackManager.current) {
|
||||
playbackManager.current.setEffects(randomized.effectParams)
|
||||
playbackManager.current.setVariables(
|
||||
randomized.engineParams.a ?? DEFAULT_VARIABLES.a,
|
||||
randomized.engineParams.b ?? DEFAULT_VARIABLES.b,
|
||||
randomized.engineParams.c ?? DEFAULT_VARIABLES.c,
|
||||
randomized.engineParams.d ?? DEFAULT_VARIABLES.d
|
||||
)
|
||||
playbackManager.current.setPitch(randomized.engineParams.pitch ?? 1.0)
|
||||
|
||||
if (randomized.lfoConfigs) {
|
||||
playbackManager.current.setLFOConfig(0, randomized.lfoConfigs.lfo1)
|
||||
playbackManager.current.setLFOConfig(1, randomized.lfoConfigs.lfo2)
|
||||
playbackManager.current.setLFOConfig(2, randomized.lfoConfigs.lfo3)
|
||||
playbackManager.current.setLFOConfig(3, randomized.lfoConfigs.lfo4)
|
||||
}
|
||||
}
|
||||
|
||||
return newTiles
|
||||
})
|
||||
}
|
||||
}, [focusedTile, setCustomTile, setTiles, playing, playbackId, playbackManager])
|
||||
|
||||
const randomizeAllParams = useCallback(() => {
|
||||
if (!playbackManager.current) return
|
||||
|
||||
let playingTile: TileState | undefined
|
||||
|
||||
if (playing === playbackId) {
|
||||
setCustomTile(prev => {
|
||||
const randomized = randomizeTileParams(prev)
|
||||
playingTile = randomized
|
||||
return randomized
|
||||
})
|
||||
} else {
|
||||
setTiles(prevTiles => {
|
||||
const newTiles = prevTiles.map((row, rowIdx) =>
|
||||
row.map((tile, colIdx) => {
|
||||
const randomized = randomizeTileParams(tile)
|
||||
if (playing && focusedTile !== 'custom') {
|
||||
const tileId = `${focusedTile.row}-${focusedTile.col}`
|
||||
if (playing === tileId && rowIdx === focusedTile.row && colIdx === focusedTile.col) {
|
||||
playingTile = randomized
|
||||
}
|
||||
}
|
||||
return randomized
|
||||
})
|
||||
)
|
||||
return newTiles
|
||||
})
|
||||
|
||||
setCustomTile(prev => randomizeTileParams(prev))
|
||||
}
|
||||
|
||||
if (playingTile) {
|
||||
loadTileParams(playingTile)
|
||||
playbackManager.current.setEffects(playingTile.effectParams)
|
||||
playbackManager.current.setVariables(
|
||||
playingTile.engineParams.a ?? DEFAULT_VARIABLES.a,
|
||||
playingTile.engineParams.b ?? DEFAULT_VARIABLES.b,
|
||||
playingTile.engineParams.c ?? DEFAULT_VARIABLES.c,
|
||||
playingTile.engineParams.d ?? DEFAULT_VARIABLES.d
|
||||
)
|
||||
playbackManager.current.setPitch(playingTile.engineParams.pitch ?? 1.0)
|
||||
|
||||
if (playingTile.lfoConfigs) {
|
||||
playbackManager.current.setLFOConfig(0, playingTile.lfoConfigs.lfo1)
|
||||
playbackManager.current.setLFOConfig(1, playingTile.lfoConfigs.lfo2)
|
||||
playbackManager.current.setLFOConfig(2, playingTile.lfoConfigs.lfo3)
|
||||
playbackManager.current.setLFOConfig(3, playingTile.lfoConfigs.lfo4)
|
||||
}
|
||||
}
|
||||
}, [playing, playbackId, setCustomTile, setTiles, focusedTile, playbackManager])
|
||||
|
||||
return {
|
||||
saveCurrentParams,
|
||||
loadParams,
|
||||
handleEngineChange,
|
||||
handleEffectChange,
|
||||
randomizeParams,
|
||||
randomizeAllParams
|
||||
}
|
||||
}
|
||||
126
src/hooks/usePlaybackControl.ts
Normal file
126
src/hooks/usePlaybackControl.ts
Normal file
@ -0,0 +1,126 @@
|
||||
import { useState, useRef, useEffect, useCallback } from 'react'
|
||||
import { useStore } from '@nanostores/react'
|
||||
import type { TileState } from '../types/tiles'
|
||||
import type { SynthesisMode } from '../stores/synthesisMode'
|
||||
import { PlaybackManager } from '../services/PlaybackManager'
|
||||
import { engineSettings, effectSettings } from '../stores/settings'
|
||||
import { getSampleRateFromIndex } from '../config/parameters'
|
||||
import { DEFAULT_VARIABLES, LOOP_DURATION } from '../constants/defaults'
|
||||
|
||||
interface UsePlaybackControlProps {
|
||||
mode: SynthesisMode
|
||||
onPlaybackPositionUpdate?: (position: number) => void
|
||||
}
|
||||
|
||||
export function usePlaybackControl({ mode, onPlaybackPositionUpdate }: UsePlaybackControlProps) {
|
||||
const engineValues = useStore(engineSettings)
|
||||
const effectValues = useStore(effectSettings)
|
||||
|
||||
const [playing, setPlaying] = useState<string | null>(null)
|
||||
const [queued, setQueued] = useState<string | null>(null)
|
||||
const [playbackPosition, setPlaybackPosition] = useState<number>(0)
|
||||
|
||||
const playbackManagerRef = useRef<PlaybackManager | null>(null)
|
||||
const switchTimerRef = useRef<number | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
if (playbackManagerRef.current) {
|
||||
playbackManagerRef.current.setPlaybackPositionCallback((position) => {
|
||||
setPlaybackPosition(position)
|
||||
onPlaybackPositionUpdate?.(position)
|
||||
})
|
||||
}
|
||||
}, [onPlaybackPositionUpdate])
|
||||
|
||||
const clearSwitchTimer = useCallback(() => {
|
||||
if (switchTimerRef.current !== null) {
|
||||
clearTimeout(switchTimerRef.current)
|
||||
switchTimerRef.current = null
|
||||
}
|
||||
}, [])
|
||||
|
||||
const startSwitchTimer = useCallback((callback: () => void) => {
|
||||
clearSwitchTimer()
|
||||
switchTimerRef.current = window.setTimeout(callback, engineValues.loopCount * 1000)
|
||||
}, [engineValues.loopCount, clearSwitchTimer])
|
||||
|
||||
useEffect(() => {
|
||||
return () => clearSwitchTimer()
|
||||
}, [clearSwitchTimer])
|
||||
|
||||
const play = useCallback(async (formula: string, id: string, tile?: TileState) => {
|
||||
const sampleRate = getSampleRateFromIndex(engineValues.sampleRate)
|
||||
const duration = LOOP_DURATION
|
||||
|
||||
if (!playbackManagerRef.current) {
|
||||
playbackManagerRef.current = new PlaybackManager({ sampleRate, duration })
|
||||
playbackManagerRef.current.setMode(mode)
|
||||
} else {
|
||||
await playbackManagerRef.current.updateOptions({ sampleRate, duration })
|
||||
}
|
||||
|
||||
playbackManagerRef.current.stop()
|
||||
playbackManagerRef.current.setEffects(effectValues)
|
||||
playbackManagerRef.current.setVariables(
|
||||
engineValues.a ?? DEFAULT_VARIABLES.a,
|
||||
engineValues.b ?? DEFAULT_VARIABLES.b,
|
||||
engineValues.c ?? DEFAULT_VARIABLES.c,
|
||||
engineValues.d ?? DEFAULT_VARIABLES.d
|
||||
)
|
||||
playbackManagerRef.current.setPitch(engineValues.pitch ?? 1.0)
|
||||
|
||||
const fmPatch = mode === 'fm' ? JSON.parse(formula) : null
|
||||
const lfoRates = fmPatch?.lfoRates || undefined
|
||||
playbackManagerRef.current.setAlgorithm(engineValues.fmAlgorithm ?? 0, lfoRates)
|
||||
playbackManagerRef.current.setFeedback(engineValues.fmFeedback ?? 0)
|
||||
|
||||
if (tile?.lfoConfigs) {
|
||||
playbackManagerRef.current.setLFOConfig(0, tile.lfoConfigs.lfo1)
|
||||
playbackManagerRef.current.setLFOConfig(1, tile.lfoConfigs.lfo2)
|
||||
playbackManagerRef.current.setLFOConfig(2, tile.lfoConfigs.lfo3)
|
||||
playbackManagerRef.current.setLFOConfig(3, tile.lfoConfigs.lfo4)
|
||||
}
|
||||
|
||||
await playbackManagerRef.current.play(formula)
|
||||
setPlaying(id)
|
||||
setQueued(null)
|
||||
}, [mode, engineValues, effectValues])
|
||||
|
||||
const stop = useCallback(() => {
|
||||
clearSwitchTimer()
|
||||
playbackManagerRef.current?.stop()
|
||||
setPlaying(null)
|
||||
setQueued(null)
|
||||
setPlaybackPosition(0)
|
||||
}, [clearSwitchTimer])
|
||||
|
||||
const queue = useCallback((id: string, callback: () => void) => {
|
||||
setQueued(id)
|
||||
startSwitchTimer(callback)
|
||||
}, [startSwitchTimer])
|
||||
|
||||
const cancelQueue = useCallback(() => {
|
||||
clearSwitchTimer()
|
||||
setQueued(null)
|
||||
}, [clearSwitchTimer])
|
||||
|
||||
const updateMode = useCallback((newMode: SynthesisMode) => {
|
||||
if (playbackManagerRef.current) {
|
||||
playbackManagerRef.current.setMode(newMode)
|
||||
playbackManagerRef.current.setAlgorithm(engineValues.fmAlgorithm ?? 0)
|
||||
playbackManagerRef.current.setFeedback(engineValues.fmFeedback ?? 0)
|
||||
}
|
||||
}, [engineValues.fmAlgorithm, engineValues.fmFeedback])
|
||||
|
||||
return {
|
||||
playing,
|
||||
queued,
|
||||
playbackPosition,
|
||||
playbackManager: playbackManagerRef,
|
||||
play,
|
||||
stop,
|
||||
queue,
|
||||
cancelQueue,
|
||||
updateMode
|
||||
}
|
||||
}
|
||||
66
src/hooks/useTileGrid.ts
Normal file
66
src/hooks/useTileGrid.ts
Normal file
@ -0,0 +1,66 @@
|
||||
import { useState, useCallback } from 'react'
|
||||
import { useStore } from '@nanostores/react'
|
||||
import type { TileState } from '../types/tiles'
|
||||
import { synthesisMode, setSynthesisMode, type SynthesisMode } from '../stores/synthesisMode'
|
||||
import { engineSettings } from '../stores/settings'
|
||||
import { generateTileGrid, generateRandomFormula } from '../utils/bytebeatFormulas'
|
||||
import { generateFMTileGrid, generateRandomFMPatch, createFMTileState } from '../utils/fmPatches'
|
||||
import { createTileStateFromCurrent } from '../utils/tileState'
|
||||
import { TILE_GRID } from '../constants/defaults'
|
||||
|
||||
export function useTileGrid() {
|
||||
const mode = useStore(synthesisMode)
|
||||
const engineValues = useStore(engineSettings)
|
||||
|
||||
const [tiles, setTiles] = useState<TileState[][]>(() =>
|
||||
mode === 'fm'
|
||||
? generateFMTileGrid(TILE_GRID.SIZE, TILE_GRID.COLUMNS, engineValues.complexity)
|
||||
: generateTileGrid(TILE_GRID.SIZE, TILE_GRID.COLUMNS, engineValues.complexity)
|
||||
)
|
||||
|
||||
const regenerateAll = useCallback(() => {
|
||||
if (mode === 'fm') {
|
||||
setTiles(generateFMTileGrid(TILE_GRID.SIZE, TILE_GRID.COLUMNS, engineValues.complexity))
|
||||
} else {
|
||||
setTiles(generateTileGrid(TILE_GRID.SIZE, TILE_GRID.COLUMNS, engineValues.complexity))
|
||||
}
|
||||
}, [mode, engineValues.complexity])
|
||||
|
||||
const regenerateTile = useCallback((row: number, col: number) => {
|
||||
let newTile: TileState
|
||||
|
||||
if (mode === 'fm') {
|
||||
const patch = generateRandomFMPatch(engineValues.complexity)
|
||||
newTile = createFMTileState(patch)
|
||||
} else {
|
||||
const newFormula = generateRandomFormula(engineValues.complexity)
|
||||
newTile = createTileStateFromCurrent(newFormula)
|
||||
}
|
||||
|
||||
setTiles(prevTiles => {
|
||||
const newTiles = [...prevTiles]
|
||||
newTiles[row] = [...newTiles[row]]
|
||||
newTiles[row][col] = newTile
|
||||
return newTiles
|
||||
})
|
||||
}, [mode, engineValues.complexity])
|
||||
|
||||
const switchMode = useCallback((newMode: SynthesisMode) => {
|
||||
setSynthesisMode(newMode)
|
||||
|
||||
if (newMode === 'fm') {
|
||||
setTiles(generateFMTileGrid(TILE_GRID.SIZE, TILE_GRID.COLUMNS, engineValues.complexity))
|
||||
} else {
|
||||
setTiles(generateTileGrid(TILE_GRID.SIZE, TILE_GRID.COLUMNS, engineValues.complexity))
|
||||
}
|
||||
}, [engineValues.complexity])
|
||||
|
||||
return {
|
||||
tiles,
|
||||
setTiles,
|
||||
mode,
|
||||
regenerateAll,
|
||||
regenerateTile,
|
||||
switchMode
|
||||
}
|
||||
}
|
||||
56
src/services/ParameterManager.ts
Normal file
56
src/services/ParameterManager.ts
Normal file
@ -0,0 +1,56 @@
|
||||
import { engineSettings, effectSettings, lfoSettings } from '../stores/settings'
|
||||
import type { TileState } from '../types/tiles'
|
||||
import type { LFOSettings, LFOConfig } from '../stores/settings'
|
||||
|
||||
export class ParameterManager {
|
||||
loadTileParams(tile: TileState): void {
|
||||
Object.entries(tile.engineParams).forEach(([key, value]) => {
|
||||
engineSettings.setKey(key as any, value)
|
||||
})
|
||||
|
||||
Object.entries(tile.effectParams).forEach(([key, value]) => {
|
||||
effectSettings.setKey(key as any, value as any)
|
||||
})
|
||||
|
||||
if (tile.lfoConfigs) {
|
||||
Object.entries(tile.lfoConfigs).forEach(([key, value]) => {
|
||||
lfoSettings.setKey(key as keyof LFOSettings, value)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
saveTileParams(tile: TileState): TileState {
|
||||
return {
|
||||
...tile,
|
||||
engineParams: { ...engineSettings.get() },
|
||||
effectParams: { ...effectSettings.get() },
|
||||
lfoConfigs: JSON.parse(JSON.stringify(lfoSettings.get()))
|
||||
}
|
||||
}
|
||||
|
||||
getEngineParams(): Record<string, number> {
|
||||
return engineSettings.get() as Record<string, number>
|
||||
}
|
||||
|
||||
getEffectParams(): Record<string, number | boolean | string> {
|
||||
return effectSettings.get()
|
||||
}
|
||||
|
||||
getLFOConfigs(): LFOSettings {
|
||||
return lfoSettings.get()
|
||||
}
|
||||
|
||||
setEngineParam(key: string, value: number): void {
|
||||
engineSettings.setKey(key as any, value)
|
||||
}
|
||||
|
||||
setEffectParam(key: string, value: number | boolean | string): void {
|
||||
effectSettings.setKey(key as any, value as any)
|
||||
}
|
||||
|
||||
setLFOConfig(lfoKey: keyof LFOSettings, config: LFOConfig): void {
|
||||
lfoSettings.setKey(lfoKey, config)
|
||||
}
|
||||
}
|
||||
|
||||
export const parameterManager = new ParameterManager()
|
||||
@ -45,8 +45,8 @@ export class PlaybackManager {
|
||||
this.player.setMode(mode)
|
||||
}
|
||||
|
||||
setAlgorithm(algorithmId: number, lfoRates?: number[]): void {
|
||||
this.player.setAlgorithm(algorithmId, lfoRates)
|
||||
setAlgorithm(algorithmId: number, lfoRates?: number[], pitchLFO?: { waveform: number; depth: number; baseRate: number }): void {
|
||||
this.player.setAlgorithm(algorithmId, lfoRates, pitchLFO)
|
||||
}
|
||||
|
||||
setFeedback(feedback: number): void {
|
||||
|
||||
5
src/stores/index.ts
Normal file
5
src/stores/index.ts
Normal file
@ -0,0 +1,5 @@
|
||||
export { engineSettings, effectSettings, lfoSettings, getDefaultLFOConfig, getDefaultLFOValues } from './settings'
|
||||
export { synthesisMode, setSynthesisMode } from './synthesisMode'
|
||||
export { mappingMode, toggleMappingMode, exitMappingMode } from './mappingMode'
|
||||
export type { LFOMapping, LFOConfig, LFOSettings } from './settings'
|
||||
export type { SynthesisMode } from './synthesisMode'
|
||||
@ -1,5 +1,5 @@
|
||||
import { map } from 'nanostores'
|
||||
import { getDefaultEngineValues, getDefaultEffectValues } from '../config/effects'
|
||||
import { getDefaultEngineValues, getDefaultEffectValues } from '../config/parameters'
|
||||
import type { LFOWaveform } from '../domain/modulation/LFO'
|
||||
|
||||
const STORAGE_KEY_ENGINE = 'engine:'
|
||||
@ -71,6 +71,17 @@ function saveToStorage() {
|
||||
}
|
||||
}
|
||||
|
||||
// Save incrementally on changes (debounced to avoid excessive writes)
|
||||
if (typeof window !== 'undefined') {
|
||||
let saveTimeout: number | null = null
|
||||
const debouncedSave = () => {
|
||||
if (saveTimeout !== null) clearTimeout(saveTimeout)
|
||||
saveTimeout = window.setTimeout(saveToStorage, 500)
|
||||
}
|
||||
|
||||
engineSettings.listen(debouncedSave)
|
||||
effectSettings.listen(debouncedSave)
|
||||
lfoSettings.listen(debouncedSave)
|
||||
|
||||
window.addEventListener('beforeunload', saveToStorage)
|
||||
}
|
||||
@ -1,19 +1,2 @@
|
||||
export interface EffectParameter {
|
||||
id: string
|
||||
label: string
|
||||
min: number
|
||||
max: number
|
||||
default: number | string
|
||||
step: number
|
||||
unit?: string
|
||||
options?: { value: string; label: string }[]
|
||||
}
|
||||
|
||||
export interface EffectConfig {
|
||||
id: string
|
||||
name: string
|
||||
parameters: EffectParameter[]
|
||||
bypassable?: boolean
|
||||
}
|
||||
|
||||
export type EffectValues = Record<string, number | boolean | string>
|
||||
// Re-export from parameters for backward compatibility
|
||||
export type { ParameterConfig as EffectParameter, ParameterGroup as EffectConfig, EffectValues } from './parameters'
|
||||
2
src/types/index.ts
Normal file
2
src/types/index.ts
Normal file
@ -0,0 +1,2 @@
|
||||
export type { ParameterConfig, ParameterGroup, ParameterValues, EngineValues, EffectValues } from './parameters'
|
||||
export type { TileState } from './tiles'
|
||||
22
src/types/parameters.ts
Normal file
22
src/types/parameters.ts
Normal file
@ -0,0 +1,22 @@
|
||||
export interface ParameterConfig {
|
||||
id: string
|
||||
label: string
|
||||
min: number
|
||||
max: number
|
||||
default: number | string
|
||||
step: number
|
||||
unit?: string
|
||||
options?: { value: string; label: string }[]
|
||||
}
|
||||
|
||||
export interface ParameterGroup {
|
||||
id: string
|
||||
name: string
|
||||
parameters: ParameterConfig[]
|
||||
bypassable?: boolean
|
||||
}
|
||||
|
||||
export type ParameterValues = Record<string, number | boolean | string>
|
||||
|
||||
export type EngineValues = ParameterValues
|
||||
export type EffectValues = ParameterValues
|
||||
@ -1,11 +1,16 @@
|
||||
import type { TileState } from '../types/tiles'
|
||||
import { getDefaultEngineValues, getDefaultEffectValues } from '../config/effects'
|
||||
import { getDefaultEngineValues, getDefaultEffectValues } from '../config/parameters'
|
||||
import { getDefaultLFOValues } from '../stores/settings'
|
||||
|
||||
export interface FMPatchConfig {
|
||||
algorithm: number
|
||||
feedback: number
|
||||
lfoRates: [number, number, number, number]
|
||||
pitchLFO: {
|
||||
waveform: number
|
||||
depth: number
|
||||
baseRate: number
|
||||
}
|
||||
}
|
||||
|
||||
export function generateRandomFMPatch(complexity: number = 1): FMPatchConfig {
|
||||
@ -32,7 +37,13 @@ export function generateRandomFMPatch(complexity: number = 1): FMPatchConfig {
|
||||
0.25 + Math.random() * 0.9
|
||||
]
|
||||
|
||||
return { algorithm, feedback, lfoRates }
|
||||
const pitchLFO = {
|
||||
waveform: Math.floor(Math.random() * 4),
|
||||
depth: Math.random() < 0.4 ? 0.03 + Math.random() * 0.22 : 0,
|
||||
baseRate: 0.1 + Math.random() * 9.9
|
||||
}
|
||||
|
||||
return { algorithm, feedback, lfoRates, pitchLFO }
|
||||
}
|
||||
|
||||
export function createFMTileState(patch: FMPatchConfig): TileState {
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
import { SAMPLE_RATES } from '../config/effects'
|
||||
import { SAMPLE_RATES } from '../config/parameters'
|
||||
import { getAlgorithmName } from '../config/fmAlgorithms'
|
||||
|
||||
export function getComplexityLabel(index: number): string {
|
||||
|
||||
8
src/utils/index.ts
Normal file
8
src/utils/index.ts
Normal file
@ -0,0 +1,8 @@
|
||||
export { generateTileGrid, generateRandomFormula } from './bytebeatFormulas'
|
||||
export { generateFMTileGrid, generateRandomFMPatch, createFMTileState, parseFMPatch } from './fmPatches'
|
||||
export { createTileState, createTileStateFromCurrent, loadTileParams, saveTileParams, cloneTileState, randomizeTileParams } from './tileState'
|
||||
export { getTileId, getTileFromGrid, isCustomTileFocused, isTileFocused } from './tileHelpers'
|
||||
export { generateWaveformData, drawWaveform } from './waveformGenerator'
|
||||
export { generateFMWaveformData } from './fmWaveformGenerator'
|
||||
export { getComplexityLabel, getBitDepthLabel, getSampleRateLabel, getAlgorithmLabel } from './formatters'
|
||||
export type { FocusedTile } from './tileHelpers'
|
||||
@ -1,7 +1,8 @@
|
||||
import type { TileState } from '../types/tiles'
|
||||
import { engineSettings, effectSettings, lfoSettings, getDefaultLFOValues } from '../stores/settings'
|
||||
import { getDefaultEngineValues, getDefaultEffectValues, ENGINE_CONTROLS, EFFECTS } from '../config/effects'
|
||||
import { getDefaultLFOValues } from '../stores/settings'
|
||||
import { getDefaultEngineValues, getDefaultEffectValues, ENGINE_CONTROLS, EFFECTS } from '../config/parameters'
|
||||
import type { LFOSettings } from '../stores/settings'
|
||||
import { parameterManager } from '../services/ParameterManager'
|
||||
|
||||
export function createTileState(
|
||||
formula: string,
|
||||
@ -20,35 +21,18 @@ export function createTileState(
|
||||
export function createTileStateFromCurrent(formula: string): TileState {
|
||||
return {
|
||||
formula,
|
||||
engineParams: { ...engineSettings.get() },
|
||||
effectParams: { ...effectSettings.get() },
|
||||
lfoConfigs: JSON.parse(JSON.stringify(lfoSettings.get()))
|
||||
engineParams: { ...parameterManager.getEngineParams() },
|
||||
effectParams: { ...parameterManager.getEffectParams() },
|
||||
lfoConfigs: JSON.parse(JSON.stringify(parameterManager.getLFOConfigs()))
|
||||
}
|
||||
}
|
||||
|
||||
export function loadTileParams(tile: TileState): void {
|
||||
Object.entries(tile.engineParams).forEach(([key, value]) => {
|
||||
engineSettings.setKey(key as keyof ReturnType<typeof getDefaultEngineValues>, value)
|
||||
})
|
||||
|
||||
Object.entries(tile.effectParams).forEach(([key, value]) => {
|
||||
effectSettings.setKey(key as never, value as never)
|
||||
})
|
||||
|
||||
if (tile.lfoConfigs) {
|
||||
Object.entries(tile.lfoConfigs).forEach(([key, value]) => {
|
||||
lfoSettings.setKey(key as keyof LFOSettings, value)
|
||||
})
|
||||
}
|
||||
parameterManager.loadTileParams(tile)
|
||||
}
|
||||
|
||||
export function saveTileParams(tile: TileState): TileState {
|
||||
return {
|
||||
...tile,
|
||||
engineParams: { ...engineSettings.get() },
|
||||
effectParams: { ...effectSettings.get() },
|
||||
lfoConfigs: JSON.parse(JSON.stringify(lfoSettings.get()))
|
||||
}
|
||||
return parameterManager.saveTileParams(tile)
|
||||
}
|
||||
|
||||
export function cloneTileState(tile: TileState): TileState {
|
||||
|
||||
Reference in New Issue
Block a user