Adding new FM synthesis mode

This commit is contained in:
2025-10-06 13:08:59 +02:00
parent 0110a9760b
commit 324cf9d2ed
13 changed files with 1233 additions and 69 deletions

View 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)

View File

@ -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) =>

View File

@ -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

View File

@ -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

View File

@ -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: '%'
}
]
}

View 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}`
}

View File

@ -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

View 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()
}
}

View File

@ -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
}

View 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
View 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
}

View 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
}

View File

@ -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)
}