Adding new FM synthesis mode
This commit is contained in:
391
public/worklets/fm-processor.js
Normal file
391
public/worklets/fm-processor.js
Normal file
@ -0,0 +1,391 @@
|
||||
class FMProcessor extends AudioWorkletProcessor {
|
||||
constructor() {
|
||||
super()
|
||||
|
||||
this.phase1 = 0
|
||||
this.phase2 = 0
|
||||
this.phase3 = 0
|
||||
this.phase4 = 0
|
||||
this.baseFreq = 220
|
||||
this.algorithm = 0
|
||||
this.opLevel1 = 128
|
||||
this.opLevel2 = 128
|
||||
this.opLevel3 = 128
|
||||
this.opLevel4 = 128
|
||||
this.feedback = 0
|
||||
this.playbackRate = 1.0
|
||||
this.loopLength = 0
|
||||
this.sampleCount = 0
|
||||
this.feedbackSample = 0
|
||||
|
||||
this.frequencyRatios = [1.0, 1.0, 1.0, 1.0]
|
||||
|
||||
this.lfoPhase1 = 0
|
||||
this.lfoPhase2 = 0
|
||||
this.lfoPhase3 = 0
|
||||
this.lfoPhase4 = 0
|
||||
this.lfoRate1 = 0.37
|
||||
this.lfoRate2 = 0.53
|
||||
this.lfoRate3 = 0.71
|
||||
this.lfoRate4 = 0.43
|
||||
this.lfoDepth = 0.3
|
||||
|
||||
this.port.onmessage = (event) => {
|
||||
const { type, value } = event.data
|
||||
switch (type) {
|
||||
case 'algorithm':
|
||||
this.algorithm = value.id
|
||||
this.frequencyRatios = value.ratios
|
||||
if (value.lfoRates) {
|
||||
this.lfoRate1 = value.lfoRates[0]
|
||||
this.lfoRate2 = value.lfoRates[1]
|
||||
this.lfoRate3 = value.lfoRates[2]
|
||||
this.lfoRate4 = value.lfoRates[3]
|
||||
}
|
||||
break
|
||||
case 'operatorLevels':
|
||||
this.opLevel1 = value.a
|
||||
this.opLevel2 = value.b
|
||||
this.opLevel3 = value.c
|
||||
this.opLevel4 = value.d
|
||||
break
|
||||
case 'baseFreq':
|
||||
this.baseFreq = value
|
||||
break
|
||||
case 'feedback':
|
||||
this.feedback = value / 100.0
|
||||
break
|
||||
case 'reset':
|
||||
this.phase1 = 0
|
||||
this.phase2 = 0
|
||||
this.phase3 = 0
|
||||
this.phase4 = 0
|
||||
this.sampleCount = 0
|
||||
this.feedbackSample = 0
|
||||
break
|
||||
case 'loopLength':
|
||||
this.loopLength = value
|
||||
break
|
||||
case 'playbackRate':
|
||||
this.playbackRate = value
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
synthesize(algorithm) {
|
||||
const TWO_PI = Math.PI * 2
|
||||
const sampleRate = 44100
|
||||
|
||||
const freq1 = (this.baseFreq * this.frequencyRatios[0] * TWO_PI) / sampleRate
|
||||
const freq2 = (this.baseFreq * this.frequencyRatios[1] * TWO_PI) / sampleRate
|
||||
const freq3 = (this.baseFreq * this.frequencyRatios[2] * TWO_PI) / sampleRate
|
||||
const freq4 = (this.baseFreq * this.frequencyRatios[3] * TWO_PI) / sampleRate
|
||||
|
||||
const lfo1 = Math.sin(this.lfoPhase1)
|
||||
const lfo2 = Math.sin(this.lfoPhase2)
|
||||
const lfo3 = Math.sin(this.lfoPhase3)
|
||||
const lfo4 = Math.sin(this.lfoPhase4)
|
||||
|
||||
const level1 = (this.opLevel1 / 255.0) * (1 + this.lfoDepth * lfo1)
|
||||
const level2 = (this.opLevel2 / 255.0) * (1 + this.lfoDepth * lfo2)
|
||||
const level3 = (this.opLevel3 / 255.0) * (1 + this.lfoDepth * lfo3)
|
||||
const level4 = (this.opLevel4 / 255.0) * (1 + this.lfoDepth * lfo4)
|
||||
|
||||
this.lfoPhase1 += (this.lfoRate1 * TWO_PI) / sampleRate
|
||||
this.lfoPhase2 += (this.lfoRate2 * TWO_PI) / sampleRate
|
||||
this.lfoPhase3 += (this.lfoRate3 * TWO_PI) / sampleRate
|
||||
this.lfoPhase4 += (this.lfoRate4 * TWO_PI) / sampleRate
|
||||
|
||||
if (this.lfoPhase1 > TWO_PI) this.lfoPhase1 -= TWO_PI
|
||||
if (this.lfoPhase2 > TWO_PI) this.lfoPhase2 -= TWO_PI
|
||||
if (this.lfoPhase3 > TWO_PI) this.lfoPhase3 -= TWO_PI
|
||||
if (this.lfoPhase4 > TWO_PI) this.lfoPhase4 -= TWO_PI
|
||||
|
||||
let output = 0
|
||||
|
||||
switch (algorithm) {
|
||||
case 0: {
|
||||
const op1 = Math.sin(this.phase1) * level1
|
||||
const op2 = Math.sin(this.phase2) * level2
|
||||
const op3 = Math.sin(this.phase3) * level3
|
||||
const op4 = Math.sin(this.phase4) * level4
|
||||
output = (op1 + op2 + op3 + op4) * 0.25
|
||||
this.phase1 += freq1 * this.playbackRate
|
||||
this.phase2 += freq2 * this.playbackRate
|
||||
this.phase3 += freq3 * this.playbackRate
|
||||
this.phase4 += freq4 * this.playbackRate
|
||||
break
|
||||
}
|
||||
|
||||
case 1: {
|
||||
const op1 = Math.sin(this.phase1) * level1
|
||||
const mod1 = op1 * 10
|
||||
const op2 = Math.sin(this.phase2 + mod1) * level2
|
||||
const mod2 = op2 * 10
|
||||
const op3 = Math.sin(this.phase3 + mod2) * level3
|
||||
const mod3 = op3 * 10
|
||||
const op4 = Math.sin(this.phase4 + mod3 + this.feedbackSample * this.feedback * 10) * level4
|
||||
output = op4
|
||||
this.feedbackSample = op4
|
||||
this.phase1 += freq1 * this.playbackRate
|
||||
this.phase2 += freq2 * this.playbackRate
|
||||
this.phase3 += freq3 * this.playbackRate
|
||||
this.phase4 += freq4 * this.playbackRate
|
||||
break
|
||||
}
|
||||
|
||||
case 2: {
|
||||
const op1 = Math.sin(this.phase1) * level1
|
||||
const mod1 = op1 * 10
|
||||
const op2 = Math.sin(this.phase2 + mod1) * level2
|
||||
const op3 = Math.sin(this.phase3) * level3
|
||||
const mod3 = op3 * 10
|
||||
const op4 = Math.sin(this.phase4 + mod3) * level4
|
||||
output = (op2 + op4) * 0.5
|
||||
this.phase1 += freq1 * this.playbackRate
|
||||
this.phase2 += freq2 * this.playbackRate
|
||||
this.phase3 += freq3 * this.playbackRate
|
||||
this.phase4 += freq4 * this.playbackRate
|
||||
break
|
||||
}
|
||||
|
||||
case 3: {
|
||||
const op1 = Math.sin(this.phase1) * level1
|
||||
const mod1 = op1 * 10
|
||||
const op2 = Math.sin(this.phase2 + mod1) * level2
|
||||
const mod2 = op2 * 10
|
||||
const op3 = Math.sin(this.phase3 + mod2) * level3
|
||||
const op4 = Math.sin(this.phase4) * level4
|
||||
output = (op3 + op4) * 0.5
|
||||
this.phase1 += freq1 * this.playbackRate
|
||||
this.phase2 += freq2 * this.playbackRate
|
||||
this.phase3 += freq3 * this.playbackRate
|
||||
this.phase4 += freq4 * this.playbackRate
|
||||
break
|
||||
}
|
||||
|
||||
case 4: {
|
||||
const op1 = Math.sin(this.phase1) * level1
|
||||
const mod1 = op1 * 10
|
||||
const op2 = Math.sin(this.phase2) * level2
|
||||
const mod2 = op2 * 10
|
||||
const op3 = Math.sin(this.phase3 + mod1 + mod2) * level3
|
||||
const mod3 = op3 * 10
|
||||
const op4 = Math.sin(this.phase4 + mod3) * level4
|
||||
output = op4
|
||||
this.phase1 += freq1 * this.playbackRate
|
||||
this.phase2 += freq2 * this.playbackRate
|
||||
this.phase3 += freq3 * this.playbackRate
|
||||
this.phase4 += freq4 * this.playbackRate
|
||||
break
|
||||
}
|
||||
|
||||
case 5: {
|
||||
const op1 = Math.sin(this.phase1) * level1
|
||||
const mod1 = op1 * 10
|
||||
const op2 = Math.sin(this.phase2 + mod1) * level2
|
||||
const mod2 = op2 * 10
|
||||
const op3 = Math.sin(this.phase3 + mod1) * level3
|
||||
const mod3 = op3 * 10
|
||||
const op4 = Math.sin(this.phase4 + mod2 + mod3) * level4
|
||||
output = op4
|
||||
this.phase1 += freq1 * this.playbackRate
|
||||
this.phase2 += freq2 * this.playbackRate
|
||||
this.phase3 += freq3 * this.playbackRate
|
||||
this.phase4 += freq4 * this.playbackRate
|
||||
break
|
||||
}
|
||||
|
||||
case 6: {
|
||||
const op1 = Math.sin(this.phase1) * level1
|
||||
const mod1 = op1 * 10
|
||||
const op2 = Math.sin(this.phase2 + mod1) * level2
|
||||
const op3 = Math.sin(this.phase3) * level3
|
||||
const op4 = Math.sin(this.phase4) * level4
|
||||
output = (op2 + op3 + op4) * 0.333
|
||||
this.phase1 += freq1 * this.playbackRate
|
||||
this.phase2 += freq2 * this.playbackRate
|
||||
this.phase3 += freq3 * this.playbackRate
|
||||
this.phase4 += freq4 * this.playbackRate
|
||||
break
|
||||
}
|
||||
|
||||
case 7: {
|
||||
const op1 = Math.sin(this.phase1) * level1
|
||||
const mod1 = op1 * 10
|
||||
const op2 = Math.sin(this.phase2 + mod1) * level2
|
||||
const mod2 = op2 * 1.5
|
||||
const op3 = Math.sin(this.phase3 + mod1) * level3
|
||||
const mod3 = op3 * 1.5
|
||||
const op4 = Math.sin(this.phase4 + mod2 + mod3) * level4
|
||||
output = op4
|
||||
this.phase1 += freq1 * this.playbackRate
|
||||
this.phase2 += freq2 * this.playbackRate
|
||||
this.phase3 += freq3 * this.playbackRate
|
||||
this.phase4 += freq4 * this.playbackRate
|
||||
break
|
||||
}
|
||||
|
||||
case 8: {
|
||||
const op1 = Math.sin(this.phase1) * level1
|
||||
const mod1 = op1 * 10
|
||||
const op3 = Math.sin(this.phase3 + mod1) * level3
|
||||
const op2 = Math.sin(this.phase2) * level2
|
||||
const mod2 = op2 * 10
|
||||
const op4 = Math.sin(this.phase4 + mod2) * level4
|
||||
output = (op3 + op4) * 0.5
|
||||
this.phase1 += freq1 * this.playbackRate
|
||||
this.phase2 += freq2 * this.playbackRate
|
||||
this.phase3 += freq3 * this.playbackRate
|
||||
this.phase4 += freq4 * this.playbackRate
|
||||
break
|
||||
}
|
||||
|
||||
case 9: {
|
||||
const op1 = Math.sin(this.phase1) * level1
|
||||
const mod1 = op1 * 10
|
||||
const op4 = Math.sin(this.phase4 + mod1) * level4
|
||||
const op2 = Math.sin(this.phase2) * level2
|
||||
const mod2 = op2 * 10
|
||||
const op3 = Math.sin(this.phase3 + mod2) * level3
|
||||
output = (op3 + op4) * 0.5
|
||||
this.phase1 += freq1 * this.playbackRate
|
||||
this.phase2 += freq2 * this.playbackRate
|
||||
this.phase3 += freq3 * this.playbackRate
|
||||
this.phase4 += freq4 * this.playbackRate
|
||||
break
|
||||
}
|
||||
|
||||
case 10: {
|
||||
const op1 = Math.sin(this.phase1) * level1
|
||||
const mod1 = op1 * 10
|
||||
const op2 = Math.sin(this.phase2 + mod1) * level2
|
||||
const op3 = Math.sin(this.phase3) * level3
|
||||
const mod3 = op3 * 10
|
||||
const op4 = Math.sin(this.phase4 + mod3) * level4
|
||||
output = (op2 + op4) * 0.5
|
||||
this.phase1 += freq1 * this.playbackRate
|
||||
this.phase2 += freq2 * this.playbackRate
|
||||
this.phase3 += freq3 * this.playbackRate
|
||||
this.phase4 += freq4 * this.playbackRate
|
||||
break
|
||||
}
|
||||
|
||||
case 11: {
|
||||
const op1 = Math.sin(this.phase1) * level1
|
||||
const op2 = Math.sin(this.phase2) * level2
|
||||
const mod2 = op2 * 10
|
||||
const op3 = Math.sin(this.phase3 + mod2) * level3
|
||||
const mod3 = op3 * 10
|
||||
const op4 = Math.sin(this.phase4 + mod3) * level4
|
||||
output = (op1 + op4) * 0.5
|
||||
this.phase1 += freq1 * this.playbackRate
|
||||
this.phase2 += freq2 * this.playbackRate
|
||||
this.phase3 += freq3 * this.playbackRate
|
||||
this.phase4 += freq4 * this.playbackRate
|
||||
break
|
||||
}
|
||||
|
||||
case 12: {
|
||||
const op1 = Math.sin(this.phase1) * level1
|
||||
const mod1 = op1 * 10
|
||||
const op2 = Math.sin(this.phase2 + mod1) * level2
|
||||
const mod2 = op2 * 10
|
||||
const op4 = Math.sin(this.phase4 + mod2) * level4
|
||||
const op3 = Math.sin(this.phase3) * level3
|
||||
output = (op3 + op4) * 0.5
|
||||
this.phase1 += freq1 * this.playbackRate
|
||||
this.phase2 += freq2 * this.playbackRate
|
||||
this.phase3 += freq3 * this.playbackRate
|
||||
this.phase4 += freq4 * this.playbackRate
|
||||
break
|
||||
}
|
||||
|
||||
case 13: {
|
||||
const op1 = Math.sin(this.phase1) * level1
|
||||
const mod1 = op1 * 10
|
||||
const op2 = Math.sin(this.phase2 + mod1) * level2
|
||||
const mod2 = op2 * 10
|
||||
const op3 = Math.sin(this.phase3 + mod1) * level3
|
||||
const mod3 = op3 * 10
|
||||
const op4 = Math.sin(this.phase4 + mod2 + mod3) * level4
|
||||
output = op4
|
||||
this.phase1 += freq1 * this.playbackRate
|
||||
this.phase2 += freq2 * this.playbackRate
|
||||
this.phase3 += freq3 * this.playbackRate
|
||||
this.phase4 += freq4 * this.playbackRate
|
||||
break
|
||||
}
|
||||
|
||||
case 14: {
|
||||
const op1 = Math.sin(this.phase1) * level1
|
||||
const mod1 = op1 * 10
|
||||
const op3 = Math.sin(this.phase3) * level3
|
||||
const mod3 = op3 * 10
|
||||
const op4 = Math.sin(this.phase4 + mod3) * level4
|
||||
const mod4 = op4 * 1.5
|
||||
const op2 = Math.sin(this.phase2 + mod1 + mod4) * level2
|
||||
output = op2
|
||||
this.phase1 += freq1 * this.playbackRate
|
||||
this.phase2 += freq2 * this.playbackRate
|
||||
this.phase3 += freq3 * this.playbackRate
|
||||
this.phase4 += freq4 * this.playbackRate
|
||||
break
|
||||
}
|
||||
|
||||
case 15: {
|
||||
const op1 = Math.sin(this.phase1) * level1
|
||||
const mod1 = op1 * 10
|
||||
const op2 = Math.sin(this.phase2) * level2
|
||||
const mod2 = op2 * 10
|
||||
const op3 = Math.sin(this.phase3) * level3
|
||||
const mod3 = op3 * 10
|
||||
const op4 = Math.sin(this.phase4 + mod1 + mod2 + mod3) * level4
|
||||
output = op4
|
||||
this.phase1 += freq1 * this.playbackRate
|
||||
this.phase2 += freq2 * this.playbackRate
|
||||
this.phase3 += freq3 * this.playbackRate
|
||||
this.phase4 += freq4 * this.playbackRate
|
||||
break
|
||||
}
|
||||
|
||||
default:
|
||||
output = 0
|
||||
}
|
||||
|
||||
const TWO_PI_LIMIT = TWO_PI * 10
|
||||
if (this.phase1 > TWO_PI_LIMIT) this.phase1 -= TWO_PI_LIMIT
|
||||
if (this.phase2 > TWO_PI_LIMIT) this.phase2 -= TWO_PI_LIMIT
|
||||
if (this.phase3 > TWO_PI_LIMIT) this.phase3 -= TWO_PI_LIMIT
|
||||
if (this.phase4 > TWO_PI_LIMIT) this.phase4 -= TWO_PI_LIMIT
|
||||
|
||||
return output
|
||||
}
|
||||
|
||||
process(inputs, outputs) {
|
||||
const output = outputs[0]
|
||||
|
||||
if (output.length > 0) {
|
||||
const outputChannel = output[0]
|
||||
|
||||
for (let i = 0; i < outputChannel.length; i++) {
|
||||
outputChannel[i] = this.synthesize(this.algorithm)
|
||||
|
||||
this.sampleCount++
|
||||
if (this.loopLength > 0 && this.sampleCount >= this.loopLength) {
|
||||
this.phase1 = 0
|
||||
this.phase2 = 0
|
||||
this.phase3 = 0
|
||||
this.phase4 = 0
|
||||
this.sampleCount = 0
|
||||
this.feedbackSample = 0
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
registerProcessor('fm-processor', FMProcessor)
|
||||
105
src/App.tsx
105
src/App.tsx
@ -4,6 +4,7 @@ import { Square, Archive, Dices, Sparkles } from 'lucide-react'
|
||||
import { PlaybackManager } from './services/PlaybackManager'
|
||||
import { DownloadService } from './services/DownloadService'
|
||||
import { generateTileGrid, generateRandomFormula } from './utils/bytebeatFormulas'
|
||||
import { generateFMTileGrid, generateRandomFMPatch, createFMTileState } from './utils/fmPatches'
|
||||
import { BytebeatTile } from './components/BytebeatTile'
|
||||
import { EffectsBar } from './components/EffectsBar'
|
||||
import { EngineControls } from './components/EngineControls'
|
||||
@ -13,6 +14,7 @@ import { AudioContextWarning } from './components/AudioContextWarning'
|
||||
import { HelpModal } from './components/HelpModal'
|
||||
import { getSampleRateFromIndex } from './config/effects'
|
||||
import { engineSettings, effectSettings, lfoSettings, type LFOConfig } from './stores/settings'
|
||||
import { synthesisMode, setSynthesisMode } from './stores/synthesisMode'
|
||||
import { exitMappingMode } from './stores/mappingMode'
|
||||
import { useKeyboardShortcuts } from './hooks/useKeyboardShortcuts'
|
||||
import { useTileParams } from './hooks/useTileParams'
|
||||
@ -24,9 +26,12 @@ import { getTileId, getTileFromGrid, type FocusedTile } from './utils/tileHelper
|
||||
function App() {
|
||||
const engineValues = useStore(engineSettings)
|
||||
const effectValues = useStore(effectSettings)
|
||||
const mode = useStore(synthesisMode)
|
||||
|
||||
const [tiles, setTiles] = useState<TileState[][]>(() =>
|
||||
generateTileGrid(TILE_GRID.SIZE, TILE_GRID.COLUMNS, engineValues.complexity)
|
||||
mode === 'fm'
|
||||
? generateFMTileGrid(TILE_GRID.SIZE, TILE_GRID.COLUMNS, engineValues.complexity)
|
||||
: generateTileGrid(TILE_GRID.SIZE, TILE_GRID.COLUMNS, engineValues.complexity)
|
||||
)
|
||||
const [playing, setPlaying] = useState<string | null>(null)
|
||||
const [queued, setQueued] = useState<string | null>(null)
|
||||
@ -81,10 +86,32 @@ function App() {
|
||||
|
||||
const handleRandom = () => {
|
||||
clearSwitchTimer()
|
||||
if (mode === 'fm') {
|
||||
setTiles(generateFMTileGrid(TILE_GRID.SIZE, TILE_GRID.COLUMNS, engineValues.complexity))
|
||||
} else {
|
||||
setTiles(generateTileGrid(TILE_GRID.SIZE, TILE_GRID.COLUMNS, engineValues.complexity))
|
||||
}
|
||||
setQueued(null)
|
||||
}
|
||||
|
||||
const handleModeToggle = () => {
|
||||
const newMode = mode === 'bytebeat' ? 'fm' : 'bytebeat'
|
||||
handleStop()
|
||||
setSynthesisMode(newMode)
|
||||
|
||||
if (playbackManagerRef.current) {
|
||||
playbackManagerRef.current.setMode(newMode)
|
||||
playbackManagerRef.current.setAlgorithm(engineValues.fmAlgorithm ?? 0)
|
||||
playbackManagerRef.current.setFeedback(engineValues.fmFeedback ?? 0)
|
||||
}
|
||||
|
||||
if (newMode === 'fm') {
|
||||
setTiles(generateFMTileGrid(TILE_GRID.SIZE, TILE_GRID.COLUMNS, engineValues.complexity))
|
||||
} else {
|
||||
setTiles(generateTileGrid(TILE_GRID.SIZE, TILE_GRID.COLUMNS, engineValues.complexity))
|
||||
}
|
||||
}
|
||||
|
||||
const handleRandomizeAllParams = () => {
|
||||
clearSwitchTimer()
|
||||
let newRandomized: TileState | null = null
|
||||
@ -145,6 +172,7 @@ function App() {
|
||||
|
||||
if (!playbackManagerRef.current) {
|
||||
playbackManagerRef.current = new PlaybackManager({ sampleRate, duration })
|
||||
playbackManagerRef.current.setMode(mode)
|
||||
} else {
|
||||
await playbackManagerRef.current.updateOptions({ sampleRate, duration })
|
||||
}
|
||||
@ -158,6 +186,10 @@ function App() {
|
||||
engineValues.d ?? DEFAULT_VARIABLES.d
|
||||
)
|
||||
playbackManagerRef.current.setPitch(engineValues.pitch ?? 1.0)
|
||||
const fmPatch = mode === 'fm' ? JSON.parse(formula) : null
|
||||
const lfoRates = fmPatch?.lfoRates || undefined
|
||||
playbackManagerRef.current.setAlgorithm(engineValues.fmAlgorithm ?? 0, lfoRates)
|
||||
playbackManagerRef.current.setFeedback(engineValues.fmFeedback ?? 0)
|
||||
|
||||
await playbackManagerRef.current.play(formula)
|
||||
setPlaying(id)
|
||||
@ -206,6 +238,14 @@ function App() {
|
||||
playbackManagerRef.current.setPitch(value)
|
||||
}
|
||||
|
||||
if (parameterId === 'fmAlgorithm' && playbackManagerRef.current) {
|
||||
playbackManagerRef.current.setAlgorithm(value)
|
||||
}
|
||||
|
||||
if (parameterId === 'fmFeedback' && playbackManagerRef.current) {
|
||||
playbackManagerRef.current.setFeedback(value)
|
||||
}
|
||||
|
||||
if (['a', 'b', 'c', 'd'].includes(parameterId) && playbackManagerRef.current && playing) {
|
||||
const updatedValues = { ...engineValues, [parameterId]: value }
|
||||
playbackManagerRef.current.setVariables(
|
||||
@ -324,8 +364,15 @@ function App() {
|
||||
}
|
||||
|
||||
const handleRegenerate = (row: number, col: number) => {
|
||||
let newTile: TileState
|
||||
|
||||
if (mode === 'fm') {
|
||||
const patch = generateRandomFMPatch(engineValues.complexity)
|
||||
newTile = createFMTileState(patch)
|
||||
} else {
|
||||
const newFormula = generateRandomFormula(engineValues.complexity)
|
||||
const newTile = createTileStateFromCurrent(newFormula)
|
||||
newTile = createTileStateFromCurrent(newFormula)
|
||||
}
|
||||
|
||||
setTiles(prevTiles => {
|
||||
const newTiles = [...prevTiles]
|
||||
@ -547,7 +594,30 @@ function App() {
|
||||
>
|
||||
BRUITISTE
|
||||
</h1>
|
||||
<div className="flex gap-1 border-2 border-white">
|
||||
<div className="flex gap-1">
|
||||
<div className="flex border-2 border-white">
|
||||
<button
|
||||
onClick={handleModeToggle}
|
||||
className={`px-2 py-1 font-mono text-[7px] tracking-[0.15em] transition-colors ${
|
||||
mode === 'bytebeat'
|
||||
? 'bg-white text-black'
|
||||
: 'bg-black text-white hover:bg-white/10'
|
||||
}`}
|
||||
>
|
||||
1-BIT
|
||||
</button>
|
||||
<button
|
||||
onClick={handleModeToggle}
|
||||
className={`px-2 py-1 font-mono text-[7px] tracking-[0.15em] transition-colors ${
|
||||
mode === 'fm'
|
||||
? 'bg-white text-black'
|
||||
: 'bg-black text-white hover:bg-white/10'
|
||||
}`}
|
||||
>
|
||||
4OP-FM
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex border-2 border-white">
|
||||
<button
|
||||
onClick={() => setMobileHeaderTab('global')}
|
||||
className={`px-2 py-1 font-mono text-[8px] tracking-[0.15em] transition-colors ${
|
||||
@ -580,6 +650,7 @@ function App() {
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{mobileHeaderTab === 'global' && (
|
||||
<div className="flex gap-2">
|
||||
@ -635,12 +706,36 @@ function App() {
|
||||
|
||||
{/* Desktop header */}
|
||||
<div className="hidden lg:flex items-center gap-4">
|
||||
<div className="flex flex-col gap-1 flex-shrink-0">
|
||||
<h1
|
||||
onClick={() => setShowHelp(true)}
|
||||
className="font-mono text-sm tracking-[0.3em] text-white flex-shrink-0 cursor-pointer hover:opacity-70 transition-opacity"
|
||||
className="font-mono text-sm tracking-[0.3em] text-white cursor-pointer hover:opacity-70 transition-opacity"
|
||||
>
|
||||
BRUITISTE
|
||||
</h1>
|
||||
<div className="flex border-2 border-white w-fit">
|
||||
<button
|
||||
onClick={handleModeToggle}
|
||||
className={`px-2 py-0.5 font-mono text-[7px] tracking-[0.15em] transition-colors whitespace-nowrap ${
|
||||
mode === 'bytebeat'
|
||||
? 'bg-white text-black'
|
||||
: 'bg-black text-white hover:bg-white/10'
|
||||
}`}
|
||||
>
|
||||
1-BIT
|
||||
</button>
|
||||
<button
|
||||
onClick={handleModeToggle}
|
||||
className={`px-2 py-0.5 font-mono text-[7px] tracking-[0.15em] transition-colors whitespace-nowrap ${
|
||||
mode === 'fm'
|
||||
? 'bg-white text-black'
|
||||
: 'bg-black text-white hover:bg-white/10'
|
||||
}`}
|
||||
>
|
||||
4OP-FM
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<EngineControls
|
||||
values={engineValues}
|
||||
@ -687,6 +782,7 @@ function App() {
|
||||
<LFOPanel onChange={handleLFOChange} onUpdateDepth={handleUpdateMappingDepth} onRemoveMapping={handleRemoveMapping} />
|
||||
|
||||
<div className="flex-1 flex flex-col overflow-auto bg-white">
|
||||
{mode === 'bytebeat' && (
|
||||
<div className="grid grid-cols-1 lg:grid-cols-4 gap-[1px] bg-white p-[1px]">
|
||||
<div className="col-span-1 lg:col-span-4">
|
||||
<FormulaEditor
|
||||
@ -700,6 +796,7 @@ function App() {
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex-1 grid grid-cols-1 lg:grid-cols-4 auto-rows-min gap-[1px] bg-white p-[1px]">
|
||||
{tiles.map((row, i) =>
|
||||
|
||||
@ -1,6 +1,11 @@
|
||||
import { useRef, useEffect } from 'react'
|
||||
import { useStore } from '@nanostores/react'
|
||||
import { Download, Dices } from 'lucide-react'
|
||||
import { generateWaveformData, drawWaveform } from '../utils/waveformGenerator'
|
||||
import { generateFMWaveformData } from '../utils/fmWaveformGenerator'
|
||||
import { synthesisMode } from '../stores/synthesisMode'
|
||||
import { parseFMPatch } from '../utils/fmPatches'
|
||||
import { getAlgorithmName } from '../config/fmAlgorithms'
|
||||
|
||||
interface BytebeatTileProps {
|
||||
formula: string
|
||||
@ -22,6 +27,7 @@ interface 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 mode = useStore(synthesisMode)
|
||||
|
||||
useEffect(() => {
|
||||
const canvas = canvasRef.current
|
||||
@ -31,10 +37,19 @@ export function BytebeatTile({ formula, row, col, isPlaying, isQueued, isFocused
|
||||
canvas.width = rect.width * window.devicePixelRatio
|
||||
canvas.height = rect.height * window.devicePixelRatio
|
||||
|
||||
const waveformData = generateWaveformData(formula, canvas.width, 8000, 0.5, a, b, c, d)
|
||||
let waveformData: number[]
|
||||
if (mode === 'fm') {
|
||||
const fmPatch = parseFMPatch(formula)
|
||||
const algorithmId = fmPatch?.algorithm ?? 0
|
||||
const feedback = fmPatch?.feedback ?? 0
|
||||
waveformData = generateFMWaveformData(algorithmId, a, b, c, d, feedback, canvas.width, 8000, 0.5)
|
||||
} else {
|
||||
waveformData = generateWaveformData(formula, canvas.width, 8000, 0.5, a, b, c, d)
|
||||
}
|
||||
|
||||
const color = isPlaying ? 'rgba(0, 0, 0, 0.3)' : 'rgba(255, 255, 255, 0.35)'
|
||||
drawWaveform(canvas, waveformData, color)
|
||||
}, [formula, isPlaying, isQueued, a, b, c, d])
|
||||
}, [formula, isPlaying, isQueued, a, b, c, d, mode])
|
||||
|
||||
const handleDownload = (e: React.MouseEvent) => {
|
||||
e.stopPropagation()
|
||||
@ -66,7 +81,11 @@ export function BytebeatTile({ formula, row, col, isPlaying, isQueued, isFocused
|
||||
/>
|
||||
)}
|
||||
<div className="text-xs break-all font-light flex-1 relative z-10">
|
||||
{formula}
|
||||
{mode === 'fm' ? (() => {
|
||||
const fmPatch = parseFMPatch(formula)
|
||||
const algorithmName = getAlgorithmName(fmPatch?.algorithm ?? 0)
|
||||
return `${algorithmName} [${a},${b},${c},${d}] FB:${fmPatch?.feedback ?? 0}`
|
||||
})() : formula}
|
||||
</div>
|
||||
<div className="flex gap-1 flex-shrink-0 relative z-10">
|
||||
<div
|
||||
|
||||
@ -1,7 +1,9 @@
|
||||
import { useStore } from '@nanostores/react'
|
||||
import { ENGINE_CONTROLS } from '../config/effects'
|
||||
import { getComplexityLabel, getBitDepthLabel, getSampleRateLabel } from '../utils/formatters'
|
||||
import { getComplexityLabel, getBitDepthLabel, getSampleRateLabel, getAlgorithmLabel } from '../utils/formatters'
|
||||
import type { EffectValues } from '../types/effects'
|
||||
import { Knob } from './Knob'
|
||||
import { synthesisMode } from '../stores/synthesisMode'
|
||||
|
||||
interface EngineControlsProps {
|
||||
values: EffectValues
|
||||
@ -15,6 +17,8 @@ interface EngineControlsProps {
|
||||
const KNOB_PARAMS = ['masterVolume', 'pitch', 'a', 'b', 'c', 'd']
|
||||
|
||||
export function EngineControls({ values, onChange, onMapClick, getMappedLFOs, showOnlySliders, showOnlyKnobs }: EngineControlsProps) {
|
||||
const mode = useStore(synthesisMode)
|
||||
|
||||
const formatValue = (id: string, value: number): string => {
|
||||
switch (id) {
|
||||
case 'sampleRate':
|
||||
@ -23,6 +27,8 @@ export function EngineControls({ values, onChange, onMapClick, getMappedLFOs, sh
|
||||
return getComplexityLabel(value)
|
||||
case 'bitDepth':
|
||||
return getBitDepthLabel(value)
|
||||
case 'fmAlgorithm':
|
||||
return getAlgorithmLabel(value)
|
||||
default: {
|
||||
const param = ENGINE_CONTROLS[0].parameters.find(p => p.id === id)
|
||||
return `${value}${param?.unit || ''}`
|
||||
@ -35,6 +41,9 @@ export function EngineControls({ values, onChange, onMapClick, getMappedLFOs, sh
|
||||
{ENGINE_CONTROLS[0].parameters.map(param => {
|
||||
const useKnob = KNOB_PARAMS.includes(param.id)
|
||||
|
||||
if (mode === 'bytebeat' && (param.id === 'fmAlgorithm' || param.id === 'fmFeedback')) return null
|
||||
if (mode === 'fm' && (param.id === 'complexity' || param.id === 'bitDepth')) return null
|
||||
|
||||
if (showOnlySliders && useKnob) return null
|
||||
if (showOnlyKnobs && !useKnob) return null
|
||||
|
||||
|
||||
@ -94,6 +94,24 @@ export const ENGINE_CONTROLS: EffectConfig[] = [
|
||||
default: 64,
|
||||
step: 1,
|
||||
unit: ''
|
||||
},
|
||||
{
|
||||
id: 'fmAlgorithm',
|
||||
label: 'Algorithm',
|
||||
min: 0,
|
||||
max: 15,
|
||||
default: 0,
|
||||
step: 1,
|
||||
unit: ''
|
||||
},
|
||||
{
|
||||
id: 'fmFeedback',
|
||||
label: 'Feedback',
|
||||
min: 0,
|
||||
max: 100,
|
||||
default: 0,
|
||||
step: 1,
|
||||
unit: '%'
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
97
src/config/fmAlgorithms.ts
Normal file
97
src/config/fmAlgorithms.ts
Normal file
@ -0,0 +1,97 @@
|
||||
export interface FMAlgorithm {
|
||||
id: number
|
||||
name: string
|
||||
frequencyRatios: [number, number, number, number]
|
||||
}
|
||||
|
||||
export const FM_ALGORITHMS: FMAlgorithm[] = [
|
||||
{
|
||||
id: 0,
|
||||
name: 'Algo 0',
|
||||
frequencyRatios: [1.0, 1.0, 1.0, 1.0]
|
||||
},
|
||||
{
|
||||
id: 1,
|
||||
name: 'Algo 1',
|
||||
frequencyRatios: [1.0, 1.0, 1.0, 1.0]
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
name: 'Algo 2',
|
||||
frequencyRatios: [1.0, 2.0, 1.0, 2.0]
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
name: 'Algo 3',
|
||||
frequencyRatios: [1.0, 2.0, 3.0, 1.0]
|
||||
},
|
||||
{
|
||||
id: 4,
|
||||
name: 'Algo 4',
|
||||
frequencyRatios: [1.0, 1.414, 2.0, 2.828]
|
||||
},
|
||||
{
|
||||
id: 5,
|
||||
name: 'Algo 5',
|
||||
frequencyRatios: [1.0, 2.0, 3.0, 4.0]
|
||||
},
|
||||
{
|
||||
id: 6,
|
||||
name: 'Algo 6',
|
||||
frequencyRatios: [1.0, 2.5, 1.0, 1.0]
|
||||
},
|
||||
{
|
||||
id: 7,
|
||||
name: 'Algo 7',
|
||||
frequencyRatios: [1.0, 1.5, 1.5, 2.0]
|
||||
},
|
||||
{
|
||||
id: 8,
|
||||
name: 'Algo 8',
|
||||
frequencyRatios: [1.0, 1.0, 2.0, 2.0]
|
||||
},
|
||||
{
|
||||
id: 9,
|
||||
name: 'Algo 9',
|
||||
frequencyRatios: [1.0, 1.0, 2.0, 1.5]
|
||||
},
|
||||
{
|
||||
id: 10,
|
||||
name: 'Algo 10',
|
||||
frequencyRatios: [1.0, 1.0, 1.0, 2.0]
|
||||
},
|
||||
{
|
||||
id: 11,
|
||||
name: 'Algo 11',
|
||||
frequencyRatios: [1.0, 1.0, 2.0, 3.0]
|
||||
},
|
||||
{
|
||||
id: 12,
|
||||
name: 'Algo 12',
|
||||
frequencyRatios: [1.0, 1.5, 1.0, 2.0]
|
||||
},
|
||||
{
|
||||
id: 13,
|
||||
name: 'Algo 13',
|
||||
frequencyRatios: [1.0, 2.0, 3.0, 4.0]
|
||||
},
|
||||
{
|
||||
id: 14,
|
||||
name: 'Algo 14',
|
||||
frequencyRatios: [1.0, 2.0, 1.5, 2.5]
|
||||
},
|
||||
{
|
||||
id: 15,
|
||||
name: 'Algo 15',
|
||||
frequencyRatios: [1.0, 1.414, 1.732, 2.0]
|
||||
}
|
||||
]
|
||||
|
||||
export function getAlgorithmById(id: number): FMAlgorithm {
|
||||
return FM_ALGORITHMS[id % FM_ALGORITHMS.length] || FM_ALGORITHMS[0]
|
||||
}
|
||||
|
||||
export function getAlgorithmName(id: number): string {
|
||||
const alg = getAlgorithmById(id)
|
||||
return `${alg.name}`
|
||||
}
|
||||
@ -1,8 +1,11 @@
|
||||
import { EffectsChain } from './effects/EffectsChain'
|
||||
import { BytebeatSourceEffect } from './effects/BytebeatSourceEffect'
|
||||
import { FMSourceEffect } from './effects/FMSourceEffect'
|
||||
import { ModulationEngine } from '../modulation/ModulationEngine'
|
||||
import type { LFOWaveform } from '../modulation/LFO'
|
||||
import type { EffectValues } from '../../types/effects'
|
||||
import type { SynthesisMode } from '../../stores/synthesisMode'
|
||||
import { getAlgorithmById } from '../../config/fmAlgorithms'
|
||||
|
||||
export interface AudioPlayerOptions {
|
||||
sampleRate: number
|
||||
@ -12,6 +15,7 @@ export interface AudioPlayerOptions {
|
||||
export class AudioPlayer {
|
||||
private audioContext: AudioContext | null = null
|
||||
private bytebeatSource: BytebeatSourceEffect | null = null
|
||||
private fmSource: FMSourceEffect | null = null
|
||||
private effectsChain: EffectsChain | null = null
|
||||
private modulationEngine: ModulationEngine | null = null
|
||||
private effectValues: EffectValues = {}
|
||||
@ -20,6 +24,9 @@ export class AudioPlayer {
|
||||
private duration: number
|
||||
private workletRegistered: boolean = false
|
||||
private currentPitch: number = 1.0
|
||||
private currentMode: SynthesisMode = 'bytebeat'
|
||||
private currentAlgorithm: number = 0
|
||||
private currentFeedback: number = 0
|
||||
|
||||
constructor(options: AudioPlayerOptions) {
|
||||
this.sampleRate = options.sampleRate
|
||||
@ -66,6 +73,7 @@ export class AudioPlayer {
|
||||
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/fm-processor.js'),
|
||||
context.audioWorklet.addModule('/worklets/output-limiter.js')
|
||||
])
|
||||
this.workletRegistered = true
|
||||
@ -141,9 +149,29 @@ export class AudioPlayer {
|
||||
}
|
||||
}
|
||||
|
||||
setMode(mode: SynthesisMode): void {
|
||||
this.currentMode = mode
|
||||
}
|
||||
|
||||
setAlgorithm(algorithmId: number, lfoRates?: number[]): void {
|
||||
this.currentAlgorithm = algorithmId
|
||||
if (this.fmSource) {
|
||||
const algorithm = getAlgorithmById(algorithmId)
|
||||
this.fmSource.setAlgorithm(algorithm, lfoRates)
|
||||
}
|
||||
}
|
||||
|
||||
setFeedback(feedback: number): void {
|
||||
this.currentFeedback = feedback
|
||||
if (this.fmSource) {
|
||||
this.fmSource.setFeedback(feedback)
|
||||
}
|
||||
}
|
||||
|
||||
async playRealtime(formula: string, a: number, b: number, c: number, d: number): Promise<void> {
|
||||
await this.ensureAudioContext()
|
||||
|
||||
if (this.currentMode === 'bytebeat') {
|
||||
if (!this.bytebeatSource) {
|
||||
this.bytebeatSource = new BytebeatSourceEffect(this.audioContext!)
|
||||
await this.bytebeatSource.initialize(this.audioContext!)
|
||||
@ -156,6 +184,26 @@ export class AudioPlayer {
|
||||
this.bytebeatSource.reset()
|
||||
|
||||
this.bytebeatSource.getOutputNode().connect(this.effectsChain!.getInputNode())
|
||||
} else {
|
||||
if (!this.fmSource) {
|
||||
this.fmSource = new FMSourceEffect(this.audioContext!)
|
||||
await this.fmSource.initialize(this.audioContext!)
|
||||
}
|
||||
|
||||
this.fmSource.setLoopLength(this.sampleRate, this.duration)
|
||||
const algorithm = getAlgorithmById(this.currentAlgorithm)
|
||||
const patch = formula ? JSON.parse(formula) : null
|
||||
const lfoRates = patch?.lfoRates || undefined
|
||||
this.fmSource.setAlgorithm(algorithm, lfoRates)
|
||||
this.fmSource.setOperatorLevels(a, b, c, d)
|
||||
this.fmSource.setBaseFrequency(220 * this.currentPitch)
|
||||
this.fmSource.setFeedback(this.currentFeedback)
|
||||
this.fmSource.setPlaybackRate(1.0)
|
||||
this.fmSource.reset()
|
||||
|
||||
this.fmSource.getOutputNode().connect(this.effectsChain!.getInputNode())
|
||||
}
|
||||
|
||||
this.effectsChain!.getOutputNode().connect(this.audioContext!.destination)
|
||||
|
||||
if (this.modulationEngine) {
|
||||
@ -166,14 +214,18 @@ export class AudioPlayer {
|
||||
}
|
||||
|
||||
updateRealtimeVariables(a: number, b: number, c: number, d: number): void {
|
||||
if (this.bytebeatSource) {
|
||||
if (this.currentMode === 'bytebeat' && this.bytebeatSource) {
|
||||
this.bytebeatSource.setVariables(a, b, c, d)
|
||||
} else if (this.currentMode === 'fm' && this.fmSource) {
|
||||
this.fmSource.setOperatorLevels(a, b, c, d)
|
||||
}
|
||||
}
|
||||
|
||||
private applyPitch(pitch: number): void {
|
||||
if (this.bytebeatSource) {
|
||||
if (this.currentMode === 'bytebeat' && this.bytebeatSource) {
|
||||
this.bytebeatSource.setPlaybackRate(pitch)
|
||||
} else if (this.currentMode === 'fm' && this.fmSource) {
|
||||
this.fmSource.setBaseFrequency(220 * pitch)
|
||||
}
|
||||
}
|
||||
|
||||
@ -197,6 +249,9 @@ export class AudioPlayer {
|
||||
if (this.bytebeatSource) {
|
||||
this.bytebeatSource.getOutputNode().disconnect()
|
||||
}
|
||||
if (this.fmSource) {
|
||||
this.fmSource.getOutputNode().disconnect()
|
||||
}
|
||||
if (this.modulationEngine) {
|
||||
this.modulationEngine.stop()
|
||||
}
|
||||
@ -209,6 +264,10 @@ export class AudioPlayer {
|
||||
this.bytebeatSource.dispose()
|
||||
this.bytebeatSource = null
|
||||
}
|
||||
if (this.fmSource) {
|
||||
this.fmSource.dispose()
|
||||
this.fmSource = null
|
||||
}
|
||||
if (this.modulationEngine) {
|
||||
this.modulationEngine.dispose()
|
||||
this.modulationEngine = null
|
||||
|
||||
90
src/domain/audio/effects/FMSourceEffect.ts
Normal file
90
src/domain/audio/effects/FMSourceEffect.ts
Normal file
@ -0,0 +1,90 @@
|
||||
import type { Effect } from './Effect.interface'
|
||||
import type { FMAlgorithm } from '../../../config/fmAlgorithms'
|
||||
|
||||
export class FMSourceEffect implements Effect {
|
||||
readonly id = 'fm-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, 'fm-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
|
||||
}
|
||||
|
||||
setAlgorithm(algorithm: FMAlgorithm, lfoRates?: number[]): void {
|
||||
if (!this.processorNode) return
|
||||
this.processorNode.port.postMessage({
|
||||
type: 'algorithm',
|
||||
value: {
|
||||
id: algorithm.id,
|
||||
ratios: algorithm.frequencyRatios,
|
||||
lfoRates: lfoRates || [0.37, 0.53, 0.71, 0.43]
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
setOperatorLevels(a: number, b: number, c: number, d: number): void {
|
||||
if (!this.processorNode) return
|
||||
this.processorNode.port.postMessage({
|
||||
type: 'operatorLevels',
|
||||
value: { a, b, c, d }
|
||||
})
|
||||
}
|
||||
|
||||
setBaseFrequency(freq: number): void {
|
||||
if (!this.processorNode) return
|
||||
this.processorNode.port.postMessage({ type: 'baseFreq', value: freq })
|
||||
}
|
||||
|
||||
setFeedback(amount: number): void {
|
||||
if (!this.processorNode) return
|
||||
this.processorNode.port.postMessage({ type: 'feedback', value: amount })
|
||||
}
|
||||
|
||||
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()
|
||||
}
|
||||
}
|
||||
@ -1,6 +1,7 @@
|
||||
import { AudioPlayer } from '../domain/audio/AudioPlayer'
|
||||
import type { LFOWaveform } from '../domain/modulation/LFO'
|
||||
import type { EffectValues } from '../types/effects'
|
||||
import type { SynthesisMode } from '../stores/synthesisMode'
|
||||
import { DEFAULT_VARIABLES } from '../constants/defaults'
|
||||
|
||||
export interface PlaybackOptions {
|
||||
@ -40,6 +41,18 @@ export class PlaybackManager {
|
||||
this.player.updatePitch(pitch)
|
||||
}
|
||||
|
||||
setMode(mode: SynthesisMode): void {
|
||||
this.player.setMode(mode)
|
||||
}
|
||||
|
||||
setAlgorithm(algorithmId: number, lfoRates?: number[]): void {
|
||||
this.player.setAlgorithm(algorithmId, lfoRates)
|
||||
}
|
||||
|
||||
setFeedback(feedback: number): void {
|
||||
this.player.setFeedback(feedback)
|
||||
}
|
||||
|
||||
setPlaybackPositionCallback(callback: (position: number) => void): void {
|
||||
this.playbackPositionCallback = callback
|
||||
}
|
||||
|
||||
25
src/stores/synthesisMode.ts
Normal file
25
src/stores/synthesisMode.ts
Normal file
@ -0,0 +1,25 @@
|
||||
import { atom } from 'nanostores'
|
||||
|
||||
export type SynthesisMode = 'bytebeat' | 'fm'
|
||||
|
||||
const STORAGE_KEY = 'synthesisMode'
|
||||
|
||||
function loadFromStorage(): SynthesisMode {
|
||||
try {
|
||||
const stored = localStorage.getItem(STORAGE_KEY)
|
||||
return (stored === 'fm' || stored === 'bytebeat') ? stored : 'bytebeat'
|
||||
} catch {
|
||||
return 'bytebeat'
|
||||
}
|
||||
}
|
||||
|
||||
export const synthesisMode = atom<SynthesisMode>(loadFromStorage())
|
||||
|
||||
export function setSynthesisMode(mode: SynthesisMode): void {
|
||||
synthesisMode.set(mode)
|
||||
try {
|
||||
localStorage.setItem(STORAGE_KEY, mode)
|
||||
} catch {
|
||||
// Silently fail on storage errors
|
||||
}
|
||||
}
|
||||
89
src/utils/fmPatches.ts
Normal file
89
src/utils/fmPatches.ts
Normal file
@ -0,0 +1,89 @@
|
||||
import type { TileState } from '../types/tiles'
|
||||
import { getDefaultEngineValues, getDefaultEffectValues } from '../config/effects'
|
||||
import { getDefaultLFOValues } from '../stores/settings'
|
||||
|
||||
export interface FMPatchConfig {
|
||||
algorithm: number
|
||||
feedback: number
|
||||
lfoRates: [number, number, number, number]
|
||||
}
|
||||
|
||||
export function generateRandomFMPatch(complexity: number = 1): FMPatchConfig {
|
||||
let algorithmRange: number[]
|
||||
|
||||
switch (complexity) {
|
||||
case 0:
|
||||
algorithmRange = [0, 2, 6, 8, 9, 10]
|
||||
break
|
||||
case 2:
|
||||
algorithmRange = [1, 4, 5, 7, 11, 12, 13, 14, 15]
|
||||
break
|
||||
default:
|
||||
algorithmRange = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15]
|
||||
}
|
||||
|
||||
const algorithm = algorithmRange[Math.floor(Math.random() * algorithmRange.length)]
|
||||
const feedback = Math.floor(Math.random() * 100)
|
||||
|
||||
const lfoRates: [number, number, number, number] = [
|
||||
0.2 + Math.random() * 0.8,
|
||||
0.3 + Math.random() * 1.0,
|
||||
0.4 + Math.random() * 1.2,
|
||||
0.25 + Math.random() * 0.9
|
||||
]
|
||||
|
||||
return { algorithm, feedback, lfoRates }
|
||||
}
|
||||
|
||||
export function createFMTileState(patch: FMPatchConfig): TileState {
|
||||
const formula = JSON.stringify(patch)
|
||||
|
||||
const pitchValues = [0.25, 0.5, 0.75, 1.0, 1.25, 1.5, 2.0]
|
||||
const randomPitch = pitchValues[Math.floor(Math.random() * pitchValues.length)]
|
||||
|
||||
return {
|
||||
formula,
|
||||
engineParams: {
|
||||
...getDefaultEngineValues(),
|
||||
a: Math.floor(Math.random() * 128) + 64,
|
||||
b: Math.floor(Math.random() * 128) + 64,
|
||||
c: Math.floor(Math.random() * 128) + 64,
|
||||
d: Math.floor(Math.random() * 128) + 64,
|
||||
pitch: randomPitch,
|
||||
fmAlgorithm: patch.algorithm,
|
||||
fmFeedback: patch.feedback
|
||||
},
|
||||
effectParams: getDefaultEffectValues(),
|
||||
lfoConfigs: getDefaultLFOValues()
|
||||
}
|
||||
}
|
||||
|
||||
export function generateFMTileGrid(size: number, columns: number, complexity: number = 1): TileState[][] {
|
||||
const tiles: TileState[][] = []
|
||||
const rows = Math.ceil(size / columns)
|
||||
|
||||
for (let i = 0; i < rows; i++) {
|
||||
const row: TileState[] = []
|
||||
for (let j = 0; j < columns; j++) {
|
||||
if (i * columns + j < size) {
|
||||
const patch = generateRandomFMPatch(complexity)
|
||||
row.push(createFMTileState(patch))
|
||||
}
|
||||
}
|
||||
tiles.push(row)
|
||||
}
|
||||
|
||||
return tiles
|
||||
}
|
||||
|
||||
export function parseFMPatch(formula: string): FMPatchConfig | null {
|
||||
try {
|
||||
const parsed = JSON.parse(formula)
|
||||
if (typeof parsed.algorithm === 'number' && typeof parsed.feedback === 'number') {
|
||||
return parsed
|
||||
}
|
||||
} catch {
|
||||
// Not a valid FM patch
|
||||
}
|
||||
return null
|
||||
}
|
||||
252
src/utils/fmWaveformGenerator.ts
Normal file
252
src/utils/fmWaveformGenerator.ts
Normal file
@ -0,0 +1,252 @@
|
||||
import { getAlgorithmById } from '../config/fmAlgorithms'
|
||||
|
||||
export function generateFMWaveformData(
|
||||
algorithmId: number,
|
||||
a: number,
|
||||
b: number,
|
||||
c: number,
|
||||
d: number,
|
||||
feedback: number,
|
||||
width: number,
|
||||
sampleRate: number,
|
||||
duration: number
|
||||
): number[] {
|
||||
const algorithm = getAlgorithmById(algorithmId)
|
||||
const samples = Math.floor(sampleRate * duration)
|
||||
const data: number[] = []
|
||||
|
||||
const level1 = a / 255.0
|
||||
const level2 = b / 255.0
|
||||
const level3 = c / 255.0
|
||||
const level4 = d / 255.0
|
||||
const feedbackAmount = feedback / 100.0
|
||||
|
||||
const baseFreq = 220
|
||||
const TWO_PI = Math.PI * 2
|
||||
|
||||
const freq1 = (baseFreq * algorithm.frequencyRatios[0] * TWO_PI) / sampleRate
|
||||
const freq2 = (baseFreq * algorithm.frequencyRatios[1] * TWO_PI) / sampleRate
|
||||
const freq3 = (baseFreq * algorithm.frequencyRatios[2] * TWO_PI) / sampleRate
|
||||
const freq4 = (baseFreq * algorithm.frequencyRatios[3] * TWO_PI) / sampleRate
|
||||
|
||||
let phase1 = 0
|
||||
let phase2 = 0
|
||||
let phase3 = 0
|
||||
let phase4 = 0
|
||||
let feedbackSample = 0
|
||||
|
||||
for (let i = 0; i < samples; i++) {
|
||||
let output = 0
|
||||
|
||||
switch (algorithmId) {
|
||||
case 0: {
|
||||
const op1 = Math.sin(phase1) * level1
|
||||
const op2 = Math.sin(phase2) * level2
|
||||
const op3 = Math.sin(phase3) * level3
|
||||
const op4 = Math.sin(phase4) * level4
|
||||
output = (op1 + op2 + op3 + op4) * 0.25
|
||||
break
|
||||
}
|
||||
|
||||
case 1: {
|
||||
const op1 = Math.sin(phase1) * level1
|
||||
const mod1 = op1 * 10
|
||||
const op2 = Math.sin(phase2 + mod1) * level2
|
||||
const mod2 = op2 * 10
|
||||
const op3 = Math.sin(phase3 + mod2) * level3
|
||||
const mod3 = op3 * 10
|
||||
const op4 = Math.sin(phase4 + mod3 + feedbackSample * feedbackAmount) * level4
|
||||
output = op4
|
||||
feedbackSample = op4
|
||||
break
|
||||
}
|
||||
|
||||
case 2: {
|
||||
const op1 = Math.sin(phase1) * level1
|
||||
const mod1 = op1 * 10
|
||||
const op2 = Math.sin(phase2 + mod1) * level2
|
||||
const op3 = Math.sin(phase3) * level3
|
||||
const mod3 = op3 * 10
|
||||
const op4 = Math.sin(phase4 + mod3) * level4
|
||||
output = (op2 + op4) * 0.5
|
||||
break
|
||||
}
|
||||
|
||||
case 3: {
|
||||
const op1 = Math.sin(phase1) * level1
|
||||
const mod1 = op1 * 10
|
||||
const op2 = Math.sin(phase2 + mod1) * level2
|
||||
const mod2 = op2 * 10
|
||||
const op3 = Math.sin(phase3 + mod2) * level3
|
||||
const op4 = Math.sin(phase4) * level4
|
||||
output = (op3 + op4) * 0.5
|
||||
break
|
||||
}
|
||||
|
||||
case 4: {
|
||||
const op1 = Math.sin(phase1) * level1
|
||||
const mod1 = op1 * 10
|
||||
const op2 = Math.sin(phase2) * level2
|
||||
const mod2 = op2 * 10
|
||||
const op3 = Math.sin(phase3 + mod1 + mod2) * level3
|
||||
const mod3 = op3 * 10
|
||||
const op4 = Math.sin(phase4 + mod3) * level4
|
||||
output = op4
|
||||
break
|
||||
}
|
||||
|
||||
case 5: {
|
||||
const op1 = Math.sin(phase1) * level1
|
||||
const mod1 = op1 * 10
|
||||
const op2 = Math.sin(phase2 + mod1) * level2
|
||||
const mod2 = op2 * 10
|
||||
const op3 = Math.sin(phase3 + mod1) * level3
|
||||
const mod3 = op3 * 10
|
||||
const op4 = Math.sin(phase4 + mod2 + mod3) * level4
|
||||
output = op4
|
||||
break
|
||||
}
|
||||
|
||||
case 6: {
|
||||
const op1 = Math.sin(phase1) * level1
|
||||
const mod1 = op1 * 10
|
||||
const op2 = Math.sin(phase2 + mod1) * level2
|
||||
const op3 = Math.sin(phase3) * level3
|
||||
const op4 = Math.sin(phase4) * level4
|
||||
output = (op2 + op3 + op4) * 0.333
|
||||
break
|
||||
}
|
||||
|
||||
case 7: {
|
||||
const op1 = Math.sin(phase1) * level1
|
||||
const mod1 = op1 * 10
|
||||
const op2 = Math.sin(phase2 + mod1) * level2
|
||||
const mod2 = op2 * 1.5
|
||||
const op3 = Math.sin(phase3 + mod1) * level3
|
||||
const mod3 = op3 * 1.5
|
||||
const op4 = Math.sin(phase4 + mod2 + mod3) * level4
|
||||
output = op4
|
||||
break
|
||||
}
|
||||
|
||||
case 8: {
|
||||
const op1 = Math.sin(phase1) * level1
|
||||
const mod1 = op1 * 10
|
||||
const op3 = Math.sin(phase3 + mod1) * level3
|
||||
const op2 = Math.sin(phase2) * level2
|
||||
const mod2 = op2 * 10
|
||||
const op4 = Math.sin(phase4 + mod2) * level4
|
||||
output = (op3 + op4) * 0.5
|
||||
break
|
||||
}
|
||||
|
||||
case 9: {
|
||||
const op1 = Math.sin(phase1) * level1
|
||||
const mod1 = op1 * 10
|
||||
const op4 = Math.sin(phase4 + mod1) * level4
|
||||
const op2 = Math.sin(phase2) * level2
|
||||
const mod2 = op2 * 10
|
||||
const op3 = Math.sin(phase3 + mod2) * level3
|
||||
output = (op3 + op4) * 0.5
|
||||
break
|
||||
}
|
||||
|
||||
case 10: {
|
||||
const op1 = Math.sin(phase1) * level1
|
||||
const mod1 = op1 * 10
|
||||
const op2 = Math.sin(phase2 + mod1) * level2
|
||||
const op3 = Math.sin(phase3) * level3
|
||||
const mod3 = op3 * 10
|
||||
const op4 = Math.sin(phase4 + mod3) * level4
|
||||
output = (op2 + op4) * 0.5
|
||||
break
|
||||
}
|
||||
|
||||
case 11: {
|
||||
const op1 = Math.sin(phase1) * level1
|
||||
const op2 = Math.sin(phase2) * level2
|
||||
const mod2 = op2 * 10
|
||||
const op3 = Math.sin(phase3 + mod2) * level3
|
||||
const mod3 = op3 * 10
|
||||
const op4 = Math.sin(phase4 + mod3) * level4
|
||||
output = (op1 + op4) * 0.5
|
||||
break
|
||||
}
|
||||
|
||||
case 12: {
|
||||
const op1 = Math.sin(phase1) * level1
|
||||
const mod1 = op1 * 10
|
||||
const op2 = Math.sin(phase2 + mod1) * level2
|
||||
const mod2 = op2 * 10
|
||||
const op4 = Math.sin(phase4 + mod2) * level4
|
||||
const op3 = Math.sin(phase3) * level3
|
||||
output = (op3 + op4) * 0.5
|
||||
break
|
||||
}
|
||||
|
||||
case 13: {
|
||||
const op1 = Math.sin(phase1) * level1
|
||||
const mod1 = op1 * 10
|
||||
const op2 = Math.sin(phase2 + mod1) * level2
|
||||
const mod2 = op2 * 10
|
||||
const op3 = Math.sin(phase3 + mod1) * level3
|
||||
const mod3 = op3 * 10
|
||||
const op4 = Math.sin(phase4 + mod2 + mod3) * level4
|
||||
output = op4
|
||||
break
|
||||
}
|
||||
|
||||
case 14: {
|
||||
const op1 = Math.sin(phase1) * level1
|
||||
const mod1 = op1 * 10
|
||||
const op3 = Math.sin(phase3) * level3
|
||||
const mod3 = op3 * 10
|
||||
const op4 = Math.sin(phase4 + mod3) * level4
|
||||
const mod4 = op4 * 1.5
|
||||
const op2 = Math.sin(phase2 + mod1 + mod4) * level2
|
||||
output = op2
|
||||
break
|
||||
}
|
||||
|
||||
case 15: {
|
||||
const op1 = Math.sin(phase1) * level1
|
||||
const mod1 = op1 * 10
|
||||
const op2 = Math.sin(phase2) * level2
|
||||
const mod2 = op2 * 10
|
||||
const op3 = Math.sin(phase3) * level3
|
||||
const mod3 = op3 * 10
|
||||
const op4 = Math.sin(phase4 + mod1 + mod2 + mod3) * level4
|
||||
output = op4
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
data.push(output)
|
||||
|
||||
phase1 += freq1
|
||||
phase2 += freq2
|
||||
phase3 += freq3
|
||||
phase4 += freq4
|
||||
}
|
||||
|
||||
const samplesPerPixel = Math.max(1, Math.floor(samples / width))
|
||||
const downsampledData: number[] = []
|
||||
|
||||
for (let i = 0; i < width; i++) {
|
||||
let min = Infinity
|
||||
let max = -Infinity
|
||||
|
||||
for (let s = 0; s < samplesPerPixel; s++) {
|
||||
const index = i * samplesPerPixel + s
|
||||
if (index < data.length) {
|
||||
const value = data[index]
|
||||
min = Math.min(min, value)
|
||||
max = Math.max(max, value)
|
||||
}
|
||||
}
|
||||
|
||||
downsampledData.push(min === Infinity ? 0 : min, max === -Infinity ? 0 : max)
|
||||
}
|
||||
|
||||
return downsampledData
|
||||
}
|
||||
@ -1,4 +1,5 @@
|
||||
import { SAMPLE_RATES } from '../config/effects'
|
||||
import { getAlgorithmName } from '../config/fmAlgorithms'
|
||||
|
||||
export function getComplexityLabel(index: number): string {
|
||||
const labels = ['Simple', 'Medium', 'Complex']
|
||||
@ -13,3 +14,7 @@ export function getBitDepthLabel(index: number): string {
|
||||
export function getSampleRateLabel(index: number): string {
|
||||
return `${SAMPLE_RATES[index]}Hz`
|
||||
}
|
||||
|
||||
export function getAlgorithmLabel(index: number): string {
|
||||
return getAlgorithmName(index)
|
||||
}
|
||||
Reference in New Issue
Block a user