Compare commits
10 Commits
697be5cf65
...
ef50cc9918
| Author | SHA1 | Date | |
|---|---|---|---|
| ef50cc9918 | |||
| 5cc10dec0c | |||
| 18766f3d8a | |||
| ac772054c9 | |||
| ba37b94908 | |||
| c6cc1a47c0 | |||
| 012c3534be | |||
| a960f4e18b | |||
| 2d0bfe2297 | |||
| 0fc7ffdee0 |
89
public/worklets/bytebeat-processor.js
Normal file
89
public/worklets/bytebeat-processor.js
Normal file
@ -0,0 +1,89 @@
|
|||||||
|
class BytebeatProcessor extends AudioWorkletProcessor {
|
||||||
|
constructor() {
|
||||||
|
super()
|
||||||
|
|
||||||
|
this.t = 0
|
||||||
|
this.a = 8
|
||||||
|
this.b = 16
|
||||||
|
this.c = 32
|
||||||
|
this.d = 64
|
||||||
|
this.formula = null
|
||||||
|
this.compiledFormula = null
|
||||||
|
this.sampleRate = 8000
|
||||||
|
this.duration = 4
|
||||||
|
this.loopLength = this.sampleRate * this.duration
|
||||||
|
this.playbackRate = 1.0
|
||||||
|
this.error = false
|
||||||
|
|
||||||
|
this.port.onmessage = (event) => {
|
||||||
|
const { type, value } = event.data
|
||||||
|
switch (type) {
|
||||||
|
case 'formula':
|
||||||
|
this.setFormula(value)
|
||||||
|
break
|
||||||
|
case 'variables':
|
||||||
|
this.a = value.a ?? this.a
|
||||||
|
this.b = value.b ?? this.b
|
||||||
|
this.c = value.c ?? this.c
|
||||||
|
this.d = value.d ?? this.d
|
||||||
|
break
|
||||||
|
case 'reset':
|
||||||
|
this.t = 0
|
||||||
|
break
|
||||||
|
case 'loopLength':
|
||||||
|
this.loopLength = value
|
||||||
|
break
|
||||||
|
case 'playbackRate':
|
||||||
|
this.playbackRate = value
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
setFormula(formulaString) {
|
||||||
|
try {
|
||||||
|
this.compiledFormula = new Function('t', 'a', 'b', 'c', 'd', `return ${formulaString}`)
|
||||||
|
this.formula = formulaString
|
||||||
|
this.error = false
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Failed to compile bytebeat formula:', e)
|
||||||
|
this.error = true
|
||||||
|
this.compiledFormula = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
process(inputs, outputs) {
|
||||||
|
const output = outputs[0]
|
||||||
|
|
||||||
|
if (output.length > 0) {
|
||||||
|
const outputChannel = output[0]
|
||||||
|
|
||||||
|
for (let i = 0; i < outputChannel.length; i++) {
|
||||||
|
if (!this.compiledFormula || this.error) {
|
||||||
|
outputChannel[i] = 0
|
||||||
|
} else {
|
||||||
|
try {
|
||||||
|
const value = this.compiledFormula(this.t, this.a, this.b, this.c, this.d)
|
||||||
|
const byteValue = value & 0xFF
|
||||||
|
outputChannel[i] = (byteValue - 128) / 128
|
||||||
|
} catch (e) {
|
||||||
|
outputChannel[i] = 0
|
||||||
|
if (!this.error) {
|
||||||
|
console.error('Bytebeat runtime error:', e)
|
||||||
|
this.error = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
this.t += this.playbackRate
|
||||||
|
if (this.loopLength > 0 && this.t >= this.loopLength) {
|
||||||
|
this.t = 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
registerProcessor('bytebeat-processor', BytebeatProcessor)
|
||||||
@ -2,7 +2,7 @@ class FoldCrushProcessor extends AudioWorkletProcessor {
|
|||||||
constructor() {
|
constructor() {
|
||||||
super()
|
super()
|
||||||
|
|
||||||
this.clipMode = 'wrap'
|
this.clipMode = 'fold'
|
||||||
this.drive = 1
|
this.drive = 1
|
||||||
this.bitDepth = 16
|
this.bitDepth = 16
|
||||||
this.crushAmount = 0
|
this.crushAmount = 0
|
||||||
@ -28,39 +28,63 @@ class FoldCrushProcessor extends AudioWorkletProcessor {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
wrap(sample) {
|
clamp(x, min, max) {
|
||||||
const range = 2.0
|
return Math.max(min, Math.min(max, x))
|
||||||
let wrapped = sample
|
|
||||||
while (wrapped > 1.0) wrapped -= range
|
|
||||||
while (wrapped < -1.0) wrapped += range
|
|
||||||
return wrapped
|
|
||||||
}
|
}
|
||||||
|
|
||||||
clamp(sample) {
|
mod(x, y) {
|
||||||
return Math.max(-1.0, Math.min(1.0, sample))
|
return ((x % y) + y) % y
|
||||||
}
|
}
|
||||||
|
|
||||||
fold(sample) {
|
squash(x) {
|
||||||
let folded = sample
|
return x / (1 + Math.abs(x))
|
||||||
while (folded > 1.0 || folded < -1.0) {
|
|
||||||
if (folded > 1.0) {
|
|
||||||
folded = 2.0 - folded
|
|
||||||
}
|
}
|
||||||
if (folded < -1.0) {
|
|
||||||
folded = -2.0 - folded
|
soft(x, k) {
|
||||||
|
return Math.tanh(x * (1 + k))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
hard(x, k) {
|
||||||
|
return this.clamp((1 + k) * x, -1, 1)
|
||||||
}
|
}
|
||||||
return folded
|
|
||||||
|
fold(x, k) {
|
||||||
|
let y = (1 + 0.5 * k) * x
|
||||||
|
const window = this.mod(y + 1, 4)
|
||||||
|
return 1 - Math.abs(window - 2)
|
||||||
|
}
|
||||||
|
|
||||||
|
cubic(x, k) {
|
||||||
|
const t = this.squash(Math.log1p(k))
|
||||||
|
const cubic = (x - (t / 3) * x * x * x) / (1 - t / 3)
|
||||||
|
return this.soft(cubic, k)
|
||||||
|
}
|
||||||
|
|
||||||
|
diode(x, k) {
|
||||||
|
const g = 1 + 2 * k
|
||||||
|
const t = this.squash(Math.log1p(k))
|
||||||
|
const bias = 0.07 * t
|
||||||
|
const pos = this.soft(x + bias, 2 * k)
|
||||||
|
const neg = this.soft(-x + bias, 2 * k)
|
||||||
|
const y = pos - neg
|
||||||
|
const sech = 1 / Math.cosh(g * bias)
|
||||||
|
const sech2 = sech * sech
|
||||||
|
const denom = Math.max(1e-8, 2 * g * sech2)
|
||||||
|
return this.soft(y / denom, k)
|
||||||
}
|
}
|
||||||
|
|
||||||
processWavefolder(sample) {
|
processWavefolder(sample) {
|
||||||
switch (this.clipMode) {
|
switch (this.clipMode) {
|
||||||
case 'wrap':
|
case 'soft':
|
||||||
return this.wrap(sample)
|
return this.soft(sample, this.drive)
|
||||||
case 'clamp':
|
case 'hard':
|
||||||
return this.clamp(sample)
|
return this.hard(sample, this.drive)
|
||||||
case 'fold':
|
case 'fold':
|
||||||
return this.fold(sample)
|
return this.fold(sample, this.drive)
|
||||||
|
case 'cubic':
|
||||||
|
return this.cubic(sample, this.drive)
|
||||||
|
case 'diode':
|
||||||
|
return this.diode(sample, this.drive)
|
||||||
default:
|
default:
|
||||||
return sample
|
return sample
|
||||||
}
|
}
|
||||||
@ -86,6 +110,14 @@ class FoldCrushProcessor extends AudioWorkletProcessor {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
safetyLimiter(sample) {
|
||||||
|
const threshold = 0.8
|
||||||
|
if (Math.abs(sample) > threshold) {
|
||||||
|
return Math.tanh(sample * 0.9) / Math.tanh(0.9)
|
||||||
|
}
|
||||||
|
return sample
|
||||||
|
}
|
||||||
|
|
||||||
process(inputs, outputs) {
|
process(inputs, outputs) {
|
||||||
const input = inputs[0]
|
const input = inputs[0]
|
||||||
const output = outputs[0]
|
const output = outputs[0]
|
||||||
@ -95,9 +127,9 @@ class FoldCrushProcessor extends AudioWorkletProcessor {
|
|||||||
const outputChannel = output[0]
|
const outputChannel = output[0]
|
||||||
|
|
||||||
for (let i = 0; i < inputChannel.length; i++) {
|
for (let i = 0; i < inputChannel.length; i++) {
|
||||||
const driven = inputChannel[i] * this.drive
|
let processed = this.processWavefolder(inputChannel[i])
|
||||||
let processed = this.processWavefolder(driven)
|
|
||||||
processed = this.processBitcrush(processed)
|
processed = this.processBitcrush(processed)
|
||||||
|
processed = this.safetyLimiter(processed)
|
||||||
outputChannel[i] = processed
|
outputChannel[i] = processed
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
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)
|
||||||
595
src/App.tsx
595
src/App.tsx
@ -1,47 +1,146 @@
|
|||||||
import { useState, useRef, useEffect } from 'react'
|
import { useState, useRef, useEffect } from 'react'
|
||||||
import { useStore } from '@nanostores/react'
|
import { useStore } from '@nanostores/react'
|
||||||
|
import { Square, Archive, Dices, Sparkles } from 'lucide-react'
|
||||||
import { PlaybackManager } from './services/PlaybackManager'
|
import { PlaybackManager } from './services/PlaybackManager'
|
||||||
import { DownloadService } from './services/DownloadService'
|
import { DownloadService } from './services/DownloadService'
|
||||||
import { generateFormulaGrid, generateRandomFormula } from './utils/bytebeatFormulas'
|
import { generateTileGrid, generateRandomFormula } from './utils/bytebeatFormulas'
|
||||||
import { BytebeatTile } from './components/BytebeatTile'
|
import { BytebeatTile } from './components/BytebeatTile'
|
||||||
import { EffectsBar } from './components/EffectsBar'
|
import { EffectsBar } from './components/EffectsBar'
|
||||||
import { EngineControls } from './components/EngineControls'
|
import { EngineControls } from './components/EngineControls'
|
||||||
|
import { FormulaEditor } from './components/FormulaEditor'
|
||||||
|
import { LFOPanel } from './components/LFOPanel'
|
||||||
|
import { AudioContextWarning } from './components/AudioContextWarning'
|
||||||
|
import { HelpModal } from './components/HelpModal'
|
||||||
import { getSampleRateFromIndex } from './config/effects'
|
import { getSampleRateFromIndex } from './config/effects'
|
||||||
import { engineSettings, effectSettings } from './stores/settings'
|
import { engineSettings, effectSettings, lfoSettings, type LFOConfig } 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, randomizeTileParams } from './utils/tileState'
|
||||||
|
import { DEFAULT_VARIABLES, PLAYBACK_ID, TILE_GRID, DEFAULT_DOWNLOAD_OPTIONS, LOOP_DURATION } from './constants/defaults'
|
||||||
|
import { getTileId, getTileFromGrid, type FocusedTile } from './utils/tileHelpers'
|
||||||
|
|
||||||
function App() {
|
function App() {
|
||||||
const engineValues = useStore(engineSettings)
|
const engineValues = useStore(engineSettings)
|
||||||
const effectValues = useStore(effectSettings)
|
const effectValues = useStore(effectSettings)
|
||||||
|
|
||||||
const [formulas, setFormulas] = useState<string[][]>(() =>
|
const [tiles, setTiles] = useState<TileState[][]>(() =>
|
||||||
generateFormulaGrid(100, 2, engineValues.complexity)
|
generateTileGrid(TILE_GRID.SIZE, TILE_GRID.COLUMNS, engineValues.complexity)
|
||||||
)
|
)
|
||||||
const [playing, setPlaying] = useState<string | null>(null)
|
const [playing, setPlaying] = useState<string | null>(null)
|
||||||
const [queued, setQueued] = useState<string | null>(null)
|
const [queued, setQueued] = useState<string | null>(null)
|
||||||
const [regenerating, setRegenerating] = useState<string | null>(null)
|
|
||||||
const [playbackPosition, setPlaybackPosition] = useState<number>(0)
|
const [playbackPosition, setPlaybackPosition] = useState<number>(0)
|
||||||
const [downloading, setDownloading] = useState(false)
|
const [downloading, setDownloading] = useState(false)
|
||||||
|
const [focusedTile, setFocusedTile] = useState<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 playbackManagerRef = useRef<PlaybackManager | null>(null)
|
||||||
const downloadServiceRef = useRef<DownloadService>(new DownloadService())
|
const downloadServiceRef = useRef<DownloadService>(new DownloadService())
|
||||||
const animationFrameRef = useRef<number | null>(null)
|
const switchTimerRef = useRef<number | null>(null)
|
||||||
const formulasRef = useRef<string[][]>(formulas)
|
|
||||||
|
const { saveCurrentTileParams } = useTileParams({ tiles, setTiles, customTile, setCustomTile, focusedTile })
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
formulasRef.current = formulas
|
if (playbackManagerRef.current) {
|
||||||
}, [formulas])
|
playbackManagerRef.current.setPlaybackPositionCallback(setPlaybackPosition)
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
effectSettings.setKey('masterVolume', engineValues.masterVolume)
|
effectSettings.setKey('masterVolume', engineValues.masterVolume)
|
||||||
}, [engineValues.masterVolume])
|
}, [engineValues.masterVolume])
|
||||||
|
|
||||||
|
const clearSwitchTimer = () => {
|
||||||
|
if (switchTimerRef.current !== null) {
|
||||||
|
clearTimeout(switchTimerRef.current)
|
||||||
|
switchTimerRef.current = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const startSwitchTimer = (queuedId: string) => {
|
||||||
|
clearSwitchTimer()
|
||||||
|
|
||||||
|
switchTimerRef.current = window.setTimeout(() => {
|
||||||
|
const [rowStr, colStr] = queuedId.split('-')
|
||||||
|
const row = parseInt(rowStr, 10)
|
||||||
|
const col = parseInt(colStr, 10)
|
||||||
|
const tile = getTileFromGrid(tiles, row, col)
|
||||||
|
|
||||||
|
if (tile) {
|
||||||
|
playFormula(tile.formula, queuedId)
|
||||||
|
}
|
||||||
|
}, engineValues.loopCount * 1000)
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
return () => clearSwitchTimer()
|
||||||
|
}, [])
|
||||||
|
|
||||||
const handleRandom = () => {
|
const handleRandom = () => {
|
||||||
setFormulas(generateFormulaGrid(100, 2, engineValues.complexity))
|
clearSwitchTimer()
|
||||||
|
setTiles(generateTileGrid(TILE_GRID.SIZE, TILE_GRID.COLUMNS, engineValues.complexity))
|
||||||
|
setQueued(null)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleRandomizeAllParams = () => {
|
||||||
|
clearSwitchTimer()
|
||||||
|
let newRandomized: TileState | null = null
|
||||||
|
|
||||||
|
if (playing === PLAYBACK_ID.CUSTOM) {
|
||||||
|
setCustomTile(prev => {
|
||||||
|
const randomized = randomizeTileParams(prev)
|
||||||
|
newRandomized = randomized
|
||||||
|
return randomized
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
setTiles(prevTiles => {
|
||||||
|
const newTiles = prevTiles.map((row, rowIdx) =>
|
||||||
|
row.map((tile, colIdx) => {
|
||||||
|
const randomized = randomizeTileParams(tile)
|
||||||
|
if (playing && focusedTile !== 'custom') {
|
||||||
|
const tileId = getTileId(focusedTile.row, focusedTile.col)
|
||||||
|
if (playing === tileId && rowIdx === focusedTile.row && colIdx === focusedTile.col) {
|
||||||
|
newRandomized = randomized
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return randomized
|
||||||
|
})
|
||||||
|
)
|
||||||
|
return newTiles
|
||||||
|
})
|
||||||
|
|
||||||
|
setCustomTile(prev => randomizeTileParams(prev))
|
||||||
|
}
|
||||||
|
|
||||||
|
if (newRandomized && playbackManagerRef.current) {
|
||||||
|
const params = newRandomized as TileState
|
||||||
|
loadTileParams(params)
|
||||||
|
|
||||||
|
playbackManagerRef.current.setEffects(params.effectParams)
|
||||||
|
playbackManagerRef.current.setVariables(
|
||||||
|
params.engineParams.a ?? DEFAULT_VARIABLES.a,
|
||||||
|
params.engineParams.b ?? DEFAULT_VARIABLES.b,
|
||||||
|
params.engineParams.c ?? DEFAULT_VARIABLES.c,
|
||||||
|
params.engineParams.d ?? DEFAULT_VARIABLES.d
|
||||||
|
)
|
||||||
|
playbackManagerRef.current.setPitch(params.engineParams.pitch ?? 1.0)
|
||||||
|
|
||||||
|
if (params.lfoConfigs) {
|
||||||
|
playbackManagerRef.current.setLFOConfig(0, params.lfoConfigs.lfo1)
|
||||||
|
playbackManagerRef.current.setLFOConfig(1, params.lfoConfigs.lfo2)
|
||||||
|
playbackManagerRef.current.setLFOConfig(2, params.lfoConfigs.lfo3)
|
||||||
|
playbackManagerRef.current.setLFOConfig(3, params.lfoConfigs.lfo4)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
setQueued(null)
|
setQueued(null)
|
||||||
}
|
}
|
||||||
|
|
||||||
const playFormula = async (formula: string, id: string) => {
|
const playFormula = async (formula: string, id: string) => {
|
||||||
const sampleRate = getSampleRateFromIndex(engineValues.sampleRate)
|
const sampleRate = getSampleRateFromIndex(engineValues.sampleRate)
|
||||||
const duration = engineValues.loopDuration
|
const duration = LOOP_DURATION
|
||||||
|
|
||||||
if (!playbackManagerRef.current) {
|
if (!playbackManagerRef.current) {
|
||||||
playbackManagerRef.current = new PlaybackManager({ sampleRate, duration })
|
playbackManagerRef.current = new PlaybackManager({ sampleRate, duration })
|
||||||
@ -51,60 +150,46 @@ function App() {
|
|||||||
|
|
||||||
playbackManagerRef.current.stop()
|
playbackManagerRef.current.stop()
|
||||||
playbackManagerRef.current.setEffects(effectValues)
|
playbackManagerRef.current.setEffects(effectValues)
|
||||||
playbackManagerRef.current.setPitch(engineValues.pitch ?? 1)
|
playbackManagerRef.current.setVariables(
|
||||||
|
engineValues.a ?? DEFAULT_VARIABLES.a,
|
||||||
|
engineValues.b ?? DEFAULT_VARIABLES.b,
|
||||||
|
engineValues.c ?? DEFAULT_VARIABLES.c,
|
||||||
|
engineValues.d ?? DEFAULT_VARIABLES.d
|
||||||
|
)
|
||||||
|
playbackManagerRef.current.setPitch(engineValues.pitch ?? 1.0)
|
||||||
|
|
||||||
const success = await playbackManagerRef.current.play(formula, sampleRate, duration)
|
await playbackManagerRef.current.play(formula)
|
||||||
|
|
||||||
if (success) {
|
|
||||||
setPlaying(id)
|
setPlaying(id)
|
||||||
setQueued(null)
|
setQueued(null)
|
||||||
startPlaybackTracking()
|
return true
|
||||||
} else {
|
|
||||||
console.error('Failed to play formula')
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const startPlaybackTracking = () => {
|
const handleTileClick = (_formula: string, row: number, col: number, isDoubleClick: boolean = false) => {
|
||||||
if (animationFrameRef.current) {
|
const id = getTileId(row, col)
|
||||||
cancelAnimationFrame(animationFrameRef.current)
|
const tile = getTileFromGrid(tiles, row, col)
|
||||||
|
|
||||||
|
if (!tile) return
|
||||||
|
|
||||||
|
if (focusedTile === 'custom' || (focusedTile.row !== row || focusedTile.col !== col)) {
|
||||||
|
saveCurrentTileParams()
|
||||||
}
|
}
|
||||||
|
|
||||||
const updatePosition = () => {
|
if (tile) {
|
||||||
if (playbackManagerRef.current) {
|
loadTileParams(tile)
|
||||||
const position = playbackManagerRef.current.getPlaybackPosition()
|
|
||||||
setPlaybackPosition(position)
|
|
||||||
animationFrameRef.current = requestAnimationFrame(updatePosition)
|
|
||||||
}
|
}
|
||||||
}
|
setFocusedTile({ row, col })
|
||||||
updatePosition()
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleTileClick = (formula: string, row: number, col: number, isDoubleClick: boolean = false) => {
|
|
||||||
const id = `${row}-${col}`
|
|
||||||
|
|
||||||
if (playing === id) {
|
if (playing === id) {
|
||||||
playbackManagerRef.current?.stop()
|
handleStop()
|
||||||
setPlaying(null)
|
|
||||||
setQueued(null)
|
|
||||||
if (animationFrameRef.current) {
|
|
||||||
cancelAnimationFrame(animationFrameRef.current)
|
|
||||||
animationFrameRef.current = null
|
|
||||||
}
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isDoubleClick || playing === null) {
|
if (isDoubleClick || playing === null) {
|
||||||
playFormula(formula, id)
|
clearSwitchTimer()
|
||||||
|
playFormula(tile.formula, id)
|
||||||
} else {
|
} else {
|
||||||
setQueued(id)
|
setQueued(id)
|
||||||
if (playbackManagerRef.current) {
|
startSwitchTimer(id)
|
||||||
playbackManagerRef.current.scheduleNextTrack(() => {
|
|
||||||
const queuedFormula = formulasRef.current.flat()[parseInt(id.split('-')[0]) * 2 + parseInt(id.split('-')[1])]
|
|
||||||
if (queuedFormula) {
|
|
||||||
playFormula(queuedFormula, id)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -113,99 +198,433 @@ function App() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
const handleEngineChange = (parameterId: string, value: number) => {
|
const handleEngineChange = async (parameterId: string, value: number) => {
|
||||||
engineSettings.setKey(parameterId as keyof typeof engineValues, value)
|
engineSettings.setKey(parameterId as keyof typeof engineValues, value)
|
||||||
|
saveCurrentTileParams()
|
||||||
|
|
||||||
if (parameterId === 'masterVolume' && playbackManagerRef.current) {
|
if (parameterId === 'masterVolume' && playbackManagerRef.current) {
|
||||||
playbackManagerRef.current.setEffects({ ...effectValues, masterVolume: value })
|
playbackManagerRef.current.setEffects({ ...effectValues, masterVolume: value })
|
||||||
}
|
}
|
||||||
|
|
||||||
if (parameterId === 'pitch' && playbackManagerRef.current) {
|
if (parameterId === 'pitch' && playbackManagerRef.current && playing) {
|
||||||
playbackManagerRef.current.setPitch(value)
|
playbackManagerRef.current.setPitch(value)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (['a', 'b', 'c', 'd'].includes(parameterId) && playbackManagerRef.current && playing) {
|
||||||
|
const updatedValues = { ...engineValues, [parameterId]: value }
|
||||||
|
playbackManagerRef.current.setVariables(
|
||||||
|
updatedValues.a ?? DEFAULT_VARIABLES.a,
|
||||||
|
updatedValues.b ?? DEFAULT_VARIABLES.b,
|
||||||
|
updatedValues.c ?? DEFAULT_VARIABLES.c,
|
||||||
|
updatedValues.d ?? DEFAULT_VARIABLES.d
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleEffectChange = (parameterId: string, value: number | boolean) => {
|
const handleEffectChange = (parameterId: string, value: number | boolean | string) => {
|
||||||
effectSettings.setKey(parameterId as any, value as any)
|
effectSettings.setKey(parameterId as keyof typeof effectValues, value as never)
|
||||||
|
saveCurrentTileParams()
|
||||||
|
|
||||||
if (playbackManagerRef.current) {
|
if (playbackManagerRef.current) {
|
||||||
playbackManagerRef.current.setEffects(effectValues)
|
playbackManagerRef.current.setEffects(effectValues)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const handleLFOChange = (lfoIndex: number, config: LFOConfig) => {
|
||||||
|
if (playbackManagerRef.current) {
|
||||||
|
playbackManagerRef.current.setLFOConfig(lfoIndex, config)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleParameterMapClick = (paramId: string, lfoIndex: number) => {
|
||||||
|
const lfoKey = `lfo${lfoIndex + 1}` as 'lfo1' | 'lfo2' | 'lfo3' | 'lfo4'
|
||||||
|
const currentLFO = lfoSettings.get()[lfoKey]
|
||||||
|
|
||||||
|
const existingMappingIndex = currentLFO.mappings.findIndex(m => m.targetParam === paramId)
|
||||||
|
|
||||||
|
let updatedMappings
|
||||||
|
if (existingMappingIndex >= 0) {
|
||||||
|
updatedMappings = currentLFO.mappings.filter((_, i) => i !== existingMappingIndex)
|
||||||
|
} else {
|
||||||
|
updatedMappings = [...currentLFO.mappings, { targetParam: paramId, depth: 50 }]
|
||||||
|
}
|
||||||
|
|
||||||
|
const updatedLFO = { ...currentLFO, mappings: updatedMappings }
|
||||||
|
lfoSettings.setKey(lfoKey, updatedLFO)
|
||||||
|
|
||||||
|
if (playbackManagerRef.current) {
|
||||||
|
playbackManagerRef.current.setLFOConfig(lfoIndex, updatedLFO)
|
||||||
|
}
|
||||||
|
|
||||||
|
saveCurrentTileParams()
|
||||||
|
|
||||||
|
if (updatedMappings.length === 0 || existingMappingIndex >= 0) {
|
||||||
|
exitMappingMode()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleUpdateMappingDepth = (lfoIndex: number, paramId: string, depth: number) => {
|
||||||
|
const lfoKey = `lfo${lfoIndex + 1}` as 'lfo1' | 'lfo2' | 'lfo3' | 'lfo4'
|
||||||
|
const currentLFO = lfoSettings.get()[lfoKey]
|
||||||
|
|
||||||
|
const updatedMappings = currentLFO.mappings.map(m =>
|
||||||
|
m.targetParam === paramId ? { ...m, depth } : m
|
||||||
|
)
|
||||||
|
|
||||||
|
const updatedLFO = { ...currentLFO, mappings: updatedMappings }
|
||||||
|
lfoSettings.setKey(lfoKey, updatedLFO)
|
||||||
|
|
||||||
|
if (playbackManagerRef.current) {
|
||||||
|
playbackManagerRef.current.setLFOConfig(lfoIndex, updatedLFO)
|
||||||
|
}
|
||||||
|
|
||||||
|
saveCurrentTileParams()
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleRemoveMapping = (lfoIndex: number, paramId: string) => {
|
||||||
|
const lfoKey = `lfo${lfoIndex + 1}` as 'lfo1' | 'lfo2' | 'lfo3' | 'lfo4'
|
||||||
|
const currentLFO = lfoSettings.get()[lfoKey]
|
||||||
|
|
||||||
|
const updatedMappings = currentLFO.mappings.filter(m => m.targetParam !== paramId)
|
||||||
|
|
||||||
|
const updatedLFO = { ...currentLFO, mappings: updatedMappings }
|
||||||
|
lfoSettings.setKey(lfoKey, updatedLFO)
|
||||||
|
|
||||||
|
if (playbackManagerRef.current) {
|
||||||
|
playbackManagerRef.current.setLFOConfig(lfoIndex, updatedLFO)
|
||||||
|
}
|
||||||
|
|
||||||
|
saveCurrentTileParams()
|
||||||
|
}
|
||||||
|
|
||||||
|
const getMappedLFOs = (paramId: string): number[] => {
|
||||||
|
const lfos = lfoSettings.get()
|
||||||
|
const mapped: number[] = []
|
||||||
|
|
||||||
|
Object.entries(lfos).forEach(([, lfo], index) => {
|
||||||
|
if (lfo.mappings.some((m: { targetParam: string }) => m.targetParam === paramId)) {
|
||||||
|
mapped.push(index)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
return mapped
|
||||||
|
}
|
||||||
|
|
||||||
const handleDownloadAll = async () => {
|
const handleDownloadAll = async () => {
|
||||||
setDownloading(true)
|
setDownloading(true)
|
||||||
await downloadServiceRef.current.downloadAll(formulas, { duration: 10, bitDepth: 8 })
|
const formulas = tiles.map(row => row.map(tile => tile.formula))
|
||||||
|
await downloadServiceRef.current.downloadAll(formulas, {
|
||||||
|
duration: DEFAULT_DOWNLOAD_OPTIONS.DURATION,
|
||||||
|
bitDepth: DEFAULT_DOWNLOAD_OPTIONS.BIT_DEPTH
|
||||||
|
})
|
||||||
setDownloading(false)
|
setDownloading(false)
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleDownloadFormula = (formula: string, filename: string) => {
|
const handleDownloadFormula = (formula: string, filename: string) => {
|
||||||
downloadServiceRef.current.downloadFormula(formula, filename, { duration: 10, bitDepth: 8 })
|
downloadServiceRef.current.downloadFormula(formula, filename, {
|
||||||
|
duration: DEFAULT_DOWNLOAD_OPTIONS.DURATION,
|
||||||
|
bitDepth: DEFAULT_DOWNLOAD_OPTIONS.BIT_DEPTH
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleRegenerate = (row: number, col: number) => {
|
const handleRegenerate = (row: number, col: number) => {
|
||||||
const id = `${row}-${col}`
|
|
||||||
const newFormula = generateRandomFormula(engineValues.complexity)
|
const newFormula = generateRandomFormula(engineValues.complexity)
|
||||||
|
const newTile = createTileStateFromCurrent(newFormula)
|
||||||
|
|
||||||
if (playing === id && playbackManagerRef.current) {
|
setTiles(prevTiles => {
|
||||||
setRegenerating(id)
|
const newTiles = [...prevTiles]
|
||||||
playbackManagerRef.current.scheduleNextTrack(() => {
|
newTiles[row] = [...newTiles[row]]
|
||||||
setFormulas(prevFormulas => {
|
newTiles[row][col] = newTile
|
||||||
const newFormulas = [...prevFormulas]
|
return newTiles
|
||||||
newFormulas[row] = [...newFormulas[row]]
|
|
||||||
newFormulas[row][col] = newFormula
|
|
||||||
return newFormulas
|
|
||||||
})
|
})
|
||||||
playFormula(newFormula, id)
|
}
|
||||||
setRegenerating(null)
|
|
||||||
|
const handleStop = () => {
|
||||||
|
clearSwitchTimer()
|
||||||
|
playbackManagerRef.current?.stop()
|
||||||
|
setPlaying(null)
|
||||||
|
setQueued(null)
|
||||||
|
setPlaybackPosition(0)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleCustomEvaluate = (formula: string) => {
|
||||||
|
if (focusedTile !== 'custom') {
|
||||||
|
saveCurrentTileParams()
|
||||||
|
loadTileParams(customTile)
|
||||||
|
}
|
||||||
|
|
||||||
|
setFocusedTile('custom')
|
||||||
|
setCustomTile({ ...customTile, formula })
|
||||||
|
playFormula(formula, PLAYBACK_ID.CUSTOM)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleCustomStop = () => {
|
||||||
|
if (playing === PLAYBACK_ID.CUSTOM) {
|
||||||
|
handleStop()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleCustomRandom = () => {
|
||||||
|
return generateRandomFormula(engineValues.complexity)
|
||||||
|
}
|
||||||
|
|
||||||
|
const moveFocus = (direction: 'up' | 'down' | 'left' | 'right', step: number = 1) => {
|
||||||
|
saveCurrentTileParams()
|
||||||
|
|
||||||
|
setFocusedTile(prev => {
|
||||||
|
if (prev === 'custom') return prev
|
||||||
|
|
||||||
|
let { row, col } = prev
|
||||||
|
const maxRow = tiles.length - 1
|
||||||
|
const maxCol = (tiles[row]?.length || 1) - 1
|
||||||
|
|
||||||
|
switch (direction) {
|
||||||
|
case 'up':
|
||||||
|
row = Math.max(0, row - step)
|
||||||
|
break
|
||||||
|
case 'down':
|
||||||
|
row = Math.min(maxRow, row + step)
|
||||||
|
break
|
||||||
|
case 'left':
|
||||||
|
col = Math.max(0, col - step)
|
||||||
|
break
|
||||||
|
case 'right':
|
||||||
|
col = Math.min(maxCol, col + step)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
const newTile = tiles[row]?.[col]
|
||||||
|
if (newTile) {
|
||||||
|
loadTileParams(newTile)
|
||||||
|
return { row, col }
|
||||||
|
}
|
||||||
|
|
||||||
|
return prev
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleKeyboardSpace = () => {
|
||||||
|
if (playing) {
|
||||||
|
handleStop()
|
||||||
|
} else if (focusedTile !== 'custom') {
|
||||||
|
const tile = tiles[focusedTile.row]?.[focusedTile.col]
|
||||||
|
if (tile) {
|
||||||
|
handleTileClick(tile.formula, focusedTile.row, focusedTile.col, true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleKeyboardEnter = () => {
|
||||||
|
if (focusedTile !== 'custom') {
|
||||||
|
const tile = tiles[focusedTile.row]?.[focusedTile.col]
|
||||||
|
if (tile) {
|
||||||
|
handleTileClick(tile.formula, focusedTile.row, focusedTile.col, false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleKeyboardDoubleEnter = () => {
|
||||||
|
if (focusedTile !== 'custom') {
|
||||||
|
const tile = tiles[focusedTile.row]?.[focusedTile.col]
|
||||||
|
if (tile) {
|
||||||
|
handleTileClick(tile.formula, focusedTile.row, focusedTile.col, true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleKeyboardR = () => {
|
||||||
|
if (focusedTile !== 'custom') {
|
||||||
|
handleRegenerate(focusedTile.row, focusedTile.col)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleKeyboardShiftR = () => {
|
||||||
|
handleRandom()
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleEscape = () => {
|
||||||
|
exitMappingMode()
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleKeyboardC = () => {
|
||||||
|
if (focusedTile === 'custom') {
|
||||||
|
setCustomTile(prev => {
|
||||||
|
const randomized = randomizeTileParams(prev)
|
||||||
|
loadTileParams(randomized)
|
||||||
|
|
||||||
|
if (playing === PLAYBACK_ID.CUSTOM && playbackManagerRef.current) {
|
||||||
|
playbackManagerRef.current.setEffects(randomized.effectParams)
|
||||||
|
playbackManagerRef.current.setVariables(
|
||||||
|
randomized.engineParams.a ?? DEFAULT_VARIABLES.a,
|
||||||
|
randomized.engineParams.b ?? DEFAULT_VARIABLES.b,
|
||||||
|
randomized.engineParams.c ?? DEFAULT_VARIABLES.c,
|
||||||
|
randomized.engineParams.d ?? DEFAULT_VARIABLES.d
|
||||||
|
)
|
||||||
|
playbackManagerRef.current.setPitch(randomized.engineParams.pitch ?? 1.0)
|
||||||
|
|
||||||
|
if (randomized.lfoConfigs) {
|
||||||
|
playbackManagerRef.current.setLFOConfig(0, randomized.lfoConfigs.lfo1)
|
||||||
|
playbackManagerRef.current.setLFOConfig(1, randomized.lfoConfigs.lfo2)
|
||||||
|
playbackManagerRef.current.setLFOConfig(2, randomized.lfoConfigs.lfo3)
|
||||||
|
playbackManagerRef.current.setLFOConfig(3, randomized.lfoConfigs.lfo4)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return randomized
|
||||||
})
|
})
|
||||||
} else {
|
} else {
|
||||||
setFormulas(prevFormulas => {
|
const tileId = getTileId(focusedTile.row, focusedTile.col)
|
||||||
const newFormulas = [...prevFormulas]
|
setTiles(prevTiles => {
|
||||||
newFormulas[row] = [...newFormulas[row]]
|
const newTiles = [...prevTiles]
|
||||||
newFormulas[row][col] = newFormula
|
newTiles[focusedTile.row] = [...newTiles[focusedTile.row]]
|
||||||
return newFormulas
|
const randomized = randomizeTileParams(newTiles[focusedTile.row][focusedTile.col])
|
||||||
|
newTiles[focusedTile.row][focusedTile.col] = randomized
|
||||||
|
|
||||||
|
loadTileParams(randomized)
|
||||||
|
|
||||||
|
if (playing === tileId && playbackManagerRef.current) {
|
||||||
|
playbackManagerRef.current.setEffects(randomized.effectParams)
|
||||||
|
playbackManagerRef.current.setVariables(
|
||||||
|
randomized.engineParams.a ?? DEFAULT_VARIABLES.a,
|
||||||
|
randomized.engineParams.b ?? DEFAULT_VARIABLES.b,
|
||||||
|
randomized.engineParams.c ?? DEFAULT_VARIABLES.c,
|
||||||
|
randomized.engineParams.d ?? DEFAULT_VARIABLES.d
|
||||||
|
)
|
||||||
|
playbackManagerRef.current.setPitch(randomized.engineParams.pitch ?? 1.0)
|
||||||
|
|
||||||
|
if (randomized.lfoConfigs) {
|
||||||
|
playbackManagerRef.current.setLFOConfig(0, randomized.lfoConfigs.lfo1)
|
||||||
|
playbackManagerRef.current.setLFOConfig(1, randomized.lfoConfigs.lfo2)
|
||||||
|
playbackManagerRef.current.setLFOConfig(2, randomized.lfoConfigs.lfo3)
|
||||||
|
playbackManagerRef.current.setLFOConfig(3, randomized.lfoConfigs.lfo4)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return newTiles
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const handleKeyboardShiftC = () => {
|
||||||
|
handleRandomizeAllParams()
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleDismissWarning = () => {
|
||||||
|
setShowWarning(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
useKeyboardShortcuts({
|
||||||
|
onSpace: handleKeyboardSpace,
|
||||||
|
onArrowUp: (shift) => moveFocus('up', shift ? 10 : 1),
|
||||||
|
onArrowDown: (shift) => moveFocus('down', shift ? 10 : 1),
|
||||||
|
onArrowLeft: (shift) => moveFocus('left', shift ? 10 : 1),
|
||||||
|
onArrowRight: (shift) => moveFocus('right', shift ? 10 : 1),
|
||||||
|
onEnter: handleKeyboardEnter,
|
||||||
|
onDoubleEnter: handleKeyboardDoubleEnter,
|
||||||
|
onR: handleKeyboardR,
|
||||||
|
onShiftR: handleKeyboardShiftR,
|
||||||
|
onC: handleKeyboardC,
|
||||||
|
onShiftC: handleKeyboardShiftC,
|
||||||
|
onEscape: handleEscape
|
||||||
|
})
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (focusedTile !== 'custom') {
|
||||||
|
const element = document.querySelector(`[data-tile-id="${focusedTile.row}-${focusedTile.col}"]`)
|
||||||
|
if (element) {
|
||||||
|
element.scrollIntoView({ behavior: 'smooth', block: 'nearest' })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [focusedTile])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="w-screen h-screen flex flex-col bg-black overflow-hidden">
|
<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">
|
<header className="bg-black border-b-2 border-white px-6 py-3">
|
||||||
<div className="flex items-center justify-between mb-3">
|
<div className="flex items-center justify-between gap-6">
|
||||||
<h1 className="font-mono text-sm tracking-[0.3em] text-white">BRUITISTE</h1>
|
<h1
|
||||||
<div className="flex gap-4">
|
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}
|
||||||
|
disabled={!playing}
|
||||||
|
className="px-6 py-2 bg-black text-white border-2 border-white font-mono text-[11px] tracking-[0.2em] hover:bg-white hover:text-black transition-all disabled:opacity-30 disabled:cursor-not-allowed flex items-center gap-2"
|
||||||
|
>
|
||||||
|
<Square size={14} strokeWidth={2} fill="currentColor" />
|
||||||
|
STOP
|
||||||
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={handleRandom}
|
onClick={handleRandom}
|
||||||
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"
|
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"
|
||||||
>
|
>
|
||||||
|
<Dices size={14} strokeWidth={2} />
|
||||||
RANDOM
|
RANDOM
|
||||||
</button>
|
</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
|
<button
|
||||||
onClick={handleDownloadAll}
|
onClick={handleDownloadAll}
|
||||||
disabled={downloading}
|
disabled={downloading}
|
||||||
className="px-6 py-2 bg-black text-white border-2 border-white font-mono text-[11px] tracking-[0.2em] hover:bg-white hover:text-black transition-all disabled:opacity-30 disabled:cursor-not-allowed"
|
className="px-6 py-2 bg-black text-white border-2 border-white font-mono text-[11px] tracking-[0.2em] hover:bg-white hover:text-black transition-all disabled:opacity-30 disabled:cursor-not-allowed flex items-center gap-2"
|
||||||
>
|
>
|
||||||
{downloading ? 'DOWNLOADING...' : 'DOWNLOAD ALL'}
|
<Archive size={14} strokeWidth={2} />
|
||||||
|
{downloading ? 'DOWNLOADING...' : 'PACK'}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<EngineControls values={engineValues} onChange={handleEngineChange} />
|
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<div className="flex-1 grid grid-cols-2 auto-rows-min gap-[1px] bg-white p-[1px] overflow-auto">
|
<LFOPanel onChange={handleLFOChange} onUpdateDepth={handleUpdateMappingDepth} onRemoveMapping={handleRemoveMapping} />
|
||||||
{formulas.map((row, i) =>
|
|
||||||
row.map((formula, j) => {
|
<div className="flex-1 flex flex-col overflow-auto bg-white">
|
||||||
const id = `${i}-${j}`
|
<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}
|
||||||
|
isFocused={focusedTile === 'custom'}
|
||||||
|
playbackPosition={playing === PLAYBACK_ID.CUSTOM ? playbackPosition : 0}
|
||||||
|
onEvaluate={handleCustomEvaluate}
|
||||||
|
onStop={handleCustomStop}
|
||||||
|
onRandom={handleCustomRandom}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex-1 grid grid-cols-4 auto-rows-min gap-[1px] bg-white p-[1px]">
|
||||||
|
{tiles.map((row, i) =>
|
||||||
|
row.map((tile, j) => {
|
||||||
|
const id = getTileId(i, j)
|
||||||
return (
|
return (
|
||||||
<BytebeatTile
|
<BytebeatTile
|
||||||
key={id}
|
key={id}
|
||||||
formula={formula}
|
formula={tile.formula}
|
||||||
row={i}
|
row={i}
|
||||||
col={j}
|
col={j}
|
||||||
isPlaying={playing === id}
|
isPlaying={playing === id}
|
||||||
isQueued={queued === id}
|
isQueued={queued === id}
|
||||||
isRegenerating={regenerating === id}
|
isFocused={focusedTile !== 'custom' && focusedTile.row === i && focusedTile.col === j}
|
||||||
playbackPosition={playing === id ? playbackPosition : 0}
|
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}
|
onPlay={handleTileClick}
|
||||||
onDoubleClick={handleTileDoubleClick}
|
onDoubleClick={handleTileDoubleClick}
|
||||||
onDownload={handleDownloadFormula}
|
onDownload={handleDownloadFormula}
|
||||||
@ -215,8 +634,14 @@ function App() {
|
|||||||
})
|
})
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<EffectsBar values={effectValues} onChange={handleEffectChange} />
|
<EffectsBar
|
||||||
|
values={effectValues}
|
||||||
|
onChange={handleEffectChange}
|
||||||
|
onMapClick={handleParameterMapClick}
|
||||||
|
getMappedLFOs={getMappedLFOs}
|
||||||
|
/>
|
||||||
</div>
|
</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 />
|
||||||
|
}
|
||||||
@ -1,5 +1,5 @@
|
|||||||
import { useRef, useEffect } from 'react'
|
import { useRef, useEffect } from 'react'
|
||||||
import { Download, RefreshCw } from 'lucide-react'
|
import { Download, Dices } from 'lucide-react'
|
||||||
import { generateWaveformData, drawWaveform } from '../utils/waveformGenerator'
|
import { generateWaveformData, drawWaveform } from '../utils/waveformGenerator'
|
||||||
|
|
||||||
interface BytebeatTileProps {
|
interface BytebeatTileProps {
|
||||||
@ -8,15 +8,19 @@ interface BytebeatTileProps {
|
|||||||
col: number
|
col: number
|
||||||
isPlaying: boolean
|
isPlaying: boolean
|
||||||
isQueued: boolean
|
isQueued: boolean
|
||||||
isRegenerating: boolean
|
isFocused: boolean
|
||||||
playbackPosition: number
|
playbackPosition: number
|
||||||
|
a: number
|
||||||
|
b: number
|
||||||
|
c: number
|
||||||
|
d: number
|
||||||
onPlay: (formula: string, row: number, col: number) => void
|
onPlay: (formula: string, row: number, col: number) => void
|
||||||
onDoubleClick: (formula: string, row: number, col: number) => void
|
onDoubleClick: (formula: string, row: number, col: number) => void
|
||||||
onDownload: (formula: string, filename: string) => void
|
onDownload: (formula: string, filename: string) => void
|
||||||
onRegenerate: (row: number, col: number) => void
|
onRegenerate: (row: number, col: number) => void
|
||||||
}
|
}
|
||||||
|
|
||||||
export function BytebeatTile({ formula, row, col, isPlaying, isQueued, isRegenerating, 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)
|
const canvasRef = useRef<HTMLCanvasElement>(null)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@ -27,10 +31,10 @@ export function BytebeatTile({ formula, row, col, isPlaying, isQueued, isRegener
|
|||||||
canvas.width = rect.width * window.devicePixelRatio
|
canvas.width = rect.width * window.devicePixelRatio
|
||||||
canvas.height = rect.height * 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)'
|
const color = isPlaying ? 'rgba(0, 0, 0, 0.3)' : 'rgba(255, 255, 255, 0.35)'
|
||||||
drawWaveform(canvas, waveformData, color)
|
drawWaveform(canvas, waveformData, color)
|
||||||
}, [formula, isPlaying, isQueued])
|
}, [formula, isPlaying, isQueued, a, b, c, d])
|
||||||
|
|
||||||
const handleDownload = (e: React.MouseEvent) => {
|
const handleDownload = (e: React.MouseEvent) => {
|
||||||
e.stopPropagation()
|
e.stopPropagation()
|
||||||
@ -44,11 +48,12 @@ export function BytebeatTile({ formula, row, col, isPlaying, isQueued, isRegener
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
|
data-tile-id={`${row}-${col}`}
|
||||||
onClick={() => onPlay(formula, row, col)}
|
onClick={() => onPlay(formula, row, col)}
|
||||||
onDoubleClick={() => onDoubleClick(formula, row, col)}
|
onDoubleClick={() => onDoubleClick(formula, row, col)}
|
||||||
className={`relative hover:scale-[0.98] transition-all duration-150 font-mono p-3 flex items-center justify-between gap-3 cursor-pointer overflow-hidden ${
|
className={`relative hover:scale-[0.98] transition-all duration-150 font-mono p-3 flex items-center justify-between gap-1 cursor-pointer overflow-hidden ${
|
||||||
isPlaying ? 'bg-white text-black' : isQueued ? 'bg-black text-white animate-pulse' : isRegenerating ? 'bg-black text-white border-2 border-white' : 'bg-black text-white'
|
isPlaying ? 'bg-white text-black' : isQueued ? 'bg-black text-white animate-pulse' : 'bg-black text-white'
|
||||||
}`}
|
} ${isFocused ? 'outline outline-2 outline-white outline-offset-[-4px]' : ''}`}
|
||||||
>
|
>
|
||||||
<canvas
|
<canvas
|
||||||
ref={canvasRef}
|
ref={canvasRef}
|
||||||
@ -63,7 +68,7 @@ export function BytebeatTile({ formula, row, col, isPlaying, isQueued, isRegener
|
|||||||
<div className="text-xs break-all font-light flex-1 relative z-10">
|
<div className="text-xs break-all font-light flex-1 relative z-10">
|
||||||
{formula}
|
{formula}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex gap-2 flex-shrink-0 relative z-10">
|
<div className="flex gap-1 flex-shrink-0 relative z-10">
|
||||||
<div
|
<div
|
||||||
onClick={handleRegenerate}
|
onClick={handleRegenerate}
|
||||||
className={`p-2 border transition-all duration-150 cursor-pointer hover:scale-105 ${
|
className={`p-2 border transition-all duration-150 cursor-pointer hover:scale-105 ${
|
||||||
@ -72,7 +77,7 @@ export function BytebeatTile({ formula, row, col, isPlaying, isQueued, isRegener
|
|||||||
: 'bg-white text-black border-white'
|
: 'bg-white text-black border-white'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<RefreshCw size={14} strokeWidth={2} className={isRegenerating ? 'animate-spin' : ''} />
|
<Dices size={14} strokeWidth={2} />
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
onClick={handleDownload}
|
onClick={handleDownload}
|
||||||
|
|||||||
27
src/components/Dropdown.tsx
Normal file
27
src/components/Dropdown.tsx
Normal file
@ -0,0 +1,27 @@
|
|||||||
|
interface DropdownProps {
|
||||||
|
label: string
|
||||||
|
value: string
|
||||||
|
options: { value: string; label: string }[]
|
||||||
|
onChange: (value: string) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Dropdown({ label, value, options, onChange }: DropdownProps) {
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-1">
|
||||||
|
<label className="font-mono text-[9px] tracking-[0.15em] text-white">
|
||||||
|
{label.toUpperCase()}
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
value={value}
|
||||||
|
onChange={(e) => onChange(e.target.value)}
|
||||||
|
className="bg-black text-white border-2 border-white font-mono text-[10px] tracking-[0.1em] px-2 py-1 cursor-pointer hover:bg-white hover:text-black transition-colors"
|
||||||
|
>
|
||||||
|
{options.map((option) => (
|
||||||
|
<option key={option.value} value={option.value}>
|
||||||
|
{option.label.toUpperCase()}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@ -1,103 +1,75 @@
|
|||||||
|
import { Dices } from 'lucide-react'
|
||||||
import { Slider } from './Slider'
|
import { Slider } from './Slider'
|
||||||
import { Switch } from './Switch'
|
import { Switch } from './Switch'
|
||||||
|
import { Dropdown } from './Dropdown'
|
||||||
import { EFFECTS } from '../config/effects'
|
import { EFFECTS } from '../config/effects'
|
||||||
import { getClipModeLabel } from '../utils/formatters'
|
|
||||||
import type { EffectValues } from '../types/effects'
|
import type { EffectValues } from '../types/effects'
|
||||||
|
|
||||||
interface EffectsBarProps {
|
interface EffectsBarProps {
|
||||||
values: EffectValues
|
values: EffectValues
|
||||||
onChange: (parameterId: string, value: number | boolean) => void
|
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 formatValue = (id: string, value: number): string => {
|
const randomizeEffect = (effect: typeof EFFECTS[number]) => {
|
||||||
if (id === 'clipMode') {
|
effect.parameters.forEach(param => {
|
||||||
return getClipModeLabel(value)
|
if (param.id.endsWith('Enable')) return
|
||||||
|
|
||||||
|
if (param.options) {
|
||||||
|
const randomOption = param.options[Math.floor(Math.random() * param.options.length)]
|
||||||
|
onChange(param.id, randomOption.value)
|
||||||
|
} else {
|
||||||
|
const range = param.max - param.min
|
||||||
|
const steps = Math.floor(range / param.step)
|
||||||
|
const randomStep = Math.floor(Math.random() * (steps + 1))
|
||||||
|
const randomValue = param.min + (randomStep * param.step)
|
||||||
|
onChange(param.id, randomValue)
|
||||||
}
|
}
|
||||||
return value.toString()
|
})
|
||||||
}
|
|
||||||
|
|
||||||
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">
|
|
||||||
<h3 className="font-mono text-[10px] tracking-[0.2em] text-white mb-3">
|
|
||||||
{effect.name.toUpperCase()}
|
|
||||||
</h3>
|
|
||||||
<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">
|
|
||||||
<Switch
|
|
||||||
checked={Boolean(values[enableParam.id])}
|
|
||||||
onChange={(checked) => onChange(enableParam.id, checked ? 1 : 0)}
|
|
||||||
vertical
|
|
||||||
/>
|
|
||||||
<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 (
|
return (
|
||||||
<div className="bg-black border-t-2 border-white px-6 py-4">
|
<div className="bg-black border-t-2 border-white px-6 py-4">
|
||||||
<div className="grid grid-cols-4 gap-4">
|
<div className="grid grid-cols-4 gap-4">
|
||||||
{EFFECTS.map(effect => {
|
{EFFECTS.map(effect => {
|
||||||
if (effect.id === 'filter') {
|
|
||||||
return renderFilterEffect(effect)
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div key={effect.id} className="border-2 border-white p-3">
|
<div key={effect.id} className="border-2 border-white p-3">
|
||||||
<div className="flex items-center justify-between mb-3">
|
<div className="flex items-center justify-between mb-3">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
<h3 className="font-mono text-[10px] tracking-[0.2em] text-white">
|
<h3 className="font-mono text-[10px] tracking-[0.2em] text-white">
|
||||||
{effect.name.toUpperCase()}
|
{effect.name.toUpperCase()}
|
||||||
</h3>
|
</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>
|
||||||
{effect.bypassable && (
|
{effect.bypassable && (
|
||||||
<Switch
|
<Switch
|
||||||
checked={!Boolean(values[`${effect.id}Bypass`])}
|
checked={!values[`${effect.id}Bypass`]}
|
||||||
onChange={(checked) => onChange(`${effect.id}Bypass`, !checked)}
|
onChange={(checked) => onChange(`${effect.id}Bypass`, !checked)}
|
||||||
label={Boolean(values[`${effect.id}Bypass`]) ? 'OFF' : 'ON'}
|
label={values[`${effect.id}Bypass`] ? 'OFF' : 'ON'}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-col gap-3">
|
<div className="flex flex-col gap-3">
|
||||||
{effect.parameters.map(param => {
|
{effect.parameters.map(param => {
|
||||||
|
if (param.options) {
|
||||||
|
return (
|
||||||
|
<Dropdown
|
||||||
|
key={param.id}
|
||||||
|
label={param.label}
|
||||||
|
value={values[param.id] as string ?? param.default as string}
|
||||||
|
options={param.options}
|
||||||
|
onChange={(value) => onChange(param.id, value)}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
const isSwitch = param.min === 0 && param.max === 1 && param.step === 1
|
const isSwitch = param.min === 0 && param.max === 1 && param.step === 1
|
||||||
|
|
||||||
if (isSwitch) {
|
if (isSwitch) {
|
||||||
@ -110,7 +82,7 @@ export function EffectsBar({ values, onChange }: EffectsBarProps) {
|
|||||||
<Switch
|
<Switch
|
||||||
checked={Boolean(values[param.id])}
|
checked={Boolean(values[param.id])}
|
||||||
onChange={(checked) => onChange(param.id, checked ? 1 : 0)}
|
onChange={(checked) => onChange(param.id, checked ? 1 : 0)}
|
||||||
label={Boolean(values[param.id]) ? 'ON' : 'OFF'}
|
label={values[param.id] ? 'ON' : 'OFF'}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -121,14 +93,16 @@ export function EffectsBar({ values, onChange }: EffectsBarProps) {
|
|||||||
<Slider
|
<Slider
|
||||||
key={param.id}
|
key={param.id}
|
||||||
label={param.label}
|
label={param.label}
|
||||||
value={values[param.id] as number ?? param.default}
|
value={values[param.id] as number ?? param.default as number}
|
||||||
min={param.min}
|
min={param.min}
|
||||||
max={param.max}
|
max={param.max}
|
||||||
step={param.step}
|
step={param.step}
|
||||||
unit={param.unit}
|
unit={param.unit}
|
||||||
onChange={(value) => onChange(param.id, value)}
|
onChange={(value) => onChange(param.id, value)}
|
||||||
formatValue={param.id === 'clipMode' ? formatValue : undefined}
|
|
||||||
valueId={param.id}
|
valueId={param.id}
|
||||||
|
paramId={param.id}
|
||||||
|
onMapClick={onMapClick}
|
||||||
|
mappedLFOs={getMappedLFOs ? getMappedLFOs(param.id) : []}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
})}
|
})}
|
||||||
|
|||||||
@ -1,13 +1,18 @@
|
|||||||
import { ENGINE_CONTROLS } from '../config/effects'
|
import { ENGINE_CONTROLS } from '../config/effects'
|
||||||
import { getComplexityLabel, getBitDepthLabel, getSampleRateLabel } from '../utils/formatters'
|
import { getComplexityLabel, getBitDepthLabel, getSampleRateLabel } from '../utils/formatters'
|
||||||
import type { EffectValues } from '../types/effects'
|
import type { EffectValues } from '../types/effects'
|
||||||
|
import { Knob } from './Knob'
|
||||||
|
|
||||||
interface EngineControlsProps {
|
interface EngineControlsProps {
|
||||||
values: EffectValues
|
values: EffectValues
|
||||||
onChange: (parameterId: string, value: number) => void
|
onChange: (parameterId: string, value: number) => void
|
||||||
|
onMapClick?: (paramId: string, lfoIndex: number) => void
|
||||||
|
getMappedLFOs?: (paramId: string) => number[]
|
||||||
}
|
}
|
||||||
|
|
||||||
export function EngineControls({ values, onChange }: EngineControlsProps) {
|
const KNOB_PARAMS = ['masterVolume', 'pitch', 'a', 'b', 'c', 'd']
|
||||||
|
|
||||||
|
export function EngineControls({ values, onChange, onMapClick, getMappedLFOs }: EngineControlsProps) {
|
||||||
const formatValue = (id: string, value: number): string => {
|
const formatValue = (id: string, value: number): string => {
|
||||||
switch (id) {
|
switch (id) {
|
||||||
case 'sampleRate':
|
case 'sampleRate':
|
||||||
@ -16,15 +21,39 @@ export function EngineControls({ values, onChange }: EngineControlsProps) {
|
|||||||
return getComplexityLabel(value)
|
return getComplexityLabel(value)
|
||||||
case 'bitDepth':
|
case 'bitDepth':
|
||||||
return getBitDepthLabel(value)
|
return getBitDepthLabel(value)
|
||||||
default:
|
default: {
|
||||||
const param = ENGINE_CONTROLS[0].parameters.find(p => p.id === id)
|
const param = ENGINE_CONTROLS[0].parameters.find(p => p.id === id)
|
||||||
return `${value}${param?.unit || ''}`
|
return `${value}${param?.unit || ''}`
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center gap-6">
|
<div className="flex items-center gap-6">
|
||||||
{ENGINE_CONTROLS[0].parameters.map(param => (
|
{ENGINE_CONTROLS[0].parameters.map(param => {
|
||||||
|
const useKnob = KNOB_PARAMS.includes(param.id)
|
||||||
|
|
||||||
|
if (useKnob) {
|
||||||
|
return (
|
||||||
|
<Knob
|
||||||
|
key={param.id}
|
||||||
|
label={param.label}
|
||||||
|
value={(values[param.id] as number) ?? param.default}
|
||||||
|
min={param.min as number}
|
||||||
|
max={param.max as number}
|
||||||
|
step={param.step as number}
|
||||||
|
unit={param.unit}
|
||||||
|
onChange={(value) => onChange(param.id, value)}
|
||||||
|
formatValue={formatValue}
|
||||||
|
valueId={param.id}
|
||||||
|
paramId={param.id}
|
||||||
|
onMapClick={onMapClick}
|
||||||
|
mappedLFOs={getMappedLFOs ? getMappedLFOs(param.id) : []}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
<div key={param.id} className="flex flex-col gap-1 min-w-[100px]">
|
<div key={param.id} className="flex flex-col gap-1 min-w-[100px]">
|
||||||
<div className="flex justify-between items-baseline">
|
<div className="flex justify-between items-baseline">
|
||||||
<label className="font-mono text-[9px] tracking-[0.15em] text-white">
|
<label className="font-mono text-[9px] tracking-[0.15em] text-white">
|
||||||
@ -44,7 +73,8 @@ export function EngineControls({ values, onChange }: EngineControlsProps) {
|
|||||||
className="w-full h-[2px] bg-white appearance-none cursor-pointer"
|
className="w-full h-[2px] bg-white appearance-none cursor-pointer"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
))}
|
)
|
||||||
|
})}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
100
src/components/FormulaEditor.tsx
Normal file
100
src/components/FormulaEditor.tsx
Normal file
@ -0,0 +1,100 @@
|
|||||||
|
import { useState, useEffect } from 'react'
|
||||||
|
import { Play, Square, Dices } from 'lucide-react'
|
||||||
|
|
||||||
|
interface FormulaEditorProps {
|
||||||
|
formula: string
|
||||||
|
isPlaying: boolean
|
||||||
|
isFocused: boolean
|
||||||
|
playbackPosition: number
|
||||||
|
onEvaluate: (formula: string) => void
|
||||||
|
onStop: () => void
|
||||||
|
onRandom: () => string
|
||||||
|
}
|
||||||
|
|
||||||
|
export function FormulaEditor({ formula: externalFormula, isPlaying, isFocused, playbackPosition, onEvaluate, onStop, onRandom }: FormulaEditorProps) {
|
||||||
|
const [formula, setFormula] = useState(externalFormula)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setFormula(externalFormula)
|
||||||
|
}, [externalFormula])
|
||||||
|
|
||||||
|
const handleEvaluate = () => {
|
||||||
|
if (formula.trim()) {
|
||||||
|
onEvaluate(formula)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleRandom = () => {
|
||||||
|
const newFormula = onRandom()
|
||||||
|
setFormula(newFormula)
|
||||||
|
onEvaluate(newFormula)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleKeyDown = (e: React.KeyboardEvent) => {
|
||||||
|
if (e.key === 'Enter') {
|
||||||
|
if (e.ctrlKey) {
|
||||||
|
e.preventDefault()
|
||||||
|
handleEvaluate()
|
||||||
|
} else {
|
||||||
|
e.preventDefault()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={`relative font-mono p-3 flex items-center gap-3 transition-all duration-150 ${
|
||||||
|
isPlaying ? 'bg-white text-black' : 'bg-black text-white'
|
||||||
|
} ${isFocused ? 'ring-2 ring-white ring-inset' : ''}`}
|
||||||
|
>
|
||||||
|
{isPlaying && (
|
||||||
|
<div
|
||||||
|
className="absolute left-0 top-0 bottom-0 bg-black opacity-10 transition-all duration-75 ease-linear"
|
||||||
|
style={{ width: `${playbackPosition * 100}%` }}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={formula}
|
||||||
|
onChange={(e) => setFormula(e.target.value)}
|
||||||
|
onKeyDown={handleKeyDown}
|
||||||
|
placeholder="Enter bytebeat formula..."
|
||||||
|
className={`flex-1 bg-transparent border-none outline-none font-mono text-xs relative z-10 ${
|
||||||
|
isPlaying ? 'text-black placeholder:text-black/50' : 'text-white placeholder:text-white/50'
|
||||||
|
}`}
|
||||||
|
/>
|
||||||
|
<div className="flex gap-2 relative z-10">
|
||||||
|
<button
|
||||||
|
onClick={handleRandom}
|
||||||
|
className={`p-2 border transition-all duration-150 cursor-pointer hover:scale-105 ${
|
||||||
|
isPlaying
|
||||||
|
? 'bg-black text-white border-black'
|
||||||
|
: 'bg-white text-black border-white'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<Dices size={14} strokeWidth={2} />
|
||||||
|
</button>
|
||||||
|
{isPlaying && (
|
||||||
|
<button
|
||||||
|
onClick={onStop}
|
||||||
|
className="px-4 py-2 border transition-all duration-150 cursor-pointer hover:scale-105 flex items-center gap-2 font-mono text-[10px] tracking-[0.2em] flex-shrink-0 bg-black text-white border-black"
|
||||||
|
>
|
||||||
|
<Square size={12} strokeWidth={2} fill="currentColor" />
|
||||||
|
STOP
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
onClick={handleEvaluate}
|
||||||
|
className={`px-4 py-2 border transition-all duration-150 cursor-pointer hover:scale-105 flex items-center gap-2 font-mono text-[10px] tracking-[0.2em] flex-shrink-0 ${
|
||||||
|
isPlaying
|
||||||
|
? 'bg-black text-white border-black'
|
||||||
|
: 'bg-white text-black border-white'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<Play size={12} strokeWidth={2} fill="currentColor" />
|
||||||
|
EVAL
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
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">
|
||||||
|
Harsh noise soundbox made as a love statement to all weird noises, hums, audio glitches and ominous textures. Be careful, lower your volume! Tweak some parameters!
|
||||||
|
</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>
|
||||||
|
)
|
||||||
|
}
|
||||||
145
src/components/Knob.tsx
Normal file
145
src/components/Knob.tsx
Normal file
@ -0,0 +1,145 @@
|
|||||||
|
import { useRef, useState, useEffect, useCallback } from 'react'
|
||||||
|
import { useStore } from '@nanostores/react'
|
||||||
|
import { mappingMode } from '../stores/mappingMode'
|
||||||
|
|
||||||
|
interface KnobProps {
|
||||||
|
label: string
|
||||||
|
value: number
|
||||||
|
min: number
|
||||||
|
max: number
|
||||||
|
step: number
|
||||||
|
unit?: string
|
||||||
|
onChange: (value: number) => void
|
||||||
|
formatValue?: (id: string, value: number) => string
|
||||||
|
valueId?: string
|
||||||
|
size?: number
|
||||||
|
paramId?: string
|
||||||
|
onMapClick?: (paramId: string, activeLFO: number) => void
|
||||||
|
mappedLFOs?: number[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Knob({
|
||||||
|
label,
|
||||||
|
value,
|
||||||
|
min,
|
||||||
|
max,
|
||||||
|
step,
|
||||||
|
unit,
|
||||||
|
onChange,
|
||||||
|
formatValue,
|
||||||
|
valueId,
|
||||||
|
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
|
||||||
|
e.preventDefault()
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleMouseMove = useCallback((e: MouseEvent) => {
|
||||||
|
if (!isDragging) return
|
||||||
|
|
||||||
|
const deltaY = startYRef.current - e.clientY
|
||||||
|
const range = max - min
|
||||||
|
const sensitivity = range / 200
|
||||||
|
const newValue = Math.max(min, Math.min(max, startValueRef.current + deltaY * sensitivity))
|
||||||
|
|
||||||
|
const steppedValue = Math.round(newValue / step) * step
|
||||||
|
onChange(steppedValue)
|
||||||
|
}, [isDragging, max, min, step, onChange])
|
||||||
|
|
||||||
|
const handleMouseUp = useCallback(() => {
|
||||||
|
setIsDragging(false)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (isDragging) {
|
||||||
|
window.addEventListener('mousemove', handleMouseMove)
|
||||||
|
window.addEventListener('mouseup', handleMouseUp)
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener('mousemove', handleMouseMove)
|
||||||
|
window.removeEventListener('mouseup', handleMouseUp)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [isDragging, handleMouseMove, handleMouseUp])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="relative flex flex-col items-center">
|
||||||
|
<div
|
||||||
|
className={`relative select-none ${isInMappingMode ? 'cursor-pointer' : 'cursor-ns-resize'}`}
|
||||||
|
onMouseDown={handleMouseDown}
|
||||||
|
style={{ width: size, height: size }}
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
width={size}
|
||||||
|
height={size}
|
||||||
|
viewBox={`0 0 ${size} ${size}`}
|
||||||
|
>
|
||||||
|
<circle
|
||||||
|
cx={size / 2}
|
||||||
|
cy={size / 2}
|
||||||
|
r={(size - 4) / 2}
|
||||||
|
fill="none"
|
||||||
|
stroke="white"
|
||||||
|
strokeWidth="2"
|
||||||
|
className={isInMappingMode ? 'animate-pulse' : ''}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<circle
|
||||||
|
cx={size / 2}
|
||||||
|
cy={size / 2}
|
||||||
|
r={(size - 8) / 2}
|
||||||
|
fill="black"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<line
|
||||||
|
x1={size / 2 + Math.cos((angle * Math.PI) / 180) * ((size - 16) / 2)}
|
||||||
|
y1={size / 2 + Math.sin((angle * Math.PI) / 180) * ((size - 16) / 2)}
|
||||||
|
x2={size / 2 + Math.cos((angle * Math.PI) / 180) * ((size - 4) / 2)}
|
||||||
|
y2={size / 2 + Math.sin((angle * Math.PI) / 180) * ((size - 4) / 2)}
|
||||||
|
stroke="white"
|
||||||
|
strokeWidth="2"
|
||||||
|
strokeLinecap="square"
|
||||||
|
/>
|
||||||
|
|
||||||
|
{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 ${isInMappingMode ? 'animate-pulse' : ''}`}>
|
||||||
|
{isDragging ? displayValue : label.toUpperCase()}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</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, useCallback } from 'react'
|
||||||
|
import { useStore } from '@nanostores/react'
|
||||||
|
import { LFO, type LFOWaveform } from '../domain/modulation/LFO'
|
||||||
|
import { mappingMode } from '../stores/mappingMode'
|
||||||
|
import { parameterRegistry } from '../domain/modulation/ParameterRegistry'
|
||||||
|
import { MappingEditor } from './MappingEditor'
|
||||||
|
|
||||||
|
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)
|
||||||
|
|
||||||
|
const getLFOValueAtPhase = useCallback((phaseVal: number): number => {
|
||||||
|
const normalizedPhase = phaseVal % 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
|
||||||
|
}
|
||||||
|
}, [waveform])
|
||||||
|
|
||||||
|
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, getLFOValueAtPhase])
|
||||||
|
|
||||||
|
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 {
|
interface SliderProps {
|
||||||
label: string
|
label: string
|
||||||
value: number
|
value: number
|
||||||
@ -8,16 +11,47 @@ interface SliderProps {
|
|||||||
onChange: (value: number) => void
|
onChange: (value: number) => void
|
||||||
formatValue?: (id: string, value: number) => string
|
formatValue?: (id: string, value: number) => string
|
||||||
valueId?: 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 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 (
|
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">
|
<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()}
|
{label.toUpperCase()}
|
||||||
|
{hasMappings && <span className="ml-1 text-[8px]">●</span>}
|
||||||
</label>
|
</label>
|
||||||
<span className="font-mono text-[10px] text-white">
|
<span className="font-mono text-[10px] text-white">
|
||||||
{displayValue}
|
{displayValue}
|
||||||
@ -30,7 +64,10 @@ export function Slider({ label, value, min, max, step, unit, onChange, formatVal
|
|||||||
step={step}
|
step={step}
|
||||||
value={value}
|
value={value}
|
||||||
onChange={(e) => onChange(Number(e.target.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>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
@ -10,18 +10,18 @@ export const ENGINE_CONTROLS: EffectConfig[] = [
|
|||||||
label: 'Sample Rate',
|
label: 'Sample Rate',
|
||||||
min: 0,
|
min: 0,
|
||||||
max: 3,
|
max: 3,
|
||||||
default: 1,
|
default: 3,
|
||||||
step: 1,
|
step: 1,
|
||||||
unit: ''
|
unit: ''
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'loopDuration',
|
id: 'loopCount',
|
||||||
label: 'Loop',
|
label: 'Loop',
|
||||||
min: 2,
|
min: 1,
|
||||||
max: 8,
|
max: 10,
|
||||||
default: 4,
|
default: 2,
|
||||||
step: 2,
|
step: 1,
|
||||||
unit: 's'
|
unit: ''
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'complexity',
|
id: 'complexity',
|
||||||
@ -37,13 +37,13 @@ export const ENGINE_CONTROLS: EffectConfig[] = [
|
|||||||
label: 'Bit Depth',
|
label: 'Bit Depth',
|
||||||
min: 0,
|
min: 0,
|
||||||
max: 2,
|
max: 2,
|
||||||
default: 0,
|
default: 2,
|
||||||
step: 1,
|
step: 1,
|
||||||
unit: ''
|
unit: ''
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'masterVolume',
|
id: 'masterVolume',
|
||||||
label: 'Volume',
|
label: 'Vol',
|
||||||
min: 0,
|
min: 0,
|
||||||
max: 100,
|
max: 100,
|
||||||
default: 75,
|
default: 75,
|
||||||
@ -53,11 +53,47 @@ export const ENGINE_CONTROLS: EffectConfig[] = [
|
|||||||
{
|
{
|
||||||
id: 'pitch',
|
id: 'pitch',
|
||||||
label: 'Pitch',
|
label: 'Pitch',
|
||||||
min: 0.1,
|
min: 0.25,
|
||||||
max: 4,
|
max: 4,
|
||||||
default: 1,
|
default: 1,
|
||||||
step: 0.01,
|
step: 0.01,
|
||||||
unit: 'x'
|
unit: 'x'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'a',
|
||||||
|
label: 'A',
|
||||||
|
min: 0,
|
||||||
|
max: 255,
|
||||||
|
default: 8,
|
||||||
|
step: 1,
|
||||||
|
unit: ''
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'b',
|
||||||
|
label: 'B',
|
||||||
|
min: 0,
|
||||||
|
max: 255,
|
||||||
|
default: 16,
|
||||||
|
step: 1,
|
||||||
|
unit: ''
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'c',
|
||||||
|
label: 'C',
|
||||||
|
min: 0,
|
||||||
|
max: 255,
|
||||||
|
default: 32,
|
||||||
|
step: 1,
|
||||||
|
unit: ''
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'd',
|
||||||
|
label: 'D',
|
||||||
|
min: 0,
|
||||||
|
max: 255,
|
||||||
|
default: 64,
|
||||||
|
step: 1,
|
||||||
|
unit: ''
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@ -67,85 +103,38 @@ export const EFFECTS: EffectConfig[] = [
|
|||||||
{
|
{
|
||||||
id: 'filter',
|
id: 'filter',
|
||||||
name: 'Filter',
|
name: 'Filter',
|
||||||
|
bypassable: true,
|
||||||
parameters: [
|
parameters: [
|
||||||
{
|
{
|
||||||
id: 'hpEnable',
|
id: 'filterMode',
|
||||||
label: 'HP',
|
label: 'Mode',
|
||||||
min: 0,
|
min: 0,
|
||||||
max: 1,
|
max: 0,
|
||||||
default: 0,
|
default: 'lowpass',
|
||||||
step: 1,
|
step: 1,
|
||||||
unit: ''
|
unit: '',
|
||||||
|
options: [
|
||||||
|
{ value: 'lowpass', label: 'LP' },
|
||||||
|
{ value: 'highpass', label: 'HP' },
|
||||||
|
{ value: 'bandpass', label: 'BP' },
|
||||||
|
{ value: 'notch', label: 'Notch' }
|
||||||
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'hpFreq',
|
id: 'filterFreq',
|
||||||
label: 'HP Freq',
|
label: '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',
|
|
||||||
min: 20,
|
min: 20,
|
||||||
max: 20000,
|
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,
|
default: 1000,
|
||||||
step: 10,
|
step: 10,
|
||||||
unit: 'Hz'
|
unit: 'Hz'
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'bpRes',
|
id: 'filterRes',
|
||||||
label: 'BP Q',
|
label: 'Res',
|
||||||
min: 0.1,
|
min: 0.5,
|
||||||
max: 20,
|
max: 10,
|
||||||
default: 1,
|
default: 0.707,
|
||||||
step: 0.1,
|
step: 0.1,
|
||||||
unit: ''
|
unit: ''
|
||||||
}
|
}
|
||||||
@ -160,19 +149,26 @@ export const EFFECTS: EffectConfig[] = [
|
|||||||
id: 'clipMode',
|
id: 'clipMode',
|
||||||
label: 'Mode',
|
label: 'Mode',
|
||||||
min: 0,
|
min: 0,
|
||||||
max: 2,
|
max: 0,
|
||||||
default: 0,
|
default: 'fold',
|
||||||
step: 1,
|
step: 1,
|
||||||
unit: ''
|
unit: '',
|
||||||
|
options: [
|
||||||
|
{ value: 'fold', label: 'Fold' },
|
||||||
|
{ value: 'soft', label: 'Soft' },
|
||||||
|
{ value: 'cubic', label: 'Cubic' },
|
||||||
|
{ value: 'diode', label: 'Diode' },
|
||||||
|
{ value: 'hard', label: 'Hard' }
|
||||||
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'wavefolderDrive',
|
id: 'wavefolderDrive',
|
||||||
label: 'Drive',
|
label: 'Drive',
|
||||||
min: 1,
|
min: 0.001,
|
||||||
max: 10,
|
max: 10,
|
||||||
default: 1,
|
default: 1,
|
||||||
step: 0.1,
|
step: 0.1,
|
||||||
unit: 'x'
|
unit: ''
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'bitcrushDepth',
|
id: 'bitcrushDepth',
|
||||||
@ -309,8 +305,8 @@ export const EFFECTS: EffectConfig[] = [
|
|||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
|
||||||
export function getDefaultEffectValues(): Record<string, number | boolean> {
|
export function getDefaultEffectValues(): Record<string, number | boolean | string> {
|
||||||
const defaults: Record<string, number | boolean> = {}
|
const defaults: Record<string, number | boolean | string> = {}
|
||||||
EFFECTS.forEach(effect => {
|
EFFECTS.forEach(effect => {
|
||||||
effect.parameters.forEach(param => {
|
effect.parameters.forEach(param => {
|
||||||
defaults[param.id] = param.default
|
defaults[param.id] = param.default
|
||||||
@ -326,7 +322,7 @@ export function getDefaultEngineValues(): Record<string, number> {
|
|||||||
const defaults: Record<string, number> = {}
|
const defaults: Record<string, number> = {}
|
||||||
ENGINE_CONTROLS.forEach(control => {
|
ENGINE_CONTROLS.forEach(control => {
|
||||||
control.parameters.forEach(param => {
|
control.parameters.forEach(param => {
|
||||||
defaults[param.id] = param.default
|
defaults[param.id] = param.default as number
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
return defaults
|
return defaults
|
||||||
|
|||||||
23
src/constants/defaults.ts
Normal file
23
src/constants/defaults.ts
Normal file
@ -0,0 +1,23 @@
|
|||||||
|
export const DEFAULT_VARIABLES = {
|
||||||
|
a: 8,
|
||||||
|
b: 16,
|
||||||
|
c: 32,
|
||||||
|
d: 64
|
||||||
|
} as const
|
||||||
|
|
||||||
|
export const PLAYBACK_ID = {
|
||||||
|
CUSTOM: 'custom'
|
||||||
|
} as const
|
||||||
|
|
||||||
|
export const TILE_GRID = {
|
||||||
|
SIZE: 100,
|
||||||
|
COLUMNS: 4
|
||||||
|
} as const
|
||||||
|
|
||||||
|
export const DEFAULT_DOWNLOAD_OPTIONS = {
|
||||||
|
SAMPLE_RATE: 44100,
|
||||||
|
DURATION: 4,
|
||||||
|
BIT_DEPTH: 24
|
||||||
|
} as const
|
||||||
|
|
||||||
|
export const LOOP_DURATION = 4
|
||||||
@ -1,4 +1,7 @@
|
|||||||
import { EffectsChain } from './effects/EffectsChain'
|
import { EffectsChain } from './effects/EffectsChain'
|
||||||
|
import { BytebeatSourceEffect } from './effects/BytebeatSourceEffect'
|
||||||
|
import { ModulationEngine } from '../modulation/ModulationEngine'
|
||||||
|
import type { LFOWaveform } from '../modulation/LFO'
|
||||||
import type { EffectValues } from '../../types/effects'
|
import type { EffectValues } from '../../types/effects'
|
||||||
|
|
||||||
export interface AudioPlayerOptions {
|
export interface AudioPlayerOptions {
|
||||||
@ -8,16 +11,15 @@ export interface AudioPlayerOptions {
|
|||||||
|
|
||||||
export class AudioPlayer {
|
export class AudioPlayer {
|
||||||
private audioContext: AudioContext | null = null
|
private audioContext: AudioContext | null = null
|
||||||
private sourceNode: AudioBufferSourceNode | null = null
|
private bytebeatSource: BytebeatSourceEffect | null = null
|
||||||
private effectsChain: EffectsChain | null = null
|
private effectsChain: EffectsChain | null = null
|
||||||
|
private modulationEngine: ModulationEngine | null = null
|
||||||
private effectValues: EffectValues = {}
|
private effectValues: EffectValues = {}
|
||||||
private startTime: number = 0
|
private startTime: number = 0
|
||||||
private pauseTime: number = 0
|
|
||||||
private isLooping: boolean = true
|
|
||||||
private sampleRate: number
|
private sampleRate: number
|
||||||
private duration: number
|
private duration: number
|
||||||
private pitch: number = 1
|
|
||||||
private workletRegistered: boolean = false
|
private workletRegistered: boolean = false
|
||||||
|
private currentPitch: number = 1.0
|
||||||
|
|
||||||
constructor(options: AudioPlayerOptions) {
|
constructor(options: AudioPlayerOptions) {
|
||||||
this.sampleRate = options.sampleRate
|
this.sampleRate = options.sampleRate
|
||||||
@ -40,11 +42,12 @@ export class AudioPlayer {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private async recreateAudioContext(): Promise<void> {
|
private async recreateAudioContext(): Promise<void> {
|
||||||
const wasPlaying = this.sourceNode !== null
|
const wasPlaying = this.bytebeatSource !== null
|
||||||
|
|
||||||
this.dispose()
|
this.dispose()
|
||||||
|
|
||||||
this.audioContext = new AudioContext({ sampleRate: this.sampleRate })
|
this.audioContext = new AudioContext({ sampleRate: this.sampleRate })
|
||||||
|
this.workletRegistered = false
|
||||||
await this.registerWorklet(this.audioContext)
|
await this.registerWorklet(this.audioContext)
|
||||||
|
|
||||||
this.effectsChain = new EffectsChain(this.audioContext)
|
this.effectsChain = new EffectsChain(this.audioContext)
|
||||||
@ -52,19 +55,20 @@ export class AudioPlayer {
|
|||||||
this.effectsChain.updateEffects(this.effectValues)
|
this.effectsChain.updateEffects(this.effectValues)
|
||||||
|
|
||||||
if (wasPlaying) {
|
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> {
|
private async registerWorklet(context: AudioContext): Promise<void> {
|
||||||
if (this.workletRegistered) return
|
if (this.workletRegistered) return
|
||||||
|
|
||||||
try {
|
await Promise.all([
|
||||||
await context.audioWorklet.addModule('/worklets/fold-crush-processor.js')
|
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
|
this.workletRegistered = true
|
||||||
} catch (error) {
|
|
||||||
console.error('Failed to register AudioWorklet:', error)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
setEffects(values: EffectValues): void {
|
setEffects(values: EffectValues): void {
|
||||||
@ -72,20 +76,18 @@ export class AudioPlayer {
|
|||||||
if (this.effectsChain) {
|
if (this.effectsChain) {
|
||||||
this.effectsChain.updateEffects(values)
|
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
|
||||||
}
|
}
|
||||||
|
})
|
||||||
setPitch(pitch: number): void {
|
this.modulationEngine.setBaseValues(numericValues)
|
||||||
this.pitch = pitch
|
|
||||||
if (this.sourceNode && this.audioContext) {
|
|
||||||
this.sourceNode.playbackRate.setTargetAtTime(
|
|
||||||
pitch,
|
|
||||||
this.audioContext.currentTime,
|
|
||||||
0.015
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async play(buffer: Float32Array, onEnded?: () => void): Promise<void> {
|
private async ensureAudioContext(): Promise<void> {
|
||||||
if (!this.audioContext) {
|
if (!this.audioContext) {
|
||||||
this.audioContext = new AudioContext({ sampleRate: this.sampleRate })
|
this.audioContext = new AudioContext({ sampleRate: this.sampleRate })
|
||||||
await this.registerWorklet(this.audioContext)
|
await this.registerWorklet(this.audioContext)
|
||||||
@ -97,77 +99,120 @@ export class AudioPlayer {
|
|||||||
this.effectsChain.updateEffects(this.effectValues)
|
this.effectsChain.updateEffects(this.effectValues)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (this.sourceNode) {
|
if (!this.modulationEngine) {
|
||||||
this.sourceNode.stop()
|
this.modulationEngine = new ModulationEngine(this.audioContext, 4)
|
||||||
}
|
this.registerModulatableParams()
|
||||||
|
|
||||||
const audioBuffer = this.audioContext.createBuffer(1, buffer.length, this.sampleRate)
|
|
||||||
audioBuffer.getChannelData(0).set(buffer)
|
|
||||||
|
|
||||||
this.sourceNode = this.audioContext.createBufferSource()
|
|
||||||
this.sourceNode.buffer = audioBuffer
|
|
||||||
this.sourceNode.loop = this.isLooping
|
|
||||||
this.sourceNode.playbackRate.value = this.pitch
|
|
||||||
|
|
||||||
if (onEnded) {
|
|
||||||
this.sourceNode.onended = onEnded
|
|
||||||
}
|
|
||||||
|
|
||||||
this.sourceNode.connect(this.effectsChain.getInputNode())
|
|
||||||
this.effectsChain.getOutputNode().connect(this.audioContext.destination)
|
|
||||||
|
|
||||||
if (this.pauseTime > 0) {
|
|
||||||
this.sourceNode.start(0, this.pauseTime)
|
|
||||||
this.startTime = this.audioContext.currentTime - this.pauseTime
|
|
||||||
this.pauseTime = 0
|
|
||||||
} else {
|
|
||||||
this.sourceNode.start(0)
|
|
||||||
this.startTime = this.audioContext.currentTime
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
setLooping(loop: boolean): void {
|
private registerModulatableParams(): void {
|
||||||
this.isLooping = loop
|
if (!this.modulationEngine || !this.effectsChain) return
|
||||||
if (this.sourceNode) {
|
|
||||||
this.sourceNode.loop = loop
|
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
|
||||||
|
)
|
||||||
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
scheduleNextTrack(callback: () => void): void {
|
this.modulationEngine.registerParameter(
|
||||||
if (this.sourceNode) {
|
'pitch',
|
||||||
this.sourceNode.loop = false
|
{ callback: (value: number) => this.applyPitch(value) },
|
||||||
this.sourceNode.onended = callback
|
this.currentPitch
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
setLFOConfig(lfoIndex: number, config: { frequency: number; phase: number; waveform: LFOWaveform; mappings: Array<{ targetParam: string; depth: number }> }): void {
|
||||||
|
if (!this.modulationEngine) return
|
||||||
|
|
||||||
|
this.modulationEngine.updateLFO(lfoIndex, config.frequency, config.phase, config.waveform)
|
||||||
|
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> {
|
||||||
|
await this.ensureAudioContext()
|
||||||
|
|
||||||
|
if (!this.bytebeatSource) {
|
||||||
|
this.bytebeatSource = new BytebeatSourceEffect(this.audioContext!)
|
||||||
|
await this.bytebeatSource.initialize(this.audioContext!)
|
||||||
|
}
|
||||||
|
|
||||||
|
this.bytebeatSource.setLoopLength(this.sampleRate, this.duration)
|
||||||
|
this.bytebeatSource.setFormula(formula)
|
||||||
|
this.bytebeatSource.setVariables(a, b, c, d)
|
||||||
|
this.bytebeatSource.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
|
||||||
|
}
|
||||||
|
|
||||||
|
updateRealtimeVariables(a: number, b: number, c: number, d: number): void {
|
||||||
|
if (this.bytebeatSource) {
|
||||||
|
this.bytebeatSource.setVariables(a, b, c, d)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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 {
|
getPlaybackPosition(): number {
|
||||||
if (!this.audioContext || !this.sourceNode || this.startTime === 0) {
|
if (!this.audioContext || this.startTime === 0) {
|
||||||
return 0
|
return 0
|
||||||
}
|
}
|
||||||
const elapsed = this.audioContext.currentTime - this.startTime
|
const elapsed = this.audioContext.currentTime - this.startTime
|
||||||
const actualDuration = this.duration / this.pitch
|
return (elapsed % this.duration) / this.duration
|
||||||
return (elapsed % actualDuration) / actualDuration
|
|
||||||
}
|
|
||||||
|
|
||||||
pause(): void {
|
|
||||||
if (this.sourceNode && this.audioContext) {
|
|
||||||
this.pauseTime = this.audioContext.currentTime - this.startTime
|
|
||||||
this.sourceNode.stop()
|
|
||||||
this.sourceNode = null
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
stop(): void {
|
stop(): void {
|
||||||
if (this.sourceNode) {
|
if (this.bytebeatSource) {
|
||||||
this.sourceNode.stop()
|
this.bytebeatSource.getOutputNode().disconnect()
|
||||||
this.sourceNode = null
|
}
|
||||||
|
if (this.modulationEngine) {
|
||||||
|
this.modulationEngine.stop()
|
||||||
}
|
}
|
||||||
this.startTime = 0
|
this.startTime = 0
|
||||||
this.pauseTime = 0
|
|
||||||
}
|
}
|
||||||
|
|
||||||
dispose(): void {
|
dispose(): void {
|
||||||
this.stop()
|
this.stop()
|
||||||
|
if (this.bytebeatSource) {
|
||||||
|
this.bytebeatSource.dispose()
|
||||||
|
this.bytebeatSource = null
|
||||||
|
}
|
||||||
|
if (this.modulationEngine) {
|
||||||
|
this.modulationEngine.dispose()
|
||||||
|
this.modulationEngine = null
|
||||||
|
}
|
||||||
if (this.effectsChain) {
|
if (this.effectsChain) {
|
||||||
this.effectsChain.dispose()
|
this.effectsChain.dispose()
|
||||||
this.effectsChain = null
|
this.effectsChain = null
|
||||||
@ -176,5 +221,7 @@ export class AudioPlayer {
|
|||||||
this.audioContext.close()
|
this.audioContext.close()
|
||||||
this.audioContext = null
|
this.audioContext = null
|
||||||
}
|
}
|
||||||
|
this.workletRegistered = false
|
||||||
|
this.startTime = 0
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -1,4 +1,4 @@
|
|||||||
export type CompiledFormula = (t: number) => number
|
export type CompiledFormula = (t: number, a: number, b: number, c: number, d: number) => number
|
||||||
|
|
||||||
export interface CompilationResult {
|
export interface CompilationResult {
|
||||||
success: boolean
|
success: boolean
|
||||||
@ -8,7 +8,7 @@ export interface CompilationResult {
|
|||||||
|
|
||||||
export function compileFormula(formula: string): CompilationResult {
|
export function compileFormula(formula: string): CompilationResult {
|
||||||
try {
|
try {
|
||||||
const compiledFormula = new Function('t', `return ${formula}`) as CompiledFormula
|
const compiledFormula = new Function('t', 'a', 'b', 'c', 'd', `return ${formula}`) as CompiledFormula
|
||||||
return {
|
return {
|
||||||
success: true,
|
success: true,
|
||||||
compiledFormula
|
compiledFormula
|
||||||
@ -28,7 +28,7 @@ export function testFormula(formula: string): boolean {
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
result.compiledFormula(0)
|
result.compiledFormula(0, 8, 16, 32, 64)
|
||||||
return true
|
return true
|
||||||
} catch {
|
} catch {
|
||||||
return false
|
return false
|
||||||
|
|||||||
@ -3,22 +3,26 @@ import type { CompiledFormula } from './BytebeatCompiler'
|
|||||||
export interface GeneratorOptions {
|
export interface GeneratorOptions {
|
||||||
sampleRate: number
|
sampleRate: number
|
||||||
duration: number
|
duration: number
|
||||||
|
a?: number
|
||||||
|
b?: number
|
||||||
|
c?: number
|
||||||
|
d?: number
|
||||||
}
|
}
|
||||||
|
|
||||||
export function generateSamples(
|
export function generateSamples(
|
||||||
compiledFormula: CompiledFormula,
|
compiledFormula: CompiledFormula,
|
||||||
options: GeneratorOptions
|
options: GeneratorOptions
|
||||||
): Float32Array {
|
): Float32Array {
|
||||||
const { sampleRate, duration } = options
|
const { sampleRate, duration, a = 8, b = 16, c = 32, d = 64 } = options
|
||||||
const numSamples = Math.floor(sampleRate * duration)
|
const numSamples = Math.floor(sampleRate * duration)
|
||||||
const buffer = new Float32Array(numSamples)
|
const buffer = new Float32Array(numSamples)
|
||||||
|
|
||||||
for (let t = 0; t < numSamples; t++) {
|
for (let t = 0; t < numSamples; t++) {
|
||||||
try {
|
try {
|
||||||
const value = compiledFormula(t)
|
const value = compiledFormula(t, a, b, c, d)
|
||||||
const byteValue = value & 0xFF
|
const byteValue = value & 0xFF
|
||||||
buffer[t] = (byteValue - 128) / 128
|
buffer[t] = (byteValue - 128) / 128
|
||||||
} catch (error) {
|
} catch {
|
||||||
buffer[t] = 0
|
buffer[t] = 0
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -31,7 +35,7 @@ export function generateSamplesWithBitDepth(
|
|||||||
options: GeneratorOptions,
|
options: GeneratorOptions,
|
||||||
bitDepth: 8 | 16 | 24
|
bitDepth: 8 | 16 | 24
|
||||||
): Float32Array {
|
): Float32Array {
|
||||||
const { sampleRate, duration } = options
|
const { sampleRate, duration, a = 8, b = 16, c = 32, d = 64 } = options
|
||||||
const numSamples = Math.floor(sampleRate * duration)
|
const numSamples = Math.floor(sampleRate * duration)
|
||||||
const buffer = new Float32Array(numSamples)
|
const buffer = new Float32Array(numSamples)
|
||||||
|
|
||||||
@ -40,10 +44,10 @@ export function generateSamplesWithBitDepth(
|
|||||||
|
|
||||||
for (let t = 0; t < numSamples; t++) {
|
for (let t = 0; t < numSamples; t++) {
|
||||||
try {
|
try {
|
||||||
const value = compiledFormula(t)
|
const value = compiledFormula(t, a, b, c, d)
|
||||||
const clampedValue = value & maxValue
|
const clampedValue = value & maxValue
|
||||||
buffer[t] = (clampedValue - midPoint) / midPoint
|
buffer[t] = (clampedValue - midPoint) / midPoint
|
||||||
} catch (error) {
|
} catch {
|
||||||
buffer[t] = 0
|
buffer[t] = 0
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,13 +1,70 @@
|
|||||||
import { encodeWAV } from '../../lib/bytebeat/wavEncoder'
|
export type BitDepth = 8 | 16 | 24
|
||||||
import type { BitDepth } from '../../lib/bytebeat/types'
|
|
||||||
|
|
||||||
export type { BitDepth }
|
|
||||||
|
|
||||||
export interface ExportOptions {
|
export interface ExportOptions {
|
||||||
sampleRate: number
|
sampleRate: number
|
||||||
bitDepth?: BitDepth
|
bitDepth?: BitDepth
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function encodeWAV(samples: Float32Array, sampleRate: number, bitDepth: BitDepth): Blob {
|
||||||
|
const numChannels = 1
|
||||||
|
const bytesPerSample = bitDepth / 8
|
||||||
|
const blockAlign = numChannels * bytesPerSample
|
||||||
|
const byteRate = sampleRate * blockAlign
|
||||||
|
const dataSize = samples.length * bytesPerSample
|
||||||
|
const bufferSize = 44 + dataSize
|
||||||
|
|
||||||
|
const buffer = new ArrayBuffer(bufferSize)
|
||||||
|
const view = new DataView(buffer)
|
||||||
|
|
||||||
|
const writeString = (offset: number, string: string) => {
|
||||||
|
for (let i = 0; i < string.length; i++) {
|
||||||
|
view.setUint8(offset + i, string.charCodeAt(i))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
writeString(0, 'RIFF')
|
||||||
|
view.setUint32(4, 36 + dataSize, true)
|
||||||
|
writeString(8, 'WAVE')
|
||||||
|
writeString(12, 'fmt ')
|
||||||
|
view.setUint32(16, 16, true)
|
||||||
|
view.setUint16(20, 1, true)
|
||||||
|
view.setUint16(22, numChannels, true)
|
||||||
|
view.setUint32(24, sampleRate, true)
|
||||||
|
view.setUint32(28, byteRate, true)
|
||||||
|
view.setUint16(32, blockAlign, true)
|
||||||
|
view.setUint16(34, bitDepth, true)
|
||||||
|
writeString(36, 'data')
|
||||||
|
view.setUint32(40, dataSize, true)
|
||||||
|
|
||||||
|
const maxValue = Math.pow(2, bitDepth - 1) - 1
|
||||||
|
let offset = 44
|
||||||
|
|
||||||
|
for (let i = 0; i < samples.length; i++) {
|
||||||
|
const sample = Math.max(-1, Math.min(1, samples[i]))
|
||||||
|
const intSample = Math.round(sample * maxValue)
|
||||||
|
|
||||||
|
if (bitDepth === 8) {
|
||||||
|
view.setUint8(offset, intSample + 128)
|
||||||
|
offset += 1
|
||||||
|
} else if (bitDepth === 16) {
|
||||||
|
view.setInt16(offset, intSample, true)
|
||||||
|
offset += 2
|
||||||
|
} else if (bitDepth === 24) {
|
||||||
|
const bytes = [
|
||||||
|
intSample & 0xff,
|
||||||
|
(intSample >> 8) & 0xff,
|
||||||
|
(intSample >> 16) & 0xff
|
||||||
|
]
|
||||||
|
view.setUint8(offset, bytes[0])
|
||||||
|
view.setUint8(offset + 1, bytes[1])
|
||||||
|
view.setUint8(offset + 2, bytes[2])
|
||||||
|
offset += 3
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return new Blob([buffer], { type: 'audio/wav' })
|
||||||
|
}
|
||||||
|
|
||||||
export function exportToWav(
|
export function exportToWav(
|
||||||
samples: Float32Array,
|
samples: Float32Array,
|
||||||
options: ExportOptions
|
options: ExportOptions
|
||||||
|
|||||||
@ -72,12 +72,12 @@ export class BitcrushEffect implements Effect {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
updateParams(values: Record<string, number>): void {
|
updateParams(values: Record<string, number | string>): void {
|
||||||
if (values.bitcrushDepth !== undefined) {
|
if (values.bitcrushDepth !== undefined && typeof values.bitcrushDepth === 'number') {
|
||||||
this.bitDepth = values.bitcrushDepth
|
this.bitDepth = values.bitcrushDepth
|
||||||
}
|
}
|
||||||
|
|
||||||
if (values.bitcrushRate !== undefined) {
|
if (values.bitcrushRate !== undefined && typeof values.bitcrushRate === 'number') {
|
||||||
this.crushAmount = values.bitcrushRate
|
this.crushAmount = values.bitcrushRate
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
69
src/domain/audio/effects/BytebeatSourceEffect.ts
Normal file
69
src/domain/audio/effects/BytebeatSourceEffect.ts
Normal file
@ -0,0 +1,69 @@
|
|||||||
|
import type { Effect } from './Effect.interface'
|
||||||
|
|
||||||
|
export class BytebeatSourceEffect implements Effect {
|
||||||
|
readonly id = 'bytebeat-source'
|
||||||
|
|
||||||
|
private inputNode: GainNode
|
||||||
|
private outputNode: GainNode
|
||||||
|
private processorNode: AudioWorkletNode | null = null
|
||||||
|
|
||||||
|
constructor(audioContext: AudioContext) {
|
||||||
|
this.inputNode = audioContext.createGain()
|
||||||
|
this.outputNode = audioContext.createGain()
|
||||||
|
}
|
||||||
|
|
||||||
|
async initialize(audioContext: AudioContext): Promise<void> {
|
||||||
|
this.processorNode = new AudioWorkletNode(audioContext, 'bytebeat-processor')
|
||||||
|
this.processorNode.connect(this.outputNode)
|
||||||
|
}
|
||||||
|
|
||||||
|
getInputNode(): AudioNode {
|
||||||
|
return this.inputNode
|
||||||
|
}
|
||||||
|
|
||||||
|
getOutputNode(): AudioNode {
|
||||||
|
return this.outputNode
|
||||||
|
}
|
||||||
|
|
||||||
|
setBypass(): void {
|
||||||
|
// Source node doesn't support bypass
|
||||||
|
}
|
||||||
|
|
||||||
|
updateParams(): void {
|
||||||
|
// Parameters handled via specific methods
|
||||||
|
}
|
||||||
|
|
||||||
|
setFormula(formula: string): void {
|
||||||
|
if (!this.processorNode) return
|
||||||
|
this.processorNode.port.postMessage({ type: 'formula', value: formula })
|
||||||
|
}
|
||||||
|
|
||||||
|
setVariables(a: number, b: number, c: number, d: number): void {
|
||||||
|
if (!this.processorNode) return
|
||||||
|
this.processorNode.port.postMessage({ type: 'variables', value: { a, b, c, d } })
|
||||||
|
}
|
||||||
|
|
||||||
|
setLoopLength(sampleRate: number, duration: number): void {
|
||||||
|
if (!this.processorNode) return
|
||||||
|
const loopLength = sampleRate * duration
|
||||||
|
this.processorNode.port.postMessage({ type: 'loopLength', value: loopLength })
|
||||||
|
}
|
||||||
|
|
||||||
|
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' })
|
||||||
|
}
|
||||||
|
|
||||||
|
dispose(): void {
|
||||||
|
if (this.processorNode) {
|
||||||
|
this.processorNode.disconnect()
|
||||||
|
}
|
||||||
|
this.inputNode.disconnect()
|
||||||
|
this.outputNode.disconnect()
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -92,8 +92,8 @@ export class DelayEffect implements Effect {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
updateParams(values: Record<string, number>): void {
|
updateParams(values: Record<string, number | string>): void {
|
||||||
if (values.delayTime !== undefined) {
|
if (values.delayTime !== undefined && typeof values.delayTime === 'number') {
|
||||||
const time = values.delayTime / 1000
|
const time = values.delayTime / 1000
|
||||||
this.delayNode.delayTime.setTargetAtTime(
|
this.delayNode.delayTime.setTargetAtTime(
|
||||||
time,
|
time,
|
||||||
@ -102,7 +102,7 @@ export class DelayEffect implements Effect {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (values.delayFeedback !== undefined) {
|
if (values.delayFeedback !== undefined && typeof values.delayFeedback === 'number') {
|
||||||
const feedback = values.delayFeedback / 100
|
const feedback = values.delayFeedback / 100
|
||||||
this.feedbackNode.gain.setTargetAtTime(
|
this.feedbackNode.gain.setTargetAtTime(
|
||||||
feedback * 0.95,
|
feedback * 0.95,
|
||||||
@ -111,7 +111,7 @@ export class DelayEffect implements Effect {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (values.delayWetDry !== undefined) {
|
if (values.delayWetDry !== undefined && typeof values.delayWetDry === 'number') {
|
||||||
const wet = values.delayWetDry / 100
|
const wet = values.delayWetDry / 100
|
||||||
this.currentWetValue = wet
|
this.currentWetValue = wet
|
||||||
this.currentDryValue = 1 - wet
|
this.currentDryValue = 1 - wet
|
||||||
@ -130,7 +130,7 @@ export class DelayEffect implements Effect {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (values.delayTone !== undefined) {
|
if (values.delayTone !== undefined && typeof values.delayTone === 'number') {
|
||||||
const tone = values.delayTone / 100
|
const tone = values.delayTone / 100
|
||||||
const freq = 200 + tone * 7800
|
const freq = 200 + tone * 7800
|
||||||
this.filterNode.frequency.setTargetAtTime(
|
this.filterNode.frequency.setTargetAtTime(
|
||||||
@ -140,12 +140,12 @@ export class DelayEffect implements Effect {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (values.delaySaturation !== undefined) {
|
if (values.delaySaturation !== undefined && typeof values.delaySaturation === 'number') {
|
||||||
const saturation = values.delaySaturation / 100
|
const saturation = values.delaySaturation / 100
|
||||||
this.createSaturationCurve(saturation)
|
this.createSaturationCurve(saturation)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (values.delayFlutter !== undefined) {
|
if (values.delayFlutter !== undefined && typeof values.delayFlutter === 'number') {
|
||||||
const flutter = values.delayFlutter / 100
|
const flutter = values.delayFlutter / 100
|
||||||
const baseDelay = this.delayNode.delayTime.value
|
const baseDelay = this.delayNode.delayTime.value
|
||||||
const modDepth = baseDelay * flutter * 0.1
|
const modDepth = baseDelay * flutter * 0.1
|
||||||
|
|||||||
@ -2,8 +2,9 @@ export interface Effect {
|
|||||||
readonly id: string
|
readonly id: string
|
||||||
getInputNode(): AudioNode
|
getInputNode(): AudioNode
|
||||||
getOutputNode(): AudioNode
|
getOutputNode(): AudioNode
|
||||||
updateParams(values: Record<string, number>): void
|
updateParams(values: Record<string, number | string>): void
|
||||||
setBypass(bypass: boolean): void
|
setBypass(bypass: boolean): void
|
||||||
|
getModulatableParams?(): Map<string, AudioParam>
|
||||||
dispose(): void
|
dispose(): void
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -3,33 +3,43 @@ import { FilterEffect } from './FilterEffect'
|
|||||||
import { FoldCrushEffect } from './FoldCrushEffect'
|
import { FoldCrushEffect } from './FoldCrushEffect'
|
||||||
import { DelayEffect } from './DelayEffect'
|
import { DelayEffect } from './DelayEffect'
|
||||||
import { ReverbEffect } from './ReverbEffect'
|
import { ReverbEffect } from './ReverbEffect'
|
||||||
|
import { OutputLimiter } from './OutputLimiter'
|
||||||
|
|
||||||
export class EffectsChain {
|
export class EffectsChain {
|
||||||
private inputNode: GainNode
|
private inputNode: GainNode
|
||||||
private outputNode: GainNode
|
private outputNode: GainNode
|
||||||
private masterGainNode: GainNode
|
private masterGainNode: GainNode
|
||||||
private effects: Effect[]
|
private effects: Effect[]
|
||||||
|
private filterEffect: FilterEffect
|
||||||
private foldCrushEffect: FoldCrushEffect
|
private foldCrushEffect: FoldCrushEffect
|
||||||
|
private outputLimiter: OutputLimiter
|
||||||
|
|
||||||
constructor(audioContext: AudioContext) {
|
constructor(audioContext: AudioContext) {
|
||||||
this.inputNode = audioContext.createGain()
|
this.inputNode = audioContext.createGain()
|
||||||
this.outputNode = audioContext.createGain()
|
this.outputNode = audioContext.createGain()
|
||||||
this.masterGainNode = audioContext.createGain()
|
this.masterGainNode = audioContext.createGain()
|
||||||
|
|
||||||
|
this.filterEffect = new FilterEffect(audioContext)
|
||||||
this.foldCrushEffect = new FoldCrushEffect(audioContext)
|
this.foldCrushEffect = new FoldCrushEffect(audioContext)
|
||||||
|
this.outputLimiter = new OutputLimiter(audioContext)
|
||||||
|
|
||||||
this.effects = [
|
this.effects = [
|
||||||
new FilterEffect(audioContext),
|
this.filterEffect,
|
||||||
this.foldCrushEffect,
|
this.foldCrushEffect,
|
||||||
new DelayEffect(audioContext),
|
new DelayEffect(audioContext),
|
||||||
new ReverbEffect(audioContext)
|
new ReverbEffect(audioContext),
|
||||||
|
this.outputLimiter
|
||||||
]
|
]
|
||||||
|
|
||||||
this.setupChain()
|
this.setupChain()
|
||||||
}
|
}
|
||||||
|
|
||||||
async initialize(audioContext: AudioContext): Promise<void> {
|
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 {
|
private setupChain(): void {
|
||||||
@ -44,7 +54,11 @@ export class EffectsChain {
|
|||||||
this.masterGainNode.connect(this.outputNode)
|
this.masterGainNode.connect(this.outputNode)
|
||||||
}
|
}
|
||||||
|
|
||||||
updateEffects(values: Record<string, number | boolean>): void {
|
getEffects(): Effect[] {
|
||||||
|
return this.effects
|
||||||
|
}
|
||||||
|
|
||||||
|
updateEffects(values: Record<string, number | boolean | string>): void {
|
||||||
for (const effect of this.effects) {
|
for (const effect of this.effects) {
|
||||||
const effectId = effect.id
|
const effectId = effect.id
|
||||||
const bypassKey = `${effectId}Bypass`
|
const bypassKey = `${effectId}Bypass`
|
||||||
@ -53,13 +67,13 @@ export class EffectsChain {
|
|||||||
effect.setBypass(Boolean(values[bypassKey]))
|
effect.setBypass(Boolean(values[bypassKey]))
|
||||||
}
|
}
|
||||||
|
|
||||||
const numericValues: Record<string, number> = {}
|
const effectValues: Record<string, number | string> = {}
|
||||||
for (const [key, value] of Object.entries(values)) {
|
for (const [key, value] of Object.entries(values)) {
|
||||||
if (typeof value === 'number') {
|
if (typeof value === 'number' || typeof value === 'string') {
|
||||||
numericValues[key] = value
|
effectValues[key] = value
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
effect.updateParams(numericValues)
|
effect.updateParams(effectValues)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (values.masterVolume !== undefined) {
|
if (values.masterVolume !== undefined) {
|
||||||
|
|||||||
@ -3,20 +3,13 @@ import type { Effect } from './Effect.interface'
|
|||||||
export class FilterEffect implements Effect {
|
export class FilterEffect implements Effect {
|
||||||
readonly id = 'filter'
|
readonly id = 'filter'
|
||||||
|
|
||||||
private audioContext: AudioContext
|
|
||||||
private inputNode: GainNode
|
private inputNode: GainNode
|
||||||
private outputNode: GainNode
|
private outputNode: GainNode
|
||||||
|
private processorNode: AudioWorkletNode | null = null
|
||||||
private wetNode: GainNode
|
private wetNode: GainNode
|
||||||
private dryNode: 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) {
|
constructor(audioContext: AudioContext) {
|
||||||
this.audioContext = audioContext
|
|
||||||
this.inputNode = audioContext.createGain()
|
this.inputNode = audioContext.createGain()
|
||||||
this.outputNode = audioContext.createGain()
|
this.outputNode = audioContext.createGain()
|
||||||
this.wetNode = audioContext.createGain()
|
this.wetNode = audioContext.createGain()
|
||||||
@ -25,27 +18,14 @@ export class FilterEffect implements Effect {
|
|||||||
this.wetNode.gain.value = 0
|
this.wetNode.gain.value = 0
|
||||||
this.dryNode.gain.value = 1
|
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.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)
|
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)
|
this.wetNode.connect(this.outputNode)
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -57,117 +37,46 @@ export class FilterEffect implements Effect {
|
|||||||
return this.outputNode
|
return this.outputNode
|
||||||
}
|
}
|
||||||
|
|
||||||
setBypass(_bypass: boolean): void {
|
setBypass(bypass: boolean): void {
|
||||||
// No global bypass for filters - each filter has individual enable switch
|
if (bypass) {
|
||||||
}
|
|
||||||
|
|
||||||
private updateBypassState(): void {
|
|
||||||
const anyEnabled = this.hpEnabled || this.lpEnabled || this.bpEnabled
|
|
||||||
if (anyEnabled) {
|
|
||||||
this.wetNode.gain.value = 1
|
|
||||||
this.dryNode.gain.value = 0
|
|
||||||
} else {
|
|
||||||
this.wetNode.gain.value = 0
|
this.wetNode.gain.value = 0
|
||||||
this.dryNode.gain.value = 1
|
this.dryNode.gain.value = 1
|
||||||
|
} else {
|
||||||
|
this.wetNode.gain.value = 1
|
||||||
|
this.dryNode.gain.value = 0
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
updateParams(values: Record<string, number>): void {
|
getModulatableParams(): Map<string, AudioParam> {
|
||||||
if (values.hpEnable !== undefined) {
|
if (!this.processorNode) return new Map()
|
||||||
this.hpEnabled = values.hpEnable === 1
|
|
||||||
this.updateBypassState()
|
const params = new Map<string, AudioParam>()
|
||||||
|
params.set('filterFreq', this.processorNode.parameters.get('frequency')!)
|
||||||
|
params.set('filterRes', this.processorNode.parameters.get('resonance')!)
|
||||||
|
return params
|
||||||
}
|
}
|
||||||
|
|
||||||
if (values.hpFreq !== undefined) {
|
updateParams(values: Record<string, number | string>): void {
|
||||||
this.hpFilter.frequency.cancelScheduledValues(this.audioContext.currentTime)
|
if (!this.processorNode) return
|
||||||
this.hpFilter.frequency.setValueAtTime(
|
|
||||||
this.hpFilter.frequency.value,
|
|
||||||
this.audioContext.currentTime
|
|
||||||
)
|
|
||||||
this.hpFilter.frequency.linearRampToValueAtTime(
|
|
||||||
values.hpFreq,
|
|
||||||
this.audioContext.currentTime + 0.02
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (values.hpRes !== undefined) {
|
if (values.filterMode !== undefined) {
|
||||||
this.hpFilter.Q.cancelScheduledValues(this.audioContext.currentTime)
|
this.processorNode.port.postMessage({ type: 'mode', value: values.filterMode })
|
||||||
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') {
|
||||||
if (values.lpEnable !== undefined) {
|
this.processorNode.parameters.get('frequency')!.value = values.filterFreq
|
||||||
this.lpEnabled = values.lpEnable === 1
|
|
||||||
this.updateBypassState()
|
|
||||||
}
|
}
|
||||||
|
if (values.filterRes !== undefined && typeof values.filterRes === 'number') {
|
||||||
if (values.lpFreq !== undefined) {
|
this.processorNode.parameters.get('resonance')!.value = values.filterRes
|
||||||
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) {
|
|
||||||
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) {
|
|
||||||
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) {
|
|
||||||
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
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
dispose(): void {
|
dispose(): void {
|
||||||
this.inputNode.disconnect()
|
if (this.processorNode) {
|
||||||
this.outputNode.disconnect()
|
this.processorNode.disconnect()
|
||||||
|
}
|
||||||
this.wetNode.disconnect()
|
this.wetNode.disconnect()
|
||||||
this.dryNode.disconnect()
|
this.dryNode.disconnect()
|
||||||
this.hpFilter.disconnect()
|
this.inputNode.disconnect()
|
||||||
this.lpFilter.disconnect()
|
this.outputNode.disconnect()
|
||||||
this.bpFilter.disconnect()
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -15,22 +15,18 @@ export class FoldCrushEffect implements Effect {
|
|||||||
this.wetNode = audioContext.createGain()
|
this.wetNode = audioContext.createGain()
|
||||||
this.dryNode = audioContext.createGain()
|
this.dryNode = audioContext.createGain()
|
||||||
|
|
||||||
this.wetNode.gain.value = 1
|
this.wetNode.gain.value = 0
|
||||||
this.dryNode.gain.value = 0
|
this.dryNode.gain.value = 1
|
||||||
|
|
||||||
this.inputNode.connect(this.dryNode)
|
this.inputNode.connect(this.dryNode)
|
||||||
this.dryNode.connect(this.outputNode)
|
this.dryNode.connect(this.outputNode)
|
||||||
}
|
}
|
||||||
|
|
||||||
async initialize(audioContext: AudioContext): Promise<void> {
|
async initialize(audioContext: AudioContext): Promise<void> {
|
||||||
try {
|
|
||||||
this.processorNode = new AudioWorkletNode(audioContext, 'fold-crush-processor')
|
this.processorNode = new AudioWorkletNode(audioContext, 'fold-crush-processor')
|
||||||
this.inputNode.connect(this.processorNode)
|
this.inputNode.connect(this.processorNode)
|
||||||
this.processorNode.connect(this.wetNode)
|
this.processorNode.connect(this.wetNode)
|
||||||
this.wetNode.connect(this.outputNode)
|
this.wetNode.connect(this.outputNode)
|
||||||
} catch (error) {
|
|
||||||
console.error('Failed to initialize FoldCrushEffect worklet:', error)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
getInputNode(): AudioNode {
|
getInputNode(): AudioNode {
|
||||||
@ -51,13 +47,11 @@ export class FoldCrushEffect implements Effect {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
updateParams(values: Record<string, number>): void {
|
updateParams(values: Record<string, number | string>): void {
|
||||||
if (!this.processorNode) return
|
if (!this.processorNode) return
|
||||||
|
|
||||||
if (values.clipMode !== undefined) {
|
if (values.clipMode !== undefined) {
|
||||||
const modeIndex = values.clipMode
|
this.processorNode.port.postMessage({ type: 'clipMode', value: values.clipMode })
|
||||||
const clipMode = ['wrap', 'clamp', 'fold'][modeIndex] || 'wrap'
|
|
||||||
this.processorNode.port.postMessage({ type: 'clipMode', value: clipMode })
|
|
||||||
}
|
}
|
||||||
if (values.wavefolderDrive !== undefined) {
|
if (values.wavefolderDrive !== undefined) {
|
||||||
this.processorNode.port.postMessage({ type: 'drive', value: values.wavefolderDrive })
|
this.processorNode.port.postMessage({ type: 'drive', value: values.wavefolderDrive })
|
||||||
|
|||||||
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(): void {
|
||||||
|
// Output limiter is always on
|
||||||
|
}
|
||||||
|
|
||||||
|
updateParams(): void {
|
||||||
|
// Uses default parameters from worklet
|
||||||
|
}
|
||||||
|
|
||||||
|
dispose(): void {
|
||||||
|
if (this.processorNode) {
|
||||||
|
this.processorNode.disconnect()
|
||||||
|
}
|
||||||
|
this.inputNode.disconnect()
|
||||||
|
this.outputNode.disconnect()
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -1,30 +0,0 @@
|
|||||||
import type { Effect } from './Effect.interface'
|
|
||||||
|
|
||||||
export class PassThroughEffect implements Effect {
|
|
||||||
readonly id: string
|
|
||||||
private node: GainNode
|
|
||||||
|
|
||||||
constructor(audioContext: AudioContext, id: string) {
|
|
||||||
this.id = id
|
|
||||||
this.node = audioContext.createGain()
|
|
||||||
this.node.gain.value = 1
|
|
||||||
}
|
|
||||||
|
|
||||||
getInputNode(): AudioNode {
|
|
||||||
return this.node
|
|
||||||
}
|
|
||||||
|
|
||||||
getOutputNode(): AudioNode {
|
|
||||||
return this.node
|
|
||||||
}
|
|
||||||
|
|
||||||
setBypass(_bypass: boolean): void {
|
|
||||||
}
|
|
||||||
|
|
||||||
updateParams(_values: Record<string, number>): void {
|
|
||||||
}
|
|
||||||
|
|
||||||
dispose(): void {
|
|
||||||
this.node.disconnect()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -6,7 +6,6 @@ export class ReverbEffect implements Effect {
|
|||||||
private audioContext: AudioContext
|
private audioContext: AudioContext
|
||||||
private inputNode: GainNode
|
private inputNode: GainNode
|
||||||
private outputNode: GainNode
|
private outputNode: GainNode
|
||||||
private convolverNode: ConvolverNode
|
|
||||||
private wetNode: GainNode
|
private wetNode: GainNode
|
||||||
private dryNode: GainNode
|
private dryNode: GainNode
|
||||||
private mixNode: GainNode
|
private mixNode: GainNode
|
||||||
@ -16,15 +15,40 @@ export class ReverbEffect implements Effect {
|
|||||||
private bypassed: boolean = false
|
private bypassed: boolean = false
|
||||||
private currentWetValue: number = 0
|
private currentWetValue: number = 0
|
||||||
private currentDryValue: number = 1
|
private currentDryValue: number = 1
|
||||||
private currentDecay: number = 2
|
private currentDecay: number = 0.5
|
||||||
private currentDamping: number = 50
|
private currentDamping: number = 0.5
|
||||||
|
|
||||||
|
private earlyReflectionsNode: GainNode
|
||||||
|
private earlyReflectionDelays: DelayNode[] = []
|
||||||
|
private earlyReflectionGains: GainNode[] = []
|
||||||
|
private earlyReflectionFilters: BiquadFilterNode[] = []
|
||||||
|
|
||||||
|
private lowBandSplitter: BiquadFilterNode
|
||||||
|
private midBandLowPass: BiquadFilterNode
|
||||||
|
private midBandHighPass: BiquadFilterNode
|
||||||
|
private highBandSplitter: BiquadFilterNode
|
||||||
|
|
||||||
|
private lowBandProcessor: BandProcessor
|
||||||
|
private midBandProcessor: BandProcessor
|
||||||
|
private highBandProcessor: BandProcessor
|
||||||
|
|
||||||
|
private lowEnvFollower: DynamicsCompressorNode
|
||||||
|
private midEnvFollower: DynamicsCompressorNode
|
||||||
|
private highEnvFollower: DynamicsCompressorNode
|
||||||
|
|
||||||
|
private lowToHighModGain: GainNode
|
||||||
|
private highToLowModGain: GainNode
|
||||||
|
private midToGlobalModGain: GainNode
|
||||||
|
|
||||||
|
private bandMixer: GainNode
|
||||||
|
|
||||||
constructor(audioContext: AudioContext) {
|
constructor(audioContext: AudioContext) {
|
||||||
this.audioContext = audioContext
|
this.audioContext = audioContext
|
||||||
|
const sr = audioContext.sampleRate
|
||||||
|
|
||||||
this.inputNode = audioContext.createGain()
|
this.inputNode = audioContext.createGain()
|
||||||
this.outputNode = audioContext.createGain()
|
this.outputNode = audioContext.createGain()
|
||||||
this.mixNode = audioContext.createGain()
|
this.mixNode = audioContext.createGain()
|
||||||
this.convolverNode = audioContext.createConvolver()
|
|
||||||
this.wetNode = audioContext.createGain()
|
this.wetNode = audioContext.createGain()
|
||||||
this.dryNode = audioContext.createGain()
|
this.dryNode = audioContext.createGain()
|
||||||
this.pannerNode = audioContext.createStereoPanner()
|
this.pannerNode = audioContext.createStereoPanner()
|
||||||
@ -40,98 +64,146 @@ export class ReverbEffect implements Effect {
|
|||||||
this.panLfoGainNode.connect(this.pannerNode.pan)
|
this.panLfoGainNode.connect(this.pannerNode.pan)
|
||||||
this.panLfoNode.start()
|
this.panLfoNode.start()
|
||||||
|
|
||||||
|
this.earlyReflectionsNode = audioContext.createGain()
|
||||||
|
this.buildEarlyReflections(sr)
|
||||||
|
|
||||||
|
this.lowBandSplitter = audioContext.createBiquadFilter()
|
||||||
|
this.lowBandSplitter.type = 'lowpass'
|
||||||
|
this.lowBandSplitter.frequency.value = 250
|
||||||
|
this.lowBandSplitter.Q.value = 0.707
|
||||||
|
|
||||||
|
this.midBandHighPass = audioContext.createBiquadFilter()
|
||||||
|
this.midBandHighPass.type = 'highpass'
|
||||||
|
this.midBandHighPass.frequency.value = 250
|
||||||
|
this.midBandHighPass.Q.value = 0.707
|
||||||
|
|
||||||
|
this.midBandLowPass = audioContext.createBiquadFilter()
|
||||||
|
this.midBandLowPass.type = 'lowpass'
|
||||||
|
this.midBandLowPass.frequency.value = 2500
|
||||||
|
this.midBandLowPass.Q.value = 0.707
|
||||||
|
|
||||||
|
this.highBandSplitter = audioContext.createBiquadFilter()
|
||||||
|
this.highBandSplitter.type = 'highpass'
|
||||||
|
this.highBandSplitter.frequency.value = 2500
|
||||||
|
this.highBandSplitter.Q.value = 0.707
|
||||||
|
|
||||||
|
this.lowBandProcessor = new BandProcessor(audioContext, 'low', sr)
|
||||||
|
this.midBandProcessor = new BandProcessor(audioContext, 'mid', sr)
|
||||||
|
this.highBandProcessor = new BandProcessor(audioContext, 'high', sr)
|
||||||
|
|
||||||
|
this.lowEnvFollower = audioContext.createDynamicsCompressor()
|
||||||
|
this.lowEnvFollower.threshold.value = -50
|
||||||
|
this.lowEnvFollower.knee.value = 40
|
||||||
|
this.lowEnvFollower.ratio.value = 12
|
||||||
|
this.lowEnvFollower.attack.value = 0.003
|
||||||
|
this.lowEnvFollower.release.value = 0.25
|
||||||
|
|
||||||
|
this.midEnvFollower = audioContext.createDynamicsCompressor()
|
||||||
|
this.midEnvFollower.threshold.value = -50
|
||||||
|
this.midEnvFollower.knee.value = 40
|
||||||
|
this.midEnvFollower.ratio.value = 12
|
||||||
|
this.midEnvFollower.attack.value = 0.003
|
||||||
|
this.midEnvFollower.release.value = 0.25
|
||||||
|
|
||||||
|
this.highEnvFollower = audioContext.createDynamicsCompressor()
|
||||||
|
this.highEnvFollower.threshold.value = -50
|
||||||
|
this.highEnvFollower.knee.value = 40
|
||||||
|
this.highEnvFollower.ratio.value = 12
|
||||||
|
this.highEnvFollower.attack.value = 0.001
|
||||||
|
this.highEnvFollower.release.value = 0.1
|
||||||
|
|
||||||
|
this.lowToHighModGain = audioContext.createGain()
|
||||||
|
this.highToLowModGain = audioContext.createGain()
|
||||||
|
this.midToGlobalModGain = audioContext.createGain()
|
||||||
|
|
||||||
|
this.bandMixer = audioContext.createGain()
|
||||||
|
|
||||||
|
this.buildGraph()
|
||||||
|
this.updateDecayAndDamping()
|
||||||
|
|
||||||
this.inputNode.connect(this.dryNode)
|
this.inputNode.connect(this.dryNode)
|
||||||
this.inputNode.connect(this.convolverNode)
|
|
||||||
this.convolverNode.connect(this.wetNode)
|
|
||||||
this.dryNode.connect(this.mixNode)
|
this.dryNode.connect(this.mixNode)
|
||||||
this.wetNode.connect(this.mixNode)
|
this.wetNode.connect(this.mixNode)
|
||||||
this.mixNode.connect(this.pannerNode)
|
this.mixNode.connect(this.pannerNode)
|
||||||
this.pannerNode.connect(this.outputNode)
|
this.pannerNode.connect(this.outputNode)
|
||||||
|
|
||||||
this.convolverNode.buffer = this.createDummyBuffer()
|
|
||||||
this.generateReverb(this.currentDecay, this.currentDamping)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private createDummyBuffer(): AudioBuffer {
|
private buildEarlyReflections(sr: number): void {
|
||||||
const buffer = this.audioContext.createBuffer(2, this.audioContext.sampleRate * 0.1, this.audioContext.sampleRate)
|
const primes = [17, 29, 41, 59, 71, 97, 113, 127]
|
||||||
for (let i = 0; i < 2; i++) {
|
const scale = sr / 48000
|
||||||
const data = buffer.getChannelData(i)
|
|
||||||
for (let j = 0; j < data.length; j++) {
|
|
||||||
data[j] = (Math.random() * 2 - 1) * Math.exp(-j / (this.audioContext.sampleRate * 0.05))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return buffer
|
|
||||||
}
|
|
||||||
|
|
||||||
private generateReverb(decayTime: number, damping: number): void {
|
for (let i = 0; i < primes.length; i++) {
|
||||||
const sampleRate = this.audioContext.sampleRate
|
const delay = this.audioContext.createDelay(0.2)
|
||||||
const numChannels = 2
|
delay.delayTime.value = (primes[i] * scale) / 1000
|
||||||
const totalTime = decayTime * 1.5
|
|
||||||
const decaySampleFrames = Math.round(decayTime * sampleRate)
|
|
||||||
const numSampleFrames = Math.round(totalTime * sampleRate)
|
|
||||||
const fadeInTime = 0.05
|
|
||||||
const fadeInSampleFrames = Math.round(fadeInTime * sampleRate)
|
|
||||||
const decayBase = Math.pow(1 / 1000, 1 / decaySampleFrames)
|
|
||||||
|
|
||||||
const reverbIR = this.audioContext.createBuffer(numChannels, numSampleFrames, sampleRate)
|
const gain = this.audioContext.createGain()
|
||||||
|
gain.gain.value = 0.7 * Math.pow(0.85, i)
|
||||||
|
|
||||||
for (let i = 0; i < numChannels; i++) {
|
const filter = this.audioContext.createBiquadFilter()
|
||||||
const chan = reverbIR.getChannelData(i)
|
filter.type = i % 2 === 0 ? 'lowpass' : 'highshelf'
|
||||||
for (let j = 0; j < numSampleFrames; j++) {
|
filter.frequency.value = 3000 + i * 500
|
||||||
chan[j] = (Math.random() * 2 - 1) * Math.pow(decayBase, j)
|
filter.gain.value = -2 * i
|
||||||
}
|
|
||||||
for (let j = 0; j < fadeInSampleFrames; j++) {
|
this.earlyReflectionDelays.push(delay)
|
||||||
chan[j] *= j / fadeInSampleFrames
|
this.earlyReflectionGains.push(gain)
|
||||||
|
this.earlyReflectionFilters.push(filter)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const lpFreqStart = 10000
|
private buildGraph(): void {
|
||||||
const lpFreqEnd = 200 + (damping / 100) * 7800
|
this.inputNode.connect(this.earlyReflectionsNode)
|
||||||
|
|
||||||
this.applyGradualLowpass(reverbIR, lpFreqStart, lpFreqEnd, decayTime, (buffer) => {
|
for (let i = 0; i < this.earlyReflectionDelays.length; i++) {
|
||||||
this.convolverNode.buffer = buffer
|
this.earlyReflectionsNode.connect(this.earlyReflectionDelays[i])
|
||||||
})
|
this.earlyReflectionDelays[i].connect(this.earlyReflectionFilters[i])
|
||||||
|
this.earlyReflectionFilters[i].connect(this.earlyReflectionGains[i])
|
||||||
|
this.earlyReflectionGains[i].connect(this.wetNode)
|
||||||
}
|
}
|
||||||
|
|
||||||
private applyGradualLowpass(
|
this.earlyReflectionsNode.connect(this.lowBandSplitter)
|
||||||
input: AudioBuffer,
|
this.earlyReflectionsNode.connect(this.midBandHighPass)
|
||||||
lpFreqStart: number,
|
this.earlyReflectionsNode.connect(this.highBandSplitter)
|
||||||
lpFreqEnd: number,
|
|
||||||
lpFreqEndAt: number,
|
this.midBandHighPass.connect(this.midBandLowPass)
|
||||||
callback: (buffer: AudioBuffer) => void
|
|
||||||
): void {
|
this.lowBandSplitter.connect(this.lowBandProcessor.getInputNode())
|
||||||
if (lpFreqStart === 0) {
|
this.midBandLowPass.connect(this.midBandProcessor.getInputNode())
|
||||||
callback(input)
|
this.highBandSplitter.connect(this.highBandProcessor.getInputNode())
|
||||||
return
|
|
||||||
|
this.lowBandProcessor.getOutputNode().connect(this.lowEnvFollower)
|
||||||
|
this.midBandProcessor.getOutputNode().connect(this.midEnvFollower)
|
||||||
|
this.highBandProcessor.getOutputNode().connect(this.highEnvFollower)
|
||||||
|
|
||||||
|
this.lowEnvFollower.connect(this.lowToHighModGain)
|
||||||
|
this.highEnvFollower.connect(this.highToLowModGain)
|
||||||
|
this.midEnvFollower.connect(this.midToGlobalModGain)
|
||||||
|
|
||||||
|
this.lowToHighModGain.connect(this.highBandProcessor.getModulationTarget())
|
||||||
|
this.highToLowModGain.connect(this.lowBandProcessor.getModulationTarget())
|
||||||
|
|
||||||
|
this.lowBandProcessor.getOutputNode().connect(this.bandMixer)
|
||||||
|
this.midBandProcessor.getOutputNode().connect(this.bandMixer)
|
||||||
|
this.highBandProcessor.getOutputNode().connect(this.bandMixer)
|
||||||
|
|
||||||
|
this.bandMixer.connect(this.wetNode)
|
||||||
}
|
}
|
||||||
|
|
||||||
const context = new OfflineAudioContext(
|
private updateDecayAndDamping(): void {
|
||||||
input.numberOfChannels,
|
const decay = this.currentDecay
|
||||||
input.length,
|
const damping = this.currentDamping
|
||||||
input.sampleRate
|
|
||||||
)
|
|
||||||
|
|
||||||
const player = context.createBufferSource()
|
this.lowBandProcessor.setDecay(decay * 1.2)
|
||||||
player.buffer = input
|
this.midBandProcessor.setDecay(decay)
|
||||||
|
this.highBandProcessor.setDecay(decay * 0.6)
|
||||||
|
|
||||||
const filter = context.createBiquadFilter()
|
this.lowBandProcessor.setDamping(damping * 0.5)
|
||||||
lpFreqStart = Math.min(lpFreqStart, input.sampleRate / 2)
|
this.midBandProcessor.setDamping(damping)
|
||||||
lpFreqEnd = Math.min(lpFreqEnd, input.sampleRate / 2)
|
this.highBandProcessor.setDamping(damping * 1.5)
|
||||||
|
|
||||||
filter.type = 'lowpass'
|
const modAmount = 0.3
|
||||||
filter.Q.value = 0.0001
|
this.lowToHighModGain.gain.value = modAmount
|
||||||
filter.frequency.setValueAtTime(lpFreqStart, 0)
|
this.highToLowModGain.gain.value = modAmount * 0.7
|
||||||
filter.frequency.linearRampToValueAtTime(lpFreqEnd, lpFreqEndAt)
|
this.midToGlobalModGain.gain.value = modAmount * 0.5
|
||||||
|
|
||||||
player.connect(filter)
|
|
||||||
filter.connect(context.destination)
|
|
||||||
player.start()
|
|
||||||
|
|
||||||
context.oncomplete = (event) => {
|
|
||||||
callback(event.renderedBuffer)
|
|
||||||
}
|
|
||||||
|
|
||||||
context.startRendering()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
getInputNode(): AudioNode {
|
getInputNode(): AudioNode {
|
||||||
@ -153,20 +225,20 @@ export class ReverbEffect implements Effect {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
updateParams(values: Record<string, number>): void {
|
updateParams(values: Record<string, number | string>): void {
|
||||||
let needsRegeneration = false
|
let needsUpdate = false
|
||||||
|
|
||||||
if (values.reverbDecay !== undefined && values.reverbDecay !== this.currentDecay) {
|
if (values.reverbDecay !== undefined && typeof values.reverbDecay === 'number') {
|
||||||
this.currentDecay = values.reverbDecay
|
this.currentDecay = values.reverbDecay / 100
|
||||||
needsRegeneration = true
|
needsUpdate = true
|
||||||
}
|
}
|
||||||
|
|
||||||
if (values.reverbDamping !== undefined && values.reverbDamping !== this.currentDamping) {
|
if (values.reverbDamping !== undefined && typeof values.reverbDamping === 'number') {
|
||||||
this.currentDamping = values.reverbDamping
|
this.currentDamping = values.reverbDamping / 100
|
||||||
needsRegeneration = true
|
needsUpdate = true
|
||||||
}
|
}
|
||||||
|
|
||||||
if (values.reverbWetDry !== undefined) {
|
if (values.reverbWetDry !== undefined && typeof values.reverbWetDry === 'number') {
|
||||||
const wet = values.reverbWetDry / 100
|
const wet = values.reverbWetDry / 100
|
||||||
this.currentWetValue = wet
|
this.currentWetValue = wet
|
||||||
this.currentDryValue = 1 - wet
|
this.currentDryValue = 1 - wet
|
||||||
@ -177,7 +249,7 @@ export class ReverbEffect implements Effect {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (values.reverbPanRate !== undefined) {
|
if (values.reverbPanRate !== undefined && typeof values.reverbPanRate === 'number') {
|
||||||
const rate = values.reverbPanRate
|
const rate = values.reverbPanRate
|
||||||
this.panLfoNode.frequency.setTargetAtTime(
|
this.panLfoNode.frequency.setTargetAtTime(
|
||||||
rate,
|
rate,
|
||||||
@ -186,7 +258,7 @@ export class ReverbEffect implements Effect {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (values.reverbPanWidth !== undefined) {
|
if (values.reverbPanWidth !== undefined && typeof values.reverbPanWidth === 'number') {
|
||||||
const width = values.reverbPanWidth / 100
|
const width = values.reverbPanWidth / 100
|
||||||
this.panLfoGainNode.gain.setTargetAtTime(
|
this.panLfoGainNode.gain.setTargetAtTime(
|
||||||
width,
|
width,
|
||||||
@ -195,8 +267,8 @@ export class ReverbEffect implements Effect {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (needsRegeneration) {
|
if (needsUpdate) {
|
||||||
this.generateReverb(this.currentDecay, this.currentDamping)
|
this.updateDecayAndDamping()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -207,9 +279,273 @@ export class ReverbEffect implements Effect {
|
|||||||
this.inputNode.disconnect()
|
this.inputNode.disconnect()
|
||||||
this.outputNode.disconnect()
|
this.outputNode.disconnect()
|
||||||
this.mixNode.disconnect()
|
this.mixNode.disconnect()
|
||||||
this.convolverNode.disconnect()
|
|
||||||
this.wetNode.disconnect()
|
this.wetNode.disconnect()
|
||||||
this.dryNode.disconnect()
|
this.dryNode.disconnect()
|
||||||
this.pannerNode.disconnect()
|
this.pannerNode.disconnect()
|
||||||
|
this.earlyReflectionsNode.disconnect()
|
||||||
|
|
||||||
|
this.earlyReflectionDelays.forEach(d => d.disconnect())
|
||||||
|
this.earlyReflectionGains.forEach(g => g.disconnect())
|
||||||
|
this.earlyReflectionFilters.forEach(f => f.disconnect())
|
||||||
|
|
||||||
|
this.lowBandSplitter.disconnect()
|
||||||
|
this.midBandHighPass.disconnect()
|
||||||
|
this.midBandLowPass.disconnect()
|
||||||
|
this.highBandSplitter.disconnect()
|
||||||
|
|
||||||
|
this.lowBandProcessor.dispose()
|
||||||
|
this.midBandProcessor.dispose()
|
||||||
|
this.highBandProcessor.dispose()
|
||||||
|
|
||||||
|
this.lowEnvFollower.disconnect()
|
||||||
|
this.midEnvFollower.disconnect()
|
||||||
|
this.highEnvFollower.disconnect()
|
||||||
|
|
||||||
|
this.lowToHighModGain.disconnect()
|
||||||
|
this.highToLowModGain.disconnect()
|
||||||
|
this.midToGlobalModGain.disconnect()
|
||||||
|
|
||||||
|
this.bandMixer.disconnect()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class BandProcessor {
|
||||||
|
private audioContext: AudioContext
|
||||||
|
private bandType: 'low' | 'mid' | 'high'
|
||||||
|
|
||||||
|
private inputNode: GainNode
|
||||||
|
private outputNode: GainNode
|
||||||
|
private modulationTarget: GainNode
|
||||||
|
|
||||||
|
private delay1: DelayNode
|
||||||
|
private delay2: DelayNode
|
||||||
|
private allpass1: DelayNode
|
||||||
|
private allpass2: DelayNode
|
||||||
|
|
||||||
|
private ap1Gain: GainNode
|
||||||
|
private ap2Gain: GainNode
|
||||||
|
|
||||||
|
private filter1: BiquadFilterNode
|
||||||
|
private filter2: BiquadFilterNode
|
||||||
|
private filter3: BiquadFilterNode
|
||||||
|
|
||||||
|
private feedbackGain: GainNode
|
||||||
|
private saturation: WaveShaperNode
|
||||||
|
|
||||||
|
private feedbackMixer: GainNode
|
||||||
|
|
||||||
|
constructor(audioContext: AudioContext, bandType: 'low' | 'mid' | 'high', sr: number) {
|
||||||
|
this.audioContext = audioContext
|
||||||
|
this.bandType = bandType
|
||||||
|
|
||||||
|
this.inputNode = audioContext.createGain()
|
||||||
|
this.outputNode = audioContext.createGain()
|
||||||
|
this.modulationTarget = audioContext.createGain()
|
||||||
|
this.modulationTarget.gain.value = 0
|
||||||
|
|
||||||
|
const scale = sr / 48000
|
||||||
|
const delayTimes = this.getDelayTimes(bandType, scale, sr)
|
||||||
|
|
||||||
|
this.delay1 = audioContext.createDelay(1.0)
|
||||||
|
this.delay2 = audioContext.createDelay(1.0)
|
||||||
|
this.delay1.delayTime.value = delayTimes.d1
|
||||||
|
this.delay2.delayTime.value = delayTimes.d2
|
||||||
|
|
||||||
|
this.allpass1 = audioContext.createDelay(0.1)
|
||||||
|
this.allpass2 = audioContext.createDelay(0.1)
|
||||||
|
this.allpass1.delayTime.value = delayTimes.ap1
|
||||||
|
this.allpass2.delayTime.value = delayTimes.ap2
|
||||||
|
|
||||||
|
this.ap1Gain = audioContext.createGain()
|
||||||
|
this.ap2Gain = audioContext.createGain()
|
||||||
|
this.ap1Gain.gain.value = 0.7
|
||||||
|
this.ap2Gain.gain.value = 0.7
|
||||||
|
|
||||||
|
this.filter1 = audioContext.createBiquadFilter()
|
||||||
|
this.filter2 = audioContext.createBiquadFilter()
|
||||||
|
this.filter3 = audioContext.createBiquadFilter()
|
||||||
|
this.setupFilters(bandType)
|
||||||
|
|
||||||
|
this.feedbackGain = audioContext.createGain()
|
||||||
|
this.feedbackGain.gain.value = 0.5
|
||||||
|
|
||||||
|
this.saturation = audioContext.createWaveShaper()
|
||||||
|
this.saturation.curve = this.createSaturationCurve(bandType)
|
||||||
|
this.saturation.oversample = '2x'
|
||||||
|
|
||||||
|
this.feedbackMixer = audioContext.createGain()
|
||||||
|
|
||||||
|
this.buildGraph()
|
||||||
|
}
|
||||||
|
|
||||||
|
private getDelayTimes(bandType: string, scale: number, sr: number) {
|
||||||
|
const times: Record<string, { d1: number; d2: number; ap1: number; ap2: number }> = {
|
||||||
|
low: {
|
||||||
|
d1: (1201 * scale) / sr,
|
||||||
|
d2: (6171 * scale) / sr,
|
||||||
|
ap1: (2333 * scale) / sr,
|
||||||
|
ap2: (4513 * scale) / sr,
|
||||||
|
},
|
||||||
|
mid: {
|
||||||
|
d1: (907 * scale) / sr,
|
||||||
|
d2: (4217 * scale) / sr,
|
||||||
|
ap1: (1801 * scale) / sr,
|
||||||
|
ap2: (3119 * scale) / sr,
|
||||||
|
},
|
||||||
|
high: {
|
||||||
|
d1: (503 * scale) / sr,
|
||||||
|
d2: (2153 * scale) / sr,
|
||||||
|
ap1: (907 * scale) / sr,
|
||||||
|
ap2: (1453 * scale) / sr,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
return times[bandType]
|
||||||
|
}
|
||||||
|
|
||||||
|
private setupFilters(bandType: string): void {
|
||||||
|
if (bandType === 'low') {
|
||||||
|
this.filter1.type = 'lowpass'
|
||||||
|
this.filter1.frequency.value = 1200
|
||||||
|
this.filter1.Q.value = 0.707
|
||||||
|
|
||||||
|
this.filter2.type = 'lowshelf'
|
||||||
|
this.filter2.frequency.value = 200
|
||||||
|
this.filter2.gain.value = 2
|
||||||
|
|
||||||
|
this.filter3.type = 'peaking'
|
||||||
|
this.filter3.frequency.value = 600
|
||||||
|
this.filter3.Q.value = 1.0
|
||||||
|
this.filter3.gain.value = -3
|
||||||
|
} else if (bandType === 'mid') {
|
||||||
|
this.filter1.type = 'lowpass'
|
||||||
|
this.filter1.frequency.value = 5000
|
||||||
|
this.filter1.Q.value = 0.707
|
||||||
|
|
||||||
|
this.filter2.type = 'peaking'
|
||||||
|
this.filter2.frequency.value = 1200
|
||||||
|
this.filter2.Q.value = 1.5
|
||||||
|
this.filter2.gain.value = -2
|
||||||
|
|
||||||
|
this.filter3.type = 'highshelf'
|
||||||
|
this.filter3.frequency.value = 3000
|
||||||
|
this.filter3.gain.value = -4
|
||||||
|
} else {
|
||||||
|
this.filter1.type = 'lowpass'
|
||||||
|
this.filter1.frequency.value = 12000
|
||||||
|
this.filter1.Q.value = 0.5
|
||||||
|
|
||||||
|
this.filter2.type = 'lowpass'
|
||||||
|
this.filter2.frequency.value = 8000
|
||||||
|
this.filter2.Q.value = 0.707
|
||||||
|
|
||||||
|
this.filter3.type = 'highshelf'
|
||||||
|
this.filter3.frequency.value = 5000
|
||||||
|
this.filter3.gain.value = -6
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private createSaturationCurve(bandType: string): Float32Array {
|
||||||
|
const samples = 4096
|
||||||
|
const curve = new Float32Array(samples)
|
||||||
|
const amount = bandType === 'low' ? 0.8 : bandType === 'mid' ? 0.5 : 0.3
|
||||||
|
|
||||||
|
for (let i = 0; i < samples; i++) {
|
||||||
|
const x = (i * 2) / samples - 1
|
||||||
|
curve[i] = Math.tanh(x * (1 + amount)) / (1 + amount * 0.5)
|
||||||
|
}
|
||||||
|
|
||||||
|
return curve
|
||||||
|
}
|
||||||
|
|
||||||
|
private buildGraph(): void {
|
||||||
|
this.inputNode.connect(this.delay1)
|
||||||
|
this.delay1.connect(this.filter1)
|
||||||
|
this.filter1.connect(this.filter2)
|
||||||
|
this.filter2.connect(this.filter3)
|
||||||
|
this.filter3.connect(this.delay2)
|
||||||
|
|
||||||
|
const ap1Out = this.createAllPass(this.delay2, this.allpass1, this.ap1Gain)
|
||||||
|
const ap2Out = this.createAllPass(ap1Out, this.allpass2, this.ap2Gain)
|
||||||
|
|
||||||
|
ap2Out.connect(this.feedbackGain)
|
||||||
|
this.feedbackGain.connect(this.saturation)
|
||||||
|
this.saturation.connect(this.feedbackMixer)
|
||||||
|
|
||||||
|
this.modulationTarget.connect(this.feedbackMixer)
|
||||||
|
|
||||||
|
this.feedbackMixer.connect(this.inputNode)
|
||||||
|
|
||||||
|
ap2Out.connect(this.outputNode)
|
||||||
|
}
|
||||||
|
|
||||||
|
private createAllPass(input: AudioNode, delay: DelayNode, gain: GainNode): AudioNode {
|
||||||
|
const output = this.audioContext.createGain()
|
||||||
|
const feedbackGain = this.audioContext.createGain()
|
||||||
|
feedbackGain.gain.value = -1
|
||||||
|
|
||||||
|
input.connect(delay)
|
||||||
|
input.connect(feedbackGain)
|
||||||
|
feedbackGain.connect(output)
|
||||||
|
|
||||||
|
delay.connect(gain)
|
||||||
|
gain.connect(output)
|
||||||
|
gain.connect(input)
|
||||||
|
|
||||||
|
return output
|
||||||
|
}
|
||||||
|
|
||||||
|
getInputNode(): AudioNode {
|
||||||
|
return this.inputNode
|
||||||
|
}
|
||||||
|
|
||||||
|
getOutputNode(): AudioNode {
|
||||||
|
return this.outputNode
|
||||||
|
}
|
||||||
|
|
||||||
|
getModulationTarget(): AudioNode {
|
||||||
|
return this.modulationTarget
|
||||||
|
}
|
||||||
|
|
||||||
|
setDecay(decay: number): void {
|
||||||
|
this.feedbackGain.gain.setTargetAtTime(
|
||||||
|
Math.min(0.95, decay),
|
||||||
|
this.audioContext.currentTime,
|
||||||
|
0.01
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
setDamping(damping: number): void {
|
||||||
|
let cutoff: number
|
||||||
|
if (this.bandType === 'low') {
|
||||||
|
cutoff = 500 + damping * 1500
|
||||||
|
} else if (this.bandType === 'mid') {
|
||||||
|
cutoff = 2000 + damping * 6000
|
||||||
|
} else {
|
||||||
|
cutoff = 4000 + damping * 10000
|
||||||
|
}
|
||||||
|
|
||||||
|
this.filter1.frequency.setTargetAtTime(
|
||||||
|
cutoff,
|
||||||
|
this.audioContext.currentTime,
|
||||||
|
0.01
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
dispose(): void {
|
||||||
|
this.inputNode.disconnect()
|
||||||
|
this.outputNode.disconnect()
|
||||||
|
this.modulationTarget.disconnect()
|
||||||
|
this.delay1.disconnect()
|
||||||
|
this.delay2.disconnect()
|
||||||
|
this.allpass1.disconnect()
|
||||||
|
this.allpass2.disconnect()
|
||||||
|
this.ap1Gain.disconnect()
|
||||||
|
this.ap2Gain.disconnect()
|
||||||
|
this.filter1.disconnect()
|
||||||
|
this.filter2.disconnect()
|
||||||
|
this.filter3.disconnect()
|
||||||
|
this.feedbackGain.disconnect()
|
||||||
|
this.saturation.disconnect()
|
||||||
|
this.feedbackMixer.disconnect()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -96,12 +96,11 @@ export class WavefolderEffect implements Effect {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
updateParams(values: Record<string, number>): void {
|
updateParams(values: Record<string, number | string>): void {
|
||||||
if (values.clipMode !== undefined) {
|
if (values.clipMode !== undefined && typeof values.clipMode === 'string') {
|
||||||
const modeIndex = values.clipMode
|
this.mode = values.clipMode as ClipMode
|
||||||
this.mode = ['wrap', 'clamp', 'fold'][modeIndex] as ClipMode || 'wrap'
|
|
||||||
}
|
}
|
||||||
if (values.wavefolderDrive !== undefined) {
|
if (values.wavefolderDrive !== undefined && typeof values.wavefolderDrive === 'number') {
|
||||||
this.drive = values.wavefolderDrive
|
this.drive = values.wavefolderDrive
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
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()
|
||||||
105
src/hooks/useKeyboardShortcuts.ts
Normal file
105
src/hooks/useKeyboardShortcuts.ts
Normal file
@ -0,0 +1,105 @@
|
|||||||
|
import { useEffect, useRef } from 'react'
|
||||||
|
|
||||||
|
export interface KeyboardShortcutHandlers {
|
||||||
|
onSpace?: () => void
|
||||||
|
onArrowUp?: (shift: boolean) => void
|
||||||
|
onArrowDown?: (shift: boolean) => void
|
||||||
|
onArrowLeft?: (shift: boolean) => void
|
||||||
|
onArrowRight?: (shift: boolean) => void
|
||||||
|
onEnter?: () => void
|
||||||
|
onDoubleEnter?: () => void
|
||||||
|
onR?: () => void
|
||||||
|
onShiftR?: () => void
|
||||||
|
onC?: () => void
|
||||||
|
onShiftC?: () => void
|
||||||
|
onEscape?: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
const DOUBLE_ENTER_THRESHOLD = 300
|
||||||
|
|
||||||
|
export function useKeyboardShortcuts(handlers: KeyboardShortcutHandlers) {
|
||||||
|
const handlersRef = useRef(handlers)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
handlersRef.current = handlers
|
||||||
|
}, [handlers])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let lastEnterTime = 0
|
||||||
|
|
||||||
|
const handleKeyDown = (e: KeyboardEvent) => {
|
||||||
|
if (e.target instanceof HTMLInputElement || e.target instanceof HTMLTextAreaElement) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const h = handlersRef.current
|
||||||
|
|
||||||
|
switch (e.key) {
|
||||||
|
case ' ':
|
||||||
|
e.preventDefault()
|
||||||
|
h.onSpace?.()
|
||||||
|
break
|
||||||
|
|
||||||
|
case 'ArrowUp':
|
||||||
|
e.preventDefault()
|
||||||
|
h.onArrowUp?.(e.shiftKey)
|
||||||
|
break
|
||||||
|
|
||||||
|
case 'ArrowDown':
|
||||||
|
e.preventDefault()
|
||||||
|
h.onArrowDown?.(e.shiftKey)
|
||||||
|
break
|
||||||
|
|
||||||
|
case 'ArrowLeft':
|
||||||
|
e.preventDefault()
|
||||||
|
h.onArrowLeft?.(e.shiftKey)
|
||||||
|
break
|
||||||
|
|
||||||
|
case 'ArrowRight':
|
||||||
|
e.preventDefault()
|
||||||
|
h.onArrowRight?.(e.shiftKey)
|
||||||
|
break
|
||||||
|
|
||||||
|
case 'Enter': {
|
||||||
|
e.preventDefault()
|
||||||
|
const now = Date.now()
|
||||||
|
if (now - lastEnterTime < DOUBLE_ENTER_THRESHOLD) {
|
||||||
|
h.onDoubleEnter?.()
|
||||||
|
} else {
|
||||||
|
h.onEnter?.()
|
||||||
|
}
|
||||||
|
lastEnterTime = now
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'r':
|
||||||
|
case 'R':
|
||||||
|
e.preventDefault()
|
||||||
|
if (e.shiftKey) {
|
||||||
|
h.onShiftR?.()
|
||||||
|
} else {
|
||||||
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
window.addEventListener('keydown', handleKeyDown)
|
||||||
|
return () => window.removeEventListener('keydown', handleKeyDown)
|
||||||
|
}, [])
|
||||||
|
}
|
||||||
33
src/hooks/useTileParams.ts
Normal file
33
src/hooks/useTileParams.ts
Normal file
@ -0,0 +1,33 @@
|
|||||||
|
import { useCallback } from 'react'
|
||||||
|
import type { TileState } from '../types/tiles'
|
||||||
|
import { saveTileParams } from '../utils/tileState'
|
||||||
|
import { getTileFromGrid, type FocusedTile } from '../utils/tileHelpers'
|
||||||
|
|
||||||
|
interface UseTileParamsProps {
|
||||||
|
tiles: TileState[][]
|
||||||
|
setTiles: React.Dispatch<React.SetStateAction<TileState[][]>>
|
||||||
|
customTile: TileState
|
||||||
|
setCustomTile: React.Dispatch<React.SetStateAction<TileState>>
|
||||||
|
focusedTile: FocusedTile
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useTileParams({ tiles, setTiles, customTile, setCustomTile, focusedTile }: UseTileParamsProps) {
|
||||||
|
const saveCurrentTileParams = useCallback(() => {
|
||||||
|
if (focusedTile === 'custom') {
|
||||||
|
setCustomTile(saveTileParams(customTile))
|
||||||
|
} else {
|
||||||
|
const currentTile = getTileFromGrid(tiles, focusedTile.row, focusedTile.col)
|
||||||
|
if (currentTile) {
|
||||||
|
const updatedTile = saveTileParams(currentTile)
|
||||||
|
setTiles(prevTiles => {
|
||||||
|
const newTiles = [...prevTiles]
|
||||||
|
newTiles[focusedTile.row] = [...newTiles[focusedTile.row]]
|
||||||
|
newTiles[focusedTile.row][focusedTile.col] = updatedTile
|
||||||
|
return newTiles
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [focusedTile, tiles, setTiles, customTile, setCustomTile])
|
||||||
|
|
||||||
|
return { saveCurrentTileParams }
|
||||||
|
}
|
||||||
@ -1,109 +0,0 @@
|
|||||||
import type { BytebeatOptions, BitDepth } from './types'
|
|
||||||
import type { EffectValues } from '../../types/effects'
|
|
||||||
import { compileFormula } from '../../domain/audio/BytebeatCompiler'
|
|
||||||
import { generateSamples } from '../../domain/audio/SampleGenerator'
|
|
||||||
import { exportToWav } from '../../domain/audio/WavExporter'
|
|
||||||
import { AudioPlayer } from '../../domain/audio/AudioPlayer'
|
|
||||||
|
|
||||||
export class BytebeatGenerator {
|
|
||||||
private sampleRate: number
|
|
||||||
private duration: number
|
|
||||||
private audioBuffer: Float32Array | null = null
|
|
||||||
private audioPlayer: AudioPlayer
|
|
||||||
|
|
||||||
constructor(options: BytebeatOptions = {}) {
|
|
||||||
this.sampleRate = options.sampleRate ?? 8000
|
|
||||||
this.duration = options.duration ?? 10
|
|
||||||
this.audioPlayer = new AudioPlayer({ sampleRate: this.sampleRate, duration: this.duration })
|
|
||||||
}
|
|
||||||
|
|
||||||
updateOptions(options: Partial<BytebeatOptions>): void {
|
|
||||||
if (options.sampleRate !== undefined) {
|
|
||||||
this.sampleRate = options.sampleRate
|
|
||||||
this.audioBuffer = null
|
|
||||||
}
|
|
||||||
if (options.duration !== undefined) {
|
|
||||||
this.duration = options.duration
|
|
||||||
this.audioBuffer = null
|
|
||||||
}
|
|
||||||
this.audioPlayer.updateOptions({ sampleRate: this.sampleRate, duration: this.duration })
|
|
||||||
}
|
|
||||||
|
|
||||||
setFormula(formula: string): void {
|
|
||||||
const result = compileFormula(formula)
|
|
||||||
|
|
||||||
if (!result.success || !result.compiledFormula) {
|
|
||||||
throw new Error(`Invalid formula: ${result.error}`)
|
|
||||||
}
|
|
||||||
|
|
||||||
this.audioBuffer = generateSamples(result.compiledFormula, {
|
|
||||||
sampleRate: this.sampleRate,
|
|
||||||
duration: this.duration
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
generate(): Float32Array {
|
|
||||||
if (!this.audioBuffer) {
|
|
||||||
throw new Error('No formula set. Call setFormula() first.')
|
|
||||||
}
|
|
||||||
return this.audioBuffer
|
|
||||||
}
|
|
||||||
|
|
||||||
setEffects(values: EffectValues): void {
|
|
||||||
this.audioPlayer.setEffects(values)
|
|
||||||
}
|
|
||||||
|
|
||||||
getPlaybackPosition(): number {
|
|
||||||
return this.audioPlayer.getPlaybackPosition()
|
|
||||||
}
|
|
||||||
|
|
||||||
play(): void {
|
|
||||||
if (!this.audioBuffer) {
|
|
||||||
throw new Error('No audio buffer. Call setFormula() first.')
|
|
||||||
}
|
|
||||||
this.audioPlayer.play(this.audioBuffer)
|
|
||||||
}
|
|
||||||
|
|
||||||
onLoopEnd(callback: () => void): void {
|
|
||||||
if (!this.audioBuffer) return
|
|
||||||
this.audioPlayer.setLooping(false)
|
|
||||||
this.audioPlayer.play(this.audioBuffer, callback)
|
|
||||||
}
|
|
||||||
|
|
||||||
setLooping(loop: boolean): void {
|
|
||||||
this.audioPlayer.setLooping(loop)
|
|
||||||
}
|
|
||||||
|
|
||||||
scheduleNextTrack(callback: () => void): void {
|
|
||||||
this.audioPlayer.scheduleNextTrack(callback)
|
|
||||||
}
|
|
||||||
|
|
||||||
pause(): void {
|
|
||||||
this.audioPlayer.pause()
|
|
||||||
}
|
|
||||||
|
|
||||||
stop(): void {
|
|
||||||
this.audioPlayer.stop()
|
|
||||||
}
|
|
||||||
|
|
||||||
exportWAV(bitDepth: BitDepth = 8): Blob {
|
|
||||||
if (!this.audioBuffer) {
|
|
||||||
throw new Error('No audio buffer. Call setFormula() first.')
|
|
||||||
}
|
|
||||||
return exportToWav(this.audioBuffer, { sampleRate: this.sampleRate, bitDepth })
|
|
||||||
}
|
|
||||||
|
|
||||||
downloadWAV(filename: string = 'bytebeat.wav', bitDepth: BitDepth = 8): void {
|
|
||||||
const blob = this.exportWAV(bitDepth)
|
|
||||||
const url = URL.createObjectURL(blob)
|
|
||||||
const a = document.createElement('a')
|
|
||||||
a.href = url
|
|
||||||
a.download = filename
|
|
||||||
a.click()
|
|
||||||
URL.revokeObjectURL(url)
|
|
||||||
}
|
|
||||||
|
|
||||||
dispose(): void {
|
|
||||||
this.audioPlayer.dispose()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,124 +0,0 @@
|
|||||||
import type { EffectValues } from '../../types/effects'
|
|
||||||
|
|
||||||
export class EffectsChain {
|
|
||||||
private audioContext: AudioContext
|
|
||||||
private inputNode: GainNode
|
|
||||||
private outputNode: GainNode
|
|
||||||
private masterGainNode: GainNode
|
|
||||||
|
|
||||||
private delayNode: DelayNode
|
|
||||||
private delayFeedbackNode: GainNode
|
|
||||||
private delayWetNode: GainNode
|
|
||||||
private delayDryNode: GainNode
|
|
||||||
|
|
||||||
private convolverNode: ConvolverNode
|
|
||||||
private reverbWetNode: GainNode
|
|
||||||
private reverbDryNode: GainNode
|
|
||||||
|
|
||||||
private tbdNode: GainNode
|
|
||||||
|
|
||||||
constructor(audioContext: AudioContext) {
|
|
||||||
this.audioContext = audioContext
|
|
||||||
|
|
||||||
this.inputNode = audioContext.createGain()
|
|
||||||
this.masterGainNode = audioContext.createGain()
|
|
||||||
this.outputNode = audioContext.createGain()
|
|
||||||
|
|
||||||
this.delayNode = audioContext.createDelay(2.0)
|
|
||||||
this.delayFeedbackNode = audioContext.createGain()
|
|
||||||
this.delayWetNode = audioContext.createGain()
|
|
||||||
this.delayDryNode = audioContext.createGain()
|
|
||||||
|
|
||||||
this.convolverNode = audioContext.createConvolver()
|
|
||||||
this.reverbWetNode = audioContext.createGain()
|
|
||||||
this.reverbDryNode = audioContext.createGain()
|
|
||||||
|
|
||||||
this.tbdNode = audioContext.createGain()
|
|
||||||
|
|
||||||
this.setupChain()
|
|
||||||
this.generateImpulseResponse()
|
|
||||||
}
|
|
||||||
|
|
||||||
private setupChain(): void {
|
|
||||||
this.delayDryNode.gain.value = 1
|
|
||||||
this.delayWetNode.gain.value = 0
|
|
||||||
|
|
||||||
this.inputNode.connect(this.delayDryNode)
|
|
||||||
this.inputNode.connect(this.delayNode)
|
|
||||||
this.delayNode.connect(this.delayFeedbackNode)
|
|
||||||
this.delayFeedbackNode.connect(this.delayNode)
|
|
||||||
this.delayNode.connect(this.delayWetNode)
|
|
||||||
|
|
||||||
this.delayDryNode.connect(this.reverbDryNode)
|
|
||||||
this.delayWetNode.connect(this.reverbDryNode)
|
|
||||||
|
|
||||||
this.delayDryNode.connect(this.convolverNode)
|
|
||||||
this.delayWetNode.connect(this.convolverNode)
|
|
||||||
this.convolverNode.connect(this.reverbWetNode)
|
|
||||||
|
|
||||||
this.reverbDryNode.connect(this.tbdNode)
|
|
||||||
this.reverbWetNode.connect(this.tbdNode)
|
|
||||||
|
|
||||||
this.tbdNode.connect(this.masterGainNode)
|
|
||||||
this.masterGainNode.connect(this.outputNode)
|
|
||||||
}
|
|
||||||
|
|
||||||
private generateImpulseResponse(): void {
|
|
||||||
const length = this.audioContext.sampleRate * 2
|
|
||||||
const impulse = this.audioContext.createBuffer(2, length, this.audioContext.sampleRate)
|
|
||||||
const left = impulse.getChannelData(0)
|
|
||||||
const right = impulse.getChannelData(1)
|
|
||||||
|
|
||||||
for (let i = 0; i < length; i++) {
|
|
||||||
left[i] = (Math.random() * 2 - 1) * Math.pow(1 - i / length, 2)
|
|
||||||
right[i] = (Math.random() * 2 - 1) * Math.pow(1 - i / length, 2)
|
|
||||||
}
|
|
||||||
|
|
||||||
this.convolverNode.buffer = impulse
|
|
||||||
}
|
|
||||||
|
|
||||||
updateEffects(values: EffectValues): void {
|
|
||||||
if (typeof values.reverbWetDry === 'number') {
|
|
||||||
const reverbWet = values.reverbWetDry / 100
|
|
||||||
this.reverbWetNode.gain.value = reverbWet
|
|
||||||
this.reverbDryNode.gain.value = 1 - reverbWet
|
|
||||||
}
|
|
||||||
|
|
||||||
if (typeof values.delayTime === 'number') {
|
|
||||||
this.delayNode.delayTime.value = values.delayTime / 1000
|
|
||||||
const delayAmount = Math.min(values.delayTime / 1000, 0.5)
|
|
||||||
this.delayWetNode.gain.value = delayAmount
|
|
||||||
this.delayDryNode.gain.value = 1 - delayAmount
|
|
||||||
}
|
|
||||||
|
|
||||||
if (typeof values.delayFeedback === 'number') {
|
|
||||||
this.delayFeedbackNode.gain.value = values.delayFeedback / 100
|
|
||||||
}
|
|
||||||
|
|
||||||
if (typeof values.masterVolume === 'number') {
|
|
||||||
this.masterGainNode.gain.value = values.masterVolume / 100
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
getInputNode(): AudioNode {
|
|
||||||
return this.inputNode
|
|
||||||
}
|
|
||||||
|
|
||||||
getOutputNode(): AudioNode {
|
|
||||||
return this.outputNode
|
|
||||||
}
|
|
||||||
|
|
||||||
dispose(): void {
|
|
||||||
this.inputNode.disconnect()
|
|
||||||
this.outputNode.disconnect()
|
|
||||||
this.masterGainNode.disconnect()
|
|
||||||
this.delayNode.disconnect()
|
|
||||||
this.delayFeedbackNode.disconnect()
|
|
||||||
this.delayWetNode.disconnect()
|
|
||||||
this.delayDryNode.disconnect()
|
|
||||||
this.convolverNode.disconnect()
|
|
||||||
this.reverbWetNode.disconnect()
|
|
||||||
this.reverbDryNode.disconnect()
|
|
||||||
this.tbdNode.disconnect()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,15 +0,0 @@
|
|||||||
export { BytebeatGenerator } from './BytebeatGenerator'
|
|
||||||
export type { BytebeatOptions, BytebeatState, BitDepth } from './types'
|
|
||||||
|
|
||||||
export const EXAMPLE_FORMULAS = {
|
|
||||||
classic: 't * ((t>>12)|(t>>8))&(63&(t>>4))',
|
|
||||||
melody: 't>>6^t&0x25|t+(t^t>>11)',
|
|
||||||
simple: 't & (t>>4)|(t>>8)',
|
|
||||||
harmony: '(t>>10&42)*t',
|
|
||||||
glitch: 't*(t>>8*((t>>15)|(t>>8))&(20|(t>>19)*5>>t|(t>>3)))',
|
|
||||||
drums: '((t>>10)&42)*(t>>8)',
|
|
||||||
ambient: '(t*5&t>>7)|(t*3&t>>10)',
|
|
||||||
noise: 't>>6&1?t>>5:-t>>4',
|
|
||||||
arpeggio: 't*(((t>>9)|(t>>13))&25&t>>6)',
|
|
||||||
chaos: 't*(t^t+(t>>15|1))',
|
|
||||||
} as const
|
|
||||||
@ -1,12 +0,0 @@
|
|||||||
export interface BytebeatOptions {
|
|
||||||
sampleRate?: number
|
|
||||||
duration?: number
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface BytebeatState {
|
|
||||||
isPlaying: boolean
|
|
||||||
isPaused: boolean
|
|
||||||
currentTime: number
|
|
||||||
}
|
|
||||||
|
|
||||||
export type BitDepth = 8 | 16
|
|
||||||
@ -1,49 +0,0 @@
|
|||||||
import type { BitDepth } from './types'
|
|
||||||
|
|
||||||
function writeString(view: DataView, offset: number, str: string): void {
|
|
||||||
for (let i = 0; i < str.length; i++) {
|
|
||||||
view.setUint8(offset + i, str.charCodeAt(i))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export function encodeWAV(samples: Float32Array, sampleRate: number, bitDepth: BitDepth): Blob {
|
|
||||||
const numChannels = 1
|
|
||||||
const bytesPerSample = bitDepth / 8
|
|
||||||
const blockAlign = numChannels * bytesPerSample
|
|
||||||
const dataSize = samples.length * bytesPerSample
|
|
||||||
const buffer = new ArrayBuffer(44 + dataSize)
|
|
||||||
const view = new DataView(buffer)
|
|
||||||
|
|
||||||
writeString(view, 0, 'RIFF')
|
|
||||||
view.setUint32(4, 36 + dataSize, true)
|
|
||||||
writeString(view, 8, 'WAVE')
|
|
||||||
|
|
||||||
writeString(view, 12, 'fmt ')
|
|
||||||
view.setUint32(16, 16, true)
|
|
||||||
view.setUint16(20, 1, true)
|
|
||||||
view.setUint16(22, numChannels, true)
|
|
||||||
view.setUint32(24, sampleRate, true)
|
|
||||||
view.setUint32(28, sampleRate * blockAlign, true)
|
|
||||||
view.setUint16(32, blockAlign, true)
|
|
||||||
view.setUint16(34, bitDepth, true)
|
|
||||||
|
|
||||||
writeString(view, 36, 'data')
|
|
||||||
view.setUint32(40, dataSize, true)
|
|
||||||
|
|
||||||
let offset = 44
|
|
||||||
for (let i = 0; i < samples.length; i++) {
|
|
||||||
const sample = Math.max(-1, Math.min(1, samples[i]))
|
|
||||||
|
|
||||||
if (bitDepth === 8) {
|
|
||||||
const value = Math.floor((sample + 1) * 127.5)
|
|
||||||
view.setUint8(offset, value)
|
|
||||||
offset += 1
|
|
||||||
} else {
|
|
||||||
const value = Math.floor(sample * 32767)
|
|
||||||
view.setInt16(offset, value, true)
|
|
||||||
offset += 2
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return new Blob([buffer], { type: 'audio/wav' })
|
|
||||||
}
|
|
||||||
@ -3,6 +3,7 @@ import { compileFormula } from '../domain/audio/BytebeatCompiler'
|
|||||||
import { generateSamples } from '../domain/audio/SampleGenerator'
|
import { generateSamples } from '../domain/audio/SampleGenerator'
|
||||||
import { exportToWav } from '../domain/audio/WavExporter'
|
import { exportToWav } from '../domain/audio/WavExporter'
|
||||||
import type { BitDepth } from '../domain/audio/WavExporter'
|
import type { BitDepth } from '../domain/audio/WavExporter'
|
||||||
|
import { DEFAULT_DOWNLOAD_OPTIONS } from '../constants/defaults'
|
||||||
|
|
||||||
export interface DownloadOptions {
|
export interface DownloadOptions {
|
||||||
sampleRate?: number
|
sampleRate?: number
|
||||||
@ -24,29 +25,22 @@ export class DownloadService {
|
|||||||
formula: string,
|
formula: string,
|
||||||
filename: string,
|
filename: string,
|
||||||
options: DownloadOptions = {}
|
options: DownloadOptions = {}
|
||||||
): boolean {
|
): void {
|
||||||
const {
|
const {
|
||||||
sampleRate = 8000,
|
sampleRate = DEFAULT_DOWNLOAD_OPTIONS.SAMPLE_RATE,
|
||||||
duration = 10,
|
duration = DEFAULT_DOWNLOAD_OPTIONS.DURATION,
|
||||||
bitDepth = 8
|
bitDepth = DEFAULT_DOWNLOAD_OPTIONS.BIT_DEPTH
|
||||||
} = options
|
} = options
|
||||||
|
|
||||||
const result = compileFormula(formula)
|
const result = compileFormula(formula)
|
||||||
|
|
||||||
if (!result.success || !result.compiledFormula) {
|
if (!result.success || !result.compiledFormula) {
|
||||||
console.error('Failed to compile formula:', result.error)
|
throw new Error(`Failed to compile formula: ${result.error}`)
|
||||||
return false
|
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
|
||||||
const buffer = generateSamples(result.compiledFormula, { sampleRate, duration })
|
const buffer = generateSamples(result.compiledFormula, { sampleRate, duration })
|
||||||
const blob = exportToWav(buffer, { sampleRate, bitDepth })
|
const blob = exportToWav(buffer, { sampleRate, bitDepth })
|
||||||
this.downloadBlob(blob, filename)
|
this.downloadBlob(blob, filename)
|
||||||
return true
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Failed to download formula:', error)
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async downloadAll(
|
async downloadAll(
|
||||||
@ -54,9 +48,9 @@ export class DownloadService {
|
|||||||
options: DownloadOptions = {}
|
options: DownloadOptions = {}
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const {
|
const {
|
||||||
sampleRate = 8000,
|
sampleRate = DEFAULT_DOWNLOAD_OPTIONS.SAMPLE_RATE,
|
||||||
duration = 10,
|
duration = DEFAULT_DOWNLOAD_OPTIONS.DURATION,
|
||||||
bitDepth = 8
|
bitDepth = DEFAULT_DOWNLOAD_OPTIONS.BIT_DEPTH
|
||||||
} = options
|
} = options
|
||||||
|
|
||||||
const zip = new JSZip()
|
const zip = new JSZip()
|
||||||
@ -66,17 +60,12 @@ export class DownloadService {
|
|||||||
const result = compileFormula(formula)
|
const result = compileFormula(formula)
|
||||||
|
|
||||||
if (!result.success || !result.compiledFormula) {
|
if (!result.success || !result.compiledFormula) {
|
||||||
console.error(`Failed to compile ${i}_${j}:`, result.error)
|
throw new Error(`Failed to compile ${i}_${j}: ${result.error}`)
|
||||||
return
|
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
|
||||||
const buffer = generateSamples(result.compiledFormula, { sampleRate, duration })
|
const buffer = generateSamples(result.compiledFormula, { sampleRate, duration })
|
||||||
const blob = exportToWav(buffer, { sampleRate, bitDepth })
|
const blob = exportToWav(buffer, { sampleRate, bitDepth })
|
||||||
zip.file(`bytebeat_${i}_${j}.wav`, blob)
|
zip.file(`bytebeat_${i}_${j}.wav`, blob)
|
||||||
} catch (error) {
|
|
||||||
console.error(`Failed to generate ${i}_${j}:`, error)
|
|
||||||
}
|
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@ -1,7 +1,7 @@
|
|||||||
import { compileFormula } from '../domain/audio/BytebeatCompiler'
|
|
||||||
import { generateSamples } from '../domain/audio/SampleGenerator'
|
|
||||||
import { AudioPlayer } from '../domain/audio/AudioPlayer'
|
import { AudioPlayer } from '../domain/audio/AudioPlayer'
|
||||||
|
import type { LFOWaveform } from '../domain/modulation/LFO'
|
||||||
import type { EffectValues } from '../types/effects'
|
import type { EffectValues } from '../types/effects'
|
||||||
|
import { DEFAULT_VARIABLES } from '../constants/defaults'
|
||||||
|
|
||||||
export interface PlaybackOptions {
|
export interface PlaybackOptions {
|
||||||
sampleRate: number
|
sampleRate: number
|
||||||
@ -11,8 +11,9 @@ export interface PlaybackOptions {
|
|||||||
export class PlaybackManager {
|
export class PlaybackManager {
|
||||||
private player: AudioPlayer
|
private player: AudioPlayer
|
||||||
private currentFormula: string | null = null
|
private currentFormula: string | null = null
|
||||||
private currentBuffer: Float32Array | null = null
|
private variables: { a: number; b: number; c: number; d: number } = { ...DEFAULT_VARIABLES }
|
||||||
private queuedCallback: (() => void) | null = null
|
private playbackPositionCallback: ((position: number) => void) | null = null
|
||||||
|
private animationFrameId: number | null = null
|
||||||
|
|
||||||
constructor(options: PlaybackOptions) {
|
constructor(options: PlaybackOptions) {
|
||||||
this.player = new AudioPlayer(options)
|
this.player = new AudioPlayer(options)
|
||||||
@ -20,51 +21,67 @@ export class PlaybackManager {
|
|||||||
|
|
||||||
async updateOptions(options: Partial<PlaybackOptions>): Promise<void> {
|
async updateOptions(options: Partial<PlaybackOptions>): Promise<void> {
|
||||||
await this.player.updateOptions(options)
|
await this.player.updateOptions(options)
|
||||||
this.currentBuffer = null
|
|
||||||
}
|
}
|
||||||
|
|
||||||
setEffects(values: EffectValues): void {
|
setEffects(values: EffectValues): void {
|
||||||
this.player.setEffects(values)
|
this.player.setEffects(values)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
setVariables(a: number, b: number, c: number, d: number): void {
|
||||||
|
this.variables = { a, b, c, d }
|
||||||
|
this.player.updateRealtimeVariables(a, b, c, d)
|
||||||
|
}
|
||||||
|
|
||||||
|
setLFOConfig(lfoIndex: number, config: { frequency: number; phase: number; waveform: LFOWaveform; mappings: Array<{ targetParam: string; depth: number }> }): void {
|
||||||
|
this.player.setLFOConfig(lfoIndex, config)
|
||||||
|
}
|
||||||
|
|
||||||
setPitch(pitch: number): void {
|
setPitch(pitch: number): void {
|
||||||
this.player.setPitch(pitch)
|
this.player.updatePitch(pitch)
|
||||||
}
|
}
|
||||||
|
|
||||||
async play(formula: string, sampleRate: number, duration: number): Promise<boolean> {
|
setPlaybackPositionCallback(callback: (position: number) => void): void {
|
||||||
const result = compileFormula(formula)
|
this.playbackPositionCallback = callback
|
||||||
|
|
||||||
if (!result.success || !result.compiledFormula) {
|
|
||||||
console.error('Failed to compile formula:', result.error)
|
|
||||||
return false
|
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
private startPlaybackTracking(): void {
|
||||||
this.currentBuffer = generateSamples(result.compiledFormula, { sampleRate, duration })
|
if (this.animationFrameId) {
|
||||||
|
cancelAnimationFrame(this.animationFrameId)
|
||||||
|
}
|
||||||
|
|
||||||
|
const updatePosition = () => {
|
||||||
|
const position = this.player.getPlaybackPosition()
|
||||||
|
if (this.playbackPositionCallback) {
|
||||||
|
this.playbackPositionCallback(position)
|
||||||
|
}
|
||||||
|
this.animationFrameId = requestAnimationFrame(updatePosition)
|
||||||
|
}
|
||||||
|
updatePosition()
|
||||||
|
}
|
||||||
|
|
||||||
|
private stopPlaybackTracking(): void {
|
||||||
|
if (this.animationFrameId) {
|
||||||
|
cancelAnimationFrame(this.animationFrameId)
|
||||||
|
this.animationFrameId = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async play(formula: string): Promise<void> {
|
||||||
this.currentFormula = formula
|
this.currentFormula = formula
|
||||||
this.player.setLooping(true)
|
await this.player.playRealtime(
|
||||||
await this.player.play(this.currentBuffer)
|
formula,
|
||||||
return true
|
this.variables.a,
|
||||||
} catch (error) {
|
this.variables.b,
|
||||||
console.error('Failed to generate samples:', error)
|
this.variables.c,
|
||||||
return false
|
this.variables.d
|
||||||
}
|
)
|
||||||
|
this.startPlaybackTracking()
|
||||||
}
|
}
|
||||||
|
|
||||||
stop(): void {
|
stop(): void {
|
||||||
|
this.stopPlaybackTracking()
|
||||||
this.player.stop()
|
this.player.stop()
|
||||||
this.currentFormula = null
|
this.currentFormula = null
|
||||||
this.queuedCallback = null
|
|
||||||
}
|
|
||||||
|
|
||||||
scheduleNextTrack(callback: () => void): void {
|
|
||||||
this.queuedCallback = callback
|
|
||||||
this.player.scheduleNextTrack(() => {
|
|
||||||
if (this.queuedCallback) {
|
|
||||||
this.queuedCallback()
|
|
||||||
this.queuedCallback = null
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
getPlaybackPosition(): number {
|
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,15 +1,76 @@
|
|||||||
import { persistentMap } from '@nanostores/persistent'
|
import { map } from 'nanostores'
|
||||||
import { getDefaultEngineValues, getDefaultEffectValues } from '../config/effects'
|
import { getDefaultEngineValues, getDefaultEffectValues } from '../config/effects'
|
||||||
|
import type { LFOWaveform } from '../domain/modulation/LFO'
|
||||||
|
|
||||||
export const engineSettings = persistentMap('engine:', getDefaultEngineValues(), {
|
const STORAGE_KEY_ENGINE = 'engine:'
|
||||||
encode: JSON.stringify,
|
const STORAGE_KEY_EFFECTS = 'effects:'
|
||||||
decode: JSON.parse
|
const STORAGE_KEY_LFO = 'lfo:'
|
||||||
})
|
|
||||||
|
|
||||||
export const effectSettings = persistentMap('effects:', {
|
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 {
|
||||||
|
const stored = localStorage.getItem(key)
|
||||||
|
return stored ? { ...defaults, ...JSON.parse(stored) } : defaults
|
||||||
|
} catch {
|
||||||
|
return defaults
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const engineSettings = map(loadFromStorage(STORAGE_KEY_ENGINE, getDefaultEngineValues()))
|
||||||
|
|
||||||
|
export const effectSettings = map(loadFromStorage(STORAGE_KEY_EFFECTS, {
|
||||||
...getDefaultEffectValues(),
|
...getDefaultEffectValues(),
|
||||||
masterVolume: 75
|
masterVolume: 75
|
||||||
}, {
|
}))
|
||||||
encode: JSON.stringify,
|
|
||||||
decode: JSON.parse
|
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()))
|
||||||
|
localStorage.setItem(STORAGE_KEY_LFO, JSON.stringify(lfoSettings.get()))
|
||||||
|
} catch {
|
||||||
|
// Silently fail on storage errors (quota exceeded, private browsing, etc.)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof window !== 'undefined') {
|
||||||
|
window.addEventListener('beforeunload', saveToStorage)
|
||||||
|
}
|
||||||
@ -3,9 +3,10 @@ export interface EffectParameter {
|
|||||||
label: string
|
label: string
|
||||||
min: number
|
min: number
|
||||||
max: number
|
max: number
|
||||||
default: number
|
default: number | string
|
||||||
step: number
|
step: number
|
||||||
unit?: string
|
unit?: string
|
||||||
|
options?: { value: string; label: string }[]
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface EffectConfig {
|
export interface EffectConfig {
|
||||||
@ -15,4 +16,4 @@ export interface EffectConfig {
|
|||||||
bypassable?: boolean
|
bypassable?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
export type EffectValues = Record<string, number | boolean>
|
export type EffectValues = Record<string, number | boolean | string>
|
||||||
8
src/types/tiles.ts
Normal file
8
src/types/tiles.ts
Normal file
@ -0,0 +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
|
||||||
|
}
|
||||||
@ -1,3 +1,6 @@
|
|||||||
|
import type { TileState } from '../types/tiles'
|
||||||
|
import { createTileState } from './tileState'
|
||||||
|
|
||||||
interface Template {
|
interface Template {
|
||||||
pattern: string
|
pattern: string
|
||||||
weight: number
|
weight: number
|
||||||
@ -69,7 +72,71 @@ const TEMPLATES: Template[] = [
|
|||||||
{ pattern: "((t*t)/(t^t>>S))&N", weight: 5 },
|
{ pattern: "((t*t)/(t^t>>S))&N", weight: 5 },
|
||||||
{ pattern: "(t*(t>>S1))^(t*(t>>S2))", weight: 6 },
|
{ pattern: "(t*(t>>S1))^(t*(t>>S2))", weight: 6 },
|
||||||
{ pattern: "((t>>S1)*(t>>S2))&((t>>S3)|(t>>S4))", weight: 6 },
|
{ pattern: "((t>>S1)*(t>>S2))&((t>>S3)|(t>>S4))", weight: 6 },
|
||||||
{ pattern: "(t&(t>>S1))^((t>>S2)&(t>>S3))", weight: 5 }
|
{ pattern: "(t&(t>>S1))^((t>>S2)&(t>>S3))", weight: 5 },
|
||||||
|
|
||||||
|
{ pattern: "t*(a&t>>b)", weight: 6 },
|
||||||
|
{ pattern: "(t>>a)|(t>>b)", weight: 6 },
|
||||||
|
{ pattern: "t&(t>>a)&(t>>c)", weight: 5 },
|
||||||
|
{ pattern: "(t*a)&(t>>b)", weight: 5 },
|
||||||
|
{ pattern: "t%(d)+(t>>a)", weight: 5 },
|
||||||
|
{ pattern: "(t>>a)^(t>>c)", weight: 5 },
|
||||||
|
{ pattern: "t*((t>>a)|(t>>b))&c", weight: 5 },
|
||||||
|
{ pattern: "((t>>a)&b)*(t>>c)", weight: 5 },
|
||||||
|
{ pattern: "(t&(t>>a))^(t>>d)", weight: 4 },
|
||||||
|
{ pattern: "t/(b+(t>>a|t>>c))", weight: 4 },
|
||||||
|
|
||||||
|
{ pattern: "t&t>>a", weight: 7 },
|
||||||
|
{ pattern: "t&t>>b", weight: 7 },
|
||||||
|
{ pattern: "(t*a&t>>b)|(t*c&t>>d)", weight: 9 },
|
||||||
|
{ pattern: "(t>>a)&(t>>b)", weight: 6 },
|
||||||
|
{ pattern: "t*(a&t>>b)|(t>>c)", weight: 7 },
|
||||||
|
{ pattern: "(t*a&t>>S1)|(t*b&t>>S2)", weight: 8 },
|
||||||
|
{ pattern: "t&(t>>a)|(t>>b)", weight: 6 },
|
||||||
|
{ pattern: "(t*c&t>>a)&(t>>b)", weight: 6 },
|
||||||
|
{ pattern: "t*(t>>a&t>>b)", weight: 6 },
|
||||||
|
{ pattern: "((t>>a)&N)|(t*b&t>>c)", weight: 7 },
|
||||||
|
|
||||||
|
{ pattern: "t&N?(t*a&t>>b):(t>>c)", weight: 7 },
|
||||||
|
{ pattern: "(t>>a)&N?(t*b):(t*c)", weight: 7 },
|
||||||
|
{ pattern: "t&M?(t>>a|t>>b):(t>>c&t>>d)", weight: 6 },
|
||||||
|
{ pattern: "(t>>S)&N?(t*a):(t>>b)", weight: 6 },
|
||||||
|
{ pattern: "t%(M)?(t>>a):(t*b&t>>c)", weight: 6 },
|
||||||
|
{ pattern: "t&d?(t*a&t>>S):(t>>b)", weight: 6 },
|
||||||
|
{ pattern: "(t>>a)&(t>>b)?(t*c):(t>>S)", weight: 5 },
|
||||||
|
{ pattern: "t&M?(t>>a)^(t>>b):(t*c)", weight: 5 },
|
||||||
|
|
||||||
|
{ pattern: "t*a%(M)", weight: 6 },
|
||||||
|
{ pattern: "(t*a)%(M1)+(t*b)%(M2)", weight: 7 },
|
||||||
|
{ pattern: "t*a&(t>>b)%(M)", weight: 6 },
|
||||||
|
{ pattern: "(t*c%(M))&(t>>a)", weight: 6 },
|
||||||
|
{ pattern: "t*a+(t*b&t>>c)", weight: 6 },
|
||||||
|
{ pattern: "(t*a&t>>S)+(t*b%(M))", weight: 7 },
|
||||||
|
{ pattern: "t*b%(M)*(t>>a)", weight: 6 },
|
||||||
|
{ pattern: "(t*a|t*b)&(t>>c)", weight: 6 },
|
||||||
|
{ pattern: "t*c&((t>>a)|(t>>b))", weight: 6 },
|
||||||
|
{ pattern: "(t*a%(M1))^(t*b%(M2))", weight: 6 },
|
||||||
|
|
||||||
|
{ pattern: "(t>>a)^(t>>b)^(t>>c)", weight: 6 },
|
||||||
|
{ pattern: "t^(t>>a)^(t*b)", weight: 6 },
|
||||||
|
{ pattern: "((t>>a)^(t>>b))&(t*c)", weight: 6 },
|
||||||
|
{ pattern: "(t^t>>a)*(t^t>>b)", weight: 6 },
|
||||||
|
{ pattern: "t^(t>>a)&(t>>b)&(t>>c)", weight: 5 },
|
||||||
|
{ pattern: "((t>>a)^N)*((t>>b)^M)", weight: 6 },
|
||||||
|
{ pattern: "(t*a)^(t>>b)^(t>>c)", weight: 6 },
|
||||||
|
{ pattern: "t^(t*a>>b)^(t>>c)", weight: 5 },
|
||||||
|
{ pattern: "((t^t>>a)&N)|(t>>b)", weight: 5 },
|
||||||
|
{ pattern: "(t>>a)^(t*b&t>>c)", weight: 6 },
|
||||||
|
|
||||||
|
{ pattern: "((t>>a)&(t>>b))*((t>>c)|(t*d))", weight: 7 },
|
||||||
|
{ pattern: "t*((t>>a|t>>b)&(t>>c|t*d))", weight: 7 },
|
||||||
|
{ pattern: "(t&(t>>a))*(t%(M))", weight: 6 },
|
||||||
|
{ pattern: "t/(D+(t>>a)&(t>>b))", weight: 5 },
|
||||||
|
{ pattern: "((t*a)&(t>>b))^((t*c)%(M))", weight: 6 },
|
||||||
|
{ pattern: "(t>>a|t*b)&((t>>c)^(t*d))", weight: 6 },
|
||||||
|
{ pattern: "t*(t>>a)%(M1)+(t>>b)%(M2)", weight: 7 },
|
||||||
|
{ pattern: "((t>>a)%(M))*((t*b)&(t>>c))", weight: 6 },
|
||||||
|
{ pattern: "t&((t>>a)|(t*b))^(t>>c)", weight: 6 },
|
||||||
|
{ pattern: "(t*a&N)|(t>>b&M)|(t*c)", weight: 7 }
|
||||||
]
|
]
|
||||||
|
|
||||||
function randomElement<T>(arr: T[]): T {
|
function randomElement<T>(arr: T[]): T {
|
||||||
@ -119,9 +186,9 @@ function fillTemplate(pattern: string): string {
|
|||||||
|
|
||||||
function applyParenthesizationRandomization(formula: string): string {
|
function applyParenthesizationRandomization(formula: string): string {
|
||||||
if (Math.random() < 0.2) {
|
if (Math.random() < 0.2) {
|
||||||
const operators = formula.match(/[\+\-\*\/\&\|\^]/g)
|
const operators = formula.match(/[+\-*/&|^]/g)
|
||||||
if (operators && operators.length > 0) {
|
if (operators && operators.length > 0) {
|
||||||
const parts = formula.split(/([+\-*\/&|^])/)
|
const parts = formula.split(/([+\-*/&|^])/)
|
||||||
if (parts.length >= 3) {
|
if (parts.length >= 3) {
|
||||||
const idx = Math.floor(Math.random() * (parts.length - 2) / 2) * 2
|
const idx = Math.floor(Math.random() * (parts.length - 2) / 2) * 2
|
||||||
parts[idx] = `(${parts[idx]})`
|
parts[idx] = `(${parts[idx]})`
|
||||||
@ -217,3 +284,22 @@ export function generateFormulaGrid(rows: number, cols: number, complexity: numb
|
|||||||
}
|
}
|
||||||
return grid
|
return grid
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function generateTileGrid(rows: number, cols: number, complexity: number = 1): TileState[][] {
|
||||||
|
const grid: TileState[][] = []
|
||||||
|
for (let i = 0; i < rows; i++) {
|
||||||
|
const row: TileState[] = []
|
||||||
|
for (let j = 0; j < cols; j++) {
|
||||||
|
const formula = generateRandomFormula(complexity)
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
return grid
|
||||||
|
}
|
||||||
@ -10,11 +10,6 @@ export function getBitDepthLabel(index: number): string {
|
|||||||
return labels[index] || '8bit'
|
return labels[index] || '8bit'
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getClipModeLabel(index: number): string {
|
|
||||||
const labels = ['Wrap', 'Clamp', 'Fold']
|
|
||||||
return labels[index] || 'Wrap'
|
|
||||||
}
|
|
||||||
|
|
||||||
export function getSampleRateLabel(index: number): string {
|
export function getSampleRateLabel(index: number): string {
|
||||||
return `${SAMPLE_RATES[index]}Hz`
|
return `${SAMPLE_RATES[index]}Hz`
|
||||||
}
|
}
|
||||||
20
src/utils/tileHelpers.ts
Normal file
20
src/utils/tileHelpers.ts
Normal file
@ -0,0 +1,20 @@
|
|||||||
|
import type { TileState } from '../types/tiles'
|
||||||
|
|
||||||
|
export type FocusedTile = { row: number; col: number } | 'custom'
|
||||||
|
|
||||||
|
export function getTileId(row: number, col: number): string {
|
||||||
|
return `${row}-${col}`
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isCustomTileFocused(focusedTile: FocusedTile): boolean {
|
||||||
|
return focusedTile === 'custom'
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isTileFocused(focusedTile: FocusedTile, row: number, col: number): boolean {
|
||||||
|
if (focusedTile === 'custom') return false
|
||||||
|
return focusedTile.row === row && focusedTile.col === col
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getTileFromGrid(tiles: TileState[][], row: number, col: number): TileState | undefined {
|
||||||
|
return tiles[row]?.[col]
|
||||||
|
}
|
||||||
180
src/utils/tileState.ts
Normal file
180
src/utils/tileState.ts
Normal file
@ -0,0 +1,180 @@
|
|||||||
|
import type { TileState } from '../types/tiles'
|
||||||
|
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>,
|
||||||
|
lfoConfigs?: LFOSettings
|
||||||
|
): TileState {
|
||||||
|
return {
|
||||||
|
formula,
|
||||||
|
engineParams: engineParams ?? { ...getDefaultEngineValues() },
|
||||||
|
effectParams: effectParams ?? { ...getDefaultEffectValues(), masterVolume: 75 },
|
||||||
|
lfoConfigs: lfoConfigs ?? getDefaultLFOValues()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createTileStateFromCurrent(formula: string): TileState {
|
||||||
|
return {
|
||||||
|
formula,
|
||||||
|
engineParams: { ...engineSettings.get() },
|
||||||
|
effectParams: { ...effectSettings.get() },
|
||||||
|
lfoConfigs: JSON.parse(JSON.stringify(lfoSettings.get()))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function loadTileParams(tile: TileState): void {
|
||||||
|
Object.entries(tile.engineParams).forEach(([key, value]) => {
|
||||||
|
engineSettings.setKey(key as keyof ReturnType<typeof getDefaultEngineValues>, value)
|
||||||
|
})
|
||||||
|
|
||||||
|
Object.entries(tile.effectParams).forEach(([key, value]) => {
|
||||||
|
effectSettings.setKey(key as never, value as never)
|
||||||
|
})
|
||||||
|
|
||||||
|
if (tile.lfoConfigs) {
|
||||||
|
Object.entries(tile.lfoConfigs).forEach(([key, value]) => {
|
||||||
|
lfoSettings.setKey(key as keyof LFOSettings, value)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function saveTileParams(tile: TileState): TileState {
|
||||||
|
return {
|
||||||
|
...tile,
|
||||||
|
engineParams: { ...engineSettings.get() },
|
||||||
|
effectParams: { ...effectSettings.get() },
|
||||||
|
lfoConfigs: JSON.parse(JSON.stringify(lfoSettings.get()))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function cloneTileState(tile: TileState): TileState {
|
||||||
|
return {
|
||||||
|
formula: tile.formula,
|
||||||
|
engineParams: { ...tile.engineParams },
|
||||||
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -1,6 +1,6 @@
|
|||||||
export function generateWaveformData(formula: string, width: number, sampleRate: number = 8000, duration: number = 0.5): number[] {
|
export function generateWaveformData(formula: string, width: number, sampleRate: number = 8000, duration: number = 0.5, a: number = 8, b: number = 16, c: number = 32, d: number = 64): number[] {
|
||||||
try {
|
try {
|
||||||
const compiledFormula = new Function('t', `return ${formula}`) as (t: number) => number
|
const compiledFormula = new Function('t', 'a', 'b', 'c', 'd', `return ${formula}`) as (t: number, a: number, b: number, c: number, d: number) => number
|
||||||
const samplesPerPixel = Math.floor((sampleRate * duration) / width)
|
const samplesPerPixel = Math.floor((sampleRate * duration) / width)
|
||||||
const waveform: number[] = []
|
const waveform: number[] = []
|
||||||
|
|
||||||
@ -10,16 +10,11 @@ export function generateWaveformData(formula: string, width: number, sampleRate:
|
|||||||
|
|
||||||
for (let s = 0; s < samplesPerPixel; s++) {
|
for (let s = 0; s < samplesPerPixel; s++) {
|
||||||
const t = x * samplesPerPixel + s
|
const t = x * samplesPerPixel + s
|
||||||
try {
|
const value = compiledFormula(t, a, b, c, d)
|
||||||
const value = compiledFormula(t)
|
|
||||||
const byteValue = value & 0xFF
|
const byteValue = value & 0xFF
|
||||||
const normalized = (byteValue - 128) / 128
|
const normalized = (byteValue - 128) / 128
|
||||||
min = Math.min(min, normalized)
|
min = Math.min(min, normalized)
|
||||||
max = Math.max(max, normalized)
|
max = Math.max(max, normalized)
|
||||||
} catch {
|
|
||||||
min = Math.min(min, 0)
|
|
||||||
max = Math.max(max, 0)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
waveform.push(min, max)
|
waveform.push(min, max)
|
||||||
@ -27,7 +22,7 @@ export function generateWaveformData(formula: string, width: number, sampleRate:
|
|||||||
|
|
||||||
return waveform
|
return waveform
|
||||||
} catch {
|
} catch {
|
||||||
return new Array(width * 2).fill(0)
|
return Array(width * 2).fill(0)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user