slightly better
This commit is contained in:
@ -12,6 +12,7 @@ class BytebeatProcessor extends AudioWorkletProcessor {
|
||||
this.sampleRate = 8000
|
||||
this.duration = 4
|
||||
this.loopLength = this.sampleRate * this.duration
|
||||
this.playbackRate = 1.0
|
||||
this.error = false
|
||||
|
||||
this.port.onmessage = (event) => {
|
||||
@ -32,6 +33,9 @@ class BytebeatProcessor extends AudioWorkletProcessor {
|
||||
case 'loopLength':
|
||||
this.loopLength = value
|
||||
break
|
||||
case 'playbackRate':
|
||||
this.playbackRate = value
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -71,7 +75,7 @@ class BytebeatProcessor extends AudioWorkletProcessor {
|
||||
}
|
||||
}
|
||||
|
||||
this.t++
|
||||
this.t += this.playbackRate
|
||||
if (this.loopLength > 0 && this.t >= this.loopLength) {
|
||||
this.t = 0
|
||||
}
|
||||
|
||||
60
public/worklets/output-limiter.js
Normal file
60
public/worklets/output-limiter.js
Normal file
@ -0,0 +1,60 @@
|
||||
class OutputLimiter extends AudioWorkletProcessor {
|
||||
static get parameterDescriptors() {
|
||||
return [
|
||||
{
|
||||
name: 'threshold',
|
||||
defaultValue: 0.8,
|
||||
minValue: 0.1,
|
||||
maxValue: 1.0,
|
||||
automationRate: 'k-rate'
|
||||
},
|
||||
{
|
||||
name: 'makeup',
|
||||
defaultValue: 1.5,
|
||||
minValue: 1.0,
|
||||
maxValue: 3.0,
|
||||
automationRate: 'k-rate'
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
constructor() {
|
||||
super()
|
||||
}
|
||||
|
||||
softClip(x, threshold) {
|
||||
if (Math.abs(x) < threshold) {
|
||||
return x
|
||||
}
|
||||
const sign = x < 0 ? -1 : 1
|
||||
const scaled = (Math.abs(x) - threshold) / (1 - threshold)
|
||||
return sign * (threshold + (1 - threshold) * Math.tanh(scaled))
|
||||
}
|
||||
|
||||
process(inputs, outputs, parameters) {
|
||||
const input = inputs[0]
|
||||
const output = outputs[0]
|
||||
|
||||
if (input.length === 0 || output.length === 0) {
|
||||
return true
|
||||
}
|
||||
|
||||
const threshold = parameters.threshold[0]
|
||||
const makeup = parameters.makeup[0]
|
||||
|
||||
for (let channel = 0; channel < input.length; channel++) {
|
||||
const inputChannel = input[channel]
|
||||
const outputChannel = output[channel]
|
||||
|
||||
for (let i = 0; i < inputChannel.length; i++) {
|
||||
let sample = inputChannel[i] * makeup
|
||||
sample = this.softClip(sample, threshold)
|
||||
outputChannel[i] = sample
|
||||
}
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
registerProcessor('output-limiter', OutputLimiter)
|
||||
103
public/worklets/svf-processor.js
Normal file
103
public/worklets/svf-processor.js
Normal file
@ -0,0 +1,103 @@
|
||||
class SVFProcessor extends AudioWorkletProcessor {
|
||||
static get parameterDescriptors() {
|
||||
return [
|
||||
{
|
||||
name: 'frequency',
|
||||
defaultValue: 1000,
|
||||
minValue: 20,
|
||||
maxValue: 20000,
|
||||
automationRate: 'k-rate'
|
||||
},
|
||||
{
|
||||
name: 'resonance',
|
||||
defaultValue: 0.707,
|
||||
minValue: 0.05,
|
||||
maxValue: 10,
|
||||
automationRate: 'k-rate'
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
constructor() {
|
||||
super()
|
||||
|
||||
this.mode = 'lowpass'
|
||||
this.ic1eq = 0
|
||||
this.ic2eq = 0
|
||||
|
||||
this.port.onmessage = (event) => {
|
||||
const { type, value } = event.data
|
||||
if (type === 'mode') {
|
||||
this.mode = value
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
process(inputs, outputs, parameters) {
|
||||
const input = inputs[0]
|
||||
const output = outputs[0]
|
||||
|
||||
if (input.length === 0 || output.length === 0) {
|
||||
return true
|
||||
}
|
||||
|
||||
const inputChannel = input[0]
|
||||
const outputChannel = output[0]
|
||||
const frequency = parameters.frequency
|
||||
const resonance = parameters.resonance
|
||||
|
||||
const sampleRate = globalThis.sampleRate || 44100
|
||||
const isFreqArray = frequency.length > 1
|
||||
const isResArray = resonance.length > 1
|
||||
|
||||
for (let i = 0; i < inputChannel.length; i++) {
|
||||
const freq = isFreqArray ? frequency[i] : frequency[0]
|
||||
const res = isResArray ? resonance[i] : resonance[0]
|
||||
|
||||
const g = Math.tan(Math.PI * Math.min(freq, sampleRate * 0.49) / sampleRate)
|
||||
const k = 1 / Math.max(0.05, res)
|
||||
|
||||
const inputSample = inputChannel[i]
|
||||
|
||||
const a1 = 1 / (1 + g * (g + k))
|
||||
const a2 = g * a1
|
||||
const a3 = g * a2
|
||||
|
||||
const v3 = inputSample - this.ic2eq
|
||||
const v1 = a1 * this.ic1eq + a2 * v3
|
||||
const v2 = this.ic2eq + a2 * this.ic1eq + a3 * v3
|
||||
|
||||
this.ic1eq = 2 * v1 - this.ic1eq
|
||||
this.ic2eq = 2 * v2 - this.ic2eq
|
||||
|
||||
const lp = v2
|
||||
const bp = v1
|
||||
const hp = inputSample - k * v1 - v2
|
||||
const notch = inputSample - k * v1
|
||||
|
||||
let outSample
|
||||
switch (this.mode) {
|
||||
case 'lowpass':
|
||||
outSample = lp
|
||||
break
|
||||
case 'highpass':
|
||||
outSample = hp
|
||||
break
|
||||
case 'bandpass':
|
||||
outSample = bp
|
||||
break
|
||||
case 'notch':
|
||||
outSample = notch
|
||||
break
|
||||
default:
|
||||
outSample = lp
|
||||
}
|
||||
|
||||
outputChannel[i] = outSample
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
registerProcessor('svf-processor', SVFProcessor)
|
||||
280
src/App.tsx
280
src/App.tsx
@ -1,6 +1,6 @@
|
||||
import { useState, useRef, useEffect } from 'react'
|
||||
import { useStore } from '@nanostores/react'
|
||||
import { Square, Archive, Dices } from 'lucide-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'
|
||||
@ -8,12 +8,16 @@ 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 } from './stores/settings'
|
||||
import { engineSettings, effectSettings, lfoSettings } from './stores/settings'
|
||||
import { exitMappingMode } from './stores/mappingMode'
|
||||
import { useKeyboardShortcuts } from './hooks/useKeyboardShortcuts'
|
||||
import { useTileParams } from './hooks/useTileParams'
|
||||
import type { TileState } from './types/tiles'
|
||||
import { createTileStateFromCurrent, loadTileParams } from './utils/tileState'
|
||||
import { createTileStateFromCurrent, loadTileParams, randomizeTileParams } from './utils/tileState'
|
||||
import { DEFAULT_VARIABLES, PLAYBACK_ID, TILE_GRID, DEFAULT_DOWNLOAD_OPTIONS } from './constants/defaults'
|
||||
import { getTileId, getTileFromGrid, type FocusedTile } from './utils/tileHelpers'
|
||||
|
||||
@ -30,6 +34,8 @@ function App() {
|
||||
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 playbackManagerRef = useRef<PlaybackManager | null>(null)
|
||||
const downloadServiceRef = useRef<DownloadService>(new DownloadService())
|
||||
|
||||
@ -50,6 +56,59 @@ function App() {
|
||||
setQueued(null)
|
||||
}
|
||||
|
||||
const handleRandomizeAllParams = () => {
|
||||
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 as any)
|
||||
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 = engineValues.loopDuration
|
||||
@ -68,17 +127,12 @@ function App() {
|
||||
engineValues.c ?? DEFAULT_VARIABLES.c,
|
||||
engineValues.d ?? DEFAULT_VARIABLES.d
|
||||
)
|
||||
playbackManagerRef.current.setPitch(engineValues.pitch ?? 1.0)
|
||||
|
||||
const success = await playbackManagerRef.current.play(formula)
|
||||
|
||||
if (success) {
|
||||
setPlaying(id)
|
||||
setQueued(null)
|
||||
return true
|
||||
} else {
|
||||
console.error('Failed to play formula')
|
||||
return false
|
||||
}
|
||||
await playbackManagerRef.current.play(formula)
|
||||
setPlaying(id)
|
||||
setQueued(null)
|
||||
return true
|
||||
}
|
||||
|
||||
const handleTileClick = (_formula: string, row: number, col: number, isDoubleClick: boolean = false) => {
|
||||
@ -121,7 +175,7 @@ function App() {
|
||||
playbackManagerRef.current.setEffects({ ...effectValues, masterVolume: value })
|
||||
}
|
||||
|
||||
if (parameterId === 'pitch' && playbackManagerRef.current) {
|
||||
if (parameterId === 'pitch' && playbackManagerRef.current && playing) {
|
||||
playbackManagerRef.current.setPitch(value)
|
||||
}
|
||||
|
||||
@ -145,6 +199,86 @@ function App() {
|
||||
}
|
||||
}
|
||||
|
||||
const handleLFOChange = (lfoIndex: number, config: any) => {
|
||||
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))
|
||||
@ -276,6 +410,77 @@ function App() {
|
||||
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 as any)
|
||||
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 as any)
|
||||
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 handleDismissWarning = () => {
|
||||
setShowWarning(false)
|
||||
}
|
||||
|
||||
useKeyboardShortcuts({
|
||||
onSpace: handleKeyboardSpace,
|
||||
onArrowUp: (shift) => moveFocus('up', shift ? 10 : 1),
|
||||
@ -285,7 +490,10 @@ function App() {
|
||||
onEnter: handleKeyboardEnter,
|
||||
onDoubleEnter: handleKeyboardDoubleEnter,
|
||||
onR: handleKeyboardR,
|
||||
onShiftR: handleKeyboardShiftR
|
||||
onShiftR: handleKeyboardShiftR,
|
||||
onC: handleKeyboardC,
|
||||
onShiftC: handleKeyboardShiftC,
|
||||
onEscape: handleEscape
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
@ -299,10 +507,22 @@ function App() {
|
||||
|
||||
return (
|
||||
<div className="w-screen h-screen flex flex-col bg-black overflow-hidden">
|
||||
{showWarning && <AudioContextWarning onDismiss={handleDismissWarning} />}
|
||||
{showHelp && <HelpModal onClose={() => setShowHelp(false)} />}
|
||||
<header className="bg-black border-b-2 border-white px-6 py-3">
|
||||
<div className="flex items-center justify-between gap-6">
|
||||
<h1 className="font-mono text-sm tracking-[0.3em] text-white flex-shrink-0">BRUITISTE</h1>
|
||||
<EngineControls values={engineValues} onChange={handleEngineChange} />
|
||||
<h1
|
||||
onClick={() => setShowHelp(true)}
|
||||
className="font-mono text-sm tracking-[0.3em] text-white flex-shrink-0 cursor-pointer hover:opacity-70 transition-opacity"
|
||||
>
|
||||
BRUITISTE
|
||||
</h1>
|
||||
<EngineControls
|
||||
values={engineValues}
|
||||
onChange={handleEngineChange}
|
||||
onMapClick={handleParameterMapClick}
|
||||
getMappedLFOs={getMappedLFOs}
|
||||
/>
|
||||
<div className="flex gap-4 flex-shrink-0">
|
||||
<button
|
||||
onClick={handleStop}
|
||||
@ -319,6 +539,13 @@ function App() {
|
||||
<Dices size={14} strokeWidth={2} />
|
||||
RANDOM
|
||||
</button>
|
||||
<button
|
||||
onClick={handleRandomizeAllParams}
|
||||
className="px-6 py-2 bg-white text-black font-mono text-[11px] tracking-[0.2em] hover:bg-black hover:text-white border-2 border-white transition-all flex items-center gap-2"
|
||||
>
|
||||
<Sparkles size={14} strokeWidth={2} />
|
||||
CHAOS
|
||||
</button>
|
||||
<button
|
||||
onClick={handleDownloadAll}
|
||||
disabled={downloading}
|
||||
@ -331,9 +558,11 @@ function App() {
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<LFOPanel onChange={handleLFOChange} onUpdateDepth={handleUpdateMappingDepth} onRemoveMapping={handleRemoveMapping} />
|
||||
|
||||
<div className="flex-1 flex flex-col overflow-auto bg-white">
|
||||
<div className="grid grid-cols-2 gap-[1px] bg-white p-[1px]">
|
||||
<div className="col-span-2">
|
||||
<div className="grid grid-cols-4 gap-[1px] bg-white p-[1px]">
|
||||
<div className="col-span-4">
|
||||
<FormulaEditor
|
||||
formula={customTile.formula}
|
||||
isPlaying={playing === PLAYBACK_ID.CUSTOM}
|
||||
@ -346,7 +575,7 @@ function App() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 grid grid-cols-2 auto-rows-min gap-[1px] bg-white p-[1px]">
|
||||
<div className="flex-1 grid grid-cols-4 auto-rows-min gap-[1px] bg-white p-[1px]">
|
||||
{tiles.map((row, i) =>
|
||||
row.map((tile, j) => {
|
||||
const id = getTileId(i, j)
|
||||
@ -360,6 +589,10 @@ function App() {
|
||||
isQueued={queued === id}
|
||||
isFocused={focusedTile !== 'custom' && focusedTile.row === i && focusedTile.col === j}
|
||||
playbackPosition={playing === id ? playbackPosition : 0}
|
||||
a={tile.engineParams.a ?? 8}
|
||||
b={tile.engineParams.b ?? 16}
|
||||
c={tile.engineParams.c ?? 32}
|
||||
d={tile.engineParams.d ?? 64}
|
||||
onPlay={handleTileClick}
|
||||
onDoubleClick={handleTileDoubleClick}
|
||||
onDownload={handleDownloadFormula}
|
||||
@ -371,7 +604,12 @@ function App() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<EffectsBar values={effectValues} onChange={handleEffectChange} />
|
||||
<EffectsBar
|
||||
values={effectValues}
|
||||
onChange={handleEffectChange}
|
||||
onMapClick={handleParameterMapClick}
|
||||
getMappedLFOs={getMappedLFOs}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
9
src/components/AudioContextWarning.tsx
Normal file
9
src/components/AudioContextWarning.tsx
Normal file
@ -0,0 +1,9 @@
|
||||
import { HelpModal } from './HelpModal'
|
||||
|
||||
interface AudioContextWarningProps {
|
||||
onDismiss: () => void
|
||||
}
|
||||
|
||||
export function AudioContextWarning({ onDismiss }: AudioContextWarningProps) {
|
||||
return <HelpModal onClose={onDismiss} showStartButton />
|
||||
}
|
||||
@ -10,13 +10,17 @@ interface BytebeatTileProps {
|
||||
isQueued: boolean
|
||||
isFocused: boolean
|
||||
playbackPosition: number
|
||||
a: number
|
||||
b: number
|
||||
c: number
|
||||
d: number
|
||||
onPlay: (formula: string, row: number, col: number) => void
|
||||
onDoubleClick: (formula: string, row: number, col: number) => void
|
||||
onDownload: (formula: string, filename: string) => void
|
||||
onRegenerate: (row: number, col: number) => void
|
||||
}
|
||||
|
||||
export function BytebeatTile({ formula, row, col, isPlaying, isQueued, isFocused, playbackPosition, onPlay, onDoubleClick, onDownload, onRegenerate }: BytebeatTileProps) {
|
||||
export function BytebeatTile({ formula, row, col, isPlaying, isQueued, isFocused, playbackPosition, a, b, c, d, onPlay, onDoubleClick, onDownload, onRegenerate }: BytebeatTileProps) {
|
||||
const canvasRef = useRef<HTMLCanvasElement>(null)
|
||||
|
||||
useEffect(() => {
|
||||
@ -27,10 +31,10 @@ export function BytebeatTile({ formula, row, col, isPlaying, isQueued, isFocused
|
||||
canvas.width = rect.width * window.devicePixelRatio
|
||||
canvas.height = rect.height * window.devicePixelRatio
|
||||
|
||||
const waveformData = generateWaveformData(formula, canvas.width)
|
||||
const waveformData = generateWaveformData(formula, canvas.width, 8000, 0.5, a, b, c, d)
|
||||
const color = isPlaying ? 'rgba(0, 0, 0, 0.3)' : 'rgba(255, 255, 255, 0.35)'
|
||||
drawWaveform(canvas, waveformData, color)
|
||||
}, [formula, isPlaying, isQueued])
|
||||
}, [formula, isPlaying, isQueued, a, b, c, d])
|
||||
|
||||
const handleDownload = (e: React.MouseEvent) => {
|
||||
e.stopPropagation()
|
||||
@ -47,7 +51,7 @@ export function BytebeatTile({ formula, row, col, isPlaying, isQueued, isFocused
|
||||
data-tile-id={`${row}-${col}`}
|
||||
onClick={() => onPlay(formula, row, col)}
|
||||
onDoubleClick={() => onDoubleClick(formula, row, col)}
|
||||
className={`relative hover:scale-[0.98] transition-all duration-150 font-mono p-3 flex items-center justify-between gap-3 cursor-pointer overflow-hidden ${
|
||||
className={`relative hover:scale-[0.98] transition-all duration-150 font-mono p-3 flex items-center justify-between gap-1 cursor-pointer overflow-hidden ${
|
||||
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]' : ''}`}
|
||||
>
|
||||
@ -64,7 +68,7 @@ export function BytebeatTile({ formula, row, col, isPlaying, isQueued, isFocused
|
||||
<div className="text-xs break-all font-light flex-1 relative z-10">
|
||||
{formula}
|
||||
</div>
|
||||
<div className="flex gap-2 flex-shrink-0 relative z-10">
|
||||
<div className="flex gap-1 flex-shrink-0 relative z-10">
|
||||
<div
|
||||
onClick={handleRegenerate}
|
||||
className={`p-2 border transition-all duration-150 cursor-pointer hover:scale-105 ${
|
||||
|
||||
@ -8,9 +8,11 @@ import type { EffectValues } from '../types/effects'
|
||||
interface EffectsBarProps {
|
||||
values: EffectValues
|
||||
onChange: (parameterId: string, value: number | boolean | string) => void
|
||||
onMapClick?: (paramId: string, lfoIndex: number) => void
|
||||
getMappedLFOs?: (paramId: string) => number[]
|
||||
}
|
||||
|
||||
export function EffectsBar({ values, onChange }: EffectsBarProps) {
|
||||
export function EffectsBar({ values, onChange, onMapClick, getMappedLFOs }: EffectsBarProps) {
|
||||
const randomizeEffect = (effect: typeof EFFECTS[number]) => {
|
||||
effect.parameters.forEach(param => {
|
||||
if (param.id.endsWith('Enable')) return
|
||||
@ -28,82 +30,10 @@ export function EffectsBar({ values, onChange }: EffectsBarProps) {
|
||||
})
|
||||
}
|
||||
|
||||
const renderFilterEffect = (effect: typeof EFFECTS[number]) => {
|
||||
const filterGroups = [
|
||||
{ prefix: 'hp', label: 'HP' },
|
||||
{ prefix: 'lp', label: 'LP' },
|
||||
{ prefix: 'bp', label: 'BP' }
|
||||
]
|
||||
|
||||
return (
|
||||
<div key={effect.id} className="border-2 border-white p-3">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<h3 className="font-mono text-[10px] tracking-[0.2em] text-white">
|
||||
{effect.name.toUpperCase()}
|
||||
</h3>
|
||||
<button
|
||||
onClick={() => randomizeEffect(effect)}
|
||||
className="p-1 text-white hover:bg-white hover:text-black transition-colors"
|
||||
>
|
||||
<Dices size={12} strokeWidth={2} />
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex flex-col gap-3">
|
||||
{filterGroups.map(group => {
|
||||
const enableParam = effect.parameters.find(p => p.id === `${group.prefix}Enable`)
|
||||
const freqParam = effect.parameters.find(p => p.id === `${group.prefix}Freq`)
|
||||
const resParam = effect.parameters.find(p => p.id === `${group.prefix}Res`)
|
||||
|
||||
if (!enableParam || !freqParam || !resParam) return null
|
||||
|
||||
return (
|
||||
<div key={group.prefix} className="flex gap-2 items-center">
|
||||
<button
|
||||
onClick={() => onChange(enableParam.id, values[enableParam.id] ? 0 : 1)}
|
||||
className="w-4 h-4 border-2 border-white bg-black flex items-center justify-center cursor-pointer hover:bg-white transition-colors group"
|
||||
>
|
||||
{Boolean(values[enableParam.id]) && (
|
||||
<div className="w-2 h-2 bg-white group-hover:bg-black" />
|
||||
)}
|
||||
</button>
|
||||
<div className="flex-1 flex flex-col gap-2">
|
||||
<Slider
|
||||
label={freqParam.label}
|
||||
value={values[freqParam.id] as number ?? freqParam.default}
|
||||
min={freqParam.min}
|
||||
max={freqParam.max}
|
||||
step={freqParam.step}
|
||||
unit={freqParam.unit}
|
||||
onChange={(value) => onChange(freqParam.id, value)}
|
||||
valueId={freqParam.id}
|
||||
/>
|
||||
<Slider
|
||||
label={resParam.label}
|
||||
value={values[resParam.id] as number ?? resParam.default}
|
||||
min={resParam.min}
|
||||
max={resParam.max}
|
||||
step={resParam.step}
|
||||
unit={resParam.unit}
|
||||
onChange={(value) => onChange(resParam.id, value)}
|
||||
valueId={resParam.id}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="bg-black border-t-2 border-white px-6 py-4">
|
||||
<div className="grid grid-cols-4 gap-4">
|
||||
{EFFECTS.map(effect => {
|
||||
if (effect.id === 'filter') {
|
||||
return renderFilterEffect(effect)
|
||||
}
|
||||
|
||||
return (
|
||||
<div key={effect.id} className="border-2 border-white p-3">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
@ -170,6 +100,9 @@ export function EffectsBar({ values, onChange }: EffectsBarProps) {
|
||||
unit={param.unit}
|
||||
onChange={(value) => onChange(param.id, value)}
|
||||
valueId={param.id}
|
||||
paramId={param.id}
|
||||
onMapClick={onMapClick}
|
||||
mappedLFOs={getMappedLFOs ? getMappedLFOs(param.id) : []}
|
||||
/>
|
||||
)
|
||||
})}
|
||||
|
||||
@ -6,11 +6,13 @@ import { Knob } from './Knob'
|
||||
interface EngineControlsProps {
|
||||
values: EffectValues
|
||||
onChange: (parameterId: string, value: number) => void
|
||||
onMapClick?: (paramId: string, lfoIndex: number) => void
|
||||
getMappedLFOs?: (paramId: string) => number[]
|
||||
}
|
||||
|
||||
const KNOB_PARAMS = ['masterVolume', 'a', 'b', 'c', 'd']
|
||||
const KNOB_PARAMS = ['masterVolume', 'pitch', 'a', 'b', 'c', 'd']
|
||||
|
||||
export function EngineControls({ values, onChange }: EngineControlsProps) {
|
||||
export function EngineControls({ values, onChange, onMapClick, getMappedLFOs }: EngineControlsProps) {
|
||||
const formatValue = (id: string, value: number): string => {
|
||||
switch (id) {
|
||||
case 'sampleRate':
|
||||
@ -43,6 +45,9 @@ export function EngineControls({ values, onChange }: EngineControlsProps) {
|
||||
onChange={(value) => onChange(param.id, value)}
|
||||
formatValue={formatValue}
|
||||
valueId={param.id}
|
||||
paramId={param.id}
|
||||
onMapClick={onMapClick}
|
||||
mappedLFOs={getMappedLFOs ? getMappedLFOs(param.id) : []}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
105
src/components/HelpModal.tsx
Normal file
105
src/components/HelpModal.tsx
Normal file
@ -0,0 +1,105 @@
|
||||
interface HelpModalProps {
|
||||
onClose: () => void
|
||||
showStartButton?: boolean
|
||||
}
|
||||
|
||||
export function HelpModal({ onClose, showStartButton = false }: HelpModalProps) {
|
||||
return (
|
||||
<div
|
||||
className="fixed inset-0 bg-black/70 flex items-center justify-center z-50"
|
||||
onClick={onClose}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Escape' || e.key === 'Enter' || e.key === ' ') {
|
||||
onClose()
|
||||
}
|
||||
}}
|
||||
tabIndex={0}
|
||||
>
|
||||
<div
|
||||
className="border-4 border-white bg-black p-12 max-w-4xl w-full mx-8"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<h1 className="font-mono text-2xl tracking-[0.3em] text-white mb-6 text-center">
|
||||
BRUITISTE
|
||||
</h1>
|
||||
<p className="font-mono text-sm text-white mb-2 leading-relaxed text-center">
|
||||
A harsh noise soundbox
|
||||
</p>
|
||||
<p className="font-mono text-xs text-white mb-6 opacity-70 text-center">
|
||||
Made by Raphaël Forment (BuboBubo) — <a href="https://raphaelforment.fr" target="_blank" rel="noopener noreferrer" className="underline hover:opacity-100">raphaelforment.fr</a>
|
||||
</p>
|
||||
|
||||
<div className="font-mono text-sm text-white mb-8">
|
||||
<h2 className="text-lg tracking-[0.2em] mb-4">KEYBOARD SHORTCUTS</h2>
|
||||
|
||||
<table className="w-full border-2 border-white">
|
||||
<thead>
|
||||
<tr className="border-b-2 border-white">
|
||||
<th className="text-left p-3 bg-white text-black tracking-[0.1em]">KEY</th>
|
||||
<th className="text-left p-3 bg-white text-black tracking-[0.1em]">ACTION</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr className="border-b border-white">
|
||||
<td className="p-3 border-r border-white">SPACE</td>
|
||||
<td className="p-3">Play/Stop current tile</td>
|
||||
</tr>
|
||||
<tr className="border-b border-white">
|
||||
<td className="p-3 border-r border-white">ARROWS</td>
|
||||
<td className="p-3">Navigate tiles</td>
|
||||
</tr>
|
||||
<tr className="border-b border-white">
|
||||
<td className="p-3 border-r border-white">SHIFT + ARROWS</td>
|
||||
<td className="p-3">Jump 10 tiles</td>
|
||||
</tr>
|
||||
<tr className="border-b border-white">
|
||||
<td className="p-3 border-r border-white">ENTER</td>
|
||||
<td className="p-3">Queue tile (play after current)</td>
|
||||
</tr>
|
||||
<tr className="border-b border-white">
|
||||
<td className="p-3 border-r border-white">DOUBLE ENTER</td>
|
||||
<td className="p-3">Play immediately</td>
|
||||
</tr>
|
||||
<tr className="border-b border-white">
|
||||
<td className="p-3 border-r border-white">R</td>
|
||||
<td className="p-3">Regenerate current tile</td>
|
||||
</tr>
|
||||
<tr className="border-b border-white">
|
||||
<td className="p-3 border-r border-white">SHIFT + R</td>
|
||||
<td className="p-3">Randomize all tiles</td>
|
||||
</tr>
|
||||
<tr className="border-b border-white">
|
||||
<td className="p-3 border-r border-white">C</td>
|
||||
<td className="p-3">Randomize current tile params</td>
|
||||
</tr>
|
||||
<tr className="border-b border-white">
|
||||
<td className="p-3 border-r border-white">SHIFT + C</td>
|
||||
<td className="p-3">Randomize all params (CHAOS)</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td className="p-3 border-r border-white">ESC</td>
|
||||
<td className="p-3">Exit mapping mode</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{showStartButton ? (
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="w-full px-8 py-4 bg-white text-black font-mono text-sm tracking-[0.2em] hover:bg-black hover:text-white border-2 border-white transition-all"
|
||||
>
|
||||
START
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="w-full px-8 py-4 bg-white text-black font-mono text-sm tracking-[0.2em] hover:bg-black hover:text-white border-2 border-white transition-all"
|
||||
>
|
||||
CLOSE
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@ -1,4 +1,6 @@
|
||||
import { useRef, useState, useEffect } from 'react'
|
||||
import { useStore } from '@nanostores/react'
|
||||
import { mappingMode } from '../stores/mappingMode'
|
||||
|
||||
interface KnobProps {
|
||||
label: string
|
||||
@ -11,6 +13,9 @@ interface KnobProps {
|
||||
formatValue?: (id: string, value: number) => string
|
||||
valueId?: string
|
||||
size?: number
|
||||
paramId?: string
|
||||
onMapClick?: (paramId: string, activeLFO: number) => void
|
||||
mappedLFOs?: number[]
|
||||
}
|
||||
|
||||
export function Knob({
|
||||
@ -23,18 +28,30 @@ export function Knob({
|
||||
onChange,
|
||||
formatValue,
|
||||
valueId,
|
||||
size = 48
|
||||
size = 48,
|
||||
paramId,
|
||||
onMapClick,
|
||||
mappedLFOs = []
|
||||
}: KnobProps) {
|
||||
const [isDragging, setIsDragging] = useState(false)
|
||||
const startYRef = useRef<number>(0)
|
||||
const startValueRef = useRef<number>(0)
|
||||
const mappingModeState = useStore(mappingMode)
|
||||
|
||||
const displayValue = formatValue && valueId ? formatValue(valueId, value) : `${value}${unit || ''}`
|
||||
const isInMappingMode = mappingModeState.isActive && paramId
|
||||
const hasMappings = mappedLFOs.length > 0
|
||||
|
||||
const normalizedValue = (value - min) / (max - min)
|
||||
const angle = -225 + normalizedValue * 270
|
||||
|
||||
const handleMouseDown = (e: React.MouseEvent) => {
|
||||
if (isInMappingMode && paramId && mappingModeState.activeLFO !== null && onMapClick) {
|
||||
onMapClick(paramId, mappingModeState.activeLFO)
|
||||
e.preventDefault()
|
||||
return
|
||||
}
|
||||
|
||||
setIsDragging(true)
|
||||
startYRef.current = e.clientY
|
||||
startValueRef.current = value
|
||||
@ -71,7 +88,7 @@ export function Knob({
|
||||
return (
|
||||
<div className="relative flex flex-col items-center">
|
||||
<div
|
||||
className="relative cursor-ns-resize select-none"
|
||||
className={`relative select-none ${isInMappingMode ? 'cursor-pointer' : 'cursor-ns-resize'}`}
|
||||
onMouseDown={handleMouseDown}
|
||||
style={{ width: size, height: size }}
|
||||
>
|
||||
@ -87,6 +104,7 @@ export function Knob({
|
||||
fill="none"
|
||||
stroke="white"
|
||||
strokeWidth="2"
|
||||
className={isInMappingMode ? 'animate-pulse' : ''}
|
||||
/>
|
||||
|
||||
<circle
|
||||
@ -105,10 +123,19 @@ export function Knob({
|
||||
strokeWidth="2"
|
||||
strokeLinecap="square"
|
||||
/>
|
||||
|
||||
{hasMappings && (
|
||||
<circle
|
||||
cx={size / 2}
|
||||
cy={8}
|
||||
r={2}
|
||||
fill="white"
|
||||
/>
|
||||
)}
|
||||
</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">
|
||||
<span className={`font-mono text-[9px] tracking-[0.15em] text-white ${isInMappingMode ? 'animate-pulse' : ''}`}>
|
||||
{isDragging ? displayValue : label.toUpperCase()}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
61
src/components/LFOPanel.tsx
Normal file
61
src/components/LFOPanel.tsx
Normal file
@ -0,0 +1,61 @@
|
||||
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'
|
||||
|
||||
interface LFOPanelProps {
|
||||
onChange: (lfoIndex: number, config: LFOConfig) => void
|
||||
onUpdateDepth: (lfoIndex: number, paramId: string, depth: number) => void
|
||||
onRemoveMapping: (lfoIndex: number, paramId: string) => void
|
||||
}
|
||||
|
||||
type LFOKey = 'lfo1' | 'lfo2' | 'lfo3' | 'lfo4'
|
||||
|
||||
export function LFOPanel({ onChange, onUpdateDepth, onRemoveMapping }: LFOPanelProps) {
|
||||
const lfoValues = useStore(lfoSettings)
|
||||
|
||||
const handleLFOChange = (lfoKey: LFOKey, lfoIndex: number, frequency: number, phase: number, waveform: LFOWaveform) => {
|
||||
const lfo = lfoValues[lfoKey]
|
||||
const updated = { ...lfo, frequency, phase, waveform }
|
||||
lfoSettings.setKey(lfoKey, updated)
|
||||
onChange(lfoIndex, updated)
|
||||
}
|
||||
|
||||
const handleMapClick = (_lfoKey: LFOKey, lfoIndex: number) => {
|
||||
toggleMappingMode(lfoIndex)
|
||||
}
|
||||
|
||||
const lfoConfigs: Array<{ key: LFOKey; index: number }> = [
|
||||
{ key: 'lfo1', index: 0 },
|
||||
{ key: 'lfo2', index: 1 },
|
||||
{ key: 'lfo3', index: 2 },
|
||||
{ key: 'lfo4', index: 3 }
|
||||
]
|
||||
|
||||
return (
|
||||
<div className="bg-black border-t-2 border-white">
|
||||
<div className="grid grid-cols-4 divide-x-2 divide-white">
|
||||
{lfoConfigs.map(({ key, index }) => {
|
||||
const lfo = lfoValues[key]
|
||||
return (
|
||||
<div key={key} className="px-2 py-3 flex items-center">
|
||||
<LFOScope
|
||||
lfoIndex={index}
|
||||
waveform={lfo.waveform}
|
||||
frequency={lfo.frequency}
|
||||
phase={lfo.phase}
|
||||
mappings={lfo.mappings}
|
||||
onChange={(freq, phase, waveform) => handleLFOChange(key, index, freq, phase, waveform)}
|
||||
onMapClick={() => handleMapClick(key, index)}
|
||||
onUpdateDepth={(paramId, depth) => onUpdateDepth(index, paramId, depth)}
|
||||
onRemoveMapping={(paramId) => onRemoveMapping(index, paramId)}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
238
src/components/LFOScope.tsx
Normal file
238
src/components/LFOScope.tsx
Normal file
@ -0,0 +1,238 @@
|
||||
import { useEffect, useRef, useState } 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'
|
||||
|
||||
interface LFOScopeProps {
|
||||
lfoIndex: number
|
||||
waveform: LFOWaveform
|
||||
frequency: number
|
||||
phase: number
|
||||
mappings: Array<{ targetParam: string; depth: number }>
|
||||
onChange: (frequency: number, phase: number, waveform: LFOWaveform) => void
|
||||
onMapClick: () => void
|
||||
onUpdateDepth: (paramId: string, depth: number) => void
|
||||
onRemoveMapping: (paramId: string) => void
|
||||
}
|
||||
|
||||
const WAVEFORMS: LFOWaveform[] = ['sine', 'triangle', 'square', 'sawtooth', 'random']
|
||||
|
||||
const CANVAS_WIDTH = 340
|
||||
const CANVAS_HEIGHT = 60
|
||||
const MIN_FREQ = 0.01
|
||||
const MAX_FREQ = 20
|
||||
|
||||
export function LFOScope({ lfoIndex, waveform, frequency, phase, mappings, onChange, onMapClick, onUpdateDepth, onRemoveMapping }: LFOScopeProps) {
|
||||
const canvasRef = useRef<HTMLCanvasElement>(null)
|
||||
const lfoRef = useRef<LFO | null>(null)
|
||||
const animationRef = useRef<number | null>(null)
|
||||
const [isDragging, setIsDragging] = useState(false)
|
||||
const [showMappings, setShowMappings] = useState(false)
|
||||
const [showEditor, setShowEditor] = useState(false)
|
||||
const dragStartRef = useRef<{ x: number; y: number; freq: number; phase: number; moved: boolean } | null>(null)
|
||||
const mappingModeState = useStore(mappingMode)
|
||||
|
||||
useEffect(() => {
|
||||
if (!lfoRef.current) {
|
||||
lfoRef.current = new LFO(new AudioContext(), frequency, phase, waveform)
|
||||
} else {
|
||||
lfoRef.current.setFrequency(frequency)
|
||||
lfoRef.current.setPhase(phase)
|
||||
lfoRef.current.setWaveform(waveform)
|
||||
}
|
||||
}, [frequency, phase, waveform])
|
||||
|
||||
useEffect(() => {
|
||||
const canvas = canvasRef.current
|
||||
if (!canvas) return
|
||||
|
||||
const ctx = canvas.getContext('2d')
|
||||
if (!ctx) return
|
||||
|
||||
let time = 0
|
||||
|
||||
const render = () => {
|
||||
if (!lfoRef.current) return
|
||||
|
||||
ctx.fillStyle = '#000000'
|
||||
ctx.fillRect(0, 0, CANVAS_WIDTH, CANVAS_HEIGHT)
|
||||
|
||||
ctx.strokeStyle = '#ffffff'
|
||||
ctx.lineWidth = 2
|
||||
ctx.beginPath()
|
||||
|
||||
const samples = CANVAS_WIDTH
|
||||
const centerY = CANVAS_HEIGHT / 2
|
||||
|
||||
for (let x = 0; x < samples; x++) {
|
||||
const t = (x / samples) + time
|
||||
const phase = t % 1
|
||||
const value = getLFOValueAtPhase(phase)
|
||||
const y = centerY - (value * (centerY - 4))
|
||||
|
||||
if (x === 0) {
|
||||
ctx.moveTo(x, y)
|
||||
} else {
|
||||
ctx.lineTo(x, y)
|
||||
}
|
||||
}
|
||||
|
||||
ctx.stroke()
|
||||
|
||||
ctx.fillStyle = '#ffffff'
|
||||
ctx.font = '9px monospace'
|
||||
ctx.textAlign = 'left'
|
||||
ctx.fillText(`${frequency.toFixed(2)}Hz`, 4, 12)
|
||||
ctx.fillText(`${phase.toFixed(0)}°`, 4, 24)
|
||||
|
||||
time += frequency * 0.016
|
||||
animationRef.current = requestAnimationFrame(render)
|
||||
}
|
||||
|
||||
render()
|
||||
|
||||
return () => {
|
||||
if (animationRef.current) {
|
||||
cancelAnimationFrame(animationRef.current)
|
||||
}
|
||||
}
|
||||
}, [frequency, waveform, phase])
|
||||
|
||||
const getLFOValueAtPhase = (phase: number): number => {
|
||||
const normalizedPhase = phase % 1
|
||||
|
||||
switch (waveform) {
|
||||
case 'sine':
|
||||
return Math.sin(normalizedPhase * 2 * Math.PI)
|
||||
case 'triangle':
|
||||
return normalizedPhase < 0.5
|
||||
? -1 + 4 * normalizedPhase
|
||||
: 3 - 4 * normalizedPhase
|
||||
case 'square':
|
||||
return normalizedPhase < 0.5 ? 1 : -1
|
||||
case 'sawtooth':
|
||||
return 2 * normalizedPhase - 1
|
||||
case 'random':
|
||||
return Math.sin(normalizedPhase * 2 * Math.PI)
|
||||
default:
|
||||
return 0
|
||||
}
|
||||
}
|
||||
|
||||
const handleMouseDown = (e: React.MouseEvent<HTMLCanvasElement>) => {
|
||||
if (e.button === 2) return
|
||||
|
||||
setIsDragging(true)
|
||||
dragStartRef.current = {
|
||||
x: e.clientX,
|
||||
y: e.clientY,
|
||||
freq: frequency,
|
||||
phase: phase,
|
||||
moved: false
|
||||
}
|
||||
}
|
||||
|
||||
const handleMouseMove = (e: React.MouseEvent<HTMLCanvasElement>) => {
|
||||
if (!isDragging || !dragStartRef.current) return
|
||||
|
||||
const deltaY = dragStartRef.current.y - e.clientY
|
||||
const deltaX = e.clientX - dragStartRef.current.x
|
||||
|
||||
const distance = Math.sqrt(deltaX * deltaX + deltaY * deltaY)
|
||||
if (distance > 3) {
|
||||
dragStartRef.current.moved = true
|
||||
}
|
||||
|
||||
if (!dragStartRef.current.moved) return
|
||||
|
||||
const freqSensitivity = 0.05
|
||||
let newFreq = dragStartRef.current.freq * Math.exp(deltaY * freqSensitivity)
|
||||
newFreq = Math.max(MIN_FREQ, Math.min(MAX_FREQ, newFreq))
|
||||
|
||||
const phaseSensitivity = 2
|
||||
let newPhase = (dragStartRef.current.phase + deltaX * phaseSensitivity) % 360
|
||||
if (newPhase < 0) newPhase += 360
|
||||
|
||||
onChange(newFreq, newPhase, waveform)
|
||||
}
|
||||
|
||||
const handleMouseUp = () => {
|
||||
if (dragStartRef.current && !dragStartRef.current.moved) {
|
||||
onMapClick()
|
||||
}
|
||||
setIsDragging(false)
|
||||
dragStartRef.current = null
|
||||
}
|
||||
|
||||
const handleContextMenu = (e: React.MouseEvent<HTMLCanvasElement>) => {
|
||||
e.preventDefault()
|
||||
setShowEditor(true)
|
||||
}
|
||||
|
||||
const handleDoubleClick = () => {
|
||||
const currentIndex = WAVEFORMS.indexOf(waveform)
|
||||
const nextIndex = (currentIndex + 1) % WAVEFORMS.length
|
||||
const nextWaveform = WAVEFORMS[nextIndex]
|
||||
onChange(frequency, phase, nextWaveform)
|
||||
}
|
||||
|
||||
const isActive = mappingModeState.isActive && mappingModeState.activeLFO === lfoIndex
|
||||
const hasMappings = mappings.length > 0
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-1 w-full relative">
|
||||
<canvas
|
||||
ref={canvasRef}
|
||||
width={CANVAS_WIDTH}
|
||||
height={CANVAS_HEIGHT}
|
||||
className={`border-2 border-white cursor-move flex-1 ${
|
||||
isActive ? 'animate-pulse' : ''
|
||||
}`}
|
||||
style={{ maxWidth: CANVAS_WIDTH }}
|
||||
onMouseDown={handleMouseDown}
|
||||
onMouseMove={handleMouseMove}
|
||||
onMouseUp={handleMouseUp}
|
||||
onMouseLeave={handleMouseUp}
|
||||
onContextMenu={handleContextMenu}
|
||||
onDoubleClick={handleDoubleClick}
|
||||
onMouseEnter={() => setShowMappings(true)}
|
||||
onMouseOut={() => setShowMappings(false)}
|
||||
/>
|
||||
|
||||
{isActive && (
|
||||
<div className="absolute top-2 right-2 bg-black border border-white px-2 py-1 pointer-events-none">
|
||||
<div className="font-mono text-[9px] text-white tracking-wider">
|
||||
CLICK PARAM TO MAP | ESC TO EXIT
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{showMappings && hasMappings && (
|
||||
<div className="absolute left-2 bottom-2 bg-black border border-white p-1 z-10 pointer-events-none">
|
||||
<div className="font-mono text-[8px] text-white">
|
||||
{mappings.map((m, i) => {
|
||||
const meta = parameterRegistry.getMetadata(m.targetParam)
|
||||
return (
|
||||
<div key={i} className="whitespace-nowrap">
|
||||
{meta?.label ?? m.targetParam} ({m.depth}%)
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{showEditor && (
|
||||
<MappingEditor
|
||||
lfoIndex={lfoIndex}
|
||||
mappings={mappings}
|
||||
onUpdateDepth={onUpdateDepth}
|
||||
onRemoveMapping={onRemoveMapping}
|
||||
onClose={() => setShowEditor(false)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
81
src/components/MappingEditor.tsx
Normal file
81
src/components/MappingEditor.tsx
Normal file
@ -0,0 +1,81 @@
|
||||
import { parameterRegistry } from '../domain/modulation/ParameterRegistry'
|
||||
|
||||
interface Mapping {
|
||||
targetParam: string
|
||||
depth: number
|
||||
}
|
||||
|
||||
interface MappingEditorProps {
|
||||
lfoIndex: number
|
||||
mappings: Mapping[]
|
||||
onUpdateDepth: (paramId: string, depth: number) => void
|
||||
onRemoveMapping: (paramId: string) => void
|
||||
onClose: () => void
|
||||
}
|
||||
|
||||
export function MappingEditor({ lfoIndex, mappings, onUpdateDepth, onRemoveMapping, onClose }: MappingEditorProps) {
|
||||
return (
|
||||
<>
|
||||
<div className="fixed inset-0 z-50" onClick={onClose} />
|
||||
<div className="fixed inset-0 pointer-events-none flex items-center justify-center z-50">
|
||||
<div
|
||||
className="bg-black border-2 border-white p-4 min-w-[300px] max-w-[400px] pointer-events-auto"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<div className="flex justify-between items-center mb-4">
|
||||
<h2 className="font-mono text-sm tracking-[0.15em] text-white">
|
||||
LFO {lfoIndex + 1} MAPPINGS
|
||||
</h2>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="font-mono text-xs text-white hover:bg-white hover:text-black px-2 py-1 border-2 border-white"
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{mappings.length === 0 ? (
|
||||
<div className="font-mono text-xs text-white text-center py-4">
|
||||
NO MAPPINGS
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{mappings.map((mapping) => {
|
||||
const meta = parameterRegistry.getMetadata(mapping.targetParam)
|
||||
return (
|
||||
<div key={mapping.targetParam} className="border-2 border-white p-2">
|
||||
<div className="flex justify-between items-center mb-2">
|
||||
<span className="font-mono text-xs text-white tracking-[0.1em]">
|
||||
{meta?.label ?? mapping.targetParam}
|
||||
</span>
|
||||
<button
|
||||
onClick={() => onRemoveMapping(mapping.targetParam)}
|
||||
className="font-mono text-[10px] text-white hover:bg-white hover:text-black px-1 border border-white"
|
||||
>
|
||||
REMOVE
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
type="range"
|
||||
min="0"
|
||||
max="100"
|
||||
step="1"
|
||||
value={mapping.depth}
|
||||
onChange={(e) => onUpdateDepth(mapping.targetParam, Number(e.target.value))}
|
||||
className="flex-1 h-[2px] appearance-none cursor-pointer slider bg-white"
|
||||
/>
|
||||
<span className="font-mono text-xs text-white w-12 text-right">
|
||||
{mapping.depth}%
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@ -1,3 +1,6 @@
|
||||
import { useStore } from '@nanostores/react'
|
||||
import { mappingMode } from '../stores/mappingMode'
|
||||
|
||||
interface SliderProps {
|
||||
label: string
|
||||
value: number
|
||||
@ -8,16 +11,47 @@ interface SliderProps {
|
||||
onChange: (value: number) => void
|
||||
formatValue?: (id: string, value: number) => string
|
||||
valueId?: string
|
||||
paramId?: string
|
||||
onMapClick?: (paramId: string, activeLFO: number) => void
|
||||
mappedLFOs?: number[]
|
||||
}
|
||||
|
||||
export function Slider({ label, value, min, max, step, unit, onChange, formatValue, valueId }: SliderProps) {
|
||||
export function Slider({
|
||||
label,
|
||||
value,
|
||||
min,
|
||||
max,
|
||||
step,
|
||||
unit,
|
||||
onChange,
|
||||
formatValue,
|
||||
valueId,
|
||||
paramId,
|
||||
onMapClick,
|
||||
mappedLFOs = []
|
||||
}: SliderProps) {
|
||||
const mappingModeState = useStore(mappingMode)
|
||||
const displayValue = formatValue && valueId ? formatValue(valueId, value) : `${value}${unit || ''}`
|
||||
const isInMappingMode = !!(mappingModeState.isActive && paramId)
|
||||
const hasMappings = mappedLFOs.length > 0
|
||||
|
||||
const handleClick = () => {
|
||||
if (isInMappingMode && paramId && mappingModeState.activeLFO !== null && onMapClick) {
|
||||
onMapClick(paramId, mappingModeState.activeLFO)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-2">
|
||||
<div
|
||||
className={`flex flex-col gap-2 ${isInMappingMode ? 'cursor-pointer' : ''}`}
|
||||
onClick={handleClick}
|
||||
>
|
||||
<div className="flex justify-between items-baseline">
|
||||
<label className="font-mono text-[10px] tracking-[0.2em] text-white">
|
||||
<label className={`font-mono text-[10px] tracking-[0.2em] ${
|
||||
isInMappingMode ? 'text-white animate-pulse' : hasMappings ? 'text-white' : 'text-white'
|
||||
}`}>
|
||||
{label.toUpperCase()}
|
||||
{hasMappings && <span className="ml-1 text-[8px]">●</span>}
|
||||
</label>
|
||||
<span className="font-mono text-[10px] text-white">
|
||||
{displayValue}
|
||||
@ -30,7 +64,10 @@ export function Slider({ label, value, min, max, step, unit, onChange, formatVal
|
||||
step={step}
|
||||
value={value}
|
||||
onChange={(e) => onChange(Number(e.target.value))}
|
||||
className="w-full h-[2px] bg-white appearance-none cursor-pointer slider"
|
||||
className={`w-full h-[2px] appearance-none cursor-pointer slider ${
|
||||
hasMappings ? 'bg-white opacity-80' : 'bg-white'
|
||||
}`}
|
||||
disabled={isInMappingMode}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
|
||||
@ -50,6 +50,15 @@ export const ENGINE_CONTROLS: EffectConfig[] = [
|
||||
step: 1,
|
||||
unit: '%'
|
||||
},
|
||||
{
|
||||
id: 'pitch',
|
||||
label: 'Pitch',
|
||||
min: 0.25,
|
||||
max: 4,
|
||||
default: 1,
|
||||
step: 0.01,
|
||||
unit: 'x'
|
||||
},
|
||||
{
|
||||
id: 'a',
|
||||
label: 'A',
|
||||
@ -94,85 +103,38 @@ export const EFFECTS: EffectConfig[] = [
|
||||
{
|
||||
id: 'filter',
|
||||
name: 'Filter',
|
||||
bypassable: true,
|
||||
parameters: [
|
||||
{
|
||||
id: 'hpEnable',
|
||||
label: 'HP',
|
||||
id: 'filterMode',
|
||||
label: 'Mode',
|
||||
min: 0,
|
||||
max: 1,
|
||||
default: 0,
|
||||
max: 0,
|
||||
default: 'lowpass',
|
||||
step: 1,
|
||||
unit: ''
|
||||
unit: '',
|
||||
options: [
|
||||
{ value: 'lowpass', label: 'LP' },
|
||||
{ value: 'highpass', label: 'HP' },
|
||||
{ value: 'bandpass', label: 'BP' },
|
||||
{ value: 'notch', label: 'Notch' }
|
||||
]
|
||||
},
|
||||
{
|
||||
id: 'hpFreq',
|
||||
label: 'HP Freq',
|
||||
min: 20,
|
||||
max: 10000,
|
||||
default: 1000,
|
||||
step: 10,
|
||||
unit: 'Hz'
|
||||
},
|
||||
{
|
||||
id: 'hpRes',
|
||||
label: 'HP Q',
|
||||
min: 0.1,
|
||||
max: 20,
|
||||
default: 1,
|
||||
step: 0.1,
|
||||
unit: ''
|
||||
},
|
||||
{
|
||||
id: 'lpEnable',
|
||||
label: 'LP',
|
||||
min: 0,
|
||||
max: 1,
|
||||
default: 0,
|
||||
step: 1,
|
||||
unit: ''
|
||||
},
|
||||
{
|
||||
id: 'lpFreq',
|
||||
label: 'LP Freq',
|
||||
id: 'filterFreq',
|
||||
label: 'Freq',
|
||||
min: 20,
|
||||
max: 20000,
|
||||
default: 5000,
|
||||
step: 10,
|
||||
unit: 'Hz'
|
||||
},
|
||||
{
|
||||
id: 'lpRes',
|
||||
label: 'LP Q',
|
||||
min: 0.1,
|
||||
max: 20,
|
||||
default: 1,
|
||||
step: 0.1,
|
||||
unit: ''
|
||||
},
|
||||
{
|
||||
id: 'bpEnable',
|
||||
label: 'BP',
|
||||
min: 0,
|
||||
max: 1,
|
||||
default: 0,
|
||||
step: 1,
|
||||
unit: ''
|
||||
},
|
||||
{
|
||||
id: 'bpFreq',
|
||||
label: 'BP Freq',
|
||||
min: 20,
|
||||
max: 10000,
|
||||
default: 1000,
|
||||
step: 10,
|
||||
unit: 'Hz'
|
||||
},
|
||||
{
|
||||
id: 'bpRes',
|
||||
label: 'BP Q',
|
||||
min: 0.1,
|
||||
max: 20,
|
||||
default: 1,
|
||||
id: 'filterRes',
|
||||
label: 'Res',
|
||||
min: 0.5,
|
||||
max: 10,
|
||||
default: 0.707,
|
||||
step: 0.1,
|
||||
unit: ''
|
||||
}
|
||||
|
||||
@ -11,7 +11,7 @@ export const PLAYBACK_ID = {
|
||||
|
||||
export const TILE_GRID = {
|
||||
SIZE: 100,
|
||||
COLUMNS: 2
|
||||
COLUMNS: 4
|
||||
} as const
|
||||
|
||||
export const DEFAULT_DOWNLOAD_OPTIONS = {
|
||||
|
||||
@ -1,5 +1,6 @@
|
||||
import { EffectsChain } from './effects/EffectsChain'
|
||||
import { BytebeatSourceEffect } from './effects/BytebeatSourceEffect'
|
||||
import { ModulationEngine } from '../modulation/ModulationEngine'
|
||||
import type { EffectValues } from '../../types/effects'
|
||||
|
||||
export interface AudioPlayerOptions {
|
||||
@ -11,11 +12,13 @@ export class AudioPlayer {
|
||||
private audioContext: AudioContext | null = null
|
||||
private bytebeatSource: BytebeatSourceEffect | null = null
|
||||
private effectsChain: EffectsChain | null = null
|
||||
private modulationEngine: ModulationEngine | null = null
|
||||
private effectValues: EffectValues = {}
|
||||
private startTime: number = 0
|
||||
private sampleRate: number
|
||||
private duration: number
|
||||
private workletRegistered: boolean = false
|
||||
private currentPitch: number = 1.0
|
||||
|
||||
constructor(options: AudioPlayerOptions) {
|
||||
this.sampleRate = options.sampleRate
|
||||
@ -51,22 +54,20 @@ export class AudioPlayer {
|
||||
this.effectsChain.updateEffects(this.effectValues)
|
||||
|
||||
if (wasPlaying) {
|
||||
console.warn('Audio context recreated due to sample rate change. Playback stopped.')
|
||||
throw new Error('Cannot change sample rate during playback')
|
||||
}
|
||||
}
|
||||
|
||||
private async registerWorklet(context: AudioContext): Promise<void> {
|
||||
if (this.workletRegistered) return
|
||||
|
||||
try {
|
||||
await Promise.all([
|
||||
context.audioWorklet.addModule('/worklets/fold-crush-processor.js'),
|
||||
context.audioWorklet.addModule('/worklets/bytebeat-processor.js')
|
||||
])
|
||||
this.workletRegistered = true
|
||||
} catch (error) {
|
||||
console.error('Failed to register AudioWorklet:', error)
|
||||
}
|
||||
await Promise.all([
|
||||
context.audioWorklet.addModule('/worklets/svf-processor.js'),
|
||||
context.audioWorklet.addModule('/worklets/fold-crush-processor.js'),
|
||||
context.audioWorklet.addModule('/worklets/bytebeat-processor.js'),
|
||||
context.audioWorklet.addModule('/worklets/output-limiter.js')
|
||||
])
|
||||
this.workletRegistered = true
|
||||
}
|
||||
|
||||
setEffects(values: EffectValues): void {
|
||||
@ -74,6 +75,15 @@ export class AudioPlayer {
|
||||
if (this.effectsChain) {
|
||||
this.effectsChain.updateEffects(values)
|
||||
}
|
||||
if (this.modulationEngine) {
|
||||
const numericValues: Record<string, number> = {}
|
||||
Object.entries(values).forEach(([key, value]) => {
|
||||
if (typeof value === 'number') {
|
||||
numericValues[key] = value
|
||||
}
|
||||
})
|
||||
this.modulationEngine.setBaseValues(numericValues)
|
||||
}
|
||||
}
|
||||
|
||||
private async ensureAudioContext(): Promise<void> {
|
||||
@ -87,6 +97,47 @@ export class AudioPlayer {
|
||||
await this.effectsChain.initialize(this.audioContext)
|
||||
this.effectsChain.updateEffects(this.effectValues)
|
||||
}
|
||||
|
||||
if (!this.modulationEngine) {
|
||||
this.modulationEngine = new ModulationEngine(this.audioContext, 4)
|
||||
this.registerModulatableParams()
|
||||
}
|
||||
}
|
||||
|
||||
private registerModulatableParams(): void {
|
||||
if (!this.modulationEngine || !this.effectsChain) return
|
||||
|
||||
const effects = this.effectsChain.getEffects()
|
||||
for (const effect of effects) {
|
||||
if (effect.getModulatableParams) {
|
||||
const params = effect.getModulatableParams()
|
||||
params.forEach((audioParam, paramId) => {
|
||||
const baseValue = this.effectValues[paramId] as number ?? audioParam.value
|
||||
this.modulationEngine!.registerParameter(
|
||||
paramId,
|
||||
{ audioParam },
|
||||
baseValue
|
||||
)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
this.modulationEngine.registerParameter(
|
||||
'pitch',
|
||||
{ callback: (value: number) => this.applyPitch(value) },
|
||||
this.currentPitch
|
||||
)
|
||||
}
|
||||
|
||||
setLFOConfig(lfoIndex: number, config: { frequency: number; phase: number; waveform: string; mappings: Array<{ targetParam: string; depth: number }> }): void {
|
||||
if (!this.modulationEngine) return
|
||||
|
||||
this.modulationEngine.updateLFO(lfoIndex, config.frequency, config.phase, config.waveform as any)
|
||||
this.modulationEngine.clearMappings(lfoIndex)
|
||||
|
||||
for (const mapping of config.mappings) {
|
||||
this.modulationEngine.addMapping(lfoIndex, mapping.targetParam, mapping.depth)
|
||||
}
|
||||
}
|
||||
|
||||
async playRealtime(formula: string, a: number, b: number, c: number, d: number): Promise<void> {
|
||||
@ -100,11 +151,16 @@ export class AudioPlayer {
|
||||
this.bytebeatSource.setLoopLength(this.sampleRate, this.duration)
|
||||
this.bytebeatSource.setFormula(formula)
|
||||
this.bytebeatSource.setVariables(a, b, c, d)
|
||||
this.bytebeatSource.setPlaybackRate(this.currentPitch)
|
||||
this.bytebeatSource.reset()
|
||||
|
||||
this.bytebeatSource.getOutputNode().connect(this.effectsChain!.getInputNode())
|
||||
this.effectsChain!.getOutputNode().connect(this.audioContext!.destination)
|
||||
|
||||
if (this.modulationEngine) {
|
||||
this.modulationEngine.start()
|
||||
}
|
||||
|
||||
this.startTime = this.audioContext!.currentTime
|
||||
}
|
||||
|
||||
@ -114,6 +170,20 @@ export class AudioPlayer {
|
||||
}
|
||||
}
|
||||
|
||||
private applyPitch(pitch: number): void {
|
||||
if (this.bytebeatSource) {
|
||||
this.bytebeatSource.setPlaybackRate(pitch)
|
||||
}
|
||||
}
|
||||
|
||||
updatePitch(pitch: number): void {
|
||||
this.currentPitch = pitch
|
||||
this.applyPitch(pitch)
|
||||
if (this.modulationEngine) {
|
||||
this.modulationEngine.updateBaseValue('pitch', pitch)
|
||||
}
|
||||
}
|
||||
|
||||
getPlaybackPosition(): number {
|
||||
if (!this.audioContext || this.startTime === 0) {
|
||||
return 0
|
||||
@ -126,6 +196,9 @@ export class AudioPlayer {
|
||||
if (this.bytebeatSource) {
|
||||
this.bytebeatSource.getOutputNode().disconnect()
|
||||
}
|
||||
if (this.modulationEngine) {
|
||||
this.modulationEngine.stop()
|
||||
}
|
||||
this.startTime = 0
|
||||
}
|
||||
|
||||
@ -135,6 +208,10 @@ export class AudioPlayer {
|
||||
this.bytebeatSource.dispose()
|
||||
this.bytebeatSource = null
|
||||
}
|
||||
if (this.modulationEngine) {
|
||||
this.modulationEngine.dispose()
|
||||
this.modulationEngine = null
|
||||
}
|
||||
if (this.effectsChain) {
|
||||
this.effectsChain.dispose()
|
||||
this.effectsChain = null
|
||||
|
||||
@ -13,12 +13,8 @@ export class BytebeatSourceEffect implements Effect {
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
this.processorNode = new AudioWorkletNode(audioContext, 'bytebeat-processor')
|
||||
this.processorNode.connect(this.outputNode)
|
||||
}
|
||||
|
||||
getInputNode(): AudioNode {
|
||||
@ -53,6 +49,11 @@ export class BytebeatSourceEffect implements Effect {
|
||||
this.processorNode.port.postMessage({ type: 'loopLength', value: loopLength })
|
||||
}
|
||||
|
||||
setPlaybackRate(rate: number): void {
|
||||
if (!this.processorNode) return
|
||||
this.processorNode.port.postMessage({ type: 'playbackRate', value: rate })
|
||||
}
|
||||
|
||||
reset(): void {
|
||||
if (!this.processorNode) return
|
||||
this.processorNode.port.postMessage({ type: 'reset' })
|
||||
|
||||
@ -4,6 +4,7 @@ export interface Effect {
|
||||
getOutputNode(): AudioNode
|
||||
updateParams(values: Record<string, number | string>): void
|
||||
setBypass(bypass: boolean): void
|
||||
getModulatableParams?(): Map<string, AudioParam>
|
||||
dispose(): void
|
||||
}
|
||||
|
||||
|
||||
@ -3,33 +3,43 @@ import { FilterEffect } from './FilterEffect'
|
||||
import { FoldCrushEffect } from './FoldCrushEffect'
|
||||
import { DelayEffect } from './DelayEffect'
|
||||
import { ReverbEffect } from './ReverbEffect'
|
||||
import { OutputLimiter } from './OutputLimiter'
|
||||
|
||||
export class EffectsChain {
|
||||
private inputNode: GainNode
|
||||
private outputNode: GainNode
|
||||
private masterGainNode: GainNode
|
||||
private effects: Effect[]
|
||||
private filterEffect: FilterEffect
|
||||
private foldCrushEffect: FoldCrushEffect
|
||||
private outputLimiter: OutputLimiter
|
||||
|
||||
constructor(audioContext: AudioContext) {
|
||||
this.inputNode = audioContext.createGain()
|
||||
this.outputNode = audioContext.createGain()
|
||||
this.masterGainNode = audioContext.createGain()
|
||||
|
||||
this.filterEffect = new FilterEffect(audioContext)
|
||||
this.foldCrushEffect = new FoldCrushEffect(audioContext)
|
||||
this.outputLimiter = new OutputLimiter(audioContext)
|
||||
|
||||
this.effects = [
|
||||
new FilterEffect(audioContext),
|
||||
this.filterEffect,
|
||||
this.foldCrushEffect,
|
||||
new DelayEffect(audioContext),
|
||||
new ReverbEffect(audioContext)
|
||||
new ReverbEffect(audioContext),
|
||||
this.outputLimiter
|
||||
]
|
||||
|
||||
this.setupChain()
|
||||
}
|
||||
|
||||
async initialize(audioContext: AudioContext): Promise<void> {
|
||||
await this.foldCrushEffect.initialize(audioContext)
|
||||
await Promise.all([
|
||||
this.filterEffect.initialize(audioContext),
|
||||
this.foldCrushEffect.initialize(audioContext),
|
||||
this.outputLimiter.initialize(audioContext)
|
||||
])
|
||||
}
|
||||
|
||||
private setupChain(): void {
|
||||
@ -44,6 +54,10 @@ export class EffectsChain {
|
||||
this.masterGainNode.connect(this.outputNode)
|
||||
}
|
||||
|
||||
getEffects(): Effect[] {
|
||||
return this.effects
|
||||
}
|
||||
|
||||
updateEffects(values: Record<string, number | boolean | string>): void {
|
||||
for (const effect of this.effects) {
|
||||
const effectId = effect.id
|
||||
|
||||
@ -3,20 +3,13 @@ import type { Effect } from './Effect.interface'
|
||||
export class FilterEffect implements Effect {
|
||||
readonly id = 'filter'
|
||||
|
||||
private audioContext: AudioContext
|
||||
private inputNode: GainNode
|
||||
private outputNode: GainNode
|
||||
private processorNode: AudioWorkletNode | null = null
|
||||
private wetNode: GainNode
|
||||
private dryNode: GainNode
|
||||
private hpFilter: BiquadFilterNode
|
||||
private lpFilter: BiquadFilterNode
|
||||
private bpFilter: BiquadFilterNode
|
||||
private hpEnabled: boolean = false
|
||||
private lpEnabled: boolean = false
|
||||
private bpEnabled: boolean = false
|
||||
|
||||
constructor(audioContext: AudioContext) {
|
||||
this.audioContext = audioContext
|
||||
this.inputNode = audioContext.createGain()
|
||||
this.outputNode = audioContext.createGain()
|
||||
this.wetNode = audioContext.createGain()
|
||||
@ -25,27 +18,14 @@ export class FilterEffect implements Effect {
|
||||
this.wetNode.gain.value = 0
|
||||
this.dryNode.gain.value = 1
|
||||
|
||||
this.hpFilter = audioContext.createBiquadFilter()
|
||||
this.hpFilter.type = 'highpass'
|
||||
this.hpFilter.frequency.value = 1000
|
||||
this.hpFilter.Q.value = 1
|
||||
|
||||
this.lpFilter = audioContext.createBiquadFilter()
|
||||
this.lpFilter.type = 'lowpass'
|
||||
this.lpFilter.frequency.value = 5000
|
||||
this.lpFilter.Q.value = 1
|
||||
|
||||
this.bpFilter = audioContext.createBiquadFilter()
|
||||
this.bpFilter.type = 'bandpass'
|
||||
this.bpFilter.frequency.value = 1000
|
||||
this.bpFilter.Q.value = 1
|
||||
|
||||
this.inputNode.connect(this.dryNode)
|
||||
this.inputNode.connect(this.hpFilter)
|
||||
this.hpFilter.connect(this.lpFilter)
|
||||
this.lpFilter.connect(this.bpFilter)
|
||||
this.bpFilter.connect(this.wetNode)
|
||||
this.dryNode.connect(this.outputNode)
|
||||
}
|
||||
|
||||
async initialize(audioContext: AudioContext): Promise<void> {
|
||||
this.processorNode = new AudioWorkletNode(audioContext, 'svf-processor')
|
||||
this.inputNode.connect(this.processorNode)
|
||||
this.processorNode.connect(this.wetNode)
|
||||
this.wetNode.connect(this.outputNode)
|
||||
}
|
||||
|
||||
@ -57,117 +37,46 @@ export class FilterEffect implements Effect {
|
||||
return this.outputNode
|
||||
}
|
||||
|
||||
setBypass(_bypass: boolean): void {
|
||||
// No global bypass for filters - each filter has individual enable switch
|
||||
}
|
||||
|
||||
private updateBypassState(): void {
|
||||
const anyEnabled = this.hpEnabled || this.lpEnabled || this.bpEnabled
|
||||
if (anyEnabled) {
|
||||
this.wetNode.gain.value = 1
|
||||
this.dryNode.gain.value = 0
|
||||
} else {
|
||||
setBypass(bypass: boolean): void {
|
||||
if (bypass) {
|
||||
this.wetNode.gain.value = 0
|
||||
this.dryNode.gain.value = 1
|
||||
} else {
|
||||
this.wetNode.gain.value = 1
|
||||
this.dryNode.gain.value = 0
|
||||
}
|
||||
}
|
||||
|
||||
getModulatableParams(): Map<string, AudioParam> {
|
||||
if (!this.processorNode) return new Map()
|
||||
|
||||
const params = new Map<string, AudioParam>()
|
||||
params.set('filterFreq', this.processorNode.parameters.get('frequency')!)
|
||||
params.set('filterRes', this.processorNode.parameters.get('resonance')!)
|
||||
return params
|
||||
}
|
||||
|
||||
updateParams(values: Record<string, number | string>): void {
|
||||
if (values.hpEnable !== undefined) {
|
||||
this.hpEnabled = values.hpEnable === 1
|
||||
this.updateBypassState()
|
||||
}
|
||||
if (!this.processorNode) return
|
||||
|
||||
if (values.hpFreq !== undefined && typeof values.hpFreq === 'number') {
|
||||
this.hpFilter.frequency.cancelScheduledValues(this.audioContext.currentTime)
|
||||
this.hpFilter.frequency.setValueAtTime(
|
||||
this.hpFilter.frequency.value,
|
||||
this.audioContext.currentTime
|
||||
)
|
||||
this.hpFilter.frequency.linearRampToValueAtTime(
|
||||
values.hpFreq,
|
||||
this.audioContext.currentTime + 0.02
|
||||
)
|
||||
if (values.filterMode !== undefined) {
|
||||
this.processorNode.port.postMessage({ type: 'mode', value: values.filterMode })
|
||||
}
|
||||
|
||||
if (values.hpRes !== undefined && typeof values.hpRes === 'number') {
|
||||
this.hpFilter.Q.cancelScheduledValues(this.audioContext.currentTime)
|
||||
this.hpFilter.Q.setValueAtTime(
|
||||
this.hpFilter.Q.value,
|
||||
this.audioContext.currentTime
|
||||
)
|
||||
this.hpFilter.Q.linearRampToValueAtTime(
|
||||
values.hpRes,
|
||||
this.audioContext.currentTime + 0.02
|
||||
)
|
||||
if (values.filterFreq !== undefined && typeof values.filterFreq === 'number') {
|
||||
this.processorNode.parameters.get('frequency')!.value = values.filterFreq
|
||||
}
|
||||
|
||||
if (values.lpEnable !== undefined) {
|
||||
this.lpEnabled = values.lpEnable === 1
|
||||
this.updateBypassState()
|
||||
}
|
||||
|
||||
if (values.lpFreq !== undefined && typeof values.lpFreq === 'number') {
|
||||
this.lpFilter.frequency.cancelScheduledValues(this.audioContext.currentTime)
|
||||
this.lpFilter.frequency.setValueAtTime(
|
||||
this.lpFilter.frequency.value,
|
||||
this.audioContext.currentTime
|
||||
)
|
||||
this.lpFilter.frequency.linearRampToValueAtTime(
|
||||
values.lpFreq,
|
||||
this.audioContext.currentTime + 0.02
|
||||
)
|
||||
}
|
||||
|
||||
if (values.lpRes !== undefined && typeof values.lpRes === 'number') {
|
||||
this.lpFilter.Q.cancelScheduledValues(this.audioContext.currentTime)
|
||||
this.lpFilter.Q.setValueAtTime(
|
||||
this.lpFilter.Q.value,
|
||||
this.audioContext.currentTime
|
||||
)
|
||||
this.lpFilter.Q.linearRampToValueAtTime(
|
||||
values.lpRes,
|
||||
this.audioContext.currentTime + 0.02
|
||||
)
|
||||
}
|
||||
|
||||
if (values.bpEnable !== undefined) {
|
||||
this.bpEnabled = values.bpEnable === 1
|
||||
this.updateBypassState()
|
||||
}
|
||||
|
||||
if (values.bpFreq !== undefined && typeof values.bpFreq === 'number') {
|
||||
this.bpFilter.frequency.cancelScheduledValues(this.audioContext.currentTime)
|
||||
this.bpFilter.frequency.setValueAtTime(
|
||||
this.bpFilter.frequency.value,
|
||||
this.audioContext.currentTime
|
||||
)
|
||||
this.bpFilter.frequency.linearRampToValueAtTime(
|
||||
values.bpFreq,
|
||||
this.audioContext.currentTime + 0.02
|
||||
)
|
||||
}
|
||||
|
||||
if (values.bpRes !== undefined && typeof values.bpRes === 'number') {
|
||||
this.bpFilter.Q.cancelScheduledValues(this.audioContext.currentTime)
|
||||
this.bpFilter.Q.setValueAtTime(
|
||||
this.bpFilter.Q.value,
|
||||
this.audioContext.currentTime
|
||||
)
|
||||
this.bpFilter.Q.linearRampToValueAtTime(
|
||||
values.bpRes,
|
||||
this.audioContext.currentTime + 0.02
|
||||
)
|
||||
if (values.filterRes !== undefined && typeof values.filterRes === 'number') {
|
||||
this.processorNode.parameters.get('resonance')!.value = values.filterRes
|
||||
}
|
||||
}
|
||||
|
||||
dispose(): void {
|
||||
this.inputNode.disconnect()
|
||||
this.outputNode.disconnect()
|
||||
if (this.processorNode) {
|
||||
this.processorNode.disconnect()
|
||||
}
|
||||
this.wetNode.disconnect()
|
||||
this.dryNode.disconnect()
|
||||
this.hpFilter.disconnect()
|
||||
this.lpFilter.disconnect()
|
||||
this.bpFilter.disconnect()
|
||||
this.inputNode.disconnect()
|
||||
this.outputNode.disconnect()
|
||||
}
|
||||
}
|
||||
|
||||
@ -23,14 +23,10 @@ export class FoldCrushEffect implements Effect {
|
||||
}
|
||||
|
||||
async initialize(audioContext: AudioContext): Promise<void> {
|
||||
try {
|
||||
this.processorNode = new AudioWorkletNode(audioContext, 'fold-crush-processor')
|
||||
this.inputNode.connect(this.processorNode)
|
||||
this.processorNode.connect(this.wetNode)
|
||||
this.wetNode.connect(this.outputNode)
|
||||
} catch (error) {
|
||||
console.error('Failed to initialize FoldCrushEffect worklet:', error)
|
||||
}
|
||||
this.processorNode = new AudioWorkletNode(audioContext, 'fold-crush-processor')
|
||||
this.inputNode.connect(this.processorNode)
|
||||
this.processorNode.connect(this.wetNode)
|
||||
this.wetNode.connect(this.outputNode)
|
||||
}
|
||||
|
||||
getInputNode(): AudioNode {
|
||||
|
||||
48
src/domain/audio/effects/OutputLimiter.ts
Normal file
48
src/domain/audio/effects/OutputLimiter.ts
Normal file
@ -0,0 +1,48 @@
|
||||
import type { Effect } from './Effect.interface'
|
||||
|
||||
export class OutputLimiter implements Effect {
|
||||
readonly id = 'limiter'
|
||||
|
||||
private inputNode: GainNode
|
||||
private outputNode: GainNode
|
||||
private processorNode: AudioWorkletNode | null = null
|
||||
|
||||
constructor(audioContext: AudioContext) {
|
||||
this.inputNode = audioContext.createGain()
|
||||
this.outputNode = audioContext.createGain()
|
||||
|
||||
this.inputNode.connect(this.outputNode)
|
||||
}
|
||||
|
||||
async initialize(audioContext: AudioContext): Promise<void> {
|
||||
this.processorNode = new AudioWorkletNode(audioContext, 'output-limiter')
|
||||
|
||||
this.inputNode.disconnect()
|
||||
this.inputNode.connect(this.processorNode)
|
||||
this.processorNode.connect(this.outputNode)
|
||||
}
|
||||
|
||||
getInputNode(): AudioNode {
|
||||
return this.inputNode
|
||||
}
|
||||
|
||||
getOutputNode(): AudioNode {
|
||||
return this.outputNode
|
||||
}
|
||||
|
||||
setBypass(_bypass: boolean): void {
|
||||
// Output limiter is always on
|
||||
}
|
||||
|
||||
updateParams(_values: Record<string, number | string>): void {
|
||||
// Uses default parameters from worklet
|
||||
}
|
||||
|
||||
dispose(): void {
|
||||
if (this.processorNode) {
|
||||
this.processorNode.disconnect()
|
||||
}
|
||||
this.inputNode.disconnect()
|
||||
this.outputNode.disconnect()
|
||||
}
|
||||
}
|
||||
66
src/domain/modulation/LFO.ts
Normal file
66
src/domain/modulation/LFO.ts
Normal file
@ -0,0 +1,66 @@
|
||||
export type LFOWaveform = 'sine' | 'triangle' | 'square' | 'sawtooth' | 'random'
|
||||
|
||||
export class LFO {
|
||||
private startTime: number
|
||||
private frequency: number
|
||||
private phase: number
|
||||
private waveform: LFOWaveform
|
||||
private audioContext: AudioContext
|
||||
private lastRandomValue: number = 0
|
||||
private lastRandomTime: number = 0
|
||||
|
||||
constructor(audioContext: AudioContext, frequency: number = 1, phase: number = 0, waveform: LFOWaveform = 'sine') {
|
||||
this.audioContext = audioContext
|
||||
this.frequency = frequency
|
||||
this.phase = phase
|
||||
this.waveform = waveform
|
||||
this.startTime = audioContext.currentTime
|
||||
}
|
||||
|
||||
setFrequency(frequency: number): void {
|
||||
this.frequency = frequency
|
||||
}
|
||||
|
||||
setPhase(phase: number): void {
|
||||
this.phase = phase
|
||||
}
|
||||
|
||||
setWaveform(waveform: LFOWaveform): void {
|
||||
this.waveform = waveform
|
||||
}
|
||||
|
||||
getValue(time?: number): number {
|
||||
const currentTime = time ?? this.audioContext.currentTime
|
||||
const elapsed = currentTime - this.startTime
|
||||
const phaseOffset = (this.phase / 360) * (1 / this.frequency)
|
||||
const phase = ((elapsed + phaseOffset) * this.frequency) % 1
|
||||
|
||||
switch (this.waveform) {
|
||||
case 'sine':
|
||||
return Math.sin(phase * 2 * Math.PI)
|
||||
|
||||
case 'triangle':
|
||||
return phase < 0.5
|
||||
? -1 + 4 * phase
|
||||
: 3 - 4 * phase
|
||||
|
||||
case 'square':
|
||||
return phase < 0.5 ? 1 : -1
|
||||
|
||||
case 'sawtooth':
|
||||
return 2 * phase - 1
|
||||
|
||||
case 'random': {
|
||||
const interval = 1 / this.frequency
|
||||
if (currentTime - this.lastRandomTime >= interval) {
|
||||
this.lastRandomValue = Math.random() * 2 - 1
|
||||
this.lastRandomTime = currentTime
|
||||
}
|
||||
return this.lastRandomValue
|
||||
}
|
||||
|
||||
default:
|
||||
return 0
|
||||
}
|
||||
}
|
||||
}
|
||||
159
src/domain/modulation/ModulationEngine.ts
Normal file
159
src/domain/modulation/ModulationEngine.ts
Normal file
@ -0,0 +1,159 @@
|
||||
import { LFO, type LFOWaveform } from './LFO'
|
||||
import { parameterRegistry } from './ParameterRegistry'
|
||||
|
||||
export interface LFOMapping {
|
||||
lfoIndex: number
|
||||
targetParam: string
|
||||
depth: number
|
||||
}
|
||||
|
||||
export interface ParameterTarget {
|
||||
audioParam?: AudioParam
|
||||
callback?: (value: number) => void
|
||||
}
|
||||
|
||||
export class ModulationEngine {
|
||||
private audioContext: AudioContext
|
||||
private lfos: LFO[]
|
||||
private mappings: LFOMapping[]
|
||||
private paramTargets: Map<string, ParameterTarget>
|
||||
private baseValues: Map<string, number>
|
||||
private animationFrameId: number | null = null
|
||||
private isRunning: boolean = false
|
||||
|
||||
constructor(audioContext: AudioContext, lfoCount: number = 4) {
|
||||
this.audioContext = audioContext
|
||||
this.lfos = []
|
||||
this.mappings = []
|
||||
this.paramTargets = new Map()
|
||||
this.baseValues = new Map()
|
||||
|
||||
for (let i = 0; i < lfoCount; i++) {
|
||||
this.lfos.push(new LFO(audioContext))
|
||||
}
|
||||
}
|
||||
|
||||
registerParameter(paramId: string, target: ParameterTarget, baseValue: number): void {
|
||||
this.paramTargets.set(paramId, target)
|
||||
this.baseValues.set(paramId, baseValue)
|
||||
}
|
||||
|
||||
updateBaseValue(paramId: string, baseValue: number): void {
|
||||
this.baseValues.set(paramId, baseValue)
|
||||
}
|
||||
|
||||
setBaseValues(values: Record<string, number>): void {
|
||||
Object.entries(values).forEach(([paramId, value]) => {
|
||||
this.baseValues.set(paramId, value)
|
||||
})
|
||||
}
|
||||
|
||||
addMapping(lfoIndex: number, targetParam: string, depth: number): void {
|
||||
const existingIndex = this.mappings.findIndex(
|
||||
m => m.lfoIndex === lfoIndex && m.targetParam === targetParam
|
||||
)
|
||||
|
||||
if (existingIndex >= 0) {
|
||||
this.mappings[existingIndex].depth = depth
|
||||
} else {
|
||||
this.mappings.push({ lfoIndex, targetParam, depth })
|
||||
}
|
||||
}
|
||||
|
||||
removeMapping(lfoIndex: number, targetParam: string): void {
|
||||
this.mappings = this.mappings.filter(
|
||||
m => !(m.lfoIndex === lfoIndex && m.targetParam === targetParam)
|
||||
)
|
||||
}
|
||||
|
||||
clearMappings(lfoIndex?: number): void {
|
||||
if (lfoIndex !== undefined) {
|
||||
this.mappings = this.mappings.filter(m => m.lfoIndex !== lfoIndex)
|
||||
} else {
|
||||
this.mappings = []
|
||||
}
|
||||
}
|
||||
|
||||
updateLFO(lfoIndex: number, frequency: number, phase: number, waveform: LFOWaveform): void {
|
||||
const lfo = this.lfos[lfoIndex]
|
||||
if (!lfo) return
|
||||
|
||||
lfo.setFrequency(frequency)
|
||||
lfo.setPhase(phase)
|
||||
lfo.setWaveform(waveform)
|
||||
}
|
||||
|
||||
private updateModulation = (): void => {
|
||||
if (!this.isRunning) return
|
||||
|
||||
const currentTime = this.audioContext.currentTime
|
||||
|
||||
const modulatedValues = new Map<string, number>()
|
||||
|
||||
for (const [paramId, baseValue] of this.baseValues) {
|
||||
modulatedValues.set(paramId, baseValue)
|
||||
}
|
||||
|
||||
for (const mapping of this.mappings) {
|
||||
const lfo = this.lfos[mapping.lfoIndex]
|
||||
if (!lfo) continue
|
||||
|
||||
const baseValue = this.baseValues.get(mapping.targetParam)
|
||||
if (baseValue === undefined) continue
|
||||
|
||||
const meta = parameterRegistry.getMetadata(mapping.targetParam)
|
||||
if (!meta) continue
|
||||
|
||||
const lfoValue = lfo.getValue(currentTime)
|
||||
const normalized = parameterRegistry.normalizeValue(mapping.targetParam, baseValue)
|
||||
const depthNormalized = (mapping.depth / 100) * lfoValue
|
||||
const modulatedNormalized = normalized + depthNormalized
|
||||
const modulatedValue = parameterRegistry.denormalizeValue(mapping.targetParam, modulatedNormalized)
|
||||
const clampedValue = parameterRegistry.clampValue(mapping.targetParam, modulatedValue)
|
||||
|
||||
modulatedValues.set(mapping.targetParam, clampedValue)
|
||||
}
|
||||
|
||||
for (const [paramId, value] of modulatedValues) {
|
||||
const target = this.paramTargets.get(paramId)
|
||||
if (!target) continue
|
||||
|
||||
if (target.audioParam) {
|
||||
target.audioParam.setTargetAtTime(value, currentTime, 0.01)
|
||||
} else if (target.callback) {
|
||||
target.callback(value)
|
||||
}
|
||||
}
|
||||
|
||||
this.animationFrameId = requestAnimationFrame(this.updateModulation)
|
||||
}
|
||||
|
||||
start(): void {
|
||||
if (this.isRunning) return
|
||||
this.isRunning = true
|
||||
this.updateModulation()
|
||||
}
|
||||
|
||||
stop(): void {
|
||||
this.isRunning = false
|
||||
if (this.animationFrameId !== null) {
|
||||
cancelAnimationFrame(this.animationFrameId)
|
||||
this.animationFrameId = null
|
||||
}
|
||||
}
|
||||
|
||||
getMappingsForLFO(lfoIndex: number): LFOMapping[] {
|
||||
return this.mappings.filter(m => m.lfoIndex === lfoIndex)
|
||||
}
|
||||
|
||||
getMappingsForParam(paramId: string): LFOMapping[] {
|
||||
return this.mappings.filter(m => m.targetParam === paramId)
|
||||
}
|
||||
|
||||
dispose(): void {
|
||||
this.stop()
|
||||
this.mappings = []
|
||||
this.paramTargets.clear()
|
||||
this.baseValues.clear()
|
||||
}
|
||||
}
|
||||
133
src/domain/modulation/ParameterRegistry.ts
Normal file
133
src/domain/modulation/ParameterRegistry.ts
Normal file
@ -0,0 +1,133 @@
|
||||
import { ENGINE_CONTROLS, EFFECTS } from '../../config/effects'
|
||||
import type { EffectParameter } from '../../types/effects'
|
||||
|
||||
export type ParameterScaling = 'linear' | 'exponential'
|
||||
|
||||
export interface ParameterMetadata {
|
||||
id: string
|
||||
label: string
|
||||
min: number
|
||||
max: number
|
||||
step: number
|
||||
unit?: string
|
||||
scaling: ParameterScaling
|
||||
isAudioParam: boolean
|
||||
category: 'engine' | 'effect'
|
||||
effectId?: string
|
||||
}
|
||||
|
||||
export class ParameterRegistry {
|
||||
private metadata: Map<string, ParameterMetadata> = new Map()
|
||||
|
||||
constructor() {
|
||||
this.buildRegistry()
|
||||
}
|
||||
|
||||
private buildRegistry(): void {
|
||||
ENGINE_CONTROLS.forEach(control => {
|
||||
control.parameters.forEach(param => {
|
||||
if (this.isNumericParameter(param)) {
|
||||
this.metadata.set(param.id, {
|
||||
id: param.id,
|
||||
label: param.label,
|
||||
min: param.min,
|
||||
max: param.max,
|
||||
step: param.step,
|
||||
unit: param.unit,
|
||||
scaling: 'linear',
|
||||
isAudioParam: false,
|
||||
category: 'engine'
|
||||
})
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
EFFECTS.forEach(effect => {
|
||||
effect.parameters.forEach(param => {
|
||||
if (this.isNumericParameter(param)) {
|
||||
const isFreqParam = param.id.toLowerCase().includes('freq')
|
||||
const isAudioParam = this.checkIfAudioParam(effect.id, param.id)
|
||||
|
||||
this.metadata.set(param.id, {
|
||||
id: param.id,
|
||||
label: param.label,
|
||||
min: param.min,
|
||||
max: param.max,
|
||||
step: param.step,
|
||||
unit: param.unit,
|
||||
scaling: isFreqParam ? 'exponential' : 'linear',
|
||||
isAudioParam,
|
||||
category: 'effect',
|
||||
effectId: effect.id
|
||||
})
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
private isNumericParameter(param: EffectParameter): boolean {
|
||||
return typeof param.default === 'number' && !param.options
|
||||
}
|
||||
|
||||
private checkIfAudioParam(effectId: string, paramId: string): boolean {
|
||||
if (effectId !== 'filter') return false
|
||||
|
||||
const audioParamIds = ['hpFreq', 'hpRes', 'lpFreq', 'lpRes', 'bpFreq', 'bpRes']
|
||||
return audioParamIds.includes(paramId)
|
||||
}
|
||||
|
||||
getMetadata(paramId: string): ParameterMetadata | undefined {
|
||||
return this.metadata.get(paramId)
|
||||
}
|
||||
|
||||
isAudioParam(paramId: string): boolean {
|
||||
return this.metadata.get(paramId)?.isAudioParam ?? false
|
||||
}
|
||||
|
||||
getAllModulatableParams(): string[] {
|
||||
return Array.from(this.metadata.keys())
|
||||
}
|
||||
|
||||
getModulatableParamsByCategory(category: 'engine' | 'effect'): string[] {
|
||||
return Array.from(this.metadata.entries())
|
||||
.filter(([_, meta]) => meta.category === category)
|
||||
.map(([id, _]) => id)
|
||||
}
|
||||
|
||||
clampValue(paramId: string, value: number): number {
|
||||
const meta = this.metadata.get(paramId)
|
||||
if (!meta) return value
|
||||
return Math.max(meta.min, Math.min(meta.max, value))
|
||||
}
|
||||
|
||||
normalizeValue(paramId: string, value: number): number {
|
||||
const meta = this.metadata.get(paramId)
|
||||
if (!meta) return 0
|
||||
|
||||
if (meta.scaling === 'exponential') {
|
||||
const logMin = Math.log(meta.min)
|
||||
const logMax = Math.log(meta.max)
|
||||
const logValue = Math.log(Math.max(meta.min, value))
|
||||
return (logValue - logMin) / (logMax - logMin)
|
||||
} else {
|
||||
return (value - meta.min) / (meta.max - meta.min)
|
||||
}
|
||||
}
|
||||
|
||||
denormalizeValue(paramId: string, normalized: number): number {
|
||||
const meta = this.metadata.get(paramId)
|
||||
if (!meta) return 0
|
||||
|
||||
const clamped = Math.max(0, Math.min(1, normalized))
|
||||
|
||||
if (meta.scaling === 'exponential') {
|
||||
const logMin = Math.log(meta.min)
|
||||
const logMax = Math.log(meta.max)
|
||||
return Math.exp(logMin + clamped * (logMax - logMin))
|
||||
} else {
|
||||
return meta.min + clamped * (meta.max - meta.min)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const parameterRegistry = new ParameterRegistry()
|
||||
@ -10,6 +10,9 @@ export interface KeyboardShortcutHandlers {
|
||||
onDoubleEnter?: () => void
|
||||
onR?: () => void
|
||||
onShiftR?: () => void
|
||||
onC?: () => void
|
||||
onShiftC?: () => void
|
||||
onEscape?: () => void
|
||||
}
|
||||
|
||||
const DOUBLE_ENTER_THRESHOLD = 300
|
||||
@ -77,6 +80,21 @@ export function useKeyboardShortcuts(handlers: KeyboardShortcutHandlers) {
|
||||
h.onR?.()
|
||||
}
|
||||
break
|
||||
|
||||
case 'c':
|
||||
case 'C':
|
||||
e.preventDefault()
|
||||
if (e.shiftKey) {
|
||||
h.onShiftC?.()
|
||||
} else {
|
||||
h.onC?.()
|
||||
}
|
||||
break
|
||||
|
||||
case 'Escape':
|
||||
e.preventDefault()
|
||||
h.onEscape?.()
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -25,7 +25,7 @@ export class DownloadService {
|
||||
formula: string,
|
||||
filename: string,
|
||||
options: DownloadOptions = {}
|
||||
): boolean {
|
||||
): void {
|
||||
const {
|
||||
sampleRate = DEFAULT_DOWNLOAD_OPTIONS.SAMPLE_RATE,
|
||||
duration = DEFAULT_DOWNLOAD_OPTIONS.DURATION,
|
||||
@ -35,19 +35,12 @@ export class DownloadService {
|
||||
const result = compileFormula(formula)
|
||||
|
||||
if (!result.success || !result.compiledFormula) {
|
||||
console.error('Failed to compile formula:', result.error)
|
||||
return false
|
||||
throw new Error(`Failed to compile formula: ${result.error}`)
|
||||
}
|
||||
|
||||
try {
|
||||
const buffer = generateSamples(result.compiledFormula, { sampleRate, duration })
|
||||
const blob = exportToWav(buffer, { sampleRate, bitDepth })
|
||||
this.downloadBlob(blob, filename)
|
||||
return true
|
||||
} catch (error) {
|
||||
console.error('Failed to download formula:', error)
|
||||
return false
|
||||
}
|
||||
const buffer = generateSamples(result.compiledFormula, { sampleRate, duration })
|
||||
const blob = exportToWav(buffer, { sampleRate, bitDepth })
|
||||
this.downloadBlob(blob, filename)
|
||||
}
|
||||
|
||||
async downloadAll(
|
||||
@ -67,17 +60,12 @@ export class DownloadService {
|
||||
const result = compileFormula(formula)
|
||||
|
||||
if (!result.success || !result.compiledFormula) {
|
||||
console.error(`Failed to compile ${i}_${j}:`, result.error)
|
||||
return
|
||||
throw new Error(`Failed to compile ${i}_${j}: ${result.error}`)
|
||||
}
|
||||
|
||||
try {
|
||||
const buffer = generateSamples(result.compiledFormula, { sampleRate, duration })
|
||||
const blob = exportToWav(buffer, { sampleRate, bitDepth })
|
||||
zip.file(`bytebeat_${i}_${j}.wav`, blob)
|
||||
} catch (error) {
|
||||
console.error(`Failed to generate ${i}_${j}:`, error)
|
||||
}
|
||||
const buffer = generateSamples(result.compiledFormula, { sampleRate, duration })
|
||||
const blob = exportToWav(buffer, { sampleRate, bitDepth })
|
||||
zip.file(`bytebeat_${i}_${j}.wav`, blob)
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@ -10,8 +10,7 @@ export interface PlaybackOptions {
|
||||
export class PlaybackManager {
|
||||
private player: AudioPlayer
|
||||
private currentFormula: string | null = null
|
||||
private queuedCallback: (() => void) | null = null
|
||||
private variables = { ...DEFAULT_VARIABLES }
|
||||
private variables: { a: number; b: number; c: number; d: number } = { ...DEFAULT_VARIABLES }
|
||||
private playbackPositionCallback: ((position: number) => void) | null = null
|
||||
private animationFrameId: number | null = null
|
||||
|
||||
@ -32,8 +31,12 @@ export class PlaybackManager {
|
||||
this.player.updateRealtimeVariables(a, b, c, d)
|
||||
}
|
||||
|
||||
setLFOConfig(lfoIndex: number, config: { frequency: number; phase: number; waveform: string; mappings: Array<{ targetParam: string; depth: number }> }): void {
|
||||
this.player.setLFOConfig(lfoIndex, config)
|
||||
}
|
||||
|
||||
setPitch(pitch: number): void {
|
||||
// Pitch is already handled via setEffects, but we could add specific handling here if needed
|
||||
this.player.updatePitch(pitch)
|
||||
}
|
||||
|
||||
setPlaybackPositionCallback(callback: (position: number) => void): void {
|
||||
@ -62,29 +65,22 @@ export class PlaybackManager {
|
||||
}
|
||||
}
|
||||
|
||||
async play(formula: string): Promise<boolean> {
|
||||
try {
|
||||
this.currentFormula = formula
|
||||
await this.player.playRealtime(
|
||||
formula,
|
||||
this.variables.a,
|
||||
this.variables.b,
|
||||
this.variables.c,
|
||||
this.variables.d
|
||||
)
|
||||
this.startPlaybackTracking()
|
||||
return true
|
||||
} catch (error) {
|
||||
console.error('Failed to start realtime playback:', error)
|
||||
return false
|
||||
}
|
||||
async play(formula: string): Promise<void> {
|
||||
this.currentFormula = formula
|
||||
await this.player.playRealtime(
|
||||
formula,
|
||||
this.variables.a,
|
||||
this.variables.b,
|
||||
this.variables.c,
|
||||
this.variables.d
|
||||
)
|
||||
this.startPlaybackTracking()
|
||||
}
|
||||
|
||||
stop(): void {
|
||||
this.stopPlaybackTracking()
|
||||
this.player.stop()
|
||||
this.currentFormula = null
|
||||
this.queuedCallback = null
|
||||
}
|
||||
|
||||
getPlaybackPosition(): number {
|
||||
|
||||
34
src/stores/mappingMode.ts
Normal file
34
src/stores/mappingMode.ts
Normal file
@ -0,0 +1,34 @@
|
||||
import { atom } from 'nanostores'
|
||||
|
||||
export interface MappingModeState {
|
||||
isActive: boolean
|
||||
activeLFO: number | null
|
||||
}
|
||||
|
||||
export const mappingMode = atom<MappingModeState>({
|
||||
isActive: false,
|
||||
activeLFO: null
|
||||
})
|
||||
|
||||
export function enterMappingMode(lfoIndex: number): void {
|
||||
mappingMode.set({
|
||||
isActive: true,
|
||||
activeLFO: lfoIndex
|
||||
})
|
||||
}
|
||||
|
||||
export function exitMappingMode(): void {
|
||||
mappingMode.set({
|
||||
isActive: false,
|
||||
activeLFO: null
|
||||
})
|
||||
}
|
||||
|
||||
export function toggleMappingMode(lfoIndex: number): void {
|
||||
const current = mappingMode.get()
|
||||
if (current.isActive && current.activeLFO === lfoIndex) {
|
||||
exitMappingMode()
|
||||
} else {
|
||||
enterMappingMode(lfoIndex)
|
||||
}
|
||||
}
|
||||
@ -1,8 +1,47 @@
|
||||
import { map } from 'nanostores'
|
||||
import { getDefaultEngineValues, getDefaultEffectValues } from '../config/effects'
|
||||
import type { LFOWaveform } from '../domain/modulation/LFO'
|
||||
|
||||
const STORAGE_KEY_ENGINE = 'engine:'
|
||||
const STORAGE_KEY_EFFECTS = 'effects:'
|
||||
const STORAGE_KEY_LFO = 'lfo:'
|
||||
|
||||
export interface LFOMapping {
|
||||
targetParam: string
|
||||
depth: number
|
||||
}
|
||||
|
||||
export interface LFOConfig {
|
||||
waveform: LFOWaveform
|
||||
frequency: number
|
||||
phase: number
|
||||
mappings: LFOMapping[]
|
||||
}
|
||||
|
||||
export interface LFOSettings {
|
||||
lfo1: LFOConfig
|
||||
lfo2: LFOConfig
|
||||
lfo3: LFOConfig
|
||||
lfo4: LFOConfig
|
||||
}
|
||||
|
||||
export function getDefaultLFOConfig(): LFOConfig {
|
||||
return {
|
||||
waveform: 'sine',
|
||||
frequency: 1,
|
||||
phase: 0,
|
||||
mappings: []
|
||||
}
|
||||
}
|
||||
|
||||
export function getDefaultLFOValues(): LFOSettings {
|
||||
return {
|
||||
lfo1: getDefaultLFOConfig(),
|
||||
lfo2: getDefaultLFOConfig(),
|
||||
lfo3: getDefaultLFOConfig(),
|
||||
lfo4: getDefaultLFOConfig()
|
||||
}
|
||||
}
|
||||
|
||||
function loadFromStorage<T>(key: string, defaults: T): T {
|
||||
try {
|
||||
@ -20,12 +59,15 @@ export const effectSettings = map(loadFromStorage(STORAGE_KEY_EFFECTS, {
|
||||
masterVolume: 75
|
||||
}))
|
||||
|
||||
export const lfoSettings = map(loadFromStorage(STORAGE_KEY_LFO, getDefaultLFOValues()))
|
||||
|
||||
function saveToStorage() {
|
||||
try {
|
||||
localStorage.setItem(STORAGE_KEY_ENGINE, JSON.stringify(engineSettings.get()))
|
||||
localStorage.setItem(STORAGE_KEY_EFFECTS, JSON.stringify(effectSettings.get()))
|
||||
} catch (e) {
|
||||
console.error('Failed to save settings:', e)
|
||||
localStorage.setItem(STORAGE_KEY_LFO, JSON.stringify(lfoSettings.get()))
|
||||
} catch {
|
||||
// Silently fail on storage errors (quota exceeded, private browsing, etc.)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -1,5 +1,8 @@
|
||||
import type { LFOSettings } from '../stores/settings'
|
||||
|
||||
export interface TileState {
|
||||
formula: string
|
||||
engineParams: Record<string, number>
|
||||
effectParams: Record<string, number | boolean | string>
|
||||
lfoConfigs: LFOSettings
|
||||
}
|
||||
|
||||
@ -291,7 +291,13 @@ export function generateTileGrid(rows: number, cols: number, complexity: number
|
||||
const row: TileState[] = []
|
||||
for (let j = 0; j < cols; j++) {
|
||||
const formula = generateRandomFormula(complexity)
|
||||
row.push(createTileState(formula))
|
||||
const tile = createTileState(formula)
|
||||
tile.engineParams.a = Math.floor(Math.random() * 256)
|
||||
tile.engineParams.b = Math.floor(Math.random() * 256)
|
||||
tile.engineParams.c = Math.floor(Math.random() * 256)
|
||||
tile.engineParams.d = Math.floor(Math.random() * 256)
|
||||
tile.engineParams.pitch = 0.1 + Math.random() * 0.9
|
||||
row.push(tile)
|
||||
}
|
||||
grid.push(row)
|
||||
}
|
||||
|
||||
@ -1,16 +1,19 @@
|
||||
import type { TileState } from '../types/tiles'
|
||||
import { engineSettings, effectSettings } from '../stores/settings'
|
||||
import { getDefaultEngineValues, getDefaultEffectValues } from '../config/effects'
|
||||
import { engineSettings, effectSettings, lfoSettings, getDefaultLFOValues } from '../stores/settings'
|
||||
import { getDefaultEngineValues, getDefaultEffectValues, ENGINE_CONTROLS, EFFECTS } from '../config/effects'
|
||||
import type { LFOSettings } from '../stores/settings'
|
||||
|
||||
export function createTileState(
|
||||
formula: string,
|
||||
engineParams?: Record<string, number>,
|
||||
effectParams?: Record<string, number | boolean | string>
|
||||
effectParams?: Record<string, number | boolean | string>,
|
||||
lfoConfigs?: LFOSettings
|
||||
): TileState {
|
||||
return {
|
||||
formula,
|
||||
engineParams: engineParams ?? { ...getDefaultEngineValues() },
|
||||
effectParams: effectParams ?? { ...getDefaultEffectValues(), masterVolume: 75 }
|
||||
effectParams: effectParams ?? { ...getDefaultEffectValues(), masterVolume: 75 },
|
||||
lfoConfigs: lfoConfigs ?? getDefaultLFOValues()
|
||||
}
|
||||
}
|
||||
|
||||
@ -18,7 +21,8 @@ export function createTileStateFromCurrent(formula: string): TileState {
|
||||
return {
|
||||
formula,
|
||||
engineParams: { ...engineSettings.get() },
|
||||
effectParams: { ...effectSettings.get() }
|
||||
effectParams: { ...effectSettings.get() },
|
||||
lfoConfigs: JSON.parse(JSON.stringify(lfoSettings.get()))
|
||||
}
|
||||
}
|
||||
|
||||
@ -30,13 +34,20 @@ export function loadTileParams(tile: TileState): void {
|
||||
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 any, value)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
export function saveTileParams(tile: TileState): TileState {
|
||||
return {
|
||||
...tile,
|
||||
engineParams: { ...engineSettings.get() },
|
||||
effectParams: { ...effectSettings.get() }
|
||||
effectParams: { ...effectSettings.get() },
|
||||
lfoConfigs: JSON.parse(JSON.stringify(lfoSettings.get()))
|
||||
}
|
||||
}
|
||||
|
||||
@ -44,6 +55,126 @@ export function cloneTileState(tile: TileState): TileState {
|
||||
return {
|
||||
formula: tile.formula,
|
||||
engineParams: { ...tile.engineParams },
|
||||
effectParams: { ...tile.effectParams }
|
||||
effectParams: { ...tile.effectParams },
|
||||
lfoConfigs: JSON.parse(JSON.stringify(tile.lfoConfigs))
|
||||
}
|
||||
}
|
||||
|
||||
function randomInRange(min: number, max: number, step: number): number {
|
||||
const steps = Math.floor((max - min) / step)
|
||||
const randomStep = Math.floor(Math.random() * (steps + 1))
|
||||
return min + randomStep * step
|
||||
}
|
||||
|
||||
export function randomizeTileParams(tile: TileState): TileState {
|
||||
const randomEngineParams: Record<string, number> = {}
|
||||
const randomEffectParams: Record<string, number | boolean | string> = {}
|
||||
|
||||
ENGINE_CONTROLS.forEach(control => {
|
||||
control.parameters.forEach(param => {
|
||||
if (param.id === 'sampleRate') {
|
||||
randomEngineParams[param.id] = param.max as number
|
||||
} else if (param.id === 'bitDepth') {
|
||||
randomEngineParams[param.id] = param.max as number
|
||||
} else if (param.id === 'pitch') {
|
||||
randomEngineParams[param.id] = 0.1 + Math.random() * 1.4
|
||||
} else if (param.id === 'a' || param.id === 'b' || param.id === 'c' || param.id === 'd') {
|
||||
randomEngineParams[param.id] = Math.floor(Math.random() * 256)
|
||||
} else {
|
||||
randomEngineParams[param.id] = randomInRange(
|
||||
param.min as number,
|
||||
param.max as number,
|
||||
param.step as number
|
||||
)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
const filterModes = ['lowpass', 'highpass']
|
||||
const selectedFilterMode = filterModes[Math.floor(Math.random() * filterModes.length)]
|
||||
const filterFreq = selectedFilterMode === 'lowpass'
|
||||
? 800 + Math.random() * 4200
|
||||
: 100 + Math.random() * 700
|
||||
|
||||
randomEffectParams['filterMode'] = selectedFilterMode
|
||||
randomEffectParams['filterFreq'] = filterFreq
|
||||
|
||||
EFFECTS.forEach(effect => {
|
||||
effect.parameters.forEach(param => {
|
||||
if (param.id === 'filterMode' || param.id === 'filterFreq') {
|
||||
return
|
||||
}
|
||||
|
||||
if (param.id === 'delayWetDry') {
|
||||
randomEffectParams[param.id] = Math.random() * 50
|
||||
} else if (param.id === 'delayFeedback') {
|
||||
randomEffectParams[param.id] = Math.random() * 90
|
||||
} else if (param.id === 'bitcrushDepth') {
|
||||
randomEffectParams[param.id] = 12 + Math.floor(Math.random() * 5)
|
||||
} else if (param.id === 'bitcrushRate') {
|
||||
randomEffectParams[param.id] = Math.random() * 30
|
||||
} else if (param.options) {
|
||||
const options = param.options
|
||||
const randomOption = options[Math.floor(Math.random() * options.length)]
|
||||
randomEffectParams[param.id] = randomOption.value
|
||||
} else if (typeof param.default === 'boolean') {
|
||||
randomEffectParams[param.id] = Math.random() > 0.5
|
||||
} else {
|
||||
randomEffectParams[param.id] = randomInRange(
|
||||
param.min as number,
|
||||
param.max as number,
|
||||
param.step as number
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
if (effect.bypassable) {
|
||||
randomEffectParams[`${effect.id}Bypass`] = Math.random() > 0.5
|
||||
}
|
||||
})
|
||||
|
||||
const modulatableParams = [
|
||||
'filterFreq', 'filterRes',
|
||||
'wavefolderDrive', 'bitcrushDepth', 'bitcrushRate',
|
||||
'delayTime', 'delayFeedback', 'delayWetDry',
|
||||
'reverbWetDry', 'reverbDecay', 'reverbDamping'
|
||||
]
|
||||
|
||||
const randomLFOConfigs = getDefaultLFOValues()
|
||||
const waveforms: Array<'sine' | 'triangle' | 'square' | 'sawtooth'> = ['sine', 'triangle', 'square', 'sawtooth']
|
||||
|
||||
randomLFOConfigs.lfo1 = {
|
||||
waveform: waveforms[Math.floor(Math.random() * waveforms.length)],
|
||||
frequency: Math.random() * 10,
|
||||
phase: Math.random() * 360,
|
||||
mappings: [{
|
||||
targetParam: 'filterFreq',
|
||||
depth: 20 + Math.random() * 60
|
||||
}]
|
||||
}
|
||||
|
||||
const availableParams = modulatableParams.filter(p => p !== 'filterFreq')
|
||||
const lfoConfigs = [randomLFOConfigs.lfo2, randomLFOConfigs.lfo3, randomLFOConfigs.lfo4]
|
||||
|
||||
lfoConfigs.forEach(config => {
|
||||
if (availableParams.length > 0 && Math.random() > 0.3) {
|
||||
const paramIndex = Math.floor(Math.random() * availableParams.length)
|
||||
const targetParam = availableParams.splice(paramIndex, 1)[0]
|
||||
|
||||
config.waveform = waveforms[Math.floor(Math.random() * waveforms.length)]
|
||||
config.frequency = Math.random() * 10
|
||||
config.phase = Math.random() * 360
|
||||
config.mappings = [{
|
||||
targetParam,
|
||||
depth: 20 + Math.random() * 60
|
||||
}]
|
||||
}
|
||||
})
|
||||
|
||||
return {
|
||||
formula: tile.formula,
|
||||
engineParams: randomEngineParams,
|
||||
effectParams: randomEffectParams,
|
||||
lfoConfigs: randomLFOConfigs
|
||||
}
|
||||
}
|
||||
|
||||
@ -10,24 +10,19 @@ export function generateWaveformData(formula: string, width: number, sampleRate:
|
||||
|
||||
for (let s = 0; s < samplesPerPixel; s++) {
|
||||
const t = x * samplesPerPixel + s
|
||||
try {
|
||||
const value = compiledFormula(t, a, b, c, d)
|
||||
const byteValue = value & 0xFF
|
||||
const normalized = (byteValue - 128) / 128
|
||||
min = Math.min(min, normalized)
|
||||
max = Math.max(max, normalized)
|
||||
} catch {
|
||||
min = Math.min(min, 0)
|
||||
max = Math.max(max, 0)
|
||||
}
|
||||
const value = compiledFormula(t, a, b, c, d)
|
||||
const byteValue = value & 0xFF
|
||||
const normalized = (byteValue - 128) / 128
|
||||
min = Math.min(min, normalized)
|
||||
max = Math.max(max, normalized)
|
||||
}
|
||||
|
||||
waveform.push(min, max)
|
||||
}
|
||||
|
||||
return waveform
|
||||
} catch {
|
||||
return new Array(width * 2).fill(0)
|
||||
} catch (error) {
|
||||
return Array(width * 2).fill(0)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user