# Technical Analysis & Implementation Plan: SpessaSynth → FluidSynth WASM Migration
## 1. Key Findings from Codebase Investigation
### 1.1 Current State (SpessaSynth)
| Aspect | Detail |
|---|---|
| Engine | `spessasynth_lib@4.3.1` (CDN via jsDelivr + importmap) |
| File | `app/static/js/services/soundfontPlayer.js` — IIFE, `window.SonicSF` singleton |
| API surface | 10 methods: `init`, `playNote`, `stopNote`, `stopAll`, `selectInstrument`, `controllerChange`, `programChange`, `pitchBend`, `sustainActive`, `applyAITrackInstrument`, `allocateChannel`, `getChannelState`, `loadSoundFont` (internal) |
| Call sites | 45 in `app.jsx` across piano roll, MIDI keyboard, timeline playback, external MIDI input, AI track generation |
| storage | `app/static/js/services/soundfontStorage.js` — IndexedDB cache, `window.SonicSFStorage` |
| Server render | `app/core/render_engine.py` uses `pyfluidsynth` (C++ FluidSynth) |
| Server SF3 | `app/core/soundfont_converter.py` converts SF2→SF3 via `fluidsynth`/`mscore` CLI |
| API endpoint | `GET /api/v1/plugins/soundfonts/download/{sf_id}` — serves both `.sf2` and `.sf3` |
| Docker | `libfluidsynth3`, `fluidsynth` CLI installed in container |
### 1.2 Target State (FluidSynth WASM) — Corrected Spec
**CDN package**: `fluidsynth-wasm` does NOT exist on npm/CDN.
**Real package**: `@enikey87/fluidsynth-emscripten@0.1.1`
| File | Size | Purpose |
|---|---|---|
| `libfluidsynth-2.3.0-sf3.js` | 130 KB | JS loader, SF3 support, separate WASM |
| `libfluidsynth-2.3.0-sf3.wasm` | 1.7 MB | WASM binary with Ogg/SF3 support |
| `libfluidsynth-2.3.0-sf3-all-in-one.js` | 2.39 MB | JS+WASM embedded (no separate .wasm fetch) |
**CDN URL** (chosen: separate .wasm for caching):
```
https://cdn.jsdelivr.net/npm/@enikey87/fluidsynth-emscripten@0.1.1/dist/libfluidsynth-2.3.0-sf3.js
https://cdn.jsdelivr.net/npm/@enikey87/fluidsynth-emscripten@0.1.1/dist/libfluidsynth-2.3.0-sf3.wasm
```
### 1.3 CRITICAL Architecture Flaw in Spec
The spec's code (md/39_SF3_NEW.md) passes `wasmModule` and `synthPtr` via `postMessage` to AudioWorklet. **This cannot work** because:
1. AudioWorklet runs in a separate audio thread with an isolated global scope
2. `synthPtr` (a C pointer / integer) refers to WASM linear memory in the main thread's `WebAssembly.Module` instance
3. Loading `libfluidsynth-2.3.0-sf3.js` inside the worklet creates a **separate WASM instance** with its own memory
4. The spec also uses `_fluid_synth_write_float` inside `process()` — this blocks the audio thread if audio buffer underruns occur
**Correct architecture (per README):**
> "To use libfluidsynth-X.X.X.js in AudioWorklet, load it into AudioWorklet before your worklet JS file."
The FluidSynth instance must be created **inside** the AudioWorklet. MIDI events + ArrayBuffer data are sent from main thread to worklet via `port.postMessage`.
### 1.4 `playNote` Signature Complexity
Current `playNote(note, velocity, durationMs, startTime, program, destNode, channel, synthEngine)` has 8 parameters and 16 call sites. The `synthEngine` object carries `{ soundfont_id, soundfont_bank, soundfont_program }` which must be converted to FluidSynth's `bank_select` + `program_change` before `noteon`.
### 1.5 Additional `window.SonicSF` Methods Not in Spec
Must preserve in new implementation:
- `stopNote(channel, pitch)` — MIDI note-off with extra CC 64/120 cleanup
- `controllerChange(channel, controller, value)` — MIDI CC forwarding
- `programChange(channel, program)` — pure JS state tracking
- `pitchBend(channel, value)` — 14-bit bend value
- `sustainActive(channel)` — getter for sustain pedal state
- `allocateChannel(bank)` — channel allocation (percussion = ch 9)
- `getChannelState(channel)` — JS-side { bank, program, isPercussion } state
- `saveToIndexedDB / loadFromIndexedDB` — legacy storage
---
## 2. Implementation Plan
### Phase 1: Preparation (Infrastructure)
**Task 1.1 — Create worklet directory**
```
mkdir -p app/static/js/worklets/
touch app/static/js/worklets/.gitkeep
```
**Task 1.2 — Update `index.html`**
- Remove SpessaSynth importmap + module script (lines 13-25)
- Add FluidSynth WASM CDN script:
```html
```
### Phase 2: AudioWorklet Processor
**Task 2.1 — Create `app/static/js/worklets/fluidsynth-worklet.js`**
This file runs inside AudioWorkletGlobalScope. It:
- Receives the FluidSynth WASM module (loaded via `addModule()`)
- Maintains its own `_fluid_synth` instance
- Receives commands from main thread via `port.onmessage`:
- `INIT_SYNTH` — create settings + synth, store `synthPtr`
- `LOAD_SF` — receive ArrayBuffer, write to MEMFS, call `_fluid_synth_sfload`
- `NOTE_ON` — `_fluid_synth_noteon(synthPtr, channel, pitch, velocity)`
- `NOTE_OFF` — `_fluid_synth_noteoff(synthPtr, channel, pitch)`
- `CC` — `_fluid_synth_cc(synthPtr, channel, controller, value)`
- `PROGRAM_CHANGE` — `_fluid_synth_program_change / bank_select`
- `PITCH_BEND` — `_fluid_synth_pitch_bend(synthPtr, channel, value)`
- `ALL_NOTES_OFF` / `ALL_SOUNDS_OFF`
- `SET_GAIN` — `_fluid_synth_set_gain`
- In `process(inputs, outputs)`:
- Get `synthPtr` from closure
- Call `_fluid_synth_write_float(synthPtr, bufferSize, leftPtr, 0, 1, rightPtr, 0, 1)`
- Return `true` to keep processor alive
Key constraint: The FluidSynth WASM's `_fluid_synth_write_float` needs access to the output channel Float32Array's **byteOffset** relative to the WASM heap. AudioWorklet `output` arrays are not backed by WASM memory. Two solutions:
- **A**: After rendering, copy from WASM heap Float32Array to output channels (less efficient but safe)
- **B**: Load FluidSynth WASM inside worklet, allocate output buffers inside WASM heap (complex)
**Recommendation: Solution A** — simpler and avoids memory management issues.
### Phase 3: Rewrite `soundfontPlayer.js`
**Task 3.1 — Rewrite as IIFE-compatible `window.SonicSF`**
Keep the IIFE pattern (`window.SonicSF = SonicSF`) to minimize diff in `app.jsx`. Internal implementation uses `FluidSynthWasmWorkletBridge` class.
```javascript
// Internal state
const _channels = Array.from({ length: 16 }, () => ({ bank: 0, program: 0, isPercussion: false }));
const _sustainStates = new Array(16).fill(false);
let _workletNode = null;
let _audioCtx = null;
let _initialized = false;
let _initPromise = null;
let _currentSfId = null;
let _sfHandleMap = new Map(); // sfId -> sfHandle (integer, tracked on JS side)
let _fluidModule = null; // guarded global, used before worklet takes over
```
**Key architectural decision:**
The FluidSynth instance lives in the AudioWorklet. The main thread `soundfontPlayer.js`:
1. Loads the WASM module only for FS operations (MEMFS file writing) - needed because worklet can't do `fetch()`
2. Creates an `AudioWorkletNode`, registers the worklet
3. Sends ArrayBuffer data + MIDI commands to worklet via `port.postMessage`
4. Tracks JS-side state (channel, bank, program) for methods like `getChannelState`, `allocateChannel`
**Alternative (simpler, recommended for v1):**
Run FluidSynth entirely on **main thread**, render PCM buffers in a rAF/setInterval loop, feed to a `ScriptProcessorNode` or a simple AudioWorklet that just outputs pre-rendered buffers. This avoids the AudioWorklet WASM complexity.
**Decision:** Use main-thread FluidSynth + simple AudioWorklet output. Rationale:
- Much simpler implementation
- FluidSynth `_fluid_synth_write_float` is fast enough for real-time rendering
- No need to manage two WASM instances
- Can reuse the spec's API surface directly
- MIDI events can be processed synchronously on main thread
**Architecture:**
```
Main thread:
FluidSynth Module (loaded via CDN