diff --git a/docs/audio-engine-tlc.md b/docs/audio-engine-tlc.md new file mode 100644 index 0000000..131c7bb --- /dev/null +++ b/docs/audio-engine-tlc.md @@ -0,0 +1,940 @@ +> **Execution status (2026-07-14, branch `refactor/audio-engine-tlc`):** implemented. +> Phases 0-8 of Part IV/V are complete on this branch, including the chain-mutation +> serializer + chainGeneration (native + executor), the monitor-mute arbiter, the +> single persistence store, and getLatencyBreakdown. Two sequencing changes vs. the +> roadmap below: the gain-sanitization fix shipped first (before phase 1), and the +> Part II ownership work that needs the rig_builder repo (single chain owner, +> legacy-path deletion, alias removal) is NOT on this branch. Drift note: the +> "21 clearChain call sites" count in Part II grew to 30 by execution time. + +# Audio Engine TLC — Consolidated Findings & Refactor Plan + +TLC pass on the feedBack-desktop audio engine (2026-07-12, branch `fix/loopback-capture-permission`). +Single consolidated document; supersedes the four separate docs +(overview / collisions / deep-read / refactor-plan). + +Contents: +- **Part I** — how the engine works and integrates with the app, effects (NAM/VST/IR), plugins. +- **Part II** — redundant code, settings with multiple writers, control collisions. +- **Part III** — line-level deep read of `AudioEngine.cpp` and `NodeAddon.cpp`. +- **Part IV** — decomposition plan for the monolithic files. +- **Part V** — merged priority roadmap. + +--- + +# Part I — Architecture Overview +Covers: engine architecture, desktop-app integration, input/output paths, effects (NAM / VST / IR), +detection pipeline, and which bundled plugins touch the engine and how. + +--- + +### 1. Layer map + +``` +Renderer plugins (rig_builder, note_detect, stems, …) + │ window.feedBackDesktop.audio.* / .audioEffects.* (aliased: slopsmithDesktop) + ▼ +preload.ts ── contextBridge, ~99 audio methods + audioEffects methods + │ ipcRenderer.invoke / send + ▼ +Main process + audio-bridge.ts 102 ipcMain.handle channels (audio:*, audio-effects:*) + audio-effects-executor.ts validates chain plans, drives native chain + vst-crash-guard.ts sentinel files → blocklist crashy VSTs across restarts + plugin-manager.ts git-based plugin install/update (server plugins) + │ require('slopsmith_audio.node') + ▼ +NodeAddon.cpp (N-API, ~160 KB) ── marshals JS ⇄ C++, async workers for VST loads + ▼ +AudioEngine (JUCE, C++) src/audio/ + ├── SourceChain ×8 (pooled) per-input capture + detect + tone chain + │ └── SignalChain ordered ProcessorSlots (VST | NAM | IR) + ├── Backing-track transport + signalsmith-stretch + ├── Stream sink (2nd output device for OBS/Discord) + └── Renderer bus (WebAudio master → engine output) + ▼ +JUCE AudioDeviceManager(s) → WASAPI / ASIO / DirectSound / CoreAudio / ALSA / JACK + +Out-of-process: slopsmith-vst-host.exe (src/vst-host/main.cpp) — VST3 sandbox child. +``` + +Key files: + +| Area | File | +|---|---| +| Engine core | `src/audio/AudioEngine.{h,cpp}` (47K header / 156K impl) | +| Per-input chain | `src/audio/SourceChain.{h,cpp}` | +| Effects chain | `src/audio/SignalChain.{h,cpp}` | +| NAM | `src/audio/NAMProcessor.{h,cpp}` (wraps NeuralAmpModelerCore, `src/audio/third_party/NAM`) | +| IR / cab | `src/audio/IRLoader.{h,cpp}` (juce::dsp::Convolution) | +| VST hosting | `src/audio/VSTHost.{h,cpp}` | +| VST sandbox | `src/audio/Sandbox/*` + `src/vst-host/main.cpp` | +| Detection | `PitchDetector` (YIN), `MlNoteDetector` (Basic Pitch ONNX), `ChordScorer`, `NoteVerifier`, `OnsetDetector` | +| Utility DSP | `NoiseGate`, `TonePolish`, `BackingLeveler`, `AudioSanitize` | +| JS bridge | `src/audio/NodeAddon.cpp`, `src/main/audio-bridge.ts`, `src/main/preload.ts` | + +--- + +### 2. AudioEngine core + +`AudioEngine` is a `juce::AudioIODeviceCallback` owning **two** `AudioDeviceManager`s: + +- **Duplex mode** (default): `inputDeviceManager` owns both directions; one callback + (`audioDeviceIOCallbackWithContext`) reads input, processes, writes output directly. +- **Split mode**: input-only on `inputDeviceManager`, output-only on `outputDeviceManager` + (separate device types possible, e.g. ASIO in + WASAPI out). Processed stereo crosses via + `outputPendingRing` — a lock-free SPSC ring of 4096 frames where each stereo frame is packed + into one `atomic` (bit_cast L|R) so reads are tear-free. ~85 ms of drift absorption. + Input and output block sizes may differ; the ring absorbs asymmetry. + +Device management surface: enumerate types/devices, dual-type probing +(`probeDeviceOptionsDual` — sample-rate intersection, `compatible` flag), `setAudioDevices`, +metrics (overflow/underflow counters, ring fill). Config persisted by the renderer via +`audio:saveDeviceSettings` / `loadDeviceSettings` (legacy single-`type` settings are mirrored to +input+output type). + +Threading model (recurring pattern throughout): audio thread never locks — atomics everywhere, +lock-free SPSC rings, `static_assert(is_always_lock_free)`, control-thread mutation via +pending-flag handoff (e.g. `backingPendingSpeed`), and drop-oldest on overflow with counters. + +--- + +### 3. Audio input + +#### Sources (multi-input) + +Per-input state lives on **SourceChain**, a fixed pool of `kMaxSources = 8` constructed up front +(pointers never reassigned → no race with audio thread; add/remove only flips an atomic `active` +flag). `sources[0]` is the permanent legacy default input; the engine facade forwards the +single-source API to it so NodeAddon/renderer needed no change. + +- `addSource(inputChannel, deviceKey)` — bind another channel of the current device + (multi-channel interfaces, e.g. Valeton GP-5) or of an **additional physical input device** + (`bindInputDevice(deviceKey, name)`, up to 3 extras, each at its own clock; forces split mode). +- Removal uses a per-deviceKey `callbacksInFlight` counter handshake; wedged callbacks defer the + release (`pendingRelease[]`) instead of blocking. +- Per-source: input gain, channel select (-1 = mono mix), monitor mute/kill, meters, verifier + offsets (auto device-latency delta + user fine-tune, summed). + +#### Per-source capture path (SourceChain::processBlock, audio thread) + +``` +device input → channel select / mono mix → inputGain + ├─→ MlNoteDetector feed + pre-gate inputFrameRing (8192, SPSC) [getInputFrame/getInputSince] + → NoiseGate (post-gain, pre-FX; pitch detector sees ungated signal) + ├─→ YIN PitchDetector feed + post-gate rawAudioRing (16384) [getRawAudioFrame → tuner] + → SignalChain (VST/NAM/IR tone chain) + → sanitize (non-finite/runaway scrub, counted — issue #403) + → monitor mute / monitor kill / chainOutputGain → TonePolish (fixed 3-band EQ, guitar bus only) + → summed into output mix (sourceMonitorScratch, pre-sized) +``` + +Monitor semantics: `monitorMute` mutes dry pass-through only when chain empty (suppressible +around song-load chain rebuilds); `monitorKill` silences the guitar bus unconditionally +(external-rig users), applied to every pooled source. + +--- + +### 4. Effects — SignalChain + +Ordered `ProcessorSlot`s, each `Type::{VST, NAM, IR, Empty}` holding a +`unique_ptr`. Features: + +- **Routing**: per-slot `pan` (constant-power), `branch` (0 = serial trunk, ≥1 = parallel branch, + branches read the pre-split signal, panned outputs summed at merge), `branchSrc` (branch reads + L / R / both), `postGain` (per-amp loudness trim). Pre-allocated scratch buffers; all-trunk path + pays nothing. +- **State**: per-slot base64 VST state; whole-chain JSON preset save/load (`savePreset` / + `loadPreset`); `replaceProcessor` swaps a slot in place (same id/position) — used for sandbox + promotion and `replaceIR` cab swaps; `setSlotState` for the tone-switcher's incremental rebuild. +- **MIDI**: lock-free SPSC queue (64 msgs), `queueMidiMessage(slotId, msg)` from the N-API thread + → drained on audio thread (`audio:sendMidiToSlot`). +- Oversized device blocks (WASAPI shared after device start) are sliced to the prepared block size. +- SEH/signal guards around plugin prepare/state calls; faults blocklist the plugin path. + +#### Processor types + +- **NAMProcessor** — mono in/out neural amp model (`.nam`), NeuralAmpModelerCore backend + (`SLOPSMITH_NAM_SUPPORT`). Async-safe model load: staged `pendingModel`, atomic swap. + Input/output level params. No editor. +- **IRLoader** — cab IR convolution (`.wav/.aif/.ir`) via `juce::dsp::Convolution`. +- **VST3** via **VSTHost**: background directory scanning (per-file subprocess probe through + `slopsmith-vst-host --scan-plugin` → XML merge, so crashy plugins can't kill the app), + known-plugin persistence, sync `loadPlugin` + async `loadPluginAsync` (message-thread pumping + required for AmpliTube-class plugins that post messages to themselves during init). + +#### VST sandbox (out-of-process) + +`SandboxedProcessor` (src/audio/Sandbox) is a `juce::AudioProcessor` façade that forwards +everything to a spawned `slopsmith-vst-host.exe` child over a control pipe + shared-memory audio +channel (`Protocol.h`; platform impls `_win` / `_posix` / `_shared`). Properties: + +- One child per plugin; child dies with the processor. Crash → `isAlive()` false → audio thread + inserts silence; `CrashCallback` + `CrashAttribution` report which plugin died. +- Child owns the plugin editor as its own top-level window (Reaper-style; cross-process HWND + reparenting broke D3D/GL plugins like Neural DSP Archetypes). +- **Promotion path**: an in-process VST3 is promoted to the sandbox when its editor is opened + (in-process editors are the Windows WndProc/Qt crash path). `captureVstStateForPromotion` + snapshots state under lock + SEH guard, then `replaceProcessor` swaps in the sandboxed twin. +- Child guarantees JUCE MessageManager on the OS main thread (impossible in the Node addon where + V8 owns it); audio runs on a dedicated ring-drain worker. +- Known v1 gaps (documented in header): no `AudioProcessorParameter` proxies; bus layout + hard-coded stereo↔stereo. +- **vst-crash-guard.ts** (main process): arms a sentinel file before risky load/editor operations; + a crash leaves the sentinel behind → next launch blocklists that plugin. + +--- + +### 5. Audio output + +Three output paths, mixed in the engine: + +1. **Monitor output** (primary device): sum of active sources' processed guitar buses + + backing-track mix (`backingVolume` fader, `BackingLeveler` per-song loudness normalizer) + + master `outputGain`. Duplex writes in-callback; split drains `outputPendingRing`. +2. **Stream sink** — an ADDITIONAL output device carrying an independent submix + (backing/game and/or guitar monitor, own gain, sanitized 0..8) for OBS/Discord capture. + `setStreamOutputDevice` / `setStreamBus`; underflow/overflow counters + level meter exposed. +3. **Renderer bus** (Phase 2, exclusive-mode support): the renderer's WebAudio master mix is + pushed over IPC (`audio:pushRendererAudio`, fire-and-forget `ipcRenderer.send`) into a large + SPSC ring (65536 frames ≈ 1.5 s; producer is the jittery IPC thread), linear-resampled + producer-side to device rate, mixed into engine output. Keeps song/stem audio audible when the + output device is exclusive-style (ASIO / WASAPI exclusive) and the OS mixer path is silent. + Fed renderer-side by a whole-app `getDisplayMedia({audio})` loopback capture — + `setDisplayMediaRequestHandler` in `main.ts` grants it (audio-only, own-app loopback; other + apps' audio not captured). Current branch (`fix/loopback-capture-permission`) fixes the media + permission handler to allow this getDisplayMedia request. + +**Backing track**: JUCE `AudioFormatReaderSource` → `AudioTransportSource` buffered by a +`TimeSliceThread` read-ahead → optional signalsmith-stretch phase vocoder for speed change +(1x bypass path; lock-free speed handoff via pending atomic so slider drags never block the RT +tryLock). Playhead = accumulated heard frames minus stretcher latency; non-blocking cached +position/duration getters. Used for local audio-file playback; sloppak/HTML5-routed songs play +through the renderer's WebAudio instead (engine playhead frozen; the verifier is fed the +renderer's corrected playhead via `setPlayhead`). + +--- + +### 6. Detection & scoring (engine-side) + +- **PitchDetector** — monophonic YIN, sub-Hz parabolic interpolation, reads post-gate signal + (silent when gate closed). Backs the always-on home tuner (`audio:getRawPitch`). +- **MlNoteDetector** — polyphonic Basic Pitch ONNX model (`loadNoteModel`); armed only while a + consumer actually reads ML notes (`setMlNoteDetectionEnabled`) so ONNX inference isn't paid + otherwise. `getActiveDetection()` prefers ML when loaded, else YIN — same shape either way. +- **ChordScorer** — scores a renderer-supplied chord context against the input ring + (`audio:scoreChord`); ML-backed variant when the ML detector is live. +- **NoteVerifier** — background thread per source; renderer pushes the chart once (`setChart`), + verifier scores each note's timing window against the live playhead + input ring, renderer + drains verdicts (`getNoteVerdicts`). Replaced the per-tick scoreChord IPC loop that starved on + dense passages. Playhead offset = auto device-latency delta + user fine-tune. +- **OnsetDetector** — consumes the input ring gaplessly via `getInputSince`. + +--- + +### 7. Main-process integration + +- **audio-bridge.ts** (65K): loads `slopsmith_audio.node`, registers all 102 `audio:*` / + `audio-effects:*` IPC handlers, normalizes/persists device settings, wires vst-crash-guard + sentinels around VST loads and editor opens, forwards renderer-bus audio. +- **audio-effects-executor.ts**: the capability-pipeline backend for the `audio-effects` + capability. Accepts validated **chain plans** (`feedBack.audio_effects.chain_plan.v1`, legacy + `slopsmith.…` schema accepted): up to 24 stages of kind `nam | ir | vst | utility | bypass` + with roles (pedal/amp/cab/…), route keys (default `desktop-main`), gain sets, authorization + gating (`user-action` / `restore-selection` / `playback-session`). Translates plans into native + calls (loadPreset/clearChain/setBypass/setParameter/setGain/…) and reports structured outcomes + (`handled | degraded | failed | unavailable | no-target | user-action-required`). +- **preload.ts**: exposes the whole surface as `window.feedBackDesktop` (alias + `window.slopsmithDesktop`) — `audio.*` (~99 methods) + `audioEffects.*`. +- **NodeAddon.cpp**: N-API glue; libuv async workers for plugin loads (message thread keeps + pumping); sandbox-aware VST loading (`loadVstSandboxAware`); shutdown cancels pending loads. + +--- + +### 8. Bundled plugins that touch audio + +Plugins are renderer-side (screen.js + plugin.json manifest, capability-pipelines.v1). The ones +interacting with the engine: + +| Plugin | Interaction | +|---|---| +| **audio_engine** (bundled in this repo, `src/renderer/`) | The engine's own UI: device setup, chain editor, meters. Declares provider capabilities `audio-input`, `audio-mix`, `audio-monitoring`; observes `playback` lifecycle to rebuild tone automation / tear down native chain state. | +| **rig_builder** | Biggest consumer. Builds amp/cab/pedal rigs; declares `audio-effects` capability and submits chain plans (NAM stages, IRs, VSTs, per-stage gain/bypass/params) through audio-effects-executor. Also privileged-capabilities, jobs, library. Own repo also contains a VST (`vst/`) and tone-curation tooling. | +| **nam_tone** | NAM tone library (server-side): manages `nam_models/`, `nam_irs/`, `nam_tone.db` on the Python backend; models/IRs are what `audio:loadNAMModel` / `loadIR` consume. | +| **note_detect** | Real-time detection/scoring: arms ML detection, pushes charts (`setChart`), drains verdicts, reads pitch/raw frames, drives per-source scoring APIs. | +| **stems** / **stem_mixer** | WebAudio-side stem mix (`audio-mix` capability, mute/volume commands). Their master mix reaches the engine only via the renderer bus on exclusive-mode outputs. | +| **midi_amp** | Sends MIDI Program Change to external amps/modelers on tone switches (external gear path; engine-side per-slot MIDI exists via `audio:sendMidiToSlot`). | +| **tuner (built-in home tuner)** | Always-on YIN readout via `getRawPitch` / `getRawAudioFrame` — deliberately never pays ONNX cost. | +| **virtuoso / practice / minigames** | Consume detection results (verdicts/pitch) rather than driving the chain. | + +Plugin lifecycle: `plugin-manager.ts` installs/updates plugins as git checkouts under the +plugins dir (https-only remotes, path-safe names); restart of the Python backend activates them. + +--- + +### 9. Observations for the TLC pass (starting points) + +- `AudioEngine.cpp` (156K) and `NodeAddon.cpp` (160K) are monoliths; SourceChain extraction + ("Phase 0/2" comments) is mid-flight — multi-source fan-out phases still landing. +- Duplicated facade surface: engine forwards ~40 single-source methods to `source0()` while a + parallel `getSource(id)`-indexed API grows alongside (`audio:*` vs `audio:setSource*`). +- Sandbox v1 gaps documented in `SandboxedProcessor.h` (no parameter proxies, fixed stereo buses). +- Three separate SPSC ring implementations (outputPendingRing, renderer bus, stream sink) share + the packed-LR pattern — candidate for one templated ring. +- Backing-track transport is legacy for sloppak songs (renderer WebAudio does playback); the + frozen-playhead special case leaks into NoteVerifier via `setPlayhead`. +- Naming drift: slopsmith → feedBack rebrand half-done (addon name `slopsmith_audio.node`, + `slopsmith-vst-host.exe`, legacy schema ids, `window.slopsmithDesktop` alias). + +--- + +# Part II — Redundancy & Control Collisions +Every finding below was +verified in source; file references point at the current tree. + +Severity legend: 🔴 active conflict (two writers fight at runtime) · 🟠 dual ownership +(same setting settable from two places, last-writer-wins, no arbitration) · 🟡 redundancy +(duplicate surface/code, no runtime conflict yet). + +--- + +### 1. 🔴 Signal chain has three independent writers + +The native `SignalChain` is a single global resource, but three parties load/clear it: + +1. **audio_engine bundle** (`src/renderer/screen.js`): direct `api.loadVST` / `loadNAMModel` + / `loadIR` / `loadPreset` / `clearChain` (21 `clearChain` call sites), plus its own tone + auto-switch/automation (`applyToneMappingsNow`, `applyToneAutomationFor`, + `_restorePresetBlob` → `clearChain` + `loadPreset`). +2. **rig_builder via capability pipeline**: `audioEffects.loadPlan` → main-process + `audio-effects-executor.ts` → `nativeAudio.loadPreset`. +3. **rig_builder legacy direct path**: `feedBackDesktop.audio.loadPreset` (tracked in its own + telemetry as `audio-effects.legacy-native-load`). + +Concrete evidence of the fight (rig_builder `screen.js`): + +> "PROACTIVE TRANSIENT KILL: the bundle calls loadPreset ~1ms after we return this response. +> We can't monkey-patch `feedBackDesktop.audio.loadPreset` … the object is frozen by +> contextBridge" + +rig_builder ships timing hacks (`_rbUnmuteTimer`, transient kill, fallback unmute) purely to +survive the bundle re-loading the chain right after it did. That is two plugins racing on the +same native chain with wall-clock heuristics as the arbiter. + +**Additional executor-state hazard**: the executor keeps a `routes` map with +`stageSlots` (stageId → native slotId). Any direct `loadPreset` / `clearChain` / +`removeProcessor` / `moveProcessor` from path 1 or 3 invalidates those slot ids silently — +subsequent `setStageBypass` / `setStageParameter` / `activateSegment` then flip +bypass/params on the **wrong slots** (slot ids are reused sequentially by `nextSlotId`) or +return `no-target`. Nothing detects the divergence. + +### 2. 🔴 Monitor mute: five writers, one atomic, persisted preference gets clobbered + +`SourceChain::monitorMuted` writers: + +| Writer | Where | When | +|---|---|---| +| audio_engine settings UI checkbox | `screen.js` (`ae-monitor-mute`) | user toggle; persisted in device settings | +| startup restore | `screen.js` ~901 | pushes saved value into engine on boot | +| executor preload-mute | `audio-effects-executor.ts:479-489` | saves `previousMonitorMute`, forces mute/unmute during chain load, restores on a `setTimeout` ramp | +| executor `releaseRoute` | `:616` | **unconditionally** `setMonitorMute(true)` + `setMonitorMuteSuppressed(false)` | +| renderer song-load suppression | `screen.js` (2 sites) + `audio:setMonitorMuteSuppressed` | temporary override around chain rebuild | + +Collisions: + +- `releaseRoute` forces mute=true regardless of the user's persisted `monitorMute:false` + preference — the checkbox UI and the engine now disagree until the next toggle/restart. +- The executor's read-modify-restore (`previousMonitorMute` + delayed `schedulePreloadRestore`) + races a user toggling the checkbox during the hold window: the restore overwrites the fresh + user choice with the stale snapshot. `preloadRestoreVersion` guards against *newer executor + loads*, not against other writers. +- `monitorMuteSuppressed` is set by both the renderer (song load) and executor flows with no + refcount — whoever clears last wins; overlapping windows un-suppress early. + +### 3. 🔴/🟠 Gain: four knobs, three surfaces, inconsistent clamping + +Native gains: per-source `inputGain`, per-source `chainOutputGain`, global `outputGain` +(master), `backingVolume` — all reachable through `audio:setGain(which, value)` +(`NodeAddon.cpp SetGain`), and `input`/`chain` also through +`audio-effects:setRouteGain` + chain-plan `options.gains` + `preloadMute.targetGain`. + +- **Dual ownership of `chain` gain**: audio_engine screen sets it (9 `setGain` sites); + the executor zeroes it (`trySetGain('chain', 0)` on load and on `releaseRoute`) and later + ramps it to `targetGain` (default **1**, or plan-supplied) on a timer. If the user (or tone + automation) set chain gain meanwhile, the ramp silently overwrites it. Same + stale-snapshot race as monitor mute. +- **Clamp inconsistency**: executor clamps to `0..32` (`clampGain`); `NodeAddon::SetGain` does + **no** validation — `NaN`/`Infinity` from any direct `audio:setGain` caller reaches + `outputGain.store()` / `inputGain.store()` raw. The engine sanitizes only the *stream* and + *renderer-bus* gains (`sanitizeStreamGain`, 0..8, explicitly "so a NaN/Inf from JS can never + reach the ring") — the exact same hazard is unguarded for master/input/chain/backing. + A NaN master gain silences output and poisons the peak meters. +- Per-slot `postGain` overlaps conceptually with `chainOutputGain` (both are "level after the + amp"): rig plans carry per-stage loudness trims while the screen's chain gain scales the + same signal — two normalization layers, no documented ownership. + +### 4. 🟠 Device settings: two persistence stores, newest-timestamp arbitration + +`screen.js loadDeviceSettings()` merges **file-backed** settings (main process, +`audio:saveDeviceSettings`) with **`localStorage['slopsmith-audio-device']`**, picking +whichever has the newer `savedAt`. Two stores for one setting means: + +- A main-side migration/reset (`config-reset.ts` territory) leaves stale localStorage that can + win the timestamp race and resurrect wiped settings. +- `monitorMute` / `monitorKill` ride inside the *device* settings blob, so a device re-save + from one path re-persists mute flags captured from checkbox state at that moment — + interleaving with §2's runtime writers. +- Renderer keeps 10 `slopsmith-*` localStorage keys total (`slopsmith-signal-chain`, + `slopsmith-chain-presets`, `slopsmith-tone-automation`, …) — the chain is *also* persisted + renderer-side while rig_builder persists rigs server-side (`routes.py` / DB): two saved + descriptions of the same chain that can disagree on restore. + +### 5. 🟡 Legacy alias surfaces (three layers deep) + +Same setting, multiple entry points kept for back-compat — each a place for behavior to drift: + +- **Engine facade**: `getDeviceManager()` ≡ `getInputDeviceManager()`; + `setInputDeviceType()` ≡ `setDeviceType()`; `DeviceOptions.type` ≡ `inputType`; + single-source methods (`setInputGain`, `setMonitorMute`, `setChart`, `scoreChord`, ~40 of + them) forward to `source0()` while a parallel indexed API (`getSource(id)` → + `audio:setSource*`) does the same thing for id 0. Two IPC routes mutate the same atomic + (`audio:setMonitorMute` vs `audio:setSourceMonitorMute(0, …)`). +- **Settings shape**: legacy `{type}` vs `{inputType, outputType}` normalized in **two + places** — `audio-bridge.ts normalizeDeviceSettings` *and* `screen.js + normalizeDeviceSettings` (duplicated logic, must stay in sync by hand). +- **Schema/branding**: `feedBack.audio_effects.chain_plan.v1` + accepted legacy + `slopsmith.…` id; `window.feedBackDesktop` + `window.slopsmithDesktop`; localStorage keys + still `slopsmith-*`. Each alias doubles the grep surface for every future change. + +### 6. 🟡 Duplicated implementation code + +- **Three packed-LR SPSC rings** in `AudioEngine.h` (split-mode `outputPendingRing`, renderer + bus, stream sink) — same pack/unpack, same power-of-two asserts, same drop-oldest logic, + three hand-maintained copies. One templated ring kills ~2/3 of the index math. +- **Two fail-soft wrappers per method** in the JS layer: audio-bridge's typeof-guarded + handlers and the executor's `trySetGain`/`trySetMonitorMute`/… re-wrap the same native + calls with slightly different error policy (bridge: silent no-op; executor: outcome + strings). A single native-call helper with one policy would remove a class of divergence. +- **`normalizeLoadResult` tolerance duplicated**: both rig_builder (`screen.js`: "Some JUCE + bridges return {success:false} or bare …") and the executor normalize loadPreset results + independently. +- **Chain-restore logic**: executor rollback (`rollbackPreset` + `restorePreset`) vs + screen.js `_restorePresetBlob` — two snapshot/rollback implementations for the same chain. + +### 7. 🟠 `startAudio` / route lifecycle from two sides + +`audio:startAudio` is invoked by the renderer UI **and** best-effort by the executor when a +chain plan carries `startAudio: true` (`:574`). Neither side knows the other's intent; there's +no matching stop ownership — `releaseRoute` clears the chain and mutes but leaves the device +running or not depending on who started it. + +--- + +### Recommended direction (for TLC scoping, not yet implemented) + +1. **Single chain owner**: make the audio-effects executor the *only* writer of the native + chain; migrate the audio_engine screen's direct loadVST/loadPreset/tone-switch calls onto + route-scoped executor operations; then delete rig_builder's transient-kill hacks and the + legacy direct `loadPreset` path. Executor should reject/re-sync when + `getChainState` disagrees with its `stageSlots` map (generation counter on the native chain). +2. **Arbitrated monitor state**: replace raw `setMonitorMute` writes with a small state owner + (user preference + N stackable suppressions/overrides, refcounted). `releaseRoute` releases + its override instead of forcing `true`. +3. **Sanitize all gains natively**: extend `sanitizeStreamGain`-style clamping to + input/chain/output/backing in `AudioEngine` setters (single choke point) and drop the + JS-side clamp divergence. +4. **One persistence store per setting**: file-backed settings as the single source; treat + localStorage as a migration source only, delete after import. Move mute flags out of the + device blob. +5. **Deprecation plan for aliases**: freeze `slopsmith*` surfaces, log-once on use, remove on + next major. + +--- + +# Part III — Deep Read: AudioEngine.cpp + NodeAddon.cpp + +Full read of `src/audio/AudioEngine.cpp` (3223 lines) and the load-bearing regions of +`src/audio/NodeAddon.cpp` (3699 lines). Line refs current as of +`fix/loopback-capture-permission`. + +**Overall verdict first**: the RT core is in much better shape than its size suggests — +disciplined lock-free SPSC rings, no allocation on the audio thread, denormal flushing on every +callback clock, a correct per-deviceKey quiescence handshake for source removal, and unusually +good comments that cite the bug each guard fixes. The problems live at the *edges*: the JS↔native +boundary, concurrency between async workers, and inconsistent input sanitization. + +--- + +### 1. 🔴 Chain-mutating async workers are not serialized (NodeAddon) + +`LoadPresetWorker`, `LoadVSTWorker`, `LoadNAMWorker`, `LoadIRWorker`, `ReplaceIRWorker` all queue +on the libuv threadpool (default 4 threads) with **no mutual exclusion between workers**. +`SignalChain` locks per-operation only, so the sequence `clear() → addProcessor() × N` +(`NodeAddon.cpp:3312-3408`) is not atomic. + +Two `loadPreset` calls in flight — which is precisely the documented rig_builder-vs-bundle +"~1ms later" race from the collisions doc — can interleave as: + +``` +worker A: clear() worker B: clear() +worker A: add(ampA) worker B: add(ampB) +worker A: add(irA) → final chain: [ampA, ampB, irA, irB] (merged garbage) +``` + +Both report `success:true` with wrong `slotsLoaded` semantics; the executor's stageId→slotId map +is then built against a chain that neither caller described. A `loadVST` concurrent with a +`loadPreset` similarly lands a slot into (or after) someone else's rebuild. + +**Fix shape**: one native "chain mutation" mutex (or a serial dispatch queue) around +clear+rebuild and single-slot adds; alternatively a chain generation counter returned to JS so +callers detect they lost the race. This is the single highest-value fix of the whole pass — +it converts the plugin-vs-plugin fight from corruption to last-writer-wins. + +### 2. 🔴 Argument sanitization is inconsistent across the N-API surface + +The addon knows the hazard — `getValidatedSource` (`NodeAddon.cpp:89-103`) documents that +`Int32Value()` coerces NaN→0, and `setAudioDevices` normalizes sampleRate against "NaN slipping +past N-API" (`AudioEngine.cpp:700-708`). But that rigor is only applied to the *newer* bindings: + +| Guarded (fail-soft) | Unguarded (blind `As<>()` coercion) | +|---|---| +| `getValidatedSource` (all `*Source*` methods) | `SetGain` — NaN/Inf reaches `outputGain.store()` raw | +| `SetSlotState` (IsNumber/IsString checks) | `SetParameter`, `SetBypass`, `RemoveProcessor`, `MoveProcessor` — NaN slotId → **slot 0** | +| `SetMonitorMuteSuppressed`, `SetMonitorKill` (bridge-side Boolean coercion) | `SetMultiBypass` (per-item `As` uncheck) | +| `setBackingSpeed` (isfinite + clamp, engine-side) | `SendMidiToSlot` (channel/program unclamped → JUCE assertions) | + +Consequences of the worst one: a NaN master gain via `audio:setGain('output', NaN)` multiplies +the entire device output to NaN (`buffer.applyGain(outputGain.load())`, +`AudioEngine.cpp:2407/3083`) — full silence plus poisoned peak meters, and nothing scrubs it +(the per-source NaN scrub runs *before* the master gain). Engine-side clamps at the four gain +setters (mirroring `sanitizeStreamGain`) fix every caller at once. + +### 3. 🔴 `wasRunning` race in `setAudioDevices` (AudioEngine.cpp:658) + +`audioDeviceStopped()` clears `audioRunning` on **transient** stops, and the code's own comment +says WASAPI exclusive opens "routinely fire one mid-start". `setAudioDevices` captures +`wasRunning = audioRunning.load()` and only calls `startAudio()` at the end when it was true. +The comment above it (`:651-657`) fixes the *detach* half of this race (stopAudio is now +unconditional) but the *restart* half still reads the racy flag: a reconfigure landing inside a +transient-stop window sees `wasRunning == false` and leaves the engine configured but stopped — +"no audio until user presses Start/Apply again". The intent flag it should read is "did the user +want audio running", which currently doesn't exist separately from device state (see §6). + +### 4. 🟠 `setRendererBus(false)` violates the ring's own SPSC discipline + +`AudioEngine.h` (`setRendererBus`) drops buffered audio on disable by writing +`rendererBusReadIndex` from the **control thread**, while `pullRendererBus` +(`AudioEngine.cpp:3143-3208`) is the designated single consumer-side writer of that index (the +file's comments elsewhere are explicit that "only the consumer ever moves readIndex"). A +concurrent output callback mid-`pullRendererBus` can overwrite the control thread's store with +`r + pull`, replaying a stale tail after re-enable — exactly what the drop was meant to prevent. +Low probability, audible-blip severity; fix by setting a "flush requested" atomic the consumer +honors instead of writing its index. + +### 5. 🟠 Latency accounting has three unreconciled truths + +- `getLatencyMs()` (`:469-496`): device latencies + (split only) a static `kOutputRingFrames/2` + ≈ 42.7 ms ring-residency guess. The actual ring fill is measurable (`getDeviceMetrics` reports + it) but not used. +- Verifier auto-offset (`extraInputAboutToStart`, `:2554-2568`): per-device *input-latency + delta* only, 0 on JACK/PipeWire (documented), user offset summed on top. +- Renderer bus: adds `kRendererBusPrimeFrames` (~10 ms) prime + fill drift + producer-side + resample, none of it surfaced in any latency figure; stems audio through the bus is delayed by + an amount the UI never reports and the verifier never compensates. + +For a TLC pass: one `getLatencyBreakdown()` that owns all terms would replace three ad-hoc sums. + +### 6. 🟠 `audioRunning` conflates user intent with device state + +Writers: `startAudio`/`stopAudio` (user intent), `audioDeviceAboutToStart` (device came up — +including JUCE auto-restarts the user never asked for, `:1802`), `audioDeviceStopped` (device +went down — including transient stops the user didn't ask for). Readers assume different +meanings: `setAudioDevices` reads it as intent (§3), detection guards read it as device state +(correct), the bridge's `isAudioRunning` surfaces it to the UI as intent. Two booleans +(`userWantsAudio`, `deviceRunning`) would kill the §3 race and make the auto-restart paths +self-explanatory. Related: `stopAudio()` does not stop the backing transport — `backingPlaying` +stays true and playback resumes on the next start, which is intentional for unplug-recovery but +surprising for an explicit user stop. + +### 7. 🟡 Probe/apply duplication — three copies of the rate-tolerance logic + +The `|r - r2| <= 0.5` sample-rate matching + round-to-nominal logic exists in +`probeDeviceOptionsDual` (`:316-350`), `applySplitSetup::rateSupportedBy` (`:961-982`), and the +post-open verify (`:1146`). The comments at each site narrate keeping the three in sync by hand +("<= 0.5 (not <) to match…", "Tolerance matches the probe-side rounding…") — i.e. they've +already been bitten. Same story for empty-name→first-enumerated resolution (probe, preflight, +apply must agree; three sites). One shared helper each. + +### 8. 🟡 Device identity is display-name only (documented limitation) + +`getBindableInputDevices` (`:120-161`) and `bindInputDevice`'s duplicate/primary checks compare +`juce::String` names. Two identical interfaces collapse to one entry; a device exposed under two +backends may bind the wrong one. The comment block is honest about it; flagging here because the +fix ((typeName, name) identity threaded through bind/reopen/persistence) also touches the +renderer's saved settings — a cross-layer change worth scheduling deliberately. + +### 9. 🟡 NodeAddon miscellany + +- **`LoadPresetWorker` state restore bypasses `setSlotState`**: it `const_cast`s the slot from + `getSlot()` and calls `slot->setState(state)` directly (`:3402-3404`) — outside whatever + synchronization `SignalChain::setSlotState` provides against a concurrently-processing audio + thread. During a preset load the chain was just rebuilt so the window is small, but it's the + only chain mutation in the file that dodges the class's own API. +- **Every preset load closes every editor window** (`LoadPreset:3452`, `ClearChain:2668` — + required by the #56 use-after-free). Combined with the tone auto-switch calling loadPreset on + song events, a user tweaking an amp editor mid-song has the window yanked. A single-slot + replace path (`replaceProcessor` exists) for tone switches would avoid the nuke. +- **macOS is a second-class citizen by design**: no JUCE dispatch loop (`startJuceMessageThread` + JUCE_MAC branch), `dispatchOnMessageThread` runs inline, VST/AU instantiation "given up until + a proper libuv-based pump lands". Every load path carries a divergent `#if JUCE_MAC` branch — + a large, mostly-untested platform fork woven through the file. +- **`loadVstSandboxAware` holds a libuv worker for the whole plugin init** (documented tradeoff, + `:2241-2248`); concurrent slow loads can starve fs/crypto AsyncWorkers. +- **Misnomer**: `inputOverflowCount` is incremented by the *output consumer's* catch-up on the + primary split ring (`:2941`) — it counts ring overruns, not input overflows; the metric name + leaks into `DeviceMetrics`/diagnostics. + +### 10. What is genuinely solid (don't "fix") + +- The per-deviceKey `callbacksInFlight` handshake + `pendingRelease` deferral for source removal + (`:1554-1618`) — correct, well-reasoned, and the 200 ms bounded wait is the right call. +- Ring discipline: packed-LR single-atomic frames, consumer-side drop-oldest, `w < r` resync + after index resets, consume-vs-pull split to avoid clock skew after clamps (`:2944-2974`). +- RT allocation hygiene: every scratch pre-sized in about-to-start, every hot path clamps to + capacity instead of resizing; stream scratches deliberately fixed at ring capacity so a + hotplug about-to-start can't realloc under a live producer (`:1830-1841`). +- `ScopedNoDenormals` on *both* callback clocks with the explanation of why (`:2268-2273,2898`). +- Backing speed hand-off (pending atomic + same-block stretcher reset, `:1670-1691`) and the + read-ahead thread rationale, including the honest note about `BufferingAudioSource`'s residual + lock (`:1334-1341`). +- `bindInputDevice`'s failure hygiene: every abort path closes the half-open device; validate- + eagerly-then-close when the engine is stopped (`:2774-2783`). + +--- + +### Priority order for the TLC pass + +1. **Serialize chain mutations** (§1) — prerequisite for any single-chain-owner work from the + collisions doc; without it the executor can't even trust its own load result. +2. **Sanitize gains + slot ids natively** (§2) — small, mechanical, kills a user-visible + silence-the-app bug class. +3. **Split `audioRunning` into intent + state** (§6, fixes §3) — unlocks correct + reconfigure-under-transient-stop and clarifies every auto-restart path. +4. **Renderer-bus flush flag** (§4) — one-line-ish, closes the last SPSC discipline hole. +5. **Latency breakdown API** (§5) and **probe/apply shared helpers** (§7) — quality-of-life, + schedule with the settings-ownership work. + +--- + +# Part IV — Decomposition / Refactor Plan +Targets the two monoliths: +`AudioEngine.{h,cpp}` (819 + 3223 lines) and `NodeAddon.cpp` (3699 lines). +Builds on the findings in Part II and Part III — +several fixes there (chain-mutation serialization, gain sanitization, intent/state split) +get a natural home in the new units instead of being bolted onto the monolith. + +**Precedent**: the SourceChain extraction already proved the working method on this codebase — +move a cohesive member cluster verbatim into a class, bind shared engine atomics by reference, +keep the facade byte-identical, land in phases. This plan repeats that recipe seven more times. + +**Prime directive**: no behavior change per phase. Every phase is a pure code move that +compiles + passes the existing tests (`tests/audio_sanitize`, `tests/sandbox/*`, e2e) before +the next starts. Bug fixes ride in separate commits on top of the phase that creates their home. + +--- + +### 1. Target layout + +``` +src/audio/ + engine/ + AudioEngine.{h,cpp} facade + callback orchestration only (~500 lines total) + DeviceSetup.{h,cpp} probe/apply/teardown for duplex + split (§2.1) + SourcePool.{h,cpp} source add/remove/reclaim + in-flight counts (§2.2) + ExtraInputs.{h,cpp} InputDeviceSlot registry, bind/unbind/reopen (§2.3) + BackingPlayer.{h,cpp} transport + stretch + leveler + playhead (§2.4) + StreamSink.{h,cpp} 2nd output device + submix compose (§2.5) + RendererBus.{h,cpp} WebAudio→engine ring, push/pull/metrics (§2.6) + PackedStereoRing.h the one SPSC ring template (§2.7) + EngineState.h shared atomics: rates, block sizes, run state(§2.8) + addon/ + NodeAddon.cpp module init + binding registration only + AddonContext.{h,cpp} engine/vstHost lifetime, message thread, shutdown latch + NapiHelpers.h arg validation (the getValidatedSource pattern, generalized) + ChainOps.{h,cpp} chain workers (LoadPreset/VST/NAM/IR) + mutation queue + DeviceBindings.cpp device enumeration/config/metrics bindings + ControlBindings.cpp gain/mute/gate/stream/renderer-bus bindings + DetectionBindings.cpp pitch/chord/chart/verdict/source-indexed bindings + BackingBindings.cpp backing-track bindings + EditorWindows.{h,cpp} PluginEditorWindow + open/close/promotion + (existing DSP files stay where they are) +``` + +CMake: append the new files to the existing source list in `src/audio/CMakeLists.txt`; no +target restructuring needed. + +--- + +### 2. AudioEngine decomposition (one phase per unit) + +Ordering is by extraction risk, lowest first. Each unit lists what moves, its boundary, and +which known bug lands in it afterwards. + +#### 2.1 `PackedStereoRing` — first, everything else builds on it + +Template over capacity; owns `array>`, write/read indices, the pack/unpack +helpers, and the three ritual moves currently copy-pasted at six sites: producer publish +(`packStereoIntoRing`), consumer `w < r` resync, lapped catch-up with overflow counter, and the +pull-vs-consume split. Replaces: `outputPendingRing`, each `InputDeviceSlot::ring`, +`streamSink.ring`, `rendererBusRing`. The static_asserts move inside the template. +**Bug fixed here after the move**: none — but §2.6's flush fix becomes a one-method addition +(`requestFlush()` honored by the consumer) instead of index surgery. + +#### 2.2 `SourcePool` + +Moves: `sources[]` array, `sourcesMutex`, `callbacksInFlight[]`, `pendingRelease[]`, +`addSource/removeSource/reclaimPendingReleases/listSources/getSource`, the fan-out helpers +(`setMlNoteDetectionEnabled`, `setMonitorKill` loop, `resetPeaks` loop), and +`mixSourcesForDevice`. Boundary: callbacks call `pool.enterCallback(deviceKey)` / +`pool.exitCallback(deviceKey)` (RAII guard) and `pool.mixForDevice(...)`. +This is the most delicate move (RT-shared state) but it is also the best-commented, most +self-contained cluster — the handshake logic doesn't touch any other member. + +#### 2.3 `ExtraInputs` + +Moves: `InputDeviceSlot` + `extraInputs[]`, `bindInputDevice/unbindInputDevice/ +closeExtraInputDevice/reopenDesiredExtraInputs/activeExtraInputCount`, the extra callback +trio (`extraInputCallback/AboutToStart/Stopped`). Depends on SourcePool (prepares/releases +sources by deviceKey) and PackedStereoRing. The (typeName, name) device-identity fix +(deep-read §8) lands here later without touching the engine again. + +#### 2.4 `BackingPlayer` + +Moves: transport + reader + read-ahead thread, signalsmith stretch state, `backingLock`, +speed hand-off atomics, `BackingLeveler`, `renderBackingBlockLocked`, all playhead caches, +load/start/stop/seek/speed. Boundary: `backing.renderInto(buffer, numSamples)` returning frames +(caller mixes + meters), `backing.prepare(sr, bs)` from the about-to-start hooks. The duplex and +split callbacks already share `renderBackingBlockLocked`, so the seam exists. +**Lands here later**: the "stop engine ≠ stop backing" intent decision (deep-read §6 note). + +#### 2.5 `StreamSink` + +Already 80% a struct — promote to a class owning its manager, callback, ring, scratches, +`composeAndPushStreamMix`, `set/clear/reopen/close`. Bus flags (`includeBacking/includeGuitar/ +gain`) move in. The engine's callbacks call `sink.publish(guitarMix, backing, renderer, n)`. + +#### 2.6 `RendererBus` + +Moves: ring + indices + resampler carry-state (`rendererBusSrcPos/PrevL/PrevR`), prime/fill +constants, `pushRendererAudio/pullRendererBus/getRendererBusMetrics/setRendererBus`. +**Bug fixed here after the move**: the control-thread readIndex write on disable (deep-read §4) +becomes an atomic `flushRequested` flag consumed in `pull()`. + +#### 2.7 `DeviceSetup` + +Moves: `probeDeviceOptions[Dual]`, `applyDuplexSetup`, `applySplitSetup`, `teardownSplitMode`, +type resolution/preference tables, and the three hand-synced helpers extracted once: +`rateIntersection()`, `bufferIntersection()`, `resolveDeviceName()` (deep-read §7). Stateless +apart from references to the two managers + EngineState; takes managers by reference so it owns +no lifetime. `setAudioDevices` stays on the facade as the orchestrator (stop → resolve → +duplex-or-split → restart) but shrinks to ~40 lines. + +#### 2.8 `EngineState` + +Tiny header: `currentSampleRate`, `inputBlockSize`, `outputBlockSize`, `duplexMode`, and — +**the deliberate fix from deep-read §3/§6** — `userWantsAudio` (intent, written only by +start/stopAudio) split from `deviceRunning` (state, written by the device callbacks). +SourceChain already binds engine atomics by reference; it re-binds to this struct unchanged. +Every unit above takes `EngineState&`, which is what keeps them unit-testable without JUCE +devices (hand them a state struct + a fake ring). + +#### What remains on `AudioEngine` + +The `AudioIODeviceCallback` implementations (now ~60 lines each: enter pool guard → mix → +backing → renderer bus → sink publish → master gain → meters), the facade forwarding to +`source0()` (unchanged for NodeAddon compatibility), device enumeration getters, and +construction/destruction ordering. Header drops from 819 lines to roughly 250. + +--- + +### 3. NodeAddon decomposition + +The file is 100+ bindings sharing four bits of infrastructure. Split infrastructure first, +then the bindings become mechanical moves. + +#### 3.1 `AddonContext` — lifetime + threading + +Moves: `engine/vstHost` globals + mutexes + `snapshotEngine/snapshotVstHost`, the JUCE message +thread (`startJuceMessageThread/stop/dispatchOnMessageThread` with the macOS fork in ONE place), +`alreadyShutDown`, `doShutdown`, `registerPendingLoad/cancelAllPendingLoads`. Everything else +receives `AddonContext&`. This quarantines the `#if JUCE_MAC` platform fork (deep-read §9) into +a single file instead of a branch inside every load path. + +#### 3.2 `NapiHelpers.h` — kill the validation inconsistency structurally + +Generalize the `getValidatedSource` pattern into typed extractors: + +```cpp +std::optional argSlotId(info, i); // finite integer, >= 0 +std::optional argGain(info, i); // finite, clamped 0..8 (one policy) +std::optional argParamValue(info, i); // finite, clamped 0..1 +std::optional argBool(info, i); +``` + +Then rewriting `SetGain/SetParameter/SetBypass/SendMidiToSlot/SetMultiBypass` onto them is the +deep-read §2 fix, done once, enforced by convention (new bindings have no raw `As<>()` path to +copy). Engine-side clamps in the gain setters stay as the second belt. + +#### 3.3 `ChainOps` — the serialization point + +Moves: all five chain workers + `loadVstSandboxAware` + `decodeStateBlob`. Adds the +**chain-mutation serializer** (deep-read §1): a single `std::mutex chainMutationMutex` acquired +for the full Execute() of every worker, plus a monotonic `chainGeneration` bumped on every +mutation and returned in load results — the executor and renderer can then detect a lost race +instead of trusting a corrupted merge. The `const_cast` slot-state bypass (deep-read §9) is +replaced with `setSlotState()` during this move. + +#### 3.4 `EditorWindows` + +Moves: `PluginEditorWindow`, the window map, open/close/destroy-on-message-thread, and the +sandbox-promotion flow inside `OpenPluginEditor`. Later improvement lands here: tone-switch +single-slot replace instead of close-all-editors (collisions/deep-read editor-nuke issue). + +#### 3.5 Binding files + +`DeviceBindings/ControlBindings/DetectionBindings/BackingBindings` — pure moves, grouped to +match the preload API sections, each ~400-600 lines. `NodeAddon.cpp` keeps only `Init/Shutdown` +and the `exports.Set(...)` table (which doubles as the API index the current file lacks). + +--- + +### 4. Phase 0 — Compatibility contract & test scaffolding + +Runs BEFORE any code moves. Purpose: turn "no public API change" from a review rule into a +failing CI check, and codify the compat decisions consumers (core screen, rig_builder, +note_detect, stems) depend on. Test infrastructure follows the repo's existing two-track +convention: native `tests//test.cpp` targets registered in `tests/CMakeLists.txt`, and +Node `tests/*.test.js` for the JS/addon boundary. + +**0.a Contract snapshots (`tests/contracts/`)** + +| Snapshot | Source | How | +|---|---|---| +| `addon-exports.json` | `slopsmith_audio.node` export table | Node script: `Object.keys(require(addon)).sort()` | +| `preload-audio-api.json` | `window.feedBackDesktop.audio.*` + `audioEffects.*` key lists | static extraction from `preload.ts` | +| `ipc-channels.json` | every `ipcMain.handle`/`ipcMain.on` name in `audio-bridge.ts` | static extraction | +| `result-shapes.json` | golden key/type shapes (not values) for `loadPreset`, `loadVST`, `loadNAM/IR`, `getChainState`, `savePreset`, `getDeviceMetrics`, `getRendererBusMetrics`, and every executor outcome (`loadChainPlan`/`releaseRoute`/`setRouteGain`/…) | run against the real addon (null audio device) + executor with a stubbed native | + +CI job `contract-check` regenerates all four and diffs against the committed snapshots. +Additive keys require a deliberate snapshot update in the same PR; removals/renames fail. + +**0.b Compat decisions codified as tests** + +- `isAudioRunning` reports **device state** (current semantics) — pinned by a test across a + simulated transient stop, so the phase-1 intent/state split can't silently change it. +- Native gain clamp bounds = **0..32** (matching the executor's `clampGain`), NaN/Inf rejected + universally; only stream/renderer-bus gains keep the tighter 0..8 `sanitizeStreamGain`. + Pinned by a table-driven test so phase 8's clamps can't under-shoot a legit rig gain. +- Concurrent `loadPreset` storm test written NOW (expected-fail / quarantined): two overlapping + loads must end with the chain equal to exactly one caller's preset. Documents today's + corruption, flips to expected-pass at phase 7, and doubles as the rig_builder timing smoke + (its transient-kill/unmute heuristics tolerate serialized latency — assert its fallback + unmute path still fires). + +**0.c Native unit-test harness** + +Add a `tests/engine_units/` CMake target (same pattern as `tests/audio_sanitize`) that links the +audio sources without a real device — the home for every per-unit test below. Add a tiny +`FakeClock`/`NullDevice` helper pair here once; all later phases reuse them. + +### 5. Bespoke tests per phase (unit + integration) + +Each extraction phase ships WITH its tests in the same PR — the unit tests pin the moved logic, +the integration gate proves the seam. "U" = `tests/engine_units` C++ test, "I" = Node/e2e. + +| Phase | Unit tests (new) | Integration tests | +|---|---|---| +| 1 `PackedStereoRing` | U: SPSC threaded stress (producer/consumer at different block sizes); wrap + drop-oldest lap; `w < r` resync after index reset; L/R tear check under lap (packed-atomic invariant); pull-vs-consume skew accounting; overflow/underflow counters | I: existing audio smoke (duplex, split, stream, renderer bus pass audio); contract-check green | +| 1 `EngineState` | U: intent/state transition table — user start/stop × device aboutToStart/stopped × transient stop; `isAudioRunning` compat pin from 0.b stays green | I: reconfigure-during-transient-stop scenario (documents deep-read §3; expected-fail until phase 8) | +| 2 `RendererBus` | U: resampler continuity across pushes (fractional pos + carried frame → no discontinuity at chunk seams); equal-rate degenerate path bit-exact; prime gate (no output until ~10 ms); underflow → silence + re-prime; fill clamp trims to prime target; flush-on-disable drops tail (expected-fail until phase 8 flag fix); metrics arithmetic | I: `getRendererBusMetrics` shape + push/consume accounting via addon against null device | +| 2 `StreamSink` | U: submix compose matrix (guitar/backing/renderer × include flags × gain); oversized-block skipped AND counted; scratch-not-sized skip is silent-safe | I: OBS-capture manual smoke; stream under/overflow counters via IPC | +| 3 `BackingPlayer` | U (synthetic reader source): speed change adopts rate + stretch reset in same block; EOF short-read playhead clamp; stretch-latency compensation vs 1× bypass; leveler re-prepare on SR change; tryLock-miss drops block without state damage | I: existing backing play/seek/speed e2e, duplex + split; `audio-chain-persistence.test.js` green | +| 4 `DeviceSetup` | U: `rateIntersection`/`bufferIntersection`/`resolveDeviceName` helpers — incl. the 0.5 Hz boundary cases the three duplicated sites hand-narrate today, midpoint-rounding fail-closed cases, empty-name resolution parity | I: manual device matrix (WASAPI shared/exclusive, ASIO, dual-type split); probe verdict == apply outcome assertion in a scripted run | +| 5 `SourcePool` | U: threaded add/remove storm under a fake callback loop — per-deviceKey quiescence handshake, deferred release + reclaim, no release while in-flight (TSAN job on this target); active-snapshot consistency in `mixForDevice` | I: `multi-source.test.js` + `tests/sandbox` e2e (GP-5 scenario), remove-under-load | +| 5 `ExtraInputs` | U: bind rejection matrix (duplicate name, primary device, duplex mode, out-of-range key); transient close keeps intent, permanent unbind clears + deactivates; reopen-failure ghost-source cleanup | I: second-interface e2e; meters zeroed while device gone | +| 6 `NapiHelpers` | I (Node, real addon): table-driven arg fuzz per extractor — NaN/Inf/negative/string/missing/object for slot ids, gains, params, midi bytes → no crash, documented no-op or clamp; pins the 0.b clamp decisions | I: addon init→shutdown→init cycle; pending-load cancellation on shutdown | +| 7 `ChainOps` | U: serializer — N threads × (loadPreset/loadVST/clearChain) → final chain equals exactly one caller's request, `chainGeneration` strictly monotonic, per-caller result reports the generation it produced | I: 0.b storm test flips to expected-pass; `audio-effects-executor.test.js` extended — executor detects stale generation; rig_builder legacy-path telemetry smoke; editor open/close + sandbox promotion e2e | +| 8 bug fixes | U: gain clamp tables (0..32 native, NaN reject); renderer flush flag; `wasRunning` intent read (phase-1 expected-fail flips to pass); latency breakdown terms sum | I: full regression: all snapshots + all suites green | + +Cross-cutting: + +- **TSAN/ASAN lane** for `tests/engine_units` in CI (the ring/pool tests are exactly what + sanitizers are for; the RT code has never had one). +- **Expected-fail discipline**: known bugs get their test at the phase that creates the home, + marked expected-fail with the deep-read § reference; the fix commit flips the mark. No fix + lands without its test having existed first. +- Existing suites (`audio_sanitize`, `chordscorer`, `mlnotedetector`, `sandbox/*`, + `*.test.js`) run on every phase — they are the behavior-freeze net. + +### 6. Phasing & verification + +| Phase | Content | Risk | Gate | +|---|---|---|---| +| **0** | **Contract snapshots + compat pins + `tests/engine_units` harness + storm test (expected-fail)** | **none (test-only)** | **`contract-check` job green; snapshots committed; harness builds on all 3 platforms** | +| 1 | `PackedStereoRing` + `EngineState` (incl. intent/state split behind a facade-compatible `isAudioRunning`) | low | phase-1 unit tests + audio smoke: duplex, split, stream sink, renderer bus each pass audio | +| 2 | `RendererBus`, `StreamSink` | low | phase-2 unit tests; renderer-bus metrics unchanged in diag build; OBS capture works | +| 3 | `BackingPlayer` | low-med | phase-3 unit tests; backing play/seek/speed e2e; split + duplex | +| 4 | `DeviceSetup` | med | helper unit tests; device matrix: WASAPI shared/exclusive, ASIO, dual-type split, probe==apply verdicts | +| 5 | `SourcePool` + `ExtraInputs` | med-high | TSAN-clean pool stress; multi-source + second-interface tests (`tests/sandbox` e2e, GP-5 scenario), remove-under-load | +| 6 | `AddonContext` + `NapiHelpers` | low | arg-fuzz suite; addon init/shutdown cycles, macOS build | +| 7 | `ChainOps` (with serializer) + `EditorWindows` + binding split | med | storm test flips to pass; serializer unit tests; editor open/close, sandbox promotion | +| 8 | Bug-fix commits now homed: gain clamps, renderer flush flag, `wasRunning` intent read, latency breakdown | — | each fix flips its pre-existing expected-fail test | + +Every phase additionally requires: `contract-check` green (public surface unchanged) and the +full pre-existing suite green. + +Rules that keep this safe: + +- **Move, don't edit**: each phase's diff should be reviewable as "same lines, new file" plus a + thin call seam. The excellent existing comments move with their code. +- **Reference-bind shared state** (the SourceChain trick) rather than adding getters — keeps the + RT paths free of indirection changes. +- **No public API change**: NodeAddon exports, IPC channel names, and preload surface stay + identical throughout; the collisions-doc ownership work (single chain owner, monitor-state + arbiter) is a separate track that starts after phase 7 gives it `chainGeneration`. +- Tester diag counters (`audiodiag`, `[asio-diag]`) must survive verbatim — they're how field + regressions get caught. + +### 7. Explicit non-goals + +- Rewriting the JS layer (`audio-bridge.ts` 65K / `preload.ts`) — separate track; its shape + already mirrors the binding groups this plan creates. +- Replacing JUCE transport/BufferingAudioSource, adaptive resampling for split mode, sandbox + parameter proxies — feature work, not decomposition. +- Renaming slopsmith→feedBack artifacts — orthogonal, and renaming during a move-refactor + destroys diff reviewability. + +--- + +# Part V — Merged Priority Roadmap + +One ordered list combining the collision remediation (Part II), the deep-read fixes (Part III), +and the decomposition phases (Part IV). Decomposition and fixes interleave: each fix lands as a +separate commit in the unit that becomes its home. + +1. **Phase 0** — contract snapshots, compat pins (gain bounds, `isAudioRunning` semantics), + unit-test harness, concurrency storm test (expected-fail). +2. **Refactor phases 1–2** (`PackedStereoRing`, `EngineState` with intent/state split, + `RendererBus`, `StreamSink`) — low risk, creates the homes. +3. **Fix: renderer-bus flush flag** (III §4) and **gain/slot-id sanitization** (III §2) — + small, kills the NaN-master-gain and stale-tail bug classes. +4. **Refactor phases 3–5** (`BackingPlayer`, `DeviceSetup`, `SourcePool` + `ExtraInputs`). +5. **Fix: `wasRunning` intent read in setAudioDevices** (III §3) — now trivial on the split + intent/state atomics. +6. **Refactor phases 6–7** (`AddonContext`, `NapiHelpers`, `ChainOps` + serializer + + `chainGeneration`, `EditorWindows`, binding split). +7. **Ownership work (Part II)** — single chain owner via executor (needs `chainGeneration`), + refcounted monitor-state arbiter, one persistence store per setting, tone-switch + single-slot replace instead of editor nuke. +8. **Long tail** — latency breakdown API, (typeName, name) device identity, + slopsmith→feedBack alias deprecation. diff --git a/src/audio/AudioEngine.cpp b/src/audio/AudioEngine.cpp index 8f4d881..4f258ea 100644 --- a/src/audio/AudioEngine.cpp +++ b/src/audio/AudioEngine.cpp @@ -20,9 +20,7 @@ inline bool firstN(std::atomic& c) { return c.fetch_add(1, std::memory } // Hard ceiling on backing playback speed. This drives input buffer sizing and runtime clamp. -static constexpr double kMaxBackingSpeed = 4.0; // Transparent full-speed path — skip the stretcher when rate is effectively 1×. -static constexpr double kBackingSpeedBypassEpsilon = 1.0e-4; // On Windows, ASIO drivers can crash with access violations. // We catch C++ exceptions but can't easily catch SEH in functions with dtors. @@ -31,32 +29,14 @@ static constexpr double kBackingSpeedBypassEpsilon = 1.0e-4; AudioEngine::AudioEngine() { - formatManager.registerBasicFormats(); // Start the backing read-ahead worker so the transport's BufferingAudioSource // always has a live thread to pull decoded audio on. It sleeps while idle and // costs nothing until a track is loaded. - backingReadThread.startThread(); - // Construct the full source pool up front so addSource/removeSource never - // reassign a pointer the audio thread reads — they only flip `active`. Each - // chain reads the engine's audioRunning / currentSampleRate atomics by - // reference (both already constructed as members before this body runs). - // sources[0] is the permanent default input, active from the start; the rest - // are inactive (no threads — NoteVerifier's worker only starts in prepare()). - for (int i = 0; i < kMaxSources; ++i) - sources[(size_t) i] = std::make_unique(i, audioRunning, currentSampleRate); - sources[0]->setActive(true); + // The source pool (chains, quiescence handshake) lives on SourcePool now. - // Phase 2: tag each additional-input slot with its identity so its JUCE - // callback can route back to the engine. deviceKey = slot index + 1 (0 is the - // primary inputDeviceManager). The managers stay idle until bindInputDevice. - for (int i = 0; i < kMaxExtraInputDevices; ++i) - { - extraInputs[(size_t) i].callback.engine = this; - extraInputs[(size_t) i].callback.slot = i; - extraInputs[(size_t) i].deviceKey = i + 1; - } + // Extra-input slot registry lives on ExtraInputs now. auto result = inputDeviceManager.initialiseWithDefaultDevices(2, 2); if (result.isNotEmpty()) @@ -87,11 +67,7 @@ AudioEngine::~AudioEngine() { // Stop every extra input device FIRST so no slot callback can fire into a // half-destroyed engine. closeAudioDevice blocks for the callback thread. - for (int i = 0; i < kMaxExtraInputDevices; ++i) - { - extraInputs[(size_t) i].manager.closeAudioDevice(); - extraInputs[(size_t) i].manager.removeAudioCallback(&extraInputs[(size_t) i].callback); - } + extraInputs.closeAllForShutdown(); stopAudio(); stopBacking(); } @@ -119,45 +95,7 @@ juce::Array AudioEngine::getDeviceTypes() std::vector AudioEngine::getBindableInputDevices() { - std::vector out; - // The device already open as the primary input IS "Main" — don't offer it as - // an extra (would double-open the same hardware on two managers). - juce::String primaryName; - if (auto* dev = inputDeviceManager.getCurrentAudioDevice()) - primaryName = dev->getName(); - // Enumerate across ALL device types, not just the primary's current one — - // bindInputDevice() can open a device under any backend (JACK/ALSA/CoreAudio/…), - // so an extra interface exposed under a DIFFERENT backend than the primary must - // still be offered, or the multi-device path is unreachable from the picker. - // - // KNOWN LIMITATION: identity is the display name. JUCE opens input devices BY - // NAME, so two interfaces sharing a label (e.g. two identical USB cables) cannot - // be distinguished or independently opened without a backend-specific device-id - // rework — they collapse to one entry here. The SAME root cause makes a device - // exposed under MULTIPLE backends (e.g. ALSA + JACK/PipeWire on Linux) ambiguous: - // we dedup by name and bindInputDevice() re-derives the backend (preferring the - // primary's), so we may bind the wrong backend if only another would open. A real - // fix needs (typeName, name) identity threaded through bind/reopen. Distinct-name, - // single-backend rigs (the common case, and the validated GP-5 + Spark setup) are - // unaffected. - juce::StringArray seen; - for (auto* t : inputDeviceManager.getAvailableDeviceTypes()) - { - if (!t) continue; - t->scanForDevices(); - const juce::String typeName = t->getTypeName(); - for (const auto& name : t->getDeviceNames(true)) - { - if (name == primaryName || seen.contains(name)) continue; // dedup across backends - // Skip monitor / loopback pseudo-inputs — not instrument inputs, only - // confuse the picker. - const juce::String lower = name.toLowerCase(); - if (lower.contains("monitor") || lower.contains("loopback")) continue; - seen.add(name); - out.push_back({ typeName, name }); - } - } - return out; + return extraInputs.listBindable(); } juce::Array AudioEngine::getSampleRates() @@ -194,212 +132,7 @@ AudioEngine::DeviceOptions AudioEngine::probeDeviceOptionsDual(const juce::Strin const juce::String& outputTypeName, const juce::String& outputName) { - DeviceOptions options; - options.inputType = inputTypeName; - options.outputType = outputTypeName.isEmpty() ? inputTypeName : outputTypeName; - options.type = options.inputType; // legacy alias - - // Resolve each side from its own manager so probe stays consistent with - // applySplitSetup()/setOutputDeviceType(), which mutate the manager that - // owns the side they're configuring. Using inputDeviceManager for the - // output lookup would silently fall back to whatever input has scanned, - // which can miss output-only backends. - auto findType = [](juce::AudioDeviceManager& manager, - const juce::String& wanted) -> juce::AudioIODeviceType* { - juce::AudioIODeviceType* match = nullptr; - for (auto* type : manager.getAvailableDeviceTypes()) - { - if ((wanted.isNotEmpty() && type->getTypeName() == wanted) - || (wanted.isEmpty() && match == nullptr)) - { - match = type; - if (wanted.isNotEmpty()) break; - } - } - return match; - }; - - auto* inputType = findType(inputDeviceManager, options.inputType); - - // Match setAudioDevices's resolution: when the caller didn't specify - // an output type, default it to the SAME type the input side resolved - // to (using the type's name, looked up in outputDeviceManager). Without - // this, an empty `options.outputType` would let findType pick whatever - // outputDeviceManager enumerates first — potentially a different - // backend than inputDeviceManager picked from the empty string, which - // then disagrees with the apply path's duplex classification. - juce::String effectiveOutputTypeName = options.outputType; - if (effectiveOutputTypeName.isEmpty() && inputType != nullptr) - effectiveOutputTypeName = inputType->getTypeName(); - auto* outputType = findType(outputDeviceManager, effectiveOutputTypeName); - - if (inputType == nullptr) - { - options.error = "Input device type not found"; - options.compatible = false; - return options; - } - if (outputType == nullptr) - { - options.error = "Output device type not found"; - options.compatible = false; - return options; - } - - try - { - options.inputType = inputType->getTypeName(); - options.outputType = outputType->getTypeName(); - options.type = options.inputType; - - options.input = inputName; - options.output = outputName; - - // For probing we still need a concrete device to instantiate. - // Resolve empty names to first-enumerated ONLY for the probe-device - // creation below — DON'T write back into options.input/options.output; - // those flow to the UI and the apply path, which treat empty as - // "OS default" per side. - auto inputs = inputType->getDeviceNames(true); - auto outputs = outputType->getDeviceNames(false); - const juce::String probeInputName = - options.input.isEmpty() && inputs.size() > 0 ? inputs[0] : options.input; - const juce::String probeOutputName = - options.output.isEmpty() && outputs.size() > 0 ? outputs[0] : options.output; - - // Probe the SAME way setAudioDevices() will actually apply, or the - // startup auto-apply mis-fires: init() fail-closes on this probe's - // `compatible` verdict, so if the probe measures a combined duplex device - // but apply then opens split (or vice-versa), the verdict describes a - // config that won't be the one used — the classic symptom being "no audio - // until I press Apply". Duplex is only attempted for the SAME physical - // endpoint (a true single-clock device); two different endpoints of the - // same backend (USB cable in + separate speakers out) are two clocks and - // go split. Mirror setAudioDevices()'s sameEndpointIntent exactly. - bool isDuplex = (options.inputType == options.outputType) - && (options.input == options.output); - - if (isDuplex) - { - std::unique_ptr dev( - inputType->createDevice(probeOutputName, probeInputName)); - if (dev) - { - options.inputChannels = dev->getInputChannelNames(); - options.outputChannels = dev->getOutputChannelNames(); - for (auto rate : dev->getAvailableSampleRates()) - options.sampleRates.addIfNotAlreadyThere(rate); - for (auto size : dev->getAvailableBufferSizes()) - options.bufferSizes.addIfNotAlreadyThere(size); - } - else - { - isDuplex = false; - } - } - if (!isDuplex) - { - std::unique_ptr inDev( - inputType->createDevice({}, probeInputName)); - std::unique_ptr outDev( - outputType->createDevice(probeOutputName, {})); - if (!inDev || !outDev) - { - options.error = "Could not create dual probe devices"; - options.compatible = false; - return options; - } - - options.inputChannels = inDev->getInputChannelNames(); - options.outputChannels = outDev->getOutputChannelNames(); - - // Tolerance covers backends that report fractional drift around the nominal rate. - const auto inRates = inDev->getAvailableSampleRates(); - const auto outRates = outDev->getAvailableSampleRates(); - for (auto r : inRates) - { - for (auto r2 : outRates) - { - // <= 0.5 (not <) to match applySplitSetup's rateSupportedBy - // check. A backend reporting 47999.5 on both sides has - // |r - r2| = 0 (matches anyway) but a backend mixing - // 47999.5 in / 48000.0 out has |diff| = 0.5 exactly, which - // < 0.5 would reject from the probe even though the - // apply-side check accepts it. - if (std::abs(r - r2) <= 0.5) - { - // Round the midpoint to a clean nominal rate - // (backends sometimes report fractional near-48000 - // rates; surfacing the raw value would fail the - // apply-side setAudioDeviceSetup, which expects an - // exact supported nominal). Re-check the rounded - // candidate is within tolerance of BOTH sides — a - // matched pair like 48000.4/48000.6 passes the |r-r2| - // check but std::round(48000.4)=48000 would fall - // outside tolerance of 48000.6 (diff 0.6). Skip - // those so the probe stays fail-closed. - const double candidate = std::round((r + r2) * 0.5); - if (std::abs(r - candidate) <= 0.5 - && std::abs(r2 - candidate) <= 0.5) - { - options.sampleRates.addIfNotAlreadyThere(candidate); - } - break; - } - } - } - if (options.sampleRates.isEmpty()) - { - options.error = "Input and output devices share no common sample rate"; - options.compatible = false; - } - - // Split mode opens both sides with the same bufferSize, so the - // UI should only see sizes the intersection of both devices - // supports — a union would let the user pick a value that - // predictably fails at apply time on one side. - const auto inBufs = inDev->getAvailableBufferSizes(); - const auto outBufs = outDev->getAvailableBufferSizes(); - for (auto b : inBufs) - { - for (auto b2 : outBufs) - { - if (b == b2) - { - options.bufferSizes.addIfNotAlreadyThere(b); - break; - } - } - } - // An empty intersection means there's no buffer size both sides - // accept; setting compatible=false stops the UI from re-enabling - // Apply against a guaranteed-fail config. - if (options.bufferSizes.isEmpty() && options.error.isEmpty()) - { - options.error = "Input and output devices share no common buffer size"; - options.compatible = false; - } - } - - fprintf(stderr, "[AudioEngine] Probed device options: inType='%s' outType='%s' in='%s' out='%s' " - "duplex=%d inputs=%d outputs=%d rates=%d buffers=%d compatible=%d\n", - options.inputType.toRawUTF8(), options.outputType.toRawUTF8(), - options.input.toRawUTF8(), options.output.toRawUTF8(), - (int) isDuplex, options.inputChannels.size(), options.outputChannels.size(), - options.sampleRates.size(), options.bufferSizes.size(), (int) options.compatible); - } - catch (const std::exception& e) - { - options.error = e.what(); - options.compatible = false; - } - catch (...) - { - options.error = "Probe failed"; - options.compatible = false; - } - - return options; + return deviceSetup.probeDual(inputTypeName, inputName, outputTypeName, outputName); } juce::String AudioEngine::getCurrentDeviceType() @@ -458,8 +191,8 @@ AudioEngine::DeviceMetrics AudioEngine::getDeviceMetrics() const // (w - r) larger than capacity. Clamp uint64 → int via the // capacity ceiling so the consumer-facing field never overflows // or goes negative. - const uint64_t w = outputRingWriteIndex.load(std::memory_order_acquire); - const uint64_t r = outputRingReadIndex.load(std::memory_order_acquire); + const uint64_t w = outputRing.writeIndex.load(std::memory_order_acquire); + const uint64_t r = outputRing.readIndex.load(std::memory_order_acquire); const uint64_t fill = (w >= r) ? (w - r) : 0; m.outputRingFillFrames = (int) std::min(fill, (uint64_t) kOutputRingFrames); } @@ -495,6 +228,54 @@ double AudioEngine::getLatencyMs() const return (totalSamples / sr) * 1000.0; } +AudioEngine::LatencyBreakdown AudioEngine::getLatencyBreakdown() const +{ + LatencyBreakdown b; + const double sr = currentSampleRate.load(std::memory_order_relaxed); + b.sampleRate = sr; + b.duplex = duplexMode.load(std::memory_order_relaxed); + if (sr <= 0.0) return b; + const auto ms = [sr](double samples) { return (samples / sr) * 1000.0; }; + + if (b.duplex) + { + if (auto* device = inputDeviceManager.getCurrentAudioDevice()) + { + b.deviceBufferMs = ms(device->getCurrentBufferSizeSamples()); + b.inputLatencyMs = ms(device->getInputLatencyInSamples()); + b.outputLatencyMs = ms(device->getOutputLatencyInSamples()); + } + } + else + { + if (auto* in = inputDeviceManager.getCurrentAudioDevice()) + { + b.deviceBufferMs += ms(in->getCurrentBufferSizeSamples()); + b.inputLatencyMs = ms(in->getInputLatencyInSamples()); + } + if (auto* out = outputDeviceManager.getCurrentAudioDevice()) + { + b.deviceBufferMs += ms(out->getCurrentBufferSizeSamples()); + b.outputLatencyMs = ms(out->getOutputLatencyInSamples()); + } + // MEASURED ring residency — getLatencyMs() still reports the static + // half-capacity estimate for compatibility; this is the live figure + // getDeviceMetrics already exposes, converted to time. + const uint64_t w = outputRing.writeIndex.load(std::memory_order_acquire); + const uint64_t r = outputRing.readIndex.load(std::memory_order_acquire); + const uint64_t fill = (w >= r) ? (w - r) : 0; + b.splitRingMs = ms((double) std::min(fill, (uint64_t) kOutputRingFrames)); + } + b.monitorTotalMs = b.deviceBufferMs + b.inputLatencyMs + b.outputLatencyMs + b.splitRingMs; + + // Song audio over the renderer bus is delayed by the measured bus fill — + // a term no previous latency figure surfaced (deep-read 5). + const auto busMetrics = rendererBus.metrics(); + if (busMetrics.enabled) + b.rendererBusMs = ms((double) busMetrics.fillFrames); + return b; +} + // ── Device Selection ────────────────────────────────────────────────────────── bool AudioEngine::setDeviceType(const juce::String& typeName) @@ -655,7 +436,15 @@ AudioEngine::DeviceConfigResult AudioEngine::setAudioDevices(const DeviceConfig& // stale output callback attached. stopAudio() is itself idempotent // (R9 fix — removeAudioCallback is a no-op when not registered), so // running it unconditionally is safe regardless of audioRunning. - const bool wasRunning = audioRunning.load(std::memory_order_relaxed); + // Read USER INTENT, not device state (deep-read §3 fix): audioRunning + // (deviceRunning) is cleared by transient audioDeviceStopped() fires — + // WASAPI exclusive opens routinely fire one mid-start — so a reconfigure + // racing that window used to see false and leave the engine configured + // but stopped ("no audio until Start/Apply is pressed again"). + // userWantsAudio is written only by start/stopAudio, so it answers the + // question this restart decision actually asks. NOTE: stopAudio() below + // clears the intent flag, hence the capture BEFORE it. + const bool wasRunning = state.userWantsAudio.load(std::memory_order_relaxed); // stopAudio() closes every extra input device but KEEPS its desiredDeviceName; // the startAudio() below re-opens them at the new config (so panels using a @@ -723,8 +512,9 @@ AudioEngine::DeviceConfigResult AudioEngine::setAudioDevices(const DeviceConfig& { teardownSplitMode(); - const juce::String err = applyDuplexSetup(resolvedInput, resolvedOutput, - requestedSampleRate, requestedBufferSize); + const juce::String err = deviceSetup.applyDuplex(resolvedInput, resolvedOutput, + requestedSampleRate, requestedBufferSize, + source0()); if (err.isEmpty()) { duplexMode.store(true, std::memory_order_relaxed); @@ -758,7 +548,9 @@ AudioEngine::DeviceConfigResult AudioEngine::setAudioDevices(const DeviceConfig& resolved.sampleRate = requestedSampleRate; resolved.bufferSize = requestedBufferSize; - res = applySplitSetup(resolved); + res = deviceSetup.applySplit(resolved, source0(), outputRing, + outputUnderflowCount, inputOverflowCount, + outputCallback, outputCallbackRegistered); if (!res.ok) return res; duplexMode.store(false, std::memory_order_relaxed); @@ -772,428 +564,19 @@ AudioEngine::DeviceConfigResult AudioEngine::setAudioDevices(const DeviceConfig& return res; } -juce::String AudioEngine::applyDuplexSetup(const juce::String& inputName, - const juce::String& outputName, - double sampleRate, int bufferSize) -{ - juce::AudioDeviceManager::AudioDeviceSetup setup; - setup.inputDeviceName = inputName; - setup.outputDeviceName = outputName; - setup.sampleRate = sampleRate > 0 ? sampleRate : 48000.0; - setup.bufferSize = bufferSize > 0 ? bufferSize : 256; - setup.useDefaultInputChannels = inputName.isEmpty(); - setup.useDefaultOutputChannels = outputName.isEmpty(); - - // Channel masks must match too — high-numbered selectedInputChannel needs - // the expanded mask that an older session may not have opened. - if (auto* currentDevice = inputDeviceManager.getCurrentAudioDevice()) - { - try - { - juce::AudioDeviceManager::AudioDeviceSetup current; - inputDeviceManager.getAudioDeviceSetup(current); - - const int advertisedInputs = currentDevice->getInputChannelNames().size(); - juce::BigInteger expectedInputs; - expectedInputs.setRange(0, advertisedInputs > 0 ? advertisedInputs : 2, true); - - const int advertisedOutputs = currentDevice->getOutputChannelNames().size(); - juce::BigInteger expectedOutputs; - expectedOutputs.setRange(0, juce::jmin(advertisedOutputs > 0 ? advertisedOutputs : 2, 2), true); - - if (current.inputDeviceName == setup.inputDeviceName - && current.outputDeviceName == setup.outputDeviceName - && current.sampleRate == setup.sampleRate - && current.bufferSize == setup.bufferSize - && current.useDefaultInputChannels == setup.useDefaultInputChannels - && current.useDefaultOutputChannels == setup.useDefaultOutputChannels - && current.inputChannels == expectedInputs - && current.outputChannels == expectedOutputs - && duplexMode.load(std::memory_order_relaxed)) - { - fprintf(stderr, "[AudioEngine] Duplex device already configured with same settings, skipping\n"); - return {}; - } - } - catch (const std::exception& e) - { - fprintf(stderr, "[AudioEngine] Current device channel check failed: %s\n", e.what()); - } - catch (...) - { - fprintf(stderr, "[AudioEngine] Current device channel check failed (unknown)\n"); - } - } - - // ALSA deadlocks on reconfigure unless we fully close first. WASAPI - // reconfigures in place and is much slower if closed. -#if JUCE_LINUX - juce::String currentTypeName; - if (auto* currentType = inputDeviceManager.getCurrentDeviceTypeObject()) - currentTypeName = currentType->getTypeName(); - if (inputDeviceManager.getCurrentAudioDevice() != nullptr) - { - try { - inputDeviceManager.closeAudioDevice(); - fprintf(stderr, "[AudioEngine] Closed device for reconfiguration\n"); - if (currentTypeName.isNotEmpty()) - inputDeviceManager.setCurrentAudioDeviceType(currentTypeName, true); - } catch (...) { - fprintf(stderr, "[AudioEngine] closeAudioDevice crashed, continuing\n"); - } - } -#endif - - int inputChannelCount = 0; - int outputChannelCount = 0; - if (auto* type = inputDeviceManager.getCurrentDeviceTypeObject()) - { - try - { - if (auto probe = std::unique_ptr(type->createDevice(outputName, inputName))) - { - inputChannelCount = probe->getInputChannelNames().size(); - outputChannelCount = probe->getOutputChannelNames().size(); - } - } - catch (const std::exception& e) - { - fprintf(stderr, "[AudioEngine] Channel probe failed: %s\n", e.what()); - } - catch (...) - { - fprintf(stderr, "[AudioEngine] Channel probe failed (unknown)\n"); - } - } - if (inputChannelCount <= 0) inputChannelCount = 2; - if (outputChannelCount <= 0) outputChannelCount = 2; - - setup.inputChannels.setRange(0, inputChannelCount, true); - setup.outputChannels.setRange(0, juce::jmin(outputChannelCount, 2), true); - - juce::String result; - try { - result = inputDeviceManager.setAudioDeviceSetup(setup, true); - } catch (...) { - return "setAudioDeviceSetup threw"; - } - if (result.isNotEmpty()) - { - fprintf(stderr, "[AudioEngine] Device setup error: %s\n", result.toRawUTF8()); - try { - result = inputDeviceManager.initialiseWithDefaultDevices(2, 2); - } catch (...) { - return "fallback initialiseWithDefaultDevices threw"; - } - if (result.isNotEmpty()) - return "device setup failed: " + result; - } - - if (auto* configuredDevice = inputDeviceManager.getCurrentAudioDevice()) - { - const double sr = configuredDevice->getCurrentSampleRate(); - const int bs = configuredDevice->getCurrentBufferSizeSamples(); - currentSampleRate.store(sr, std::memory_order_relaxed); - inputBlockSize.store(bs, std::memory_order_relaxed); - outputBlockSize.store(bs, std::memory_order_relaxed); - - fprintf(stderr, "[AudioEngine] Duplex device configured OK. Current device: %s\n", - configuredDevice->getName().toRawUTF8()); - fprintf(stderr, "[AudioEngine] Actual device setup: sr=%.0f bs=%d (requested bs=%d)\n", - sr, bs, bufferSize); - - source0().prepareMonitorChain(sr, bs); - return {}; - } - currentSampleRate.store(0.0, std::memory_order_relaxed); - inputBlockSize.store(0, std::memory_order_relaxed); - outputBlockSize.store(0, std::memory_order_relaxed); - source0().releaseMonitorChain(); - return "no current device after setup"; -} - -AudioEngine::DeviceConfigResult AudioEngine::applySplitSetup(const DeviceConfig& config) -{ - DeviceConfigResult res; - res.duplex = false; - - // The split-mode output ring is fixed at kOutputRingFrames samples - // (~85ms @ 48kHz). A single callback at bufferSize > kOutputRingFrames - // would overrun the ring in one go, guaranteeing immediate - // overwrite/wrap and audible glitches. Reject those configurations up - // front — duplex still works fine since it bypasses the ring entirely. - if (config.bufferSize > kOutputRingFrames) - { - res.error = "Buffer size " + juce::String(config.bufferSize) - + " exceeds split-mode ring capacity (" - + juce::String(kOutputRingFrames) + "). Pick a smaller buffer size or use duplex."; - return res; - } - - // setCurrentAudioDeviceType can throw from JUCE backends (ASIO). - // Catch so the failure surfaces as a structured error rather than an - // exception crossing the N-API boundary. - try - { - if (auto* current = outputDeviceManager.getCurrentDeviceTypeObject()) - { - if (current->getTypeName() != config.outputType) - outputDeviceManager.setCurrentAudioDeviceType(config.outputType, true); - } - else - { - outputDeviceManager.setCurrentAudioDeviceType(config.outputType, true); - } - } - catch (...) - { - res.error = "setCurrentAudioDeviceType threw for output type '" + config.outputType + "'"; - return res; - } - - // v1 forces matching nominal SR — no adaptive resampler yet. - // Resolve empty name to first-enumerated for the createDevice probe - // call (matches probeDeviceOptionsDual's strategy). createDevice("") - // is implementation-defined per backend — some return the default, - // some return null. Using first-enumerated keeps probe and apply - // checking the SAME concrete device, so an empty-name config can't - // pass the UI probe and then fail this check. - auto rateSupportedBy = [&](juce::AudioIODeviceType* t, - const juce::String& dev, bool isInput, double sr) { - if (!t) return false; - juce::String resolved = dev; - if (resolved.isEmpty()) - { - auto names = t->getDeviceNames(isInput); - if (names.size() > 0) resolved = names[0]; - } - std::unique_ptr probe( - isInput ? t->createDevice({}, resolved) : t->createDevice(resolved, {})); - if (!probe) return false; - // Tolerance matches the probe-side rounding: probeDeviceOptionsDual - // rounds the matched rate to the nearest integer (see :208), so a - // backend reporting e.g. 47999.5 surfaces 48000 in the UI. If we - // kept `< 0.5` here, the round-trip would fail at apply time because - // |47999.5 - 48000.0| is exactly 0.5. Use `<= 0.5` so the boundary - // case the probe accepted is also accepted at apply. - for (auto r : probe->getAvailableSampleRates()) - if (std::abs(r - sr) <= 0.5) return true; - return false; - }; - juce::AudioIODeviceType* inputType = nullptr; - juce::AudioIODeviceType* outputType = nullptr; - for (auto* t : inputDeviceManager.getAvailableDeviceTypes()) - if (t->getTypeName() == config.inputType) { inputType = t; break; } - for (auto* t : outputDeviceManager.getAvailableDeviceTypes()) - if (t->getTypeName() == config.outputType) { outputType = t; break; } - if (!inputType || !outputType) - { - res.error = "Device type not found"; - return res; - } - if (!rateSupportedBy(inputType, config.inputDevice, true, config.sampleRate) - || !rateSupportedBy(outputType, config.outputDevice, false, config.sampleRate)) - { - res.error = "Sample rate not supported by both input and output devices"; - return res; - } - - juce::AudioDeviceManager::AudioDeviceSetup inSetup; - // Resolve empty name to first-enumerated input device — matches the - // rateSupportedBy preflight above AND probeDeviceOptionsDual. Using - // empty + useDefault*Channels here would make JUCE open the OS - // default, which can differ from inputs[0] on platforms where the - // OS-default differs from JUCE's enumeration order. The probe + SR - // preflight + actual open all need to agree on the same concrete - // device for the apply path to behave consistently with what the UI - // showed the user. - juce::String resolvedInputName = config.inputDevice; - if (resolvedInputName.isEmpty()) - { - auto names = inputType->getDeviceNames(true); - if (names.size() > 0) resolvedInputName = names[0]; - } - - inSetup.inputDeviceName = resolvedInputName; - inSetup.outputDeviceName = ""; - inSetup.sampleRate = config.sampleRate; - inSetup.bufferSize = config.bufferSize; - inSetup.useDefaultInputChannels = false; - inSetup.useDefaultOutputChannels = false; - - int inputChannelCount = 0; - { - try { - std::unique_ptr probe(inputType->createDevice({}, resolvedInputName)); - if (probe) inputChannelCount = probe->getInputChannelNames().size(); - } catch (...) {} - } - if (inputChannelCount <= 0) inputChannelCount = 2; - inSetup.inputChannels.setRange(0, inputChannelCount, true); - inSetup.outputChannels.clear(); - - // Rollback helper: on any failure path after a side has been opened, - // close both managers' devices so we don't leave the OS audio resource - // held (sometimes exclusively, e.g. ASIO) while setDevice reports a - // failure. closeAudioDevice is idempotent so unconditional calls are - // safe even when only the input or neither side opened. - auto rollbackOpenedDevices = [&]() { - // Drop any callback we already attached to the output manager — - // closeAudioDevice() does not invoke removeAudioCallback, and leaving - // outputCallbackRegistered=true would cause the next startAudio() - // to skip the re-attach (it gates on !outputCallbackRegistered), - // leaving split-mode output silent after a partial-open failure. - if (outputCallbackRegistered) - { - try { outputDeviceManager.removeAudioCallback(&outputCallback); } catch (...) {} - outputCallbackRegistered = false; - } - try { inputDeviceManager.closeAudioDevice(); } catch (...) {} - try { outputDeviceManager.closeAudioDevice(); } catch (...) {} - }; - - // Mirror applyDuplexSetup's JUCE_LINUX close-before-reconfigure pattern: - // ALSA deadlocks if we let setAudioDeviceSetup mutate a live device. The - // device type is re-asserted afterwards so the close doesn't drop us back - // to whatever JUCE picked at startup. closeAudioDevice/setCurrentAudioDeviceType - // throwing is non-fatal — we still try the setup below and surface its error. -#if JUCE_LINUX - { - juce::String currentInputTypeName; - if (auto* currentType = inputDeviceManager.getCurrentDeviceTypeObject()) - currentInputTypeName = currentType->getTypeName(); - if (inputDeviceManager.getCurrentAudioDevice() != nullptr) - { - try { - inputDeviceManager.closeAudioDevice(); - if (currentInputTypeName.isNotEmpty()) - inputDeviceManager.setCurrentAudioDeviceType(currentInputTypeName, true); - } catch (...) { - fprintf(stderr, "[AudioEngine] split-mode input close threw, continuing\n"); - } - } - } -#endif - - juce::String inErr; - try { inErr = inputDeviceManager.setAudioDeviceSetup(inSetup, true); } - catch (...) { res.error = "input setAudioDeviceSetup threw"; rollbackOpenedDevices(); return res; } - if (inErr.isNotEmpty()) { res.error = "input setup: " + inErr; rollbackOpenedDevices(); return res; } - - auto* inDev = inputDeviceManager.getCurrentAudioDevice(); - if (!inDev) { res.error = "no input device after setup"; rollbackOpenedDevices(); return res; } - const double inSr = inDev->getCurrentSampleRate(); - const int inBs = inDev->getCurrentBufferSizeSamples(); - - // Same first-enumerated resolution on the output side — see input note - // above for why this matches the probe + SR preflight strategy. - juce::String resolvedOutputName = config.outputDevice; - if (resolvedOutputName.isEmpty()) - { - auto names = outputType->getDeviceNames(false); - if (names.size() > 0) resolvedOutputName = names[0]; - } - - juce::AudioDeviceManager::AudioDeviceSetup outSetup; - outSetup.inputDeviceName = ""; - outSetup.outputDeviceName = resolvedOutputName; - outSetup.sampleRate = config.sampleRate; - outSetup.bufferSize = config.bufferSize; - outSetup.useDefaultInputChannels = false; - outSetup.useDefaultOutputChannels = false; - - int outputChannelCount = 0; - { - try { - std::unique_ptr probe(outputType->createDevice(resolvedOutputName, {})); - if (probe) outputChannelCount = probe->getOutputChannelNames().size(); - } catch (...) {} - } - if (outputChannelCount <= 0) outputChannelCount = 2; - outSetup.inputChannels.clear(); - outSetup.outputChannels.setRange(0, juce::jmin(outputChannelCount, 2), true); - - // Same JUCE_LINUX close-before-reconfigure as the input side above — also - // protects when split mode is re-applied with a different output device. -#if JUCE_LINUX - { - juce::String currentOutputTypeName; - if (auto* currentType = outputDeviceManager.getCurrentDeviceTypeObject()) - currentOutputTypeName = currentType->getTypeName(); - if (outputDeviceManager.getCurrentAudioDevice() != nullptr) - { - try { - outputDeviceManager.closeAudioDevice(); - if (currentOutputTypeName.isNotEmpty()) - outputDeviceManager.setCurrentAudioDeviceType(currentOutputTypeName, true); - } catch (...) { - fprintf(stderr, "[AudioEngine] split-mode output close threw, continuing\n"); - } - } - } -#endif - - juce::String outErr; - try { outErr = outputDeviceManager.setAudioDeviceSetup(outSetup, true); } - catch (...) { res.error = "output setAudioDeviceSetup threw"; rollbackOpenedDevices(); return res; } - if (outErr.isNotEmpty()) { res.error = "output setup: " + outErr; rollbackOpenedDevices(); return res; } - - auto* outDev = outputDeviceManager.getCurrentAudioDevice(); - if (!outDev) { res.error = "no output device after setup"; rollbackOpenedDevices(); return res; } - const double outSr = outDev->getCurrentSampleRate(); - const int outBs = outDev->getCurrentBufferSizeSamples(); - - if (std::abs(inSr - outSr) > 0.5) - { - res.error = "Input and output devices opened at different sample rates"; - rollbackOpenedDevices(); - return res; - } - - currentSampleRate.store(inSr, std::memory_order_relaxed); - inputBlockSize.store(inBs, std::memory_order_relaxed); - outputBlockSize.store(outBs, std::memory_order_relaxed); - - fprintf(stderr, "[AudioEngine] Split mode configured: inSr=%.0f inBs=%d outSr=%.0f outBs=%d\n", - inSr, inBs, outSr, outBs); - - outputRingWriteIndex.store(0, std::memory_order_relaxed); - outputRingReadIndex.store(0, std::memory_order_relaxed); - outputUnderflowCount.store(0, std::memory_order_relaxed); - inputOverflowCount.store(0, std::memory_order_relaxed); - for (auto& slot : outputPendingRing) - slot.store(0u, std::memory_order_relaxed); - - source0().prepareMonitorChain(inSr, inBs); - - res.ok = true; - res.sampleRate = inSr; - res.inputBlockSize = inBs; - res.outputBlockSize = outBs; - return res; -} - void AudioEngine::teardownSplitMode() { - // Unconditional remove — JUCE's removeAudioCallback is idempotent - // (no-op if the callback isn't registered), so we don't need the - // outputCallbackRegistered guard here. This makes teardown robust - // against a stale flag left over from a previous failed split setup. - outputDeviceManager.removeAudioCallback(&outputCallback); - outputCallbackRegistered = false; - try { outputDeviceManager.closeAudioDevice(); } - catch (...) { fprintf(stderr, "[AudioEngine] teardownSplitMode: output close threw\n"); } - - outputRingWriteIndex.store(0, std::memory_order_relaxed); - outputRingReadIndex.store(0, std::memory_order_relaxed); - for (auto& slot : outputPendingRing) - slot.store(0u, std::memory_order_relaxed); + deviceSetup.teardownSplit(outputRing, outputCallback, outputCallbackRegistered); } // ── Audio Control ───────────────────────────────────────────────────────────── void AudioEngine::startAudio() { + // Intent flag first — even if the device open below fails or a transient + // stop races in, "the user wants audio" survives (read by phase 8's + // setAudioDevices restart fix; see EngineState.h). + state.userWantsAudio.store(true, std::memory_order_relaxed); if (audioRunning.load(std::memory_order_relaxed)) { fprintf(stderr, "[AudioEngine] startAudio: already running\n"); @@ -1241,15 +624,16 @@ void AudioEngine::startAudio() // Restore any extra input devices the user still wants bound (stopAudio closed // them but kept the intent). This is what makes a stop/start cycle or a device // reconfigure transparently resume multi-input detection. - reopenDesiredExtraInputs(); + extraInputs.reopenDesired(); // Restore the streamer-mix output device too (same intent-survives-restart // pattern). Best-effort — a failure leaves the sink inactive, never blocks. - reopenDesiredStreamSink(); + streamSink.reopenDesired(); } void AudioEngine::stopAudio() { + state.userWantsAudio.store(false, std::memory_order_relaxed); if (slopsmith_vst_trace::isEnabled()) fprintf(stderr, "[diag] stopAudio: audioRunning=%d inputCbReg=%d outputCbReg=%d\n", (int) audioRunning.load(std::memory_order_relaxed), @@ -1272,207 +656,24 @@ void AudioEngine::stopAudio() // or a device reconfigure, restores extra inputs automatically). No-op when none // are bound — the single-device path is unchanged. for (int dk = 1; dk <= kMaxExtraInputDevices; ++dk) - closeExtraInputDevice(dk - 1); + extraInputs.closeSlot(dk - 1); // Tear down the streamer-mix OUTPUT device too — KEEP its desired intent so the // next startAudio() reopens it (same pattern as extra inputs). Without this the // 2nd output device keeps running and underflowing while the engine is "stopped", // and the destructor (which calls stopAudio()) would be the only path that ever // closes it — closing the device here also makes that destructor teardown safe. - closeStreamSinkDevice(); + streamSink.close(); audioRunning.store(false, std::memory_order_relaxed); currentBackingLevel.store(0.0f); } // ── Backing Track ───────────────────────────────────────────────────────────── -bool AudioEngine::loadBackingTrack(const juce::File& file) -{ - const juce::ScopedLock sl(backingLock); - stopBackingNoLock(); - backingTransport.reset(); - backingSource.reset(); - - const bool exists = file.existsAsFile(); - std::cerr << "[AudioEngine] loadBackingTrack path=" - << file.getFullPathName().toStdString() - << " exists=" << exists - << " size=" << (exists ? (long long)file.getSize() : -1) - << std::endl; - - auto* reader = formatManager.createReaderFor(file); - if (!reader) - { - std::cerr << "[AudioEngine] loadBackingTrack: no reader for ext='" - << file.getFileExtension().toStdString() - << "' (registered formats=" << formatManager.getNumKnownFormats() - << ")" << std::endl; - // Transport/source already reset above; clear cached state so the renderer - // doesn't keep displaying the previous track's position/duration. - cachedBackingPosition.store(0.0); - cachedBackingDuration.store(0.0); - return false; - } - - const double readerSampleRate = reader->sampleRate; - const juce::int64 readerLengthInSamples = reader->lengthInSamples; - const double sr = currentSampleRate.load(std::memory_order_relaxed); - // Backing audio plays through the output device in both modes, so size - // against outputBlockSize. In duplex mode outputBlockSize == inputBlockSize; - // in split mode the output device's clock drives the backing pull. - const int bs = outputBlockSize.load(std::memory_order_relaxed); - - backingSource = std::make_unique(reader, true); - backingTransport = std::make_unique(); - // Read-ahead on backingReadThread so the RT audio thread normally never - // touches the disk or the format codec. Previously this passed - // (…, 0, nullptr, …): with no read-ahead buffer the transport decoded the - // file synchronously inside getNextAudioBlock ON the audio callback, so any - // disk seek / decode spike (worst for compressed formats) blew the block - // budget → underruns heard as glitches or brief mutes while a song plays. - // 32768 source frames ≈ 0.68 s @ 48k of look-ahead absorbs those spikes. - // - // Known residual (accepted): juce::BufferingAudioSource is not fully - // RT-safe — readBufferSection() holds callbackLock across the decode of one - // refill chunk, and the callback's getNextAudioBlock() takes the same lock, - // so the RT thread can still block behind an in-flight chunk decode. The - // window is bounded (JUCE caps chunks at 2048 source frames) and only hit - // when a refill is mid-decode, vs. the old guaranteed full decode on every - // block; a truly lock-free ring would mean replacing the JUCE transport - // stack and isn't worth it here. - // The 4th arg makes AudioTransportSource SRC the file to device rate. - // Stretch always sees device-rate audio so that its presetDefault parameters match. - constexpr int kBackingReadAheadSamples = 32768; - backingTransport->setSource(backingSource.get(), kBackingReadAheadSamples, - &backingReadThread, readerSampleRate); - - // Loading a backing track before the audio device has started leaves - // sr/bs at zero. presetDefault(2, 0.0f) would seed the stretcher with - // undefined internal timing, and prepareToPlay(0, 0) is similarly - // ill-defined. Defer the stretcher + buffer setup; the relevant - // audio*AboutToStart() re-runs the same block once a real sample - // rate / block size are known (audioDeviceAboutToStart for duplex, - // audioOutputAboutToStart for split). - if (sr > 0.0 && bs > 0) - { - // prepareToPlay's first arg is an upper bound on subsequent - // getNextAudioBlock requests, per the juce::AudioSource contract. - // The RT callback can pull ceil(bs * kMaxBackingSpeed) frames in a - // single block when the speed is above 1×, so prepare for that - // worst case — preparing with just `bs` would risk JUCE internal - // buffer overruns/asserts on the first faster-than-1× block. - const int maxInputFrames = (int) std::ceil(bs * kMaxBackingSpeed) + 64; - backingTransport->prepareToPlay(maxInputFrames, sr); - - backingStretch.presetDefault(2, (float) sr); - backingStretch.reset(); - backingStretchLatencySamples.store(backingStretch.outputLatency(), std::memory_order_relaxed); - - backingInputBuffer.setSize(2, maxInputFrames, false, false, true); - backingBuffer.setSize(2, bs, false, false, true); - } - - cachedBackingDuration.store(backingTransport->getLengthInSeconds()); - cachedBackingPosition.store(0.0); - backingHeardPositionSec.store(0.0, std::memory_order_relaxed); - - // Reset the loudness leveler for the new song: clearing the cached sample - // rate forces renderBackingBlockLocked() to re-prepare() it on the next - // block, dropping the previous track's AGC gain + limiter state. Otherwise - // the ~300 ms gain follower would carry over and briefly mis-level the start - // of a much louder/quieter next song. Safe here — loadBackingTrack holds - // backingLock, the same lock the render path runs under. - backingLevelerSr = 0.0; - std::cerr << "[AudioEngine] loadBackingTrack OK sr=" << readerSampleRate - << " len=" << readerLengthInSamples - << std::endl; - return true; -} - -void AudioEngine::setBackingPosition(double seconds) -{ - const juce::ScopedLock sl(backingLock); - if (backingTransport) - { - backingTransport->setPosition(seconds); - backingStretch.reset(); - // Read back the actual position; the transport may clamp (e.g. negative or past EOF). - const double pos = backingTransport->getCurrentPosition(); - cachedBackingPosition.store(pos); - backingHeardPositionSec.store(pos, std::memory_order_relaxed); - } -} - -void AudioEngine::startBacking() -{ - const juce::ScopedLock sl(backingLock); - if (backingTransport) - { - backingTransport->start(); - backingPlaying.store(true); - backingHeardPositionSec.store(backingTransport->getCurrentPosition(), - std::memory_order_relaxed); - } -} - -void AudioEngine::stopBackingNoLock() -{ - if (backingTransport) - { - backingTransport->stop(); - backingStretch.reset(); - backingPlaying.store(false); - } - currentBackingLevel.store(0.0f); -} - -void AudioEngine::stopBacking() -{ - const juce::ScopedLock sl(backingLock); - stopBackingNoLock(); -} - -void AudioEngine::setBackingSpeed(double speed) -{ - if (!std::isfinite(speed) || speed <= 0.0) - { - return; - } - - const double clamped = juce::jlimit(0.01, kMaxBackingSpeed, speed); - // Dead-zone against the last *requested* rate to coalesce rapid slider - // ticks — but never skip a change that crosses the 1× bypass boundary, or a - // request just shy of 1× (e.g. 0.9995 -> 1.0, diff < 0.001) would leave the - // stretcher path engaged when the caller actually asked for transparent - // full speed. - const double prev = backingPendingSpeed.load(std::memory_order_relaxed); - const bool prevBypass = std::abs(prev - 1.0) < kBackingSpeedBypassEpsilon; - const bool newBypass = std::abs(clamped - 1.0) < kBackingSpeedBypassEpsilon; - if (std::abs(clamped - prev) < 0.001 && prevBypass == newBypass) - { - return; - } - - // Lock-free hand-off to the audio thread. Publish the requested rate, then - // raise the pending flag with release so the RT thread is guaranteed to see - // the new rate once it observes the flag. renderBackingBlockLocked() adopts - // the rate and resets the stretcher together, on the audio thread, so: - // * a control-thread caller (e.g. a speed slider at 30-60 Hz) never takes - // backingLock and so never starves the RT tryLock into dropping a block; - // * the new rate is never processed with stale stretch state — the reset - // and the rate adoption happen in the same RT block (see PR #237). - // Multiple updates before the RT consumes them coalesce (latest wins), which - // naturally throttles stretcher resets during a drag. - backingPendingSpeed.store(clamped, std::memory_order_relaxed); - backingSpeedChangePending.store(true, std::memory_order_release); -} - void AudioEngine::resetPeaks() { // Input peak is per-source — clear EVERY active source (getSourceLevels() exposes // each one's peak), not just source 0, or extra-device peaks latch forever. - for (auto& src : sources) - if (src->isActive()) - src->resetInputPeak(); + pool.forEachActive([](SourceChain& s) { s.resetInputPeak(); }); outputPeak.store(0.0f); } @@ -1495,126 +696,30 @@ int AudioEngine::addSource(int inputChannel, int deviceKey) // configure its detector even though the device was successfully (deferred-)bound. if (deviceKey >= 1) { - const InputDeviceSlot& es = extraInputs[(size_t) (deviceKey - 1)]; - if (! es.active.load(std::memory_order_acquire) && es.desiredDeviceName.isEmpty()) + if (! extraInputs.resolveForSource(deviceKey).usable) return -1; } - std::lock_guard lock(sourcesMutex); - reclaimPendingReleases(); // free up any slot whose release was deferred - - // Find a free pooled slot (slot 0 is the permanent default). Skip a slot whose - // release is still pending — its chain/worker hasn't been torn down yet, so - // re-preparing it would double-start the verifier thread. - int slot = -1; - for (int i = 1; i < kMaxSources; ++i) - if (! sources[(size_t) i]->isActive() && ! pendingRelease[(size_t) i]) { slot = i; break; } - if (slot < 0) - return -1; // pool full - - SourceChain& src = *sources[(size_t) slot]; - src.setInputChannel(inputChannel); - src.setDeviceKey(deviceKey); - // Inherit the bound device's capture-latency correction (0 for the primary). - // The device usually started before the source was created (the user picks the - // device, then enables detect), so its delta is already known. - if (deviceKey >= 1 && deviceKey <= kMaxExtraInputDevices) - src.setVerifierAutoOffset(extraInputs[(size_t) (deviceKey - 1)].latencyDeltaSec.load(std::memory_order_relaxed)); - else - src.setVerifierAutoOffset(0.0); - // Clear any MANUAL offset left on this pooled chain by a previous player — a - // freshly added source starts with no user fine-tune (the renderer re-applies - // its own via setSourceVerifierOffset). releaseResources() doesn't touch it. - src.setVerifierUserOffset(0.0); - // Likewise clear stale meters so this source doesn't briefly report the previous - // player's level/peak through getSourceLevels() until fresh audio arrives. - src.resetInputMeters(); - - // Prepare fully BEFORE making it visible to the audio thread, so the first - // callback that observes active==true sees a ready chain + rings. When audio - // isn't running yet, audioDeviceAboutToStart (primary) / extraInputAboutToStart - // (extra) prepares it later. An EXTRA-device source must be prepared with ITS - // device's sample rate / block size, not the primary's — the two can differ. + // Resolve device readiness/format/latency for the pool (the extra-device + // registry is engine-owned; the pool takes resolved values). bool deviceReady = audioRunning.load(std::memory_order_relaxed); double sr = currentSampleRate.load(std::memory_order_relaxed); int bs = inputBlockSize.load(std::memory_order_relaxed); + double latencyDeltaSec = 0.0; if (deviceKey >= 1 && deviceKey <= kMaxExtraInputDevices) { - const InputDeviceSlot& es = extraInputs[(size_t) (deviceKey - 1)]; - deviceReady = es.active.load(std::memory_order_acquire); - sr = es.sampleRate.load(std::memory_order_relaxed); - bs = es.blockSize.load(std::memory_order_relaxed); + const auto r = extraInputs.resolveForSource(deviceKey); + deviceReady = r.ready; + sr = r.sr; + bs = r.bs; + latencyDeltaSec = r.latencyDelta; } - if (deviceReady && sr > 0.0 && bs > 0) - src.prepare(sr, bs); - src.setActive(true); // release-store: now picked up by the audio callback - return slot; + return pool.addResolved(inputChannel, deviceKey, deviceReady, sr, bs, latencyDeltaSec); } bool AudioEngine::removeSource(int id) { - if (id <= 0 || id >= kMaxSources) - return false; // 0 is permanent; out-of-range rejected - - std::lock_guard lock(sourcesMutex); - reclaimPendingReleases(); // opportunistically reclaim earlier deferrals - - SourceChain& src = *sources[(size_t) id]; - if (! src.isActive()) - return false; - - // Hide it from the audio callback first; subsequent blocks snapshot active - // once and skip it. It is logically removed from here on, regardless of when - // its resources are reclaimed. - src.setActive(false); - - // Reclaim now if we can confirm THIS SOURCE's device callback is not executing. - // Only the callback for the source's own deviceKey can touch it; that counter is - // decremented at the callback's real exit (release store), so observing 0 - // (acquire) proves it is not inside processBlock right now; any callback that - // starts afterwards snapshots active and skips this (now-inactive) source — so - // releasing cannot race the audio thread. Keying on the source's deviceKey (not a - // global all-callbacks-idle check) is what lets removals reclaim during steady - // multi-device playback, when callbacks on independent clocks are never all idle - // at once. Bounded so a wedged device can't hang this thread. - const size_t dk = (size_t) src.getDeviceKey(); - for (int spins = 0; spins < 200; ++spins) // ~200 ms cap - { - if (callbacksInFlight[dk].load(std::memory_order_acquire) == 0) - { - src.releaseResources(); // stops its threads + releases its chain - return true; - } - std::this_thread::sleep_for(std::chrono::milliseconds(1)); - } - - // A callback stayed wedged in-flight past the wait (a >200 ms block would be a - // catastrophic stall). Do NOT force a release that could race it — DEFER it. - // The source is already inactive so no future callback touches it; reclaim it - // later (next add/removeSource, or audioDeviceStopped) when the body is quiet. - pendingRelease[(size_t) id] = true; - return true; -} - -void AudioEngine::reclaimPendingReleases() -{ - // Caller holds sourcesMutex. A deferred source is inactive (future callbacks skip - // it); releasing it is safe once the callback for ITS deviceKey is not in a body. - // We key per-deviceKey (decremented by each callback at its real exit) so a - // pending release frees as soon as its OWN device is quiescent — not only when - // every device callback happens to be idle simultaneously (which, on independent - // clocks during steady multi-device playback, may never occur and would strand - // the slot until full stop). On the audioDeviceStopped path the relevant callback - // already left its count at 0. - for (int i = 1; i < kMaxSources; ++i) - { - if (! pendingRelease[(size_t) i]) continue; - const size_t dk = (size_t) sources[(size_t) i]->getDeviceKey(); - if (callbacksInFlight[dk].load(std::memory_order_acquire) != 0) - continue; // this source's device is mid-body — try again later - sources[(size_t) i]->releaseResources(); - pendingRelease[(size_t) i] = false; - } + return pool.remove(id); } // Lifetime/threading of getSource() + the NodeAddon *Source* methods: @@ -1634,10 +739,7 @@ void AudioEngine::reclaimPendingReleases() // new race class versus the original single-source engine. SourceChain* AudioEngine::getSource(int id) { - if (id < 0 || id >= kMaxSources) - return nullptr; - SourceChain& src = *sources[(size_t) id]; - return (id == 0 || src.isActive()) ? &src : nullptr; + return pool.get(id); } void AudioEngine::setMlNoteDetectionEnabled(bool e) @@ -1646,143 +748,17 @@ void AudioEngine::setMlNoteDetectionEnabled(bool e) // activated later inherits the current arm state instead of silently // staying dormant. Each MlNoteDetector::setEnabled is a cheap atomic + a // cold-state clear on a real transition. - for (int i = 0; i < kMaxSources; ++i) - sources[(size_t) i]->getMlNoteDetector().setEnabled(e); + pool.forEach([e](SourceChain& s) { s.getMlNoteDetector().setEnabled(e); }); } std::vector AudioEngine::listSources() const { std::vector out; - for (int i = 0; i < kMaxSources; ++i) - { - const SourceChain& src = *sources[(size_t) i]; - if (! src.isActive()) continue; - SourceInfo info; - info.id = src.getId(); - info.inputChannel = src.getInputChannel(); - info.deviceKey = src.getDeviceKey(); - info.active = true; - out.push_back(info); - } + for (const auto& i : pool.list()) + out.push_back({ i.id, i.inputChannel, i.deviceKey, i.active }); return out; } -int AudioEngine::renderBackingBlockLocked(int numSamples) -{ - // Adopt any speed change requested since the last block (set lock-free by - // setBackingSpeed). Common (no-change) path is a plain acquire load — no - // locked RMW, so the flag's cache line stays shared and isn't bounced to - // this core every callback. Only the rare block that actually consumes a - // change does the exchange (clearing the flag atomically so a concurrent - // setBackingSpeed can't lose an update). The acquire pairs with the - // release-store in setBackingSpeed so the new rate is visible here. Reset - // the stretcher and re-anchor the heard position in the SAME block we adopt - // the rate, so a block is never processed at the new rate with stale stretch - // state. reset() only clears state (no allocation), so it's audio-thread safe. - if (backingSpeedChangePending.load(std::memory_order_acquire)) - { - backingSpeedChangePending.exchange(false, std::memory_order_acquire); - backingSpeed.store(juce::jlimit(0.01, kMaxBackingSpeed, - backingPendingSpeed.load(std::memory_order_relaxed)), - std::memory_order_relaxed); - backingStretch.reset(); - backingHeardPositionSec.store(backingTransport->getCurrentPosition(), - std::memory_order_relaxed); - } - - const double rate = juce::jlimit(0.01, kMaxBackingSpeed, backingSpeed.load(std::memory_order_relaxed)); - - // Defensive clamp: the buffers are sized in audioDeviceAboutToStart() / - // audioOutputAboutToStart() from the device's nominal block size, but a - // callback can deliver a larger numSamples on a device-reconfig race. Drop - // the excess frames silently rather than reading/writing past the allocated - // span; the next callback after reconfig arrives at the new nominal size. - const int outCap = backingBuffer.getNumSamples(); - const int inCap = backingInputBuffer.getNumSamples(); - const int outSamples = juce::jmin(numSamples, outCap); - const double sr = currentSampleRate.load(std::memory_order_relaxed); - const bool bypassStretch = std::abs(rate - 1.0) < kBackingSpeedBypassEpsilon; - - int sourceFramesPulled = 0; - - if (bypassStretch) - { - // 1× — direct transport read, no phase-vocoder path. (The transport - // still sample-rate-converts the file to the device rate, so this is - // "no time-stretch", not necessarily bit-perfect.) - backingBuffer.clear(0, outSamples); - juce::AudioSourceChannelInfo info(&backingBuffer, 0, outSamples); - backingTransport->getNextAudioBlock(info); - sourceFramesPulled = outSamples; - } - else - { - // Slow/fast path — pull only the source frames needed for this output - // block (output * rate), then stretch in-process to fill outSamples. - const int inputFrames = juce::jmin((int) std::ceil(outSamples * rate), inCap); - - backingInputBuffer.clear(0, inputFrames); - juce::AudioSourceChannelInfo info(&backingInputBuffer, 0, inputFrames); - backingTransport->getNextAudioBlock(info); - sourceFramesPulled = inputFrames; - - backingBuffer.clear(0, outSamples); - - const float* const* inPtrs = backingInputBuffer.getArrayOfReadPointers(); - float* const* outPtrs = backingBuffer.getArrayOfWritePointers(); - backingStretch.process(inPtrs, inputFrames, outPtrs, outSamples); - } - - const double transportPos = backingTransport->getCurrentPosition(); - if (sr > 0.0 && sourceFramesPulled > 0) - { - // Accumulate the heard (source) position, but clamp to the transport's - // actual position. sourceFramesPulled is the requested block size; a - // short read (e.g. at EOF, where the transport returns fewer real frames - // and zero-pads) would otherwise advance the playhead past the true - // source point and report progress beyond the track duration before - // backingPlaying flips false. getCurrentPosition() stays clamped to the - // real source position. - double heard = backingHeardPositionSec.load(std::memory_order_relaxed) - + static_cast(sourceFramesPulled) / sr; - heard = juce::jmin(heard, transportPos); - backingHeardPositionSec.store(heard, std::memory_order_relaxed); - - // Bypass reads straight from the transport — no phase-vocoder output - // latency to compensate for. Only the stretch path adds latency. - const double latencyInputSec = bypassStretch - ? 0.0 - : (backingStretchLatencySamples.load(std::memory_order_relaxed) * rate) / sr; - cachedBackingPosition.store(juce::jmax(0.0, heard - latencyInputSec)); - } - else - { - // currentSampleRate is transiently 0 during device teardown/reconfig. - // We can't accumulate (no Hz to divide by), so anchor both the heard - // accumulator and the published playhead to the real transport position - // rather than leaving a stale value visible to the UI. - backingHeardPositionSec.store(transportPos, std::memory_order_relaxed); - cachedBackingPosition.store(juce::jmax(0.0, transportPos)); - } - - // Sync the flag if transport stopped at EOF. - if (!backingTransport->isPlaying()) - backingPlaying.store(false); - - // Normalize the backing track to a consistent target loudness (-12 LUFS) - // BEFORE the mixer's backing-volume fader is applied (later in the RT - // callback), so every song sits at the same level while the fader still - // attenuates it. Standard BS.1770 K-weighting (full-mix music) + a brickwall - // limiter to keep boosted peaks safe. RT-safe (no allocation). - if (outSamples > 0 && sr > 0.0) - { - if (sr != backingLevelerSr) { backingLeveler.prepare(sr); backingLevelerSr = sr; } - backingLeveler.process(backingBuffer, outSamples, -12.0f); - } - - return outSamples; -} - // setNoiseGate / setTonePolishEnabled are now inline forwarders to sources[0] // (see AudioEngine.h). @@ -1837,7 +813,7 @@ void AudioEngine::audioDeviceAboutToStart(juce::AudioIODevice* device) // once: it can never realloc under a live producer, for any later block size. constexpr int streamScratchCap = (int) kOutputRingFrames; if (streamGuitarScratch.getNumSamples() < streamScratchCap) streamGuitarScratch.setSize(2, streamScratchCap, false, false, true); - if (streamMixScratch.getNumSamples() < streamScratchCap) streamMixScratch.setSize(2, streamScratchCap, false, false, true); + streamSink.prepareProducerScratch(); if (rendererBusPullScratch.getNumSamples() < streamScratchCap) rendererBusPullScratch.setSize(2, streamScratchCap, false, false, true); // Prepare each ACTIVE PRIMARY-device source's DSP and reset its rings for a @@ -1845,31 +821,14 @@ void AudioEngine::audioDeviceAboutToStart(juce::AudioIODevice* device) // device sources (deviceKey > 0) are prepared by their own extraInputAboutToStart // with THAT device's format — a primary restart must not clobber them with the // primary's sample rate / block size (they run on a different hardware clock). - for (auto& src : sources) - if (src->isActive() && src->getDeviceKey() == 0) - src->prepare(sr, bs); + pool.forEachActive([&](SourceChain& s) { if (s.getDeviceKey() == 0) s.prepare(sr, bs); }); // Split mode preps the backing stretcher in audioOutputAboutToStart // instead — that callback owns the device the backing audio actually // plays on, and pulls from backingTransport at the output device's // block size. if (duplexMode.load(std::memory_order_relaxed)) - { - const juce::ScopedLock sl(backingLock); - if (backingTransport) - { - // See loadBackingTrack() for why prepareToPlay uses maxInputFrames - // rather than bs: the RT callback can pull ceil(bs * kMaxBackingSpeed) - // frames in a single block at faster-than-1× speeds. - const int maxInputFrames = (int) std::ceil(bs * kMaxBackingSpeed) + 64; - backingTransport->prepareToPlay(maxInputFrames, sr); - backingStretch.presetDefault(2, (float) sr); - backingStretch.reset(); - backingStretchLatencySamples.store(backingStretch.outputLatency(), std::memory_order_relaxed); - backingInputBuffer.setSize(2, maxInputFrames, false, false, true); - backingBuffer.setSize(2, bs, false, false, true); - } - } + backing.prepare(sr, bs); } void AudioEngine::audioDeviceStopped() @@ -1883,19 +842,10 @@ void AudioEngine::audioDeviceStopped() if (slopsmith_vst_trace::isEnabled()) fprintf(stderr, "[diag] audioDeviceStopped (audioRunning cleared; callbacks stay attached for JUCE auto-restart)\n"); audioRunning.store(false, std::memory_order_relaxed); - { - std::lock_guard lock(sourcesMutex); - // Release each ACTIVE primary-device source's chain and zero its rings. - // Inactive pooled chains were never prepared. - for (auto& src : sources) - if (src->isActive() && src->getDeviceKey() == 0) - src->releaseResources(); - // Reclaim deferred removals only when ALL callback bodies are quiescent - // (an extra-device callback could still be mid-block). - reclaimPendingReleases(); - } - outputRingWriteIndex.store(0, std::memory_order_relaxed); - outputRingReadIndex.store(0, std::memory_order_relaxed); + // Release each ACTIVE primary-device source's chain and zero its rings; + // retries deferred removals now that the primary body is quiescent. + pool.releaseDeviceSources(0, false, false); + outputRing.resetIndices(); currentBackingLevel.store(0.0f); // Note on split-mode lifecycle: we deliberately do NOT detach the @@ -1904,7 +854,7 @@ void AudioEngine::audioDeviceStopped() // doesn't re-add the output callback, so detaching would break // automatic recovery (output stays silent until a manual reconfigure). // While input is down, the guitar/DSP side of the output goes silent - // (no producer feeding outputPendingRing, so the consumer's underflow + // (no producer feeding outputRing, so the consumer's underflow // branch zero-fills), but the backing track keeps playing — the output // callback mixes backingTransport independently of ring state. That's // intentional UX: a user unplugging their interface mid-song doesn't @@ -1929,7 +879,7 @@ void AudioEngine::audioOutputAboutToStart(juce::AudioIODevice* device) // note in audioDeviceAboutToStart(). constexpr int streamScratchCap = (int) kOutputRingFrames; if (streamGuitarScratch.getNumSamples() < streamScratchCap) streamGuitarScratch.setSize(2, streamScratchCap, false, false, true); - if (streamMixScratch.getNumSamples() < streamScratchCap) streamMixScratch.setSize(2, streamScratchCap, false, false, true); + streamSink.prepareProducerScratch(); if (rendererBusPullScratch.getNumSamples() < streamScratchCap) rendererBusPullScratch.setSize(2, streamScratchCap, false, false, true); // NOTE: outputBackingBuffer is sized by audioDeviceAboutToStart() from the // INPUT device's block size — it's the split-input DSP scratch, not an @@ -1959,25 +909,9 @@ void AudioEngine::audioOutputAboutToStart(juce::AudioIODevice* device) // device-side rate change (sleep/resume, format change) would leave // currentSampleRate stuck at the input-side seed value. if (sr > 0.0) currentSampleRate.store(sr, std::memory_order_relaxed); - { - const juce::ScopedLock sl(backingLock); - if (backingTransport && sr > 0.0 && bs > 0) - { - // Mirror loadBackingTrack() / audioDeviceAboutToStart() — the - // output device drives backing playback in split mode, so this - // is where the stretcher gets sized for that side. prepareToPlay - // upper-bounds future getNextAudioBlock requests, and the - // RT callback can pull ceil(bs * kMaxBackingSpeed) at faster - // speeds. - const int maxInputFrames = (int) std::ceil(bs * kMaxBackingSpeed) + 64; - backingTransport->prepareToPlay(maxInputFrames, sr); - backingStretch.presetDefault(2, (float) sr); - backingStretch.reset(); - backingStretchLatencySamples.store(backingStretch.outputLatency(), std::memory_order_relaxed); - backingInputBuffer.setSize(2, maxInputFrames, false, false, true); - backingBuffer.setSize(2, bs, false, false, true); - } - } + // The output device drives backing playback in split mode, so this is + // where the stretcher gets sized for that side. + backing.prepare(sr, bs); } void AudioEngine::audioOutputStopped() @@ -1993,271 +927,17 @@ void AudioEngine::audioOutputStopped() // ring invariants being re-established, which is harder to reason about. } -// ── Streamer mix output sink (PR1) ────────────────────────────────────────── -// Producer (primary/output callback): compose the stream submix and pack it into -// the sink's ring. Consumer (streamSinkCallback): drain + write to the device. -// Mirrors the InputDeviceSlot ring discipline, inverted to the output side. - -void AudioEngine::composeAndPushStreamMix(const juce::AudioBuffer& guitarMix, - const juce::AudioBuffer* backingBuf, - int backingFrames, float backingVol, - const juce::AudioBuffer* rendererBuf, - int rendererFrames, int numSamples) -{ - if (! streamSink.active.load(std::memory_order_acquire)) return; - // A block larger than the entire ring can't be published atomically (it would - // wrap and overwrite unread slots before writeIndex is bumped). Skip it and - // count an overflow. Checked FIRST (before the scratch guard) so an oversized - // block is always counted — the fixed-size scratch is exactly the ring, so an - // oversized block also trips the scratch guard below and would otherwise be - // dropped silently. The split path already rejects oversized devices at setup; - // this guards the duplex path, whose block size we don't pre-validate. - if (numSamples > (int) kOutputRingFrames) - { - streamSink.overflowCount.fetch_add(1, std::memory_order_relaxed); - return; - } - // Scratch not yet sized to the full ring (cold start before the producer's - // about-to-start ran, or a transient reconfig) — skip rather than alloc on RT. - // After warm-up the scratch is exactly the ring, so for an in-range block this - // never trips. - if (streamMixScratch.getNumSamples() < numSamples) return; - - const bool ig = streamBusIncludeGuitar.load(std::memory_order_relaxed); - const bool ib = streamBusIncludeBacking.load(std::memory_order_relaxed); - const float gain = streamBusGain.load(std::memory_order_relaxed); - - streamMixScratch.clear(0, 0, numSamples); - streamMixScratch.clear(1, 0, numSamples); - if (ig) - for (int ch = 0; ch < 2; ++ch) - streamMixScratch.addFrom(ch, 0, guitarMix, - juce::jmin(ch, guitarMix.getNumChannels() - 1), 0, numSamples); - if (ib && backingBuf != nullptr && backingFrames > 0) - { - const int n = juce::jmin(backingFrames, numSamples); - for (int ch = 0; ch < 2; ++ch) - streamMixScratch.addFrom(ch, 0, *backingBuf, - juce::jmin(ch, backingBuf->getNumChannels() - 1), 0, n, backingVol); - } - // Renderer-fed song audio (stems / element / loopback riding the renderer - // bus) is song audio for the streamer too — without this the stream mix - // carries guitar only whenever the song bypasses the native transport - // (multi-stem under exclusive/ASIO output). Bus gain is already applied by - // pullRendererBus; only the stream gain below shapes it further. Backing - // transport and renderer bus are mutually exclusive song paths in - // practice, so this never double-carries. - if (ib && rendererBuf != nullptr && rendererFrames > 0) - { - const int n = juce::jmin(rendererFrames, numSamples); - for (int ch = 0; ch < 2; ++ch) - streamMixScratch.addFrom(ch, 0, *rendererBuf, - juce::jmin(ch, rendererBuf->getNumChannels() - 1), 0, n); - } - streamMixScratch.applyGain(0, 0, numSamples, gain); - streamMixScratch.applyGain(1, 0, numSamples, gain); - - const float peak = juce::jmax(streamMixScratch.getMagnitude(0, 0, numSamples), - streamMixScratch.getMagnitude(1, 0, numSamples)); - streamSinkLevel.store(peak, std::memory_order_relaxed); - - packStereoIntoRing(streamMixScratch, numSamples, streamSink.ring, streamSink.writeIndex); -} - -void AudioEngine::streamSinkCallback(float* const* outputData, int numOutputChannels, int numSamples) -{ - const juce::ScopedNoDenormals noDenormals; - if (numOutputChannels <= 0) return; - juce::AudioBuffer buffer(outputData, numOutputChannels, numSamples); - buffer.clear(); - - constexpr uint64_t kMask = (uint64_t) kOutputRingFrames - 1; - constexpr uint64_t kCap = (uint64_t) kOutputRingFrames; - const int scratchCap = (int) streamSink.pullScratchL.size(); - const int outSamples = juce::jmin(numSamples, scratchCap); - - uint64_t r = streamSink.readIndex.load(std::memory_order_relaxed); - const uint64_t w = streamSink.writeIndex.load(std::memory_order_acquire); - if (w < r) { r = w; streamSink.readIndex.store(r, std::memory_order_relaxed); } - if ((w - r) > kCap) - { - r = w - kCap; - streamSink.readIndex.store(r, std::memory_order_relaxed); - streamSink.overflowCount.fetch_add(1, std::memory_order_relaxed); - } - const uint64_t available = w - r; - const int pullCount = juce::jmin(outSamples, (int) available); - const int consumeCount = juce::jmin(numSamples, (int) available); - const int copyChannels = juce::jmin(numOutputChannels, 2); - for (int i = 0; i < pullCount; ++i) - { - const uint64_t slot = (r + (uint64_t) i) & kMask; - float l, rr; - unpackLR(streamSink.ring[(size_t) slot].load(std::memory_order_relaxed), l, rr); - buffer.setSample(0, i, l); - if (copyChannels > 1) buffer.setSample(1, i, rr); - } - if (pullCount < outSamples) - streamSink.underflowCount.fetch_add(1, std::memory_order_relaxed); - streamSink.readIndex.store(r + (uint64_t) consumeCount, std::memory_order_release); -} - -void AudioEngine::streamSinkAboutToStart(juce::AudioIODevice* device) -{ - if (device == nullptr) return; - const int bs = device->getCurrentBufferSizeSamples(); - double sr = device->getCurrentSampleRate(); - if (sr <= 0.0) sr = currentSampleRate.load(std::memory_order_relaxed); - streamSink.blockSize.store(bs, std::memory_order_relaxed); - streamSink.sampleRate.store(sr, std::memory_order_relaxed); - const int cap = juce::jmax(bs, 2048); - if ((int) streamSink.pullScratchL.size() < cap) streamSink.pullScratchL.assign((size_t) cap, 0.0f); - if ((int) streamSink.pullScratchR.size() < cap) streamSink.pullScratchR.assign((size_t) cap, 0.0f); - streamSink.writeIndex.store(0, std::memory_order_relaxed); - streamSink.readIndex.store(0, std::memory_order_relaxed); - streamSink.underflowCount.store(0, std::memory_order_relaxed); - streamSink.overflowCount.store(0, std::memory_order_relaxed); - for (auto& v : streamSink.ring) v.store(0, std::memory_order_relaxed); -} - -void AudioEngine::streamSinkStopped() -{ - // Fires on any stop of the stream device — an unplanned loss (unplug / driver - // reset) as well as our own teardown. Mark inactive so the producer stops - // filling a now-consumer-less ring and the meter clears; desiredTypeName/Name - // are deliberately left intact so reopenDesiredStreamSink() can restore it. - streamSink.active.store(false, std::memory_order_release); - streamSinkLevel.store(0.0f, std::memory_order_relaxed); -} +// ── Streamer mix output sink — moved to engine/StreamSink.{h,cpp} (TLC +// phase 2). Facades below keep the NodeAddon-visible surface unchanged. juce::String AudioEngine::setStreamOutputDevice(const juce::String& typeName, const juce::String& deviceName) { - // Control-thread only. Opens an OUTPUT-only device on the stream sink's own - // AudioDeviceManager and attaches the drain callback. Mirrors applySplitSetup's - // output open. v1 requires the sink's nominal SR to match the engine rate (no - // async resampler yet — that's PR3); a mismatch is rejected with a clear error. - streamSink.desiredTypeName = typeName; - streamSink.desiredDeviceName = deviceName; - - // Stop the producer from writing the ring while we (re)configure the device: - // setAudioDeviceSetup() below drives streamSinkAboutToStart(), which resets the - // ring indices and slots. Clearing `active` first stops the main callback from - // STARTING new pushes. A producer block already past the active-check can still - // finish one push, but the device close/reopen takes far longer than a single - // audio block, so that in-flight push completes well before about-to-start runs. - // Worst case is therefore one imperfect block on the STREAM bus (never the local - // monitor) during a manual device switch — atomic, no data race, no UAF. We only - // re-arm `active` after a fully clean open. - streamSink.active.store(false, std::memory_order_release); - - // Any failure below: detach/close the half-open device AND drop the desired - // intent. A deterministic failure (e.g. SR mismatch) is then NOT retried on every - // startAudio(), and the engine never reports active with no device behind it. The - // renderer keeps its own persisted choice and re-applies it, so nothing is lost. - auto fail = [this](const juce::String& msg) -> juce::String { - closeStreamSinkDevice(); - streamSink.desiredTypeName = {}; - streamSink.desiredDeviceName = {}; - return msg; - }; - - if (! streamSink.initialised) - { - streamSink.manager.initialise(0, 2, nullptr, false); - streamSink.initialised = true; - } - - juce::AudioIODeviceType* outType = nullptr; - for (auto* t : streamSink.manager.getAvailableDeviceTypes()) - if (t->getTypeName() == typeName) { outType = t; break; } - if (! outType) return fail("Stream output device type not found: " + typeName); - - try { - if (auto* cur = streamSink.manager.getCurrentDeviceTypeObject()) - { - if (cur->getTypeName() != typeName) - streamSink.manager.setCurrentAudioDeviceType(typeName, true); - } - else streamSink.manager.setCurrentAudioDeviceType(typeName, true); - } catch (...) { return fail("setCurrentAudioDeviceType threw for stream output type '" + typeName + "'"); } - - juce::String resolved = deviceName; - if (resolved.isEmpty()) - { - auto names = outType->getDeviceNames(false); - if (names.size() > 0) resolved = names[0]; - } - - juce::AudioDeviceManager::AudioDeviceSetup setup; - setup.inputDeviceName = ""; - setup.outputDeviceName = resolved; - setup.sampleRate = currentSampleRate.load(std::memory_order_relaxed); - setup.bufferSize = outputBlockSize.load(std::memory_order_relaxed); - setup.useDefaultInputChannels = false; - setup.useDefaultOutputChannels = false; - setup.inputChannels.clear(); - setup.outputChannels.setRange(0, 2, true); - - juce::String err; - try { err = streamSink.manager.setAudioDeviceSetup(setup, true); } - catch (...) { return fail("stream output setAudioDeviceSetup threw"); } - if (err.isNotEmpty()) return fail("stream output setup: " + err); - - auto* dev = streamSink.manager.getCurrentAudioDevice(); - if (! dev) return fail("no stream output device after setup"); - const double devSr = dev->getCurrentSampleRate(); - const double engineSr = currentSampleRate.load(std::memory_order_relaxed); - if (engineSr > 0.0 && std::abs(devSr - engineSr) > 0.5) - return fail("Stream output sample rate (" + juce::String(devSr) - + ") must match the engine rate (" + juce::String(engineSr) - + "). Pick a device that supports " + juce::String(engineSr) + " Hz."); - - streamSink.callback.engine = this; - if (! streamSink.callbackRegistered) - { - streamSink.manager.addAudioCallback(&streamSink.callback); - streamSink.callbackRegistered = true; - } - streamSink.active.store(true, std::memory_order_release); - fprintf(stderr, "[AudioEngine] stream output active: %s (%s)\n", - resolved.toRawUTF8(), typeName.toRawUTF8()); - return {}; -} - -void AudioEngine::closeStreamSinkDevice() -{ - // Detach the drain callback and close the device, leaving desiredTypeName/Name - // intact so startAudio()/reopenDesiredStreamSink() can restore it. active=false - // first so the producer stops pushing; removeAudioCallback() then blocks until - // the consumer callback is no longer in flight, so the ring/device go quiescent - // before close. Idempotent — safe when nothing is open. - streamSink.active.store(false, std::memory_order_release); - if (streamSink.callbackRegistered) - { - streamSink.manager.removeAudioCallback(&streamSink.callback); - streamSink.callbackRegistered = false; - } - try { streamSink.manager.closeAudioDevice(); } catch (...) {} - streamSinkLevel.store(0.0f, std::memory_order_relaxed); + return streamSink.open(typeName, deviceName); } void AudioEngine::clearStreamOutput() { - closeStreamSinkDevice(); - streamSink.desiredTypeName = {}; - streamSink.desiredDeviceName = {}; -} - -void AudioEngine::reopenDesiredStreamSink() -{ - // Copy the intent first: setStreamOutputDevice() mutates desiredTypeName/Name - // (and clears them on failure), so don't pass the members in by reference. - const juce::String t = streamSink.desiredTypeName; - const juce::String d = streamSink.desiredDeviceName; - if (d.isEmpty() && t.isEmpty()) return; - const juce::String err = setStreamOutputDevice(t, d); - if (err.isNotEmpty()) - fprintf(stderr, "[AudioEngine] reopenDesiredStreamSink failed: %s\n", err.toRawUTF8()); + streamSink.clear(); } void AudioEngine::audioDeviceIOCallbackWithContext( @@ -2275,7 +955,8 @@ void AudioEngine::audioDeviceIOCallbackWithContext( // Publish that the callback body is executing so removeSource() and deferred- // release reclamation know when no source is being processed (the body is // quiescent) and a removed source can be safely released. Index 0 = primary. - const int inFlightBefore = callbacksInFlight[0].fetch_add(1, std::memory_order_acq_rel); + const slopsmith::SourcePool::CallbackGuard cbGuard(pool, 0); + const int inFlightBefore = cbGuard.previousInFlight; // DIAG: two primary callback bodies at once = the input callback is // registered twice on the device manager (the half-speed/garble bug) or a @@ -2295,7 +976,7 @@ void AudioEngine::audioDeviceIOCallbackWithContext( const bool duplex = duplexMode.load(std::memory_order_relaxed); // Duplex writes outputData directly. Split runs DSP into a private 2-channel - // scratch and pushes the result to outputPendingRing for OutputCallback. + // scratch and pushes the result to outputRing for OutputCallback. juce::AudioBuffer buffer; if (duplex) { @@ -2335,8 +1016,8 @@ void AudioEngine::audioDeviceIOCallbackWithContext( // clock — never here. With no extra device every source is deviceKey 0, so // this is the single-device path unchanged. See mixSourcesForDevice for the // fast-path / multi-source rationale; it is shared with each extra callback. - mixSourcesForDevice(0, inputData, numInputChannels, buffer, sourceMonitorScratch, - effectiveOutputChannels, numSamples); + pool.mixForDevice(0, inputData, numInputChannels, buffer, sourceMonitorScratch, + effectiveOutputChannels, numSamples); // Duplex mixes backing + applies output gain + meters here. // Split defers all three to OutputCallback (output device's clock). @@ -2344,7 +1025,7 @@ void AudioEngine::audioDeviceIOCallbackWithContext( { // Stream sink (producer): snapshot the guitar monitor mix BEFORE backing is // added, so the stream submix can carry guitar independent of the local mix. - const bool streamActive = streamSink.active.load(std::memory_order_acquire); + const bool streamActive = streamSink.isActive(); int streamBackingFrames = 0; float streamBackingVol = 0.0f; bool streamBackingOn = false; @@ -2353,17 +1034,17 @@ void AudioEngine::audioDeviceIOCallbackWithContext( streamGuitarScratch.copyFrom(ch, 0, buffer, juce::jmin(ch, buffer.getNumChannels() - 1), 0, numSamples); - const juce::ScopedTryLock sl(backingLock); - if (sl.isLocked() && backingTransport && backingPlaying.load()) + const juce::ScopedTryLock sl(backing.getLock()); + if (sl.isLocked() && backing.readyLocked()) { - const int outSamples = renderBackingBlockLocked(numSamples); + const int outSamples = backing.renderBlockLocked(numSamples); const float bVol = backingVolume.load(); streamBackingFrames = outSamples; streamBackingVol = bVol; streamBackingOn = true; const int mixChannels = juce::jmin(numOutputChannels, 2); float backingLevelSq = 0.0f; for (int ch = 0; ch < mixChannels; ++ch) { - const float* const src = backingBuffer.getReadPointer(ch); + const float* const src = backing.renderBuffer().getReadPointer(ch); float sumSquares = 0.0f; for (int i = 0; i < outSamples; ++i) sumSquares += src[i] * src[i]; @@ -2372,7 +1053,7 @@ void AudioEngine::audioDeviceIOCallbackWithContext( ? std::sqrt(sumSquares / outSamples) * bVol : 0.0f; backingLevelSq += channelRms * channelRms; - buffer.addFrom(ch, 0, backingBuffer, ch, 0, outSamples, bVol); + buffer.addFrom(ch, 0, backing.renderBuffer(), ch, 0, outSamples, bVol); } currentBackingLevel.store((mixChannels > 0) ? std::sqrt(backingLevelSq / mixChannels) @@ -2392,8 +1073,8 @@ void AudioEngine::audioDeviceIOCallbackWithContext( // backing + renderer-bus song audio) BEFORE the local master output // gain, so the stream level is independent. if (streamActive) - composeAndPushStreamMix(streamGuitarScratch, - streamBackingOn ? &backingBuffer : nullptr, + streamSink.publish(streamGuitarScratch, + streamBackingOn ? &backing.renderBuffer() : nullptr, streamBackingFrames, streamBackingVol, rendererFrames > 0 ? &rendererBusPullScratch : nullptr, rendererFrames, numSamples); @@ -2419,474 +1100,25 @@ void AudioEngine::audioDeviceIOCallbackWithContext( // Split: push processed stereo (pre-backing, pre-output-gain) into the // primary ring. OutputCallback adds backing + output gain on its own clock // and sums every extra-device ring alongside this one. - packStereoIntoRing(buffer, numSamples, outputPendingRing, outputRingWriteIndex); + outputRing.push(buffer.getReadPointer(0), buffer.getReadPointer(1), numSamples); } - // Body done — this callback is no longer processing a source. Pairs with - // removeSource()/reclaimPendingReleases() acquire loads. Index 0 = primary. - callbacksInFlight[0].fetch_sub(1, std::memory_order_acq_rel); -} - -int AudioEngine::mixSourcesForDevice(int deviceKey, const float* const* inputData, int numInputChannels, - juce::AudioBuffer& mixBuf, juce::AudioBuffer& monitorScratch, - int effectiveOutputChannels, int numSamples) -{ - // Snapshot each source's active flag ONCE so the count and the process/mix - // passes are consistent within this block — a concurrent add/removeSource - // flipping a flag between two reads must not change which branch runs. - // removeSource waits for all callback bodies to drain before releasing, so a - // source snapshotted active here is safe even if deactivated an instant later. - bool act[kMaxSources]; - int firstActive = -1, activeCount = 0; - for (int i = 0; i < kMaxSources; ++i) - { - act[i] = sources[(size_t) i]->isActive() - && sources[(size_t) i]->getDeviceKey() == deviceKey; - if (act[i]) { ++activeCount; if (firstActive < 0) firstActive = i; } - } - - if (activeCount == 0) - { - // No source on this device → silence. An extra device with no bound source - // contributes nothing to the output sum. The primary always has sources[0] - // (deviceKey 0, active from construction), so it never reaches this branch. - for (int ch = 0; ch < effectiveOutputChannels; ++ch) - mixBuf.clear(ch, 0, numSamples); - return 0; - } - - if (activeCount == 1) - { - // Fast path — exactly one source: process in place on mixBuf, byte- - // identical to the single-pipeline engine (channel select / mono mix + - // input gain, metering, ML + ring feed, gate, YIN, tone chain, monitor). - sources[(size_t) firstActive] - ->processBlock(inputData, numInputChannels, mixBuf, effectiveOutputChannels, numSamples); - return 1; - } - - // Multi-source: each renders its own 2-channel monitor into monitorScratch - // (each builds its mono from its bound channel + feeds its own rings / - // detectors / verifier), summed to STEREO (0/1). A >2-channel output keeps - // channels 2+ silent in multi-source mode (the fast path still broadcasts). - for (int ch = 0; ch < effectiveOutputChannels; ++ch) - mixBuf.clear(ch, 0, numSamples); - const int mixCh = juce::jmin(effectiveOutputChannels, 2); - const int n = juce::jmin(numSamples, monitorScratch.getNumSamples()); - for (int i = 0; i < kMaxSources; ++i) - { - if (! act[i]) continue; - sources[(size_t) i]->processBlock(inputData, numInputChannels, monitorScratch, 2, n); - for (int ch = 0; ch < mixCh; ++ch) - mixBuf.addFrom(ch, 0, monitorScratch, ch, 0, n); - } - return activeCount; -} - -void AudioEngine::packStereoIntoRing(const juce::AudioBuffer& buf, int numSamples, - std::array, kOutputRingFrames>& ring, - std::atomic& writeIndex) -{ - // Strict SPSC: the producer (one device callback) only ever writes writeIndex; - // the consumer (audioOutputCallback) is the sole writer of the paired - // readIndex. Drop-oldest = letting writeIndex lap the buffer; the consumer - // advances readIndex when it observes (w - r) > cap. The single packed store - // prevents an L/R tear when the producer wraps mid-callback (relaxed because - // ordering is established by the release on writeIndex below). - constexpr uint64_t kMask = (uint64_t) kOutputRingFrames - 1; - const uint64_t w = writeIndex.load(std::memory_order_relaxed); - const float* L = buf.getReadPointer(0); - const float* R = buf.getReadPointer(1); - for (int i = 0; i < numSamples; ++i) - { - const uint64_t slot = (w + (uint64_t) i) & kMask; - ring[slot].store(packLR(L[i], R[i]), std::memory_order_relaxed); - } - writeIndex.store(w + (uint64_t) numSamples, std::memory_order_release); -} - -void AudioEngine::extraInputCallback(int slot, const float* const* inputData, int numInputChannels, int numSamples) -{ - if (slot < 0 || slot >= kMaxExtraInputDevices) return; - InputDeviceSlot& s = extraInputs[(size_t) slot]; - if (! s.active.load(std::memory_order_acquire)) return; - - callbacksInFlight[(size_t) s.deviceKey].fetch_add(1, std::memory_order_acq_rel); - - // Clamp to the per-slot scratch sized in extraInputAboutToStart so the hot - // loop never allocates if a reconfig race delivers a larger block. - const int cap = s.fanScratch.getNumSamples(); - if (numSamples > cap) numSamples = cap; - - juce::AudioBuffer mix; - mix.setDataToReferTo(s.fanScratch.getArrayOfWritePointers(), 2, numSamples); - mixSourcesForDevice(s.deviceKey, inputData, numInputChannels, mix, s.monitorScratch, 2, numSamples); - packStereoIntoRing(mix, numSamples, s.ring, s.writeIndex); - - callbacksInFlight[(size_t) s.deviceKey].fetch_sub(1, std::memory_order_acq_rel); -} - -void AudioEngine::extraInputAboutToStart(int slot, juce::AudioIODevice* device) -{ - if (slot < 0 || slot >= kMaxExtraInputDevices || device == nullptr) return; - InputDeviceSlot& s = extraInputs[(size_t) slot]; - const int bs = device->getCurrentBufferSizeSamples(); - s.blockSize.store(bs, std::memory_order_relaxed); - // Prepare against this DEVICE's actual sample rate — the source of truth. - // bindInputDevice forces it to (and verifies it equals) the engine rate, so the - // verifier (which reads the engine-wide currentSampleRate) and the detectors - // agree. Reading the device here rather than assuming currentSampleRate keeps - // the prepare correct even if a future path opens it differently. - double sr = device->getCurrentSampleRate(); - if (sr <= 0.0) sr = currentSampleRate.load(std::memory_order_relaxed); - s.sampleRate.store(sr, std::memory_order_relaxed); - // Size per-slot scratch generously (cold-start guard) on this device-management - // thread — never the RT thread. - const int cap = juce::jmax(bs, 2048); - s.fanScratch.setSize(2, cap, false, false, true); - s.monitorScratch.setSize(2, cap, false, false, true); - s.fanScratch.clear(); - s.monitorScratch.clear(); - s.writeIndex.store(0, std::memory_order_relaxed); - s.readIndex.store(0, std::memory_order_relaxed); - for (auto& v : s.ring) v.store(0, std::memory_order_relaxed); - - // Capture-latency correction: the renderer's playhead is aligned to the PRIMARY - // device's input latency, but this extra device captures with a different - // latency, so its audio sits at a different song-time than the playhead assumes. - // Set its sources' verifier offset to (extra − primary) input latency so they - // match this device's just-captured audio against the right chart notes. - int extraLatSamples = device->getInputLatencyInSamples(); - int primaryLatSamples = 0; - if (auto* pdev = inputDeviceManager.getCurrentAudioDevice()) - primaryLatSamples = pdev->getInputLatencyInSamples(); - // (extra − primary) reported input latency. On JACK/PipeWire this is 0 (no - // latency reported); the residual per-device offset is instead dialed in by the - // user via setSourceVerifierOffset (a stable auto-measure isn't possible — the - // value is device-specific and signal-level-confounded). 0 here = no auto shift. - const double deltaSec = (sr > 0.0) ? (double) (extraLatSamples - primaryLatSamples) / sr : 0.0; - s.latencyDeltaSec.store(deltaSec, std::memory_order_relaxed); - - // Prepare each source bound to this device so its verifier/detectors run, and - // apply the latency correction. - { - std::lock_guard lock(sourcesMutex); - for (auto& src : sources) - if (src->isActive() && src->getDeviceKey() == s.deviceKey) - { - src->prepare(sr, bs); - src->setVerifierAutoOffset(deltaSec); - } - } - s.active.store(true, std::memory_order_release); -} - -void AudioEngine::extraInputStopped(int slot) -{ - if (slot < 0 || slot >= kMaxExtraInputDevices) return; - InputDeviceSlot& s = extraInputs[(size_t) slot]; - // JUCE blocks for this slot's callback thread before firing this, so the - // slot's body is quiescent. Hide it from the output sum, then release ITS - // sources (no other callback touches them — they all filter by deviceKey). - s.active.store(false, std::memory_order_release); - { - std::lock_guard lock(sourcesMutex); - // PERMANENT unbind (the user removed this device): the slot will never - // re-open, so DEACTIVATE its sources too — leaving them "active" would strand - // pooled slots no callback can ever service (a ghost detector in listSources). - // A TRANSIENT close (stopAudio/reconfigure/unplug) only releases them, so - // startAudio()'s re-open resumes them in place. Read the atomic flag (set by - // the control-thread unbind) rather than the juce::String desiredDeviceName, - // which this device-thread path must not race on. - const bool permanent = s.permanentUnbind.load(std::memory_order_acquire); - for (auto& src : sources) - if (src->isActive() && src->getDeviceKey() == s.deviceKey) - { - src->releaseResources(); - // Zero the meters so getSourceLevels() reports silence while the - // device is gone — otherwise the renderer's per-source silence gate - // treats a stopped/unplugged input as still hearing audio (the last - // non-zero level latches). releaseResources() doesn't touch them. - src->resetInputMeters(); - if (permanent) src->setActive(false); - } - // Retry any removeSource() cleanup that was deferred waiting for callbacks to - // drain. With this slot's callback now stopped, callbacksInFlight may finally - // be 0; audioDeviceStopped() (primary) might never observe that window during - // multi-device playback, so reclaim here too or a pending slot can leak until - // the next add/remove. - reclaimPendingReleases(); - } - s.writeIndex.store(0, std::memory_order_relaxed); - s.readIndex.store(0, std::memory_order_relaxed); + // Body done (CallbackGuard dtor) — pairs with remove()/reclaim acquire loads. } int AudioEngine::activeExtraInputCount() const { - int n = 0; - for (const auto& s : extraInputs) - if (s.active.load(std::memory_order_acquire)) ++n; - return n; + return extraInputs.activeCount(); } juce::String AudioEngine::bindInputDevice(int deviceKey, const juce::String& deviceName) { - if (deviceKey < 1 || deviceKey > kMaxExtraInputDevices) - return "deviceKey out of range"; - const int slot = deviceKey - 1; - InputDeviceSlot& s = extraInputs[(size_t) slot]; - if (s.active.load(std::memory_order_acquire)) - return "device slot already bound"; - - // Reject binding the SAME physical device into a second slot. Two callbacks - // reading one interface is wasteful (and fails outright on exclusive drivers); - // multiple sources that want this device should share its one deviceKey and pick - // different channels instead. Checks both open + deferred (desired) slots. - for (int other = 0; other < kMaxExtraInputDevices; ++other) - if (other != slot && extraInputs[(size_t) other].desiredDeviceName == deviceName) - return "device already bound to another input slot"; - - // Reject binding the device that is the PRIMARY input — it is already "Main", and - // opening it on this slot's manager too would double-open one interface on two - // managers (fatal on exclusive backends). Critically this also guards the REOPEN - // path: if the user makes a bound extra device the new main input, the preserved - // intent must NOT resurrect it as an extra (reopenDesiredExtraInputs() then drops - // the now-invalid binding via its failure handling). - if (auto* primary = inputDeviceManager.getCurrentAudioDevice()) - if (primary->getName() == deviceName) - return "device is the primary input — use Main, not an extra slot"; - - // An extra input device requires SPLIT mode: the output callback owns the mix + - // backing + gain and sums every device ring. In DUPLEX the primary device owns - // both directions and the output manager is closed, so we cannot just flip the - // flag — that would leave the output mix path absent (silent / unrouted). Reject - // here so the renderer reconfigures to a separate output device first. Checked - // BEFORE the deferred path below — startAudio()'s reopen also skips duplex, so a - // deferred bind in duplex would silently never come up while reporting success. - if (duplexMode.load(std::memory_order_relaxed)) - return "extra input requires split mode — select a separate output device first"; - - // Deregister any STALE callback BEFORE touching the manager. An earlier unplanned - // stop (USB unplug / backend restart) leaves s.callback registered; if we opened - // the manager (initialise / setAudioDeviceSetup) with it still attached, JUCE - // could dispatch it on the default/new device mid-setup — processing the wrong - // hardware, or even firing extraInputAboutToStart() during a stopped-engine - // validation open. Idempotent no-op when not registered. - s.manager.removeAudioCallback(&s.callback); - - // Open `deviceName` input-only on this slot's own manager. initialise first so - // the manager has a device type, then switch to the requested input device with - // all its channels (the source picks a channel within). - s.manager.initialiseWithDefaultDevices(2, 0); - - // The device name may belong to a device TYPE (ALSA / JACK / CoreAudio / …) - // different from the slot manager's default — a JACK device name won't resolve - // under ALSA and vice-versa ("No such device"). Find the type that actually - // lists this input device and switch the slot manager to it. Prefer the primary - // manager's current type (the devices the user already sees working). - juce::String chosenType; - if (auto* pt = inputDeviceManager.getCurrentDeviceTypeObject()) - { - pt->scanForDevices(); - if (pt->getDeviceNames(true).contains(deviceName)) - chosenType = pt->getTypeName(); - } - if (chosenType.isEmpty()) - for (auto* t : s.manager.getAvailableDeviceTypes()) - { - t->scanForDevices(); - if (t->getDeviceNames(true).contains(deviceName)) { chosenType = t->getTypeName(); break; } - } - // setCurrentAudioDeviceType can THROW from inside some JUCE backends (ASIO, and - // misconfigured JACK/CoreAudio) — setAudioDevices() guards it for the primary, so - // this path must too, or a bad backend terminates the process instead of - // returning an error to the renderer. Close the slot manager on failure. - if (chosenType.isNotEmpty()) - { - try { s.manager.setCurrentAudioDeviceType(chosenType, true); } - catch (...) { s.manager.closeAudioDevice(); return "extra-input setCurrentAudioDeviceType threw"; } - } - - juce::AudioDeviceManager::AudioDeviceSetup setup; - s.manager.getAudioDeviceSetup(setup); - setup.inputDeviceName = deviceName; - setup.outputDeviceName = ""; - // Open ALL of the device's capture channels (not just the default first pair), - // so a source bound to channel 2+ of a multi-channel extra interface actually - // receives audio — mirrors the primary device's explicit full-range open. - int inputChannelCount = 0; - if (auto* t = s.manager.getCurrentDeviceTypeObject()) - { - std::unique_ptr probe(t->createDevice({}, deviceName)); - if (probe) inputChannelCount = probe->getInputChannelNames().size(); - } - if (inputChannelCount <= 0) inputChannelCount = 2; - setup.inputChannels.setRange(0, inputChannelCount, true); - setup.useDefaultInputChannels = false; - setup.useDefaultOutputChannels = false; - // Force the extra device to the ENGINE's sample rate. Each SourceChain's - // verifier/detectors read the engine-wide currentSampleRate (bound by - // reference at construction), so an extra input running at a different rate - // (e.g. a 44.1 kHz device in a 48 kHz engine) would be scored on the wrong - // clock — skewing pitch/timing for every source bound to it. Matching the - // engine rate here (the OS/driver resamples if needed) keeps them coherent; a - // device that cannot do this rate fails the setup below and is rejected. - const double engineSr = currentSampleRate.load(std::memory_order_relaxed); - if (engineSr > 0.0) - setup.sampleRate = engineSr; - // initialiseWithDefaultDevices above may have opened a default capture device on - // this slot manager; every failure path below must close it, or a failed bind - // leaves the interface captured until engine teardown (fatal on exclusive - // backends + breaks retries / other apps). - juce::String err; - try { err = s.manager.setAudioDeviceSetup(setup, true); } - catch (...) { s.manager.closeAudioDevice(); return "extra-input setAudioDeviceSetup threw"; } - if (err.isNotEmpty()) - { - s.manager.closeAudioDevice(); - return "extra input: " + err + (chosenType.isEmpty() ? " (no type lists this device)" : " (type " + chosenType + ")"); - } - auto* extraDev = s.manager.getCurrentAudioDevice(); - if (extraDev == nullptr) - { - s.manager.closeAudioDevice(); - return "extra input device did not open"; - } - - // Some backends accept the rate request but actually open at a different rate. - // Since the SourceChain verifier reads the engine-wide currentSampleRate, a - // mismatch would score this device on the wrong clock — reject rather than - // ship silently-wrong timing. (Tolerant of a sub-Hz rounding difference.) - if (engineSr > 0.0 && std::abs(extraDev->getCurrentSampleRate() - engineSr) > 1.0) - { - const juce::String got = juce::String(extraDev->getCurrentSampleRate()); - s.manager.closeAudioDevice(); - return "extra input opened at " + got + " Hz, not the engine rate " + juce::String(engineSr) + " Hz"; - } - - // The device opened + validated. Record the INTENT now (not before the fallible - // open above), so it drives re-open across a reconfigure without lingering after - // a failed attach. Clear the permanent-unbind flag: a future stop on this slot is - // transient (resume) until the user explicitly unbinds again. - s.desiredDeviceName = deviceName; - s.permanentUnbind.store(false, std::memory_order_release); - - // If the engine is not running, we opened only to VALIDATE eagerly (so an - // unplugged / wrong-rate device fails the bind NOW instead of silently dropping - // at the next startAudio). Close it again so a stopped engine never leaves an - // interface capturing in the background; reopenDesiredExtraInputs() re-opens it - // (and re-attaches the callback) when the engine next starts. - if (! audioRunning.load(std::memory_order_relaxed)) - { - s.manager.closeAudioDevice(); - return {}; - } - - // Attach the callback — fires extraInputAboutToStart (prepares + flips active). - // Any stale registration was already removed before the open above, so this - // registers exactly once. - s.manager.addAudioCallback(&s.callback); - return {}; + return extraInputs.bind(deviceKey, deviceName); } bool AudioEngine::unbindInputDevice(int deviceKey) { - if (deviceKey < 1 || deviceKey > kMaxExtraInputDevices) - return false; - const int slot = deviceKey - 1; - // User-initiated unbind: mark it PERMANENT (the device thread's extraInputStopped - // reads this atomic to deactivate the slot's sources) BEFORE closing, and forget - // the INTENT so a later startAudio() does not resurrect a device the user - // deliberately removed. - extraInputs[(size_t) slot].permanentUnbind.store(true, std::memory_order_release); - extraInputs[(size_t) slot].desiredDeviceName = {}; - // If the device is open, closing it fires extraInputStopped(), which — with the - // intent now cleared — deactivates this deviceKey's sources. If it was ALREADY - // closed (e.g. a prior stopAudio() kept the intent + left the sources active for - // a resume that will now never come), extraInputStopped() will NOT run, so we - // must deactivate them here — otherwise they linger as ghost sources stranding - // pool slots and showing in listSources(). - if (! closeExtraInputDevice(slot)) - { - std::lock_guard lock(sourcesMutex); - for (auto& src : sources) - if (src->isActive() && src->getDeviceKey() == deviceKey) - { - src->releaseResources(); - src->setActive(false); - } - } - return true; -} - -// Close the device open on a slot WITHOUT forgetting desiredDeviceName, so -// startAudio() re-opens it. Used by stopAudio()/reconfigure (transient close) — the -// public unbindInputDevice() clears the intent first (permanent removal). -bool AudioEngine::closeExtraInputDevice(int slot) -{ - if (slot < 0 || slot >= kMaxExtraInputDevices) - return false; - InputDeviceSlot& s = extraInputs[(size_t) slot]; - const bool wasActive = s.active.load(std::memory_order_acquire); - // Close + deregister UNCONDITIONALLY (not gated on `active`). An UNPLANNED stop - // (USB unplug / backend restart) fires extraInputStopped() — flipping active - // false — yet leaves the manager owning a (possibly auto-recovering) device and - // s.callback still registered. If we no-oped on !active, stopAudio()/reconfigure - // would never release it and the backend could resume callbacks after the engine - // is supposedly stopped. Both calls are idempotent when already closed/absent. - // For an ACTIVE slot, closeAudioDevice() blocks for the callback thread then fires - // audioDeviceStopped → extraInputStopped (releases this device's sources). - s.manager.closeAudioDevice(); - s.manager.removeAudioCallback(&s.callback); - return wasActive; -} - -// Re-open every slot that has a desiredDeviceName but is not currently active — the -// post-(re)start restore of extra inputs. No-op in duplex (extras need split) and -// when nothing is desired (the single-device path). Called from startAudio(). -void AudioEngine::reopenDesiredExtraInputs() -{ - if (duplexMode.load(std::memory_order_relaxed)) - { - // Duplex has no consumer for extra-device rings, so the desired extras cannot - // open right now. PRESERVE their intent (so a later switch back to split - // auto-restores them — setAudioDevices() promises bindings survive a device - // change) and keep their sources active to resume in place; but ZERO their - // meters so getSourceLevels() reports silence while the device is gone (the - // renderer's per-source silence gate then won't treat a temporarily-unavailable - // source as still hearing audio, and there is no false detection). The sources - // are not "ghosts": split-restore reopens the device and they resume. - for (int dk = 1; dk <= kMaxExtraInputDevices; ++dk) - { - if (extraInputs[(size_t) (dk - 1)].desiredDeviceName.isEmpty()) - continue; - std::lock_guard lock(sourcesMutex); - for (auto& src : sources) - if (src->isActive() && src->getDeviceKey() == dk) - src->resetInputMeters(); - } - return; - } - for (int dk = 1; dk <= kMaxExtraInputDevices; ++dk) - { - InputDeviceSlot& s = extraInputs[(size_t) (dk - 1)]; - if (s.desiredDeviceName.isEmpty() || s.active.load(std::memory_order_acquire)) - continue; - const juce::String err = bindInputDevice(dk, s.desiredDeviceName); // re-sets desired (idempotent) - if (err.isNotEmpty()) - { - // Reopen failed — the interface was unplugged, or no longer supports the - // engine rate. The transient close kept this slot's sources ACTIVE to - // resume; since they now never will, give up cleanly: drop the intent and - // deactivate them so they do not linger as ghost sources stranding pool - // slots. The renderer re-binds + re-adds if the device returns. - s.desiredDeviceName = {}; - std::lock_guard lock(sourcesMutex); - for (auto& src : sources) - if (src->isActive() && src->getDeviceKey() == dk) - src->setActive(false); - } - } + return extraInputs.unbind(deviceKey); } void AudioEngine::audioOutputCallback(const float* const* /*inputData*/, @@ -2905,9 +1137,6 @@ void AudioEngine::audioOutputCallback(const float* const* /*inputData*/, if (numOutputChannels <= 0) return; - constexpr uint64_t kMask = (uint64_t) kOutputRingFrames - 1; - constexpr uint64_t kCap = (uint64_t) kOutputRingFrames; - if ((int) outputPullScratchL.size() < numSamples && audiodiag::firstN(audiodiag::outputOversized)) fprintf(stderr, "[diag] output callback OVERSIZED block: numSamples=%d > scratch=%d\n", numSamples, (int) outputPullScratchL.size()); @@ -2919,27 +1148,15 @@ void AudioEngine::audioOutputCallback(const float* const* /*inputData*/, const int scratchCap = (int) outputPullScratchL.size(); const int outSamples = juce::jmin(numSamples, scratchCap); - uint64_t r = outputRingReadIndex.load(std::memory_order_relaxed); - const uint64_t w = outputRingWriteIndex.load(std::memory_order_acquire); + uint64_t r = outputRing.readIndex.load(std::memory_order_relaxed); + const uint64_t w = outputRing.writeIndex.load(std::memory_order_acquire); - // If audioDeviceStopped() raced between our two loads and reset both - // indices to 0, we can observe w < r. Treat that as an empty ring - // and resync — without this, the unsigned (w - r) wraps into a huge - // positive value and falls into the catch-up branch reading stale slots. - if (w < r) - { - r = w; - outputRingReadIndex.store(r, std::memory_order_relaxed); - } - - // Catch up if the producer has lapped (drop-oldest is achieved via this - // single-writer consumer-side advance, not a producer-side write to r). - if ((w - r) > kCap) - { - r = w - kCap; - outputRingReadIndex.store(r, std::memory_order_relaxed); + // Resync if audioDeviceStopped() raced between our two loads and reset + // the indices; catch up (drop-oldest) if the producer lapped — both moves + // live on PackedStereoRing now, same semantics as before. + outputRing.resyncIfIndicesReset(r, w); + if (outputRing.catchUpIfLapped(r, w)) inputOverflowCount.fetch_add(1, std::memory_order_relaxed); - } const uint64_t available = w - r; const int pullCount = juce::jmin(outSamples, (int) available); @@ -2954,11 +1171,10 @@ void AudioEngine::audioOutputCallback(const float* const* /*inputData*/, for (int i = 0; i < pullCount; ++i) { - const uint64_t slot = (r + (uint64_t) i) & kMask; // Single atomic load → atomic unpack of both channels (matches the // producer's packed store) so L and R always belong to the same frame. float l, rr; - unpackLR(outputPendingRing[slot].load(std::memory_order_relaxed), l, rr); + outputRing.readFrame(r + (uint64_t) i, l, rr); outputPullScratchL[(size_t) i] = l; outputPullScratchR[(size_t) i] = rr; } @@ -2971,7 +1187,7 @@ void AudioEngine::audioOutputCallback(const float* const* /*inputData*/, } outputUnderflowCount.fetch_add(1, std::memory_order_relaxed); } - outputRingReadIndex.store(r + (uint64_t) consumeCount, std::memory_order_release); + outputRing.commitRead(r + (uint64_t) consumeCount); buffer.clear(); const int copyChannels = juce::jmin(numOutputChannels, 2); @@ -2986,35 +1202,30 @@ void AudioEngine::audioOutputCallback(const float* const* /*inputData*/, // output. Each is an independent SPSC ring fed by that device's own callback at // its own hardware clock; the same drop-oldest catch-up absorbs its drift, so // two separate interfaces mix cleanly with no cross-device resampling. - for (auto& s : extraInputs) + for (auto& s : extraInputs.slots) { if (! s.active.load(std::memory_order_acquire)) continue; - uint64_t er = s.readIndex.load(std::memory_order_relaxed); - const uint64_t ew = s.writeIndex.load(std::memory_order_acquire); - if (ew < er) { er = ew; s.readIndex.store(er, std::memory_order_relaxed); } - if ((ew - er) > kCap) - { - er = ew - kCap; - s.readIndex.store(er, std::memory_order_relaxed); + uint64_t er = s.ring.readIndex.load(std::memory_order_relaxed); + const uint64_t ew = s.ring.writeIndex.load(std::memory_order_acquire); + s.ring.resyncIfIndicesReset(er, ew); + if (s.ring.catchUpIfLapped(er, ew)) s.overflowCount.fetch_add(1, std::memory_order_relaxed); - } const uint64_t eAvail = ew - er; const int ePull = juce::jmin(outSamples, (int) eAvail); const int eConsume = juce::jmin(numSamples, (int) eAvail); for (int i = 0; i < ePull; ++i) { - const uint64_t slot = (er + (uint64_t) i) & kMask; float l, rr; - unpackLR(s.ring[(size_t) slot].load(std::memory_order_relaxed), l, rr); + s.ring.readFrame(er + (uint64_t) i, l, rr); buffer.addSample(0, i, l); if (copyChannels > 1) buffer.addSample(1, i, rr); } - s.readIndex.store(er + (uint64_t) eConsume, std::memory_order_release); + s.ring.commitRead(er + (uint64_t) eConsume); } // Stream sink (producer, split clock): snapshot the full guitar mix (primary // ring + extra inputs) BEFORE backing is added. - const bool streamActive = streamSink.active.load(std::memory_order_acquire); + const bool streamActive = streamSink.isActive(); int streamBackingFrames = 0; float streamBackingVol = 0.0f; bool streamBackingOn = false; @@ -3024,11 +1235,11 @@ void AudioEngine::audioOutputCallback(const float* const* /*inputData*/, juce::jmin(ch, buffer.getNumChannels() - 1), 0, numSamples); { - const juce::ScopedTryLock sl(backingLock); - if (sl.isLocked() && backingTransport && backingPlaying.load()) + const juce::ScopedTryLock sl(backing.getLock()); + if (sl.isLocked() && backing.readyLocked()) { // Shared with the duplex path so the two callbacks can't drift. - const int backingOut = renderBackingBlockLocked(numSamples); + const int backingOut = backing.renderBlockLocked(numSamples); const float bVol = backingVolume.load(); streamBackingFrames = backingOut; streamBackingVol = bVol; streamBackingOn = true; // RMS, computed identically to the duplex path so getBackingLevel() @@ -3037,7 +1248,7 @@ void AudioEngine::audioOutputCallback(const float* const* /*inputData*/, float backingLevelSq = 0.0f; for (int ch = 0; ch < copyChannels; ++ch) { - const float* const src = backingBuffer.getReadPointer(ch); + const float* const src = backing.renderBuffer().getReadPointer(ch); float sumSquares = 0.0f; for (int i = 0; i < backingOut; ++i) sumSquares += src[i] * src[i]; @@ -3046,7 +1257,7 @@ void AudioEngine::audioOutputCallback(const float* const* /*inputData*/, ? std::sqrt(sumSquares / backingOut) * bVol : 0.0f; backingLevelSq += channelRms * channelRms; - buffer.addFrom(ch, 0, backingBuffer, ch, 0, backingOut, bVol); + buffer.addFrom(ch, 0, backing.renderBuffer(), ch, 0, backingOut, bVol); } currentBackingLevel.store((copyChannels > 0) ? std::sqrt(backingLevelSq / copyChannels) @@ -3068,8 +1279,8 @@ void AudioEngine::audioOutputCallback(const float* const* /*inputData*/, // path. When the tryLock failed (streamBackingOn=false) we pass a null backing // pointer and never touch backingBuffer, so there is nothing to protect. if (streamActive) - composeAndPushStreamMix(streamGuitarScratch, - streamBackingOn ? &backingBuffer : nullptr, + streamSink.publish(streamGuitarScratch, + streamBackingOn ? &backing.renderBuffer() : nullptr, streamBackingFrames, streamBackingVol, rendererFrames > 0 ? &rendererBusPullScratch : nullptr, rendererFrames, numSamples); @@ -3094,130 +1305,28 @@ void AudioEngine::audioOutputCallback(const float* const* /*inputData*/, bool AudioEngine::pushRendererAudio(const float* interleavedLR, int frames, double sourceRate) { - if (!rendererBusEnabled.load(std::memory_order_acquire)) return false; - if (interleavedLR == nullptr || frames <= 0) return false; - const double deviceRate = getCurrentSampleRate(); - if (deviceRate <= 0.0) return false; - if (!(sourceRate > 0.0)) sourceRate = deviceRate; - - constexpr uint64_t kMask = kRendererBusFrames - 1; - uint64_t w = rendererBusWriteIndex.load(std::memory_order_relaxed); - - // Linear resample source→device rate on this (IPC) thread. `pos` is the - // fractional read position into the incoming chunk; index -1 refers to the - // carried last frame of the previous chunk so interpolation is continuous - // across pushes. Equal rates degenerate to step == 1.0 (still exact: - // pos stays integral, frac == 0). - const double step = sourceRate / deviceRate; - double pos = rendererBusSrcPos; - uint64_t written = 0; - while (true) - { - const double ip = std::floor(pos); - const int i0 = (int) ip; - if (i0 + 1 >= frames) break; // next chunk continues from here - const float frac = (float) (pos - ip); - const float l0 = (i0 < 0) ? rendererBusPrevL : interleavedLR[(size_t) i0 * 2]; - const float r0 = (i0 < 0) ? rendererBusPrevR : interleavedLR[(size_t) i0 * 2 + 1]; - const float l1 = interleavedLR[((size_t) i0 + 1) * 2]; - const float r1 = interleavedLR[((size_t) i0 + 1) * 2 + 1]; - rendererBusRing[(size_t) (w & kMask)].store( - packLR(l0 + (l1 - l0) * frac, r0 + (r1 - r0) * frac), - std::memory_order_relaxed); - ++w; - ++written; - pos += step; - } - rendererBusSrcPos = pos - (double) frames; // relative to the next chunk - rendererBusPrevL = interleavedLR[((size_t) frames - 1) * 2]; - rendererBusPrevR = interleavedLR[((size_t) frames - 1) * 2 + 1]; - - // Publish. Overflow (producer lapping the consumer) is handled consumer- - // side with drop-oldest — same contract as the extra-input rings — so only - // the consumer ever moves readIndex. - rendererBusWriteIndex.store(w, std::memory_order_release); - rendererBusPushedFrames.fetch_add(written, std::memory_order_relaxed); - return true; + // Producer-side resample + publish live on RendererBus (engine/RendererBus.h). + return rendererBus.push(interleavedLR, frames, sourceRate, getCurrentSampleRate()); } int AudioEngine::pullRendererBus(juce::AudioBuffer& dest, int numSamples) { - if (!rendererBusEnabled.load(std::memory_order_acquire)) return 0; // Cold start before about-to-start sized the scratch — skip, never alloc // on the RT thread (same rule as the stream scratches). if (dest.getNumSamples() < numSamples || dest.getNumChannels() < 2) return 0; - constexpr uint64_t kMask = kRendererBusFrames - 1; - const uint64_t w = rendererBusWriteIndex.load(std::memory_order_acquire); - uint64_t r = rendererBusReadIndex.load(std::memory_order_relaxed); - if (w - r > (uint64_t) kRendererBusFrames) - { - // Producer lapped us — drop-oldest to the newest full ring. - r = w - (uint64_t) kRendererBusFrames; - rendererBusOverflowCount.fetch_add(1, std::memory_order_relaxed); - } - uint64_t avail = w - r; - - // Fill clamp (spike finding): steady-state drift is near zero, so a fill - // beyond kRendererBusMaxFill only ever means a renderer stall dumped a - // backlog. Trim to the prime target instead of playing the whole tail at - // ~85+ ms behind — a latency reset, not an audible gap. - if (avail > (uint64_t) kRendererBusMaxFillFrames) - { - r = w - (uint64_t) kRendererBusPrimeFrames; - avail = (uint64_t) kRendererBusPrimeFrames; - rendererBusOverflowCount.fetch_add(1, std::memory_order_relaxed); - } - - // Prefill gate (spike finding): the warmup underflow burst is the mix - // starting before the ring has a cushion. Consume nothing until the - // producer has built ~10 ms; re-arm the same gate after a real underflow - // so stall recovery is one clean gap, not a ragged refill. - if (!rendererBusPrimed) - { - if (avail < (uint64_t) kRendererBusPrimeFrames) - { - rendererBusReadIndex.store(r, std::memory_order_release); - return 0; - } - rendererBusPrimed = true; - } - if (avail < (uint64_t) numSamples) - { - // Underflow: emit silence for the whole block (partial blocks blip), - // drop what's buffered, and go back to priming. - rendererBusPrimed = false; - rendererBusUnderflowCount.fetch_add(1, std::memory_order_relaxed); - rendererBusReadIndex.store(w, std::memory_order_release); - return 0; - } - - const int pull = numSamples; - const float g = rendererBusGain.load(std::memory_order_relaxed); - float* dl = dest.getWritePointer(0); - float* dr = dest.getWritePointer(1); - for (int i = 0; i < pull; ++i) - { - float l, rr; - unpackLR(rendererBusRing[(size_t) ((r + (uint64_t) i) & kMask)].load(std::memory_order_relaxed), l, rr); - dl[i] = l * g; - dr[i] = rr * g; - } - rendererBusReadIndex.store(r + (uint64_t) pull, std::memory_order_release); - rendererBusConsumedFrames.fetch_add((uint64_t) pull, std::memory_order_relaxed); - return pull; + return rendererBus.pull(dest.getWritePointer(0), dest.getWritePointer(1), numSamples); } AudioEngine::RendererBusMetrics AudioEngine::getRendererBusMetrics() const { + const auto bm = rendererBus.metrics(); RendererBusMetrics m; - m.pushedFrames = rendererBusPushedFrames.load(std::memory_order_relaxed); - m.consumedFrames = rendererBusConsumedFrames.load(std::memory_order_relaxed); - m.underflowCount = rendererBusUnderflowCount.load(std::memory_order_relaxed); - m.overflowCount = rendererBusOverflowCount.load(std::memory_order_relaxed); - const uint64_t w = rendererBusWriteIndex.load(std::memory_order_acquire); - const uint64_t r = rendererBusReadIndex.load(std::memory_order_acquire); - m.fillFrames = (int) juce::jmin(w - r, (uint64_t) kRendererBusFrames); - m.capacityFrames = kRendererBusFrames; - m.enabled = rendererBusEnabled.load(std::memory_order_relaxed); + m.pushedFrames = bm.pushedFrames; + m.consumedFrames = bm.consumedFrames; + m.underflowCount = bm.underflowCount; + m.overflowCount = bm.overflowCount; + m.fillFrames = bm.fillFrames; + m.capacityFrames = bm.capacityFrames; + m.enabled = bm.enabled; return m; } diff --git a/src/audio/AudioEngine.h b/src/audio/AudioEngine.h index a384ae5..a2db77b 100644 --- a/src/audio/AudioEngine.h +++ b/src/audio/AudioEngine.h @@ -1,5 +1,14 @@ #pragma once #include "SourceChain.h" +#include "GainSanitize.h" +#include "engine/PackedStereoRing.h" +#include "engine/EngineState.h" +#include "engine/RendererBus.h" +#include "engine/StreamSink.h" +#include "engine/BackingPlayer.h" +#include "engine/DeviceSetup.h" +#include "engine/SourcePool.h" +#include "engine/ExtraInputs.h" #include "BackingLeveler.h" #include "signalsmith-stretch.h" #include @@ -62,39 +71,12 @@ public: juce::StringArray inputDevices; juce::StringArray outputDevices; }; - struct DeviceOptions - { - juce::String type; // legacy alias = inputType - juce::String inputType; - juce::String outputType; - juce::String input; - juce::String output; - juce::StringArray inputChannels; - juce::StringArray outputChannels; - juce::Array sampleRates; // intersection when dual-type - juce::Array bufferSizes; - bool compatible = true; // false when types share no usable sample rate - juce::String error; - }; - - struct DeviceConfig - { - juce::String inputType; - juce::String inputDevice; - juce::String outputType; - juce::String outputDevice; - double sampleRate = 48000.0; - int bufferSize = 256; - }; - struct DeviceConfigResult - { - bool ok = false; - juce::String error; - double sampleRate = 0.0; - int inputBlockSize = 0; - int outputBlockSize = 0; - bool duplex = true; - }; + // Device-config shapes moved to engine/DeviceSetup.h (TLC phase 4); + // aliased so the AudioEngine::DeviceOptions etc. spelling NodeAddon uses + // is unchanged. + using DeviceOptions = slopsmith::DeviceOptions; + using DeviceConfig = slopsmith::DeviceConfig; + using DeviceConfigResult = slopsmith::DeviceConfigResult; struct DeviceMetrics { @@ -115,7 +97,7 @@ public: // with an ALSA primary), minus the device already open as the primary (that's // "Main") and minus monitor/loopback pseudo-inputs. Keeps the per-panel device // picker to a compatible, sensible set instead of every capture node. - struct BindableInput { juce::String typeName; juce::String name; }; + using BindableInput = slopsmith::ExtraInputs::Bindable; std::vector getBindableInputDevices(); juce::Array getSampleRates(); @@ -154,7 +136,10 @@ public: // Gain controls. Input + chain-output gain are per-source (sources[0]); // output gain is the post-mix master and stays engine-global. void setInputGain(float gain) { source0().setInputGain(gain); } - void setOutputGain(float gain) { outputGain.store(gain); } + // Sanitized (see GainSanitize.h): a NaN/Inf master gain from JS would + // multiply the whole device output to NaN downstream of the per-source + // scrub — clamp at the store so every caller is covered. + void setOutputGain(float gain) { outputGain.store(slopsmith::sanitizeMasterGain(gain)); } float getInputGain() const { return source0().getInputGain(); } float getOutputGain() const { return outputGain.load(); } @@ -180,6 +165,11 @@ public: // so the brief empty-chain window doesn't silence the player's guitar. void setMonitorMuteSuppressed(bool suppressed) { source0().setMonitorMuteSuppressed(suppressed); } bool isMonitorMuteSuppressed() const { return source0().isMonitorMuteSuppressed(); } + // Refcounted force-mute overrides (see SourceChain's arbiter comment). + void acquireMonitorMuteHold() { source0().acquireMonitorMuteHold(); } + void releaseMonitorMuteHold() { source0().releaseMonitorMuteHold(); } + int getMonitorMuteHoldCount() const { return source0().getMonitorMuteHoldCount(); } + int getMonitorMuteSuppressCount() const { return source0().getMonitorMuteSuppressCount(); } // Full monitor kill — silences the guitar bus entirely (dry + processed), // for monitoring through an external rig. Unlike the per-source mute/gain @@ -191,8 +181,7 @@ public: // off the control thread is race-free. Default off; see SourceChain. void setMonitorKill(bool kill) { - for (auto& s : sources) - if (s) s->setMonitorKill(kill); + pool.forEach([kill](SourceChain& s) { s.setMonitorKill(kill); }); } bool isMonitorKilled() const { return source0().isMonitorKilled(); } @@ -215,17 +204,26 @@ public: // renderer exposes a per-preset toggle. void setTonePolishEnabled(bool enabled) { source0().setTonePolishEnabled(enabled); } - // Backing track - void setBackingVolume(float vol) { backingVolume.store(vol); } - bool loadBackingTrack(const juce::File& file); - void setBackingPosition(double seconds); - void startBacking(); - void stopBacking(); - void setBackingSpeed(double speed); - // Non-blocking reads — do not acquire backingLock and never block the audio callback - bool isBackingPlaying() const { return backingPlaying.load(); } - double getBackingPosition() const { return cachedBackingPosition.load(); } - double getBackingDuration() const { return cachedBackingDuration.load(); } + // Backing track — transport moved to engine/BackingPlayer (TLC phase 3); + // the volume fader + level meter stay engine-side (mix policy). + void setBackingVolume(float vol) { backingVolume.store(slopsmith::sanitizeMasterGain(vol)); } + bool loadBackingTrack(const juce::File& file) + { + currentBackingLevel.store(0.0f); + return backing.load(file); + } + void setBackingPosition(double seconds) { backing.setPosition(seconds); } + void startBacking() { backing.start(); } + void stopBacking() + { + backing.stop(); + currentBackingLevel.store(0.0f); + } + void setBackingSpeed(double speed) { backing.setSpeed(speed); } + // Non-blocking reads — never acquire the backing lock / block the audio callback + bool isBackingPlaying() const { return backing.isPlaying(); } + double getBackingPosition() const { return backing.getPosition(); } + double getBackingDuration() const { return backing.getDuration(); } // Metering (read from any thread — atomic). Input level/peak are per-source // (sources[0]); output level/peak are the post-mix master, engine-global. @@ -245,18 +243,13 @@ public: // setStreamOutputDevice returns "" on success or an error string. juce::String setStreamOutputDevice(const juce::String& typeName, const juce::String& deviceName); void clearStreamOutput(); - bool isStreamOutputActive() const { return streamSink.active.load(std::memory_order_acquire); } - juce::String getStreamOutputDeviceName() const { return streamSink.desiredDeviceName; } + bool isStreamOutputActive() const { return streamSink.isActive(); } + juce::String getStreamOutputDeviceName() const { return streamSink.getDesiredDeviceName(); } // Bus content: include the backing/game, include the guitar monitor mix, and a // linear output gain. All atomic — safe to set live. Gain is sanitised // (finite, clamped 0..8) so a NaN/Inf from JS can never reach the stream ring. - void setStreamBus(bool includeBacking, bool includeGuitar, float gain) - { - streamBusIncludeBacking.store(includeBacking, std::memory_order_relaxed); - streamBusIncludeGuitar.store(includeGuitar, std::memory_order_relaxed); - streamBusGain.store(sanitizeStreamGain(gain), std::memory_order_relaxed); - } - void setStreamBusGain(float gain) { streamBusGain.store(sanitizeStreamGain(gain), std::memory_order_relaxed); } + void setStreamBus(bool includeBacking, bool includeGuitar, float gain) { streamSink.setBus(includeBacking, includeGuitar, gain); } + void setStreamBusGain(float gain) { streamSink.setBusGain(gain); } // ── Renderer-audio bus (Phase 2: WebAudio master → engine output) ───────── // The renderer pushes its WebAudio master mix here (via IPC) so song/stem @@ -264,20 +257,7 @@ public: // mixer path is silenced. SPSC: producer is the main-process IPC thread, // consumer is whichever output callback is live (duplex or split). Default // off → zero behaviour change. - void setRendererBus(bool enabled, float gain) - { - rendererBusGain.store(sanitizeStreamGain(gain), std::memory_order_relaxed); - const bool was = rendererBusEnabled.exchange(enabled, std::memory_order_acq_rel); - if (was && !enabled) - { - // Drop buffered audio on disable so a later re-enable starts fresh - // instead of playing a stale tail. Consumer tolerates the jump. - rendererBusReadIndex.store( - rendererBusWriteIndex.load(std::memory_order_acquire), - std::memory_order_release); - rendererBusPrimed.store(false, std::memory_order_relaxed); - } - } + void setRendererBus(bool enabled, float gain) { rendererBus.setEnabled(enabled, gain); } // Interleaved stereo frames at `sourceRate`; linear-resampled to the device // rate on the producer thread (fractional position + previous frame carried // across calls). Returns false when the bus is disabled or the engine is @@ -291,15 +271,34 @@ public: }; RendererBusMetrics getRendererBusMetrics() const; - float getStreamSinkLevel() const { return streamSinkLevel.load(std::memory_order_relaxed); } - uint64_t getStreamUnderflowCount() const { return streamSink.underflowCount.load(std::memory_order_relaxed); } + float getStreamSinkLevel() const { return streamSink.getLevel(); } + uint64_t getStreamUnderflowCount() const { return streamSink.getUnderflowCount(); } // Producer overflow (drop-oldest): the consumer fell a full ring behind and // frames were skipped. Exposed alongside underflow for stream drift diagnosis. - uint64_t getStreamOverflowCount() const { return streamSink.overflowCount.load(std::memory_order_relaxed); } + uint64_t getStreamOverflowCount() const { return streamSink.getOverflowCount(); } // Latency double getLatencyMs() const; + // One owner for every latency term (TLC deep-read §5) — the previous + // three unreconciled truths were getLatencyMs' static half-capacity ring + // guess, the verifier's input-latency-delta-only offset, and the renderer + // bus adding prime+fill+resample that no figure surfaced. All ms. + struct LatencyBreakdown + { + double sampleRate = 0.0; + bool duplex = true; + double deviceBufferMs = 0.0; // input buffer (+ output buffer when split) + double inputLatencyMs = 0.0; // driver-reported capture latency + double outputLatencyMs = 0.0; // driver-reported playback latency + double splitRingMs = 0.0; // MEASURED primary-ring residency (0 in duplex) + double monitorTotalMs = 0.0; // guitar in→out: buffers + in/out + splitRing + // Renderer-bus song-audio delay: measured bus fill (includes the + // ~10.7 ms prime cushion once flowing). 0 when the bus is off. + double rendererBusMs = 0.0; + }; + LatencyBreakdown getLatencyBreakdown() const; + // Raw input frame snapshot for renderer-side polyphonic chord scoring in // notedetect. Backed by sources[0]'s pre-gate input ring; the rings (and the // power-of-two capacity constants) now live on SourceChain. Default snapshot @@ -375,10 +374,10 @@ public: private: // sources[0] is the legacy default input chain; always present + active. - SourceChain& source0() { return *sources[0]; } - const SourceChain& source0() const { return *sources[0]; } + SourceChain& source0() { return pool.chain0(); } + const SourceChain& source0() const { return pool.chain0(); } // Input-device callback. In duplex it writes outputData directly; in split - // it pushes processed stereo into outputPendingRing for OutputCallback. + // it pushes processed stereo into outputRing for OutputCallback. void audioDeviceIOCallbackWithContext(const float* const* inputData, int numInputChannels, float* const* outputData, @@ -387,18 +386,8 @@ private: const juce::AudioIODeviceCallbackContext& context) override; void audioDeviceAboutToStart(juce::AudioIODevice* device) override; void audioDeviceStopped() override; - void stopBackingNoLock(); // caller holds backingLock - // Renders one block of the backing track into backingBuffer (1x bypass or - // phase-vocoder stretch), advances backingHeardPositionSec / - // cachedBackingPosition, and clears backingPlaying at EOF. Returns the - // number of output frames written (== jmin(numSamples, backingBuffer cap)). - // Shared by the duplex and split output callbacks so the two paths can't - // drift. Precondition: caller holds backingLock and has verified - // backingTransport && backingPlaying. - int renderBackingBlockLocked(int numSamples); - - // Split-mode only: drains outputPendingRing, mixes backing, writes to device. + // Split-mode only: drains outputRing, mixes backing, writes to device. void audioOutputCallback(const float* const* inputData, int numInputChannels, float* const* outputData, @@ -427,11 +416,8 @@ private: }; OutputCallback outputCallback{ *this }; - juce::String applyDuplexSetup(const juce::String& inputName, - const juce::String& outputName, - double sampleRate, - int bufferSize); - DeviceConfigResult applySplitSetup(const DeviceConfig& config); + // Probe/apply/teardown moved to engine/DeviceSetup (TLC phase 4); + // setAudioDevices stays here as the orchestrator. void teardownSplitMode(); // Duplex mode: inputDeviceManager owns both directions, outputDeviceManager idle. @@ -439,59 +425,31 @@ private: // with an SPSC ring between them. juce::AudioDeviceManager inputDeviceManager; juce::AudioDeviceManager outputDeviceManager; - std::atomic duplexMode{true}; - // Per-input capture+detect+monitor chains. A FIXED pool, all constructed up - // front, so adding/removing a source never reassigns a pointer the audio - // thread is reading — addSource/removeSource only flip an atomic `active` - // flag (and prepare/release the chain). sources[0] is the legacy default, - // active from construction and bound to the primary input device. The audio - // callback fans device channels out to each active source and fans their - // monitor signals into the output mix. SourceChain reads the engine's - // audioRunning / currentSampleRate atomics through references bound at - // construction. - static constexpr int kMaxSources = 8; - // Max ADDITIONAL input devices (beyond the primary). Declared here — ahead of the - // members that size arrays by it (e.g. callbacksInFlight) — though the extra-input - // slot registry that uses it lives further below. - static constexpr int kMaxExtraInputDevices = 3; - std::array, kMaxSources> sources; - // Serialises addSource/removeSource (control threads only — never the audio - // thread, which just reads each slot's atomic `active`). - std::mutex sourcesMutex; - // Audio-thread scratch for the multi-source mix: each active source renders - // its 2-channel monitor here in turn, then it is summed into the output. - // Pre-sized in audioDeviceAboutToStart so the hot loop never allocates. + // Shared run-state atomics (TLC phase 1) — the members below are + // reference aliases under their historical names so call sites are + // untouched; extracted units take `state` (EngineState&) directly. + slopsmith::EngineState state; + std::atomic& duplexMode = state.duplexMode; + // Probe/apply/teardown component (TLC phase 4). Holds references only. + slopsmith::DeviceSetup deviceSetup{ inputDeviceManager, outputDeviceManager, state }; + + // Per-input capture+detect+monitor chains + the add/remove/reclaim + // lifecycle + per-deviceKey quiescence handshake — moved to + // engine/SourcePool (TLC phase 5). Constants mirrored for the members + // that size arrays by them (extraInputs, and NodeAddon range checks). + static constexpr int kMaxSources = slopsmith::SourcePool::kMaxSources; + static constexpr int kMaxExtraInputDevices = slopsmith::SourcePool::kMaxExtraInputDevices; + slopsmith::SourcePool pool{ state }; + // Audio-thread scratch for the multi-source mix on the PRIMARY callback: + // each active source renders its 2-channel monitor here in turn, then it + // is summed into the output. Pre-sized in audioDeviceAboutToStart so the + // hot loop never allocates. (Extra devices carry their own scratch.) juce::AudioBuffer sourceMonitorScratch; - // Count of device callback bodies currently executing, PER deviceKey (index 0 = - // primary input, 1..kMaxExtraInputDevices = each extra-input slot). Each device - // callback increments its own key on entry and decrements at its real exit. - // removeSource() flips a source inactive (future callbacks snapshot active once - // and skip it), then waits to observe THIS SOURCE's deviceKey count == 0 — at - // that instant no callback that could touch this source is inside processBlock, - // so it is safe to release. Keying per-deviceKey (not a single global counter) is - // essential: with the primary + extra inputs on independent clocks they are - // rarely ALL idle at once, so a global check would strand removals during steady - // multi-device playback. A wedged callback past the bounded wait DEFERS the - // release via pendingRelease[], reclaimed later when that key's body is quiescent. - std::array, kMaxExtraInputDevices + 1> callbacksInFlight{}; - // Sources whose release was deferred (handshake timed out). Reclaimed under - // sourcesMutex by reclaimPendingReleases() at the next add/removeSource and on - // device stop, once it is safe (audio stopped or no callback in flight). - std::array pendingRelease{}; - // Release any deferred sources that are now safe to reclaim. Caller holds - // sourcesMutex (or is the device-stop path, where the callback is gone). - void reclaimPendingReleases(); - - juce::AudioFormatManager formatManager; // Master output (post-mix) — engine-global, not per-source. std::atomic outputGain{1.0f}; std::atomic backingVolume{0.8f}; - // Per-song loudness normalizer for the backing track (applied in - // renderBackingBlockLocked, pre-fader). Owned + driven by the audio thread. - BackingLeveler backingLeveler; - double backingLevelerSr = 0.0; std::atomic currentOutputLevel{0.0f}; // Per-block RMS of the backing-track mix bus, written by the audio thread // and read on the main/JS thread via getBackingLevel(). Computed after the @@ -500,127 +458,31 @@ private: std::atomic currentBackingLevel{0.0f}; std::atomic outputPeak{0.0f}; - // Backing track - // Read-ahead worker that fills the transport's buffer off the audio thread - // (see loadBackingTrack). Declared BEFORE backingTransport so it is destroyed - // AFTER it — the transport's BufferingAudioSource holds a pointer to this - // thread and must be torn down before the thread goes away. - juce::TimeSliceThread backingReadThread { "BackingReadAhead" }; - std::unique_ptr backingSource; - std::unique_ptr backingTransport; - signalsmith::stretch::SignalsmithStretch backingStretch; - juce::AudioBuffer backingInputBuffer; // pulled from transport at device rate - juce::AudioBuffer backingBuffer; // stretch output, mixed into device buffer - std::atomic backingStretchLatencySamples{0}; - std::atomic backingPlaying{false}; - std::atomic cachedBackingPosition{0.0}; - std::atomic cachedBackingDuration{0.0}; - // Heard playhead: accumulates the source frames consumed each block, then - // clamped to backingTransport->getCurrentPosition() so a short read at EOF - // can't push it past the real source point. cachedBackingPosition is this - // value minus the stretcher output latency (zero on the 1x bypass path). - std::atomic backingHeardPositionSec{0.0}; - // Active playback rate. Mutated ONLY by the audio thread (in - // renderBackingBlockLocked), coupled with the stretcher reset, so a block - // is never processed at a new rate with stale stretch state. - std::atomic backingSpeed{1.0}; - // Lock-free speed hand-off: setBackingSpeed (control thread) publishes the - // requested rate here and raises backingSpeedChangePending; the audio - // thread adopts it on the next block. Avoids the control thread blocking on - // backingLock and starving the RT tryLock (which would drop a backing block - // mid-slider-drag). - std::atomic backingPendingSpeed{1.0}; - std::atomic backingSpeedChangePending{false}; - juce::CriticalSection backingLock; + // Backing track — transport/stretch/leveler moved to engine/BackingPlayer + // (TLC phase 3). Declared after `state` (bound by reference). + slopsmith::BackingPlayer backing{state}; - // Toggled from startAudio()/stopAudio() (main / device-management - // threads) and read from isAudioRunning() on the JS thread via the - // audio-bridge dispatch loop. Plain bool would be a data race; - // relaxed-atomic is well-defined and compiles to a plain MOV. - std::atomic audioRunning{false}; - // Sample rate is written from the JUCE device callbacks (audio - // thread / device-management thread) and read from arbitrary - // callers including the JS thread via getCurrentSampleRate(), - // so a plain double would be a C++ data race. std::atomic - // is well-defined and lock-free on the platforms we ship; the - // hot reads use relaxed since the consumer just wants the latest - // observable value, not a synchronization point. - std::atomic currentSampleRate{48000.0}; - // Split mode allows different input vs output block sizes; the ring absorbs - // the asymmetry. DSP prepares against input; backing resampler against output. - std::atomic inputBlockSize{256}; - std::atomic outputBlockSize{256}; + // audioRunning keeps its historical DEVICE-STATE semantics (isAudioRunning + // compat pin); the intent half is state.userWantsAudio — see EngineState.h. + std::atomic& audioRunning = state.deviceRunning; + std::atomic& currentSampleRate = state.currentSampleRate; + std::atomic& inputBlockSize = state.inputBlockSize; + std::atomic& outputBlockSize = state.outputBlockSize; // The per-input lock-free SPSC rings (pre-gate getInputFrame ring + post-gate // getRawAudioFrame ring), the YIN/ML detectors, and the zero-output capture // scratch now live on SourceChain — one set per input source. See // SourceChain.h for the full lock-free / power-of-two / cold-start rationale. - // Split-mode SPSC ring (unused in duplex). Each slot packs one stereo frame - // (L+R floats) into a single 64-bit atomic so the consumer reads both - // channels in one indivisible load — without packing, the producer's two - // separate atomic stores could interleave with the consumer's two loads - // during a drop-oldest wrap, surfacing as L_new+R_old (or vice versa) - // sample tears. ~85 ms @ 48 kHz — absorbs clock drift over typical sessions. + // Split-mode SPSC ring (unused in duplex). Packed-LR single-atomic frames + // — see engine/PackedStereoRing.h for the tear/lock-free rationale (moved + // there in TLC phase 1). ~85 ms @ 48 kHz — absorbs clock drift over + // typical sessions. static constexpr int kOutputRingFrames = 4096; - std::array, kOutputRingFrames> outputPendingRing{}; - static_assert((kOutputRingFrames & (kOutputRingFrames - 1)) == 0, - "kOutputRingFrames must be a power of two for mask wraparound"); - // RT-thread reads + writes touch these slots, so a lock-based fallback - // would risk priority inversion + audible dropouts. On the platforms we - // ship (x86_64 + arm64 across Linux/macOS/Windows) atomic is - // always lock-free; this assert turns a regression into a build error - // instead of a silent latency degradation if a future platform port - // breaks the assumption. - static_assert(std::atomic::is_always_lock_free, - "outputPendingRing requires lock-free atomic for RT safety"); - static_assert(sizeof(float) == 4, - "outputPendingRing pack/unpack assumes 32-bit float"); + slopsmith::PackedStereoRing outputRing; - // Pack/unpack helpers — std::bit_cast (C++20) is constexpr + alias-safe. - static inline uint64_t packLR(float l, float r) noexcept - { - const uint32_t li = std::bit_cast(l); - const uint32_t ri = std::bit_cast(r); - return (static_cast(ri) << 32) | static_cast(li); - } - static inline void unpackLR(uint64_t v, float& l, float& r) noexcept - { - l = std::bit_cast(static_cast(v & 0xFFFFFFFFu)); - r = std::bit_cast(static_cast(v >> 32)); - } - - // ── Renderer-audio bus ring (see setRendererBus/pushRendererAudio) ─────── - // Same packed-LR SPSC design as outputPendingRing. Sized generously - // (~1.5 s @ 48 kHz — vs outputPendingRing's 85 ms) because the producer is - // an IPC thread with scheduling jitter, not another audio callback; the - // consumer trims steady-state fill via the drift clamp in the mix step. - static constexpr int kRendererBusFrames = 65536; - static_assert((kRendererBusFrames & (kRendererBusFrames - 1)) == 0, - "kRendererBusFrames must be a power of two for mask wraparound"); - // Prefill gate: consume nothing until the producer has built this cushion - // (~10.7 ms @ 48 kHz); re-armed after every underflow so stall recovery is - // one clean gap. Fill clamp: fill beyond this (~85 ms) means a renderer - // stall dumped a backlog — trim to the prime target, don't play the tail. - static constexpr int kRendererBusPrimeFrames = 512; - static constexpr int kRendererBusMaxFillFrames = 4096; - std::array, kRendererBusFrames> rendererBusRing{}; - std::atomic rendererBusWriteIndex{0}; - std::atomic rendererBusReadIndex{0}; - std::atomic rendererBusPushedFrames{0}; - std::atomic rendererBusConsumedFrames{0}; - std::atomic rendererBusUnderflowCount{0}; - std::atomic rendererBusOverflowCount{0}; - std::atomic rendererBusEnabled{false}; - std::atomic rendererBusGain{1.0f}; - // Consumer-side prefill-gate state. Only the live output callback touches - // it, but duplex/split hand-offs cross threads — atomic keeps that safe. - std::atomic rendererBusPrimed{false}; - // Producer-thread-only linear-resampler state (fractional read position - // into the incoming chunk + the previous chunk's last frame for - // interpolation continuity across pushes). - double rendererBusSrcPos = 0.0; - float rendererBusPrevL = 0.0f, rendererBusPrevR = 0.0f; + // ── Renderer-audio bus (see engine/RendererBus.h — moved in TLC phase 2) + slopsmith::RendererBus rendererBus; // Shared consumer step for the duplex and split output paths: drain one // block from the renderer-bus ring into `dest` (stereo, bus gain applied, // dest cleared first). Returns numSamples on success, 0 when gated @@ -633,8 +495,6 @@ private: // in about-to-start next to the stream scratches (same no-realloc rule). juce::AudioBuffer rendererBusPullScratch; - std::atomic outputRingWriteIndex{0}; - std::atomic outputRingReadIndex{0}; std::atomic outputUnderflowCount{0}; std::atomic inputOverflowCount{0}; @@ -650,170 +510,24 @@ private: // leave a live registration behind after stopAudio()'s single remove. bool inputCallbackRegistered = false; - // ── Phase 2: additional input devices ──────────────────────────────────── - // Each ADDITIONAL physical input device (a 2nd/3rd USB interface, e.g. two - // separate cables) gets its own AudioDeviceManager + callback running on its - // OWN hardware clock, packing its sources' mixed monitor into its own SPSC - // ring. audioOutputCallback drains+sums every active ring (drop-oldest wrap - // absorbs each device's drift independently — no cross-device resampling, the - // failure mode that corrupts a software combine). deviceKey 0 = the primary - // inputDeviceManager above; deviceKeys 1..kMaxExtraInputDevices map to - // extraInputs[deviceKey-1]. When any extra device is active the engine runs - // split (the primary also uses its ring) so the output sum is uniform. - // (kMaxExtraInputDevices is declared up top, near kMaxSources.) + // ── Additional input devices — moved to engine/ExtraInputs.{h,cpp} + // (TLC phase 5). The split output callback drains extraInputs.slots + // directly; declared after pool/state (bound by reference). + slopsmith::ExtraInputs extraInputs{ pool, state, inputDeviceManager }; + using InputDeviceSlot = slopsmith::ExtraInputs::InputDeviceSlot; - // Forwards a JUCE device callback to the engine, tagged with the slot index. - struct InputSlotCallback : juce::AudioIODeviceCallback - { - AudioEngine* engine = nullptr; - int slot = -1; // index into extraInputs (deviceKey - 1) - void audioDeviceIOCallbackWithContext(const float* const* inputData, int numInputChannels, - float* const* outputData, int numOutputChannels, - int numSamples, - const juce::AudioIODeviceCallbackContext&) override - { - juce::ignoreUnused(outputData, numOutputChannels); - if (engine) engine->extraInputCallback(slot, inputData, numInputChannels, numSamples); - } - void audioDeviceAboutToStart(juce::AudioIODevice* d) override { if (engine) engine->extraInputAboutToStart(slot, d); } - void audioDeviceStopped() override { if (engine) engine->extraInputStopped(slot); } - }; + // (mixSourcesForDevice moved to SourcePool::mixForDevice — TLC phase 5.) - struct InputDeviceSlot - { - juce::AudioDeviceManager manager; - InputSlotCallback callback; - std::array, kOutputRingFrames> ring{}; - std::atomic writeIndex{0}; - std::atomic readIndex{0}; - std::atomic overflowCount{0}; - std::atomic active{false}; // a device is bound + running - std::atomic sampleRate{48000.0}; - std::atomic blockSize{256}; - // (extra input latency − primary input latency) in seconds — applied to - // this device's sources' verifiers so their capture aligns with the - // primary-corrected playhead. Computed when the device starts. - std::atomic latencyDeltaSec{0.0}; - // Audio-thread scratch — one set per slot since each slot's callback runs - // on its own thread (can't share the primary's sourceMonitorScratch). - juce::AudioBuffer fanScratch; // the 2ch mix target - juce::AudioBuffer monitorScratch; // per-source render in the N>1 path - int deviceKey = 0; // deviceKey this slot serves (slot+1) - // The device the user WANTS bound here — persistent INTENT, distinct from - // the transient `active` (currently open). Set by bindInputDevice, cleared - // only by a user unbind. stopAudio()/reconfigure close the device but keep - // this so startAudio() re-opens it; this is what survives a device change. - // Mutated + read on the control thread only. - juce::String desiredDeviceName; - // Whether the NEXT extraInputStopped() for this slot is a PERMANENT unbind - // (deactivate its sources) vs a transient close (keep them to resume). An - // atomic the control thread sets and the device thread reads, so the - // permanent-vs-transient decision never races on the juce::String above. - std::atomic permanentUnbind { false }; - }; - std::array extraInputs; - - // Per-slot callback hooks (audio + device-management threads). - void extraInputCallback(int slot, const float* const* inputData, int numInputChannels, int numSamples); - void extraInputAboutToStart(int slot, juce::AudioIODevice* device); - void extraInputStopped(int slot); - // Close an extra device but KEEP its desiredDeviceName (transient close for - // stop/reconfigure); reopenDesiredExtraInputs() restores them after a (re)start. - bool closeExtraInputDevice(int slot); - void reopenDesiredExtraInputs(); - - // Shared fan-out used by both the primary and each extra device's callback: - // mix every active source bound to `deviceKey` into `mixBuf` (using the - // caller-owned `monitorScratch` for the N>1 render so concurrent device - // threads never share scratch). Returns the active source count for that key. - int mixSourcesForDevice(int deviceKey, const float* const* inputData, int numInputChannels, - juce::AudioBuffer& mixBuf, juce::AudioBuffer& monitorScratch, - int effectiveOutputChannels, int numSamples); - // Pack a stereo block into a packed-uint64 SPSC ring (producer side). - void packStereoIntoRing(const juce::AudioBuffer& buf, int numSamples, - std::array, kOutputRingFrames>& ring, - std::atomic& writeIndex); - - // ── Streamer mix output sink (PR1) ─────────────────────────────────────── - // A second OUTPUT AudioDeviceManager on its OWN clock that drains a dedicated - // SPSC ring fed by the main output path's composed stream submix. This mirrors - // the InputDeviceSlot pattern INVERTED to the output side: the PRODUCER is the - // primary/output callback (composeAndPushStreamMix), the CONSUMER is this extra - // output device's callback (streamSinkCallback). Default off → no behaviour change. - struct StreamSinkCallback : juce::AudioIODeviceCallback - { - AudioEngine* engine = nullptr; - void audioDeviceIOCallbackWithContext(const float* const* inputData, int numInputChannels, - float* const* outputData, int numOutputChannels, - int numSamples, - const juce::AudioIODeviceCallbackContext&) override - { - juce::ignoreUnused(inputData, numInputChannels); - if (engine) engine->streamSinkCallback(outputData, numOutputChannels, numSamples); - } - void audioDeviceAboutToStart(juce::AudioIODevice* d) override { if (engine) engine->streamSinkAboutToStart(d); } - void audioDeviceStopped() override { if (engine) engine->streamSinkStopped(); } - }; - struct StreamSink - { - StreamSinkCallback callback; - std::array, kOutputRingFrames> ring{}; - std::atomic writeIndex{0}; - std::atomic readIndex{0}; - std::atomic underflowCount{0}; - std::atomic overflowCount{0}; - std::atomic active{false}; - std::atomic sampleRate{48000.0}; - std::atomic blockSize{256}; - std::vector pullScratchL, pullScratchR; // sized in streamSinkAboutToStart - bool callbackRegistered = false; - bool initialised = false; - // Declared LAST so it DESTRUCTS FIRST (members tear down in reverse - // declaration order): the manager's dtor closes the device and detaches - // `callback` while `callback`/`ring` are still alive — no use-after-free - // even if an explicit teardown path is ever missed. stopAudio() / - // closeStreamSinkDevice() also tear it down explicitly before this. - juce::AudioDeviceManager manager; - // Persistent INTENT (control thread only): the device the user chose. - // Survives a stop/restart so reopenDesiredStreamSink() can re-open it. - juce::String desiredTypeName; - juce::String desiredDeviceName; - }; - StreamSink streamSink; - std::atomic streamBusIncludeBacking{true}; - std::atomic streamBusIncludeGuitar{true}; - std::atomic streamBusGain{1.0f}; - std::atomic streamSinkLevel{0.0f}; - // Producer-side scratch (written by the primary/output callback): the guitar - // monitor-mix snapshot (pre-backing) and the composed stream submix. Sized in + // ── Streamer mix output sink — moved to engine/StreamSink.{h,cpp} (TLC + // phase 2). Declared after `state` (bound by reference). + slopsmith::StreamSink streamSink{state}; + // Producer-side guitar monitor-mix snapshot (pre-backing), written by the + // primary/output callback and handed to streamSink.publish(). Sized in // audioDeviceAboutToStart / audioOutputAboutToStart alongside the other scratch. juce::AudioBuffer streamGuitarScratch; - juce::AudioBuffer streamMixScratch; // Clamp a requested stream gain to a finite, sane range so a NaN/Inf (or a // wild value) from the JS bridge can never be packed into the stream ring. - static float sanitizeStreamGain(float g) { return std::isfinite(g) ? juce::jlimit(0.0f, 8.0f, g) : 0.0f; } - - void streamSinkCallback(float* const* outputData, int numOutputChannels, int numSamples); - void streamSinkAboutToStart(juce::AudioIODevice* device); - void streamSinkStopped(); - void reopenDesiredStreamSink(); - // Detach + close the stream-sink device but KEEP desiredTypeName/Name, so a - // stopAudio()/startAudio() cycle re-opens it (intent survives, like extra - // inputs). Also the single teardown used by the dtor and clearStreamOutput(). - void closeStreamSinkDevice(); - // Compose the stream submix from the captured guitar mix + the just-rendered - // backing block + the just-pulled renderer-bus block and pack it into the - // stream ring. Called from both output callbacks after backing render. - // `backingBuf` / `rendererBuf` may be null (not playing / bus gated). - // The renderer bus rides the includeBacking flag: it IS song audio, just - // fed from the renderer instead of the native transport (bus gain already - // applied by pullRendererBus). - void composeAndPushStreamMix(const juce::AudioBuffer& guitarMix, - const juce::AudioBuffer* backingBuf, - int backingFrames, float backingVol, - const juce::AudioBuffer* rendererBuf, - int rendererFrames, int numSamples); - + static float sanitizeStreamGain(float g) { return slopsmith::sanitizeStreamGain(g); } JUCE_DECLARE_NON_COPYABLE_WITH_LEAK_DETECTOR(AudioEngine) }; diff --git a/src/audio/CMakeLists.txt b/src/audio/CMakeLists.txt index b4870d6..41489d9 100644 --- a/src/audio/CMakeLists.txt +++ b/src/audio/CMakeLists.txt @@ -6,6 +6,19 @@ set(AUDIO_SOURCES NoiseGate.cpp TonePolish.cpp AudioEngine.cpp + engine/StreamSink.cpp + engine/BackingPlayer.cpp + engine/DeviceSetup.cpp + engine/SourcePool.cpp + engine/ExtraInputs.cpp + addon/AddonContext.cpp + addon/ChainOps.cpp + addon/EditorWindows.cpp + addon/DeviceBindings.cpp + addon/ControlBindings.cpp + addon/DetectionBindings.cpp + addon/ChainBindings.cpp + addon/BackingBindings.cpp SourceChain.cpp SignalChain.cpp VSTHost.cpp diff --git a/src/audio/GainSanitize.h b/src/audio/GainSanitize.h new file mode 100644 index 0000000..9f19310 --- /dev/null +++ b/src/audio/GainSanitize.h @@ -0,0 +1,36 @@ +#pragma once + +// Gain-argument containment (audio-engine TLC, deep-read §2). +// +// N-API's Number coercion lets NaN/Infinity from JS reach the engine's gain +// atomics raw — a NaN master gain multiplies the whole device output to NaN +// (buffer.applyGain) and poisons the peak meters, and nothing downstream +// scrubs it (the per-source NaN scrub runs before the master gain). Clamping +// at the engine setters is the single choke point that fixes every caller: +// audio:setGain, the source-indexed API, and the audio-effects executor. +// +// Bounds: 0..32 for input/chain/output/backing — matching the executor's +// JS-side clampGain so a legit high rig gain is never under-shot (compat pin, +// docs/audio-engine-tlc.md Phase 0.b). The stream/renderer-bus gains keep +// their tighter historical 0..8 (previously sanitizeStreamGain). +// +// JUCE-free on purpose, like AudioSanitize.h, so tests/engine_units can test +// it without a device. + +#include + +namespace slopsmith { + +// Non-finite → 0 (silence beats a poisoned mix); otherwise clamp to [0, max]. +inline float sanitizeGain(float g, float maxGain) noexcept +{ + if (!std::isfinite(g)) return 0.0f; + if (g < 0.0f) return 0.0f; + if (g > maxGain) return maxGain; + return g; +} + +inline float sanitizeMasterGain(float g) noexcept { return sanitizeGain(g, 32.0f); } +inline float sanitizeStreamGain(float g) noexcept { return sanitizeGain(g, 8.0f); } + +} // namespace slopsmith diff --git a/src/audio/NodeAddon.cpp b/src/audio/NodeAddon.cpp index 358e193..503b8e7 100644 --- a/src/audio/NodeAddon.cpp +++ b/src/audio/NodeAddon.cpp @@ -18,10 +18,6 @@ #include "AudioEngine.h" #include "VSTHost.h" -// Forward declaration — defined alongside loadVstSandboxAware further down. -// doShutdown (below) needs it to release any LoadVSTWorker / LoadPreset- -// Worker blocked on a pending async load before the message thread stops. -static void cancelAllPendingLoads(); #include "VSTTrace.h" #include "NAMProcessor.h" #include "IRLoader.h" @@ -30,276 +26,140 @@ static void cancelAllPendingLoads(); #include -// engine / vstHost — shared_ptr (not unique_ptr) so worker threads can take -// a stable snapshot that keeps the object alive for the duration of their -// work, even if the message thread reassigns the global mid-operation. This -// matters most for the async VST load: createPluginInstanceAsync's JUCE -// continuation must not have VSTHost / its formatManager torn out from -// under it mid-load. -// -// snapshotEngine() / snapshotVstHost() take the global under the matching -// mutex and return a private copy. The only code permitted to touch the -// bare `engine` / `vstHost` globals is the snapshot helpers below and the -// mutex-guarded writes in Init / doShutdown — the message thread mutates -// the globals there while worker threads and the napi handlers read them. -// -// Enforced rule: every *dereference* of engine / vstHost goes through a -// local snapshot. A napi handler takes that snapshot at the top, null- -// checks it, and uses only the local — either for the rest of its body, -// or (for the handlers that hand off to an AsyncWorker — LoadVST, -// LoadNAMModel, LoadIR, LoadPreset) purely as the availability guard -// before queuing, with the worker re-snapshotting on its own thread. -// Either way a concurrent doShutdown reset can never pull the object out -// from under an in-flight dereference. -static std::shared_ptr engine; -static std::mutex engineMutex; +#include "addon/AddonContext.h" +#include "addon/NapiHelpers.h" +#include "addon/ChainOps.h" +#include "addon/EditorWindows.h" +#include "addon/Bindings.h" -// Decode a state blob that may be in EITHER base64 flavour. JUCE's -// MemoryBlock::fromBase64Encoding only understands JUCE's own proprietary -// format (".") and returns false for standard RFC-4648 -// base64 — which is what the Python-side plugins (rig_builder et al.) emit -// for per-slot state. That silent false meant setState() was never called -// for those slots: IR stages lost their per-stage `gain` (the cab loudness -// makeup and amp trims never reached the engine). Try the JUCE format first -// (engine-native saves), then fall back to standard b64. -// -// `allowStandard` is only set for IR/NAM slots: their processors take a JSON -// state ({"irPath","gain"} / model path), which is exactly what the plugins -// emit. VST slots keep the JUCE-only decode — their plugin-emitted blobs are -// metadata wrappers, not real setStateInformation() chunks, and feeding those -// to a VST3 for the first time would be an unasked-for behaviour change. -static bool decodeStateBlob(const juce::String& s, juce::MemoryBlock& mb, - bool allowStandard) -{ - if (mb.fromBase64Encoding(s) && mb.getSize() > 0) - return true; - if (!allowStandard) - return false; - mb.reset(); - juce::MemoryOutputStream mo(mb, false); - return juce::Base64::convertFromBase64(mo, s) && mb.getSize() > 0; -} +using slopsmith::addon::closeAllPluginEditorWindows; +using slopsmith::addon::destroyAllPluginEditorWindowsOnMessageThread; +using slopsmith::addon::OpenPluginEditor; +using slopsmith::addon::ClosePluginEditor; +using slopsmith::addon::decodeStateBlob; +using slopsmith::addon::LoadVST; +using slopsmith::addon::LoadNAMModel; +using slopsmith::addon::LoadIR; +using slopsmith::addon::ReplaceIR; +using slopsmith::addon::LoadPreset; +using slopsmith::addon::AddSource; +using slopsmith::addon::BindInputDevice; +using slopsmith::addon::ClearChain; +using slopsmith::addon::ClearStreamOutput; +using slopsmith::addon::DetectNotes; +using slopsmith::addon::EnableFileLogging; +using slopsmith::addon::GetBackingDuration; +using slopsmith::addon::GetBackingLevel; +using slopsmith::addon::GetBackingPosition; +using slopsmith::addon::GetBufferSizes; +using slopsmith::addon::GetChainGeneration; +using slopsmith::addon::GetChainState; +using slopsmith::addon::GetCurrentDevice; +using slopsmith::addon::GetDeviceMetrics; +using slopsmith::addon::GetDeviceTypes; +using slopsmith::addon::GetLevels; +using slopsmith::addon::GetNoteVerdicts; +using slopsmith::addon::GetParameters; +using slopsmith::addon::GetPitchDetection; +using slopsmith::addon::GetRawAudioFrame; +using slopsmith::addon::GetRawPitchDetection; +using slopsmith::addon::GetRendererBusMetrics; +using slopsmith::addon::GetSampleRate; +using slopsmith::addon::GetSampleRates; +using slopsmith::addon::GetSourceLevels; +using slopsmith::addon::GetSourceNoteVerdicts; +using slopsmith::addon::GetSourcePitchDetection; +using slopsmith::addon::GetSourceRawAudioFrame; +using slopsmith::addon::GetSourceRawPitchDetection; +using slopsmith::addon::GetStreamOverflowCount; +using slopsmith::addon::GetStreamSinkLevel; +using slopsmith::addon::GetStreamUnderflowCount; +using slopsmith::addon::IsAudioRunning; +using slopsmith::addon::IsBackingPlaying; +using slopsmith::addon::IsMlNoteDetection; +using slopsmith::addon::IsMonitorMuted; +using slopsmith::addon::IsStreamOutputActive; +using slopsmith::addon::ListInputDevices; +using slopsmith::addon::ListSources; +using slopsmith::addon::LoadBackingTrack; +using slopsmith::addon::LoadNoteModel; +using slopsmith::addon::MoveProcessor; +using slopsmith::addon::ProbeDeviceOptions; +using slopsmith::addon::PushRendererAudio; +using slopsmith::addon::RemoveProcessor; +using slopsmith::addon::RemoveSource; +using slopsmith::addon::ResetPeaks; +using slopsmith::addon::SavePreset; +using slopsmith::addon::ScoreChord; +using slopsmith::addon::ScoreSourceChord; +using slopsmith::addon::SeekBacking; +using slopsmith::addon::SendMidiToSlot; +using slopsmith::addon::SetBackingSpeed; +using slopsmith::addon::SetBranch; +using slopsmith::addon::SetBranchSrc; +using slopsmith::addon::SetBypass; +using slopsmith::addon::SetChart; +using slopsmith::addon::SetDevice; +using slopsmith::addon::SetDeviceType; +using slopsmith::addon::SetGain; +using slopsmith::addon::SetInputChannel; +using slopsmith::addon::SetMonitorKill; +using slopsmith::addon::SetMonitorMute; +using slopsmith::addon::SetMonitorMuteSuppressed; +using slopsmith::addon::AcquireMonitorMuteHold; +using slopsmith::addon::ReleaseMonitorMuteHold; +using slopsmith::addon::GetMonitorMuteState; +using slopsmith::addon::GetLatencyBreakdown; +using slopsmith::addon::SetMultiBypass; +using slopsmith::addon::SetNoiseGate; +using slopsmith::addon::SetNoteDetectionEnabled; +using slopsmith::addon::SetOutputDeviceType; +using slopsmith::addon::SetPan; +using slopsmith::addon::SetParameter; +using slopsmith::addon::SetPostGain; +using slopsmith::addon::SetRendererBus; +using slopsmith::addon::SetSlotState; +using slopsmith::addon::SetSourceChart; +using slopsmith::addon::SetSourceInputChannel; +using slopsmith::addon::SetSourceMonitorMute; +using slopsmith::addon::SetSourceVerifierOffset; +using slopsmith::addon::SetStreamBus; +using slopsmith::addon::SetStreamBusGain; +using slopsmith::addon::SetStreamOutputDevice; +using slopsmith::addon::SetTonePolish; +using slopsmith::addon::StartAudio; +using slopsmith::addon::StartBacking; +using slopsmith::addon::StopAudio; +using slopsmith::addon::StopBacking; +using slopsmith::addon::UnbindInputDevice; +using slopsmith::addon::scoreChordCore; +using slopsmith::addon::setChartCore; -static std::shared_ptr snapshotEngine() -{ - std::lock_guard lock(engineMutex); - return engine; -} +// Lifetime/threading moved to addon/AddonContext (TLC phase 6); the usings +// keep the 100+ existing binding bodies unchanged. +using slopsmith::addon::snapshotEngine; +using slopsmith::addon::snapshotVstHost; +using slopsmith::addon::dispatchOnMessageThread; +using slopsmith::addon::registerPendingLoad; +using slopsmith::addon::unregisterPendingLoad; +using slopsmith::addon::cancelAllPendingLoads; +using slopsmith::addon::doShutdown; -// Validate a JS source-id argument and return the live source, or nullptr if it is -// missing / not a Number / not a FINITE INTEGER / out of range. The TS bridge already -// validates, but the addon must fail soft on its own: Int32Value() silently coerces -// NaN/Infinity into a valid index (NaN -> 0), which would let a malformed id hit a -// real source (e.g. the default source 0). getSource() does the final -// [0, kMaxSources) + active check; the 4096 guard keeps the cast well-defined. -static SourceChain* getValidatedSource(AudioEngine* eng, const Napi::CallbackInfo& info, size_t argIndex) -{ - if (eng == nullptr || argIndex >= info.Length() || ! info[argIndex].IsNumber()) - return nullptr; - const double raw = info[argIndex].As().DoubleValue(); - if (! std::isfinite(raw) || raw != std::floor(raw) || raw < 0.0 || raw > 4096.0) - return nullptr; - return eng->getSource((int) raw); -} -static std::shared_ptr vstHost; -static std::mutex vstHostMutex; -static std::shared_ptr snapshotVstHost() -{ - std::lock_guard lock(vstHostMutex); - return vstHost; -} -static double loadSafeSampleRate(const AudioEngine& eng) -{ - const double sr = eng.getCurrentSampleRate(); - return (std::isfinite(sr) && sr > 0.0) ? sr : 48000.0; -} - -static int loadSafeBlockSize(const AudioEngine& eng) -{ - const int bs = eng.getCurrentBlockSize(); - return bs > 0 ? bs : 256; -} - -static std::thread juceMessageThread; -static std::atomic juceRunning{false}; -static std::atomic alreadyShutDown{false}; - -// ── JUCE Message Thread ─────────────────────────────────────────────────────── -// JUCE requires a message thread for plugin loading, audio device management, etc. -// We pump it in a dedicated thread. - -static void startJuceMessageThread() -{ - if (juceRunning.load()) return; - juceRunning.store(true); - -#if JUCE_MAC - // On macOS, JUCE's MessageManager::runDispatchLoopUntil internally calls - // `-[NSApplication _nextEventMatchingEventMask:...]`, which AppKit asserts - // must run on the true main thread. Node.js already owns the main thread - // (running libuv's event loop), so we can't spawn a second NS event pump - // without hitting `nextEventMatchingMask should only be called from the - // Main Thread!` and aborting. - // - // Workaround: designate Node's current thread as JUCE's message thread and - // skip the dispatch loop. callAsync()'d callbacks will still queue; we - // drain them from the Node thread via a libuv timer created below. - juce::MessageManager::getInstance(); -#else - juceMessageThread = std::thread([]() { - juce::MessageManager::getInstance(); - while (juceRunning.load()) - { - juce::MessageManager::getInstance()->runDispatchLoopUntil(50); - } - juce::MessageManager::deleteInstance(); - }); -#endif -} - -static void stopJuceMessageThread() -{ - juceRunning.store(false); -#if !JUCE_MAC - if (juceMessageThread.joinable()) - juceMessageThread.join(); -#else - juce::MessageManager::deleteInstance(); -#endif -} - -// ── Helper: dispatch on JUCE message thread ─────────────────────────────────── - -template -static void dispatchOnMessageThread(Func&& func) -{ -#if JUCE_MAC - // No background message thread on macOS — execute inline on caller thread. - // Audio device / NAM / IR init is thread-safe for our use; VST/AU plugin - // instantiation (which genuinely requires a message thread on macOS) is - // the one capability we give up until a proper libuv-based pump lands. - func(); -#else - // Heap-allocate the WaitableEvent and capture by value so the queued - // callAsync closure can outlive this stack frame. Without this, a 15 s - // timeout (rare, but possible during shutdown when the message thread is - // busy) leaves the lambda running on freed `done` storage — a real UAF. - auto done = std::make_shared(); - juce::MessageManager::callAsync([func = std::forward(func), done]() mutable { - func(); - done->signal(); - }); - done->wait(15000); -#endif -} // Destroys every in-process plugin editor window. MUST be called on the message -// thread (editorWindows holds JUCE GUI objects). Defined far below, after the -// editorWindows map; forward-declared here so doShutdown — which already runs on -// the message thread — can tear editors down before engine.reset() frees the -// processors those editors point at (use-after-free; feedBack-desktop#56). -static void destroyAllPluginEditorWindowsOnMessageThread(); +// thread — lives in addon/EditorWindows now (TLC phase 7). // ── Lifecycle ───────────────────────────────────────────────────────────────── static Napi::Value Init(const Napi::CallbackInfo& info) { - auto env = info.Env(); - - // Reset the shutdown latch so a JS-level init→shutdown→init cycle (e.g. - // a test harness recreating the engine) actually runs shutdown again - // instead of treating it as already-done. - alreadyShutDown.store(false, std::memory_order_release); - - // Start JUCE message thread first (no-op on macOS — see startJuceMessageThread) - startJuceMessageThread(); - -#if !JUCE_MAC - // Small delay to ensure message thread is pumping - std::this_thread::sleep_for(std::chrono::milliseconds(200)); -#endif - - // Create engine on the JUCE message thread (or inline on macOS) - dispatchOnMessageThread([]() { - std::shared_ptr liveEngine; - { - std::lock_guard lock(engineMutex); - engine = std::make_shared(); - liveEngine = engine; - } - { - std::lock_guard lock(vstHostMutex); - vstHost = std::make_shared(); - } - - auto types = liveEngine->getDeviceTypes(); - fprintf(stderr, "[audio-native] Init complete. Device types: %d\n", types.size()); - for (int i = 0; i < types.size(); ++i) - fprintf(stderr, "[audio-native] %s: %d inputs, %d outputs\n", - types[i].name.toRawUTF8(), - types[i].inputDevices.size(), - types[i].outputDevices.size()); - }); - - return env.Undefined(); -} - -static void doShutdown() -{ - // The latch is flipped at the TOP rather than the bottom so a - // re-entrant call (e.g. env-cleanup-hook firing while a JS-level - // shutdown is mid-flight) bails immediately rather than racing on - // the same teardown sequence. Assumed serialisation invariants: - // - dispatchOnMessageThread is single-writer to engine/vstHost - // (both unique_ptrs touched only here or from Init); - // - stopJuceMessageThread is idempotent and safe to call when the - // thread was never started (defensive checks inside). - // If a future caller mutates engine/vstHost between this latch and - // the dispatch (or the dispatch's 15s wait times out), THIS call's - // body may not finish before returning — but the re-entrant - // cleanup-hook will then no-op via the latch and the dispatch - // queue itself unwinds whatever's pending. Net result: at-most- - // once execution of the gated body, even under teardown races. - bool expected = false; - if (!alreadyShutDown.compare_exchange_strong(expected, true)) return; - - // Release any LoadVSTWorker / LoadPresetWorker currently blocked on a - // pending async load. Without this they'd wait forever on the - // WaitableEvent — the createPluginInstanceAsync callback can't fire - // once the message thread is gone. Forward-declared above; the - // implementation lives near loadVstSandboxAware. - cancelAllPendingLoads(); - - if (juceRunning.load() || snapshotEngine() || snapshotVstHost()) - { - dispatchOnMessageThread([]() { - // Editors reference their slot's processor; engine.reset() below - // frees the whole chain, so destroy the editor windows first (#56). - // Already on the message thread here — call the inline variant - // directly (closeAllPluginEditorWindows() would reach the same code - // via its message-thread branch; this just skips the thread check). - destroyAllPluginEditorWindowsOnMessageThread(); - if (auto liveEngine = snapshotEngine()) - liveEngine->stopAudio(); - { - std::lock_guard lock(engineMutex); - engine.reset(); - } - { - std::lock_guard lock(vstHostMutex); - vstHost.reset(); - } - }); - } - - stopJuceMessageThread(); - - // Restore the previous top-level exception filter — the addon (and thus our - // unhandledFilter's code) may be unloaded, so it must not stay installed. - slopsmith::sandbox::uninstallVstCrashAttribution(); + // Engine/vstHost creation + message-thread start live on AddonContext; + // the UI teardown hook runs at shutdown BEFORE engine.reset() (#56). + slopsmith::addon::initialize([] { destroyAllPluginEditorWindowsOnMessageThread(); }); + return info.Env().Undefined(); } static Napi::Value Shutdown(const Napi::CallbackInfo& info) @@ -308,1570 +168,6 @@ static Napi::Value Shutdown(const Napi::CallbackInfo& info) return info.Env().Undefined(); } -// ── Device Enumeration ──────────────────────────────────────────────────────── - -static Napi::Value GetDeviceTypes(const Napi::CallbackInfo& info) -{ - auto env = info.Env(); - auto liveEngine = snapshotEngine(); - if (!liveEngine) return env.Null(); - - // Device types are already scanned during init — safe to read from any thread - auto types = liveEngine->getDeviceTypes(); - - auto result = Napi::Array::New(env, types.size()); - - for (int i = 0; i < types.size(); ++i) - { - auto obj = Napi::Object::New(env); - obj.Set("name", types[i].name.toStdString()); - - auto inputs = Napi::Array::New(env, types[i].inputDevices.size()); - for (int j = 0; j < types[i].inputDevices.size(); ++j) - inputs.Set((uint32_t)j, types[i].inputDevices[j].toStdString()); - obj.Set("inputs", inputs); - - auto outputs = Napi::Array::New(env, types[i].outputDevices.size()); - for (int j = 0; j < types[i].outputDevices.size(); ++j) - outputs.Set((uint32_t)j, types[i].outputDevices[j].toStdString()); - obj.Set("outputs", outputs); - - result.Set((uint32_t)i, obj); - } - - return result; -} - -static Napi::Value GetSampleRates(const Napi::CallbackInfo& info) -{ - auto env = info.Env(); - auto liveEngine = snapshotEngine(); - if (!liveEngine) return Napi::Array::New(env); - - auto rates = liveEngine->getSampleRates(); - auto result = Napi::Array::New(env, rates.size()); - for (int i = 0; i < rates.size(); ++i) - result.Set((uint32_t)i, rates[i]); - return result; -} - -static Napi::Value GetBufferSizes(const Napi::CallbackInfo& info) -{ - auto env = info.Env(); - auto liveEngine = snapshotEngine(); - if (!liveEngine) return Napi::Array::New(env); - - auto sizes = liveEngine->getBufferSizes(); - auto result = Napi::Array::New(env, sizes.size()); - for (int i = 0; i < sizes.size(); ++i) - result.Set((uint32_t)i, sizes[i]); - return result; -} - -static Napi::Value ProbeDeviceOptions(const Napi::CallbackInfo& info) -{ - auto env = info.Env(); - auto liveEngine = snapshotEngine(); - auto obj = Napi::Object::New(env); - // 3-arg legacy (type, input, output) or 4-arg dual (inputType, input, outputType, output). - auto arg0 = info.Length() > 0 && info[0].IsString() ? info[0].As().Utf8Value() : ""; - auto arg1 = info.Length() > 1 && info[1].IsString() ? info[1].As().Utf8Value() : ""; - auto arg2 = info.Length() > 2 && info[2].IsString() ? info[2].As().Utf8Value() : ""; - auto arg3 = info.Length() > 3 && info[3].IsString() ? info[3].As().Utf8Value() : ""; - - std::string inputType = arg0; - std::string inputName = arg1; - std::string outputType; - std::string outputName; - if (info.Length() >= 4) - { - outputType = arg2; - outputName = arg3; - } - else - { - outputType = arg0; - outputName = arg2; - } - - auto ratesArray = Napi::Array::New(env); - auto buffersArray = Napi::Array::New(env); - auto inputChannelsArray = Napi::Array::New(env); - auto outputChannelsArray = Napi::Array::New(env); - - obj.Set("type", inputType); - obj.Set("inputType", inputType); - obj.Set("outputType", outputType); - obj.Set("input", inputName); - obj.Set("output", outputName); - obj.Set("inputChannels", inputChannelsArray); - obj.Set("outputChannels", outputChannelsArray); - obj.Set("sampleRates", ratesArray); - obj.Set("bufferSizes", buffersArray); - obj.Set("compatible", true); - if (!liveEngine) - { - obj.Set("error", "Audio engine not initialized"); - obj.Set("compatible", false); - return obj; - } - - auto options = liveEngine->probeDeviceOptionsDual( - juce::String(inputType), juce::String(inputName), - juce::String(outputType), juce::String(outputName)); - obj.Set("type", options.inputType.toStdString()); // legacy alias - obj.Set("inputType", options.inputType.toStdString()); - obj.Set("outputType", options.outputType.toStdString()); - obj.Set("input", options.input.toStdString()); - obj.Set("output", options.output.toStdString()); - obj.Set("error", options.error.toStdString()); - obj.Set("compatible", options.compatible); - - inputChannelsArray = Napi::Array::New(env, options.inputChannels.size()); - for (int i = 0; i < options.inputChannels.size(); ++i) - inputChannelsArray.Set((uint32_t)i, options.inputChannels[i].toStdString()); - obj.Set("inputChannels", inputChannelsArray); - - outputChannelsArray = Napi::Array::New(env, options.outputChannels.size()); - for (int i = 0; i < options.outputChannels.size(); ++i) - outputChannelsArray.Set((uint32_t)i, options.outputChannels[i].toStdString()); - obj.Set("outputChannels", outputChannelsArray); - - ratesArray = Napi::Array::New(env, options.sampleRates.size()); - for (int i = 0; i < options.sampleRates.size(); ++i) - ratesArray.Set((uint32_t)i, options.sampleRates[i]); - obj.Set("sampleRates", ratesArray); - - buffersArray = Napi::Array::New(env, options.bufferSizes.size()); - for (int i = 0; i < options.bufferSizes.size(); ++i) - buffersArray.Set((uint32_t)i, options.bufferSizes[i]); - obj.Set("bufferSizes", buffersArray); - - return obj; -} - -static Napi::Value GetCurrentDevice(const Napi::CallbackInfo& info) -{ - auto env = info.Env(); - auto liveEngine = snapshotEngine(); - if (!liveEngine) return env.Null(); - - auto obj = Napi::Object::New(env); - const auto inputType = liveEngine->getCurrentInputDeviceType().toStdString(); - const auto outputType = liveEngine->getCurrentOutputDeviceType().toStdString(); - obj.Set("type", inputType); - obj.Set("inputType", inputType); - obj.Set("outputType", outputType); - obj.Set("input", liveEngine->getCurrentInputDevice().toStdString()); - obj.Set("output", liveEngine->getCurrentOutputDevice().toStdString()); - obj.Set("sampleRate", liveEngine->getCurrentSampleRate()); - obj.Set("blockSize", liveEngine->getCurrentBlockSize()); - obj.Set("inputBlockSize", liveEngine->getCurrentInputBlockSize()); - obj.Set("outputBlockSize", liveEngine->getCurrentOutputBlockSize()); - obj.Set("latencyMs", liveEngine->getLatencyMs()); - obj.Set("duplex", liveEngine->isDuplex()); - return obj; -} - -static Napi::Value GetDeviceMetrics(const Napi::CallbackInfo& info) -{ - auto env = info.Env(); - auto liveEngine = snapshotEngine(); - auto obj = Napi::Object::New(env); - if (!liveEngine) - { - obj.Set("duplex", true); - obj.Set("inputOverflowCount", 0.0); - obj.Set("outputUnderflowCount", 0.0); - obj.Set("outputRingFillFrames", 0); - obj.Set("outputRingCapacityFrames", 0); - return obj; - } - const auto m = liveEngine->getDeviceMetrics(); - obj.Set("duplex", m.duplex); - obj.Set("inputOverflowCount", static_cast(m.inputOverflowCount)); - obj.Set("outputUnderflowCount", static_cast(m.outputUnderflowCount)); - obj.Set("outputRingFillFrames", m.outputRingFillFrames); - obj.Set("outputRingCapacityFrames", m.outputRingCapacityFrames); - return obj; -} - -// ── Device Selection ────────────────────────────────────────────────────────── - -static Napi::Value SetDeviceType(const Napi::CallbackInfo& info) -{ - auto env = info.Env(); - auto liveEngine = snapshotEngine(); - if (!liveEngine || info.Length() < 1 || !info[0].IsString()) - return Napi::Boolean::New(env, false); - - auto typeName = info[0].As().Utf8Value(); - bool result = liveEngine->setDeviceType(juce::String(typeName)); - return Napi::Boolean::New(env, result); -} - -static Napi::Value SetOutputDeviceType(const Napi::CallbackInfo& info) -{ - auto env = info.Env(); - auto liveEngine = snapshotEngine(); - if (!liveEngine || info.Length() < 1 || !info[0].IsString()) - return Napi::Boolean::New(env, false); - auto typeName = info[0].As().Utf8Value(); - return Napi::Boolean::New(env, liveEngine->setOutputDeviceType(juce::String(typeName))); -} - -static Napi::Value SetDevice(const Napi::CallbackInfo& info) -{ - auto env = info.Env(); - auto liveEngine = snapshotEngine(); - auto result = Napi::Object::New(env); - result.Set("ok", false); - result.Set("duplex", true); - result.Set("sampleRate", 0.0); - result.Set("inputBlockSize", 0); - result.Set("outputBlockSize", 0); - result.Set("error", ""); - if (!liveEngine) - { - result.Set("error", "Audio engine not initialized"); - return result; - } - - // Object payload: setDevice({inputType, inputDevice, outputType, outputDevice, sampleRate, bufferSize}) - // Legacy positional: setDevice(input, output, sampleRate, bufferSize) - AudioEngine::DeviceConfig cfg; - if (info.Length() > 0 && info[0].IsObject() && !info[0].IsNull() && !info[0].IsArray()) - { - auto obj = info[0].As(); - auto readStr = [&](const char* key) -> std::string { - if (obj.Has(key) && obj.Get(key).IsString()) return obj.Get(key).As().Utf8Value(); - return {}; - }; - // Reject NaN/Infinity at the JS→C boundary so they can't poison - // downstream comparisons (NaN <= 0 is false, so the validation - // fallback in setAudioDevices() wouldn't catch them). Casting a - // non-finite double to int is also UB in C++. - auto readNum = [&](const char* key, double def) -> double { - if (obj.Has(key) && obj.Get(key).IsNumber()) - { - const double v = obj.Get(key).As().DoubleValue(); - if (std::isfinite(v)) return v; - } - return def; - }; - cfg.inputType = juce::String(readStr("inputType")); - cfg.inputDevice = juce::String(readStr("inputDevice")); - if (cfg.inputDevice.isEmpty()) cfg.inputDevice = juce::String(readStr("input")); - cfg.outputType = juce::String(readStr("outputType")); - cfg.outputDevice = juce::String(readStr("outputDevice")); - if (cfg.outputDevice.isEmpty()) cfg.outputDevice = juce::String(readStr("output")); - cfg.sampleRate = readNum("sampleRate", 48000.0); - // Clamp before the double→int cast: finite-but-out-of-range values - // (e.g. a JS-side bug passing 1e18) are UB to convert to int. readNum - // already filtered non-finite; we just need a range check here. - { - const double bsd = readNum("bufferSize", 256.0); - if (bsd >= 1.0 && bsd <= (double) (std::numeric_limits::max) ()) - cfg.bufferSize = (int) bsd; - else - cfg.bufferSize = 256; - } - } - else - { - auto input = info.Length() > 0 && info[0].IsString() ? info[0].As().Utf8Value() : ""; - auto output = info.Length() > 1 && info[1].IsString() ? info[1].As().Utf8Value() : ""; - double sr = info.Length() > 2 && info[2].IsNumber() ? info[2].As().DoubleValue() : 48000.0; - int bs = info.Length() > 3 && info[3].IsNumber() ? info[3].As().Int32Value() : 256; - cfg.inputDevice = juce::String(input); - cfg.outputDevice = juce::String(output); - cfg.sampleRate = sr; - cfg.bufferSize = bs; - } - - // Main thread only — JUCE's ALSA backend deadlocks if called from a worker. - const auto r = liveEngine->setAudioDevices(cfg); - result.Set("ok", r.ok); - result.Set("duplex", r.duplex); - result.Set("sampleRate", r.sampleRate); - result.Set("inputBlockSize", r.inputBlockSize); - result.Set("outputBlockSize", r.outputBlockSize); - result.Set("error", r.error.toStdString()); - return result; -} - -// ── Audio Control ───────────────────────────────────────────────────────────── - -static Napi::Value StartAudio(const Napi::CallbackInfo& info) -{ - if (auto liveEngine = snapshotEngine()) liveEngine->startAudio(); - return info.Env().Undefined(); -} - -static Napi::Value StopAudio(const Napi::CallbackInfo& info) -{ - if (auto liveEngine = snapshotEngine()) liveEngine->stopAudio(); - return info.Env().Undefined(); -} - -static Napi::Value IsAudioRunning(const Napi::CallbackInfo& info) -{ - auto liveEngine = snapshotEngine(); - return Napi::Boolean::New(info.Env(), liveEngine ? liveEngine->isAudioRunning() : false); -} - -// ── Gain ────────────────────────────────────────────────────────────────────── - -static Napi::Value SetGain(const Napi::CallbackInfo& info) -{ - auto env = info.Env(); - auto liveEngine = snapshotEngine(); - if (!liveEngine || info.Length() < 2) return env.Undefined(); - - auto which = info[0].As().Utf8Value(); - float value = info[1].As().FloatValue(); - - if (which == "input") liveEngine->setInputGain(value); - else if (which == "output") liveEngine->setOutputGain(value); - else if (which == "chain") liveEngine->setChainOutputGain(value); - else if (which == "backing") liveEngine->setBackingVolume(value); - - return env.Undefined(); -} - -static Napi::Value SetInputChannel(const Napi::CallbackInfo& info) -{ - auto liveEngine = snapshotEngine(); - if (liveEngine && info.Length() > 0) - liveEngine->setInputChannel(info[0].As().Int32Value()); - return info.Env().Undefined(); -} - -static Napi::Value SetMonitorMute(const Napi::CallbackInfo& info) -{ - auto liveEngine = snapshotEngine(); - if (liveEngine && info.Length() > 0) - liveEngine->setMonitorMute(info[0].As().Value()); - return info.Env().Undefined(); -} - -// setNoteDetectionEnabled(bool) -> undefined. Arms/suspends the polyphonic ML -// note-detection pipeline across all sources. The renderer (note_detect) calls -// this true only while a consumer actually reads ML notes (native-frame -// detection / non-verifier fallback) and false otherwise — the default -// harmonic-comb verifier path and the always-on home tuner leave ML suspended, -// so the engine runs no ONNX inference when nothing needs it. -static Napi::Value SetNoteDetectionEnabled(const Napi::CallbackInfo& info) -{ - auto liveEngine = snapshotEngine(); - if (liveEngine && info.Length() > 0) - liveEngine->setMlNoteDetectionEnabled(info[0].As().Value()); - return info.Env().Undefined(); -} - -static Napi::Value SetMonitorMuteSuppressed(const Napi::CallbackInfo& info) -{ - // IsBoolean()-guarded so a mismatched renderer build / manual caller - // passing a non-boolean is a clean no-op rather than a hard N-API failure - // (NAPI_DISABLE_CPP_EXCEPTIONS is enabled). Mirrors SetNoiseGate's style. - auto liveEngine = snapshotEngine(); - if (liveEngine && info.Length() > 0 && info[0].IsBoolean()) - liveEngine->setMonitorMuteSuppressed(info[0].As().Value()); - return info.Env().Undefined(); -} - -static Napi::Value SetMonitorKill(const Napi::CallbackInfo& info) -{ - // IsBoolean()-guarded (fail-soft no-op on a downlevel/mismatched caller), - // mirroring SetMonitorMuteSuppressed. - auto liveEngine = snapshotEngine(); - if (liveEngine && info.Length() > 0 && info[0].IsBoolean()) - liveEngine->setMonitorKill(info[0].As().Value()); - return info.Env().Undefined(); -} - -static Napi::Value SetNoiseGate(const Napi::CallbackInfo& info) -{ - auto env = info.Env(); - auto liveEngine = snapshotEngine(); - if (!liveEngine || info.Length() < 1 || !info[0].IsObject()) - return env.Undefined(); - - auto o = info[0].As(); - - bool enabled = false; - if (o.Has("enabled")) - { - auto v = o.Get("enabled"); - if (v.IsBoolean()) - enabled = v.As().Value(); - else if (v.IsNumber()) - enabled = v.As().DoubleValue() != 0.0; - } - - float thresholdDb = -60.0f; - if (o.Has("thresholdDb") && o.Get("thresholdDb").IsNumber()) - thresholdDb = (float)o.Get("thresholdDb").As().DoubleValue(); - - float releaseMs = 100.0f; - if (o.Has("releaseMs") && o.Get("releaseMs").IsNumber()) - releaseMs = (float)o.Get("releaseMs").As().DoubleValue(); - - float depthDb = -60.0f; - if (o.Has("depthDb") && o.Get("depthDb").IsNumber()) - depthDb = (float)o.Get("depthDb").As().DoubleValue(); - - liveEngine->setNoiseGate(enabled, thresholdDb, releaseMs, depthDb); - return env.Undefined(); -} - -static Napi::Value SetTonePolish(const Napi::CallbackInfo& info) -{ - // Tone Polish — { enabled: bool }. Mirrors SetNoiseGate's defensive - // shape so a mismatched renderer build / manual caller passing a - // non-object is a clean no-op rather than a hard N-API failure - // (NAPI_DISABLE_CPP_EXCEPTIONS). - auto env = info.Env(); - auto liveEngine = snapshotEngine(); - if (!liveEngine || info.Length() < 1 || !info[0].IsObject()) - return env.Undefined(); - - auto o = info[0].As(); - - bool enabled = true; - if (o.Has("enabled")) - { - auto v = o.Get("enabled"); - if (v.IsBoolean()) - enabled = v.As().Value(); - else if (v.IsNumber()) - enabled = v.As().DoubleValue() != 0.0; - } - - liveEngine->setTonePolishEnabled(enabled); - return env.Undefined(); -} - -static Napi::Value IsMonitorMuted(const Napi::CallbackInfo& info) -{ - auto liveEngine = snapshotEngine(); - return Napi::Boolean::New(info.Env(), liveEngine ? liveEngine->isMonitorMuted() : true); -} - -// ── Metering (polled — read atomics) ────────────────────────────────────────── - -static Napi::Value GetLevels(const Napi::CallbackInfo& info) -{ - auto env = info.Env(); - auto obj = Napi::Object::New(env); - auto liveEngine = snapshotEngine(); - - if (liveEngine) - { - obj.Set("inputLevel", liveEngine->getInputLevel()); - obj.Set("outputLevel", liveEngine->getOutputLevel()); - obj.Set("inputPeak", liveEngine->getInputPeak()); - obj.Set("outputPeak", liveEngine->getOutputPeak()); - } - else - { - obj.Set("inputLevel", 0.0); - obj.Set("outputLevel", 0.0); - obj.Set("inputPeak", 0.0); - obj.Set("outputPeak", 0.0); - } - - return obj; -} - -// getSourceLevels(sourceId) -> { inputLevel, inputPeak, outputLevel, outputPeak }. -// Per-source INPUT level so a bound detector's silence gate reads ITS OWN device's -// signal (not the global/primary level — which would force-fail every hit on an -// extra device the user is actually playing). Output fields mirror the master and -// are 0 (monitoring is post-mix / engine-global). Bad id -> all zeros. -static Napi::Value GetSourceLevels(const Napi::CallbackInfo& info) -{ - auto env = info.Env(); - auto obj = Napi::Object::New(env); - auto liveEngine = snapshotEngine(); - SourceChain* s = (liveEngine && info.Length() >= 1 && info[0].IsNumber()) - ? getValidatedSource(liveEngine.get(), info, 0) : nullptr; - obj.Set("inputLevel", s ? (double) s->getInputLevel() : 0.0); - obj.Set("inputPeak", s ? (double) s->getInputPeak() : 0.0); - obj.Set("outputLevel", 0.0); - obj.Set("outputPeak", 0.0); - return obj; -} - -static Napi::Value ResetPeaks(const Napi::CallbackInfo& info) -{ - if (auto liveEngine = snapshotEngine()) liveEngine->resetPeaks(); - return info.Env().Undefined(); -} - -// Backing-track mix bus RMS level — the engine's per-block running RMS after -// the backing volume fader but before the output-gain master. Returns 0.0 when -// the engine is unavailable or no backing track is loaded. Reads an atomic so -// it is safe to call from the JS thread without blocking the audio thread. -static Napi::Value GetBackingLevel(const Napi::CallbackInfo& info) -{ - auto liveEngine = snapshotEngine(); - return Napi::Number::New(info.Env(), liveEngine ? liveEngine->getBackingLevel() : 0.0f); -} - -// ── Pitch Detection (polled) ────────────────────────────────────────────────── - -// Load the Basic Pitch ONNX model for the polyphonic ML note detector. -// Called once at startup by audio-bridge.ts with the bundled model path. -// Never throws. Returns "is ML note detection available after this call" — -// a model is loaded with a valid contract. A missing/invalid file does NOT -// tear down an already-loaded model, so it can still return true; it returns -// false when the engine isn't ready or ONNX support isn't compiled in, and -// the engine then keeps using the YIN PitchDetector / ChordScorer -// (Constitution VII). -static Napi::Value LoadNoteModel(const Napi::CallbackInfo& info) -{ - auto env = info.Env(); - auto liveEngine = snapshotEngine(); - if (!liveEngine || info.Length() < 1 || !info[0].IsString()) - return Napi::Boolean::New(env, false); - - const auto path = info[0].As().Utf8Value(); - const bool ok = liveEngine->loadNoteModel(juce::File(juce::String(path))); - return Napi::Boolean::New(env, ok); -} - -// Whether the ML note detector is active (ONNX support compiled in AND a -// model loaded). Lets the renderer / tests tell the ML path from the YIN -// fallback without inferring it from behaviour. -static Napi::Value IsMlNoteDetection(const Napi::CallbackInfo& info) -{ - auto env = info.Env(); - // Report readiness, not just model-loaded: the engine only routes - // getPitchDetection()/scoreChord() to ML once the detector has published - // its first snapshot (isReady()). Reporting true during the cold-start - // window would tell the renderer "ML active" while it's still getting the - // YIN fallback. - auto liveEngine = snapshotEngine(); - return Napi::Boolean::New(env, - liveEngine && liveEngine->hasMlNoteDetector() - && liveEngine->getMlNoteDetector().isReady()); -} - -// Raw polyphonic transcription from the ML note detector — the full set of -// currently-active pitches, not just the dominant one. Returns -// `{ notes: [{ midi, confidence, onsetMs, onsetSeq }], sampleRate }`, or null when the ML -// detector isn't active (no model / ONNX support) so the renderer can feature- -// detect and fall back. Never throws. -static Napi::Value DetectNotes(const Napi::CallbackInfo& info) -{ - auto env = info.Env(); - // Gate on isReady(): the contract is that callers get null whenever the - // ML detector isn't actively producing notes. isReady() is false with no - // model, after a device stop, and during the cold-start window before the - // first inference publishes — so the renderer feature-detects correctly - // and falls back instead of consuming an empty ML stream. - auto liveEngine = snapshotEngine(); - if (!liveEngine || !liveEngine->getMlNoteDetector().isReady()) - return env.Null(); - - const auto active = liveEngine->getMlNoteDetector().getActiveNotes(); - auto notesArr = Napi::Array::New(env, active.size()); - for (size_t i = 0; i < active.size(); ++i) - { - auto entry = Napi::Object::New(env); - entry.Set("midi", active[i].midi); - entry.Set("confidence", active[i].confidence); - // Milliseconds since this pitch's onset — lets the renderer back-date - // a detection to the true onset instead of poll time. - entry.Set("onsetMs", active[i].onsetAgeMs); - // Monotonic per-pitch onset counter — a change means a new note was - // struck, so the renderer can consume onsets as discrete events. - entry.Set("onsetSeq", active[i].onsetSeq); - notesArr.Set((uint32_t) i, entry); - } - - auto obj = Napi::Object::New(env); - obj.Set("notes", notesArr); - // Normalise the sample rate: getCurrentSampleRate() is 0 when no audio - // device is active — hand the renderer a sane positive value so its - // Hz/time math can't divide by zero. - const double sr = liveEngine->getCurrentSampleRate(); - obj.Set("sampleRate", sr > 0.0 ? sr : 48000.0); - return obj; -} - -static Napi::Value GetPitchDetection(const Napi::CallbackInfo& info) -{ - auto env = info.Env(); - auto obj = Napi::Object::New(env); - auto liveEngine = snapshotEngine(); - - if (liveEngine) - { - // getActiveDetection() returns the polyphonic ML detector's dominant - // pitch when a Basic Pitch model is loaded, else the YIN detector's - // latest result — same shape either way, so the plugin is unchanged. - auto det = liveEngine->getActiveDetection(); - obj.Set("frequency", det.frequency); - obj.Set("confidence", det.confidence); - obj.Set("midiNote", det.midiNote); - obj.Set("cents", det.cents); - obj.Set("noteName", det.noteName.toStdString()); - } - else - { - obj.Set("frequency", -1.0); - obj.Set("confidence", 0.0); - obj.Set("midiNote", -1); - obj.Set("cents", 0.0); - obj.Set("noteName", ""); - } - - return obj; -} - -static Napi::Value GetRawPitchDetection(const Napi::CallbackInfo& info) -{ - auto env = info.Env(); - auto obj = Napi::Object::New(env); - auto liveEngine = snapshotEngine(); - - if (liveEngine) - { - // Always the raw YIN detection — bypasses the ML preference so frequency - // stays continuous (sub-Hz) and cents stays real even with a model loaded. - // Backs the tuner's audio:getRawPitch endpoint. - auto det = liveEngine->getRawPitchDetection(); - obj.Set("frequency", det.frequency); - obj.Set("confidence", det.confidence); - obj.Set("midiNote", det.midiNote); - obj.Set("cents", det.cents); - obj.Set("noteName", det.noteName.toStdString()); - } - else - { - obj.Set("frequency", -1.0); - obj.Set("confidence", 0.0); - obj.Set("midiNote", -1); - obj.Set("cents", 0.0); - obj.Set("noteName", ""); - } - - return obj; -} - -static Napi::Value GetRawAudioFrame(const Napi::CallbackInfo& info) -{ - auto env = info.Env(); - auto liveEngine = snapshotEngine(); - - // Optional sample count; defaults to AudioEngine::getRawAudioFrame's 4096. - // The engine clamps anything above its ring capacity. - int numSamples = 4096; - if (info.Length() > 0 && info[0].IsNumber()) - numSamples = info[0].As().Int32Value(); - - if (!liveEngine || numSamples <= 0) - return Napi::Float32Array::New(env, 0); - - // Post-gate mono snapshot for the tuner's own pitch pipeline. Returns a - // Float32Array of the most-recent N samples (left-zero-padded on cold start). - auto frame = liveEngine->getRawAudioFrame(numSamples); - auto out = Napi::Float32Array::New(env, frame.size()); - float* dst = out.Data(); - for (size_t i = 0; i < frame.size(); ++i) - dst[i] = frame[i]; - return out; -} - -// Score a polyphonic chord against the engine's most recent input -// samples. Renderer (notedetect plugin's matchNotes chord branch) -// supplies the chord context — chart notes plus tuning/arrangement -// metadata — and gets back a `{score, hitStrings, totalStrings, isHit, -// results[]}` object identical in shape to what the JS implementation -// produced. Audio never crosses the N-API boundary, which is the -// whole reason for moving the math here: constitution II says audio -// analysis lives in JUCE, and this is the missing piece. -// -// Request shape. Fields marked `required` must be present and -// internally consistent — the C++ scorer fails closed (all-miss -// result with one entry per requested note) when the validation -// invariants don't hold, rather than silently substituting defaults. -// { -// notes: [{ s, f, ho?, po?, b?, sl?, hm? }, ...], -// // required, each `s` must be in [0, stringCount) -// arrangement?: 'guitar'|'bass', // default 'guitar' — must be one of these two strings -// stringCount?: number, // default 6 — must match the (arrangement, stringCount) -// // table: bass{4,5} or guitar{6,7,8} -// offsets: number[], // required, length must equal stringCount. -// // Pass an array of zeros for standard tuning; -// // the default of `stringCount = 6` only works -// // if you supply 6 offsets. -// numSamples?: number, // analysis window (default 4096, capped at the -// // engine input-ring capacity, currently 8192) -// capo?: number, // default 0 -// pitchCheckCents?: number, // 0 = energy-only chord check (default 0) -// minHitRatio?: number, // default 0.6 -// bypassMl?: boolean, // force the DSP band-energy scorer even -// // when an ML model is loaded (default false) -// harmonicVerify?: boolean, // score each note by harmonic-comb energy -// // (f,2f..5f vs the floor between) instead -// // of band-energy/total (default false) -// harmonicSnr?: number, // min harmonic-to-floor ratio for a hit -// // when harmonicVerify is set (default 3.0) -// fundamentalRatio?: number, // fundamental-presence gate: reject when -// // f0 peak < ratio*strongest partial; lower -// // for bass, <=0 disables (default 0.20) -// } -// Shared core: parse `reqObj` into a ChordScorer::Request and score it against -// `target`'s input ring. `target` is sources[0] for the legacy scoreChord and -// getSource(id) for the source-indexed scoreSourceChord. -static Napi::Value scoreChordCore(Napi::Env env, Napi::Object reqObj, SourceChain* target) -{ - // Hard caps on caller-controlled array lengths. The scorer's - // (arrangement, stringCount) validation only accepts up to 8 - // strings; chord-notes have a natural ceiling at the same value - // (one per string). 32 is a generous headroom that still bounds - // worst-case allocations the renderer could trigger over IPC — - // without these limits, a malformed/malicious payload claiming a - // gigantic JS array length would force a multi-GB reserve before - // the scorer's own validation rejected the request. A request - // that exceeds either cap is treated as outright malformed and - // returns the "no chord requested" failure shape (totalStrings=0); - // every other validation failure goes through the all-miss path - // below so results[] stays in lockstep with notes[]. - static constexpr uint32_t kMaxOffsets = 32; - static constexpr uint32_t kMaxNotes = 32; - - auto noRequestFailure = [&env]() { - auto failure = Napi::Object::New(env); - failure.Set("score", 0.0); - failure.Set("hitStrings", 0); - failure.Set("totalStrings", 0); - failure.Set("isHit", false); - failure.Set("results", Napi::Array::New(env, 0)); - return failure; - }; - - // Capture the notes array up front so every downstream failure - // path can build a per-note all-miss result aligned 1:1 with the - // caller's notes[]. Pre-cap check happens before we even read the - // length into the helper to prevent a payload claiming an enormous - // length from forcing the helper to allocate a huge results array. - Napi::Value notesVal = reqObj.Has("notes") ? reqObj.Get("notes") : env.Null(); - if (!notesVal.IsArray()) return noRequestFailure(); - auto notesArr = notesVal.As(); - if (notesArr.Length() > kMaxNotes) return noRequestFailure(); - const uint32_t noteCount = notesArr.Length(); - - // All-miss result aligned with the caller's notes[]. Walks the - // original JS array so the per-note `s` / `f` echo back in the - // result even when the request fails validation (lets the renderer - // distinguish "this string missed" from "this string wasn't sent"). - // Used by every failure path below except the cap/no-notes case - // above, which doesn't have a coherent notes[] to mirror. - auto buildAllMiss = [&]() { - auto resultsArr = Napi::Array::New(env, noteCount); - for (uint32_t i = 0; i < noteCount; ++i) - { - int s = -1, f = -1; - auto v = notesArr.Get(i); - if (v.IsObject()) - { - auto o = v.As(); - if (o.Has("s") && o.Get("s").IsNumber()) - s = o.Get("s").As().Int32Value(); - if (o.Has("f") && o.Get("f").IsNumber()) - f = o.Get("f").As().Int32Value(); - } - auto entry = Napi::Object::New(env); - entry.Set("s", s); - entry.Set("f", f); - entry.Set("hit", false); - entry.Set("bandEnergy", 0.0); - entry.Set("centsDiff", env.Null()); - entry.Set("centsError", env.Null()); - resultsArr.Set(i, entry); - } - auto out = Napi::Object::New(env); - out.Set("score", 0.0); - out.Set("hitStrings", 0); - out.Set("totalStrings", (int) noteCount); - out.Set("isHit", false); - out.Set("results", resultsArr); - return out; - }; - - ChordScorer::Request req; - if (reqObj.Has("numSamples") && reqObj.Get("numSamples").IsNumber()) - req.numSamples = reqObj.Get("numSamples").As().Int32Value(); - if (reqObj.Has("arrangement") && reqObj.Get("arrangement").IsString()) - req.arrangement = reqObj.Get("arrangement").As().Utf8Value(); - if (reqObj.Has("stringCount") && reqObj.Get("stringCount").IsNumber()) - req.stringCount = reqObj.Get("stringCount").As().Int32Value(); - if (reqObj.Has("capo") && reqObj.Get("capo").IsNumber()) - req.capo = reqObj.Get("capo").As().Int32Value(); - if (reqObj.Has("pitchCheckCents") && reqObj.Get("pitchCheckCents").IsNumber()) - req.pitchCheckCents = reqObj.Get("pitchCheckCents").As().FloatValue(); - if (reqObj.Has("minHitRatio") && reqObj.Get("minHitRatio").IsNumber()) - req.minHitRatio = reqObj.Get("minHitRatio").As().FloatValue(); - if (reqObj.Has("bypassMl") && reqObj.Get("bypassMl").IsBoolean()) - req.bypassMl = reqObj.Get("bypassMl").As().Value(); - if (reqObj.Has("harmonicVerify") && reqObj.Get("harmonicVerify").IsBoolean()) - req.harmonicVerify = reqObj.Get("harmonicVerify").As().Value(); - if (reqObj.Has("harmonicSnr") && reqObj.Get("harmonicSnr").IsNumber()) - req.harmonicSnr = reqObj.Get("harmonicSnr").As().FloatValue(); - if (reqObj.Has("fundamentalRatio") && reqObj.Get("fundamentalRatio").IsNumber()) - { - // Drop NaN/Inf: a non-finite ratio poisons the fundamental-presence - // gate (fundMag >= NaN is always false -> every note false-rejected). - // Keep the safe 0.20 default instead. - const float v = reqObj.Get("fundamentalRatio").As().FloatValue(); - if (std::isfinite(v)) req.fundamentalRatio = v; - } - - if (reqObj.Has("offsets") && reqObj.Get("offsets").IsArray()) - { - auto arr = reqObj.Get("offsets").As(); - if (arr.Length() > kMaxOffsets) return noRequestFailure(); - req.tuningOffsets.reserve(arr.Length()); - for (uint32_t i = 0; i < arr.Length(); ++i) - { - auto v = arr.Get(i); - // Tuning offsets materially shift expected pitch — silently - // substituting 0 for a missing/non-numeric entry would - // produce confidently wrong scores. Fail closed with the - // per-note all-miss shape so the renderer sees the right - // results[] length even when the request is malformed. - if (!v.IsNumber()) return buildAllMiss(); - req.tuningOffsets.push_back(v.As().Int32Value()); - } - } - - req.notes.reserve(noteCount); - for (uint32_t i = 0; i < noteCount; ++i) - { - auto v = notesArr.Get(i); - // For malformed entries (non-object, or missing/non-numeric - // s/f) push a sentinel Note with string = -1. This keeps - // req.notes.size() in lockstep with the incoming notes[] - // length AND guarantees ChordScorer's range check - // (`n.string < 0 || n.string >= stringCount`) trips on the - // sentinel — yielding the same all-miss fail-closed result - // the shape contract advertises, never a false hit on the - // default low-string position. - ChordScorer::Note n{}; - n.string = -1; - n.fret = -1; - if (!v.IsObject()) - { - req.notes.push_back(n); - continue; - } - auto noteObj = v.As(); - const bool hasS = noteObj.Has("s") && noteObj.Get("s").IsNumber(); - const bool hasF = noteObj.Has("f") && noteObj.Get("f").IsNumber(); - if (!hasS || !hasF) - { - req.notes.push_back(n); - continue; - } - n.string = noteObj.Get("s").As().Int32Value(); - n.fret = noteObj.Get("f").As().Int32Value(); - // Technique flags are truthy/falsy in JS; coerce to bool - // here so an unset value cleanly becomes false. - auto truthy = [¬eObj](const char* key) { - if (!noteObj.Has(key)) return false; - auto val = noteObj.Get(key); - return val.ToBoolean().Value(); - }; - n.hammerOn = truthy("ho"); - n.pullOff = truthy("po"); - n.bend = truthy("b"); - n.slide = truthy("sl"); - n.harmonic = truthy("hm"); - req.notes.push_back(n); - } - - auto result = target->scoreChord(req); - - auto out = Napi::Object::New(env); - out.Set("score", result.score); - out.Set("hitStrings", result.hitStrings); - out.Set("totalStrings", result.totalStrings); - out.Set("isHit", result.isHit); - auto resultsArr = Napi::Array::New(env, result.results.size()); - for (size_t i = 0; i < result.results.size(); ++i) - { - const auto& r = result.results[i]; - auto entry = Napi::Object::New(env); - entry.Set("s", r.string); - entry.Set("f", r.fret); - entry.Set("hit", r.hit); - entry.Set("bandEnergy", r.bandEnergy); - // Mirror the JS result shape: when cents weren't measured the - // fields are present-but-null so the renderer can distinguish - // "no pitch check ran" (null) from "pitch check said 0" - // (numeric 0). - if (r.hasCents) - { - entry.Set("centsDiff", r.centsDiff); - entry.Set("centsError", r.centsError); - } - else - { - entry.Set("centsDiff", env.Null()); - entry.Set("centsError", env.Null()); - } - resultsArr.Set(i, entry); - } - out.Set("results", resultsArr); - return out; -} - -// Legacy: scoreChord(req) — targets sources[0]. Backward-compatible. -static Napi::Value ScoreChord(const Napi::CallbackInfo& info) -{ - auto env = info.Env(); - auto liveEngine = snapshotEngine(); - auto noRequestFailure = [&env]() { - auto failure = Napi::Object::New(env); - failure.Set("score", 0.0); - failure.Set("hitStrings", 0); - failure.Set("totalStrings", 0); - failure.Set("isHit", false); - failure.Set("results", Napi::Array::New(env, 0)); - return failure; - }; - if (!liveEngine || info.Length() < 1 || !info[0].IsObject()) - return noRequestFailure(); - return scoreChordCore(env, info[0].As(), liveEngine->getSource(0)); -} - -// Source-indexed: scoreSourceChord(sourceId, req). Bad id / payload -> the -// same "no chord requested" failure shape (totalStrings=0). -static Napi::Value ScoreSourceChord(const Napi::CallbackInfo& info) -{ - auto env = info.Env(); - auto liveEngine = snapshotEngine(); - auto noRequestFailure = [&env]() { - auto failure = Napi::Object::New(env); - failure.Set("score", 0.0); - failure.Set("hitStrings", 0); - failure.Set("totalStrings", 0); - failure.Set("isHit", false); - failure.Set("results", Napi::Array::New(env, 0)); - return failure; - }; - if (!liveEngine || info.Length() < 2 || !info[0].IsNumber() || !info[1].IsObject()) - return noRequestFailure(); - SourceChain* target = getValidatedSource(liveEngine.get(), info, 0); - if (!target) return noRequestFailure(); - return scoreChordCore(env, info[1].As(), target); -} - -// ── Multi-input source management bridge ───────────────────────────────────── -// A source is one independent input chain (own arrangement chart, detection, -// scoring, tone, monitor). sources[0] always exists. The renderer adds a source -// per extra player, binds it to an input channel, and drives its scoring via the -// *Source* methods below; the legacy un-suffixed methods keep targeting source 0. - -// addSource(inputChannel?) -> sourceId (number), or -1 if the pool is full. -static Napi::Value AddSource(const Napi::CallbackInfo& info) -{ - auto env = info.Env(); - auto liveEngine = snapshotEngine(); - if (!liveEngine) return Napi::Number::New(env, -1); - int channel = -1; // default: mono mix of the first pair - if (info.Length() > 0 && info[0].IsNumber()) - channel = info[0].As().Int32Value(); - int deviceKey = 0; // default: primary input device - if (info.Length() > 1 && info[1].IsNumber()) - { - const int k = info[1].As().Int32Value(); - if (k >= 0) deviceKey = k; // negatives ignored → primary - } - return Napi::Number::New(env, liveEngine->addSource(channel, deviceKey)); -} - -// removeSource(sourceId) -> boolean. sources[0] cannot be removed. -static Napi::Value RemoveSource(const Napi::CallbackInfo& info) -{ - auto env = info.Env(); - auto liveEngine = snapshotEngine(); - if (!liveEngine || info.Length() < 1 || !info[0].IsNumber()) - return Napi::Boolean::New(env, false); - return Napi::Boolean::New(env, liveEngine->removeSource(info[0].As().Int32Value())); -} - -// listSources() -> [{ id, inputChannel, active }]. Null on a missing engine. -static Napi::Value ListSources(const Napi::CallbackInfo& info) -{ - auto env = info.Env(); - auto liveEngine = snapshotEngine(); - if (!liveEngine) return env.Null(); - const auto sources = liveEngine->listSources(); - auto arr = Napi::Array::New(env, sources.size()); - for (size_t i = 0; i < sources.size(); ++i) - { - auto entry = Napi::Object::New(env); - entry.Set("id", sources[i].id); - entry.Set("inputChannel", sources[i].inputChannel); - entry.Set("deviceKey", sources[i].deviceKey); - entry.Set("active", sources[i].active); - arr.Set((uint32_t) i, entry); - } - return arr; -} - -// listInputDevices() -> [{ typeName, name }]. Every available capture device the -// renderer can bind to an additional engine input via bindInputDevice. Null on a -// missing engine. -static Napi::Value ListInputDevices(const Napi::CallbackInfo& info) -{ - auto env = info.Env(); - auto liveEngine = snapshotEngine(); - if (!liveEngine) return env.Null(); - const auto devices = liveEngine->getBindableInputDevices(); - auto arr = Napi::Array::New(env); - uint32_t n = 0; - for (const auto& d : devices) - { - auto entry = Napi::Object::New(env); - entry.Set("typeName", d.typeName.toStdString()); - entry.Set("name", d.name.toStdString()); - arr.Set(n++, entry); - } - return arr; -} - -// bindInputDevice(deviceKey, deviceName) -> "" on success, else an error string. -// Opens an ADDITIONAL physical input device (deviceKey 1..N) so sources created -// with addSource(channel, deviceKey) capture from it at its own clock. -static Napi::Value BindInputDevice(const Napi::CallbackInfo& info) -{ - auto env = info.Env(); - auto liveEngine = snapshotEngine(); - if (!liveEngine) return Napi::String::New(env, "no engine"); - if (info.Length() < 2 || !info[0].IsNumber() || !info[1].IsString()) - return Napi::String::New(env, "bindInputDevice(deviceKey:number, deviceName:string)"); - const int deviceKey = info[0].As().Int32Value(); - const std::string name = info[1].As().Utf8Value(); - return Napi::String::New(env, liveEngine->bindInputDevice(deviceKey, name).toStdString()); -} - -// unbindInputDevice(deviceKey) -> boolean. Stops + releases the extra device. -static Napi::Value UnbindInputDevice(const Napi::CallbackInfo& info) -{ - auto env = info.Env(); - auto liveEngine = snapshotEngine(); - if (!liveEngine || info.Length() < 1 || !info[0].IsNumber()) - return Napi::Boolean::New(env, false); - return Napi::Boolean::New(env, liveEngine->unbindInputDevice(info[0].As().Int32Value())); -} - -// ── Streamer mix output (PR1) ─────────────────────────────────────────────── -// setStreamOutputDevice(typeName, deviceName) -> "" on success, else an error. -static Napi::Value SetStreamOutputDevice(const Napi::CallbackInfo& info) -{ - auto env = info.Env(); - auto liveEngine = snapshotEngine(); - if (!liveEngine) return Napi::String::New(env, "no engine"); - if (info.Length() < 2 || !info[0].IsString() || !info[1].IsString()) - return Napi::String::New(env, "setStreamOutputDevice(typeName:string, deviceName:string)"); - const std::string typeName = info[0].As().Utf8Value(); - const std::string devName = info[1].As().Utf8Value(); - return Napi::String::New(env, - liveEngine->setStreamOutputDevice(juce::String(typeName), juce::String(devName)).toStdString()); -} - -// clearStreamOutput() -> undefined -static Napi::Value ClearStreamOutput(const Napi::CallbackInfo& info) -{ - auto liveEngine = snapshotEngine(); - if (liveEngine) liveEngine->clearStreamOutput(); - return info.Env().Undefined(); -} - -// setStreamBus(includeBacking:boolean, includeGuitar:boolean, gain:number) -static Napi::Value SetStreamBus(const Napi::CallbackInfo& info) -{ - auto liveEngine = snapshotEngine(); - if (liveEngine && info.Length() >= 3 && info[0].IsBoolean() && info[1].IsBoolean() && info[2].IsNumber()) - liveEngine->setStreamBus(info[0].As().Value(), - info[1].As().Value(), - (float) info[2].As().DoubleValue()); - return info.Env().Undefined(); -} - -// setStreamBusGain(gain:number) -static Napi::Value SetStreamBusGain(const Napi::CallbackInfo& info) -{ - auto liveEngine = snapshotEngine(); - if (liveEngine && info.Length() >= 1 && info[0].IsNumber()) - liveEngine->setStreamBusGain((float) info[0].As().DoubleValue()); - return info.Env().Undefined(); -} - -// setRendererBus(enabled:boolean, gain:number) -static Napi::Value SetRendererBus(const Napi::CallbackInfo& info) -{ - auto liveEngine = snapshotEngine(); - if (liveEngine && info.Length() >= 2 && info[0].IsBoolean() && info[1].IsNumber()) - liveEngine->setRendererBus(info[0].As().Value(), - (float) info[1].As().DoubleValue()); - return info.Env().Undefined(); -} - -// pushRendererAudio(interleavedLR:Float32Array, sourceRate:number) -> boolean -// Interleaved stereo (L0 R0 L1 R1 …); sourceRate is the renderer's -// AudioContext sample rate. Returns false when the bus is off / engine down / -// malformed args, so the renderer can stop pushing. -static Napi::Value PushRendererAudio(const Napi::CallbackInfo& info) -{ - auto env = info.Env(); - auto liveEngine = snapshotEngine(); - if (!liveEngine || info.Length() < 2 || !info[0].IsTypedArray() || !info[1].IsNumber()) - return Napi::Boolean::New(env, false); - auto ta = info[0].As(); - if (ta.TypedArrayType() != napi_float32_array) - return Napi::Boolean::New(env, false); - auto f32 = info[0].As(); - const size_t samples = f32.ElementLength(); - if (samples < 2) - return Napi::Boolean::New(env, false); - const int frames = (int) (samples / 2); - const bool ok = liveEngine->pushRendererAudio( - f32.Data(), frames, info[1].As().DoubleValue()); - return Napi::Boolean::New(env, ok); -} - -// getRendererBusMetrics() -> {enabled, fillFrames, capacityFrames, -// pushedFrames, consumedFrames, -// underflowCount, overflowCount} -static Napi::Value GetRendererBusMetrics(const Napi::CallbackInfo& info) -{ - auto env = info.Env(); - auto liveEngine = snapshotEngine(); - auto obj = Napi::Object::New(env); - if (!liveEngine) return obj; - const auto m = liveEngine->getRendererBusMetrics(); - obj.Set("enabled", m.enabled); - obj.Set("fillFrames", m.fillFrames); - obj.Set("capacityFrames", m.capacityFrames); - obj.Set("pushedFrames", (double) m.pushedFrames); - obj.Set("consumedFrames", (double) m.consumedFrames); - obj.Set("underflowCount", (double) m.underflowCount); - obj.Set("overflowCount", (double) m.overflowCount); - return obj; -} - -// getStreamSinkLevel() -> number (peak 0..1+) -static Napi::Value GetStreamSinkLevel(const Napi::CallbackInfo& info) -{ - auto liveEngine = snapshotEngine(); - return Napi::Number::New(info.Env(), liveEngine ? liveEngine->getStreamSinkLevel() : 0.0f); -} - -// isStreamOutputActive() -> boolean -static Napi::Value IsStreamOutputActive(const Napi::CallbackInfo& info) -{ - auto liveEngine = snapshotEngine(); - return Napi::Boolean::New(info.Env(), liveEngine ? liveEngine->isStreamOutputActive() : false); -} - -// getStreamUnderflowCount() -> number -static Napi::Value GetStreamUnderflowCount(const Napi::CallbackInfo& info) -{ - auto liveEngine = snapshotEngine(); - return Napi::Number::New(info.Env(), - (double) (liveEngine ? liveEngine->getStreamUnderflowCount() : 0ull)); -} - -// getStreamOverflowCount() -> number (consumer fell a full ring behind; frames dropped) -static Napi::Value GetStreamOverflowCount(const Napi::CallbackInfo& info) -{ - auto liveEngine = snapshotEngine(); - return Napi::Number::New(info.Env(), - (double) (liveEngine ? liveEngine->getStreamOverflowCount() : 0ull)); -} - -// setSourceInputChannel(sourceId, channel) -static Napi::Value SetSourceInputChannel(const Napi::CallbackInfo& info) -{ - auto liveEngine = snapshotEngine(); - if (liveEngine && info.Length() >= 2 && info[0].IsNumber() && info[1].IsNumber()) - if (SourceChain* s = getValidatedSource(liveEngine.get(), info, 0)) - s->setInputChannel(info[1].As().Int32Value()); - return info.Env().Undefined(); -} - -// setSourceVerifierOffset(sourceId, seconds) — per-source capture-latency -// correction the user dials in for an extra input device (the residual offset -// between that device's path and the primary's; not auto-measurable on JACK). -// Positive seconds DELAYS this source's scoring playhead, negative ADVANCES it. -static Napi::Value SetSourceVerifierOffset(const Napi::CallbackInfo& info) -{ - auto liveEngine = snapshotEngine(); - if (liveEngine && info.Length() >= 2 && info[0].IsNumber() && info[1].IsNumber()) - { - const double sec = info[1].As().DoubleValue(); - if (std::isfinite(sec)) - if (SourceChain* s = getValidatedSource(liveEngine.get(), info, 0)) - s->setVerifierUserOffset(sec); - } - return info.Env().Undefined(); -} - -// setSourceMonitorMute(sourceId, mute) -static Napi::Value SetSourceMonitorMute(const Napi::CallbackInfo& info) -{ - auto liveEngine = snapshotEngine(); - if (liveEngine && info.Length() >= 2 && info[0].IsNumber() && info[1].IsBoolean()) - if (SourceChain* s = getValidatedSource(liveEngine.get(), info, 0)) - s->setMonitorMute(info[1].As().Value()); - return info.Env().Undefined(); -} - -// getSourceRawAudioFrame(sourceId, numSamples?) -> Float32Array -static Napi::Value GetSourceRawAudioFrame(const Napi::CallbackInfo& info) -{ - auto env = info.Env(); - auto liveEngine = snapshotEngine(); - if (!liveEngine || info.Length() < 1 || !info[0].IsNumber()) - return Napi::Float32Array::New(env, 0); - SourceChain* s = getValidatedSource(liveEngine.get(), info, 0); - int numSamples = 4096; - if (info.Length() > 1 && info[1].IsNumber()) - numSamples = info[1].As().Int32Value(); - if (!s || numSamples <= 0) - return Napi::Float32Array::New(env, 0); - auto frame = s->getRawAudioFrame(numSamples); - auto out = Napi::Float32Array::New(env, frame.size()); - float* dst = out.Data(); - for (size_t i = 0; i < frame.size(); ++i) - dst[i] = frame[i]; - return out; -} - -// getSourcePitchDetection(sourceId) -> { frequency, confidence, midiNote, cents, -// noteName }. The no-detection shape when the id is bad/inactive. -static Napi::Value GetSourcePitchDetection(const Napi::CallbackInfo& info) -{ - auto env = info.Env(); - auto obj = Napi::Object::New(env); - auto liveEngine = snapshotEngine(); - SourceChain* s = (liveEngine && info.Length() >= 1 && info[0].IsNumber()) - ? getValidatedSource(liveEngine.get(), info, 0) : nullptr; - if (s) - { - auto det = s->getActiveDetection(); - obj.Set("frequency", det.frequency); - obj.Set("confidence", det.confidence); - obj.Set("midiNote", det.midiNote); - obj.Set("cents", det.cents); - obj.Set("noteName", det.noteName.toStdString()); - } - else - { - obj.Set("frequency", -1.0); - obj.Set("confidence", 0.0); - obj.Set("midiNote", -1); - obj.Set("cents", 0.0); - obj.Set("noteName", ""); - } - return obj; -} - -// getSourceRawPitchDetection(sourceId) -> raw YIN detection (bypasses ML), same -// shape as getSourcePitchDetection. Backs the per-source sustain glow / mono path. -static Napi::Value GetSourceRawPitchDetection(const Napi::CallbackInfo& info) -{ - auto env = info.Env(); - auto obj = Napi::Object::New(env); - auto liveEngine = snapshotEngine(); - SourceChain* s = (liveEngine && info.Length() >= 1 && info[0].IsNumber()) - ? getValidatedSource(liveEngine.get(), info, 0) : nullptr; - if (s) - { - auto det = s->getRawPitchDetection(); - obj.Set("frequency", det.frequency); - obj.Set("confidence", det.confidence); - obj.Set("midiNote", det.midiNote); - obj.Set("cents", det.cents); - obj.Set("noteName", det.noteName.toStdString()); - } - else - { - obj.Set("frequency", -1.0); - obj.Set("confidence", 0.0); - obj.Set("midiNote", -1); - obj.Set("cents", 0.0); - obj.Set("noteName", ""); - } - return obj; -} - -// getSourceNoteVerdicts(sourceId, songTime?, playing?) -> verdict array, or null -// on a missing engine / bad id. Folds in the per-source playhead push like the -// legacy getNoteVerdicts. -static Napi::Value GetSourceNoteVerdicts(const Napi::CallbackInfo& info) -{ - auto env = info.Env(); - auto liveEngine = snapshotEngine(); - if (!liveEngine || info.Length() < 1 || !info[0].IsNumber()) - return env.Null(); - SourceChain* s = getValidatedSource(liveEngine.get(), info, 0); - if (!s) return env.Null(); - - if (info.Length() >= 3 && info[1].IsNumber() && info[2].IsBoolean()) - { - const double songTime = info[1].As().DoubleValue(); - if (std::isfinite(songTime)) - s->setPlayhead(songTime, info[2].As().Value()); - } - - const auto verdicts = s->getNoteVerdicts(); - auto arr = Napi::Array::New(env, verdicts.size()); - for (size_t i = 0; i < verdicts.size(); ++i) - { - const auto& v = verdicts[i]; - auto entry = Napi::Object::New(env); - entry.Set("id", v.id); - entry.Set("detected", v.detected); - entry.Set("detectedSongTime", v.detectedSongTime); - entry.Set("centsError", v.centsError); - entry.Set("snr", v.snr); - arr.Set((uint32_t) i, entry); - } - return arr; -} - -// Push the song's note chart into the engine for continuous, background -// verification. The notedetect plugin calls this once per arrangement load; -// the engine's NoteVerifier thread then scores each note's timing window -// against the live playhead and input ring, so the renderer no longer runs a -// per-tick scoreChord IPC loop (which starved during dense passages). -// -// Expected payload: -// { -// arrangement?: 'guitar'|'bass', // default 'guitar' -// stringCount?: number, // default 6 -// tuningOffsets: number[], // length should equal stringCount -// capo?: number, // default 0 -// pitchCheckCents?: number, // default 0 (energy-only) -// harmonicSnr?: number, // default 3.0 -// fundamentalRatio?: number, // fundamental-presence gate, lower for -// // bass, <=0 disables (default 0.20) -// timingTolerance?: number, // seconds, default 0.1 -// notes: [{ id:string, t:number, s:number, f:number, sus:number, -// ho?,po?,b?,sl?,hm?:boolean }, ...] -// } -// Returns true when the chart was accepted, false on a malformed payload or -// when no engine exists. -// Shared core: parse `reqObj` into a ChartUpdate and push it to `target`'s -// verifier. `target` is sources[0] for the legacy setChart and getSource(id) for -// the source-indexed setSourceChart. A malformed payload clears the target's -// chart (so a failed reload can't leave a stale chart scoring) and returns false. -static Napi::Value setChartCore(Napi::Env env, Napi::Object reqObj, SourceChain* target) -{ - // Generous cap on the chart length — a full song's note list is well - // under this, but it bounds the worst-case allocation a malformed payload - // (claiming a gigantic JS array length) could force over IPC. - static constexpr uint32_t kMaxChartNotes = 8192; - - // Rejecting a malformed chart must also drop whatever chart the verifier - // currently holds — otherwise a failed (re)load leaves the previous - // song's chart active and getNoteVerdicts() keeps emitting stale verdicts. - auto reject = [&]() -> Napi::Value { - if (target) target->clearChart(); - return Napi::Boolean::New(env, false); - }; - - NoteVerifier::ChartUpdate chart; - if (reqObj.Has("arrangement") && reqObj.Get("arrangement").IsString()) - chart.arrangement = reqObj.Get("arrangement").As().Utf8Value(); - if (reqObj.Has("stringCount") && reqObj.Get("stringCount").IsNumber()) - chart.stringCount = reqObj.Get("stringCount").As().Int32Value(); - if (reqObj.Has("capo") && reqObj.Get("capo").IsNumber()) - chart.capo = reqObj.Get("capo").As().Int32Value(); - if (reqObj.Has("pitchCheckCents") && reqObj.Get("pitchCheckCents").IsNumber()) - chart.pitchCheckCents = reqObj.Get("pitchCheckCents").As().FloatValue(); - if (reqObj.Has("harmonicSnr") && reqObj.Get("harmonicSnr").IsNumber()) - chart.harmonicSnr = reqObj.Get("harmonicSnr").As().FloatValue(); - if (reqObj.Has("fundamentalRatio") && reqObj.Get("fundamentalRatio").IsNumber()) - { - // Drop NaN/Inf (see ScoreChord): a non-finite ratio poisons the - // fundamental-presence gate; keep the safe 0.20 default. - const float v = reqObj.Get("fundamentalRatio").As().FloatValue(); - if (std::isfinite(v)) chart.fundamentalRatio = v; - } - if (reqObj.Has("presenceRatio") && reqObj.Get("presenceRatio").IsNumber()) - { - // Temporal-persistence floor, clamped to [0,1]. Saturate rather than - // reject an out-of-range value: a stray >1 must NOT silently fall back to - // 0 (legacy ever-present), which would reintroduce the false-accept this - // guards against. Non-finite is ignored (keeps the 0 default). - const float v = reqObj.Get("presenceRatio").As().FloatValue(); - if (std::isfinite(v)) chart.presenceRatio = (v < 0.0f) ? 0.0f : (v > 1.0f ? 1.0f : v); - } - if (reqObj.Has("timingTolerance") && reqObj.Get("timingTolerance").IsNumber()) - chart.timingTolerance = reqObj.Get("timingTolerance").As().DoubleValue(); - - if (reqObj.Has("tuningOffsets") && reqObj.Get("tuningOffsets").IsArray()) - { - auto arr = reqObj.Get("tuningOffsets").As(); - if (arr.Length() > 32) return reject(); - chart.tuningOffsets.reserve(arr.Length()); - for (uint32_t i = 0; i < arr.Length(); ++i) - { - auto v = arr.Get(i); - if (!v.IsNumber()) return reject(); - chart.tuningOffsets.push_back(v.As().Int32Value()); - } - } - - // ChordScorer requires exactly one tuning offset per string and otherwise - // fails every note closed. Reject the chart here so a malformed payload - // surfaces as setChart() == false rather than a silently all-miss session - // the caller believes loaded fine. - if ((int) chart.tuningOffsets.size() != chart.stringCount) - return reject(); - - Napi::Value notesVal = reqObj.Has("notes") ? reqObj.Get("notes") : env.Null(); - if (!notesVal.IsArray()) return reject(); - auto notesArr = notesVal.As(); - if (notesArr.Length() > kMaxChartNotes) return reject(); - - chart.notes.reserve(notesArr.Length()); - for (uint32_t i = 0; i < notesArr.Length(); ++i) - { - auto v = notesArr.Get(i); - if (!v.IsObject()) return reject(); - auto noteObj = v.As(); - - // Every chart note must carry all five required fields with the right - // type. Filling defaults for a missing field would push a bogus - // time-0 note with an empty id — that breaks verdict-by-id alignment - // — so reject the whole chart instead. - const bool validNote = - noteObj.Has("id") && noteObj.Get("id").IsString() && - noteObj.Has("t") && noteObj.Get("t").IsNumber() && - noteObj.Has("s") && noteObj.Get("s").IsNumber() && - noteObj.Has("f") && noteObj.Get("f").IsNumber() && - noteObj.Has("sus") && noteObj.Get("sus").IsNumber(); - if (!validNote) return reject(); - - NoteVerifier::ChartNote n{}; - n.id = noteObj.Get("id").As().Utf8Value(); - n.t = noteObj.Get("t").As().DoubleValue(); - n.string = noteObj.Get("s").As().Int32Value(); - n.fret = noteObj.Get("f").As().Int32Value(); - n.sus = noteObj.Get("sus").As().DoubleValue(); - auto truthy = [¬eObj](const char* key) { - if (!noteObj.Has(key)) return false; - return noteObj.Get(key).ToBoolean().Value(); - }; - n.ho = truthy("ho"); - n.po = truthy("po"); - n.b = truthy("b"); - n.sl = truthy("sl"); - n.hm = truthy("hm"); - chart.notes.push_back(std::move(n)); - } - - target->setChart(chart); - return Napi::Boolean::New(env, true); -} - -// Legacy: setChart(chart) — targets sources[0]. Backward-compatible. -static Napi::Value SetChart(const Napi::CallbackInfo& info) -{ - auto env = info.Env(); - auto liveEngine = snapshotEngine(); - if (!liveEngine || info.Length() < 1 || !info[0].IsObject()) - { - if (liveEngine) liveEngine->clearChart(); - return Napi::Boolean::New(env, false); - } - return setChartCore(env, info[0].As(), liveEngine->getSource(0)); -} - -// Source-indexed: setSourceChart(sourceId, chart). Bad id / payload -> false. -static Napi::Value SetSourceChart(const Napi::CallbackInfo& info) -{ - auto env = info.Env(); - auto liveEngine = snapshotEngine(); - if (!liveEngine || info.Length() < 2 || !info[0].IsNumber() || !info[1].IsObject()) - return Napi::Boolean::New(env, false); - SourceChain* target = getValidatedSource(liveEngine.get(), info, 0); - if (!target) return Napi::Boolean::New(env, false); - return setChartCore(env, info[1].As(), target); -} - -// Drain the verdicts the NoteVerifier thread has finalized since the last -// call. Returns an array of { id, detected, detectedSongTime, centsError, snr }. -// -// Optionally also pushes the renderer's playhead: getNoteVerdicts(songTime, -// playing). The plugin calls this once per detect tick, so folding the push in -// here advances the verifier's clock without a second IPC round-trip. A -// downlevel caller passing no args still just drains. -static Napi::Value GetNoteVerdicts(const Napi::CallbackInfo& info) -{ - auto env = info.Env(); - // Null (not an empty array) on a missing engine — the bridge/preload - // contract treats null as "unsupported/unavailable" so the renderer - // feature-detects, matching detectNotes' no-engine path. - auto liveEngine = snapshotEngine(); - if (!liveEngine) return env.Null(); - - // Push the playhead before draining so this tick's verdicts reflect it. - // A JS NaN/Infinity passes IsNumber() — guard with isfinite so a bad - // value can't corrupt the verifier's interpolated timing. - if (info.Length() >= 2 && info[0].IsNumber() && info[1].IsBoolean()) - { - const double songTime = info[0].As().DoubleValue(); - if (std::isfinite(songTime)) - liveEngine->setPlayhead(songTime, info[1].As().Value()); - } - - const auto verdicts = liveEngine->getNoteVerdicts(); - auto arr = Napi::Array::New(env, verdicts.size()); - for (size_t i = 0; i < verdicts.size(); ++i) - { - const auto& v = verdicts[i]; - auto entry = Napi::Object::New(env); - entry.Set("id", v.id); - entry.Set("detected", v.detected); - entry.Set("detectedSongTime", v.detectedSongTime); - entry.Set("centsError", v.centsError); - entry.Set("snr", v.snr); - arr.Set((uint32_t) i, entry); - } - return arr; -} - -// Sample rate the audio device is running at. Notedetect's chord scorer -// needs this to map FFT bins to Hz; on the bridge path there's no -// AudioContext to read it from. Falls back to 48000 if the engine isn't -// ready (matches the historical fallback in screen.js) — and also if -// the engine is initialized but no device is currently active, which -// pins currentSampleRate to 0 internally and would otherwise propagate -// a divide-by-zero into the renderer's FFT-bin→Hz math. -static Napi::Value GetSampleRate(const Napi::CallbackInfo& info) -{ - auto env = info.Env(); - constexpr double kFallbackSampleRate = 48000.0; - auto liveEngine = snapshotEngine(); - if (!liveEngine) - return Napi::Number::New(env, kFallbackSampleRate); - const double sr = liveEngine->getCurrentSampleRate(); - if (!std::isfinite(sr) || sr <= 0.0) - return Napi::Number::New(env, kFallbackSampleRate); - return Napi::Number::New(env, sr); -} - // ── VST Plugin Scanning ────────────────────────────────────────────────────── class ScanPluginsWorker : public Napi::AsyncWorker @@ -2021,1541 +317,8 @@ static Napi::Value SetVstCrashSentinelPath(const Napi::CallbackInfo& info) return env.Undefined(); } -// ── Signal Chain Management ────────────────────────────────────────────────── - -// Pending in-process loads: each LoadVSTWorker / LoadPresetWorker that's -// currently blocked on `done->wait()` registers its event here. doShutdown -// signals them all so the workers unblock and return a clean "cancelled" -// error instead of hanging forever when the JUCE message thread is about -// to be stopped (and any unfired callback would never arrive). -static std::mutex pendingLoadsMutex; -static std::set> pendingLoads; - -static void registerPendingLoad(std::shared_ptr evt) -{ - std::lock_guard lock(pendingLoadsMutex); - pendingLoads.insert(std::move(evt)); -} - -static void unregisterPendingLoad(const std::shared_ptr& evt) -{ - std::lock_guard lock(pendingLoadsMutex); - pendingLoads.erase(evt); -} - -static void cancelAllPendingLoads() -{ - std::lock_guard lock(pendingLoadsMutex); - for (auto& evt : pendingLoads) evt->signal(); - pendingLoads.clear(); -} - -// Load a VST3, routing it through the out-of-process sandbox when -// shouldSandbox() says so (the filename pre-seed or the runtime crash -// blocklist), otherwise loading it in-process. The in-process load uses -// VSTHost::loadPluginAsync so the JUCE message thread keeps pumping during -// the plugin's init — critical for plugins like AmpliTube that post WM_USER -// / WM_TIMER messages to themselves while initialising. The sync -// createPluginInstance would block the pump, those self-messages would -// queue forever, and the plugin would end up half-wired (a pointer that -// only gets written by a queued message stays null, and the editor crashes -// on its first WindowProc dispatch — the AmpliTube failure signature). -// -// Threading: on !JUCE_MAC must be called from a libuv worker thread (NOT -// the JS main thread, NOT the JUCE message thread) — the done->wait below -// has to be on a thread that *isn't* the one running JUCE's pump or the -// load can't complete. On JUCE_MAC the inline sync fallback is used and -// the caller can be the Node/main thread (which is also JUCE's message -// thread there); LoadVST does exactly that, while LoadPresetWorker still -// hits this from a worker (a pre-existing macOS limitation). -// -// On a *required*-sandbox failure (the plugin matched shouldSandbox but the -// sandbox couldn't spawn) this returns nullptr with `error` set and -// `sandboxRequired` true, so the caller can choose how to surface it — -// LoadVSTWorker throws to JS, LoadPresetWorker just skips the slot. -static std::unique_ptr loadVstSandboxAware( - const juce::String& pluginPath, double sr, int bs, - juce::String& error, bool& sandboxRequired) -{ - sandboxRequired = false; - - // A plugin persisted in a signal-chain preset can be uninstalled or - // deleted between runs. Instantiating a VST3 whose module is gone from - // disk faults deep inside the format loader (a stack-buffer-overrun / - // 0xC0000409 on Windows) and takes the whole app down on startup — before - // the crash blocklist or sandbox can ever intervene, because the preset is - // restored independently of those guards. A native access violation also - // can't be caught by the renderer's JS try/catch around loadPreset. So - // pre-flight a cheap existence check here, the single choke point shared by - // every load path (direct LoadVST and preset restore, in-process and - // sandboxed, all platforms), and fail soft when the file is missing. - // - // Only filesystem paths are judged: VST3/LV2 fileOrIdentifiers are absolute - // paths (File::exists covers both a .vst3 file and a bundle directory), - // whereas macOS AudioUnit identifiers ("AudioUnit:...") are not absolute - // paths and must not be rejected here. - if (juce::File::isAbsolutePath(pluginPath) && ! juce::File(pluginPath).exists()) - { - error = "Plugin file not found: " + pluginPath; - VST_TRACE("loadVstSandboxAware: missing plugin file '%s' — skipping load", - pluginPath.toRawUTF8()); - return nullptr; - } - - juce::PluginDescription probeDesc; - probeDesc.fileOrIdentifier = pluginPath; - probeDesc.name = juce::File(pluginPath).getFileNameWithoutExtension(); - - if (slopsmith::sandbox::shouldSandbox(probeDesc)) - { - sandboxRequired = true; - juce::String sandboxErr; - auto processor = slopsmith::sandbox::tryLoadSandboxed( - probeDesc, sr, bs, sandboxErr); - if (!processor) - { - error = "sandbox load failed: " - + (sandboxErr.isEmpty() ? juce::String("unknown error") - : sandboxErr); - VST_TRACE("loadVstSandboxAware: sandbox path declined/failed: %s", - sandboxErr.toRawUTF8()); - } - return processor; - } - - #if JUCE_MAC - // macOS has no separate JUCE message thread (see startJuceMessageThread / - // dispatchOnMessageThread): the JUCE MessageManager is bound to the - // Node/main thread, and dispatchOnMessageThread historically ran inline - // on the caller. A callAsync + done->wait pattern would queue a callback - // to a pump that may never run in this calling context. - // - // Fall back to the sync loadPlugin, executed on whichever thread called - // in — the Node/main thread for LoadVST's JUCE_MAC branch (correct: that - // *is* the MessageManager thread on macOS), or a libuv worker thread for - // LoadPresetWorker (the pre-existing macOS constraint). Caveat: the - // existing dispatchOnMessageThread block on macOS already documents - // that "VST/AU plugin instantiation (which genuinely requires a message - // thread on macOS) is the one capability we give up until a proper - // libuv-based pump lands." LoadPresetWorker has called loadVstSandbox- - // Aware on a worker thread for ages under exactly the same constraint; - // moving LoadVST to AsyncWorker brings direct loads under the same - // (pre-existing) limitation. The AmpliTube-class self-message problem - // this PR targets is Windows-specific (Electron owns the OS main - // thread, forcing JUCE's MessageManager onto a background thread that - // createPluginInstance then blocks); macOS doesn't have that mismatch. - auto host = snapshotVstHost(); - if (! host) { error = "vstHost not initialised"; return nullptr; } - juce::String err; - auto instance = host->loadPlugin(pluginPath, sr, bs, err); - if (! instance) error = err.isNotEmpty() ? err : juce::String("load failed"); - return instance; - #else - // In-process: kick off createPluginInstanceAsync on the message thread, - // block *this* (libuv worker) thread on a WaitableEvent until the load - // callback fires. The message thread keeps pumping during the wait so - // the plugin's self-posted init messages dispatch and its state finishes - // wiring up before the editor is ever opened. - // - // All state passed across the thread hop is held by shared_ptr so it - // outlives the lambda even on an unexpected destructor / scope exit. - auto instance = std::make_shared>(); - auto loadError = std::make_shared(); - auto done = std::make_shared(); - - // Register BEFORE scheduling so a shutdown that lands between callAsync - // and the wait below can't miss us — cancelAllPendingLoads would - // otherwise see an empty set and the worker would block forever. - registerPendingLoad(done); - - // Check alreadyShutDown after registering to catch the inverse race - // (shutdown ran before we registered): if it's already set, the - // shutdown won't see this event and we must bail ourselves. - if (alreadyShutDown.load(std::memory_order_acquire)) - { - unregisterPendingLoad(done); - error = "shutdown in flight"; - return nullptr; - } - - // Snapshot a shared_ptr to vstHost so the async load and its inner - // continuation can keep VSTHost (and thus formatManager) alive even if - // shutdown resets the global mid-load. The inner callback captures the - // same hostKeeper, so JUCE retains it until createPluginInstanceAsync - // completes; once the callback destructs, the keeper drops, and if the - // global has been reset by then the VSTHost destructor runs safely - // (no work in flight). The snapshot itself goes through vstHostMutex - // so the shared_ptr copy can't race with shutdown's vstHost.reset(). - auto hostKeeper = snapshotVstHost(); - - const bool scheduled = juce::MessageManager::callAsync( - [hostKeeper, pluginPath, sr, bs, instance, loadError, done]() - { - // Shutdown may have fired between callAsync queueing this - // lambda and the message thread picking it up. Bail before - // kicking off another in-flight createPluginInstanceAsync - // that the shutdown would otherwise have to wait on. - if (alreadyShutDown.load(std::memory_order_acquire)) - { - *loadError = "shutdown in flight"; - done->signal(); - return; - } - if (! hostKeeper) - { - *loadError = "vstHost not initialised"; - done->signal(); - return; - } - hostKeeper->loadPluginAsync( - pluginPath, sr, bs, - [hostKeeper, instance, loadError, done] - (std::unique_ptr inst, juce::String err) - { - *instance = std::move(inst); - *loadError = std::move(err); - done->signal(); - }); - }); - - if (! scheduled) - { - // The message queue is gone (typically: shutdown in flight). The - // lambda will never run, so done would never signal — surface the - // failure rather than hanging the worker forever. - unregisterPendingLoad(done); - error = "message manager unavailable (shutdown?)"; - return nullptr; - } - - // No timeout: createPluginInstanceAsync is genuinely async (the message - // thread keeps pumping), so a slow first-run plugin (e.g. one doing a - // license check that exceeds 15 s) is allowed to take however long it - // takes. The old 15-second timeout in dispatchOnMessageThread could - // return early while the lambda was still running, then the lambda - // would construct a fully-initialised plugin only for it to immediately - // destruct because no one held a reference — running VST teardown on - // the message thread while the user had already moved on. That race is - // gone with this design. - // - // Tradeoff: this call holds a libuv threadpool worker for the duration - // of the plugin's init. Multiple concurrent hung loads could in theory - // starve other AsyncWorkers (fs / crypto). In practice plugin loads are - // user-driven and serialised (LoadPresetWorker loads slots one at a - // time), and a truly stuck load is bounded by app shutdown via - // cancelAllPendingLoads. A proper "fire-and-forget with a TSFN - // completion callback" model would eliminate the block entirely but - // requires a bigger API restructure than this PR's scope. - done->wait(); - unregisterPendingLoad(done); - - // Distinguish "shutdown cancelled us before the callback fired" - // (instance null AND error empty) from a normal load failure (instance - // null with error set) and a normal success. - if (! *instance && loadError->isEmpty()) - { - error = "load cancelled (shutdown)"; - return nullptr; - } - error = *loadError; - return std::move(*instance); - #endif -} - -// AsyncWorker wrapper for LoadVST. Execute() runs on a libuv worker thread, -// so loadVstSandboxAware can block-wait on the async load without freezing -// the JS main thread or deadlocking the JUCE message thread. -class LoadVSTWorker : public Napi::AsyncWorker -{ -public: - LoadVSTWorker(Napi::Env env, Napi::Promise::Deferred deferred, std::string path) - : Napi::AsyncWorker(env) - , deferred_(deferred) - , pluginPath_(std::move(path)) {} - - void Execute() override - { - // Snapshot engine + vstHost through their mutex-protected helpers so - // shutdown's reset on the message thread can't race the worker's - // dereferences below. The shared_ptr locals keep both objects alive - // for the duration of this worker even if the globals get reset - // mid-load. The atomic alreadyShutDown gate is the early-out: once - // it's set, the dispatched reset is on its way and there's no point - // continuing. - if (alreadyShutDown.load(std::memory_order_acquire)) - { - error_ = "shutdown in flight"; - return; - } - auto engineKeeper = snapshotEngine(); - auto hostSnap = snapshotVstHost(); - if (!engineKeeper || !hostSnap) - { - error_ = "engine not initialised"; - return; - } - - const auto sr = loadSafeSampleRate(*engineKeeper); - const auto bs = loadSafeBlockSize(*engineKeeper); - const auto path = juce::String(pluginPath_); - VST_TRACE("LoadVSTWorker: path='%s' sr=%.0f bs=%d", - pluginPath_.c_str(), sr, bs); - - bool sandboxRequired = false; - juce::String err; - auto processor = loadVstSandboxAware(path, sr, bs, err, sandboxRequired); - - if (sandboxRequired && !processor) - { - // The plugin's on the denylist and the sandbox couldn't spawn — - // falling back to in-process is what crashed the addon to begin - // with. Surface as a JS exception (handled in OnOK). - fprintf(stderr, "[LoadVST] Failed: %s\n", err.toRawUTF8()); - error_ = err; - sandboxFailed_ = true; - return; - } - - if (!processor) - { - fprintf(stderr, "[LoadVST] Failed: %s\n", err.toRawUTF8()); - error_ = err; - return; - } - - // Engine may have been torn down while we were waiting on the async - // load. The shared_ptr captures keep `processor` alive; just don't - // touch a freed engine. The processor destructs cleanly when this - // scope exits. - // - // Gate on alreadyShutDown (atomic, properly synchronised) before the - // raw engine/vstHost pointer reads — once that flag is set, the - // dispatched reset of engine/vstHost is on its way and any use of - // the pointers from this worker thread is racy. The atomic check is - // the authoritative "should I still be touching engine?" signal. - if (alreadyShutDown.load(std::memory_order_acquire)) - { - error_ = "engine torn down during load"; - return; - } - // Re-snapshot the engine — the original engineKeeper might have - // outlived a reset on the message thread, but the AudioEngine - // we're about to mutate must be the still-installed one. If the - // global has been reset, the local keeps the old engine alive but - // we shouldn't be adding slots to it any more. - auto liveEngine = snapshotEngine(); - if (!liveEngine || !snapshotVstHost()) - { - error_ = "engine torn down during load"; - return; - } - - auto name = processor->getName(); - slotId_ = liveEngine->getSignalChain().addProcessor( - std::move(processor), - ProcessorSlot::Type::VST, - name, - path); - } - - void OnOK() override - { - if (sandboxFailed_) - { - // Match the prior LoadVST throw-on-required-sandbox-failure - // behaviour so renderers' try/catch keeps working. - deferred_.Reject( - Napi::Error::New(Env(), error_.toStdString()).Value()); - return; - } - deferred_.Resolve(Napi::Number::New(Env(), slotId_)); - } - - void OnError(const Napi::Error& e) override { deferred_.Reject(e.Value()); } - -private: - Napi::Promise::Deferred deferred_; - std::string pluginPath_; - int slotId_ = -1; - bool sandboxFailed_ = false; - juce::String error_; -}; - -static Napi::Value LoadVST(const Napi::CallbackInfo& info) -{ - auto env = info.Env(); - auto deferred = Napi::Promise::Deferred::New(env); - - if (!snapshotEngine() || !snapshotVstHost() || info.Length() < 1) - { - deferred.Resolve(Napi::Number::New(env, -1)); - return deferred.Promise(); - } - - auto pluginPath = info[0].As().Utf8Value(); - - #if JUCE_MAC - // On macOS the JUCE MessageManager is bound to the Node/main thread. - // Running this as an AsyncWorker would call vstHost->loadPlugin on a - // libuv worker thread, which JUCE documents as unsupported for VST/AU - // instantiation. Do the load synchronously on the Node/main thread - // (same as the pre-PR LoadVST) and return a resolved Promise to match - // the new signature. Pays the foreground-block cost the AsyncWorker - // path was supposed to avoid, but that's the existing macOS reality — - // dispatchOnMessageThread already runs inline there. The async-load - // motivation (AmpliTube blocking the background JUCE message thread - // under Electron) is a Windows-only problem. - // Snapshot once for the whole load so the same AudioEngine is used for - // the sr/bs reads and the addProcessor mutation, even if shutdown - // resets the global mid-call. - auto liveEngine = snapshotEngine(); - if (! liveEngine) - { - deferred.Resolve(Napi::Number::New(env, -1)); - return deferred.Promise(); - } - juce::String error; - bool sandboxRequired = false; - auto processor = loadVstSandboxAware( - juce::String(pluginPath), - loadSafeSampleRate(*liveEngine), - loadSafeBlockSize(*liveEngine), - error, sandboxRequired); - - if (sandboxRequired && !processor) - { - fprintf(stderr, "[LoadVST] Failed: %s\n", error.toRawUTF8()); - deferred.Reject( - Napi::Error::New(env, error.toStdString()).Value()); - return deferred.Promise(); - } - - int slotId = -1; - if (processor) - { - auto name = processor->getName(); - slotId = liveEngine->getSignalChain().addProcessor( - std::move(processor), - ProcessorSlot::Type::VST, - name, - juce::String(pluginPath)); - } - else - { - fprintf(stderr, "[LoadVST] Failed: %s\n", error.toRawUTF8()); - } - deferred.Resolve(Napi::Number::New(env, slotId)); - return deferred.Promise(); - #else - auto* worker = new LoadVSTWorker(env, deferred, std::move(pluginPath)); - worker->Queue(); - return deferred.Promise(); - #endif -} - -class LoadNAMWorker : public Napi::AsyncWorker -{ -public: - LoadNAMWorker(Napi::Env env, Napi::Promise::Deferred deferred, std::string path) - : Napi::AsyncWorker(env), deferred_(deferred), modelPath_(std::move(path)) {} - - void Execute() override - { - auto liveEngine = snapshotEngine(); - if (!liveEngine) { slotId_ = -1; return; } - - auto processor = std::make_unique(); - if (processor->loadModel(juce::File(juce::String(modelPath_)))) - { - auto name = processor->getModelName(); - slotId_ = liveEngine->getSignalChain().addProcessor( - std::move(processor), - ProcessorSlot::Type::NAM, - "NAM: " + name, - juce::String(modelPath_)); - } - } - - void OnOK() override { deferred_.Resolve(Napi::Number::New(Env(), slotId_)); } - void OnError(const Napi::Error& e) override { deferred_.Reject(e.Value()); } - -private: - Napi::Promise::Deferred deferred_; - std::string modelPath_; - int slotId_ = -1; -}; - -static Napi::Value LoadNAMModel(const Napi::CallbackInfo& info) -{ - auto env = info.Env(); - auto deferred = Napi::Promise::Deferred::New(env); - - if (!snapshotEngine() || info.Length() < 1) { - deferred.Resolve(Napi::Number::New(env, -1)); - return deferred.Promise(); - } - - auto modelPath = info[0].As().Utf8Value(); - auto worker = new LoadNAMWorker(env, deferred, modelPath); - worker->Queue(); - return deferred.Promise(); -} - -class LoadIRWorker : public Napi::AsyncWorker -{ -public: - LoadIRWorker(Napi::Env env, Napi::Promise::Deferred deferred, std::string path) - : Napi::AsyncWorker(env), deferred_(deferred), irPath_(std::move(path)) {} - - void Execute() override - { - auto liveEngine = snapshotEngine(); - if (!liveEngine) { slotId_ = -1; return; } - - const auto sr = loadSafeSampleRate(*liveEngine); - const auto bs = loadSafeBlockSize(*liveEngine); - auto processor = std::make_unique(); - processor->setPlayConfigDetails(2, 2, sr, bs); - processor->prepareToPlay(sr, bs); - if (processor->loadIR(juce::File(juce::String(irPath_)))) - { - auto name = processor->getIRName(); - slotId_ = liveEngine->getSignalChain().addProcessor( - std::move(processor), - ProcessorSlot::Type::IR, - "IR: " + name, - juce::String(irPath_)); - } - } - - void OnOK() override { deferred_.Resolve(Napi::Number::New(Env(), slotId_)); } - void OnError(const Napi::Error& e) override { deferred_.Reject(e.Value()); } - -private: - Napi::Promise::Deferred deferred_; - std::string irPath_; - int slotId_ = -1; -}; - -static Napi::Value LoadIR(const Napi::CallbackInfo& info) -{ - auto env = info.Env(); - auto deferred = Napi::Promise::Deferred::New(env); - - if (!snapshotEngine() || info.Length() < 1) { - deferred.Resolve(Napi::Number::New(env, -1)); - return deferred.Promise(); - } - - auto irPath = info[0].As().Utf8Value(); - auto worker = new LoadIRWorker(env, deferred, irPath); - worker->Queue(); - return deferred.Promise(); -} - -// Replace the IR of an EXISTING convolution slot in place (cab swap / mic move), -// so the rest of the chain — the amp VST above all — is NOT torn down and rebuilt. -// Mirrors LoadIRWorker but calls SignalChain::replaceProcessor(slotId, …) instead -// of addProcessor. Optional `gain` (>=0) updates the slot's post-gain (the cab -// makeup); a negative gain leaves the existing post-gain untouched. -class ReplaceIRWorker : public Napi::AsyncWorker -{ -public: - ReplaceIRWorker(Napi::Env env, Napi::Promise::Deferred deferred, - int slotId, std::string path, float gain) - : Napi::AsyncWorker(env), deferred_(deferred), - slotId_(slotId), irPath_(std::move(path)), gain_(gain) {} - - void Execute() override - { - auto liveEngine = snapshotEngine(); - if (!liveEngine) { ok_ = false; return; } - - const auto sr = loadSafeSampleRate(*liveEngine); - const auto bs = loadSafeBlockSize(*liveEngine); - auto processor = std::make_unique(); - processor->setPlayConfigDetails(2, 2, sr, bs); - processor->prepareToPlay(sr, bs); - if (! processor->loadIR(juce::File(juce::String(irPath_)))) { ok_ = false; return; } - - auto name = processor->getIRName(); - ok_ = liveEngine->getSignalChain().replaceProcessor( - slotId_, std::move(processor), - "IR: " + name, juce::String(irPath_)); - if (ok_ && gain_ >= 0.0f) - liveEngine->getSignalChain().setPostGain(slotId_, gain_); - } - - void OnOK() override { deferred_.Resolve(Napi::Boolean::New(Env(), ok_)); } - void OnError(const Napi::Error& e) override { deferred_.Reject(e.Value()); } - -private: - Napi::Promise::Deferred deferred_; - int slotId_; - std::string irPath_; - float gain_; - bool ok_ = false; -}; - -static Napi::Value ReplaceIR(const Napi::CallbackInfo& info) -{ - auto env = info.Env(); - auto deferred = Napi::Promise::Deferred::New(env); - - if (!snapshotEngine() || info.Length() < 2 - || !info[0].IsNumber() || !info[1].IsString()) { - deferred.Resolve(Napi::Boolean::New(env, false)); - return deferred.Promise(); - } - - const int slotId = info[0].As().Int32Value(); - const auto irPath = info[1].As().Utf8Value(); - const float gain = (info.Length() >= 3 && info[2].IsNumber()) - ? info[2].As().FloatValue() : -1.0f; - - auto worker = new ReplaceIRWorker(env, deferred, slotId, irPath, gain); - worker->Queue(); - return deferred.Promise(); -} - -static Napi::Value RemoveProcessor(const Napi::CallbackInfo& info) -{ - auto liveEngine = snapshotEngine(); - if (liveEngine && info.Length() > 0) - { - int slotId = info[0].As().Int32Value(); - liveEngine->getSignalChain().removeProcessor(slotId); - } - return info.Env().Undefined(); -} - -static Napi::Value MoveProcessor(const Napi::CallbackInfo& info) -{ - auto liveEngine = snapshotEngine(); - if (liveEngine && info.Length() >= 2) - { - int from = info[0].As().Int32Value(); - int to = info[1].As().Int32Value(); - liveEngine->getSignalChain().moveProcessor(from, to); - } - return info.Env().Undefined(); -} - -static Napi::Value SetBypass(const Napi::CallbackInfo& info) -{ - auto liveEngine = snapshotEngine(); - if (liveEngine && info.Length() >= 2) - { - int slotId = info[0].As().Int32Value(); - bool bypassed = info[1].As().Value(); - liveEngine->getSignalChain().setBypass(slotId, bypassed); - } - return info.Env().Undefined(); -} - -// Destroy every open in-process plugin editor window on the message thread and -// block until done. MUST run before any path that frees slot processors -// (ClearChain, LoadPreset's chain rebuild, engine teardown): an editor window -// owns an AudioProcessorEditor bound to its slot's processor, so if the -// processor is freed first the editor's next timer/paint callback dereferences -// freed memory (use-after-free → DEP-execute crash seconds after pause; -// feedBack-desktop#56). Defined below, after the editorWindows map. -static void closeAllPluginEditorWindows(); - -static Napi::Value ClearChain(const Napi::CallbackInfo& info) -{ - // Tear editors down before their processors are freed just below (#56). - closeAllPluginEditorWindows(); - if (auto liveEngine = snapshotEngine()) liveEngine->getSignalChain().clear(); - return info.Env().Undefined(); -} - -// Stereo routing (St-1). setPan(slotId, -1..+1); setBranch(slotId, 0=trunk/>=1). -static Napi::Value SetPan(const Napi::CallbackInfo& info) -{ - auto liveEngine = snapshotEngine(); - if (liveEngine && info.Length() >= 2) - { - int slotId = info[0].As().Int32Value(); - float pan = (float) info[1].As().DoubleValue(); - liveEngine->getSignalChain().setPan(slotId, pan); - } - return info.Env().Undefined(); -} - -static Napi::Value SetPostGain(const Napi::CallbackInfo& info) -{ - auto liveEngine = snapshotEngine(); - if (liveEngine && info.Length() >= 2) - { - int slotId = info[0].As().Int32Value(); - float gain = (float) info[1].As().DoubleValue(); - liveEngine->getSignalChain().setPostGain(slotId, gain); - } - return info.Env().Undefined(); -} - -static Napi::Value SetBranch(const Napi::CallbackInfo& info) -{ - auto liveEngine = snapshotEngine(); - if (liveEngine && info.Length() >= 2) - { - int slotId = info[0].As().Int32Value(); - int branch = info[1].As().Int32Value(); - liveEngine->getSignalChain().setBranch(slotId, branch); - } - return info.Env().Undefined(); -} - -// setBranchSrc(slotId, 0=both/1=L/2=R): channel a branch reads from the split. -static Napi::Value SetBranchSrc(const Napi::CallbackInfo& info) -{ - auto liveEngine = snapshotEngine(); - if (liveEngine && info.Length() >= 2) - { - int slotId = info[0].As().Int32Value(); - int src = info[1].As().Int32Value(); - liveEngine->getSignalChain().setBranchSrc(slotId, src); - } - return info.Env().Undefined(); -} - -// ── Chain State ─────────────────────────────────────────────────────────────── - -static Napi::Value GetChainState(const Napi::CallbackInfo& info) -{ - auto env = info.Env(); - auto result = Napi::Array::New(env); - auto liveEngine = snapshotEngine(); - - if (liveEngine) - { - auto slots = liveEngine->getSignalChain().getAllSlots(); - for (int i = 0; i < slots.size(); ++i) - { - auto obj = Napi::Object::New(env); - obj.Set("id", slots[i]->id); - obj.Set("type", (int)slots[i]->type); - obj.Set("name", slots[i]->name.toStdString()); - obj.Set("path", slots[i]->path.toStdString()); - obj.Set("bypassed", slots[i]->bypassed); - obj.Set("pan", slots[i]->pan); - obj.Set("branch", slots[i]->branch); - obj.Set("branchSrc", slots[i]->branchSrc); - obj.Set("postGain", slots[i]->postGain); - obj.Set("hasEditor", slots[i]->processor && slots[i]->processor->hasEditor()); - result.Set((uint32_t)i, obj); - } - } - - return result; -} - // ── Plugin Editor Window ────────────────────────────────────────────────────── -class PluginEditorWindow; -static std::map> editorWindows; - -class PluginEditorWindow : public juce::DocumentWindow -{ -public: - PluginEditorWindow(juce::AudioProcessorEditor* ed, const juce::String& title) - : DocumentWindow(title, juce::Colours::darkgrey, DocumentWindow::closeButton) - { - setContentOwned(ed, true); - setResizable(true, false); - setUsingNativeTitleBar(true); - centreWithSize(ed->getWidth(), ed->getHeight()); - setVisible(true); - toFront(true); - } - - void closeButtonPressed() override - { - // Remove from map so editor can be reopened - for (auto it = editorWindows.begin(); it != editorWindows.end(); ++it) - { - if (it->second.get() == this) - { - auto slotId = it->first; - juce::MessageManager::callAsync([slotId]() { - editorWindows.erase(slotId); - }); - break; - } - } - setVisible(false); - } -}; - -// Inline teardown: destroys every editor window. Caller MUST already be on the -// message thread (editorWindows holds JUCE GUI objects). Forward-declared near -// Init for doShutdown's use. -static void destroyAllPluginEditorWindowsOnMessageThread() -{ - // Fails fast in assertion-enabled builds if a caller violates the - // precondition. Compiled out here under -DJUCE_DISABLE_ASSERTIONS, so it is - // documentation + a debug-build tripwire, never runtime cost. - JUCE_ASSERT_MESSAGE_THREAD - editorWindows.clear(); -} - -// See the forward declaration above ClearChain for why this exists. Tears down -// the in-process editor windows so they are destroyed before the caller frees -// the processors those editors point at. Clearing an empty map is cheap, so -// calling this on every teardown is fine even when no editor is open. -// -// IMPORTANT: every caller runs on a MAIN-thread / message-thread context — -// ClearChain and LoadPreset are N-API calls on the Node thread, doShutdown uses -// the inline variant directly. This is NOT called from a libuv worker (that is -// why LoadPreset closes editors before queuing LoadPresetWorker, rather than -// letting the worker do it). Given that: -// - Already on the message thread (doShutdown; ClearChain / LoadPreset on -// macOS, where Node's main thread IS the JUCE message thread) → tear down -// inline; posting-and-waiting on ourselves would deadlock. -// - Otherwise (ClearChain / LoadPreset on Linux/Windows, where the JUCE -// message thread is a dedicated std::thread) → post to that thread and block -// until the editors are gone. Its 50ms dispatch loop drains this promptly, -// so there is no macOS-style stall here. Report a refused post / wait -// timeout so a lingering-editor UAF stays diagnosable. -static void closeAllPluginEditorWindows() -{ - auto* mm = juce::MessageManager::getInstanceWithoutCreating(); - if (mm != nullptr && mm->isThisTheMessageThread()) - { - destroyAllPluginEditorWindowsOnMessageThread(); - return; - } - - auto done = std::make_shared(); - const bool posted = juce::MessageManager::callAsync([done]() - { - destroyAllPluginEditorWindowsOnMessageThread(); - done->signal(); - }); - if (!posted) - { - fprintf(stderr, "[audio-native] closeAllPluginEditorWindows: message queue refused the post; " - "editors may briefly outlive their processors\n"); - return; - } - if (!done->wait(15000)) - fprintf(stderr, "[audio-native] closeAllPluginEditorWindows: editor teardown did not complete " - "within 15s; proceeding\n"); -} - -static Napi::Value OpenPluginEditor(const Napi::CallbackInfo& info) -{ - auto env = info.Env(); - auto liveEngine = snapshotEngine(); - if (!liveEngine || info.Length() < 1) - return Napi::Boolean::New(env, false); - - int slotId = info[0].As().Int32Value(); - - auto slot = liveEngine->getSignalChain().getSlot(slotId); - if (!slot || !slot->processor || !slot->processor->hasEditor()) - return Napi::Boolean::New(env, false); - - // Sandboxed plugins: the editor is a top-level window owned by the - // sandbox child process. No host-side PluginEditorWindow and no - // cross-process SetParent reparent — that path produced a blank - // rendered surface for D3D / OpenGL plugins (Neural DSP Archetypes, - // etc.) because their render context lives in the child. The child's - // kOpenEditor handler brings the existing window to front on a repeat - // click, so re-entry is cheap and we don't track host-side state. - // - // Dispatch off the N-API call thread: requestOpenEditor() uses a - // blocking control->request (kDefaultReplyTimeoutMs = 10s), which on - // a slow or hung sandbox would otherwise stall V8's JS thread for - // the full timeout. Capture slotId rather than a raw processor - // pointer and re-resolve inside the message-thread lambda — that - // closes a UAF window where the slot could be removed (or the engine - // torn down) between this call returning and the async firing. - // Return optimistically; matches the in-process path below. - // - // SandboxedProcessor is compiled on all desktop platforms now (the POSIX - // sandbox runtime is active — see src/audio/CMakeLists.txt), so the - // editor-open IPC routes to the sandbox child on macOS/Linux too. The - // child owns a floating editor window (Reaper-style); the host only tracks - // the open/closed bit. -#if defined(SLOPSMITH_AUDIO_ADDON) - if (auto* sb = dynamic_cast(slot->processor.get())) - { - // Synchronous gate: if the sandbox child is already gone (crashed - // or shut down) there's no point scheduling the IPC. Return false - // so the renderer can surface "editor unavailable" rather than - // toggling its UI into a fake-open state that no event will ever - // contradict. hasEditor() above already gated on isAlive() but a - // crash between then and now is possible — re-check here. - if (!sb->isAlive()) - return Napi::Boolean::New(env, false); - const bool queued = juce::MessageManager::callAsync([slotId]() - { - auto liveEngine = snapshotEngine(); - if (!liveEngine) return; - if (auto* slot = liveEngine->getSignalChain().getSlot(slotId)) - if (auto* sb = dynamic_cast(slot->processor.get())) - sb->requestOpenEditor(); - }); - if (!queued) - { - // Message queue refused the post — typically only during - // shutdown. Surface the failure so the renderer doesn't - // toggle its UI into a fake-open state. - return Napi::Boolean::New(env, false); - } - return Napi::Boolean::New(env, true); - } -#endif - - // In-process plugin — host-side PluginEditorWindow flow. If a window - // already exists for this slot, bring it to front rather than creating - // a duplicate. - auto it = editorWindows.find(slotId); - if (it != editorWindows.end() && it->second) - { - if (it->second->isVisible()) - { - it->second->toFront(true); - return Napi::Boolean::New(env, true); - } - // Window was hidden/closed, remove stale entry - editorWindows.erase(it); - } - - // Create editor on the message thread. Capture slotId only — re-resolve - // the slot via snapshotEngine() + getSlot(slotId) inside the lambda so a - // SignalChain::removeProcessor() between this call returning and the - // async firing can't leave us calling createEditorAndMakeActive() on a - // dangling juce::AudioProcessor*. Mirrors the sandbox branch's pattern. - const bool queued = juce::MessageManager::callAsync([slotId]() - { - auto liveEngine = snapshotEngine(); - if (!liveEngine) return; - auto& chain = liveEngine->getSignalChain(); - auto* slot = chain.getSlot(slotId); - if (!slot || !slot->processor) return; - - // ── Windows editor-crash class fix ─────────────────────────────────── - // An in-process VST3 editor is created on JUCE's BACKGROUND message - // thread (V8 owns the OS main thread inside a Node addon). On Windows a - // Qt-using / window-on-init plugin then faults via USER32->WndProc on - // WM_ACTIVATEAPP with NO host frame on the stack, so the SignalChain SEH - // guard can't catch it and the whole app dies (0xC0000005 / 0xC0000409). - // Fix: never open a VST3 editor in-process on Windows — promote the slot - // to the out-of-process sandbox (which hosts the editor on a real - // top-level message thread, the environment the plugin needs) and open - // it there. Compiled on every platform so the swap path keeps building; - // gated to Windows at runtime since the in-process editor is fine on - // macOS/Linux (no WndProc) and the sandbox hop is pure overhead there. - static constexpr bool kPromoteEditorToSandbox = - #if JUCE_WINDOWS - true; - #else - false; - #endif - if (kPromoteEditorToSandbox) - { - // Decide + snapshot state SAFELY. captureVstStateForPromotion runs - // hasEditor()/getStateInformation() under the audio lock and the SEH - // guard (see its contract), so they neither race process()'s - // processBlock nor fault the app — an UNguarded getStateInformation on - // the very plugins this promotion targets would reintroduce the editor - // crash on the message thread. It returns true only for a non-sandboxed - // in-process VST3 that actually has an editor. - juce::MemoryBlock state; - if (chain.captureVstStateForPromotion(slotId, state)) - { - const juce::String path = slot->path; // immutable; message-thread only - fprintf(stderr, "[AudioEngine] editor-open: promoting in-process VST3 to sandbox: slot %d '%s'\n", - slotId, path.toRawUTF8()); - - juce::PluginDescription desc; - desc.fileOrIdentifier = path; - desc.name = juce::File(path).getFileNameWithoutExtension(); - - // tryLoadSandboxed only accepts a plugin that shouldSandbox() - // approves, so pin this path to the runtime sandbox list first. - // Remember whether it was ALREADY pinned: if the promotion fails - // we undo only OUR pin below, so a healthy, never-crashed plugin - // isn't left permanently forced to a sandbox that just proved - // unavailable (while a pre-existing/real blocklist entry stays). - const bool wasAlreadyPinned = slopsmith::sandbox::isCrashedPlugin(path); - slopsmith::sandbox::addCrashedPlugin(path); - - bool promoted = false; - juce::String err; - auto sandboxed = slopsmith::sandbox::tryLoadSandboxed( - desc, chain.getCurrentSampleRate(), chain.getCurrentBlockSize(), err); - if (sandboxed) - { - if (state.getSize() > 0) - sandboxed->setStateInformation(state.getData(), (int) state.getSize()); - if (chain.replaceProcessor(slotId, std::move(sandboxed))) - { - promoted = true; - bool editorOpened = false; - if (auto* slot2 = chain.getSlot(slotId)) - if (auto* sb = dynamic_cast(slot2->processor.get())) - editorOpened = sb->requestOpenEditor(); - fprintf(stderr, "[AudioEngine] editor-open: sandbox promotion OK for slot %d (editor %s)\n", - slotId, editorOpened ? "opened" : "FAILED to open"); - } - else - { - fprintf(stderr, "[AudioEngine] editor-open: replaceProcessor failed for slot %d\n", slotId); - } - } - else - { - fprintf(stderr, "[AudioEngine] editor-open: sandbox promotion failed for '%s': %s\n", - path.toRawUTF8(), err.toRawUTF8()); - } - - // Undo our transient pin on failure so a plugin that never crashed - // isn't stranded on the (evidently unavailable) sandbox route. - if (! promoted && ! wasAlreadyPinned) - slopsmith::sandbox::removeCrashedPlugin(path); - - // Promoted or not, never fall through to the in-process editor on - // Windows — that is the WndProc/Qt crash path this branch exists - // to avoid. - return; - } - // Not promotable (non-VST / editor-less / already-sandboxed, or the - // guarded capture faulted and released the processor). Fall through to - // the in-process branch below, which is safe for all of those cases - // (an already-sandboxed slot opens its editor out-of-process; an - // editor-less or released processor simply opens no window). - } - - // In-process editor: non-VST3, editor-less, already-sandboxed, or POSIX - // (where the in-process editor is safe). - auto* processor = slot->processor.get(); - auto name = slot->name; - juce::AudioProcessorEditor* editor = nullptr; - try { - editor = processor->createEditorAndMakeActive(); - } catch (const std::exception& e) { - fprintf(stderr, "[AudioEngine] createEditorAndMakeActive crashed for '%s': %s\n", name.toRawUTF8(), e.what()); - } catch (...) { - fprintf(stderr, "[AudioEngine] createEditorAndMakeActive crashed for '%s': unknown error\n", name.toRawUTF8()); - } - if (editor) - { - editorWindows[slotId] = std::make_unique(editor, name); - fprintf(stderr, "[AudioEngine] Opened editor for slot %d: %s (%dx%d)\n", - slotId, name.toRawUTF8(), editor->getWidth(), editor->getHeight()); - } - }); - - return Napi::Boolean::New(env, queued); -} - -static Napi::Value ClosePluginEditor(const Napi::CallbackInfo& info) -{ - auto env = info.Env(); - if (info.Length() < 1) return Napi::Boolean::New(env, false); - - int slotId = info[0].As().Int32Value(); - - // Sandboxed plugins: route the close to the sandbox child via IPC. - // No host-side PluginEditorWindow exists for these. - // - // Same shape as the open path: dispatch off the N-API thread and - // re-resolve the slot inside the lambda. requestCloseEditor() - // ultimately writes to the control pipe (writeFrame can block up - // to ~5s on a stalled reader), so running it synchronously here - // would freeze JS / the renderer UI on a slow sandbox; the - // re-resolve guards against slot-removal UAF between the napi call - // and the async firing. - // - // All desktop platforms: route the close to the sandbox child via IPC - // (SandboxedProcessor is compiled everywhere now). In-process plugins fall - // through to the host-side editor-window teardown below. -#if defined(SLOPSMITH_AUDIO_ADDON) - if (auto liveEngine = snapshotEngine()) - { - if (auto* slot = liveEngine->getSignalChain().getSlot(slotId)) - { - if (slot->processor - && dynamic_cast(slot->processor.get())) - { - const bool queued = juce::MessageManager::callAsync([slotId]() - { - auto liveEngine = snapshotEngine(); - if (!liveEngine) return; - if (auto* slot = liveEngine->getSignalChain().getSlot(slotId)) - if (auto* sb = dynamic_cast(slot->processor.get())) - sb->requestCloseEditor(); - }); - return Napi::Boolean::New(env, queued); - } - } - } -#endif - - // In-process plugin — tear down the host-side editor window. - auto it = editorWindows.find(slotId); - if (it != editorWindows.end()) - { - juce::MessageManager::callAsync([slotId]() - { - editorWindows.erase(slotId); - }); - return Napi::Boolean::New(env, true); - } - return Napi::Boolean::New(env, false); -} - -// ── Parameters ──────────────────────────────────────────────────────────────── - -static Napi::Value GetParameters(const Napi::CallbackInfo& info) -{ - auto env = info.Env(); - auto liveEngine = snapshotEngine(); - if (!liveEngine || info.Length() < 1) return Napi::Array::New(env); - - int slotId = info[0].As().Int32Value(); - auto params = liveEngine->getSignalChain().getParameters(slotId); - auto result = Napi::Array::New(env, params.size()); - - for (int i = 0; i < params.size(); ++i) - { - auto obj = Napi::Object::New(env); - obj.Set("index", params[i].index); - obj.Set("name", params[i].name.toStdString()); - obj.Set("value", params[i].value); - obj.Set("label", params[i].label.toStdString()); - obj.Set("text", params[i].text.toStdString()); - result.Set((uint32_t)i, obj); - } - - return result; -} - -static Napi::Value SetParameter(const Napi::CallbackInfo& info) -{ - auto liveEngine = snapshotEngine(); - if (liveEngine && info.Length() >= 3) - { - int slotId = info[0].As().Int32Value(); - int paramIdx = info[1].As().Int32Value(); - float value = info[2].As().FloatValue(); - liveEngine->getSignalChain().setParameter(slotId, paramIdx, value); - } - return info.Env().Undefined(); -} - -// Restore a VST slot's full state from a base64 getStateInformation() blob. -static Napi::Value SetSlotState(const Napi::CallbackInfo& info) -{ - // Type-guard both args (NAPI_DISABLE_CPP_EXCEPTIONS): a malformed IPC - // payload is a clean no-op rather than a hard addon failure. - auto liveEngine = snapshotEngine(); - if (liveEngine && info.Length() >= 2 && info[0].IsNumber() && info[1].IsString()) - { - int slotId = info[0].As().Int32Value(); - auto base64 = info[1].As().Utf8Value(); - const auto* slot = liveEngine->getSignalChain().getSlot(slotId); - const bool allowStandard = slot != nullptr - && (slot->type == ProcessorSlot::Type::IR - || slot->type == ProcessorSlot::Type::NAM); - juce::MemoryBlock mb; - if (decodeStateBlob(juce::String(base64), mb, allowStandard)) - liveEngine->getSignalChain().setSlotState(slotId, mb); - } - return info.Env().Undefined(); -} - -// ── MIDI ────────────────────────────────────────────────────────────────────── - -static Napi::Value SendMidiToSlot(const Napi::CallbackInfo& info) -{ - auto env = info.Env(); - auto liveEngine = snapshotEngine(); - if (!liveEngine || info.Length() < 4) - return Napi::Boolean::New(env, false); - - int slotId = info[0].As().Int32Value(); - int msgType = info[1].As().Int32Value(); - int channel = info[2].As().Int32Value(); - - juce::MidiMessage midiMsg; - if (msgType == 0) // Program Change - { - int program = info[3].As().Int32Value(); - midiMsg = juce::MidiMessage::programChange(channel, program); - } - else if (msgType == 1) // Control Change - { - int controller = info[3].As().Int32Value(); - int value = info.Length() > 4 ? info[4].As().Int32Value() : 0; - midiMsg = juce::MidiMessage::controllerEvent(channel, controller, value); - } - else - return Napi::Boolean::New(env, false); - - liveEngine->getSignalChain().queueMidiMessage(slotId, midiMsg); - return Napi::Boolean::New(env, true); -} - -// ── Backing Track ───────────────────────────────────────────────────────────── - -static Napi::Value LoadBackingTrack(const Napi::CallbackInfo& info) -{ - auto env = info.Env(); - auto liveEngine = snapshotEngine(); - if (!liveEngine || info.Length() < 1) return Napi::Boolean::New(env, false); - - auto path = info[0].As().Utf8Value(); - bool result = liveEngine->loadBackingTrack(juce::File(juce::String(path))); - return Napi::Boolean::New(env, result); -} - -static Napi::Value StartBacking(const Napi::CallbackInfo& info) -{ - if (auto liveEngine = snapshotEngine()) liveEngine->startBacking(); - return info.Env().Undefined(); -} - -static Napi::Value StopBacking(const Napi::CallbackInfo& info) -{ - if (auto liveEngine = snapshotEngine()) liveEngine->stopBacking(); - return info.Env().Undefined(); -} - -static Napi::Value SeekBacking(const Napi::CallbackInfo& info) -{ - auto liveEngine = snapshotEngine(); - if (liveEngine && info.Length() > 0) - liveEngine->setBackingPosition(info[0].As().DoubleValue()); - return info.Env().Undefined(); -} - -static Napi::Value GetBackingPosition(const Napi::CallbackInfo& info) -{ - auto liveEngine = snapshotEngine(); - double pos = liveEngine ? liveEngine->getBackingPosition() : 0.0; - return Napi::Number::New(info.Env(), pos); -} - -static Napi::Value GetBackingDuration(const Napi::CallbackInfo& info) -{ - auto liveEngine = snapshotEngine(); - double dur = liveEngine ? liveEngine->getBackingDuration() : 0.0; - return Napi::Number::New(info.Env(), dur); -} - -static Napi::Value IsBackingPlaying(const Napi::CallbackInfo& info) -{ - auto liveEngine = snapshotEngine(); - bool playing = liveEngine ? liveEngine->isBackingPlaying() : false; - return Napi::Boolean::New(info.Env(), playing); -} - -static Napi::Value SetBackingSpeed(const Napi::CallbackInfo& info) -{ - auto env = info.Env(); - if (info.Length() < 1 || !info[0].IsNumber()) - { - Napi::TypeError::New(env, "setBackingSpeed(speed) requires a number") - .ThrowAsJavaScriptException(); - return env.Undefined(); - } - if (engine) - engine->setBackingSpeed(info[0].As().DoubleValue()); - return env.Undefined(); -} - -// ── Presets ─────────────────────────────────────────────────────────────────── - -static Napi::Value SavePreset(const Napi::CallbackInfo& info) -{ - auto env = info.Env(); - auto liveEngine = snapshotEngine(); - if (!liveEngine) return env.Null(); - auto json = liveEngine->getSignalChain().savePreset(); - return Napi::String::New(env, json.toStdString()); -} - -class LoadPresetWorker : public Napi::AsyncWorker -{ -public: - LoadPresetWorker(Napi::Env env, Napi::Promise::Deferred deferred, std::string json) - : Napi::AsyncWorker(env), deferred_(deferred), presetJson_(std::move(json)) {} - - void Execute() override - { - auto liveEngine = snapshotEngine(); - if (!liveEngine) { success_ = false; error_ = "No engine"; return; } - - auto parsed = juce::JSON::parse(juce::String(presetJson_)); - if (!parsed.isObject()) { success_ = false; error_ = "Invalid JSON"; return; } - - auto* root = parsed.getDynamicObject(); - if (!root) { success_ = false; error_ = "Invalid preset"; return; } - - auto chainVar = root->getProperty("chain"); - auto* chainArray = chainVar.getArray(); - if (!chainArray) { success_ = false; error_ = "No chain array"; return; } - - // NB: any open in-process editor windows were already torn down on the - // message thread by LoadPreset() before this AsyncWorker was queued (see - // there) — so clearing the chain here can't leave an editor pointing at - // a freed processor (use-after-free; #56). We deliberately do NOT tear - // editors down from this worker thread: JUCE GUI objects must only be - // destroyed on the message thread, and macOS has no pump to marshal to - // from here. - // Clear existing chain - liveEngine->getSignalChain().clear(); - - double sr = loadSafeSampleRate(*liveEngine); - int bs = loadSafeBlockSize(*liveEngine); - - for (auto& slotVar : *chainArray) - { - auto* slotObj = slotVar.getDynamicObject(); - if (!slotObj) continue; - - int type = (int)slotObj->getProperty("type"); - auto name = slotObj->getProperty("name").toString(); - auto path = slotObj->getProperty("path").toString(); - bool bypassed = (bool)slotObj->getProperty("bypassed"); - auto stateB64 = slotObj->getProperty("state").toString(); - - std::unique_ptr processor; - - if (type == (int)ProcessorSlot::Type::VST && snapshotVstHost()) - { - // Sandbox-aware load: a crash-blocklisted plugin restored - // from a preset must still go out-of-process, otherwise the - // "one crash, then always sandbox" contract is defeated. - juce::String err; - bool sandboxRequired = false; - processor = loadVstSandboxAware(path, sr, bs, err, sandboxRequired); - if (!processor) - { - fprintf(stderr, "[LoadPreset] VST load failed: %s (%s)\n", - name.toRawUTF8(), err.toRawUTF8()); - continue; - } - } - else if (type == (int)ProcessorSlot::Type::NAM) - { - auto nam = std::make_unique(); - if (!nam->loadModel(juce::File(path))) - { - fprintf(stderr, "[LoadPreset] NAM load failed: %s\n", path.toRawUTF8()); - continue; - } - processor = std::move(nam); - } - else if (type == (int)ProcessorSlot::Type::IR) - { - auto ir = std::make_unique(); - ir->setPlayConfigDetails(2, 2, sr, bs); - ir->prepareToPlay(sr, bs); - if (!ir->loadIR(juce::File(path))) - { - fprintf(stderr, "[LoadPreset] IR load failed: %s\n", path.toRawUTF8()); - continue; - } - processor = std::move(ir); - } - else continue; - - int slotId = liveEngine->getSignalChain().addProcessor( - std::move(processor), - (ProcessorSlot::Type)type, - name, path); - - if (bypassed && slotId >= 0) - liveEngine->getSignalChain().setBypass(slotId, true); - - // Stereo routing (St-1). Absent keys read back as 0 (= default), so - // mono presets restore exactly as before. - if (slotId >= 0) - { - if (slotObj->hasProperty("pan")) - liveEngine->getSignalChain().setPan(slotId, (float)(double)slotObj->getProperty("pan")); - if (slotObj->hasProperty("branch")) - liveEngine->getSignalChain().setBranch(slotId, (int)slotObj->getProperty("branch")); - if (slotObj->hasProperty("postGain")) - liveEngine->getSignalChain().setPostGain(slotId, (float)(double)slotObj->getProperty("postGain")); - if (slotObj->hasProperty("branchSrc")) - liveEngine->getSignalChain().setBranchSrc(slotId, (int)slotObj->getProperty("branchSrc")); - } - - // Restore processor state (JUCE-format base64; IR/NAM slots also - // accept standard base64 — see decodeStateBlob: their plugin- - // emitted JSON states were silently dropped before, so IR stages - // never got their per-stage gain). - if (stateB64.isNotEmpty() && slotId >= 0) - { - const bool allowStandard = type == (int)ProcessorSlot::Type::IR - || type == (int)ProcessorSlot::Type::NAM; - juce::MemoryBlock state; - if (decodeStateBlob(stateB64, state, allowStandard)) - { - auto* slot = const_cast(liveEngine->getSignalChain().getSlot(slotId)); - if (slot) slot->setState(state); - } - } - - slotsLoaded_++; - } - - success_ = true; - } - - void OnOK() override - { - auto obj = Napi::Object::New(Env()); - obj.Set("success", success_); - obj.Set("slotsLoaded", slotsLoaded_); - if (!success_) obj.Set("error", error_); - deferred_.Resolve(obj); - } - void OnError(const Napi::Error& e) override { deferred_.Reject(e.Value()); } - -private: - Napi::Promise::Deferred deferred_; - std::string presetJson_; - bool success_ = false; - std::string error_; - int slotsLoaded_ = 0; -}; - -static Napi::Value LoadPreset(const Napi::CallbackInfo& info) -{ - auto env = info.Env(); - auto deferred = Napi::Promise::Deferred::New(env); - auto liveEngine = snapshotEngine(); - - if (!liveEngine || info.Length() < 1) { - auto obj = Napi::Object::New(env); - obj.Set("success", false); - obj.Set("error", "No engine or missing argument"); - deferred.Resolve(obj); - return deferred.Promise(); - } - - // Tear down any open in-process editor windows NOW, on the N-API/main - // thread, before the AsyncWorker frees the chain's processors on a libuv - // worker (#56). Doing it here — not inside LoadPresetWorker::Execute — keeps - // JUCE GUI teardown off the worker thread: on macOS this thread IS the - // message thread (inline teardown); on Linux/Windows closeAllPluginEditor- - // Windows() posts to the dedicated JUCE message thread and blocks. Either - // way editors are destroyed before Execute() clears the chain. - closeAllPluginEditorWindows(); - - auto json = info[0].As().Utf8Value(); - auto worker = new LoadPresetWorker(env, deferred, json); - worker->Queue(); - return deferred.Promise(); -} - -static Napi::Value SetMultiBypass(const Napi::CallbackInfo& info) -{ - auto env = info.Env(); - auto liveEngine = snapshotEngine(); - if (!liveEngine || info.Length() < 1 || !info[0].IsArray()) - return Napi::Boolean::New(env, false); - - auto arr = info[0].As(); - juce::Array> changes; - - for (uint32_t i = 0; i < arr.Length(); i++) - { - auto item = arr.Get(i).As(); - int slotId = item.Get("slotId").As().Int32Value(); - bool bypassed = item.Get("bypassed").As().Value(); - changes.add({ slotId, bypassed }); - } - - liveEngine->getSignalChain().setMultiBypass(changes); - return Napi::Boolean::New(env, true); -} - -// ── Debug file logging ──────────────────────────────────────────────────────── - -// Redirect the C runtime's stderr stream to a file so the native -// [AudioEngine] / [audio-native] diagnostics are captured for a bug report on -// machines with no console (packaged Windows builds). Only invoked when -// SLOPSMITH_DEBUG is set. Returns "" on success, or an error description the -// JS layer logs as an [audio] line. -// -// freopen (not dup2): a packaged GUI-subsystem app has no console, so stderr -// has no valid fd — dup2 onto fileno(stderr) fails. freopen reassigns the -// stream itself and works with or without a console. freopen would close -// stderr before trying the path, so a bad path is ruled out FIRST with a -// throwaway fopen probe (which never touches stderr); only once the path is -// known-writable do we freopen. Append mode so the JS layer's header -// survives; unbuffered so a crash leaves a complete tail. -static Napi::Value EnableFileLogging(const Napi::CallbackInfo& info) -{ - auto env = info.Env(); - if (info.Length() < 1 || !info[0].IsString()) - { - Napi::TypeError::New(env, "enableFileLogging(path) requires a string") - .ThrowAsJavaScriptException(); - return env.Undefined(); - } - -#if defined(_WIN32) - // Widen UTF-16 → wchar_t by value-converting each code unit (not a - // reinterpret_cast — char16_t and wchar_t are distinct types even though - // both are 16-bit on Windows). Wide path so a profile dir with non-ASCII - // characters isn't mangled by the ANSI codepage (cf. src/vst-host/main.cpp, - // which uses the GetEnvironmentVariableW / _wfopen wide path for the same - // reason). - const std::u16string u16 = info[0].As().Utf16Value(); - const std::wstring wpath(u16.begin(), u16.end()); - FILE* probe = _wfopen(wpath.c_str(), L"a"); -#else - const std::string path = info[0].As().Utf8Value(); - FILE* probe = std::fopen(path.c_str(), "a"); -#endif - if (probe == nullptr) - { - // Capture errno before Napi::String::New / std::to_string, which may - // call library code that clobbers it. - const int e = errno; - return Napi::String::New(env, std::string("fopen failed (errno=") - + std::to_string(e) + ")"); - } - std::fclose(probe); // path is writable; stderr never touched on this path - -#if defined(_WIN32) - FILE* fp = _wfreopen(wpath.c_str(), L"a", stderr); -#else - FILE* fp = std::freopen(path.c_str(), "a", stderr); -#endif - if (fp == nullptr) - { - const int e = errno; - // freopen closes stderr before trying the path; on failure it's left - // closed. The probe just verified the path, so this is near-impossible - // — but redirect stderr to the null device so it's a valid sink rather - // than a closed stream that could trip later fprintf(stderr) calls. -#if defined(_WIN32) - std::freopen("NUL", "w", stderr); -#else - std::freopen("/dev/null", "w", stderr); -#endif - return Napi::String::New(env, std::string("freopen failed (errno=") - + std::to_string(e) + ")"); - } - - // Unbuffered: each [AudioEngine] fprintf hits disk immediately, so a - // crash mid-reconfigure still leaves the diagnostic line that explains it. - std::setvbuf(stderr, nullptr, _IONBF, 0); - std::fprintf(stderr, "[audio-native] file logging enabled\n"); - return Napi::String::New(env, ""); // empty = success -} - // ── Module Registration ─────────────────────────────────────────────────────── static Napi::Object InitModule(Napi::Env env, Napi::Object exports) @@ -3587,6 +350,10 @@ static Napi::Object InitModule(Napi::Env env, Napi::Object exports) exports.Set("setInputChannel", Napi::Function::New(env, SetInputChannel)); exports.Set("setMonitorMute", Napi::Function::New(env, SetMonitorMute)); exports.Set("setMonitorMuteSuppressed", Napi::Function::New(env, SetMonitorMuteSuppressed)); + exports.Set("acquireMonitorMuteHold", Napi::Function::New(env, AcquireMonitorMuteHold)); + exports.Set("releaseMonitorMuteHold", Napi::Function::New(env, ReleaseMonitorMuteHold)); + exports.Set("getMonitorMuteState", Napi::Function::New(env, GetMonitorMuteState)); + exports.Set("getLatencyBreakdown", Napi::Function::New(env, GetLatencyBreakdown)); exports.Set("isMonitorMuted", Napi::Function::New(env, IsMonitorMuted)); exports.Set("setMonitorKill", Napi::Function::New(env, SetMonitorKill)); exports.Set("setNoiseGate", Napi::Function::New(env, SetNoiseGate)); @@ -3662,6 +429,7 @@ static Napi::Object InitModule(Napi::Env env, Napi::Object exports) exports.Set("setBranchSrc", Napi::Function::New(env, SetBranchSrc)); exports.Set("clearChain", Napi::Function::New(env, ClearChain)); exports.Set("getChainState", Napi::Function::New(env, GetChainState)); + exports.Set("getChainGeneration", Napi::Function::New(env, GetChainGeneration)); exports.Set("openPluginEditor", Napi::Function::New(env, OpenPluginEditor)); exports.Set("closePluginEditor", Napi::Function::New(env, ClosePluginEditor)); diff --git a/src/audio/SignalChain.cpp b/src/audio/SignalChain.cpp index 8f85c8a..e718e2e 100644 --- a/src/audio/SignalChain.cpp +++ b/src/audio/SignalChain.cpp @@ -700,12 +700,30 @@ const ProcessorSlot* SignalChain::getSlot(int slotId) const return idx >= 0 ? slots[idx] : nullptr; } -juce::Array SignalChain::getAllSlots() const +std::vector SignalChain::getSlotSummaries() const { - juce::Array result; + std::vector result; const juce::ScopedLock sl(lock); + result.reserve((size_t) slots.size()); for (auto* slot : slots) - result.add(slot); + { + SlotSummary s; + s.id = slot->id; + s.type = (int) slot->type; + s.name = slot->name; + s.path = slot->path; + s.bypassed = slot->bypassed; + s.pan = slot->pan; + s.branch = slot->branch; + s.branchSrc = slot->branchSrc; + s.postGain = slot->postGain; + // Safe under `lock`: clear() detaches the slots under the same lock + // before destroying them, so a slot reachable here cannot be freed + // mid-call. The RT thread uses a ScopedTryLock, so holding it for this + // metadata copy never blocks the audio callback. + s.hasEditor = slot->processor != nullptr && slot->processor->hasEditor(); + result.push_back(std::move(s)); + } return result; } diff --git a/src/audio/SignalChain.h b/src/audio/SignalChain.h index 77e04a9..e04ee64 100644 --- a/src/audio/SignalChain.h +++ b/src/audio/SignalChain.h @@ -2,6 +2,7 @@ #include #include #include +#include // Represents a single processor slot in the signal chain. // Can hold a VST3/AU/LV2 plugin, NAM model, or IR loader. @@ -95,7 +96,26 @@ public: // Info int getNumSlots() const; const ProcessorSlot* getSlot(int slotId) const; - juce::Array getAllSlots() const; + // Metadata for every slot, copied UNDER the lock. Replaces getAllSlots(), + // which handed raw ProcessorSlot* back to the caller after dropping the + // lock: getChainState() then dereferenced them (down to + // processor->hasEditor()) while a concurrent clear()/loadPreset could free + // the slots underneath — a read-side use-after-free on the very rebuild + // window the chain-mutation serializer exists to police. + struct SlotSummary + { + int id = 0; + int type = 0; + juce::String name; + juce::String path; + bool bypassed = false; + float pan = 0.0f; + int branch = 0; + int branchSrc = 0; + float postGain = 1.0f; + bool hasEditor = false; + }; + std::vector getSlotSummaries() const; // Current prepared playback format — used to prepare a processor that is // swapped in mid-session (replaceProcessor) at the same rate as the chain. double getCurrentSampleRate() const { return currentSampleRate; } diff --git a/src/audio/SourceChain.cpp b/src/audio/SourceChain.cpp index 93787ba..7ae7142 100644 --- a/src/audio/SourceChain.cpp +++ b/src/audio/SourceChain.cpp @@ -252,7 +252,9 @@ void SourceChain::processBlock(const float* const* inputData, int numInputChanne // chain yet. Backing track still plays through. Suppressed during a song-load // chain rebuild so the brief (or failed) empty-chain window doesn't silence // the guitar. - if (monitorMuted.load() && !hasProcessors && !monitorMuteSuppressed.load()) + if ((monitorMuteHolds.load(std::memory_order_acquire) > 0 || userMonitorMute.load()) + && !hasProcessors + && monitorMuteSuppress.load(std::memory_order_acquire) == 0) buffer.clear(); // Full monitor kill: silence the guitar bus unconditionally — dry AND the diff --git a/src/audio/SourceChain.h b/src/audio/SourceChain.h index 47c86a7..7f44d5b 100644 --- a/src/audio/SourceChain.h +++ b/src/audio/SourceChain.h @@ -3,6 +3,7 @@ #include "NoiseGate.h" #include "TonePolish.h" #include "SignalChain.h" +#include "GainSanitize.h" #include "PitchDetector.h" #include "ChordScorer.h" #include "MlNoteDetector.h" @@ -125,9 +126,11 @@ public: } void setTonePolishEnabled(bool enabled) { tonePolish.setEnabled(enabled); } - void setInputGain(float gain) { inputGain.store(gain); } + // Sanitized (see GainSanitize.h) so NaN/Inf from the JS bridge can't + // reach the audio thread via either the legacy or the indexed API. + void setInputGain(float gain) { inputGain.store(slopsmith::sanitizeMasterGain(gain)); } float getInputGain() const { return inputGain.load(); } - void setChainOutputGain(float gain) { chainOutputGain.store(gain); } + void setChainOutputGain(float gain) { chainOutputGain.store(slopsmith::sanitizeMasterGain(gain)); } float getChainOutputGain() const { return chainOutputGain.load(); } void setInputChannel(int channel) { selectedInputChannel.store(channel); } int getInputChannel() const { return selectedInputChannel.load(); } @@ -139,10 +142,40 @@ public: // then a channel index WITHIN the bound device. void setDeviceKey(int key) { deviceKey.store(key, std::memory_order_release); } int getDeviceKey() const { return deviceKey.load(std::memory_order_acquire); } - void setMonitorMute(bool mute) { monitorMuted.store(mute); } - bool isMonitorMuted() const { return monitorMuted.load(); } - void setMonitorMuteSuppressed(bool s) { monitorMuteSuppressed.store(s); } - bool isMonitorMuteSuppressed() const { return monitorMuteSuppressed.load(); } + // ── Monitor-mute arbiter (TLC Part II §2 fix) ───────────────────────────── + // The old single monitorMuted atomic had FIVE writers (settings checkbox, + // startup restore, executor preload-mute, executor releaseRoute, renderer + // song-load suppression) fighting last-writer-wins — releaseRoute clobbered + // the user's persisted preference and overlapping suppression windows + // un-suppressed each other. Now three composable inputs: + // userMonitorMute — the PREFERENCE (checkbox + startup restore). + // monitorMuteHolds — refcounted "force mute" overrides (executor + // preload-mute); released, never "restored". + // monitorMuteSuppress — refcounted "force unmute" windows (song-load + // chain rebuilds). Wins over pref + holds, + // preserving the old suppressed-beats-muted rule. + // effective dry-mute = (holds>0 || pref) && chain empty && suppress==0. + void setMonitorMute(bool mute) { userMonitorMute.store(mute); } + bool isMonitorMuted() const { return userMonitorMute.load(); } + void acquireMonitorMuteHold() { monitorMuteHolds.fetch_add(1, std::memory_order_acq_rel); } + void releaseMonitorMuteHold() + { + // Clamp at 0: an unpaired release (old callers, crashed holder) must + // not underflow into a permanently-forced state. + int cur = monitorMuteHolds.load(std::memory_order_acquire); + while (cur > 0 && !monitorMuteHolds.compare_exchange_weak(cur, cur - 1, std::memory_order_acq_rel)) {} + } + // Back-compat surface: true = acquire a suppression, false = release one. + // Overlapping windows now compose instead of last-clear-wins. + void setMonitorMuteSuppressed(bool s) + { + if (s) { monitorMuteSuppress.fetch_add(1, std::memory_order_acq_rel); return; } + int cur = monitorMuteSuppress.load(std::memory_order_acquire); + while (cur > 0 && !monitorMuteSuppress.compare_exchange_weak(cur, cur - 1, std::memory_order_acq_rel)) {} + } + bool isMonitorMuteSuppressed() const { return monitorMuteSuppress.load(std::memory_order_acquire) > 0; } + int getMonitorMuteHoldCount() const { return monitorMuteHolds.load(std::memory_order_acquire); } + int getMonitorMuteSuppressCount() const { return monitorMuteSuppress.load(std::memory_order_acquire); } // Full monitor kill — silences the guitar bus UNCONDITIONALLY (dry AND the // processed/amp-sim signal), unlike setMonitorMute which only mutes the dry // pass-through when no processors are loaded. For users who monitor through @@ -197,8 +230,9 @@ private: std::atomic deviceKey{0}; // 0 = primary input device std::atomic verifierAutoOffset{0.0}; // engine: device-latency delta std::atomic verifierUserOffset{0.0}; // renderer: manual fine-tune - std::atomic monitorMuted{true}; - std::atomic monitorMuteSuppressed{false}; + std::atomic userMonitorMute{true}; + std::atomic monitorMuteHolds{0}; + std::atomic monitorMuteSuppress{0}; std::atomic monitorKill{false}; std::atomic nonFiniteChainBlocks{0}; diff --git a/src/audio/addon/AddonContext.cpp b/src/audio/addon/AddonContext.cpp new file mode 100644 index 0000000..79ee2a9 --- /dev/null +++ b/src/audio/addon/AddonContext.cpp @@ -0,0 +1,266 @@ +// AddonContext implementation — moved verbatim from NodeAddon.cpp (TLC plan +// phase 6 / §3.1). See AddonContext.h for the lifetime rules. + +#include "AddonContext.h" + +#include "../Sandbox/CrashAttribution.h" + +#include +#include +#include +#include + +namespace slopsmith::addon { + +static std::shared_ptr engine; +static std::mutex engineMutex; +static std::shared_ptr vstHost; +static std::mutex vstHostMutex; + +static std::thread juceMessageThread; +static std::atomic juceRunning{false}; +static std::atomic alreadyShutDown{false}; + +// Runs on the message thread at the start of shutdown, before engine.reset() +// frees the processors any editor windows point at (#56). Set by initialize(). +static std::function shutdownUiTeardown; + +std::shared_ptr snapshotEngine() +{ + std::lock_guard lock(engineMutex); + return engine; +} + +std::shared_ptr snapshotVstHost() +{ + std::lock_guard lock(vstHostMutex); + return vstHost; +} + +// ── JUCE Message Thread ────────────────────────────────────────────────────── +// JUCE requires a message thread for plugin loading, audio device management, +// etc. We pump it in a dedicated thread. + +static void startJuceMessageThread() +{ + if (juceRunning.load()) return; + juceRunning.store(true); + +#if JUCE_MAC + // On macOS, JUCE's MessageManager::runDispatchLoopUntil internally calls + // `-[NSApplication _nextEventMatchingEventMask:...]`, which AppKit asserts + // must run on the true main thread. Node.js already owns the main thread + // (running libuv's event loop), so we can't spawn a second NS event pump + // without hitting `nextEventMatchingMask should only be called from the + // Main Thread!` and aborting. + // + // Workaround: designate Node's current thread as JUCE's message thread and + // skip the dispatch loop. callAsync()'d callbacks will still queue; we + // drain them from the Node thread via a libuv timer created below. + juce::MessageManager::getInstance(); +#else + juceMessageThread = std::thread([]() { + juce::MessageManager::getInstance(); + while (juceRunning.load()) + { + juce::MessageManager::getInstance()->runDispatchLoopUntil(50); + } + juce::MessageManager::deleteInstance(); + }); +#endif +} + +static void stopJuceMessageThread() +{ + juceRunning.store(false); +#if !JUCE_MAC + if (juceMessageThread.joinable()) + juceMessageThread.join(); +#else + juce::MessageManager::deleteInstance(); +#endif +} + +bool dispatchOnMessageThreadImpl(std::function func) +{ +#if JUCE_MAC + // No background message thread on macOS — execute inline on caller thread. + // Audio device / NAM / IR init is thread-safe for our use; VST/AU plugin + // instantiation (which genuinely requires a message thread on macOS) is + // the one capability we give up until a proper libuv-based pump lands. + func(); + return true; +#else + // Heap-allocate the WaitableEvent and capture by value so the queued + // callAsync closure can outlive this stack frame. Without this, a 15 s + // timeout (rare, but possible during shutdown when the message thread is + // busy) leaves the lambda running on freed `done` storage — a real UAF. + // + // Both failure modes are reported to the caller: a refused post means + // `func` will NEVER run (message queue already gone); a wait timeout + // means it hasn't run YET (it may still run later while the dispatch + // loop drains). Lifecycle callers must not proceed as if the work + // completed — doShutdown in particular used to unload the addon while + // editor teardown / stopAudio / engine destruction were still pending. + auto done = std::make_shared(); + const bool posted = juce::MessageManager::callAsync( + [func = std::move(func), done]() mutable { + func(); + done->signal(); + }); + if (!posted) + { + fprintf(stderr, "[audio-native] dispatchOnMessageThread: message queue " + "refused the post; dispatched work will not run\n"); + return false; + } + if (!done->wait(15000)) + { + fprintf(stderr, "[audio-native] dispatchOnMessageThread: dispatched work " + "did not complete within 15s\n"); + return false; + } + return true; +#endif +} + +// ── Pending async loads ────────────────────────────────────────────────────── + +static std::mutex pendingLoadsMutex; +static std::set> pendingLoads; + +bool isShuttingDown() +{ + return alreadyShutDown.load(std::memory_order_acquire); +} + +void registerPendingLoad(std::shared_ptr evt) +{ + std::lock_guard lock(pendingLoadsMutex); + pendingLoads.insert(std::move(evt)); +} + +void unregisterPendingLoad(const std::shared_ptr& evt) +{ + std::lock_guard lock(pendingLoadsMutex); + pendingLoads.erase(evt); +} + +void cancelAllPendingLoads() +{ + std::lock_guard lock(pendingLoadsMutex); + for (auto& evt : pendingLoads) evt->signal(); + pendingLoads.clear(); +} + +// ── Lifecycle ──────────────────────────────────────────────────────────────── + +void initialize(std::function uiTeardownHook) +{ + shutdownUiTeardown = std::move(uiTeardownHook); + + // Reset the shutdown latch so a JS-level init→shutdown→init cycle (e.g. + // a test harness recreating the engine) actually runs shutdown again + // instead of treating it as already-done. + alreadyShutDown.store(false, std::memory_order_release); + + // Start JUCE message thread first (no-op on macOS) + startJuceMessageThread(); + +#if !JUCE_MAC + // Small delay to ensure message thread is pumping + std::this_thread::sleep_for(std::chrono::milliseconds(200)); +#endif + + // Create engine on the JUCE message thread (or inline on macOS) + const bool initialized = dispatchOnMessageThread([]() { + std::shared_ptr liveEngine; + { + std::lock_guard lock(engineMutex); + engine = std::make_shared(); + liveEngine = engine; + } + { + std::lock_guard lock(vstHostMutex); + vstHost = std::make_shared(); + } + + auto types = liveEngine->getDeviceTypes(); + fprintf(stderr, "[audio-native] Init complete. Device types: %d\n", types.size()); + for (int i = 0; i < types.size(); ++i) + fprintf(stderr, "[audio-native] %s: %d inputs, %d outputs\n", + types[i].name.toRawUTF8(), + types[i].inputDevices.size(), + types[i].outputDevices.size()); + }); + if (!initialized) + fprintf(stderr, "[audio-native] initialize: engine creation did not complete " + "on the message thread; audio bindings will no-op until re-init\n"); +} + +void doShutdown() +{ + // The latch is flipped at the TOP rather than the bottom so a + // re-entrant call (e.g. env-cleanup-hook firing while a JS-level + // shutdown is mid-flight) bails immediately rather than racing on + // the same teardown sequence. Assumed serialisation invariants: + // - dispatchOnMessageThread is single-writer to engine/vstHost + // (both touched only here or from initialize); + // - stopJuceMessageThread is idempotent and safe to call when the + // thread was never started (defensive checks inside). + // If a future caller mutates engine/vstHost between this latch and + // the dispatch (or the dispatch's 15s wait times out), THIS call's + // body may not finish before returning — but the re-entrant + // cleanup-hook will then no-op via the latch and the dispatch + // queue itself unwinds whatever's pending. Net result: at-most- + // once execution of the gated body, even under teardown races. + bool expected = false; + if (!alreadyShutDown.compare_exchange_strong(expected, true)) return; + + // Release any LoadVSTWorker / LoadPresetWorker currently blocked on a + // pending async load. Without this they'd wait forever on the + // WaitableEvent — the createPluginInstanceAsync callback can't fire + // once the message thread is gone. + cancelAllPendingLoads(); + + if (juceRunning.load() || snapshotEngine() || snapshotVstHost()) + { + const bool toreDown = dispatchOnMessageThread([]() { + // Editors reference their slot's processor; engine.reset() below + // frees the whole chain, so destroy the editor windows first (#56). + if (shutdownUiTeardown) shutdownUiTeardown(); + if (auto liveEngine = snapshotEngine()) + liveEngine->stopAudio(); + { + std::lock_guard lock(engineMutex); + engine.reset(); + } + { + std::lock_guard lock(vstHostMutex); + vstHost.reset(); + } + }); + if (!toreDown) + { + // Editor teardown / stopAudio / engine destruction have NOT + // completed. Do not stop the message thread underneath them: a + // timed-out teardown lambda is still queued and can only finish + // if the pump keeps running. Leaking the pump thread at process + // exit beats unloading the addon mid-destruction (the exact + // shutdown UAF this path exists to prevent). The latch stays + // set, so a re-entrant shutdown call no-ops. + fprintf(stderr, "[audio-native] doShutdown: engine teardown did not " + "complete; leaving message thread running\n"); + slopsmith::sandbox::uninstallVstCrashAttribution(); + return; + } + } + + stopJuceMessageThread(); + + // Restore the previous top-level exception filter — the addon (and thus our + // unhandledFilter's code) may be unloaded, so it must not stay installed. + slopsmith::sandbox::uninstallVstCrashAttribution(); +} + +} // namespace slopsmith::addon diff --git a/src/audio/addon/AddonContext.h b/src/audio/addon/AddonContext.h new file mode 100644 index 0000000..2c2a1b9 --- /dev/null +++ b/src/audio/addon/AddonContext.h @@ -0,0 +1,70 @@ +#pragma once + +// AddonContext — engine/vstHost lifetime, the JUCE message thread, the +// shutdown latch, and the pending-async-load registry (TLC plan phase 6 / +// §3.1). Moved verbatim from NodeAddon.cpp; this quarantines the JUCE_MAC +// platform fork (no dispatch loop — see startJuceMessageThread) into ONE +// file instead of a branch inside every load path. +// +// engine / vstHost — shared_ptr (not unique_ptr) so worker threads can take +// a stable snapshot that keeps the object alive for the duration of their +// work, even if the message thread reassigns the global mid-operation. This +// matters most for the async VST load: createPluginInstanceAsync's JUCE +// continuation must not have VSTHost / its formatManager torn out from +// under it mid-load. +// +// Enforced rule: every *dereference* of engine / vstHost goes through a +// local snapshot (snapshotEngine / snapshotVstHost). The only code touching +// the bare globals is the snapshot helpers and the mutex-guarded writes in +// initialize / doShutdown. + +#include "../AudioEngine.h" +#include "../VSTHost.h" + +#include + +#include +#include +#include +#include + +namespace slopsmith::addon { + +std::shared_ptr snapshotEngine(); +std::shared_ptr snapshotVstHost(); + +// Start the pump + create engine/vstHost on the message thread (inline on +// macOS). `uiTeardownHook` runs on the message thread at the START of +// shutdown, BEFORE engine.reset() frees the processors — NodeAddon points it +// at destroyAllPluginEditorWindowsOnMessageThread (use-after-free; #56). +void initialize(std::function uiTeardownHook); +void doShutdown(); + +// Dispatch `func` on the JUCE message thread and wait (bounded 15 s). +// macOS: executes inline on the caller thread — no background pump exists +// (AppKit owns the real main thread; see the fork note in the .cpp). +// Returns false when the work did not complete: the post was refused +// (message queue gone — `func` will never run) or the wait timed out +// (`func` may still run later). Lifecycle callers must treat false as +// "teardown/init did not happen" rather than continuing. +bool dispatchOnMessageThreadImpl(std::function func); +template +inline bool dispatchOnMessageThread(Func&& func) +{ + return dispatchOnMessageThreadImpl(std::function(std::forward(func))); +} + +// Pending-async-load registry: LoadVSTWorker / LoadPresetWorker block on a +// WaitableEvent until the message-thread continuation fires; doShutdown +// signals every registered event so no worker waits forever once the pump +// is gone. +// Whether doShutdown has begun (acquire). The load workers gate on this +// after registering their pending event, catching the register-vs-shutdown +// race in both directions. +bool isShuttingDown(); + +void registerPendingLoad(std::shared_ptr evt); +void unregisterPendingLoad(const std::shared_ptr& evt); +void cancelAllPendingLoads(); + +} // namespace slopsmith::addon diff --git a/src/audio/addon/BackingBindings.cpp b/src/audio/addon/BackingBindings.cpp new file mode 100644 index 0000000..daa3197 --- /dev/null +++ b/src/audio/addon/BackingBindings.cpp @@ -0,0 +1,91 @@ +// Backing track bindings - moved verbatim from NodeAddon.cpp (TLC phase 7b +// binding split). Registered by NodeAddon's export table via Bindings.h. + +#include "Bindings.h" + +#include "AddonContext.h" +#include "NapiHelpers.h" +#include "ChainOps.h" +#include "../AudioEngine.h" +#include "../VSTHost.h" +#include "../VSTTrace.h" + +#include +#include +#include +#include + +namespace slopsmith::addon { + +// ── Backing Track ───────────────────────────────────────────────────────────── + +Napi::Value LoadBackingTrack(const Napi::CallbackInfo& info) +{ + auto env = info.Env(); + auto liveEngine = snapshotEngine(); + if (!liveEngine || info.Length() < 1) return Napi::Boolean::New(env, false); + + auto path = info[0].As().Utf8Value(); + bool result = liveEngine->loadBackingTrack(juce::File(juce::String(path))); + return Napi::Boolean::New(env, result); +} + +Napi::Value StartBacking(const Napi::CallbackInfo& info) +{ + if (auto liveEngine = snapshotEngine()) liveEngine->startBacking(); + return info.Env().Undefined(); +} + +Napi::Value StopBacking(const Napi::CallbackInfo& info) +{ + if (auto liveEngine = snapshotEngine()) liveEngine->stopBacking(); + return info.Env().Undefined(); +} + +Napi::Value SeekBacking(const Napi::CallbackInfo& info) +{ + auto liveEngine = snapshotEngine(); + if (liveEngine && info.Length() > 0) + liveEngine->setBackingPosition(info[0].As().DoubleValue()); + return info.Env().Undefined(); +} + +Napi::Value GetBackingPosition(const Napi::CallbackInfo& info) +{ + auto liveEngine = snapshotEngine(); + double pos = liveEngine ? liveEngine->getBackingPosition() : 0.0; + return Napi::Number::New(info.Env(), pos); +} + +Napi::Value GetBackingDuration(const Napi::CallbackInfo& info) +{ + auto liveEngine = snapshotEngine(); + double dur = liveEngine ? liveEngine->getBackingDuration() : 0.0; + return Napi::Number::New(info.Env(), dur); +} + +Napi::Value IsBackingPlaying(const Napi::CallbackInfo& info) +{ + auto liveEngine = snapshotEngine(); + bool playing = liveEngine ? liveEngine->isBackingPlaying() : false; + return Napi::Boolean::New(info.Env(), playing); +} + +Napi::Value SetBackingSpeed(const Napi::CallbackInfo& info) +{ + auto env = info.Env(); + if (info.Length() < 1 || !info[0].IsNumber()) + { + Napi::TypeError::New(env, "setBackingSpeed(speed) requires a number") + .ThrowAsJavaScriptException(); + return env.Undefined(); + } + // (Was a bare `engine` dereference — the one binding that dodged the + // file's own snapshot rule; surfaced by the phase-6 move.) + if (auto liveEngine = snapshotEngine()) + liveEngine->setBackingSpeed(info[0].As().DoubleValue()); + return env.Undefined(); +} + + +} // namespace slopsmith::addon diff --git a/src/audio/addon/Bindings.h b/src/audio/addon/Bindings.h new file mode 100644 index 0000000..d53f754 --- /dev/null +++ b/src/audio/addon/Bindings.h @@ -0,0 +1,113 @@ +#pragma once + +// Binding declarations for the split N-API handler files (TLC phase 7b): +// DeviceBindings / ControlBindings / DetectionBindings / ChainBindings / +// BackingBindings. NodeAddon.cpp registers them in its export table. + +#include + +class AudioEngine; +class SourceChain; + +namespace slopsmith::addon { + +Napi::Value GetLatencyBreakdown(const Napi::CallbackInfo& info); + +// Validate a JS source-id argument and return the live source (nullptr for +// missing / non-Number / non-finite / out-of-range). Shared by the +// source-indexed bindings across the split files. +SourceChain* getValidatedSource(AudioEngine* eng, const Napi::CallbackInfo& info, size_t argIndex); + +Napi::Value AddSource(const Napi::CallbackInfo& info); +Napi::Value BindInputDevice(const Napi::CallbackInfo& info); +Napi::Value ClearChain(const Napi::CallbackInfo& info); +Napi::Value ClearStreamOutput(const Napi::CallbackInfo& info); +Napi::Value DetectNotes(const Napi::CallbackInfo& info); +Napi::Value EnableFileLogging(const Napi::CallbackInfo& info); +Napi::Value GetBackingDuration(const Napi::CallbackInfo& info); +Napi::Value GetBackingLevel(const Napi::CallbackInfo& info); +Napi::Value GetBackingPosition(const Napi::CallbackInfo& info); +Napi::Value GetBufferSizes(const Napi::CallbackInfo& info); +Napi::Value GetChainGeneration(const Napi::CallbackInfo& info); +Napi::Value GetChainState(const Napi::CallbackInfo& info); +Napi::Value GetCurrentDevice(const Napi::CallbackInfo& info); +Napi::Value GetDeviceMetrics(const Napi::CallbackInfo& info); +Napi::Value GetDeviceTypes(const Napi::CallbackInfo& info); +Napi::Value GetLevels(const Napi::CallbackInfo& info); +Napi::Value GetNoteVerdicts(const Napi::CallbackInfo& info); +Napi::Value GetParameters(const Napi::CallbackInfo& info); +Napi::Value GetPitchDetection(const Napi::CallbackInfo& info); +Napi::Value GetRawAudioFrame(const Napi::CallbackInfo& info); +Napi::Value GetRawPitchDetection(const Napi::CallbackInfo& info); +Napi::Value GetRendererBusMetrics(const Napi::CallbackInfo& info); +Napi::Value GetSampleRate(const Napi::CallbackInfo& info); +Napi::Value GetSampleRates(const Napi::CallbackInfo& info); +Napi::Value GetSourceLevels(const Napi::CallbackInfo& info); +Napi::Value GetSourceNoteVerdicts(const Napi::CallbackInfo& info); +Napi::Value GetSourcePitchDetection(const Napi::CallbackInfo& info); +Napi::Value GetSourceRawAudioFrame(const Napi::CallbackInfo& info); +Napi::Value GetSourceRawPitchDetection(const Napi::CallbackInfo& info); +Napi::Value GetStreamOverflowCount(const Napi::CallbackInfo& info); +Napi::Value GetStreamSinkLevel(const Napi::CallbackInfo& info); +Napi::Value GetStreamUnderflowCount(const Napi::CallbackInfo& info); +Napi::Value IsAudioRunning(const Napi::CallbackInfo& info); +Napi::Value IsBackingPlaying(const Napi::CallbackInfo& info); +Napi::Value IsMlNoteDetection(const Napi::CallbackInfo& info); +Napi::Value IsMonitorMuted(const Napi::CallbackInfo& info); +Napi::Value IsStreamOutputActive(const Napi::CallbackInfo& info); +Napi::Value ListInputDevices(const Napi::CallbackInfo& info); +Napi::Value ListSources(const Napi::CallbackInfo& info); +Napi::Value LoadBackingTrack(const Napi::CallbackInfo& info); +Napi::Value LoadNoteModel(const Napi::CallbackInfo& info); +Napi::Value MoveProcessor(const Napi::CallbackInfo& info); +Napi::Value ProbeDeviceOptions(const Napi::CallbackInfo& info); +Napi::Value PushRendererAudio(const Napi::CallbackInfo& info); +Napi::Value RemoveProcessor(const Napi::CallbackInfo& info); +Napi::Value RemoveSource(const Napi::CallbackInfo& info); +Napi::Value ResetPeaks(const Napi::CallbackInfo& info); +Napi::Value SavePreset(const Napi::CallbackInfo& info); +Napi::Value ScoreChord(const Napi::CallbackInfo& info); +Napi::Value ScoreSourceChord(const Napi::CallbackInfo& info); +Napi::Value SeekBacking(const Napi::CallbackInfo& info); +Napi::Value SendMidiToSlot(const Napi::CallbackInfo& info); +Napi::Value SetBackingSpeed(const Napi::CallbackInfo& info); +Napi::Value SetBranch(const Napi::CallbackInfo& info); +Napi::Value SetBranchSrc(const Napi::CallbackInfo& info); +Napi::Value SetBypass(const Napi::CallbackInfo& info); +Napi::Value SetChart(const Napi::CallbackInfo& info); +Napi::Value SetDevice(const Napi::CallbackInfo& info); +Napi::Value SetDeviceType(const Napi::CallbackInfo& info); +Napi::Value SetGain(const Napi::CallbackInfo& info); +Napi::Value SetInputChannel(const Napi::CallbackInfo& info); +Napi::Value AcquireMonitorMuteHold(const Napi::CallbackInfo& info); +Napi::Value ReleaseMonitorMuteHold(const Napi::CallbackInfo& info); +Napi::Value GetMonitorMuteState(const Napi::CallbackInfo& info); +Napi::Value SetMonitorKill(const Napi::CallbackInfo& info); +Napi::Value SetMonitorMute(const Napi::CallbackInfo& info); +Napi::Value SetMonitorMuteSuppressed(const Napi::CallbackInfo& info); +Napi::Value SetMultiBypass(const Napi::CallbackInfo& info); +Napi::Value SetNoiseGate(const Napi::CallbackInfo& info); +Napi::Value SetNoteDetectionEnabled(const Napi::CallbackInfo& info); +Napi::Value SetOutputDeviceType(const Napi::CallbackInfo& info); +Napi::Value SetPan(const Napi::CallbackInfo& info); +Napi::Value SetParameter(const Napi::CallbackInfo& info); +Napi::Value SetPostGain(const Napi::CallbackInfo& info); +Napi::Value SetRendererBus(const Napi::CallbackInfo& info); +Napi::Value SetSlotState(const Napi::CallbackInfo& info); +Napi::Value SetSourceChart(const Napi::CallbackInfo& info); +Napi::Value SetSourceInputChannel(const Napi::CallbackInfo& info); +Napi::Value SetSourceMonitorMute(const Napi::CallbackInfo& info); +Napi::Value SetSourceVerifierOffset(const Napi::CallbackInfo& info); +Napi::Value SetStreamBus(const Napi::CallbackInfo& info); +Napi::Value SetStreamBusGain(const Napi::CallbackInfo& info); +Napi::Value SetStreamOutputDevice(const Napi::CallbackInfo& info); +Napi::Value SetTonePolish(const Napi::CallbackInfo& info); +Napi::Value StartAudio(const Napi::CallbackInfo& info); +Napi::Value StartBacking(const Napi::CallbackInfo& info); +Napi::Value StopAudio(const Napi::CallbackInfo& info); +Napi::Value StopBacking(const Napi::CallbackInfo& info); +Napi::Value UnbindInputDevice(const Napi::CallbackInfo& info); +Napi::Value scoreChordCore(const Napi::CallbackInfo& info); +Napi::Value setChartCore(const Napi::CallbackInfo& info); + +} // namespace slopsmith::addon diff --git a/src/audio/addon/ChainBindings.cpp b/src/audio/addon/ChainBindings.cpp new file mode 100644 index 0000000..5102490 --- /dev/null +++ b/src/audio/addon/ChainBindings.cpp @@ -0,0 +1,307 @@ +// Signal-chain slot/state/preset bindings - moved verbatim from NodeAddon.cpp (TLC phase 7b +// binding split). Registered by NodeAddon's export table via Bindings.h. + +#include "Bindings.h" + +#include "AddonContext.h" +#include "NapiHelpers.h" +#include "ChainOps.h" +#include "EditorWindows.h" +#include "../AudioEngine.h" +#include "../VSTHost.h" +#include "../VSTTrace.h" + +#include +#include +#include +#include + +namespace slopsmith::addon { + +// ── Signal Chain Management ────────────────────────────────────────────────── + +// The chain mutators resolve a promise instead of returning synchronously: the +// chain-mutation mutex can be held for the length of a plugin init, and waiting +// for it on the JS thread would freeze the main process (see ChainOps.h). Every +// caller already reaches these through ipcRenderer.invoke, so the await is free. +static Napi::Value resolvedBool(Napi::Env env, bool value) +{ + auto deferred = Napi::Promise::Deferred::New(env); + deferred.Resolve(Napi::Boolean::New(env, value)); + return deferred.Promise(); +} + +Napi::Value RemoveProcessor(const Napi::CallbackInfo& info) +{ + // Typed extractors (addon/NapiHelpers.h): NaN/Inf slot ids used to coerce + // to slot 0 and mutate the wrong slot (deep-read §2) — now a clean no-op. + auto env = info.Env(); + const auto slotId = slopsmith::addon::argSlotId(info, 0); + if (!slotId) return resolvedBool(env, false); + const int id = *slotId; + return slopsmith::addon::queueChainMutation(env, [id](AudioEngine& eng) { + eng.getSignalChain().removeProcessor(id); + }); +} + +Napi::Value MoveProcessor(const Napi::CallbackInfo& info) +{ + auto env = info.Env(); + const auto from = slopsmith::addon::argSlotId(info, 0); + const auto to = slopsmith::addon::argSlotId(info, 1); + if (!from || !to) return resolvedBool(env, false); + const int f = *from, t = *to; + return slopsmith::addon::queueChainMutation(env, [f, t](AudioEngine& eng) { + eng.getSignalChain().moveProcessor(f, t); + }); +} + +Napi::Value SetBypass(const Napi::CallbackInfo& info) +{ + auto liveEngine = snapshotEngine(); + const auto slotId = slopsmith::addon::argSlotId(info, 0); + const auto bypassed = slopsmith::addon::argBool(info, 1); + if (liveEngine && slotId && bypassed) + liveEngine->getSignalChain().setBypass(*slotId, *bypassed); + return info.Env().Undefined(); +} + +// Destroy every open in-process plugin editor window on the message thread and +// block until done. MUST run before any path that frees slot processors +// (ClearChain, LoadPreset's chain rebuild, engine teardown): an editor window +// owns an AudioProcessorEditor bound to its slot's processor, so if the +// processor is freed first the editor's next timer/paint callback dereferences +// freed memory (use-after-free → DEP-execute crash seconds after pause; +// feedBack-desktop#56). Lives in addon/EditorWindows now. + +Napi::Value ClearChain(const Napi::CallbackInfo& info) +{ + auto env = info.Env(); + + // Gate editor opens for the whole teardown+clear window (see the rebuild + // barrier in ChainOps.h): without it, an editor opened between the + // teardown below and the clear acquiring the mutex would point at a + // processor the clear is about to free. + slopsmith::addon::beginChainRebuild(); + + // Tear editors down before their processors are freed (#56). Must happen on + // THIS thread (main / message thread), not on the mutation worker: JUCE GUI + // objects may only be destroyed on the message thread. + if (!closeAllPluginEditorWindows()) + { + // Teardown refused/timed out: an editor may still be bound to a chain + // processor. Clearing now would free it under the live editor — the + // documented UAF. Skip the clear; the caller can retry. + slopsmith::addon::endChainRebuild(); + fprintf(stderr, "[audio-native] clearChain: editor teardown did not complete; " + "chain left untouched\n"); + return resolvedBool(env, false); + } + + // The worker now owns the barrier and releases it on every exit path. It + // takes the chain mutex on a libuv thread, so an in-flight preset/VST load + // delays the clear without blocking the JS thread behind it. + return slopsmith::addon::queueChainMutation(env, [](AudioEngine& eng) { + eng.getSignalChain().clear(); + }, /*releasesRebuildBarrier=*/true); +} + +// Stereo routing (St-1). setPan(slotId, -1..+1); setBranch(slotId, 0=trunk/>=1). +Napi::Value SetPan(const Napi::CallbackInfo& info) +{ + auto liveEngine = snapshotEngine(); + if (liveEngine && info.Length() >= 2) + { + const auto slotId = slopsmith::addon::argSlotId(info, 0); + const auto pan = slopsmith::addon::argFiniteFloat(info, 1); + if (slotId && pan) liveEngine->getSignalChain().setPan(*slotId, *pan); + } + return info.Env().Undefined(); +} + +Napi::Value SetPostGain(const Napi::CallbackInfo& info) +{ + auto liveEngine = snapshotEngine(); + if (liveEngine && info.Length() >= 2) + { + const auto slotId = slopsmith::addon::argSlotId(info, 0); + const auto gain = slopsmith::addon::argFiniteFloat(info, 1); + if (slotId && gain) liveEngine->getSignalChain().setPostGain(*slotId, *gain); + } + return info.Env().Undefined(); +} + +Napi::Value SetBranch(const Napi::CallbackInfo& info) +{ + auto liveEngine = snapshotEngine(); + if (liveEngine && info.Length() >= 2) + { + const auto slotId = slopsmith::addon::argSlotId(info, 0); + const auto branch = slopsmith::addon::argInt(info, 1); + if (slotId && branch) liveEngine->getSignalChain().setBranch(*slotId, *branch); + } + return info.Env().Undefined(); +} + +// setBranchSrc(slotId, 0=both/1=L/2=R): channel a branch reads from the split. +Napi::Value SetBranchSrc(const Napi::CallbackInfo& info) +{ + auto liveEngine = snapshotEngine(); + if (liveEngine && info.Length() >= 2) + { + const auto slotId = slopsmith::addon::argSlotId(info, 0); + const auto branchSrc = slopsmith::addon::argInt(info, 1, 0, 2); + if (slotId && branchSrc) liveEngine->getSignalChain().setBranchSrc(*slotId, *branchSrc); + } + return info.Env().Undefined(); +} + +// ── Chain State ─────────────────────────────────────────────────────────────── + +// Monotonic chain-mutation counter (TLC phase 7): JS-side chain owners (the +// audio-effects executor) compare this against the generation their load +// returned to detect that another writer changed the chain under them. +Napi::Value GetChainGeneration(const Napi::CallbackInfo& info) +{ + return Napi::Number::New(info.Env(), (double) slopsmith::addon::currentChainGeneration()); +} + +Napi::Value GetChainState(const Napi::CallbackInfo& info) +{ + auto env = info.Env(); + auto result = Napi::Array::New(env); + auto liveEngine = snapshotEngine(); + + if (liveEngine) + { + // Summaries are copied under SignalChain's lock — the old getAllSlots() + // handed back raw slot pointers that a concurrent clear()/loadPreset + // could free before this loop dereferenced them. + const auto slots = liveEngine->getSignalChain().getSlotSummaries(); + for (size_t i = 0; i < slots.size(); ++i) + { + auto obj = Napi::Object::New(env); + obj.Set("id", slots[i].id); + obj.Set("type", slots[i].type); + obj.Set("name", slots[i].name.toStdString()); + obj.Set("path", slots[i].path.toStdString()); + obj.Set("bypassed", slots[i].bypassed); + obj.Set("pan", slots[i].pan); + obj.Set("branch", slots[i].branch); + obj.Set("branchSrc", slots[i].branchSrc); + obj.Set("postGain", slots[i].postGain); + obj.Set("hasEditor", slots[i].hasEditor); + result.Set((uint32_t)i, obj); + } + } + + return result; +} + +// ── Parameters ──────────────────────────────────────────────────────────────── + +Napi::Value GetParameters(const Napi::CallbackInfo& info) +{ + auto env = info.Env(); + auto liveEngine = snapshotEngine(); + const auto slotId = slopsmith::addon::argSlotId(info, 0); + if (!liveEngine || !slotId) return Napi::Array::New(env); + + auto params = liveEngine->getSignalChain().getParameters(*slotId); + auto result = Napi::Array::New(env, params.size()); + + for (int i = 0; i < params.size(); ++i) + { + auto obj = Napi::Object::New(env); + obj.Set("index", params[i].index); + obj.Set("name", params[i].name.toStdString()); + obj.Set("value", params[i].value); + obj.Set("label", params[i].label.toStdString()); + obj.Set("text", params[i].text.toStdString()); + result.Set((uint32_t)i, obj); + } + + return result; +} + +Napi::Value SetParameter(const Napi::CallbackInfo& info) +{ + auto liveEngine = snapshotEngine(); + const auto slotId = slopsmith::addon::argSlotId(info, 0); + const auto paramIdx = slopsmith::addon::argSlotId(info, 1); + const auto value = slopsmith::addon::argFiniteFloat(info, 2); + if (liveEngine && slotId && paramIdx && value) + liveEngine->getSignalChain().setParameter(*slotId, *paramIdx, *value); + return info.Env().Undefined(); +} + +// Restore a VST slot's full state from a base64 getStateInformation() blob. +Napi::Value SetSlotState(const Napi::CallbackInfo& info) +{ + // Type-guard both args (NAPI_DISABLE_CPP_EXCEPTIONS): a malformed IPC + // payload is a clean no-op rather than a hard addon failure. The slot id + // goes through argSlotId like every other mutator — IsNumber() is true for + // NaN, and Int32Value() would have coerced it to slot 0 and written this + // state onto a real slot (deep-read §2). + auto liveEngine = snapshotEngine(); + const auto slotId = slopsmith::addon::argSlotId(info, 0); + if (liveEngine && slotId && info.Length() >= 2 && info[1].IsString()) + { + auto base64 = info[1].As().Utf8Value(); + const auto* slot = liveEngine->getSignalChain().getSlot(*slotId); + const bool allowStandard = slot != nullptr + && (slot->type == ProcessorSlot::Type::IR + || slot->type == ProcessorSlot::Type::NAM); + juce::MemoryBlock mb; + if (decodeStateBlob(juce::String(base64), mb, allowStandard)) + liveEngine->getSignalChain().setSlotState(*slotId, mb); + } + return info.Env().Undefined(); +} + +// ── Presets ─────────────────────────────────────────────────────────────────── + +Napi::Value SavePreset(const Napi::CallbackInfo& info) +{ + auto env = info.Env(); + auto liveEngine = snapshotEngine(); + if (!liveEngine) return env.Null(); + auto json = liveEngine->getSignalChain().savePreset(); + return Napi::String::New(env, json.toStdString()); +} + +Napi::Value SetMultiBypass(const Napi::CallbackInfo& info) +{ + auto env = info.Env(); + auto liveEngine = snapshotEngine(); + if (!liveEngine || info.Length() < 1 || !info[0].IsArray()) + return Napi::Boolean::New(env, false); + + auto arr = info[0].As(); + juce::Array> changes; + + for (uint32_t i = 0; i < arr.Length(); i++) + { + // Per-item type guards (deep-read §2): a malformed entry is skipped + // instead of coercing NaN to slot 0. + auto itemVal = arr.Get(i); + if (!itemVal.IsObject()) continue; + auto item = itemVal.As(); + auto slotVal = item.Get("slotId"); + auto bypVal = item.Get("bypassed"); + if (!slotVal.IsNumber() || !bypVal.IsBoolean()) continue; + // Same rule as argSlotId: reject the NaN/Inf/fractional class, but do + // NOT impose an index ceiling — slot ids are monotonic handles, not + // indices (see NapiHelpers.h). + const double raw = slotVal.As().DoubleValue(); + if (!std::isfinite(raw) || raw != std::floor(raw) + || raw < 0.0 || raw > (double) std::numeric_limits::max()) continue; + changes.add({ (int) raw, bypVal.As().Value() }); + } + + liveEngine->getSignalChain().setMultiBypass(changes); + return Napi::Boolean::New(env, true); +} + + +} // namespace slopsmith::addon diff --git a/src/audio/addon/ChainOps.cpp b/src/audio/addon/ChainOps.cpp new file mode 100644 index 0000000..1ca92f3 --- /dev/null +++ b/src/audio/addon/ChainOps.cpp @@ -0,0 +1,1053 @@ +// ChainOps implementation — the chain-mutating async workers, their N-API +// handlers, and loadVstSandboxAware, moved verbatim from NodeAddon.cpp (TLC +// plan phase 7b / 3.3). The serialization primitives (chainMutationMutex / +// chainGeneration) landed in phase 7a; every worker Execute() below holds +// the mutex for its full body. + +#include "ChainOps.h" + +#include "AddonContext.h" +#include "NapiHelpers.h" +#include "EditorWindows.h" +#include "../AudioEngine.h" +#include "../VSTHost.h" +#include "../VSTTrace.h" +#include "../NAMProcessor.h" +#include "../IRLoader.h" +#include "../Sandbox/SandboxedProcessor.h" + +#include + +#include +#include +#include +#include +#include + +namespace slopsmith::addon { + +std::mutex& chainMutationMutex() +{ + static std::mutex m; + return m; +} + +static std::atomic chainGeneration{0}; + +uint64_t bumpChainGeneration() +{ + return chainGeneration.fetch_add(1, std::memory_order_acq_rel) + 1; +} + +uint64_t currentChainGeneration() +{ + return chainGeneration.load(std::memory_order_acquire); +} + +// ── Rebuild barrier (see ChainOps.h) ──────────────────────────────────────── + +static std::atomic chainRebuildsPending{0}; + +void beginChainRebuild() +{ + chainRebuildsPending.fetch_add(1, std::memory_order_acq_rel); +} + +void endChainRebuild() +{ + chainRebuildsPending.fetch_sub(1, std::memory_order_acq_rel); +} + +bool isChainRebuildPending() +{ + return chainRebuildsPending.load(std::memory_order_acquire) > 0; +} + +// ── Async chain mutation (see ChainOps.h) ─────────────────────────────────── +// The synchronous mutators (clearChain / removeProcessor / moveProcessor) used +// to take chainMutationMutex with a blocking lock_guard on the N-API thread. +// That thread is Electron's main thread — and on macOS it is also the JUCE +// message thread — so waiting there behind a LoadPreset worker that holds the +// mutex across an unbounded plugin init froze the app (or deadlocked the pump +// the load needs). Queue the mutation instead and let the worker do the waiting. + +namespace { + +// As above, but the mutation yields a slot id (resolved as a Number). +class ChainSlotMutationWorker : public Napi::AsyncWorker +{ +public: + ChainSlotMutationWorker(Napi::Env env, Napi::Promise::Deferred deferred, + std::function mutate) + : Napi::AsyncWorker(env), deferred_(deferred), mutate_(std::move(mutate)) {} + + void Execute() override + { + std::lock_guard chainLock(slopsmith::addon::chainMutationMutex()); + auto liveEngine = snapshotEngine(); + if (!liveEngine) return; // slotId_ stays -1 + slotId_ = mutate_(*liveEngine); + if (slotId_ >= 0) + slopsmith::addon::bumpChainGeneration(); // still under chainLock + } + + void OnOK() override { deferred_.Resolve(Napi::Number::New(Env(), slotId_)); } + void OnError(const Napi::Error& e) override { deferred_.Reject(e.Value()); } + +private: + Napi::Promise::Deferred deferred_; + std::function mutate_; + int slotId_ = -1; +}; + +class ChainMutationWorker : public Napi::AsyncWorker +{ +public: + ChainMutationWorker(Napi::Env env, Napi::Promise::Deferred deferred, + std::function mutate, bool releasesBarrier) + : Napi::AsyncWorker(env) + , deferred_(deferred) + , mutate_(std::move(mutate)) + , releasesBarrier_(releasesBarrier) {} + + void Execute() override + { + struct BarrierRelease { + bool armed; + ~BarrierRelease() { if (armed) slopsmith::addon::endChainRebuild(); } + } barrierRelease{ releasesBarrier_ }; + + std::lock_guard chainLock(slopsmith::addon::chainMutationMutex()); + auto liveEngine = snapshotEngine(); + if (!liveEngine) return; // ok_ stays false + mutate_(*liveEngine); + slopsmith::addon::bumpChainGeneration(); // still under chainLock + ok_ = true; + } + + void OnOK() override { deferred_.Resolve(Napi::Boolean::New(Env(), ok_)); } + void OnError(const Napi::Error& e) override { deferred_.Reject(e.Value()); } + +private: + Napi::Promise::Deferred deferred_; + std::function mutate_; + bool releasesBarrier_ = false; + bool ok_ = false; +}; + +} // namespace + +Napi::Value queueChainMutation(Napi::Env env, + std::function mutate, + bool releasesRebuildBarrier) +{ + auto deferred = Napi::Promise::Deferred::New(env); + auto* worker = new ChainMutationWorker(env, deferred, std::move(mutate), + releasesRebuildBarrier); + worker->Queue(); + return deferred.Promise(); +} + +Napi::Value queueChainSlotMutation(Napi::Env env, std::function mutate) +{ + auto deferred = Napi::Promise::Deferred::New(env); + auto* worker = new ChainSlotMutationWorker(env, deferred, std::move(mutate)); + worker->Queue(); + return deferred.Promise(); +} + +// ── decodeStateBlob (moved verbatim) ──────────────────── + +// Decode a state blob that may be in EITHER base64 flavour. JUCE's +// MemoryBlock::fromBase64Encoding only understands JUCE's own proprietary +// format (".") and returns false for standard RFC-4648 +// base64 — which is what the Python-side plugins (rig_builder et al.) emit +// for per-slot state. That silent false meant setState() was never called +// for those slots: IR stages lost their per-stage `gain` (the cab loudness +// makeup and amp trims never reached the engine). Try the JUCE format first +// (engine-native saves), then fall back to standard b64. +// +// `allowStandard` is only set for IR/NAM slots: their processors take a JSON +// state ({"irPath","gain"} / model path), which is exactly what the plugins +// emit. VST slots keep the JUCE-only decode — their plugin-emitted blobs are +// metadata wrappers, not real setStateInformation() chunks, and feeding those +// to a VST3 for the first time would be an unasked-for behaviour change. +bool decodeStateBlob(const juce::String& s, juce::MemoryBlock& mb, + bool allowStandard) +{ + if (mb.fromBase64Encoding(s) && mb.getSize() > 0) + return true; + if (!allowStandard) + return false; + mb.reset(); + juce::MemoryOutputStream mo(mb, false); + return juce::Base64::convertFromBase64(mo, s) && mb.getSize() > 0; +} + +double loadSafeSampleRate(const AudioEngine& eng) +{ + const double sr = eng.getCurrentSampleRate(); + return (std::isfinite(sr) && sr > 0.0) ? sr : 48000.0; +} + +int loadSafeBlockSize(const AudioEngine& eng) +{ + const int bs = eng.getCurrentBlockSize(); + return bs > 0 ? bs : 256; +} + +// ── loadVstSandboxAware (moved verbatim from NodeAddon.cpp) ──────────────────── + +// Load a VST3, routing it through the out-of-process sandbox when +// shouldSandbox() says so (the filename pre-seed or the runtime crash +// blocklist), otherwise loading it in-process. The in-process load uses +// VSTHost::loadPluginAsync so the JUCE message thread keeps pumping during +// the plugin's init — critical for plugins like AmpliTube that post WM_USER +// / WM_TIMER messages to themselves while initialising. The sync +// createPluginInstance would block the pump, those self-messages would +// queue forever, and the plugin would end up half-wired (a pointer that +// only gets written by a queued message stays null, and the editor crashes +// on its first WindowProc dispatch — the AmpliTube failure signature). +// +// Threading: on !JUCE_MAC must be called from a libuv worker thread (NOT +// the JS main thread, NOT the JUCE message thread) — the done->wait below +// has to be on a thread that *isn't* the one running JUCE's pump or the +// load can't complete. On JUCE_MAC the inline sync fallback is used and +// the caller can be the Node/main thread (which is also JUCE's message +// thread there); LoadVST does exactly that, while LoadPresetWorker still +// hits this from a worker (a pre-existing macOS limitation). +// +// On a *required*-sandbox failure (the plugin matched shouldSandbox but the +// sandbox couldn't spawn) this returns nullptr with `error` set and +// `sandboxRequired` true, so the caller can choose how to surface it — +// LoadVSTWorker throws to JS, LoadPresetWorker just skips the slot. +std::unique_ptr loadVstSandboxAware( + const juce::String& pluginPath, double sr, int bs, + juce::String& error, bool& sandboxRequired) +{ + sandboxRequired = false; + + // A plugin persisted in a signal-chain preset can be uninstalled or + // deleted between runs. Instantiating a VST3 whose module is gone from + // disk faults deep inside the format loader (a stack-buffer-overrun / + // 0xC0000409 on Windows) and takes the whole app down on startup — before + // the crash blocklist or sandbox can ever intervene, because the preset is + // restored independently of those guards. A native access violation also + // can't be caught by the renderer's JS try/catch around loadPreset. So + // pre-flight a cheap existence check here, the single choke point shared by + // every load path (direct LoadVST and preset restore, in-process and + // sandboxed, all platforms), and fail soft when the file is missing. + // + // Only filesystem paths are judged: VST3/LV2 fileOrIdentifiers are absolute + // paths (File::exists covers both a .vst3 file and a bundle directory), + // whereas macOS AudioUnit identifiers ("AudioUnit:...") are not absolute + // paths and must not be rejected here. + if (juce::File::isAbsolutePath(pluginPath) && ! juce::File(pluginPath).exists()) + { + error = "Plugin file not found: " + pluginPath; + VST_TRACE("loadVstSandboxAware: missing plugin file '%s' — skipping load", + pluginPath.toRawUTF8()); + return nullptr; + } + + juce::PluginDescription probeDesc; + probeDesc.fileOrIdentifier = pluginPath; + probeDesc.name = juce::File(pluginPath).getFileNameWithoutExtension(); + + if (slopsmith::sandbox::shouldSandbox(probeDesc)) + { + sandboxRequired = true; + juce::String sandboxErr; + auto processor = slopsmith::sandbox::tryLoadSandboxed( + probeDesc, sr, bs, sandboxErr); + if (!processor) + { + error = "sandbox load failed: " + + (sandboxErr.isEmpty() ? juce::String("unknown error") + : sandboxErr); + VST_TRACE("loadVstSandboxAware: sandbox path declined/failed: %s", + sandboxErr.toRawUTF8()); + } + return processor; + } + + #if JUCE_MAC + // macOS has no separate JUCE message thread (see startJuceMessageThread / + // dispatchOnMessageThread): the JUCE MessageManager is bound to the + // Node/main thread, and dispatchOnMessageThread historically ran inline + // on the caller. A callAsync + done->wait pattern would queue a callback + // to a pump that may never run in this calling context. + // + // Fall back to the sync loadPlugin, executed on whichever thread called + // in — the Node/main thread for LoadVST's JUCE_MAC branch (correct: that + // *is* the MessageManager thread on macOS), or a libuv worker thread for + // LoadPresetWorker (the pre-existing macOS constraint). Caveat: the + // existing dispatchOnMessageThread block on macOS already documents + // that "VST/AU plugin instantiation (which genuinely requires a message + // thread on macOS) is the one capability we give up until a proper + // libuv-based pump lands." LoadPresetWorker has called loadVstSandbox- + // Aware on a worker thread for ages under exactly the same constraint; + // moving LoadVST to AsyncWorker brings direct loads under the same + // (pre-existing) limitation. The AmpliTube-class self-message problem + // this PR targets is Windows-specific (Electron owns the OS main + // thread, forcing JUCE's MessageManager onto a background thread that + // createPluginInstance then blocks); macOS doesn't have that mismatch. + auto host = snapshotVstHost(); + if (! host) { error = "vstHost not initialised"; return nullptr; } + juce::String err; + auto instance = host->loadPlugin(pluginPath, sr, bs, err); + if (! instance) error = err.isNotEmpty() ? err : juce::String("load failed"); + return instance; + #else + // In-process: kick off createPluginInstanceAsync on the message thread, + // block *this* (libuv worker) thread on a WaitableEvent until the load + // callback fires. The message thread keeps pumping during the wait so + // the plugin's self-posted init messages dispatch and its state finishes + // wiring up before the editor is ever opened. + // + // All state passed across the thread hop is held by shared_ptr so it + // outlives the lambda even on an unexpected destructor / scope exit. + auto instance = std::make_shared>(); + auto loadError = std::make_shared(); + auto done = std::make_shared(); + + // Register BEFORE scheduling so a shutdown that lands between callAsync + // and the wait below can't miss us — cancelAllPendingLoads would + // otherwise see an empty set and the worker would block forever. + registerPendingLoad(done); + + // Check alreadyShutDown after registering to catch the inverse race + // (shutdown ran before we registered): if it's already set, the + // shutdown won't see this event and we must bail ourselves. + if (slopsmith::addon::isShuttingDown()) + { + unregisterPendingLoad(done); + error = "shutdown in flight"; + return nullptr; + } + + // Snapshot a shared_ptr to vstHost so the async load and its inner + // continuation can keep VSTHost (and thus formatManager) alive even if + // shutdown resets the global mid-load. The inner callback captures the + // same hostKeeper, so JUCE retains it until createPluginInstanceAsync + // completes; once the callback destructs, the keeper drops, and if the + // global has been reset by then the VSTHost destructor runs safely + // (no work in flight). The snapshot itself goes through vstHostMutex + // so the shared_ptr copy can't race with shutdown's vstHost.reset(). + auto hostKeeper = snapshotVstHost(); + + const bool scheduled = juce::MessageManager::callAsync( + [hostKeeper, pluginPath, sr, bs, instance, loadError, done]() + { + // Shutdown may have fired between callAsync queueing this + // lambda and the message thread picking it up. Bail before + // kicking off another in-flight createPluginInstanceAsync + // that the shutdown would otherwise have to wait on. + if (slopsmith::addon::isShuttingDown()) + { + *loadError = "shutdown in flight"; + done->signal(); + return; + } + if (! hostKeeper) + { + *loadError = "vstHost not initialised"; + done->signal(); + return; + } + hostKeeper->loadPluginAsync( + pluginPath, sr, bs, + [hostKeeper, instance, loadError, done] + (std::unique_ptr inst, juce::String err) + { + *instance = std::move(inst); + *loadError = std::move(err); + done->signal(); + }); + }); + + if (! scheduled) + { + // The message queue is gone (typically: shutdown in flight). The + // lambda will never run, so done would never signal — surface the + // failure rather than hanging the worker forever. + unregisterPendingLoad(done); + error = "message manager unavailable (shutdown?)"; + return nullptr; + } + + // No timeout: createPluginInstanceAsync is genuinely async (the message + // thread keeps pumping), so a slow first-run plugin (e.g. one doing a + // license check that exceeds 15 s) is allowed to take however long it + // takes. The old 15-second timeout in dispatchOnMessageThread could + // return early while the lambda was still running, then the lambda + // would construct a fully-initialised plugin only for it to immediately + // destruct because no one held a reference — running VST teardown on + // the message thread while the user had already moved on. That race is + // gone with this design. + // + // Tradeoff: this call holds a libuv threadpool worker for the duration + // of the plugin's init. Multiple concurrent hung loads could in theory + // starve other AsyncWorkers (fs / crypto). In practice plugin loads are + // user-driven and serialised (LoadPresetWorker loads slots one at a + // time), and a truly stuck load is bounded by app shutdown via + // cancelAllPendingLoads. A proper "fire-and-forget with a TSFN + // completion callback" model would eliminate the block entirely but + // requires a bigger API restructure than this PR's scope. + done->wait(); + unregisterPendingLoad(done); + + // Distinguish "shutdown cancelled us before the callback fired" + // (instance null AND error empty) from a normal load failure (instance + // null with error set) and a normal success. + if (! *instance && loadError->isEmpty()) + { + error = "load cancelled (shutdown)"; + return nullptr; + } + error = *loadError; + return std::move(*instance); + #endif +} + +// AsyncWorker wrapper for LoadVST. Execute() runs on a libuv worker thread, +// so loadVstSandboxAware can block-wait on the async load without freezing +// the JS main thread or deadlocking the JUCE message thread. + +// ── VST/NAM/IR workers + handlers (moved verbatim from NodeAddon.cpp) ──────────────────── + +class LoadVSTWorker : public Napi::AsyncWorker +{ +public: + LoadVSTWorker(Napi::Env env, Napi::Promise::Deferred deferred, std::string path) + : Napi::AsyncWorker(env) + , deferred_(deferred) + , pluginPath_(std::move(path)) {} + + void Execute() override + { + // Serialize the FULL mutation (TLC deep-read 1): overlapping chain + // workers on the libuv pool must not interleave clear()/addProcessor(). + std::lock_guard chainLock(slopsmith::addon::chainMutationMutex()); + // Snapshot engine + vstHost through their mutex-protected helpers so + // shutdown's reset on the message thread can't race the worker's + // dereferences below. The shared_ptr locals keep both objects alive + // for the duration of this worker even if the globals get reset + // mid-load. The atomic alreadyShutDown gate is the early-out: once + // it's set, the dispatched reset is on its way and there's no point + // continuing. + if (slopsmith::addon::isShuttingDown()) + { + error_ = "shutdown in flight"; + return; + } + auto engineKeeper = snapshotEngine(); + auto hostSnap = snapshotVstHost(); + if (!engineKeeper || !hostSnap) + { + error_ = "engine not initialised"; + return; + } + + const auto sr = loadSafeSampleRate(*engineKeeper); + const auto bs = loadSafeBlockSize(*engineKeeper); + const auto path = juce::String(pluginPath_); + VST_TRACE("LoadVSTWorker: path='%s' sr=%.0f bs=%d", + pluginPath_.c_str(), sr, bs); + + bool sandboxRequired = false; + juce::String err; + auto processor = loadVstSandboxAware(path, sr, bs, err, sandboxRequired); + + if (sandboxRequired && !processor) + { + // The plugin's on the denylist and the sandbox couldn't spawn — + // falling back to in-process is what crashed the addon to begin + // with. Surface as a JS exception (handled in OnOK). + fprintf(stderr, "[LoadVST] Failed: %s\n", err.toRawUTF8()); + error_ = err; + sandboxFailed_ = true; + return; + } + + if (!processor) + { + fprintf(stderr, "[LoadVST] Failed: %s\n", err.toRawUTF8()); + error_ = err; + return; + } + + // Engine may have been torn down while we were waiting on the async + // load. The shared_ptr captures keep `processor` alive; just don't + // touch a freed engine. The processor destructs cleanly when this + // scope exits. + // + // Gate on alreadyShutDown (atomic, properly synchronised) before the + // raw engine/vstHost pointer reads — once that flag is set, the + // dispatched reset of engine/vstHost is on its way and any use of + // the pointers from this worker thread is racy. The atomic check is + // the authoritative "should I still be touching engine?" signal. + if (slopsmith::addon::isShuttingDown()) + { + error_ = "engine torn down during load"; + return; + } + // Re-snapshot the engine — the original engineKeeper might have + // outlived a reset on the message thread, but the AudioEngine + // we're about to mutate must be the still-installed one. If the + // global has been reset, the local keeps the old engine alive but + // we shouldn't be adding slots to it any more. + auto liveEngine = snapshotEngine(); + if (!liveEngine || !snapshotVstHost()) + { + error_ = "engine torn down during load"; + return; + } + + auto name = processor->getName(); + slotId_ = liveEngine->getSignalChain().addProcessor( + std::move(processor), + ProcessorSlot::Type::VST, + name, + path); + if (slotId_ >= 0) + slopsmith::addon::bumpChainGeneration(); // still under chainLock + } + + void OnOK() override + { + if (sandboxFailed_) + { + // Match the prior LoadVST throw-on-required-sandbox-failure + // behaviour so renderers' try/catch keeps working. + deferred_.Reject( + Napi::Error::New(Env(), error_.toStdString()).Value()); + return; + } + deferred_.Resolve(Napi::Number::New(Env(), slotId_)); + } + + void OnError(const Napi::Error& e) override { deferred_.Reject(e.Value()); } + +private: + Napi::Promise::Deferred deferred_; + std::string pluginPath_; + int slotId_ = -1; + bool sandboxFailed_ = false; + juce::String error_; +}; + +Napi::Value LoadVST(const Napi::CallbackInfo& info) +{ + auto env = info.Env(); + auto deferred = Napi::Promise::Deferred::New(env); + + if (!snapshotEngine() || !snapshotVstHost() || info.Length() < 1) + { + deferred.Resolve(Napi::Number::New(env, -1)); + return deferred.Promise(); + } + + auto pluginPath = info[0].As().Utf8Value(); + + #if JUCE_MAC + // On macOS the JUCE MessageManager is bound to the Node/main thread. + // Running the LOAD as an AsyncWorker would call vstHost->loadPlugin on a + // libuv worker thread, which JUCE documents as unsupported for VST/AU + // instantiation — so the instantiation stays here, on the Node/main + // thread, and pays the foreground-block cost (the existing macOS reality; + // dispatchOnMessageThread already runs inline there). The async-load + // motivation (AmpliTube blocking the background JUCE message thread under + // Electron) is a Windows-only problem. + // + // The chain MUTATION, however, moves to a worker — see the comment above + // queueChainSlotMutation below for the deadlock that a blocking lock here + // caused. + // + // Snapshot once for the whole load so the same AudioEngine is used for + // the sr/bs reads and the addProcessor mutation, even if shutdown + // resets the global mid-call. + auto liveEngine = snapshotEngine(); + if (! liveEngine) + { + deferred.Resolve(Napi::Number::New(env, -1)); + return deferred.Promise(); + } + juce::String error; + bool sandboxRequired = false; + auto processor = loadVstSandboxAware( + juce::String(pluginPath), + loadSafeSampleRate(*liveEngine), + loadSafeBlockSize(*liveEngine), + error, sandboxRequired); + + if (sandboxRequired && !processor) + { + fprintf(stderr, "[LoadVST] Failed: %s\n", error.toRawUTF8()); + deferred.Reject( + Napi::Error::New(env, error.toStdString()).Value()); + return deferred.Promise(); + } + + if (! processor) + { + fprintf(stderr, "[LoadVST] Failed: %s\n", error.toRawUTF8()); + deferred.Resolve(Napi::Number::New(env, -1)); + return deferred.Promise(); + } + + // Serialize with the async chain workers (deep-read 1): an unguarded + // addProcessor here could land a slot inside a LoadPresetWorker's + // clear()+rebuild running on a libuv thread. + // + // But take the mutex on a WORKER, never on this (Node/main) thread. The + // previous blocking lock_guard here deadlocked macOS: a LoadPresetWorker + // holding the mutex calls JUCE's *synchronous* createPluginInstance, which — + // when called off the message thread, as it is on a libuv worker — posts an + // AsyncCreateMessage to the message thread and blocks on it + // (juce_AudioPluginFormat.cpp: createInstanceFromDescription). On macOS the + // message thread IS this Node/main thread, and it has no pump — its queue is + // drained by a libuv timer that only runs when this thread is idle. So + // blocking here stops the queue draining, the worker's load never completes, + // the mutex is never released, and the app hangs for good. + // + // The plugin INSTANTIATION above must stay on this thread (JUCE requires the + // message thread for VST/AU on macOS); only the mutation moves off it. While + // the worker waits for the mutex, this thread stays free to drain the queue, + // so the in-flight load can finish and release it. + // + // unique_ptr can't be captured by a std::function (copyable), so hand it over + // in a shared holder. + auto held = std::make_shared>(std::move(processor)); + auto name = (*held)->getName(); + return slopsmith::addon::queueChainSlotMutation( + env, [held, name, pluginPath](AudioEngine& eng) { + if (! *held) return -1; // a retry can't re-add an already-moved processor + return eng.getSignalChain().addProcessor( + std::move(*held), ProcessorSlot::Type::VST, name, juce::String(pluginPath)); + }); + #else + auto* worker = new LoadVSTWorker(env, deferred, std::move(pluginPath)); + worker->Queue(); + return deferred.Promise(); + #endif +} + +class LoadNAMWorker : public Napi::AsyncWorker +{ +public: + LoadNAMWorker(Napi::Env env, Napi::Promise::Deferred deferred, std::string path) + : Napi::AsyncWorker(env), deferred_(deferred), modelPath_(std::move(path)) {} + + void Execute() override + { + // Serialize the FULL mutation (TLC deep-read 1): overlapping chain + // workers on the libuv pool must not interleave clear()/addProcessor(). + std::lock_guard chainLock(slopsmith::addon::chainMutationMutex()); + auto liveEngine = snapshotEngine(); + if (!liveEngine) { slotId_ = -1; return; } + + auto processor = std::make_unique(); + if (processor->loadModel(juce::File(juce::String(modelPath_)))) + { + auto name = processor->getModelName(); + slotId_ = liveEngine->getSignalChain().addProcessor( + std::move(processor), + ProcessorSlot::Type::NAM, + "NAM: " + name, + juce::String(modelPath_)); + if (slotId_ >= 0) + slopsmith::addon::bumpChainGeneration(); // still under chainLock + } + } + + void OnOK() override { deferred_.Resolve(Napi::Number::New(Env(), slotId_)); } + void OnError(const Napi::Error& e) override { deferred_.Reject(e.Value()); } + +private: + Napi::Promise::Deferred deferred_; + std::string modelPath_; + int slotId_ = -1; +}; + +Napi::Value LoadNAMModel(const Napi::CallbackInfo& info) +{ + auto env = info.Env(); + auto deferred = Napi::Promise::Deferred::New(env); + + if (!snapshotEngine() || info.Length() < 1) { + deferred.Resolve(Napi::Number::New(env, -1)); + return deferred.Promise(); + } + + auto modelPath = info[0].As().Utf8Value(); + auto worker = new LoadNAMWorker(env, deferred, modelPath); + worker->Queue(); + return deferred.Promise(); +} + +class LoadIRWorker : public Napi::AsyncWorker +{ +public: + LoadIRWorker(Napi::Env env, Napi::Promise::Deferred deferred, std::string path) + : Napi::AsyncWorker(env), deferred_(deferred), irPath_(std::move(path)) {} + + void Execute() override + { + // Serialize the FULL mutation (TLC deep-read 1): overlapping chain + // workers on the libuv pool must not interleave clear()/addProcessor(). + std::lock_guard chainLock(slopsmith::addon::chainMutationMutex()); + auto liveEngine = snapshotEngine(); + if (!liveEngine) { slotId_ = -1; return; } + + const auto sr = loadSafeSampleRate(*liveEngine); + const auto bs = loadSafeBlockSize(*liveEngine); + auto processor = std::make_unique(); + processor->setPlayConfigDetails(2, 2, sr, bs); + processor->prepareToPlay(sr, bs); + if (processor->loadIR(juce::File(juce::String(irPath_)))) + { + auto name = processor->getIRName(); + slotId_ = liveEngine->getSignalChain().addProcessor( + std::move(processor), + ProcessorSlot::Type::IR, + "IR: " + name, + juce::String(irPath_)); + if (slotId_ >= 0) + slopsmith::addon::bumpChainGeneration(); // still under chainLock + } + } + + void OnOK() override { deferred_.Resolve(Napi::Number::New(Env(), slotId_)); } + void OnError(const Napi::Error& e) override { deferred_.Reject(e.Value()); } + +private: + Napi::Promise::Deferred deferred_; + std::string irPath_; + int slotId_ = -1; +}; + +Napi::Value LoadIR(const Napi::CallbackInfo& info) +{ + auto env = info.Env(); + auto deferred = Napi::Promise::Deferred::New(env); + + if (!snapshotEngine() || info.Length() < 1) { + deferred.Resolve(Napi::Number::New(env, -1)); + return deferred.Promise(); + } + + auto irPath = info[0].As().Utf8Value(); + auto worker = new LoadIRWorker(env, deferred, irPath); + worker->Queue(); + return deferred.Promise(); +} + +// Replace the IR of an EXISTING convolution slot in place (cab swap / mic move), +// so the rest of the chain — the amp VST above all — is NOT torn down and rebuilt. +// Mirrors LoadIRWorker but calls SignalChain::replaceProcessor(slotId, …) instead +// of addProcessor. Optional `gain` (>=0) updates the slot's post-gain (the cab +// makeup); a negative gain leaves the existing post-gain untouched. +class ReplaceIRWorker : public Napi::AsyncWorker +{ +public: + ReplaceIRWorker(Napi::Env env, Napi::Promise::Deferred deferred, + int slotId, std::string path, float gain) + : Napi::AsyncWorker(env), deferred_(deferred), + slotId_(slotId), irPath_(std::move(path)), gain_(gain) {} + + void Execute() override + { + // Serialize the FULL mutation (TLC deep-read 1): overlapping chain + // workers on the libuv pool must not interleave clear()/addProcessor(). + std::lock_guard chainLock(slopsmith::addon::chainMutationMutex()); + auto liveEngine = snapshotEngine(); + if (!liveEngine) { ok_ = false; return; } + + const auto sr = loadSafeSampleRate(*liveEngine); + const auto bs = loadSafeBlockSize(*liveEngine); + auto processor = std::make_unique(); + processor->setPlayConfigDetails(2, 2, sr, bs); + processor->prepareToPlay(sr, bs); + if (! processor->loadIR(juce::File(juce::String(irPath_)))) { ok_ = false; return; } + + auto name = processor->getIRName(); + ok_ = liveEngine->getSignalChain().replaceProcessor( + slotId_, std::move(processor), + "IR: " + name, juce::String(irPath_)); + if (ok_ && gain_ >= 0.0f) + liveEngine->getSignalChain().setPostGain(slotId_, gain_); + if (ok_) + slopsmith::addon::bumpChainGeneration(); // still under chainLock + } + + void OnOK() override { deferred_.Resolve(Napi::Boolean::New(Env(), ok_)); } + void OnError(const Napi::Error& e) override { deferred_.Reject(e.Value()); } + +private: + Napi::Promise::Deferred deferred_; + int slotId_; + std::string irPath_; + float gain_; + bool ok_ = false; +}; + +Napi::Value ReplaceIR(const Napi::CallbackInfo& info) +{ + auto env = info.Env(); + auto deferred = Napi::Promise::Deferred::New(env); + + if (!snapshotEngine() || info.Length() < 2 + || !info[0].IsNumber() || !info[1].IsString()) { + deferred.Resolve(Napi::Boolean::New(env, false)); + return deferred.Promise(); + } + + const int slotId = info[0].As().Int32Value(); + const auto irPath = info[1].As().Utf8Value(); + const float gain = (info.Length() >= 3 && info[2].IsNumber()) + ? info[2].As().FloatValue() : -1.0f; + + auto worker = new ReplaceIRWorker(env, deferred, slotId, irPath, gain); + worker->Queue(); + return deferred.Promise(); +} + + +// ── LoadPresetWorker + handler (moved verbatim from NodeAddon.cpp) ──────────────────── + +class LoadPresetWorker : public Napi::AsyncWorker +{ +public: + LoadPresetWorker(Napi::Env env, Napi::Promise::Deferred deferred, std::string json) + : Napi::AsyncWorker(env), deferred_(deferred), presetJson_(std::move(json)) {} + + void Execute() override + { + // Release the rebuild barrier LoadPreset() armed before editor + // teardown, on every exit path — editors may open again once the + // rebuild below has completed (or bailed). + struct BarrierRelease { + ~BarrierRelease() { slopsmith::addon::endChainRebuild(); } + } barrierRelease; + + // Serialize the FULL mutation (TLC deep-read 1): overlapping chain + // workers on the libuv pool must not interleave clear()/addProcessor(). + std::lock_guard chainLock(slopsmith::addon::chainMutationMutex()); + auto liveEngine = snapshotEngine(); + if (!liveEngine) { success_ = false; error_ = "No engine"; return; } + + auto parsed = juce::JSON::parse(juce::String(presetJson_)); + if (!parsed.isObject()) { success_ = false; error_ = "Invalid JSON"; return; } + + auto* root = parsed.getDynamicObject(); + if (!root) { success_ = false; error_ = "Invalid preset"; return; } + + auto chainVar = root->getProperty("chain"); + auto* chainArray = chainVar.getArray(); + if (!chainArray) { success_ = false; error_ = "No chain array"; return; } + + // NB: any open in-process editor windows were already torn down on the + // message thread by LoadPreset() before this AsyncWorker was queued (see + // there) — so clearing the chain here can't leave an editor pointing at + // a freed processor (use-after-free; #56). We deliberately do NOT tear + // editors down from this worker thread: JUCE GUI objects must only be + // destroyed on the message thread, and macOS has no pump to marshal to + // from here. + // Clear existing chain + liveEngine->getSignalChain().clear(); + + double sr = loadSafeSampleRate(*liveEngine); + int bs = loadSafeBlockSize(*liveEngine); + + for (auto& slotVar : *chainArray) + { + auto* slotObj = slotVar.getDynamicObject(); + if (!slotObj) continue; + + int type = (int)slotObj->getProperty("type"); + auto name = slotObj->getProperty("name").toString(); + auto path = slotObj->getProperty("path").toString(); + bool bypassed = (bool)slotObj->getProperty("bypassed"); + auto stateB64 = slotObj->getProperty("state").toString(); + + std::unique_ptr processor; + + if (type == (int)ProcessorSlot::Type::VST && snapshotVstHost()) + { + // Sandbox-aware load: a crash-blocklisted plugin restored + // from a preset must still go out-of-process, otherwise the + // "one crash, then always sandbox" contract is defeated. + juce::String err; + bool sandboxRequired = false; + processor = loadVstSandboxAware(path, sr, bs, err, sandboxRequired); + if (!processor) + { + fprintf(stderr, "[LoadPreset] VST load failed: %s (%s)\n", + name.toRawUTF8(), err.toRawUTF8()); + continue; + } + } + else if (type == (int)ProcessorSlot::Type::NAM) + { + auto nam = std::make_unique(); + if (!nam->loadModel(juce::File(path))) + { + fprintf(stderr, "[LoadPreset] NAM load failed: %s\n", path.toRawUTF8()); + continue; + } + processor = std::move(nam); + } + else if (type == (int)ProcessorSlot::Type::IR) + { + auto ir = std::make_unique(); + ir->setPlayConfigDetails(2, 2, sr, bs); + ir->prepareToPlay(sr, bs); + if (!ir->loadIR(juce::File(path))) + { + fprintf(stderr, "[LoadPreset] IR load failed: %s\n", path.toRawUTF8()); + continue; + } + processor = std::move(ir); + } + else continue; + + int slotId = liveEngine->getSignalChain().addProcessor( + std::move(processor), + (ProcessorSlot::Type)type, + name, path); + + if (bypassed && slotId >= 0) + liveEngine->getSignalChain().setBypass(slotId, true); + + // Stereo routing (St-1). Absent keys read back as 0 (= default), so + // mono presets restore exactly as before. + if (slotId >= 0) + { + if (slotObj->hasProperty("pan")) + liveEngine->getSignalChain().setPan(slotId, (float)(double)slotObj->getProperty("pan")); + if (slotObj->hasProperty("branch")) + liveEngine->getSignalChain().setBranch(slotId, (int)slotObj->getProperty("branch")); + if (slotObj->hasProperty("postGain")) + liveEngine->getSignalChain().setPostGain(slotId, (float)(double)slotObj->getProperty("postGain")); + if (slotObj->hasProperty("branchSrc")) + liveEngine->getSignalChain().setBranchSrc(slotId, (int)slotObj->getProperty("branchSrc")); + } + + // Restore processor state (JUCE-format base64; IR/NAM slots also + // accept standard base64 — see decodeStateBlob: their plugin- + // emitted JSON states were silently dropped before, so IR stages + // never got their per-stage gain). + if (stateB64.isNotEmpty() && slotId >= 0) + { + const bool allowStandard = type == (int)ProcessorSlot::Type::IR + || type == (int)ProcessorSlot::Type::NAM; + juce::MemoryBlock state; + if (decodeStateBlob(stateB64, state, allowStandard)) + { + // Through the class's own synchronized API (deep-read 9) -- + // no more const_cast around setSlotState's locking. + liveEngine->getSignalChain().setSlotState(slotId, state); + } + } + + slotsLoaded_++; + } + + success_ = true; + generation_ = slopsmith::addon::bumpChainGeneration(); // still under chainLock + } + + void OnOK() override + { + auto obj = Napi::Object::New(Env()); + obj.Set("success", success_); + obj.Set("slotsLoaded", slotsLoaded_); + obj.Set("chainGeneration", (double) generation_); + if (!success_) obj.Set("error", error_); + deferred_.Resolve(obj); + } + void OnError(const Napi::Error& e) override { deferred_.Reject(e.Value()); } + +private: + Napi::Promise::Deferred deferred_; + std::string presetJson_; + uint64_t generation_ = 0; + bool success_ = false; + std::string error_; + int slotsLoaded_ = 0; +}; + +Napi::Value LoadPreset(const Napi::CallbackInfo& info) +{ + auto env = info.Env(); + auto deferred = Napi::Promise::Deferred::New(env); + auto liveEngine = snapshotEngine(); + + if (!liveEngine || info.Length() < 1) { + auto obj = Napi::Object::New(env); + obj.Set("success", false); + obj.Set("error", "No engine or missing argument"); + deferred.Resolve(obj); + return deferred.Promise(); + } + + // Validate + read the argument BEFORE arming the barrier: with a + // non-string arg, As() throws (JS TypeError / C++ + // exception), and anything thrown between begin and the worker taking + // ownership would leak the barrier and block editor opens forever. + if (!info[0].IsString()) + { + auto obj = Napi::Object::New(env); + obj.Set("success", false); + obj.Set("error", "preset must be a JSON string"); + deferred.Resolve(obj); + return deferred.Promise(); + } + auto json = info[0].As().Utf8Value(); + + // Arm the rebuild barrier BEFORE editor teardown: between closeAll…() + // returning and the queued worker acquiring chainMutationMutex, nothing + // else stops OpenPluginEditor from opening a fresh editor whose processor + // the worker is about to free (#56). The barrier gates editor opens for + // the whole teardown+rebuild window. + // + // Ownership passes to the worker (whose BarrierRelease covers every + // Execute() exit) only once Queue() has actually taken it. Until then this + // guard holds it, so any early return — or a throwing allocation — releases + // instead of leaking a barrier that would block editor opens forever. + slopsmith::addon::beginChainRebuild(); + bool barrierHandedOff = false; + struct BarrierGuard { + const bool& handedOff; + ~BarrierGuard() { if (!handedOff) slopsmith::addon::endChainRebuild(); } + } barrierGuard{ barrierHandedOff }; + + // Tear down any open in-process editor windows NOW, on the N-API/main + // thread, before the AsyncWorker frees the chain's processors on a libuv + // worker (#56). Doing it here — not inside LoadPresetWorker::Execute — keeps + // JUCE GUI teardown off the worker thread: on macOS this thread IS the + // message thread (inline teardown); on Linux/Windows closeAllPluginEditor- + // Windows() posts to the dedicated JUCE message thread and blocks. Either + // way editors are destroyed before Execute() clears the chain. + if (!closeAllPluginEditorWindows()) + { + // Teardown refused or timed out: an editor may still be alive and + // bound to a chain processor. Clearing/rebuilding now would free that + // processor under the live editor — the documented UAF. Abort the + // load instead of proceeding. + auto obj = Napi::Object::New(env); + obj.Set("success", false); + obj.Set("error", "editor teardown did not complete; preset load aborted"); + deferred.Resolve(obj); + return deferred.Promise(); + } + + auto worker = new LoadPresetWorker(env, deferred, std::move(json)); + worker->Queue(); + barrierHandedOff = true; + return deferred.Promise(); +} + + +} // namespace slopsmith::addon diff --git a/src/audio/addon/ChainOps.h b/src/audio/addon/ChainOps.h new file mode 100644 index 0000000..89fbf60 --- /dev/null +++ b/src/audio/addon/ChainOps.h @@ -0,0 +1,114 @@ +#pragma once + +// ChainOps — the native chain-mutation serialization point (TLC plan phase 7 +// / §3.3, deep-read §1). +// +// The five chain-mutating async workers (LoadPreset/LoadVST/LoadNAM/LoadIR/ +// ReplaceIR) queue on the libuv threadpool with no mutual exclusion, and +// SignalChain locks per-operation only — so two overlapping loadPreset calls +// could interleave clear()/addProcessor() and merge both presets into +// garbage (the documented rig_builder-vs-bundle "~1ms later" race). One +// mutex held across each worker's FULL Execute() — and across the +// synchronous mutators (clearChain / remove / move) — converts that +// corruption into last-writer-wins. +// +// chainGeneration is bumped on every completed mutation and returned in the +// load results (and via getChainGeneration), so JS-side owners (the +// audio-effects executor's stageSlots map) can detect that another writer +// changed the chain under them and re-sync instead of flipping bypass/params +// on the wrong slots. +// +// The full worker bodies migrate into this unit with the phase-7 binding +// split; the serializer lands first so the storm gate flips. + +#include + +#include + +#include +#include +#include +#include + +class AudioEngine; +namespace juce { class AudioProcessor; } + +namespace slopsmith::addon { + +// Held for the FULL clear+rebuild (or single-slot mutation). Control/worker +// threads only — never the audio thread. +std::mutex& chainMutationMutex(); + +// Monotonic, bumped AFTER a completed mutation (under the mutex). 0 = never +// mutated. +uint64_t bumpChainGeneration(); +uint64_t currentChainGeneration(); + +// Usage in a mutator: +// std::lock_guard chainLock(chainMutationMutex()); +// ... clear/rebuild/add ... +// const uint64_t gen = bumpChainGeneration(); // still under the lock +// (return gen in the result object) +// +// ...but ONLY from a libuv worker thread. NOTHING may take this mutex with a +// blocking lock on the N-API/JS thread: LoadPresetWorker holds it across the +// full clear+rebuild, which includes an unbounded in-process plugin init +// (loadVstSandboxAware's done->wait() has no timeout — a slow first-run plugin +// is allowed to take as long as it needs). A blocking lock on the JS thread +// would therefore freeze the whole Electron main process — every IPC channel +// with it — for the duration of a plugin load, and on macOS (where the N-API +// thread IS the JUCE message thread — see AddonContext's startJuceMessageThread) +// it would deadlock the very pump that load is waiting on. The message-thread +// call sites in EditorWindows use try_to_lock for exactly this reason; the +// synchronous chain mutators go through queueChainMutation instead. +// +// Run `mutate` on a libuv worker under chainMutationMutex(), bump the chain +// generation, and resolve the returned promise with true (false if the engine +// went away). When `releasesRebuildBarrier`, the worker calls endChainRebuild() +// on every exit path — the caller must have armed it with beginChainRebuild() +// before tearing editors down. +Napi::Value queueChainMutation(Napi::Env env, + std::function mutate, + bool releasesRebuildBarrier = false); + +// Same, for a mutation that yields a slot id: resolves the returned Number +// (-1 when the engine went away). Used by LoadVST's macOS branch, where the +// plugin must be INSTANTIATED on the Node/main thread but its addProcessor +// must not be, because the mutex it needs can be held by a worker that is +// itself waiting on that thread. +Napi::Value queueChainSlotMutation(Napi::Env env, std::function mutate); + +// ── Rebuild barrier (editor-open gate) ──────────────────────────────────── +// A chain clear/rebuild is a two-step dance: editors are torn down on the +// message thread FIRST, then the mutation runs (synchronously for ClearChain, +// on a queued AsyncWorker for LoadPreset). Between those steps the mutation +// mutex is NOT yet held, so an editor opened in that window would point at a +// processor the imminent clear is about to free (#56). Callers bracket the +// whole teardown+mutation with begin/end; OpenPluginEditor refuses to open +// while any rebuild is pending. Counter (not bool): overlapping LoadPreset + +// ClearChain must not un-gate each other early. +void beginChainRebuild(); +void endChainRebuild(); +bool isChainRebuildPending(); + +// ── Shared load helpers (used by the workers here and SetSlotState) ────── +// Decode a state blob in EITHER base64 flavour (JUCE-proprietary first, +// standard RFC-4648 fallback when `allowStandard` — IR/NAM slots only). +bool decodeStateBlob(const juce::String& s, juce::MemoryBlock& mb, bool allowStandard); +double loadSafeSampleRate(const AudioEngine& eng); +int loadSafeBlockSize(const AudioEngine& eng); +// Load a VST3 through the out-of-process sandbox when shouldSandbox() says +// so, else in-process via the async message-pumping path. See the .cpp for +// the threading contract. +std::unique_ptr loadVstSandboxAware( + const juce::String& pluginPath, double sr, int bs, + juce::String& error, bool& sandboxRequired); + +// ── N-API handlers (registered by NodeAddon's export table) ────────────── +Napi::Value LoadVST(const Napi::CallbackInfo& info); +Napi::Value LoadNAMModel(const Napi::CallbackInfo& info); +Napi::Value LoadIR(const Napi::CallbackInfo& info); +Napi::Value ReplaceIR(const Napi::CallbackInfo& info); +Napi::Value LoadPreset(const Napi::CallbackInfo& info); + +} // namespace slopsmith::addon diff --git a/src/audio/addon/ControlBindings.cpp b/src/audio/addon/ControlBindings.cpp new file mode 100644 index 0000000..470fa94 --- /dev/null +++ b/src/audio/addon/ControlBindings.cpp @@ -0,0 +1,367 @@ +// Gain/metering/MIDI/debug-logging bindings - moved verbatim from NodeAddon.cpp (TLC phase 7b +// binding split). Registered by NodeAddon's export table via Bindings.h. + +#include "Bindings.h" + +#include "AddonContext.h" +#include "NapiHelpers.h" +#include "ChainOps.h" +#include "../AudioEngine.h" +#include "../VSTHost.h" +#include "../VSTTrace.h" + +#include +#include +#include +#include + +namespace slopsmith::addon { + +// ── Gain ────────────────────────────────────────────────────────────────────── + +Napi::Value SetGain(const Napi::CallbackInfo& info) +{ + auto env = info.Env(); + auto liveEngine = snapshotEngine(); + if (!liveEngine || info.Length() < 2) return env.Undefined(); + + if (!info[0].IsString()) return env.Undefined(); + auto which = info[0].As().Utf8Value(); + const auto valueOpt = slopsmith::addon::argFiniteFloat(info, 1); + if (!valueOpt) return env.Undefined(); // engine clamps range; NaN/Inf rejected here + const float value = *valueOpt; + + if (which == "input") liveEngine->setInputGain(value); + else if (which == "output") liveEngine->setOutputGain(value); + else if (which == "chain") liveEngine->setChainOutputGain(value); + else if (which == "backing") liveEngine->setBackingVolume(value); + + return env.Undefined(); +} + +Napi::Value SetInputChannel(const Napi::CallbackInfo& info) +{ + auto liveEngine = snapshotEngine(); + if (liveEngine && info.Length() > 0) + liveEngine->setInputChannel(info[0].As().Int32Value()); + return info.Env().Undefined(); +} + +Napi::Value SetMonitorMute(const Napi::CallbackInfo& info) +{ + auto liveEngine = snapshotEngine(); + if (liveEngine && info.Length() > 0) + liveEngine->setMonitorMute(info[0].As().Value()); + return info.Env().Undefined(); +} + +// setNoteDetectionEnabled(bool) -> undefined. Arms/suspends the polyphonic ML +// note-detection pipeline across all sources. The renderer (note_detect) calls +// this true only while a consumer actually reads ML notes (native-frame +// detection / non-verifier fallback) and false otherwise — the default +// harmonic-comb verifier path and the always-on home tuner leave ML suspended, +// so the engine runs no ONNX inference when nothing needs it. +Napi::Value SetNoteDetectionEnabled(const Napi::CallbackInfo& info) +{ + auto liveEngine = snapshotEngine(); + if (liveEngine && info.Length() > 0) + liveEngine->setMlNoteDetectionEnabled(info[0].As().Value()); + return info.Env().Undefined(); +} + +Napi::Value SetMonitorMuteSuppressed(const Napi::CallbackInfo& info) +{ + // IsBoolean()-guarded so a mismatched renderer build / manual caller + // passing a non-boolean is a clean no-op rather than a hard N-API failure + // (NAPI_DISABLE_CPP_EXCEPTIONS is enabled). Mirrors SetNoiseGate's style. + auto liveEngine = snapshotEngine(); + if (liveEngine && info.Length() > 0 && info[0].IsBoolean()) + liveEngine->setMonitorMuteSuppressed(info[0].As().Value()); + return info.Env().Undefined(); +} + +// Refcounted force-mute overrides (monitor-mute arbiter, TLC Part II §2). +// The audio-effects executor holds one across a chain load and RELEASES it +// afterwards — it never reads/writes the user's mute preference anymore. +Napi::Value AcquireMonitorMuteHold(const Napi::CallbackInfo& info) +{ + if (auto liveEngine = snapshotEngine()) + liveEngine->acquireMonitorMuteHold(); + return info.Env().Undefined(); +} + +Napi::Value ReleaseMonitorMuteHold(const Napi::CallbackInfo& info) +{ + if (auto liveEngine = snapshotEngine()) + liveEngine->releaseMonitorMuteHold(); + return info.Env().Undefined(); +} + +// Diagnostic/testing view of the arbiter's three inputs. +Napi::Value GetMonitorMuteState(const Napi::CallbackInfo& info) +{ + auto env = info.Env(); + auto obj = Napi::Object::New(env); + if (auto liveEngine = snapshotEngine()) + { + obj.Set("userMute", liveEngine->isMonitorMuted()); + obj.Set("holds", liveEngine->getMonitorMuteHoldCount()); + obj.Set("suppressions", liveEngine->getMonitorMuteSuppressCount()); + } + return obj; +} + +Napi::Value SetMonitorKill(const Napi::CallbackInfo& info) +{ + // IsBoolean()-guarded (fail-soft no-op on a downlevel/mismatched caller), + // mirroring SetMonitorMuteSuppressed. + auto liveEngine = snapshotEngine(); + if (liveEngine && info.Length() > 0 && info[0].IsBoolean()) + liveEngine->setMonitorKill(info[0].As().Value()); + return info.Env().Undefined(); +} + +Napi::Value SetNoiseGate(const Napi::CallbackInfo& info) +{ + auto env = info.Env(); + auto liveEngine = snapshotEngine(); + if (!liveEngine || info.Length() < 1 || !info[0].IsObject()) + return env.Undefined(); + + auto o = info[0].As(); + + bool enabled = false; + if (o.Has("enabled")) + { + auto v = o.Get("enabled"); + if (v.IsBoolean()) + enabled = v.As().Value(); + else if (v.IsNumber()) + enabled = v.As().DoubleValue() != 0.0; + } + + float thresholdDb = -60.0f; + if (o.Has("thresholdDb") && o.Get("thresholdDb").IsNumber()) + thresholdDb = (float)o.Get("thresholdDb").As().DoubleValue(); + + float releaseMs = 100.0f; + if (o.Has("releaseMs") && o.Get("releaseMs").IsNumber()) + releaseMs = (float)o.Get("releaseMs").As().DoubleValue(); + + float depthDb = -60.0f; + if (o.Has("depthDb") && o.Get("depthDb").IsNumber()) + depthDb = (float)o.Get("depthDb").As().DoubleValue(); + + liveEngine->setNoiseGate(enabled, thresholdDb, releaseMs, depthDb); + return env.Undefined(); +} + +Napi::Value SetTonePolish(const Napi::CallbackInfo& info) +{ + // Tone Polish — { enabled: bool }. Mirrors SetNoiseGate's defensive + // shape so a mismatched renderer build / manual caller passing a + // non-object is a clean no-op rather than a hard N-API failure + // (NAPI_DISABLE_CPP_EXCEPTIONS). + auto env = info.Env(); + auto liveEngine = snapshotEngine(); + if (!liveEngine || info.Length() < 1 || !info[0].IsObject()) + return env.Undefined(); + + auto o = info[0].As(); + + bool enabled = true; + if (o.Has("enabled")) + { + auto v = o.Get("enabled"); + if (v.IsBoolean()) + enabled = v.As().Value(); + else if (v.IsNumber()) + enabled = v.As().DoubleValue() != 0.0; + } + + liveEngine->setTonePolishEnabled(enabled); + return env.Undefined(); +} + +Napi::Value IsMonitorMuted(const Napi::CallbackInfo& info) +{ + auto liveEngine = snapshotEngine(); + return Napi::Boolean::New(info.Env(), liveEngine ? liveEngine->isMonitorMuted() : true); +} + +// ── Metering (polled — read atomics) ────────────────────────────────────────── + +Napi::Value GetLevels(const Napi::CallbackInfo& info) +{ + auto env = info.Env(); + auto obj = Napi::Object::New(env); + auto liveEngine = snapshotEngine(); + + if (liveEngine) + { + obj.Set("inputLevel", liveEngine->getInputLevel()); + obj.Set("outputLevel", liveEngine->getOutputLevel()); + obj.Set("inputPeak", liveEngine->getInputPeak()); + obj.Set("outputPeak", liveEngine->getOutputPeak()); + } + else + { + obj.Set("inputLevel", 0.0); + obj.Set("outputLevel", 0.0); + obj.Set("inputPeak", 0.0); + obj.Set("outputPeak", 0.0); + } + + return obj; +} + +// getSourceLevels(sourceId) -> { inputLevel, inputPeak, outputLevel, outputPeak }. +// Per-source INPUT level so a bound detector's silence gate reads ITS OWN device's +// signal (not the global/primary level — which would force-fail every hit on an +// extra device the user is actually playing). Output fields mirror the master and +// are 0 (monitoring is post-mix / engine-global). Bad id -> all zeros. +Napi::Value GetSourceLevels(const Napi::CallbackInfo& info) +{ + auto env = info.Env(); + auto obj = Napi::Object::New(env); + auto liveEngine = snapshotEngine(); + SourceChain* s = (liveEngine && info.Length() >= 1 && info[0].IsNumber()) + ? getValidatedSource(liveEngine.get(), info, 0) : nullptr; + obj.Set("inputLevel", s ? (double) s->getInputLevel() : 0.0); + obj.Set("inputPeak", s ? (double) s->getInputPeak() : 0.0); + obj.Set("outputLevel", 0.0); + obj.Set("outputPeak", 0.0); + return obj; +} + +Napi::Value ResetPeaks(const Napi::CallbackInfo& info) +{ + if (auto liveEngine = snapshotEngine()) liveEngine->resetPeaks(); + return info.Env().Undefined(); +} + +// Backing-track mix bus RMS level — the engine's per-block running RMS after +// the backing volume fader but before the output-gain master. Returns 0.0 when +// the engine is unavailable or no backing track is loaded. Reads an atomic so +// it is safe to call from the JS thread without blocking the audio thread. +Napi::Value GetBackingLevel(const Napi::CallbackInfo& info) +{ + auto liveEngine = snapshotEngine(); + return Napi::Number::New(info.Env(), liveEngine ? liveEngine->getBackingLevel() : 0.0f); +} + +// ── MIDI ────────────────────────────────────────────────────────────────────── + +Napi::Value SendMidiToSlot(const Napi::CallbackInfo& info) +{ + auto env = info.Env(); + auto liveEngine = snapshotEngine(); + if (!liveEngine || info.Length() < 4) + return Napi::Boolean::New(env, false); + + // Typed + range-checked: unclamped channel/program used to trip JUCE + // assertions (deep-read §2). Out-of-range now returns false cleanly. + const auto slotId = slopsmith::addon::argSlotId(info, 0); + const auto msgType = slopsmith::addon::argInt(info, 1, 0, 1); + const auto channel = slopsmith::addon::argMidiChannel(info, 2); + if (!slotId || !msgType || !channel) + return Napi::Boolean::New(env, false); + + juce::MidiMessage midiMsg; + if (*msgType == 0) // Program Change + { + const auto program = slopsmith::addon::argMidiByte(info, 3); + if (!program) return Napi::Boolean::New(env, false); + midiMsg = juce::MidiMessage::programChange(*channel, *program); + } + else // Control Change + { + const auto controller = slopsmith::addon::argMidiByte(info, 3); + if (!controller) return Napi::Boolean::New(env, false); + const auto value = slopsmith::addon::argMidiByte(info, 4); + midiMsg = juce::MidiMessage::controllerEvent(*channel, *controller, value.value_or(0)); + } + + liveEngine->getSignalChain().queueMidiMessage(*slotId, midiMsg); + return Napi::Boolean::New(env, true); +} + +// ── Debug file logging ──────────────────────────────────────────────────────── + +// Redirect the C runtime's stderr stream to a file so the native +// [AudioEngine] / [audio-native] diagnostics are captured for a bug report on +// machines with no console (packaged Windows builds). Only invoked when +// SLOPSMITH_DEBUG is set. Returns "" on success, or an error description the +// JS layer logs as an [audio] line. +// +// freopen (not dup2): a packaged GUI-subsystem app has no console, so stderr +// has no valid fd — dup2 onto fileno(stderr) fails. freopen reassigns the +// stream itself and works with or without a console. freopen would close +// stderr before trying the path, so a bad path is ruled out FIRST with a +// throwaway fopen probe (which never touches stderr); only once the path is +// known-writable do we freopen. Append mode so the JS layer's header +// survives; unbuffered so a crash leaves a complete tail. +Napi::Value EnableFileLogging(const Napi::CallbackInfo& info) +{ + auto env = info.Env(); + if (info.Length() < 1 || !info[0].IsString()) + { + Napi::TypeError::New(env, "enableFileLogging(path) requires a string") + .ThrowAsJavaScriptException(); + return env.Undefined(); + } + +#if defined(_WIN32) + // Widen UTF-16 → wchar_t by value-converting each code unit (not a + // reinterpret_cast — char16_t and wchar_t are distinct types even though + // both are 16-bit on Windows). Wide path so a profile dir with non-ASCII + // characters isn't mangled by the ANSI codepage (cf. src/vst-host/main.cpp, + // which uses the GetEnvironmentVariableW / _wfopen wide path for the same + // reason). + const std::u16string u16 = info[0].As().Utf16Value(); + const std::wstring wpath(u16.begin(), u16.end()); + FILE* probe = _wfopen(wpath.c_str(), L"a"); +#else + const std::string path = info[0].As().Utf8Value(); + FILE* probe = std::fopen(path.c_str(), "a"); +#endif + if (probe == nullptr) + { + // Capture errno before Napi::String::New / std::to_string, which may + // call library code that clobbers it. + const int e = errno; + return Napi::String::New(env, std::string("fopen failed (errno=") + + std::to_string(e) + ")"); + } + std::fclose(probe); // path is writable; stderr never touched on this path + +#if defined(_WIN32) + FILE* fp = _wfreopen(wpath.c_str(), L"a", stderr); +#else + FILE* fp = std::freopen(path.c_str(), "a", stderr); +#endif + if (fp == nullptr) + { + const int e = errno; + // freopen closes stderr before trying the path; on failure it's left + // closed. The probe just verified the path, so this is near-impossible + // — but redirect stderr to the null device so it's a valid sink rather + // than a closed stream that could trip later fprintf(stderr) calls. +#if defined(_WIN32) + std::freopen("NUL", "w", stderr); +#else + std::freopen("/dev/null", "w", stderr); +#endif + return Napi::String::New(env, std::string("freopen failed (errno=") + + std::to_string(e) + ")"); + } + + // Unbuffered: each [AudioEngine] fprintf hits disk immediately, so a + // crash mid-reconfigure still leaves the diagnostic line that explains it. + std::setvbuf(stderr, nullptr, _IONBF, 0); + std::fprintf(stderr, "[audio-native] file logging enabled\n"); + return Napi::String::New(env, ""); // empty = success +} + + +} // namespace slopsmith::addon diff --git a/src/audio/addon/DetectionBindings.cpp b/src/audio/addon/DetectionBindings.cpp new file mode 100644 index 0000000..e82aeb8 --- /dev/null +++ b/src/audio/addon/DetectionBindings.cpp @@ -0,0 +1,589 @@ +// Pitch detection + source-indexed bindings - moved verbatim from NodeAddon.cpp (TLC phase 7b +// binding split). Registered by NodeAddon's export table via Bindings.h. + +#include "Bindings.h" + +#include "AddonContext.h" +#include "NapiHelpers.h" +#include "ChainOps.h" +#include "../AudioEngine.h" +#include "../VSTHost.h" +#include "../VSTTrace.h" + +#include +#include +#include +#include + +namespace slopsmith::addon { + +// Validate a JS source-id argument and return the live source, or nullptr if it is +// missing / not a Number / not a FINITE INTEGER / out of range. The TS bridge already +// validates, but the addon must fail soft on its own: Int32Value() silently coerces +// NaN/Infinity into a valid index (NaN -> 0), which would let a malformed id hit a +// real source (e.g. the default source 0). getSource() does the final +// [0, kMaxSources) + active check; the 4096 guard keeps the cast well-defined. +SourceChain* getValidatedSource(AudioEngine* eng, const Napi::CallbackInfo& info, size_t argIndex) +{ + if (eng == nullptr || argIndex >= info.Length() || ! info[argIndex].IsNumber()) + return nullptr; + const double raw = info[argIndex].As().DoubleValue(); + if (! std::isfinite(raw) || raw != std::floor(raw) || raw < 0.0 || raw > 4096.0) + return nullptr; + return eng->getSource((int) raw); +} + +// ── Pitch Detection (polled) ────────────────────────────────────────────────── + +// Load the Basic Pitch ONNX model for the polyphonic ML note detector. +// Called once at startup by audio-bridge.ts with the bundled model path. +// Never throws. Returns "is ML note detection available after this call" — +// a model is loaded with a valid contract. A missing/invalid file does NOT +// tear down an already-loaded model, so it can still return true; it returns +// false when the engine isn't ready or ONNX support isn't compiled in, and +// the engine then keeps using the YIN PitchDetector / ChordScorer +// (Constitution VII). +Napi::Value LoadNoteModel(const Napi::CallbackInfo& info) +{ + auto env = info.Env(); + auto liveEngine = snapshotEngine(); + if (!liveEngine || info.Length() < 1 || !info[0].IsString()) + return Napi::Boolean::New(env, false); + + const auto path = info[0].As().Utf8Value(); + const bool ok = liveEngine->loadNoteModel(juce::File(juce::String(path))); + return Napi::Boolean::New(env, ok); +} + +// Whether the ML note detector is active (ONNX support compiled in AND a +// model loaded). Lets the renderer / tests tell the ML path from the YIN +// fallback without inferring it from behaviour. +Napi::Value IsMlNoteDetection(const Napi::CallbackInfo& info) +{ + auto env = info.Env(); + // Report readiness, not just model-loaded: the engine only routes + // getPitchDetection()/scoreChord() to ML once the detector has published + // its first snapshot (isReady()). Reporting true during the cold-start + // window would tell the renderer "ML active" while it's still getting the + // YIN fallback. + auto liveEngine = snapshotEngine(); + return Napi::Boolean::New(env, + liveEngine && liveEngine->hasMlNoteDetector() + && liveEngine->getMlNoteDetector().isReady()); +} + +// Raw polyphonic transcription from the ML note detector — the full set of +// currently-active pitches, not just the dominant one. Returns +// `{ notes: [{ midi, confidence, onsetMs, onsetSeq }], sampleRate }`, or null when the ML +// detector isn't active (no model / ONNX support) so the renderer can feature- +// detect and fall back. Never throws. +Napi::Value DetectNotes(const Napi::CallbackInfo& info) +{ + auto env = info.Env(); + // Gate on isReady(): the contract is that callers get null whenever the + // ML detector isn't actively producing notes. isReady() is false with no + // model, after a device stop, and during the cold-start window before the + // first inference publishes — so the renderer feature-detects correctly + // and falls back instead of consuming an empty ML stream. + auto liveEngine = snapshotEngine(); + if (!liveEngine || !liveEngine->getMlNoteDetector().isReady()) + return env.Null(); + + const auto active = liveEngine->getMlNoteDetector().getActiveNotes(); + auto notesArr = Napi::Array::New(env, active.size()); + for (size_t i = 0; i < active.size(); ++i) + { + auto entry = Napi::Object::New(env); + entry.Set("midi", active[i].midi); + entry.Set("confidence", active[i].confidence); + // Milliseconds since this pitch's onset — lets the renderer back-date + // a detection to the true onset instead of poll time. + entry.Set("onsetMs", active[i].onsetAgeMs); + // Monotonic per-pitch onset counter — a change means a new note was + // struck, so the renderer can consume onsets as discrete events. + entry.Set("onsetSeq", active[i].onsetSeq); + notesArr.Set((uint32_t) i, entry); + } + + auto obj = Napi::Object::New(env); + obj.Set("notes", notesArr); + // Normalise the sample rate: getCurrentSampleRate() is 0 when no audio + // device is active — hand the renderer a sane positive value so its + // Hz/time math can't divide by zero. + const double sr = liveEngine->getCurrentSampleRate(); + obj.Set("sampleRate", sr > 0.0 ? sr : 48000.0); + return obj; +} + +Napi::Value GetPitchDetection(const Napi::CallbackInfo& info) +{ + auto env = info.Env(); + auto obj = Napi::Object::New(env); + auto liveEngine = snapshotEngine(); + + if (liveEngine) + { + // getActiveDetection() returns the polyphonic ML detector's dominant + // pitch when a Basic Pitch model is loaded, else the YIN detector's + // latest result — same shape either way, so the plugin is unchanged. + auto det = liveEngine->getActiveDetection(); + obj.Set("frequency", det.frequency); + obj.Set("confidence", det.confidence); + obj.Set("midiNote", det.midiNote); + obj.Set("cents", det.cents); + obj.Set("noteName", det.noteName.toStdString()); + } + else + { + obj.Set("frequency", -1.0); + obj.Set("confidence", 0.0); + obj.Set("midiNote", -1); + obj.Set("cents", 0.0); + obj.Set("noteName", ""); + } + + return obj; +} + +Napi::Value GetRawPitchDetection(const Napi::CallbackInfo& info) +{ + auto env = info.Env(); + auto obj = Napi::Object::New(env); + auto liveEngine = snapshotEngine(); + + if (liveEngine) + { + // Always the raw YIN detection — bypasses the ML preference so frequency + // stays continuous (sub-Hz) and cents stays real even with a model loaded. + // Backs the tuner's audio:getRawPitch endpoint. + auto det = liveEngine->getRawPitchDetection(); + obj.Set("frequency", det.frequency); + obj.Set("confidence", det.confidence); + obj.Set("midiNote", det.midiNote); + obj.Set("cents", det.cents); + obj.Set("noteName", det.noteName.toStdString()); + } + else + { + obj.Set("frequency", -1.0); + obj.Set("confidence", 0.0); + obj.Set("midiNote", -1); + obj.Set("cents", 0.0); + obj.Set("noteName", ""); + } + + return obj; +} + +Napi::Value GetRawAudioFrame(const Napi::CallbackInfo& info) +{ + auto env = info.Env(); + auto liveEngine = snapshotEngine(); + + // Optional sample count; defaults to AudioEngine::getRawAudioFrame's 4096. + // The engine clamps anything above its ring capacity. + int numSamples = 4096; + if (info.Length() > 0 && info[0].IsNumber()) + numSamples = info[0].As().Int32Value(); + + if (!liveEngine || numSamples <= 0) + return Napi::Float32Array::New(env, 0); + + // Post-gate mono snapshot for the tuner's own pitch pipeline. Returns a + // Float32Array of the most-recent N samples (left-zero-padded on cold start). + auto frame = liveEngine->getRawAudioFrame(numSamples); + auto out = Napi::Float32Array::New(env, frame.size()); + float* dst = out.Data(); + for (size_t i = 0; i < frame.size(); ++i) + dst[i] = frame[i]; + return out; +} + +// Score a polyphonic chord against the engine's most recent input +// samples. Renderer (notedetect plugin's matchNotes chord branch) +// supplies the chord context — chart notes plus tuning/arrangement +// metadata — and gets back a `{score, hitStrings, totalStrings, isHit, +// results[]}` object identical in shape to what the JS implementation +// produced. Audio never crosses the N-API boundary, which is the +// whole reason for moving the math here: constitution II says audio +// analysis lives in JUCE, and this is the missing piece. +// +// Request shape. Fields marked `required` must be present and +// internally consistent — the C++ scorer fails closed (all-miss +// result with one entry per requested note) when the validation +// invariants don't hold, rather than silently substituting defaults. +// { +// notes: [{ s, f, ho?, po?, b?, sl?, hm? }, ...], +// // required, each `s` must be in [0, stringCount) +// arrangement?: 'guitar'|'bass', // default 'guitar' — must be one of these two strings +// stringCount?: number, // default 6 — must match the (arrangement, stringCount) +// // table: bass{4,5} or guitar{6,7,8} +// offsets: number[], // required, length must equal stringCount. +// // Pass an array of zeros for standard tuning; +// // the default of `stringCount = 6` only works +// // if you supply 6 offsets. +// numSamples?: number, // analysis window (default 4096, capped at the +// // engine input-ring capacity, currently 8192) +// capo?: number, // default 0 +// pitchCheckCents?: number, // 0 = energy-only chord check (default 0) +// minHitRatio?: number, // default 0.6 +// bypassMl?: boolean, // force the DSP band-energy scorer even +// // when an ML model is loaded (default false) +// harmonicVerify?: boolean, // score each note by harmonic-comb energy +// // (f,2f..5f vs the floor between) instead +// // of band-energy/total (default false) +// harmonicSnr?: number, // min harmonic-to-floor ratio for a hit +// // when harmonicVerify is set (default 3.0) +// fundamentalRatio?: number, // fundamental-presence gate: reject when +// // f0 peak < ratio*strongest partial; lower +// // for bass, <=0 disables (default 0.20) +// } +// Shared core: parse `reqObj` into a ChordScorer::Request and score it against +// `target`'s input ring. `target` is sources[0] for the legacy scoreChord and +// getSource(id) for the source-indexed scoreSourceChord. +Napi::Value scoreChordCore(Napi::Env env, Napi::Object reqObj, SourceChain* target) +{ + // Hard caps on caller-controlled array lengths. The scorer's + // (arrangement, stringCount) validation only accepts up to 8 + // strings; chord-notes have a natural ceiling at the same value + // (one per string). 32 is a generous headroom that still bounds + // worst-case allocations the renderer could trigger over IPC — + // without these limits, a malformed/malicious payload claiming a + // gigantic JS array length would force a multi-GB reserve before + // the scorer's own validation rejected the request. A request + // that exceeds either cap is treated as outright malformed and + // returns the "no chord requested" failure shape (totalStrings=0); + // every other validation failure goes through the all-miss path + // below so results[] stays in lockstep with notes[]. + static constexpr uint32_t kMaxOffsets = 32; + static constexpr uint32_t kMaxNotes = 32; + + auto noRequestFailure = [&env]() { + auto failure = Napi::Object::New(env); + failure.Set("score", 0.0); + failure.Set("hitStrings", 0); + failure.Set("totalStrings", 0); + failure.Set("isHit", false); + failure.Set("results", Napi::Array::New(env, 0)); + return failure; + }; + + // Capture the notes array up front so every downstream failure + // path can build a per-note all-miss result aligned 1:1 with the + // caller's notes[]. Pre-cap check happens before we even read the + // length into the helper to prevent a payload claiming an enormous + // length from forcing the helper to allocate a huge results array. + Napi::Value notesVal = reqObj.Has("notes") ? reqObj.Get("notes") : env.Null(); + if (!notesVal.IsArray()) return noRequestFailure(); + auto notesArr = notesVal.As(); + if (notesArr.Length() > kMaxNotes) return noRequestFailure(); + const uint32_t noteCount = notesArr.Length(); + + // All-miss result aligned with the caller's notes[]. Walks the + // original JS array so the per-note `s` / `f` echo back in the + // result even when the request fails validation (lets the renderer + // distinguish "this string missed" from "this string wasn't sent"). + // Used by every failure path below except the cap/no-notes case + // above, which doesn't have a coherent notes[] to mirror. + auto buildAllMiss = [&]() { + auto resultsArr = Napi::Array::New(env, noteCount); + for (uint32_t i = 0; i < noteCount; ++i) + { + int s = -1, f = -1; + auto v = notesArr.Get(i); + if (v.IsObject()) + { + auto o = v.As(); + if (o.Has("s") && o.Get("s").IsNumber()) + s = o.Get("s").As().Int32Value(); + if (o.Has("f") && o.Get("f").IsNumber()) + f = o.Get("f").As().Int32Value(); + } + auto entry = Napi::Object::New(env); + entry.Set("s", s); + entry.Set("f", f); + entry.Set("hit", false); + entry.Set("bandEnergy", 0.0); + entry.Set("centsDiff", env.Null()); + entry.Set("centsError", env.Null()); + resultsArr.Set(i, entry); + } + auto out = Napi::Object::New(env); + out.Set("score", 0.0); + out.Set("hitStrings", 0); + out.Set("totalStrings", (int) noteCount); + out.Set("isHit", false); + out.Set("results", resultsArr); + return out; + }; + + ChordScorer::Request req; + if (reqObj.Has("numSamples") && reqObj.Get("numSamples").IsNumber()) + req.numSamples = reqObj.Get("numSamples").As().Int32Value(); + if (reqObj.Has("arrangement") && reqObj.Get("arrangement").IsString()) + req.arrangement = reqObj.Get("arrangement").As().Utf8Value(); + if (reqObj.Has("stringCount") && reqObj.Get("stringCount").IsNumber()) + req.stringCount = reqObj.Get("stringCount").As().Int32Value(); + if (reqObj.Has("capo") && reqObj.Get("capo").IsNumber()) + req.capo = reqObj.Get("capo").As().Int32Value(); + if (reqObj.Has("pitchCheckCents") && reqObj.Get("pitchCheckCents").IsNumber()) + req.pitchCheckCents = reqObj.Get("pitchCheckCents").As().FloatValue(); + if (reqObj.Has("minHitRatio") && reqObj.Get("minHitRatio").IsNumber()) + req.minHitRatio = reqObj.Get("minHitRatio").As().FloatValue(); + if (reqObj.Has("bypassMl") && reqObj.Get("bypassMl").IsBoolean()) + req.bypassMl = reqObj.Get("bypassMl").As().Value(); + if (reqObj.Has("harmonicVerify") && reqObj.Get("harmonicVerify").IsBoolean()) + req.harmonicVerify = reqObj.Get("harmonicVerify").As().Value(); + if (reqObj.Has("harmonicSnr") && reqObj.Get("harmonicSnr").IsNumber()) + req.harmonicSnr = reqObj.Get("harmonicSnr").As().FloatValue(); + if (reqObj.Has("fundamentalRatio") && reqObj.Get("fundamentalRatio").IsNumber()) + { + // Drop NaN/Inf: a non-finite ratio poisons the fundamental-presence + // gate (fundMag >= NaN is always false -> every note false-rejected). + // Keep the safe 0.20 default instead. + const float v = reqObj.Get("fundamentalRatio").As().FloatValue(); + if (std::isfinite(v)) req.fundamentalRatio = v; + } + + if (reqObj.Has("offsets") && reqObj.Get("offsets").IsArray()) + { + auto arr = reqObj.Get("offsets").As(); + if (arr.Length() > kMaxOffsets) return noRequestFailure(); + req.tuningOffsets.reserve(arr.Length()); + for (uint32_t i = 0; i < arr.Length(); ++i) + { + auto v = arr.Get(i); + // Tuning offsets materially shift expected pitch — silently + // substituting 0 for a missing/non-numeric entry would + // produce confidently wrong scores. Fail closed with the + // per-note all-miss shape so the renderer sees the right + // results[] length even when the request is malformed. + if (!v.IsNumber()) return buildAllMiss(); + req.tuningOffsets.push_back(v.As().Int32Value()); + } + } + + req.notes.reserve(noteCount); + for (uint32_t i = 0; i < noteCount; ++i) + { + auto v = notesArr.Get(i); + // For malformed entries (non-object, or missing/non-numeric + // s/f) push a sentinel Note with string = -1. This keeps + // req.notes.size() in lockstep with the incoming notes[] + // length AND guarantees ChordScorer's range check + // (`n.string < 0 || n.string >= stringCount`) trips on the + // sentinel — yielding the same all-miss fail-closed result + // the shape contract advertises, never a false hit on the + // default low-string position. + ChordScorer::Note n{}; + n.string = -1; + n.fret = -1; + if (!v.IsObject()) + { + req.notes.push_back(n); + continue; + } + auto noteObj = v.As(); + const bool hasS = noteObj.Has("s") && noteObj.Get("s").IsNumber(); + const bool hasF = noteObj.Has("f") && noteObj.Get("f").IsNumber(); + if (!hasS || !hasF) + { + req.notes.push_back(n); + continue; + } + n.string = noteObj.Get("s").As().Int32Value(); + n.fret = noteObj.Get("f").As().Int32Value(); + // Technique flags are truthy/falsy in JS; coerce to bool + // here so an unset value cleanly becomes false. + auto truthy = [¬eObj](const char* key) { + if (!noteObj.Has(key)) return false; + auto val = noteObj.Get(key); + return val.ToBoolean().Value(); + }; + n.hammerOn = truthy("ho"); + n.pullOff = truthy("po"); + n.bend = truthy("b"); + n.slide = truthy("sl"); + n.harmonic = truthy("hm"); + req.notes.push_back(n); + } + + auto result = target->scoreChord(req); + + auto out = Napi::Object::New(env); + out.Set("score", result.score); + out.Set("hitStrings", result.hitStrings); + out.Set("totalStrings", result.totalStrings); + out.Set("isHit", result.isHit); + auto resultsArr = Napi::Array::New(env, result.results.size()); + for (size_t i = 0; i < result.results.size(); ++i) + { + const auto& r = result.results[i]; + auto entry = Napi::Object::New(env); + entry.Set("s", r.string); + entry.Set("f", r.fret); + entry.Set("hit", r.hit); + entry.Set("bandEnergy", r.bandEnergy); + // Mirror the JS result shape: when cents weren't measured the + // fields are present-but-null so the renderer can distinguish + // "no pitch check ran" (null) from "pitch check said 0" + // (numeric 0). + if (r.hasCents) + { + entry.Set("centsDiff", r.centsDiff); + entry.Set("centsError", r.centsError); + } + else + { + entry.Set("centsDiff", env.Null()); + entry.Set("centsError", env.Null()); + } + resultsArr.Set(i, entry); + } + out.Set("results", resultsArr); + return out; +} + +// Legacy: scoreChord(req) — targets sources[0]. Backward-compatible. +Napi::Value ScoreChord(const Napi::CallbackInfo& info) +{ + auto env = info.Env(); + auto liveEngine = snapshotEngine(); + auto noRequestFailure = [&env]() { + auto failure = Napi::Object::New(env); + failure.Set("score", 0.0); + failure.Set("hitStrings", 0); + failure.Set("totalStrings", 0); + failure.Set("isHit", false); + failure.Set("results", Napi::Array::New(env, 0)); + return failure; + }; + if (!liveEngine || info.Length() < 1 || !info[0].IsObject()) + return noRequestFailure(); + return scoreChordCore(env, info[0].As(), liveEngine->getSource(0)); +} + +// Source-indexed: scoreSourceChord(sourceId, req). Bad id / payload -> the +// same "no chord requested" failure shape (totalStrings=0). +Napi::Value ScoreSourceChord(const Napi::CallbackInfo& info) +{ + auto env = info.Env(); + auto liveEngine = snapshotEngine(); + auto noRequestFailure = [&env]() { + auto failure = Napi::Object::New(env); + failure.Set("score", 0.0); + failure.Set("hitStrings", 0); + failure.Set("totalStrings", 0); + failure.Set("isHit", false); + failure.Set("results", Napi::Array::New(env, 0)); + return failure; + }; + if (!liveEngine || info.Length() < 2 || !info[0].IsNumber() || !info[1].IsObject()) + return noRequestFailure(); + SourceChain* target = getValidatedSource(liveEngine.get(), info, 0); + if (!target) return noRequestFailure(); + return scoreChordCore(env, info[1].As(), target); +} + +// ── Multi-input source management bridge ───────────────────────────────────── +// A source is one independent input chain (own arrangement chart, detection, +// scoring, tone, monitor). sources[0] always exists. The renderer adds a source +// per extra player, binds it to an input channel, and drives its scoring via the +// *Source* methods below; the legacy un-suffixed methods keep targeting source 0. + +// addSource(inputChannel?) -> sourceId (number), or -1 if the pool is full. +Napi::Value AddSource(const Napi::CallbackInfo& info) +{ + auto env = info.Env(); + auto liveEngine = snapshotEngine(); + if (!liveEngine) return Napi::Number::New(env, -1); + int channel = -1; // default: mono mix of the first pair + if (info.Length() > 0 && info[0].IsNumber()) + channel = info[0].As().Int32Value(); + int deviceKey = 0; // default: primary input device + if (info.Length() > 1 && info[1].IsNumber()) + { + const int k = info[1].As().Int32Value(); + if (k >= 0) deviceKey = k; // negatives ignored → primary + } + return Napi::Number::New(env, liveEngine->addSource(channel, deviceKey)); +} + +// removeSource(sourceId) -> boolean. sources[0] cannot be removed. +Napi::Value RemoveSource(const Napi::CallbackInfo& info) +{ + auto env = info.Env(); + auto liveEngine = snapshotEngine(); + if (!liveEngine || info.Length() < 1 || !info[0].IsNumber()) + return Napi::Boolean::New(env, false); + return Napi::Boolean::New(env, liveEngine->removeSource(info[0].As().Int32Value())); +} + +// listSources() -> [{ id, inputChannel, active }]. Null on a missing engine. +Napi::Value ListSources(const Napi::CallbackInfo& info) +{ + auto env = info.Env(); + auto liveEngine = snapshotEngine(); + if (!liveEngine) return env.Null(); + const auto sources = liveEngine->listSources(); + auto arr = Napi::Array::New(env, sources.size()); + for (size_t i = 0; i < sources.size(); ++i) + { + auto entry = Napi::Object::New(env); + entry.Set("id", sources[i].id); + entry.Set("inputChannel", sources[i].inputChannel); + entry.Set("deviceKey", sources[i].deviceKey); + entry.Set("active", sources[i].active); + arr.Set((uint32_t) i, entry); + } + return arr; +} + +// listInputDevices() -> [{ typeName, name }]. Every available capture device the +// renderer can bind to an additional engine input via bindInputDevice. Null on a +// missing engine. +Napi::Value ListInputDevices(const Napi::CallbackInfo& info) +{ + auto env = info.Env(); + auto liveEngine = snapshotEngine(); + if (!liveEngine) return env.Null(); + const auto devices = liveEngine->getBindableInputDevices(); + auto arr = Napi::Array::New(env); + uint32_t n = 0; + for (const auto& d : devices) + { + auto entry = Napi::Object::New(env); + entry.Set("typeName", d.typeName.toStdString()); + entry.Set("name", d.name.toStdString()); + arr.Set(n++, entry); + } + return arr; +} + +// bindInputDevice(deviceKey, deviceName) -> "" on success, else an error string. +// Opens an ADDITIONAL physical input device (deviceKey 1..N) so sources created +// with addSource(channel, deviceKey) capture from it at its own clock. +Napi::Value BindInputDevice(const Napi::CallbackInfo& info) +{ + auto env = info.Env(); + auto liveEngine = snapshotEngine(); + if (!liveEngine) return Napi::String::New(env, "no engine"); + if (info.Length() < 2 || !info[0].IsNumber() || !info[1].IsString()) + return Napi::String::New(env, "bindInputDevice(deviceKey:number, deviceName:string)"); + const int deviceKey = info[0].As().Int32Value(); + const std::string name = info[1].As().Utf8Value(); + return Napi::String::New(env, liveEngine->bindInputDevice(deviceKey, name).toStdString()); +} + +// unbindInputDevice(deviceKey) -> boolean. Stops + releases the extra device. +Napi::Value UnbindInputDevice(const Napi::CallbackInfo& info) +{ + auto env = info.Env(); + auto liveEngine = snapshotEngine(); + if (!liveEngine || info.Length() < 1 || !info[0].IsNumber()) + return Napi::Boolean::New(env, false); + return Napi::Boolean::New(env, liveEngine->unbindInputDevice(info[0].As().Int32Value())); +} + + +} // namespace slopsmith::addon diff --git a/src/audio/addon/DeviceBindings.cpp b/src/audio/addon/DeviceBindings.cpp new file mode 100644 index 0000000..f68d1d2 --- /dev/null +++ b/src/audio/addon/DeviceBindings.cpp @@ -0,0 +1,852 @@ +// Device enumeration/selection/control + stream sink bindings - moved verbatim from NodeAddon.cpp (TLC phase 7b +// binding split). Registered by NodeAddon's export table via Bindings.h. + +#include "Bindings.h" + +#include "AddonContext.h" +#include "NapiHelpers.h" +#include "ChainOps.h" +#include "../AudioEngine.h" +#include "../VSTHost.h" +#include "../VSTTrace.h" + +#include +#include +#include +#include + +namespace slopsmith::addon { + +// ── Device Enumeration ──────────────────────────────────────────────────────── + +Napi::Value GetDeviceTypes(const Napi::CallbackInfo& info) +{ + auto env = info.Env(); + auto liveEngine = snapshotEngine(); + if (!liveEngine) return env.Null(); + + // Device types are already scanned during init — safe to read from any thread + auto types = liveEngine->getDeviceTypes(); + + auto result = Napi::Array::New(env, types.size()); + + for (int i = 0; i < types.size(); ++i) + { + auto obj = Napi::Object::New(env); + obj.Set("name", types[i].name.toStdString()); + + auto inputs = Napi::Array::New(env, types[i].inputDevices.size()); + for (int j = 0; j < types[i].inputDevices.size(); ++j) + inputs.Set((uint32_t)j, types[i].inputDevices[j].toStdString()); + obj.Set("inputs", inputs); + + auto outputs = Napi::Array::New(env, types[i].outputDevices.size()); + for (int j = 0; j < types[i].outputDevices.size(); ++j) + outputs.Set((uint32_t)j, types[i].outputDevices[j].toStdString()); + obj.Set("outputs", outputs); + + result.Set((uint32_t)i, obj); + } + + return result; +} + +Napi::Value GetSampleRates(const Napi::CallbackInfo& info) +{ + auto env = info.Env(); + auto liveEngine = snapshotEngine(); + if (!liveEngine) return Napi::Array::New(env); + + auto rates = liveEngine->getSampleRates(); + auto result = Napi::Array::New(env, rates.size()); + for (int i = 0; i < rates.size(); ++i) + result.Set((uint32_t)i, rates[i]); + return result; +} + +Napi::Value GetBufferSizes(const Napi::CallbackInfo& info) +{ + auto env = info.Env(); + auto liveEngine = snapshotEngine(); + if (!liveEngine) return Napi::Array::New(env); + + auto sizes = liveEngine->getBufferSizes(); + auto result = Napi::Array::New(env, sizes.size()); + for (int i = 0; i < sizes.size(); ++i) + result.Set((uint32_t)i, sizes[i]); + return result; +} + +Napi::Value ProbeDeviceOptions(const Napi::CallbackInfo& info) +{ + auto env = info.Env(); + auto liveEngine = snapshotEngine(); + auto obj = Napi::Object::New(env); + // 3-arg legacy (type, input, output) or 4-arg dual (inputType, input, outputType, output). + auto arg0 = info.Length() > 0 && info[0].IsString() ? info[0].As().Utf8Value() : ""; + auto arg1 = info.Length() > 1 && info[1].IsString() ? info[1].As().Utf8Value() : ""; + auto arg2 = info.Length() > 2 && info[2].IsString() ? info[2].As().Utf8Value() : ""; + auto arg3 = info.Length() > 3 && info[3].IsString() ? info[3].As().Utf8Value() : ""; + + std::string inputType = arg0; + std::string inputName = arg1; + std::string outputType; + std::string outputName; + if (info.Length() >= 4) + { + outputType = arg2; + outputName = arg3; + } + else + { + outputType = arg0; + outputName = arg2; + } + + auto ratesArray = Napi::Array::New(env); + auto buffersArray = Napi::Array::New(env); + auto inputChannelsArray = Napi::Array::New(env); + auto outputChannelsArray = Napi::Array::New(env); + + obj.Set("type", inputType); + obj.Set("inputType", inputType); + obj.Set("outputType", outputType); + obj.Set("input", inputName); + obj.Set("output", outputName); + obj.Set("inputChannels", inputChannelsArray); + obj.Set("outputChannels", outputChannelsArray); + obj.Set("sampleRates", ratesArray); + obj.Set("bufferSizes", buffersArray); + obj.Set("compatible", true); + if (!liveEngine) + { + obj.Set("error", "Audio engine not initialized"); + obj.Set("compatible", false); + return obj; + } + + auto options = liveEngine->probeDeviceOptionsDual( + juce::String(inputType), juce::String(inputName), + juce::String(outputType), juce::String(outputName)); + obj.Set("type", options.inputType.toStdString()); // legacy alias + obj.Set("inputType", options.inputType.toStdString()); + obj.Set("outputType", options.outputType.toStdString()); + obj.Set("input", options.input.toStdString()); + obj.Set("output", options.output.toStdString()); + obj.Set("error", options.error.toStdString()); + obj.Set("compatible", options.compatible); + + inputChannelsArray = Napi::Array::New(env, options.inputChannels.size()); + for (int i = 0; i < options.inputChannels.size(); ++i) + inputChannelsArray.Set((uint32_t)i, options.inputChannels[i].toStdString()); + obj.Set("inputChannels", inputChannelsArray); + + outputChannelsArray = Napi::Array::New(env, options.outputChannels.size()); + for (int i = 0; i < options.outputChannels.size(); ++i) + outputChannelsArray.Set((uint32_t)i, options.outputChannels[i].toStdString()); + obj.Set("outputChannels", outputChannelsArray); + + ratesArray = Napi::Array::New(env, options.sampleRates.size()); + for (int i = 0; i < options.sampleRates.size(); ++i) + ratesArray.Set((uint32_t)i, options.sampleRates[i]); + obj.Set("sampleRates", ratesArray); + + buffersArray = Napi::Array::New(env, options.bufferSizes.size()); + for (int i = 0; i < options.bufferSizes.size(); ++i) + buffersArray.Set((uint32_t)i, options.bufferSizes[i]); + obj.Set("bufferSizes", buffersArray); + + return obj; +} + +Napi::Value GetCurrentDevice(const Napi::CallbackInfo& info) +{ + auto env = info.Env(); + auto liveEngine = snapshotEngine(); + if (!liveEngine) return env.Null(); + + auto obj = Napi::Object::New(env); + const auto inputType = liveEngine->getCurrentInputDeviceType().toStdString(); + const auto outputType = liveEngine->getCurrentOutputDeviceType().toStdString(); + obj.Set("type", inputType); + obj.Set("inputType", inputType); + obj.Set("outputType", outputType); + obj.Set("input", liveEngine->getCurrentInputDevice().toStdString()); + obj.Set("output", liveEngine->getCurrentOutputDevice().toStdString()); + obj.Set("sampleRate", liveEngine->getCurrentSampleRate()); + obj.Set("blockSize", liveEngine->getCurrentBlockSize()); + obj.Set("inputBlockSize", liveEngine->getCurrentInputBlockSize()); + obj.Set("outputBlockSize", liveEngine->getCurrentOutputBlockSize()); + obj.Set("latencyMs", liveEngine->getLatencyMs()); + obj.Set("duplex", liveEngine->isDuplex()); + return obj; +} + +Napi::Value GetDeviceMetrics(const Napi::CallbackInfo& info) +{ + auto env = info.Env(); + auto liveEngine = snapshotEngine(); + auto obj = Napi::Object::New(env); + if (!liveEngine) + { + obj.Set("duplex", true); + obj.Set("inputOverflowCount", 0.0); + obj.Set("outputUnderflowCount", 0.0); + obj.Set("outputRingFillFrames", 0); + obj.Set("outputRingCapacityFrames", 0); + return obj; + } + const auto m = liveEngine->getDeviceMetrics(); + obj.Set("duplex", m.duplex); + obj.Set("inputOverflowCount", static_cast(m.inputOverflowCount)); + obj.Set("outputUnderflowCount", static_cast(m.outputUnderflowCount)); + obj.Set("outputRingFillFrames", m.outputRingFillFrames); + obj.Set("outputRingCapacityFrames", m.outputRingCapacityFrames); + return obj; +} + +// ── Device Selection ────────────────────────────────────────────────────────── + +Napi::Value SetDeviceType(const Napi::CallbackInfo& info) +{ + auto env = info.Env(); + auto liveEngine = snapshotEngine(); + if (!liveEngine || info.Length() < 1 || !info[0].IsString()) + return Napi::Boolean::New(env, false); + + auto typeName = info[0].As().Utf8Value(); + bool result = liveEngine->setDeviceType(juce::String(typeName)); + return Napi::Boolean::New(env, result); +} + +Napi::Value SetOutputDeviceType(const Napi::CallbackInfo& info) +{ + auto env = info.Env(); + auto liveEngine = snapshotEngine(); + if (!liveEngine || info.Length() < 1 || !info[0].IsString()) + return Napi::Boolean::New(env, false); + auto typeName = info[0].As().Utf8Value(); + return Napi::Boolean::New(env, liveEngine->setOutputDeviceType(juce::String(typeName))); +} + +Napi::Value SetDevice(const Napi::CallbackInfo& info) +{ + auto env = info.Env(); + auto liveEngine = snapshotEngine(); + auto result = Napi::Object::New(env); + result.Set("ok", false); + result.Set("duplex", true); + result.Set("sampleRate", 0.0); + result.Set("inputBlockSize", 0); + result.Set("outputBlockSize", 0); + result.Set("error", ""); + if (!liveEngine) + { + result.Set("error", "Audio engine not initialized"); + return result; + } + + // Object payload: setDevice({inputType, inputDevice, outputType, outputDevice, sampleRate, bufferSize}) + // Legacy positional: setDevice(input, output, sampleRate, bufferSize) + AudioEngine::DeviceConfig cfg; + if (info.Length() > 0 && info[0].IsObject() && !info[0].IsNull() && !info[0].IsArray()) + { + auto obj = info[0].As(); + auto readStr = [&](const char* key) -> std::string { + if (obj.Has(key) && obj.Get(key).IsString()) return obj.Get(key).As().Utf8Value(); + return {}; + }; + // Reject NaN/Infinity at the JS→C boundary so they can't poison + // downstream comparisons (NaN <= 0 is false, so the validation + // fallback in setAudioDevices() wouldn't catch them). Casting a + // non-finite double to int is also UB in C++. + auto readNum = [&](const char* key, double def) -> double { + if (obj.Has(key) && obj.Get(key).IsNumber()) + { + const double v = obj.Get(key).As().DoubleValue(); + if (std::isfinite(v)) return v; + } + return def; + }; + cfg.inputType = juce::String(readStr("inputType")); + cfg.inputDevice = juce::String(readStr("inputDevice")); + if (cfg.inputDevice.isEmpty()) cfg.inputDevice = juce::String(readStr("input")); + cfg.outputType = juce::String(readStr("outputType")); + cfg.outputDevice = juce::String(readStr("outputDevice")); + if (cfg.outputDevice.isEmpty()) cfg.outputDevice = juce::String(readStr("output")); + cfg.sampleRate = readNum("sampleRate", 48000.0); + // Clamp before the double→int cast: finite-but-out-of-range values + // (e.g. a JS-side bug passing 1e18) are UB to convert to int. readNum + // already filtered non-finite; we just need a range check here. + { + const double bsd = readNum("bufferSize", 256.0); + if (bsd >= 1.0 && bsd <= (double) (std::numeric_limits::max) ()) + cfg.bufferSize = (int) bsd; + else + cfg.bufferSize = 256; + } + } + else + { + auto input = info.Length() > 0 && info[0].IsString() ? info[0].As().Utf8Value() : ""; + auto output = info.Length() > 1 && info[1].IsString() ? info[1].As().Utf8Value() : ""; + double sr = info.Length() > 2 && info[2].IsNumber() ? info[2].As().DoubleValue() : 48000.0; + int bs = info.Length() > 3 && info[3].IsNumber() ? info[3].As().Int32Value() : 256; + cfg.inputDevice = juce::String(input); + cfg.outputDevice = juce::String(output); + cfg.sampleRate = sr; + cfg.bufferSize = bs; + } + + // Main thread only — JUCE's ALSA backend deadlocks if called from a worker. + const auto r = liveEngine->setAudioDevices(cfg); + result.Set("ok", r.ok); + result.Set("duplex", r.duplex); + result.Set("sampleRate", r.sampleRate); + result.Set("inputBlockSize", r.inputBlockSize); + result.Set("outputBlockSize", r.outputBlockSize); + result.Set("error", r.error.toStdString()); + return result; +} + +// ── Audio Control ───────────────────────────────────────────────────────────── + +Napi::Value StartAudio(const Napi::CallbackInfo& info) +{ + if (auto liveEngine = snapshotEngine()) liveEngine->startAudio(); + return info.Env().Undefined(); +} + +Napi::Value StopAudio(const Napi::CallbackInfo& info) +{ + if (auto liveEngine = snapshotEngine()) liveEngine->stopAudio(); + return info.Env().Undefined(); +} + +Napi::Value IsAudioRunning(const Napi::CallbackInfo& info) +{ + auto liveEngine = snapshotEngine(); + return Napi::Boolean::New(info.Env(), liveEngine ? liveEngine->isAudioRunning() : false); +} + +// ── Streamer mix output (PR1) ─────────────────────────────────────────────── +// setStreamOutputDevice(typeName, deviceName) -> "" on success, else an error. +Napi::Value SetStreamOutputDevice(const Napi::CallbackInfo& info) +{ + auto env = info.Env(); + auto liveEngine = snapshotEngine(); + if (!liveEngine) return Napi::String::New(env, "no engine"); + if (info.Length() < 2 || !info[0].IsString() || !info[1].IsString()) + return Napi::String::New(env, "setStreamOutputDevice(typeName:string, deviceName:string)"); + const std::string typeName = info[0].As().Utf8Value(); + const std::string devName = info[1].As().Utf8Value(); + return Napi::String::New(env, + liveEngine->setStreamOutputDevice(juce::String(typeName), juce::String(devName)).toStdString()); +} + +// clearStreamOutput() -> undefined +Napi::Value ClearStreamOutput(const Napi::CallbackInfo& info) +{ + auto liveEngine = snapshotEngine(); + if (liveEngine) liveEngine->clearStreamOutput(); + return info.Env().Undefined(); +} + +// setStreamBus(includeBacking:boolean, includeGuitar:boolean, gain:number) +Napi::Value SetStreamBus(const Napi::CallbackInfo& info) +{ + auto liveEngine = snapshotEngine(); + if (liveEngine && info.Length() >= 3 && info[0].IsBoolean() && info[1].IsBoolean() && info[2].IsNumber()) + liveEngine->setStreamBus(info[0].As().Value(), + info[1].As().Value(), + (float) info[2].As().DoubleValue()); + return info.Env().Undefined(); +} + +// setStreamBusGain(gain:number) +Napi::Value SetStreamBusGain(const Napi::CallbackInfo& info) +{ + auto liveEngine = snapshotEngine(); + if (liveEngine && info.Length() >= 1 && info[0].IsNumber()) + liveEngine->setStreamBusGain((float) info[0].As().DoubleValue()); + return info.Env().Undefined(); +} + +// setRendererBus(enabled:boolean, gain:number) +Napi::Value SetRendererBus(const Napi::CallbackInfo& info) +{ + auto liveEngine = snapshotEngine(); + if (liveEngine && info.Length() >= 2 && info[0].IsBoolean() && info[1].IsNumber()) + liveEngine->setRendererBus(info[0].As().Value(), + (float) info[1].As().DoubleValue()); + return info.Env().Undefined(); +} + +// pushRendererAudio(interleavedLR:Float32Array, sourceRate:number) -> boolean +// Interleaved stereo (L0 R0 L1 R1 …); sourceRate is the renderer's +// AudioContext sample rate. Returns false when the bus is off / engine down / +// malformed args, so the renderer can stop pushing. +Napi::Value PushRendererAudio(const Napi::CallbackInfo& info) +{ + auto env = info.Env(); + auto liveEngine = snapshotEngine(); + if (!liveEngine || info.Length() < 2 || !info[0].IsTypedArray() || !info[1].IsNumber()) + return Napi::Boolean::New(env, false); + auto ta = info[0].As(); + if (ta.TypedArrayType() != napi_float32_array) + return Napi::Boolean::New(env, false); + auto f32 = info[0].As(); + const size_t samples = f32.ElementLength(); + if (samples < 2) + return Napi::Boolean::New(env, false); + const int frames = (int) (samples / 2); + const bool ok = liveEngine->pushRendererAudio( + f32.Data(), frames, info[1].As().DoubleValue()); + return Napi::Boolean::New(env, ok); +} + +// getRendererBusMetrics() -> {enabled, fillFrames, capacityFrames, +// pushedFrames, consumedFrames, +// underflowCount, overflowCount} +Napi::Value GetRendererBusMetrics(const Napi::CallbackInfo& info) +{ + auto env = info.Env(); + auto liveEngine = snapshotEngine(); + auto obj = Napi::Object::New(env); + if (!liveEngine) return obj; + const auto m = liveEngine->getRendererBusMetrics(); + obj.Set("enabled", m.enabled); + obj.Set("fillFrames", m.fillFrames); + obj.Set("capacityFrames", m.capacityFrames); + obj.Set("pushedFrames", (double) m.pushedFrames); + obj.Set("consumedFrames", (double) m.consumedFrames); + obj.Set("underflowCount", (double) m.underflowCount); + obj.Set("overflowCount", (double) m.overflowCount); + return obj; +} + +// getStreamSinkLevel() -> number (peak 0..1+) +Napi::Value GetStreamSinkLevel(const Napi::CallbackInfo& info) +{ + auto liveEngine = snapshotEngine(); + return Napi::Number::New(info.Env(), liveEngine ? liveEngine->getStreamSinkLevel() : 0.0f); +} + +// isStreamOutputActive() -> boolean +Napi::Value IsStreamOutputActive(const Napi::CallbackInfo& info) +{ + auto liveEngine = snapshotEngine(); + return Napi::Boolean::New(info.Env(), liveEngine ? liveEngine->isStreamOutputActive() : false); +} + +// getStreamUnderflowCount() -> number +Napi::Value GetStreamUnderflowCount(const Napi::CallbackInfo& info) +{ + auto liveEngine = snapshotEngine(); + return Napi::Number::New(info.Env(), + (double) (liveEngine ? liveEngine->getStreamUnderflowCount() : 0ull)); +} + +// getStreamOverflowCount() -> number (consumer fell a full ring behind; frames dropped) +Napi::Value GetStreamOverflowCount(const Napi::CallbackInfo& info) +{ + auto liveEngine = snapshotEngine(); + return Napi::Number::New(info.Env(), + (double) (liveEngine ? liveEngine->getStreamOverflowCount() : 0ull)); +} + +// setSourceInputChannel(sourceId, channel) +Napi::Value SetSourceInputChannel(const Napi::CallbackInfo& info) +{ + auto liveEngine = snapshotEngine(); + if (liveEngine && info.Length() >= 2 && info[0].IsNumber() && info[1].IsNumber()) + if (SourceChain* s = getValidatedSource(liveEngine.get(), info, 0)) + s->setInputChannel(info[1].As().Int32Value()); + return info.Env().Undefined(); +} + +// setSourceVerifierOffset(sourceId, seconds) — per-source capture-latency +// correction the user dials in for an extra input device (the residual offset +// between that device's path and the primary's; not auto-measurable on JACK). +// Positive seconds DELAYS this source's scoring playhead, negative ADVANCES it. +Napi::Value SetSourceVerifierOffset(const Napi::CallbackInfo& info) +{ + auto liveEngine = snapshotEngine(); + if (liveEngine && info.Length() >= 2 && info[0].IsNumber() && info[1].IsNumber()) + { + const double sec = info[1].As().DoubleValue(); + if (std::isfinite(sec)) + if (SourceChain* s = getValidatedSource(liveEngine.get(), info, 0)) + s->setVerifierUserOffset(sec); + } + return info.Env().Undefined(); +} + +// setSourceMonitorMute(sourceId, mute) +Napi::Value SetSourceMonitorMute(const Napi::CallbackInfo& info) +{ + auto liveEngine = snapshotEngine(); + if (liveEngine && info.Length() >= 2 && info[0].IsNumber() && info[1].IsBoolean()) + if (SourceChain* s = getValidatedSource(liveEngine.get(), info, 0)) + s->setMonitorMute(info[1].As().Value()); + return info.Env().Undefined(); +} + +// getSourceRawAudioFrame(sourceId, numSamples?) -> Float32Array +Napi::Value GetSourceRawAudioFrame(const Napi::CallbackInfo& info) +{ + auto env = info.Env(); + auto liveEngine = snapshotEngine(); + if (!liveEngine || info.Length() < 1 || !info[0].IsNumber()) + return Napi::Float32Array::New(env, 0); + SourceChain* s = getValidatedSource(liveEngine.get(), info, 0); + int numSamples = 4096; + if (info.Length() > 1 && info[1].IsNumber()) + numSamples = info[1].As().Int32Value(); + if (!s || numSamples <= 0) + return Napi::Float32Array::New(env, 0); + auto frame = s->getRawAudioFrame(numSamples); + auto out = Napi::Float32Array::New(env, frame.size()); + float* dst = out.Data(); + for (size_t i = 0; i < frame.size(); ++i) + dst[i] = frame[i]; + return out; +} + +// getSourcePitchDetection(sourceId) -> { frequency, confidence, midiNote, cents, +// noteName }. The no-detection shape when the id is bad/inactive. +Napi::Value GetSourcePitchDetection(const Napi::CallbackInfo& info) +{ + auto env = info.Env(); + auto obj = Napi::Object::New(env); + auto liveEngine = snapshotEngine(); + SourceChain* s = (liveEngine && info.Length() >= 1 && info[0].IsNumber()) + ? getValidatedSource(liveEngine.get(), info, 0) : nullptr; + if (s) + { + auto det = s->getActiveDetection(); + obj.Set("frequency", det.frequency); + obj.Set("confidence", det.confidence); + obj.Set("midiNote", det.midiNote); + obj.Set("cents", det.cents); + obj.Set("noteName", det.noteName.toStdString()); + } + else + { + obj.Set("frequency", -1.0); + obj.Set("confidence", 0.0); + obj.Set("midiNote", -1); + obj.Set("cents", 0.0); + obj.Set("noteName", ""); + } + return obj; +} + +// getSourceRawPitchDetection(sourceId) -> raw YIN detection (bypasses ML), same +// shape as getSourcePitchDetection. Backs the per-source sustain glow / mono path. +Napi::Value GetSourceRawPitchDetection(const Napi::CallbackInfo& info) +{ + auto env = info.Env(); + auto obj = Napi::Object::New(env); + auto liveEngine = snapshotEngine(); + SourceChain* s = (liveEngine && info.Length() >= 1 && info[0].IsNumber()) + ? getValidatedSource(liveEngine.get(), info, 0) : nullptr; + if (s) + { + auto det = s->getRawPitchDetection(); + obj.Set("frequency", det.frequency); + obj.Set("confidence", det.confidence); + obj.Set("midiNote", det.midiNote); + obj.Set("cents", det.cents); + obj.Set("noteName", det.noteName.toStdString()); + } + else + { + obj.Set("frequency", -1.0); + obj.Set("confidence", 0.0); + obj.Set("midiNote", -1); + obj.Set("cents", 0.0); + obj.Set("noteName", ""); + } + return obj; +} + +// getSourceNoteVerdicts(sourceId, songTime?, playing?) -> verdict array, or null +// on a missing engine / bad id. Folds in the per-source playhead push like the +// legacy getNoteVerdicts. +Napi::Value GetSourceNoteVerdicts(const Napi::CallbackInfo& info) +{ + auto env = info.Env(); + auto liveEngine = snapshotEngine(); + if (!liveEngine || info.Length() < 1 || !info[0].IsNumber()) + return env.Null(); + SourceChain* s = getValidatedSource(liveEngine.get(), info, 0); + if (!s) return env.Null(); + + if (info.Length() >= 3 && info[1].IsNumber() && info[2].IsBoolean()) + { + const double songTime = info[1].As().DoubleValue(); + if (std::isfinite(songTime)) + s->setPlayhead(songTime, info[2].As().Value()); + } + + const auto verdicts = s->getNoteVerdicts(); + auto arr = Napi::Array::New(env, verdicts.size()); + for (size_t i = 0; i < verdicts.size(); ++i) + { + const auto& v = verdicts[i]; + auto entry = Napi::Object::New(env); + entry.Set("id", v.id); + entry.Set("detected", v.detected); + entry.Set("detectedSongTime", v.detectedSongTime); + entry.Set("centsError", v.centsError); + entry.Set("snr", v.snr); + arr.Set((uint32_t) i, entry); + } + return arr; +} + +// Push the song's note chart into the engine for continuous, background +// verification. The notedetect plugin calls this once per arrangement load; +// the engine's NoteVerifier thread then scores each note's timing window +// against the live playhead and input ring, so the renderer no longer runs a +// per-tick scoreChord IPC loop (which starved during dense passages). +// +// Expected payload: +// { +// arrangement?: 'guitar'|'bass', // default 'guitar' +// stringCount?: number, // default 6 +// tuningOffsets: number[], // length should equal stringCount +// capo?: number, // default 0 +// pitchCheckCents?: number, // default 0 (energy-only) +// harmonicSnr?: number, // default 3.0 +// fundamentalRatio?: number, // fundamental-presence gate, lower for +// // bass, <=0 disables (default 0.20) +// timingTolerance?: number, // seconds, default 0.1 +// notes: [{ id:string, t:number, s:number, f:number, sus:number, +// ho?,po?,b?,sl?,hm?:boolean }, ...] +// } +// Returns true when the chart was accepted, false on a malformed payload or +// when no engine exists. +// Shared core: parse `reqObj` into a ChartUpdate and push it to `target`'s +// verifier. `target` is sources[0] for the legacy setChart and getSource(id) for +// the source-indexed setSourceChart. A malformed payload clears the target's +// chart (so a failed reload can't leave a stale chart scoring) and returns false. +Napi::Value setChartCore(Napi::Env env, Napi::Object reqObj, SourceChain* target) +{ + // Generous cap on the chart length — a full song's note list is well + // under this, but it bounds the worst-case allocation a malformed payload + // (claiming a gigantic JS array length) could force over IPC. + static constexpr uint32_t kMaxChartNotes = 8192; + + // Rejecting a malformed chart must also drop whatever chart the verifier + // currently holds — otherwise a failed (re)load leaves the previous + // song's chart active and getNoteVerdicts() keeps emitting stale verdicts. + auto reject = [&]() -> Napi::Value { + if (target) target->clearChart(); + return Napi::Boolean::New(env, false); + }; + + NoteVerifier::ChartUpdate chart; + if (reqObj.Has("arrangement") && reqObj.Get("arrangement").IsString()) + chart.arrangement = reqObj.Get("arrangement").As().Utf8Value(); + if (reqObj.Has("stringCount") && reqObj.Get("stringCount").IsNumber()) + chart.stringCount = reqObj.Get("stringCount").As().Int32Value(); + if (reqObj.Has("capo") && reqObj.Get("capo").IsNumber()) + chart.capo = reqObj.Get("capo").As().Int32Value(); + if (reqObj.Has("pitchCheckCents") && reqObj.Get("pitchCheckCents").IsNumber()) + chart.pitchCheckCents = reqObj.Get("pitchCheckCents").As().FloatValue(); + if (reqObj.Has("harmonicSnr") && reqObj.Get("harmonicSnr").IsNumber()) + chart.harmonicSnr = reqObj.Get("harmonicSnr").As().FloatValue(); + if (reqObj.Has("fundamentalRatio") && reqObj.Get("fundamentalRatio").IsNumber()) + { + // Drop NaN/Inf (see ScoreChord): a non-finite ratio poisons the + // fundamental-presence gate; keep the safe 0.20 default. + const float v = reqObj.Get("fundamentalRatio").As().FloatValue(); + if (std::isfinite(v)) chart.fundamentalRatio = v; + } + if (reqObj.Has("presenceRatio") && reqObj.Get("presenceRatio").IsNumber()) + { + // Temporal-persistence floor, clamped to [0,1]. Saturate rather than + // reject an out-of-range value: a stray >1 must NOT silently fall back to + // 0 (legacy ever-present), which would reintroduce the false-accept this + // guards against. Non-finite is ignored (keeps the 0 default). + const float v = reqObj.Get("presenceRatio").As().FloatValue(); + if (std::isfinite(v)) chart.presenceRatio = (v < 0.0f) ? 0.0f : (v > 1.0f ? 1.0f : v); + } + if (reqObj.Has("timingTolerance") && reqObj.Get("timingTolerance").IsNumber()) + chart.timingTolerance = reqObj.Get("timingTolerance").As().DoubleValue(); + + if (reqObj.Has("tuningOffsets") && reqObj.Get("tuningOffsets").IsArray()) + { + auto arr = reqObj.Get("tuningOffsets").As(); + if (arr.Length() > 32) return reject(); + chart.tuningOffsets.reserve(arr.Length()); + for (uint32_t i = 0; i < arr.Length(); ++i) + { + auto v = arr.Get(i); + if (!v.IsNumber()) return reject(); + chart.tuningOffsets.push_back(v.As().Int32Value()); + } + } + + // ChordScorer requires exactly one tuning offset per string and otherwise + // fails every note closed. Reject the chart here so a malformed payload + // surfaces as setChart() == false rather than a silently all-miss session + // the caller believes loaded fine. + if ((int) chart.tuningOffsets.size() != chart.stringCount) + return reject(); + + Napi::Value notesVal = reqObj.Has("notes") ? reqObj.Get("notes") : env.Null(); + if (!notesVal.IsArray()) return reject(); + auto notesArr = notesVal.As(); + if (notesArr.Length() > kMaxChartNotes) return reject(); + + chart.notes.reserve(notesArr.Length()); + for (uint32_t i = 0; i < notesArr.Length(); ++i) + { + auto v = notesArr.Get(i); + if (!v.IsObject()) return reject(); + auto noteObj = v.As(); + + // Every chart note must carry all five required fields with the right + // type. Filling defaults for a missing field would push a bogus + // time-0 note with an empty id — that breaks verdict-by-id alignment + // — so reject the whole chart instead. + const bool validNote = + noteObj.Has("id") && noteObj.Get("id").IsString() && + noteObj.Has("t") && noteObj.Get("t").IsNumber() && + noteObj.Has("s") && noteObj.Get("s").IsNumber() && + noteObj.Has("f") && noteObj.Get("f").IsNumber() && + noteObj.Has("sus") && noteObj.Get("sus").IsNumber(); + if (!validNote) return reject(); + + NoteVerifier::ChartNote n{}; + n.id = noteObj.Get("id").As().Utf8Value(); + n.t = noteObj.Get("t").As().DoubleValue(); + n.string = noteObj.Get("s").As().Int32Value(); + n.fret = noteObj.Get("f").As().Int32Value(); + n.sus = noteObj.Get("sus").As().DoubleValue(); + auto truthy = [¬eObj](const char* key) { + if (!noteObj.Has(key)) return false; + return noteObj.Get(key).ToBoolean().Value(); + }; + n.ho = truthy("ho"); + n.po = truthy("po"); + n.b = truthy("b"); + n.sl = truthy("sl"); + n.hm = truthy("hm"); + chart.notes.push_back(std::move(n)); + } + + target->setChart(chart); + return Napi::Boolean::New(env, true); +} + +// Legacy: setChart(chart) — targets sources[0]. Backward-compatible. +Napi::Value SetChart(const Napi::CallbackInfo& info) +{ + auto env = info.Env(); + auto liveEngine = snapshotEngine(); + if (!liveEngine || info.Length() < 1 || !info[0].IsObject()) + { + if (liveEngine) liveEngine->clearChart(); + return Napi::Boolean::New(env, false); + } + return setChartCore(env, info[0].As(), liveEngine->getSource(0)); +} + +// Source-indexed: setSourceChart(sourceId, chart). Bad id / payload -> false. +Napi::Value SetSourceChart(const Napi::CallbackInfo& info) +{ + auto env = info.Env(); + auto liveEngine = snapshotEngine(); + if (!liveEngine || info.Length() < 2 || !info[0].IsNumber() || !info[1].IsObject()) + return Napi::Boolean::New(env, false); + SourceChain* target = getValidatedSource(liveEngine.get(), info, 0); + if (!target) return Napi::Boolean::New(env, false); + return setChartCore(env, info[1].As(), target); +} + +// Drain the verdicts the NoteVerifier thread has finalized since the last +// call. Returns an array of { id, detected, detectedSongTime, centsError, snr }. +// +// Optionally also pushes the renderer's playhead: getNoteVerdicts(songTime, +// playing). The plugin calls this once per detect tick, so folding the push in +// here advances the verifier's clock without a second IPC round-trip. A +// downlevel caller passing no args still just drains. +Napi::Value GetNoteVerdicts(const Napi::CallbackInfo& info) +{ + auto env = info.Env(); + // Null (not an empty array) on a missing engine — the bridge/preload + // contract treats null as "unsupported/unavailable" so the renderer + // feature-detects, matching detectNotes' no-engine path. + auto liveEngine = snapshotEngine(); + if (!liveEngine) return env.Null(); + + // Push the playhead before draining so this tick's verdicts reflect it. + // A JS NaN/Infinity passes IsNumber() — guard with isfinite so a bad + // value can't corrupt the verifier's interpolated timing. + if (info.Length() >= 2 && info[0].IsNumber() && info[1].IsBoolean()) + { + const double songTime = info[0].As().DoubleValue(); + if (std::isfinite(songTime)) + liveEngine->setPlayhead(songTime, info[1].As().Value()); + } + + const auto verdicts = liveEngine->getNoteVerdicts(); + auto arr = Napi::Array::New(env, verdicts.size()); + for (size_t i = 0; i < verdicts.size(); ++i) + { + const auto& v = verdicts[i]; + auto entry = Napi::Object::New(env); + entry.Set("id", v.id); + entry.Set("detected", v.detected); + entry.Set("detectedSongTime", v.detectedSongTime); + entry.Set("centsError", v.centsError); + entry.Set("snr", v.snr); + arr.Set((uint32_t) i, entry); + } + return arr; +} + +// Sample rate the audio device is running at. Notedetect's chord scorer +// needs this to map FFT bins to Hz; on the bridge path there's no +// AudioContext to read it from. Falls back to 48000 if the engine isn't +// ready (matches the historical fallback in screen.js) — and also if +// the engine is initialized but no device is currently active, which +// pins currentSampleRate to 0 internally and would otherwise propagate +// a divide-by-zero into the renderer's FFT-bin→Hz math. +Napi::Value GetSampleRate(const Napi::CallbackInfo& info) +{ + auto env = info.Env(); + constexpr double kFallbackSampleRate = 48000.0; + auto liveEngine = snapshotEngine(); + if (!liveEngine) + return Napi::Number::New(env, kFallbackSampleRate); + const double sr = liveEngine->getCurrentSampleRate(); + if (!std::isfinite(sr) || sr <= 0.0) + return Napi::Number::New(env, kFallbackSampleRate); + return Napi::Number::New(env, sr); +} + + +Napi::Value GetLatencyBreakdown(const Napi::CallbackInfo& info) +{ + auto env = info.Env(); + auto obj = Napi::Object::New(env); + auto liveEngine = snapshotEngine(); + if (!liveEngine) return obj; + const auto b = liveEngine->getLatencyBreakdown(); + obj.Set("sampleRate", b.sampleRate); + obj.Set("duplex", b.duplex); + obj.Set("deviceBufferMs", b.deviceBufferMs); + obj.Set("inputLatencyMs", b.inputLatencyMs); + obj.Set("outputLatencyMs", b.outputLatencyMs); + obj.Set("splitRingMs", b.splitRingMs); + obj.Set("monitorTotalMs", b.monitorTotalMs); + obj.Set("rendererBusMs", b.rendererBusMs); + return obj; +} + +} // namespace slopsmith::addon diff --git a/src/audio/addon/EditorWindows.cpp b/src/audio/addon/EditorWindows.cpp new file mode 100644 index 0000000..1beb886 --- /dev/null +++ b/src/audio/addon/EditorWindows.cpp @@ -0,0 +1,427 @@ +// EditorWindows implementation — moved verbatim from NodeAddon.cpp (TLC plan +// phase 7 / §3.4). Only edits: statics live in this namespace now, and the +// two bindings validate their slot-id argument through NapiHelpers (the same +// deep-read 2 fix the other bindings got in phase 6 — a NaN slot id used to +// coerce to slot 0 and open/close the wrong editor). + +#include "EditorWindows.h" + +#include "AddonContext.h" +#include "NapiHelpers.h" +#include "ChainOps.h" +#include "../Sandbox/SandboxedProcessor.h" +#include "../Sandbox/CrashAttribution.h" + +#include +#include +#include +#include + +namespace slopsmith::addon { + +class PluginEditorWindow; +static std::map> editorWindows; + +class PluginEditorWindow : public juce::DocumentWindow +{ +public: + PluginEditorWindow(juce::AudioProcessorEditor* ed, const juce::String& title) + : DocumentWindow(title, juce::Colours::darkgrey, DocumentWindow::closeButton) + { + setContentOwned(ed, true); + setResizable(true, false); + setUsingNativeTitleBar(true); + centreWithSize(ed->getWidth(), ed->getHeight()); + setVisible(true); + toFront(true); + } + + void closeButtonPressed() override + { + // Remove from map so editor can be reopened + for (auto it = editorWindows.begin(); it != editorWindows.end(); ++it) + { + if (it->second.get() == this) + { + auto slotId = it->first; + juce::MessageManager::callAsync([slotId]() { + editorWindows.erase(slotId); + }); + break; + } + } + setVisible(false); + } +}; + +// Inline teardown: destroys every editor window. Caller MUST already be on the +// message thread (editorWindows holds JUCE GUI objects). Forward-declared near +// Init for doShutdown's use. +void destroyAllPluginEditorWindowsOnMessageThread() +{ + // Fails fast in assertion-enabled builds if a caller violates the + // precondition. Compiled out here under -DJUCE_DISABLE_ASSERTIONS, so it is + // documentation + a debug-build tripwire, never runtime cost. + JUCE_ASSERT_MESSAGE_THREAD + editorWindows.clear(); +} + +// See the forward declaration above ClearChain for why this exists. Tears down +// the in-process editor windows so they are destroyed before the caller frees +// the processors those editors point at. Clearing an empty map is cheap, so +// calling this on every teardown is fine even when no editor is open. +// +// IMPORTANT: every caller runs on a MAIN-thread / message-thread context — +// ClearChain and LoadPreset are N-API calls on the Node thread, doShutdown uses +// the inline variant directly. This is NOT called from a libuv worker (that is +// why LoadPreset closes editors before queuing LoadPresetWorker, rather than +// letting the worker do it). Given that: +// - Already on the message thread (doShutdown; ClearChain / LoadPreset on +// macOS, where Node's main thread IS the JUCE message thread) → tear down +// inline; posting-and-waiting on ourselves would deadlock. +// - Otherwise (ClearChain / LoadPreset on Linux/Windows, where the JUCE +// message thread is a dedicated std::thread) → post to that thread and block +// until the editors are gone. Its 50ms dispatch loop drains this promptly, +// so there is no macOS-style stall here. Report a refused post / wait +// timeout so a lingering-editor UAF stays diagnosable. +bool closeAllPluginEditorWindows() +{ + auto* mm = juce::MessageManager::getInstanceWithoutCreating(); + if (mm != nullptr && mm->isThisTheMessageThread()) + { + destroyAllPluginEditorWindowsOnMessageThread(); + return true; + } + + auto done = std::make_shared(); + const bool posted = juce::MessageManager::callAsync([done]() + { + destroyAllPluginEditorWindowsOnMessageThread(); + done->signal(); + }); + if (!posted) + { + fprintf(stderr, "[audio-native] closeAllPluginEditorWindows: message queue refused the post; " + "editors may still be alive\n"); + return false; + } + if (!done->wait(15000)) + { + // The queued teardown hasn't run: editors may still hold pointers into + // the chain. Callers must NOT free slot processors on a false return — + // proceeding here is exactly the #56 use-after-free, just delayed. + fprintf(stderr, "[audio-native] closeAllPluginEditorWindows: editor teardown did not complete " + "within 15s; caller must not free chain processors\n"); + return false; + } + return true; +} + +Napi::Value OpenPluginEditor(const Napi::CallbackInfo& info) +{ + auto env = info.Env(); + auto liveEngine = snapshotEngine(); + const auto slotIdOpt = argSlotId(info, 0); + if (!liveEngine || !slotIdOpt) + return Napi::Boolean::New(env, false); + const int slotId = *slotIdOpt; + + // Rebuild barrier (ChainOps.h): a chain clear/rebuild is between its + // editor teardown and the mutation itself — the processor this editor + // would bind to is about to be freed (#56). Refuse to open. + if (slopsmith::addon::isChainRebuildPending()) + return Napi::Boolean::New(env, false); + + // Resolve the slot under the chain-mutation mutex: getSlot returns a raw + // pointer a concurrent worker's clear()/rebuild would free under us. + // try_lock, never a blocking lock — a preset load can hold the mutex for + // seconds (VST init) and this is V8's thread; if a mutation is in flight + // the slot we'd open is about to be replaced anyway. + std::unique_lock chainLock( + slopsmith::addon::chainMutationMutex(), std::try_to_lock); + if (!chainLock.owns_lock()) + return Napi::Boolean::New(env, false); + + auto slot = liveEngine->getSignalChain().getSlot(slotId); + if (!slot || !slot->processor || !slot->processor->hasEditor()) + return Napi::Boolean::New(env, false); + + // Sandboxed plugins: the editor is a top-level window owned by the + // sandbox child process. No host-side PluginEditorWindow and no + // cross-process SetParent reparent — that path produced a blank + // rendered surface for D3D / OpenGL plugins (Neural DSP Archetypes, + // etc.) because their render context lives in the child. The child's + // kOpenEditor handler brings the existing window to front on a repeat + // click, so re-entry is cheap and we don't track host-side state. + // + // Dispatch off the N-API call thread: requestOpenEditor() uses a + // blocking control->request (kDefaultReplyTimeoutMs = 10s), which on + // a slow or hung sandbox would otherwise stall V8's JS thread for + // the full timeout. Capture slotId rather than a raw processor + // pointer and re-resolve inside the message-thread lambda — that + // closes a UAF window where the slot could be removed (or the engine + // torn down) between this call returning and the async firing. + // Return optimistically; matches the in-process path below. + // + // SandboxedProcessor is compiled on all desktop platforms now (the POSIX + // sandbox runtime is active — see src/audio/CMakeLists.txt), so the + // editor-open IPC routes to the sandbox child on macOS/Linux too. The + // child owns a floating editor window (Reaper-style); the host only tracks + // the open/closed bit. +#if defined(SLOPSMITH_AUDIO_ADDON) + if (auto* sb = dynamic_cast(slot->processor.get())) + { + // Synchronous gate: if the sandbox child is already gone (crashed + // or shut down) there's no point scheduling the IPC. Return false + // so the renderer can surface "editor unavailable" rather than + // toggling its UI into a fake-open state that no event will ever + // contradict. hasEditor() above already gated on isAlive() but a + // crash between then and now is possible — re-check here. + if (!sb->isAlive()) + return Napi::Boolean::New(env, false); + // Validation is done — release before queueing, so the lambda's own + // try_lock on the message thread can't collide with THIS thread still + // holding the mutex and drop the open as a false conflict. + chainLock.unlock(); + const bool queued = juce::MessageManager::callAsync([slotId]() + { + auto liveEngine = snapshotEngine(); + if (!liveEngine) return; + // try_lock, NEVER a blocking lock on the message thread: chain + // workers holding the mutex block-wait on this very thread + // (loadVstSandboxAware's callAsync+wait) — blocking here would + // deadlock. Contention means a mutation is rebuilding the slot; + // skip the open. + std::unique_lock chainLock( + slopsmith::addon::chainMutationMutex(), std::try_to_lock); + if (!chainLock.owns_lock()) return; + if (auto* slot = liveEngine->getSignalChain().getSlot(slotId)) + if (auto* sb = dynamic_cast(slot->processor.get())) + sb->requestOpenEditor(); + }); + if (!queued) + { + // Message queue refused the post — typically only during + // shutdown. Surface the failure so the renderer doesn't + // toggle its UI into a fake-open state. + return Napi::Boolean::New(env, false); + } + return Napi::Boolean::New(env, true); + } +#endif + + // In-process plugin — host-side PluginEditorWindow flow. Everything — + // including the duplicate-window check — runs on the message thread: + // editorWindows is a plain std::map owned by that thread, and reading or + // erasing it from this (N-API) thread raced the message-thread inserts/ + // erases. + // + // Capture slotId only — re-resolve the slot via snapshotEngine() + + // getSlot(slotId) inside the lambda so a SignalChain::removeProcessor() + // between this call returning and the async firing can't leave us calling + // createEditorAndMakeActive() on a dangling juce::AudioProcessor*. + // + // Validation is done — release before queueing, so the lambda's own + // try_lock on the message thread can't collide with THIS thread still + // holding the mutex and drop the open as a false conflict. + chainLock.unlock(); + const bool queued = juce::MessageManager::callAsync([slotId]() + { + // If a window already exists for this slot, bring it to front rather + // than creating a duplicate. + auto it = editorWindows.find(slotId); + if (it != editorWindows.end() && it->second) + { + if (it->second->isVisible()) + { + it->second->toFront(true); + return; + } + // Window was hidden/closed, remove stale entry + editorWindows.erase(it); + } + + auto liveEngine = snapshotEngine(); + if (!liveEngine) return; + // try_lock, NEVER a blocking lock on the message thread: chain + // workers holding the mutex block-wait on this very thread + // (loadVstSandboxAware's callAsync+wait) — blocking here would + // deadlock. Contention means the slot is being rebuilt; skip. + std::unique_lock chainLock( + slopsmith::addon::chainMutationMutex(), std::try_to_lock); + if (!chainLock.owns_lock()) return; + auto& chain = liveEngine->getSignalChain(); + auto* slot = chain.getSlot(slotId); + if (!slot || !slot->processor) return; + + // ── Windows editor-crash class fix ─────────────────────────────────── + // An in-process VST3 editor is created on JUCE's BACKGROUND message + // thread (V8 owns the OS main thread inside a Node addon). On Windows a + // Qt-using / window-on-init plugin then faults via USER32->WndProc on + // WM_ACTIVATEAPP with NO host frame on the stack, so the SignalChain SEH + // guard can't catch it and the whole app dies (0xC0000005 / 0xC0000409). + // Fix: never open a VST3 editor in-process on Windows — promote the slot + // to the out-of-process sandbox (which hosts the editor on a real + // top-level message thread, the environment the plugin needs) and open + // it there. Compiled on every platform so the swap path keeps building; + // gated to Windows at runtime since the in-process editor is fine on + // macOS/Linux (no WndProc) and the sandbox hop is pure overhead there. + static constexpr bool kPromoteEditorToSandbox = + #if JUCE_WINDOWS + true; + #else + false; + #endif + if (kPromoteEditorToSandbox) + { + // Decide + snapshot state SAFELY. captureVstStateForPromotion runs + // hasEditor()/getStateInformation() under the audio lock and the SEH + // guard (see its contract), so they neither race process()'s + // processBlock nor fault the app — an UNguarded getStateInformation on + // the very plugins this promotion targets would reintroduce the editor + // crash on the message thread. It returns true only for a non-sandboxed + // in-process VST3 that actually has an editor. + juce::MemoryBlock state; + if (chain.captureVstStateForPromotion(slotId, state)) + { + const juce::String path = slot->path; // immutable; message-thread only + fprintf(stderr, "[AudioEngine] editor-open: promoting in-process VST3 to sandbox: slot %d '%s'\n", + slotId, path.toRawUTF8()); + + juce::PluginDescription desc; + desc.fileOrIdentifier = path; + desc.name = juce::File(path).getFileNameWithoutExtension(); + + // tryLoadSandboxed only accepts a plugin that shouldSandbox() + // approves, so pin this path to the runtime sandbox list first. + // Remember whether it was ALREADY pinned: if the promotion fails + // we undo only OUR pin below, so a healthy, never-crashed plugin + // isn't left permanently forced to a sandbox that just proved + // unavailable (while a pre-existing/real blocklist entry stays). + const bool wasAlreadyPinned = slopsmith::sandbox::isCrashedPlugin(path); + slopsmith::sandbox::addCrashedPlugin(path); + + bool promoted = false; + juce::String err; + auto sandboxed = slopsmith::sandbox::tryLoadSandboxed( + desc, chain.getCurrentSampleRate(), chain.getCurrentBlockSize(), err); + if (sandboxed) + { + if (state.getSize() > 0) + sandboxed->setStateInformation(state.getData(), (int) state.getSize()); + if (chain.replaceProcessor(slotId, std::move(sandboxed))) + { + promoted = true; + // A promotion swaps the slot's processor: bump the + // generation (we hold chainMutationMutex via the + // try_lock above) so JS-side chain owners re-sync + // instead of driving the replaced slot blind. + slopsmith::addon::bumpChainGeneration(); + bool editorOpened = false; + if (auto* slot2 = chain.getSlot(slotId)) + if (auto* sb = dynamic_cast(slot2->processor.get())) + editorOpened = sb->requestOpenEditor(); + fprintf(stderr, "[AudioEngine] editor-open: sandbox promotion OK for slot %d (editor %s)\n", + slotId, editorOpened ? "opened" : "FAILED to open"); + } + else + { + fprintf(stderr, "[AudioEngine] editor-open: replaceProcessor failed for slot %d\n", slotId); + } + } + else + { + fprintf(stderr, "[AudioEngine] editor-open: sandbox promotion failed for '%s': %s\n", + path.toRawUTF8(), err.toRawUTF8()); + } + + // Undo our transient pin on failure so a plugin that never crashed + // isn't stranded on the (evidently unavailable) sandbox route. + if (! promoted && ! wasAlreadyPinned) + slopsmith::sandbox::removeCrashedPlugin(path); + + // Promoted or not, never fall through to the in-process editor on + // Windows — that is the WndProc/Qt crash path this branch exists + // to avoid. + return; + } + // Not promotable (non-VST / editor-less / already-sandboxed, or the + // guarded capture faulted and released the processor). Fall through to + // the in-process branch below, which is safe for all of those cases + // (an already-sandboxed slot opens its editor out-of-process; an + // editor-less or released processor simply opens no window). + } + + // In-process editor: non-VST3, editor-less, already-sandboxed, or POSIX + // (where the in-process editor is safe). + // + // Re-check the processor: the promotion branch above documents that a + // faulted captureVstStateForPromotion() can RELEASE the slot's + // processor before returning false — falling through here with a null + // processor would crash on createEditorAndMakeActive(). + auto* processor = slot->processor.get(); + if (processor == nullptr) + return; + auto name = slot->name; + juce::AudioProcessorEditor* editor = nullptr; + try { + editor = processor->createEditorAndMakeActive(); + } catch (const std::exception& e) { + fprintf(stderr, "[AudioEngine] createEditorAndMakeActive crashed for '%s': %s\n", name.toRawUTF8(), e.what()); + } catch (...) { + fprintf(stderr, "[AudioEngine] createEditorAndMakeActive crashed for '%s': unknown error\n", name.toRawUTF8()); + } + if (editor) + { + editorWindows[slotId] = std::make_unique(editor, name); + fprintf(stderr, "[AudioEngine] Opened editor for slot %d: %s (%dx%d)\n", + slotId, name.toRawUTF8(), editor->getWidth(), editor->getHeight()); + } + }); + + return Napi::Boolean::New(env, queued); +} + +Napi::Value ClosePluginEditor(const Napi::CallbackInfo& info) +{ + auto env = info.Env(); + const auto slotIdOpt = argSlotId(info, 0); + if (!slotIdOpt) return Napi::Boolean::New(env, false); + const int slotId = *slotIdOpt; + + // One queued lambda handles both the sandbox and in-process paths, for + // two reasons: + // - editorWindows is message-thread-owned; the old synchronous + // find() here raced the message-thread inserts/erases. + // - getSlot() from this (N-API) thread dereferenced a slot a chain + // worker could free mid-call; the slot is now resolved inside the + // lambda under a try_lock on the chain-mutation mutex. + // requestCloseEditor() ultimately writes to the control pipe (writeFrame + // can block up to ~5s on a stalled reader), so dispatching also keeps a + // slow sandbox from freezing JS / the renderer UI. + const bool queued = juce::MessageManager::callAsync([slotId]() + { + // Host-side window (in-process plugins). Erasing a missing key is a + // no-op; sandbox slots never have an entry here. + editorWindows.erase(slotId); + +#if defined(SLOPSMITH_AUDIO_ADDON) + auto liveEngine = snapshotEngine(); + if (!liveEngine) return; + // try_lock, NEVER a blocking lock on the message thread: chain + // workers holding the mutex block-wait on this very thread — + // blocking here would deadlock. Contention means the chain is being + // rebuilt, which tears editors down anyway. + std::unique_lock chainLock( + slopsmith::addon::chainMutationMutex(), std::try_to_lock); + if (!chainLock.owns_lock()) return; + if (auto* slot = liveEngine->getSignalChain().getSlot(slotId)) + if (auto* sb = dynamic_cast(slot->processor.get())) + sb->requestCloseEditor(); +#endif + }); + return Napi::Boolean::New(env, queued); +} + + +} // namespace slopsmith::addon diff --git a/src/audio/addon/EditorWindows.h b/src/audio/addon/EditorWindows.h new file mode 100644 index 0000000..45304a7 --- /dev/null +++ b/src/audio/addon/EditorWindows.h @@ -0,0 +1,31 @@ +#pragma once + +// EditorWindows — in-process plugin editor windows + the open/close bindings +// and the Windows sandbox-promotion flow (TLC plan phase 7 / §3.4). Moved +// verbatim from NodeAddon.cpp. Owns the slotId→window map (message-thread +// only) and the teardown helpers every chain-clearing path must run BEFORE +// freeing slot processors (use-after-free; feedBack-desktop#56). + +#include + +namespace slopsmith::addon { + +// Inline teardown: destroys every editor window. Caller MUST already be on +// the message thread (the window map holds JUCE GUI objects). doShutdown's +// UI teardown hook points here. +void destroyAllPluginEditorWindowsOnMessageThread(); + +// Tears down the in-process editor windows so they are destroyed before the +// caller frees the processors those editors point at. Safe from the Node +// thread (posts to the message thread and blocks, bounded) or the message +// thread itself (inline). Clearing an empty map is cheap. +// Returns false when teardown did NOT complete (post refused or the bounded +// wait timed out) — the caller must not free chain processors in that case +// (#56 use-after-free). +bool closeAllPluginEditorWindows(); + +// N-API bindings (registered by NodeAddon's export table). +Napi::Value OpenPluginEditor(const Napi::CallbackInfo& info); +Napi::Value ClosePluginEditor(const Napi::CallbackInfo& info); + +} // namespace slopsmith::addon diff --git a/src/audio/addon/NapiHelpers.h b/src/audio/addon/NapiHelpers.h new file mode 100644 index 0000000..7815b0c --- /dev/null +++ b/src/audio/addon/NapiHelpers.h @@ -0,0 +1,77 @@ +#pragma once + +// NapiHelpers — typed N-API argument extractors (TLC plan phase 6 / §3.2). +// Generalizes the getValidatedSource pattern so argument validation is +// structural, not per-binding: Int32Value() silently coerces NaN/Infinity +// into a valid index (NaN → 0), which let a malformed slot id hit a REAL +// slot (deep-read §2). Every extractor returns nullopt for a missing / +// non-Number / non-finite / out-of-range argument, and the binding no-ops — +// fail-soft, matching the addon's NAPI_DISABLE_CPP_EXCEPTIONS posture. +// +// New bindings should have no raw As() path to copy. + +#include + +#include +#include +#include + +namespace slopsmith::addon { + +// Finite integer in [minV, maxV]. The 4096 default ceiling keeps the cast +// well-defined for index-shaped args (channel/branch indices and the like). +inline std::optional argInt(const Napi::CallbackInfo& info, size_t i, + int minV = 0, int maxV = 4096) +{ + if (i >= info.Length() || ! info[i].IsNumber()) return std::nullopt; + const double raw = info[i].As().DoubleValue(); + if (! std::isfinite(raw) || raw != std::floor(raw)) return std::nullopt; + if (raw < (double) minV || raw > (double) maxV) return std::nullopt; + return (int) raw; +} + +// Slot / source / param-index ids: finite non-negative integers. +// +// NOT bounded by argInt's 4096 index ceiling. A slot id is a monotonic HANDLE +// from SignalChain::nextSlotId, which increments on every addProcessor and is +// never reset by clear() — a long session (each song load and mid-song tone +// switch rebuilds a chainful of slots) walks past 4096, and a ceiling here +// would then make every guarded binding — setBypass, setParameter, remove/ +// moveProcessor, open/closePluginEditor — silently no-op for the rest of the +// run. Ids that don't name a live slot are rejected by SignalChain's own +// findSlotIndex; the job here is only to keep the NaN/Inf/fractional class out +// (Int32Value() coerces NaN to 0, i.e. a real slot). +inline std::optional argSlotId(const Napi::CallbackInfo& info, size_t i) +{ + return argInt(info, i, 0, std::numeric_limits::max()); +} + +// Finite float (parameter values, gains, pans). Range clamping stays with +// the engine-side sanitizers (GainSanitize.h) — this only rejects the +// NaN/Inf class that coercion would otherwise let through. +inline std::optional argFiniteFloat(const Napi::CallbackInfo& info, size_t i) +{ + if (i >= info.Length() || ! info[i].IsNumber()) return std::nullopt; + const double raw = info[i].As().DoubleValue(); + if (! std::isfinite(raw)) return std::nullopt; + return (float) raw; +} + +inline std::optional argBool(const Napi::CallbackInfo& info, size_t i) +{ + if (i >= info.Length() || ! info[i].IsBoolean()) return std::nullopt; + return info[i].As().Value(); +} + +// MIDI channel: JUCE expects 1..16 and asserts otherwise. +inline std::optional argMidiChannel(const Napi::CallbackInfo& info, size_t i) +{ + return argInt(info, i, 1, 16); +} +// MIDI data byte (program / controller / value): 0..127. +inline std::optional argMidiByte(const Napi::CallbackInfo& info, size_t i) +{ + return argInt(info, i, 0, 127); +} + +} // namespace slopsmith::addon diff --git a/src/audio/engine/BackingPlayer.cpp b/src/audio/engine/BackingPlayer.cpp new file mode 100644 index 0000000..c6a0161 --- /dev/null +++ b/src/audio/engine/BackingPlayer.cpp @@ -0,0 +1,325 @@ +// BackingPlayer implementation — moved verbatim from AudioEngine.cpp (TLC +// plan phase 3 / §2.4); member names lose their backing prefixes, logic is +// unchanged. See BackingPlayer.h for the boundary rationale. + +#include "BackingPlayer.h" + +#include +#include + +namespace slopsmith { + +bool BackingPlayer::load(const juce::File& file) +{ + const juce::ScopedLock sl(lock); + stopNoLock(); + transport.reset(); + readerSource.reset(); + + const bool exists = file.existsAsFile(); + std::cerr << "[AudioEngine] loadBackingTrack path=" + << file.getFullPathName().toStdString() + << " exists=" << exists + << " size=" << (exists ? (long long) file.getSize() : -1) + << std::endl; + + auto* reader = formatManager.createReaderFor(file); + if (!reader) + { + std::cerr << "[AudioEngine] loadBackingTrack: no reader for ext='" + << file.getFileExtension().toStdString() + << "' (registered formats=" << formatManager.getNumKnownFormats() + << ")" << std::endl; + // Transport/source already reset above; clear cached state so the renderer + // doesn't keep displaying the previous track's position/duration. + cachedPosition.store(0.0); + cachedDuration.store(0.0); + return false; + } + + const double readerSampleRate = reader->sampleRate; + const juce::int64 readerLengthInSamples = reader->lengthInSamples; + const double sr = state.currentSampleRate.load(std::memory_order_relaxed); + // Backing audio plays through the output device in both modes, so size + // against outputBlockSize. In duplex mode outputBlockSize == inputBlockSize; + // in split mode the output device's clock drives the backing pull. + const int bs = state.outputBlockSize.load(std::memory_order_relaxed); + + readerSource = std::make_unique(reader, true); + transport = std::make_unique(); + // Read-ahead on readThread so the RT audio thread normally never touches + // the disk or the format codec. Previously this passed (…, 0, nullptr, …): + // with no read-ahead buffer the transport decoded the file synchronously + // inside getNextAudioBlock ON the audio callback, so any disk seek / + // decode spike (worst for compressed formats) blew the block budget → + // underruns heard as glitches or brief mutes while a song plays. + // 32768 source frames ≈ 0.68 s @ 48k of look-ahead absorbs those spikes. + // + // Known residual (accepted): juce::BufferingAudioSource is not fully + // RT-safe — readBufferSection() holds callbackLock across the decode of one + // refill chunk, and the callback's getNextAudioBlock() takes the same lock, + // so the RT thread can still block behind an in-flight chunk decode. The + // window is bounded (JUCE caps chunks at 2048 source frames) and only hit + // when a refill is mid-decode, vs. the old guaranteed full decode on every + // block; a truly lock-free ring would mean replacing the JUCE transport + // stack and isn't worth it here. + // The 4th arg makes AudioTransportSource SRC the file to device rate. + // Stretch always sees device-rate audio so that its presetDefault parameters match. + constexpr int kReadAheadSamples = 32768; + transport->setSource(readerSource.get(), kReadAheadSamples, + &readThread, readerSampleRate); + + // Loading a backing track before the audio device has started leaves + // sr/bs at zero. presetDefault(2, 0.0f) would seed the stretcher with + // undefined internal timing, and prepareToPlay(0, 0) is similarly + // ill-defined. Defer the stretcher + buffer setup; the relevant + // audio*AboutToStart() re-runs the same block (via prepare()) once a real + // sample rate / block size are known. + if (sr > 0.0 && bs > 0) + { + // prepareToPlay's first arg is an upper bound on subsequent + // getNextAudioBlock requests, per the juce::AudioSource contract. + // The RT callback can pull ceil(bs * kMaxSpeed) frames in a single + // block when the speed is above 1×, so prepare for that worst case — + // preparing with just `bs` would risk JUCE internal buffer + // overruns/asserts on the first faster-than-1× block. + const int maxInputFrames = (int) std::ceil(bs * kMaxSpeed) + 64; + transport->prepareToPlay(maxInputFrames, sr); + + stretch.presetDefault(2, (float) sr); + stretch.reset(); + stretchLatencySamples.store(stretch.outputLatency(), std::memory_order_relaxed); + + inputBuffer.setSize(2, maxInputFrames, false, false, true); + outputBuffer.setSize(2, bs, false, false, true); + } + + cachedDuration.store(transport->getLengthInSeconds()); + cachedPosition.store(0.0); + heardPositionSec.store(0.0, std::memory_order_relaxed); + + // Reset the loudness leveler for the new song: clearing the cached sample + // rate forces renderBlockLocked() to re-prepare() it on the next block, + // dropping the previous track's AGC gain + limiter state. Otherwise the + // ~300 ms gain follower would carry over and briefly mis-level the start + // of a much louder/quieter next song. Safe here — load holds the lock, + // the same lock the render path runs under. + levelerSr = 0.0; + std::cerr << "[AudioEngine] loadBackingTrack OK sr=" << readerSampleRate + << " len=" << readerLengthInSamples + << std::endl; + return true; +} + +void BackingPlayer::setPosition(double seconds) +{ + const juce::ScopedLock sl(lock); + if (transport) + { + transport->setPosition(seconds); + stretch.reset(); + // Read back the actual position; the transport may clamp (e.g. negative or past EOF). + const double pos = transport->getCurrentPosition(); + cachedPosition.store(pos); + heardPositionSec.store(pos, std::memory_order_relaxed); + } +} + +void BackingPlayer::start() +{ + const juce::ScopedLock sl(lock); + if (transport) + { + transport->start(); + playing.store(true); + heardPositionSec.store(transport->getCurrentPosition(), + std::memory_order_relaxed); + } +} + +void BackingPlayer::stopNoLock() +{ + if (transport) + { + transport->stop(); + stretch.reset(); + playing.store(false); + } +} + +void BackingPlayer::stop() +{ + const juce::ScopedLock sl(lock); + stopNoLock(); +} + +void BackingPlayer::setSpeed(double newSpeed) +{ + if (!std::isfinite(newSpeed) || newSpeed <= 0.0) + { + return; + } + + const double clamped = juce::jlimit(0.01, kMaxSpeed, newSpeed); + // Dead-zone against the last *requested* rate to coalesce rapid slider + // ticks — but never skip a change that crosses the 1× bypass boundary, or a + // request just shy of 1× (e.g. 0.9995 -> 1.0, diff < 0.001) would leave the + // stretcher path engaged when the caller actually asked for transparent + // full speed. + const double prev = pendingSpeed.load(std::memory_order_relaxed); + const bool prevBypass = std::abs(prev - 1.0) < kSpeedBypassEpsilon; + const bool newBypass = std::abs(clamped - 1.0) < kSpeedBypassEpsilon; + if (std::abs(clamped - prev) < 0.001 && prevBypass == newBypass) + { + return; + } + + // Lock-free hand-off to the audio thread. Publish the requested rate, then + // raise the pending flag with release so the RT thread is guaranteed to see + // the new rate once it observes the flag. renderBlockLocked() adopts the + // rate and resets the stretcher together, on the audio thread, so: + // * a control-thread caller (e.g. a speed slider at 30-60 Hz) never takes + // the lock and so never starves the RT tryLock into dropping a block; + // * the new rate is never processed with stale stretch state — the reset + // and the rate adoption happen in the same RT block (see PR #237). + // Multiple updates before the RT consumes them coalesce (latest wins), which + // naturally throttles stretcher resets during a drag. + pendingSpeed.store(clamped, std::memory_order_relaxed); + speedChangePending.store(true, std::memory_order_release); +} + +void BackingPlayer::prepare(double sr, int bs) +{ + const juce::ScopedLock sl(lock); + if (transport && sr > 0.0 && bs > 0) + { + // See load() for why prepareToPlay uses maxInputFrames rather than bs: + // the RT callback can pull ceil(bs * kMaxSpeed) frames in a single + // block at faster-than-1× speeds. + const int maxInputFrames = (int) std::ceil(bs * kMaxSpeed) + 64; + transport->prepareToPlay(maxInputFrames, sr); + stretch.presetDefault(2, (float) sr); + stretch.reset(); + stretchLatencySamples.store(stretch.outputLatency(), std::memory_order_relaxed); + inputBuffer.setSize(2, maxInputFrames, false, false, true); + outputBuffer.setSize(2, bs, false, false, true); + } +} + +int BackingPlayer::renderBlockLocked(int numSamples) +{ + // Adopt any speed change requested since the last block (set lock-free by + // setSpeed). Common (no-change) path is a plain acquire load — no locked + // RMW, so the flag's cache line stays shared and isn't bounced to this + // core every callback. Only the rare block that actually consumes a change + // does the exchange (clearing the flag atomically so a concurrent setSpeed + // can't lose an update). The acquire pairs with the release-store in + // setSpeed so the new rate is visible here. Reset the stretcher and + // re-anchor the heard position in the SAME block we adopt the rate, so a + // block is never processed at the new rate with stale stretch state. + // reset() only clears state (no allocation), so it's audio-thread safe. + if (speedChangePending.load(std::memory_order_acquire)) + { + speedChangePending.exchange(false, std::memory_order_acquire); + speed.store(juce::jlimit(0.01, kMaxSpeed, + pendingSpeed.load(std::memory_order_relaxed)), + std::memory_order_relaxed); + stretch.reset(); + heardPositionSec.store(transport->getCurrentPosition(), + std::memory_order_relaxed); + } + + const double rate = juce::jlimit(0.01, kMaxSpeed, speed.load(std::memory_order_relaxed)); + + // Defensive clamp: the buffers are sized by prepare() from the device's + // nominal block size, but a callback can deliver a larger numSamples on a + // device-reconfig race. Drop the excess frames silently rather than + // reading/writing past the allocated span; the next callback after + // reconfig arrives at the new nominal size. + const int outCap = outputBuffer.getNumSamples(); + const int inCap = inputBuffer.getNumSamples(); + const int outSamples = juce::jmin(numSamples, outCap); + const double sr = state.currentSampleRate.load(std::memory_order_relaxed); + const bool bypassStretch = std::abs(rate - 1.0) < kSpeedBypassEpsilon; + + int sourceFramesPulled = 0; + + if (bypassStretch) + { + // 1× — direct transport read, no phase-vocoder path. (The transport + // still sample-rate-converts the file to the device rate, so this is + // "no time-stretch", not necessarily bit-perfect.) + outputBuffer.clear(0, outSamples); + juce::AudioSourceChannelInfo info(&outputBuffer, 0, outSamples); + transport->getNextAudioBlock(info); + sourceFramesPulled = outSamples; + } + else + { + // Slow/fast path — pull only the source frames needed for this output + // block (output * rate), then stretch in-process to fill outSamples. + const int inputFrames = juce::jmin((int) std::ceil(outSamples * rate), inCap); + + inputBuffer.clear(0, inputFrames); + juce::AudioSourceChannelInfo info(&inputBuffer, 0, inputFrames); + transport->getNextAudioBlock(info); + sourceFramesPulled = inputFrames; + + outputBuffer.clear(0, outSamples); + + const float* const* inPtrs = inputBuffer.getArrayOfReadPointers(); + float* const* outPtrs = outputBuffer.getArrayOfWritePointers(); + stretch.process(inPtrs, inputFrames, outPtrs, outSamples); + } + + const double transportPos = transport->getCurrentPosition(); + if (sr > 0.0 && sourceFramesPulled > 0) + { + // Accumulate the heard (source) position, but clamp to the transport's + // actual position. sourceFramesPulled is the requested block size; a + // short read (e.g. at EOF, where the transport returns fewer real frames + // and zero-pads) would otherwise advance the playhead past the true + // source point and report progress beyond the track duration before + // `playing` flips false. getCurrentPosition() stays clamped to the + // real source position. + double heard = heardPositionSec.load(std::memory_order_relaxed) + + static_cast(sourceFramesPulled) / sr; + heard = juce::jmin(heard, transportPos); + heardPositionSec.store(heard, std::memory_order_relaxed); + + // Bypass reads straight from the transport — no phase-vocoder output + // latency to compensate for. Only the stretch path adds latency. + const double latencyInputSec = bypassStretch + ? 0.0 + : (stretchLatencySamples.load(std::memory_order_relaxed) * rate) / sr; + cachedPosition.store(juce::jmax(0.0, heard - latencyInputSec)); + } + else + { + // currentSampleRate is transiently 0 during device teardown/reconfig. + // We can't accumulate (no Hz to divide by), so anchor both the heard + // accumulator and the published playhead to the real transport position + // rather than leaving a stale value visible to the UI. + heardPositionSec.store(transportPos, std::memory_order_relaxed); + cachedPosition.store(juce::jmax(0.0, transportPos)); + } + + // Sync the flag if transport stopped at EOF. + if (!transport->isPlaying()) + playing.store(false); + + // Normalize the backing track to a consistent target loudness (-12 LUFS) + // BEFORE the mixer's backing-volume fader is applied (later in the RT + // callback), so every song sits at the same level while the fader still + // attenuates it. Standard BS.1770 K-weighting (full-mix music) + a brickwall + // limiter to keep boosted peaks safe. RT-safe (no allocation). + if (outSamples > 0 && sr > 0.0) + { + if (sr != levelerSr) { leveler.prepare(sr); levelerSr = sr; } + leveler.process(outputBuffer, outSamples, -12.0f); + } + + return outSamples; +} + +} // namespace slopsmith diff --git a/src/audio/engine/BackingPlayer.h b/src/audio/engine/BackingPlayer.h new file mode 100644 index 0000000..289cae1 --- /dev/null +++ b/src/audio/engine/BackingPlayer.h @@ -0,0 +1,121 @@ +#pragma once + +// BackingPlayer — the backing-track transport (TLC plan phase 3 / §2.4). +// Moved verbatim from AudioEngine: JUCE AudioFormatReaderSource → +// AudioTransportSource buffered by a TimeSliceThread read-ahead → optional +// signalsmith-stretch phase vocoder for speed change (1× bypass path), the +// per-song BackingLeveler loudness normalizer, and the playhead caches. +// +// Boundary: control-thread lifecycle (load/start/stop/seek/setSpeed) and +// non-blocking cached getters live here; the RT mix POLICY (try-lock, RMS +// metering, volume fader, stream-submix capture) stays in the engine's +// output callbacks, which use the primitives getLock() / readyLocked() / +// renderBlockLocked() / renderBuffer() exactly as they open-coded them +// before. Both callbacks hold the try-lock through their stream publish so +// renderBuffer() is never read while prepare() can resize it. + +#include "EngineState.h" +#include "../BackingLeveler.h" +#include "signalsmith-stretch.h" // resolved via SS_STRETCH_DIR include path + +#include +#include + +#include +#include + +namespace slopsmith { + +class BackingPlayer +{ +public: + static constexpr double kMaxSpeed = 4.0; + // |rate - 1| below this uses the direct transport path (no phase vocoder). + static constexpr double kSpeedBypassEpsilon = 1.0e-4; + + explicit BackingPlayer(EngineState& engineState) : state(engineState) + { + formatManager.registerBasicFormats(); + readThread.startThread(); + } + + // ── Control thread ──────────────────────────────────────────────────── + bool load(const juce::File& file); + void setPosition(double seconds); + void start(); + void stop(); + void setSpeed(double speed); + + // Non-blocking reads — do not acquire the lock, never block the audio + // callback. + bool isPlaying() const { return playing.load(); } + double getPosition() const { return cachedPosition.load(); } + double getDuration() const { return cachedDuration.load(); } + + // Re-prepare the transport + stretcher + buffers at a (new) device format. + // Call from the about-to-start hook that owns backing playback (duplex: + // input manager; split: output manager). No-op when nothing is loaded. + void prepare(double sr, int bs); + + // ── RT primitives (output callbacks) ────────────────────────────────── + // Usage pattern (unchanged from the open-coded version): + // const juce::ScopedTryLock sl(backing.getLock()); + // if (sl.isLocked() && backing.readyLocked()) { + // const int n = backing.renderBlockLocked(numSamples); + // ... mix backing.renderBuffer() with the fader, meter RMS ... + // } + juce::CriticalSection& getLock() { return lock; } + bool readyLocked() const { return transport != nullptr && playing.load(); } + // Renders one block (1× bypass or phase-vocoder stretch) into the render + // buffer, advances heard/cached playheads, runs the loudness leveler, and + // clears `playing` at EOF. Returns output frames written + // (== jmin(numSamples, render-buffer cap)). Precondition: caller holds + // the lock and has verified readyLocked(). + int renderBlockLocked(int numSamples); + const juce::AudioBuffer& renderBuffer() const { return outputBuffer; } + +private: + void stopNoLock(); + + EngineState& state; + + juce::AudioFormatManager formatManager; + + // Read-ahead worker that fills the transport's buffer off the audio thread + // (see load()). Declared BEFORE transport so it is destroyed AFTER it — + // the transport's BufferingAudioSource holds a pointer to this thread and + // must be torn down before the thread goes away. + juce::TimeSliceThread readThread { "BackingReadAhead" }; + std::unique_ptr readerSource; + std::unique_ptr transport; + signalsmith::stretch::SignalsmithStretch stretch; + juce::AudioBuffer inputBuffer; // pulled from transport at device rate + juce::AudioBuffer outputBuffer; // stretch output, mixed by the callbacks + std::atomic stretchLatencySamples{0}; + std::atomic playing{false}; + std::atomic cachedPosition{0.0}; + std::atomic cachedDuration{0.0}; + // Heard playhead: accumulates the source frames consumed each block, then + // clamped to transport->getCurrentPosition() so a short read at EOF can't + // push it past the real source point. cachedPosition is this value minus + // the stretcher output latency (zero on the 1× bypass path). + std::atomic heardPositionSec{0.0}; + // Active playback rate. Mutated ONLY by the audio thread (in + // renderBlockLocked), coupled with the stretcher reset, so a block is + // never processed at a new rate with stale stretch state. + std::atomic speed{1.0}; + // Lock-free speed hand-off: setSpeed (control thread) publishes the + // requested rate here and raises speedChangePending; the audio thread + // adopts it on the next block. Avoids the control thread blocking on the + // lock and starving the RT tryLock (which would drop a backing block + // mid-slider-drag). + std::atomic pendingSpeed{1.0}; + std::atomic speedChangePending{false}; + // Per-song loudness normalizer (applied in renderBlockLocked, pre-fader). + // Owned + driven by the audio thread. + BackingLeveler leveler; + double levelerSr = 0.0; + juce::CriticalSection lock; +}; + +} // namespace slopsmith diff --git a/src/audio/engine/DeviceSetup.cpp b/src/audio/engine/DeviceSetup.cpp new file mode 100644 index 0000000..07160fd --- /dev/null +++ b/src/audio/engine/DeviceSetup.cpp @@ -0,0 +1,623 @@ +// DeviceSetup implementation — moved verbatim from AudioEngine.cpp (TLC plan +// phase 4 / §2.7). The only edits beyond member renames are the extraction of +// the three previously hand-synced helpers (ratesMatch / resolveDeviceName / +// rateSupportedBy), which each site now calls instead of open-coding. + +#include "DeviceSetup.h" + +#include +#include +#include + +namespace slopsmith { + +juce::String DeviceSetup::resolveDeviceName(juce::AudioIODeviceType* t, + bool isInput, const juce::String& name) +{ + if (t == nullptr || name.isNotEmpty()) return name; + auto names = t->getDeviceNames(isInput); + return names.size() > 0 ? names[0] : name; +} + +bool DeviceSetup::rateSupportedBy(juce::AudioIODeviceType* t, const juce::String& dev, + bool isInput, double sr) +{ + // v1 forces matching nominal SR — no adaptive resampler yet. Resolve empty + // name to first-enumerated for the createDevice probe call (matches + // probeDual's strategy). createDevice("") is implementation-defined per + // backend — some return the default, some return null. Using + // first-enumerated keeps probe and apply checking the SAME concrete + // device, so an empty-name config can't pass the UI probe and then fail + // this check. + if (!t) return false; + const juce::String resolved = resolveDeviceName(t, isInput, dev); + std::unique_ptr probe( + isInput ? t->createDevice({}, resolved) : t->createDevice(resolved, {})); + if (!probe) return false; + // Tolerance matches the probe-side rounding: probeDual rounds the matched + // rate to the nearest integer, so a backend reporting e.g. 47999.5 + // surfaces 48000 in the UI. If we kept `< 0.5` here, the round-trip would + // fail at apply time because |47999.5 - 48000.0| is exactly 0.5. + for (auto r : probe->getAvailableSampleRates()) + if (ratesMatch(r, sr)) return true; + return false; +} + +DeviceOptions DeviceSetup::probeDual(const juce::String& inputTypeName, + const juce::String& inputName, + const juce::String& outputTypeName, + const juce::String& outputName) +{ + DeviceOptions options; + options.inputType = inputTypeName; + options.outputType = outputTypeName.isEmpty() ? inputTypeName : outputTypeName; + options.type = options.inputType; // legacy alias + + // Resolve each side from its own manager so probe stays consistent with + // applySplit()/setOutputDeviceType(), which mutate the manager that owns + // the side they're configuring. Using the input manager for the output + // lookup would silently fall back to whatever input has scanned, which + // can miss output-only backends. + auto findType = [](juce::AudioDeviceManager& manager, + const juce::String& wanted) -> juce::AudioIODeviceType* { + juce::AudioIODeviceType* match = nullptr; + for (auto* type : manager.getAvailableDeviceTypes()) + { + if ((wanted.isNotEmpty() && type->getTypeName() == wanted) + || (wanted.isEmpty() && match == nullptr)) + { + match = type; + if (wanted.isNotEmpty()) break; + } + } + return match; + }; + + auto* inputType = findType(inMgr, options.inputType); + + // Match setAudioDevices's resolution: when the caller didn't specify + // an output type, default it to the SAME type the input side resolved + // to (using the type's name, looked up in the output manager). Without + // this, an empty `options.outputType` would let findType pick whatever + // the output manager enumerates first — potentially a different backend + // than the input manager picked from the empty string, which then + // disagrees with the apply path's duplex classification. + juce::String effectiveOutputTypeName = options.outputType; + if (effectiveOutputTypeName.isEmpty() && inputType != nullptr) + effectiveOutputTypeName = inputType->getTypeName(); + auto* outputType = findType(outMgr, effectiveOutputTypeName); + + if (inputType == nullptr) + { + options.error = "Input device type not found"; + options.compatible = false; + return options; + } + if (outputType == nullptr) + { + options.error = "Output device type not found"; + options.compatible = false; + return options; + } + + try + { + options.inputType = inputType->getTypeName(); + options.outputType = outputType->getTypeName(); + options.type = options.inputType; + + options.input = inputName; + options.output = outputName; + + // For probing we still need a concrete device to instantiate. + // Resolve empty names to first-enumerated ONLY for the probe-device + // creation below — DON'T write back into options.input/options.output; + // those flow to the UI and the apply path, which treat empty as + // "OS default" per side. + const juce::String probeInputName = resolveDeviceName(inputType, true, options.input); + const juce::String probeOutputName = resolveDeviceName(outputType, false, options.output); + + // Probe the SAME way setAudioDevices() will actually apply, or the + // startup auto-apply mis-fires: init() fail-closes on this probe's + // `compatible` verdict, so if the probe measures a combined duplex device + // but apply then opens split (or vice-versa), the verdict describes a + // config that won't be the one used — the classic symptom being "no audio + // until I press Apply". Duplex is only attempted for the SAME physical + // endpoint (a true single-clock device); two different endpoints of the + // same backend (USB cable in + separate speakers out) are two clocks and + // go split. Mirror setAudioDevices()'s sameEndpointIntent exactly. + bool isDuplex = (options.inputType == options.outputType) + && (options.input == options.output); + + if (isDuplex) + { + std::unique_ptr dev( + inputType->createDevice(probeOutputName, probeInputName)); + if (dev) + { + options.inputChannels = dev->getInputChannelNames(); + options.outputChannels = dev->getOutputChannelNames(); + for (auto rate : dev->getAvailableSampleRates()) + options.sampleRates.addIfNotAlreadyThere(rate); + for (auto size : dev->getAvailableBufferSizes()) + options.bufferSizes.addIfNotAlreadyThere(size); + } + else + { + isDuplex = false; + } + } + if (!isDuplex) + { + std::unique_ptr inDev( + inputType->createDevice({}, probeInputName)); + std::unique_ptr outDev( + outputType->createDevice(probeOutputName, {})); + if (!inDev || !outDev) + { + options.error = "Could not create dual probe devices"; + options.compatible = false; + return options; + } + + options.inputChannels = inDev->getInputChannelNames(); + options.outputChannels = outDev->getOutputChannelNames(); + + // Tolerance covers backends that report fractional drift around + // the nominal rate — ratesMatch is the same <= 0.5 the apply-side + // rateSupportedBy check uses, so the probe can't reject a + // boundary case the apply would accept (or vice versa). + const auto inRates = inDev->getAvailableSampleRates(); + const auto outRates = outDev->getAvailableSampleRates(); + for (auto r : inRates) + { + for (auto r2 : outRates) + { + if (ratesMatch(r, r2)) + { + // Midpoint-rounded clean nominal, fail-closed when the + // rounded value falls outside tolerance of either side + // — see nominalRateCandidate (RateMatch.h). + double candidate = 0.0; + if (nominalRateCandidate(r, r2, candidate)) + options.sampleRates.addIfNotAlreadyThere(candidate); + break; + } + } + } + if (options.sampleRates.isEmpty()) + { + options.error = "Input and output devices share no common sample rate"; + options.compatible = false; + } + + // Split mode opens both sides with the same bufferSize, so the + // UI should only see sizes the intersection of both devices + // supports — a union would let the user pick a value that + // predictably fails at apply time on one side. + const auto inBufs = inDev->getAvailableBufferSizes(); + const auto outBufs = outDev->getAvailableBufferSizes(); + for (auto b : inBufs) + { + for (auto b2 : outBufs) + { + if (b == b2) + { + options.bufferSizes.addIfNotAlreadyThere(b); + break; + } + } + } + // An empty intersection means there's no buffer size both sides + // accept; setting compatible=false stops the UI from re-enabling + // Apply against a guaranteed-fail config. + if (options.bufferSizes.isEmpty() && options.error.isEmpty()) + { + options.error = "Input and output devices share no common buffer size"; + options.compatible = false; + } + } + + fprintf(stderr, "[AudioEngine] Probed device options: inType='%s' outType='%s' in='%s' out='%s' " + "duplex=%d inputs=%d outputs=%d rates=%d buffers=%d compatible=%d\n", + options.inputType.toRawUTF8(), options.outputType.toRawUTF8(), + options.input.toRawUTF8(), options.output.toRawUTF8(), + (int) isDuplex, options.inputChannels.size(), options.outputChannels.size(), + options.sampleRates.size(), options.bufferSizes.size(), (int) options.compatible); + } + catch (const std::exception& e) + { + options.error = e.what(); + options.compatible = false; + } + catch (...) + { + options.error = "Probe failed"; + options.compatible = false; + } + + return options; +} + +juce::String DeviceSetup::applyDuplex(const juce::String& inputName, + const juce::String& outputName, + double sampleRate, int bufferSize, + SourceChain& monitorChain) +{ + juce::AudioDeviceManager::AudioDeviceSetup setup; + setup.inputDeviceName = inputName; + setup.outputDeviceName = outputName; + setup.sampleRate = sampleRate > 0 ? sampleRate : 48000.0; + setup.bufferSize = bufferSize > 0 ? bufferSize : 256; + setup.useDefaultInputChannels = inputName.isEmpty(); + setup.useDefaultOutputChannels = outputName.isEmpty(); + + // Channel masks must match too — high-numbered selectedInputChannel needs + // the expanded mask that an older session may not have opened. + if (auto* currentDevice = inMgr.getCurrentAudioDevice()) + { + try + { + juce::AudioDeviceManager::AudioDeviceSetup current; + inMgr.getAudioDeviceSetup(current); + + const int advertisedInputs = currentDevice->getInputChannelNames().size(); + juce::BigInteger expectedInputs; + expectedInputs.setRange(0, advertisedInputs > 0 ? advertisedInputs : 2, true); + + const int advertisedOutputs = currentDevice->getOutputChannelNames().size(); + juce::BigInteger expectedOutputs; + expectedOutputs.setRange(0, juce::jmin(advertisedOutputs > 0 ? advertisedOutputs : 2, 2), true); + + if (current.inputDeviceName == setup.inputDeviceName + && current.outputDeviceName == setup.outputDeviceName + && current.sampleRate == setup.sampleRate + && current.bufferSize == setup.bufferSize + && current.useDefaultInputChannels == setup.useDefaultInputChannels + && current.useDefaultOutputChannels == setup.useDefaultOutputChannels + && current.inputChannels == expectedInputs + && current.outputChannels == expectedOutputs + && state.duplexMode.load(std::memory_order_relaxed)) + { + fprintf(stderr, "[AudioEngine] Duplex device already configured with same settings, skipping\n"); + return {}; + } + } + catch (const std::exception& e) + { + fprintf(stderr, "[AudioEngine] Current device channel check failed: %s\n", e.what()); + } + catch (...) + { + fprintf(stderr, "[AudioEngine] Current device channel check failed (unknown)\n"); + } + } + + // ALSA deadlocks on reconfigure unless we fully close first. WASAPI + // reconfigures in place and is much slower if closed. +#if JUCE_LINUX + juce::String currentTypeName; + if (auto* currentType = inMgr.getCurrentDeviceTypeObject()) + currentTypeName = currentType->getTypeName(); + if (inMgr.getCurrentAudioDevice() != nullptr) + { + try { + inMgr.closeAudioDevice(); + fprintf(stderr, "[AudioEngine] Closed device for reconfiguration\n"); + if (currentTypeName.isNotEmpty()) + inMgr.setCurrentAudioDeviceType(currentTypeName, true); + } catch (...) { + fprintf(stderr, "[AudioEngine] closeAudioDevice crashed, continuing\n"); + } + } +#endif + + int inputChannelCount = 0; + int outputChannelCount = 0; + if (auto* type = inMgr.getCurrentDeviceTypeObject()) + { + try + { + if (auto probe = std::unique_ptr(type->createDevice(outputName, inputName))) + { + inputChannelCount = probe->getInputChannelNames().size(); + outputChannelCount = probe->getOutputChannelNames().size(); + } + } + catch (const std::exception& e) + { + fprintf(stderr, "[AudioEngine] Channel probe failed: %s\n", e.what()); + } + catch (...) + { + fprintf(stderr, "[AudioEngine] Channel probe failed (unknown)\n"); + } + } + if (inputChannelCount <= 0) inputChannelCount = 2; + if (outputChannelCount <= 0) outputChannelCount = 2; + + setup.inputChannels.setRange(0, inputChannelCount, true); + setup.outputChannels.setRange(0, juce::jmin(outputChannelCount, 2), true); + + juce::String result; + try { + result = inMgr.setAudioDeviceSetup(setup, true); + } catch (...) { + return "setAudioDeviceSetup threw"; + } + if (result.isNotEmpty()) + { + fprintf(stderr, "[AudioEngine] Device setup error: %s\n", result.toRawUTF8()); + try { + result = inMgr.initialiseWithDefaultDevices(2, 2); + } catch (...) { + return "fallback initialiseWithDefaultDevices threw"; + } + if (result.isNotEmpty()) + return "device setup failed: " + result; + } + + if (auto* configuredDevice = inMgr.getCurrentAudioDevice()) + { + const double sr = configuredDevice->getCurrentSampleRate(); + const int bs = configuredDevice->getCurrentBufferSizeSamples(); + state.currentSampleRate.store(sr, std::memory_order_relaxed); + state.inputBlockSize.store(bs, std::memory_order_relaxed); + state.outputBlockSize.store(bs, std::memory_order_relaxed); + + fprintf(stderr, "[AudioEngine] Duplex device configured OK. Current device: %s\n", + configuredDevice->getName().toRawUTF8()); + fprintf(stderr, "[AudioEngine] Actual device setup: sr=%.0f bs=%d (requested bs=%d)\n", + sr, bs, bufferSize); + + monitorChain.prepareMonitorChain(sr, bs); + return {}; + } + state.currentSampleRate.store(0.0, std::memory_order_relaxed); + state.inputBlockSize.store(0, std::memory_order_relaxed); + state.outputBlockSize.store(0, std::memory_order_relaxed); + monitorChain.releaseMonitorChain(); + return "no current device after setup"; +} + +DeviceConfigResult DeviceSetup::applySplit(const DeviceConfig& config, + SourceChain& monitorChain, + OutputRing& outputRing, + std::atomic& outputUnderflowCount, + std::atomic& inputOverflowCount, + juce::AudioIODeviceCallback& outputCallback, + bool& outputCallbackRegistered) +{ + DeviceConfigResult res; + res.duplex = false; + + // The split-mode output ring is fixed at kOutputRingFrames samples + // (~85ms @ 48kHz). A single callback at bufferSize > kOutputRingFrames + // would overrun the ring in one go, guaranteeing immediate + // overwrite/wrap and audible glitches. Reject those configurations up + // front — duplex still works fine since it bypasses the ring entirely. + if (config.bufferSize > kOutputRingFrames) + { + res.error = "Buffer size " + juce::String(config.bufferSize) + + " exceeds split-mode ring capacity (" + + juce::String(kOutputRingFrames) + "). Pick a smaller buffer size or use duplex."; + return res; + } + + // setCurrentAudioDeviceType can throw from JUCE backends (ASIO). + // Catch so the failure surfaces as a structured error rather than an + // exception crossing the N-API boundary. + try + { + if (auto* current = outMgr.getCurrentDeviceTypeObject()) + { + if (current->getTypeName() != config.outputType) + outMgr.setCurrentAudioDeviceType(config.outputType, true); + } + else + { + outMgr.setCurrentAudioDeviceType(config.outputType, true); + } + } + catch (...) + { + res.error = "setCurrentAudioDeviceType threw for output type '" + config.outputType + "'"; + return res; + } + + juce::AudioIODeviceType* inputType = nullptr; + juce::AudioIODeviceType* outputType = nullptr; + for (auto* t : inMgr.getAvailableDeviceTypes()) + if (t->getTypeName() == config.inputType) { inputType = t; break; } + for (auto* t : outMgr.getAvailableDeviceTypes()) + if (t->getTypeName() == config.outputType) { outputType = t; break; } + if (!inputType || !outputType) + { + res.error = "Device type not found"; + return res; + } + if (!rateSupportedBy(inputType, config.inputDevice, true, config.sampleRate) + || !rateSupportedBy(outputType, config.outputDevice, false, config.sampleRate)) + { + res.error = "Sample rate not supported by both input and output devices"; + return res; + } + + juce::AudioDeviceManager::AudioDeviceSetup inSetup; + // Resolve empty name to first-enumerated input device — matches the + // rateSupportedBy preflight above AND probeDual. Using empty + + // useDefault*Channels here would make JUCE open the OS default, which can + // differ from inputs[0] on platforms where the OS-default differs from + // JUCE's enumeration order. The probe + SR preflight + actual open all + // need to agree on the same concrete device for the apply path to behave + // consistently with what the UI showed the user. + const juce::String resolvedInputName = resolveDeviceName(inputType, true, config.inputDevice); + + inSetup.inputDeviceName = resolvedInputName; + inSetup.outputDeviceName = ""; + inSetup.sampleRate = config.sampleRate; + inSetup.bufferSize = config.bufferSize; + inSetup.useDefaultInputChannels = false; + inSetup.useDefaultOutputChannels = false; + + int inputChannelCount = 0; + { + try { + std::unique_ptr probe(inputType->createDevice({}, resolvedInputName)); + if (probe) inputChannelCount = probe->getInputChannelNames().size(); + } catch (...) {} + } + if (inputChannelCount <= 0) inputChannelCount = 2; + inSetup.inputChannels.setRange(0, inputChannelCount, true); + inSetup.outputChannels.clear(); + + // Rollback helper: on any failure path after a side has been opened, + // close both managers' devices so we don't leave the OS audio resource + // held (sometimes exclusively, e.g. ASIO) while setDevice reports a + // failure. closeAudioDevice is idempotent so unconditional calls are + // safe even when only the input or neither side opened. + auto rollbackOpenedDevices = [&]() { + // Drop any callback we already attached to the output manager — + // closeAudioDevice() does not invoke removeAudioCallback, and leaving + // outputCallbackRegistered=true would cause the next startAudio() + // to skip the re-attach (it gates on !outputCallbackRegistered), + // leaving split-mode output silent after a partial-open failure. + if (outputCallbackRegistered) + { + try { outMgr.removeAudioCallback(&outputCallback); } catch (...) {} + outputCallbackRegistered = false; + } + try { inMgr.closeAudioDevice(); } catch (...) {} + try { outMgr.closeAudioDevice(); } catch (...) {} + }; + + // Mirror applyDuplex's JUCE_LINUX close-before-reconfigure pattern: + // ALSA deadlocks if we let setAudioDeviceSetup mutate a live device. The + // device type is re-asserted afterwards so the close doesn't drop us back + // to whatever JUCE picked at startup. closeAudioDevice/setCurrentAudioDeviceType + // throwing is non-fatal — we still try the setup below and surface its error. +#if JUCE_LINUX + { + juce::String currentInputTypeName; + if (auto* currentType = inMgr.getCurrentDeviceTypeObject()) + currentInputTypeName = currentType->getTypeName(); + if (inMgr.getCurrentAudioDevice() != nullptr) + { + try { + inMgr.closeAudioDevice(); + if (currentInputTypeName.isNotEmpty()) + inMgr.setCurrentAudioDeviceType(currentInputTypeName, true); + } catch (...) { + fprintf(stderr, "[AudioEngine] split-mode input close threw, continuing\n"); + } + } + } +#endif + + juce::String inErr; + try { inErr = inMgr.setAudioDeviceSetup(inSetup, true); } + catch (...) { res.error = "input setAudioDeviceSetup threw"; rollbackOpenedDevices(); return res; } + if (inErr.isNotEmpty()) { res.error = "input setup: " + inErr; rollbackOpenedDevices(); return res; } + + auto* inDev = inMgr.getCurrentAudioDevice(); + if (!inDev) { res.error = "no input device after setup"; rollbackOpenedDevices(); return res; } + const double inSr = inDev->getCurrentSampleRate(); + const int inBs = inDev->getCurrentBufferSizeSamples(); + + // Same first-enumerated resolution on the output side — see input note + // above for why this matches the probe + SR preflight strategy. + const juce::String resolvedOutputName = resolveDeviceName(outputType, false, config.outputDevice); + + juce::AudioDeviceManager::AudioDeviceSetup outSetup; + outSetup.inputDeviceName = ""; + outSetup.outputDeviceName = resolvedOutputName; + outSetup.sampleRate = config.sampleRate; + outSetup.bufferSize = config.bufferSize; + outSetup.useDefaultInputChannels = false; + outSetup.useDefaultOutputChannels = false; + + int outputChannelCount = 0; + { + try { + std::unique_ptr probe(outputType->createDevice(resolvedOutputName, {})); + if (probe) outputChannelCount = probe->getOutputChannelNames().size(); + } catch (...) {} + } + if (outputChannelCount <= 0) outputChannelCount = 2; + outSetup.inputChannels.clear(); + outSetup.outputChannels.setRange(0, juce::jmin(outputChannelCount, 2), true); + + // Same JUCE_LINUX close-before-reconfigure as the input side above — also + // protects when split mode is re-applied with a different output device. +#if JUCE_LINUX + { + juce::String currentOutputTypeName; + if (auto* currentType = outMgr.getCurrentDeviceTypeObject()) + currentOutputTypeName = currentType->getTypeName(); + if (outMgr.getCurrentAudioDevice() != nullptr) + { + try { + outMgr.closeAudioDevice(); + if (currentOutputTypeName.isNotEmpty()) + outMgr.setCurrentAudioDeviceType(currentOutputTypeName, true); + } catch (...) { + fprintf(stderr, "[AudioEngine] split-mode output close threw, continuing\n"); + } + } + } +#endif + + juce::String outErr; + try { outErr = outMgr.setAudioDeviceSetup(outSetup, true); } + catch (...) { res.error = "output setAudioDeviceSetup threw"; rollbackOpenedDevices(); return res; } + if (outErr.isNotEmpty()) { res.error = "output setup: " + outErr; rollbackOpenedDevices(); return res; } + + auto* outDev = outMgr.getCurrentAudioDevice(); + if (!outDev) { res.error = "no output device after setup"; rollbackOpenedDevices(); return res; } + const double outSr = outDev->getCurrentSampleRate(); + const int outBs = outDev->getCurrentBufferSizeSamples(); + + if (!ratesMatch(inSr, outSr)) + { + res.error = "Input and output devices opened at different sample rates"; + rollbackOpenedDevices(); + return res; + } + + state.currentSampleRate.store(inSr, std::memory_order_relaxed); + state.inputBlockSize.store(inBs, std::memory_order_relaxed); + state.outputBlockSize.store(outBs, std::memory_order_relaxed); + + fprintf(stderr, "[AudioEngine] Split mode configured: inSr=%.0f inBs=%d outSr=%.0f outBs=%d\n", + inSr, inBs, outSr, outBs); + + outputRing.reset(); + outputUnderflowCount.store(0, std::memory_order_relaxed); + inputOverflowCount.store(0, std::memory_order_relaxed); + + monitorChain.prepareMonitorChain(inSr, inBs); + + res.ok = true; + res.sampleRate = inSr; + res.inputBlockSize = inBs; + res.outputBlockSize = outBs; + return res; +} + +void DeviceSetup::teardownSplit(OutputRing& outputRing, + juce::AudioIODeviceCallback& outputCallback, + bool& outputCallbackRegistered) +{ + // Unconditional remove — JUCE's removeAudioCallback is idempotent + // (no-op if the callback isn't registered), so we don't need the + // outputCallbackRegistered guard here. This makes teardown robust + // against a stale flag left over from a previous failed split setup. + outMgr.removeAudioCallback(&outputCallback); + outputCallbackRegistered = false; + try { outMgr.closeAudioDevice(); } + catch (...) { fprintf(stderr, "[AudioEngine] teardownSplitMode: output close threw\n"); } + + outputRing.reset(); +} + +} // namespace slopsmith diff --git a/src/audio/engine/DeviceSetup.h b/src/audio/engine/DeviceSetup.h new file mode 100644 index 0000000..3921e42 --- /dev/null +++ b/src/audio/engine/DeviceSetup.h @@ -0,0 +1,124 @@ +#pragma once + +// DeviceSetup — probe/apply/teardown for duplex + split device configs (TLC +// plan phase 4 / §2.7). Moved verbatim from AudioEngine; owns no lifetime — +// it holds references to the engine's two AudioDeviceManagers and its +// EngineState, and the engine-owned collaborators a specific operation needs +// (monitor chain, split output ring, output callback registration) are passed +// by reference at the call. setAudioDevices stays on the AudioEngine facade +// as the orchestrator (stop → resolve → duplex-or-split → restart). +// +// The rate-tolerance (`<= 0.5`, probe/preflight/verify), midpoint-rounding, +// and empty-name→first-enumerated resolution logic that used to live in three +// hand-synced copies is extracted into the shared helpers at the bottom — +// the deep-read §7 dedupe, landed structurally by this move. + +#include "EngineState.h" +#include "PackedStereoRing.h" +#include "RateMatch.h" +#include "../SourceChain.h" + +#include + +namespace slopsmith { + +// Public device-config shapes — aliased back as AudioEngine::DeviceOptions +// etc., so the NodeAddon surface is unchanged. +struct DeviceOptions +{ + juce::String type; // legacy alias = inputType + juce::String inputType; + juce::String outputType; + juce::String input; + juce::String output; + juce::StringArray inputChannels; + juce::StringArray outputChannels; + juce::Array sampleRates; // intersection when dual-type + juce::Array bufferSizes; + bool compatible = true; // false when types share no usable sample rate + juce::String error; +}; + +struct DeviceConfig +{ + juce::String inputType; + juce::String inputDevice; + juce::String outputType; + juce::String outputDevice; + double sampleRate = 48000.0; + int bufferSize = 256; +}; + +struct DeviceConfigResult +{ + bool ok = false; + juce::String error; + double sampleRate = 0.0; + int inputBlockSize = 0; + int outputBlockSize = 0; + bool duplex = true; +}; + +class DeviceSetup +{ +public: + // Must equal the engine's split-mode ring capacity. + static constexpr int kOutputRingFrames = 4096; + using OutputRing = PackedStereoRing; + + DeviceSetup(juce::AudioDeviceManager& inputManager, + juce::AudioDeviceManager& outputManager, + EngineState& engineState) + : inMgr(inputManager), outMgr(outputManager), state(engineState) {} + + // Probe what a (typeName, deviceName) pair supports — duplex when input + // and output are the same endpoint, else the dual/split intersection. + DeviceOptions probeDual(const juce::String& inputTypeName, + const juce::String& inputName, + const juce::String& outputTypeName, + const juce::String& outputName); + + // Open the combined (single-clock) duplex device on the input manager. + // Empty error string = success; on success stores the achieved format + // into EngineState and prepares `monitorChain`. + juce::String applyDuplex(const juce::String& inputName, + const juce::String& outputName, + double sampleRate, int bufferSize, + SourceChain& monitorChain); + + // Open input-only + output-only devices at a shared nominal rate. On + // success stores the achieved format, resets the split ring + counters, + // and prepares `monitorChain`. `outputCallback`/`outputCallbackRegistered` + // are needed by the partial-open rollback (a failure after the callback + // was attached must detach it, or the next startAudio() skips re-attach). + DeviceConfigResult applySplit(const DeviceConfig& config, + SourceChain& monitorChain, + OutputRing& outputRing, + std::atomic& outputUnderflowCount, + std::atomic& inputOverflowCount, + juce::AudioIODeviceCallback& outputCallback, + bool& outputCallbackRegistered); + + // Detach the output callback + close the output device + drain the ring. + void teardownSplit(OutputRing& outputRing, + juce::AudioIODeviceCallback& outputCallback, + bool& outputCallbackRegistered); + + // ── Shared helpers (the three previously hand-synced copies) ────────── + // ratesMatch / nominalRateCandidate live in RateMatch.h (JUCE-free, unit- + // tested); the device-name resolution helpers below need JUCE types. + // Empty device name → first-enumerated for that type/direction (probe, + // SR preflight, and split open must all check the SAME concrete device). + static juce::String resolveDeviceName(juce::AudioIODeviceType* t, + bool isInput, const juce::String& name); + // Whether `dev` (resolved) supports `sr` within tolerance. + static bool rateSupportedBy(juce::AudioIODeviceType* t, const juce::String& dev, + bool isInput, double sr); + +private: + juce::AudioDeviceManager& inMgr; + juce::AudioDeviceManager& outMgr; + EngineState& state; +}; + +} // namespace slopsmith diff --git a/src/audio/engine/EngineState.h b/src/audio/engine/EngineState.h new file mode 100644 index 0000000..7dcc1ef --- /dev/null +++ b/src/audio/engine/EngineState.h @@ -0,0 +1,55 @@ +#pragma once + +// EngineState — the audio engine's shared run-state atomics (TLC plan +// phase 1 / §2.8). Every extracted engine unit takes an EngineState& instead +// of reaching back into AudioEngine, which is what keeps them unit-testable +// without JUCE devices. AudioEngine itself binds these members by reference +// under their historical names, so existing call sites are untouched. +// +// The deliberate fix homed here (deep-read §3/§6): the old single +// `audioRunning` flag conflated USER INTENT ("the user pressed Start") with +// DEVICE STATE ("a device callback is live") — audioDeviceStopped() clears it +// on transient stops (WASAPI exclusive opens routinely fire one mid-start), +// so setAudioDevices' restart decision read a racy answer. The two are now +// separate atomics: +// +// userWantsAudio — intent. Written ONLY by startAudio()/stopAudio(). +// deviceRunning — state. Written by startAudio()/stopAudio() AND the +// device callbacks (aboutToStart/stopped), i.e. the exact +// semantics the old audioRunning had. isAudioRunning() +// keeps reporting THIS one (Phase 0.b compat pin). +// +// Until the phase-8 fix, nothing reads userWantsAudio — writing it here first +// keeps that later commit a one-line read-side change in setAudioDevices. + +#include + +namespace slopsmith { + +struct EngineState +{ + // Sample rate is written from the JUCE device callbacks (audio thread / + // device-management thread) and read from arbitrary callers including the + // JS thread, so a plain double would be a C++ data race. atomic + // is lock-free on the platforms we ship; hot reads use relaxed since the + // consumer just wants the latest observable value, not a sync point. + std::atomic currentSampleRate{48000.0}; + // Split mode allows different input vs output block sizes; the ring + // absorbs the asymmetry. DSP prepares against input; backing resampler + // against output. + std::atomic inputBlockSize{256}; + std::atomic outputBlockSize{256}; + // Duplex: one device manager owns both directions. Split: input-only + + // output-only managers with an SPSC ring between them. + std::atomic duplexMode{true}; + + // Intent: the user asked for audio to run. start/stopAudio only. + std::atomic userWantsAudio{false}; + // State: toggled from startAudio()/stopAudio() (main/device-management + // threads) and the device callbacks, read from isAudioRunning() on the JS + // thread. Plain bool would be a data race; relaxed-atomic compiles to a + // plain MOV. + std::atomic deviceRunning{false}; +}; + +} // namespace slopsmith diff --git a/src/audio/engine/ExtraInputs.cpp b/src/audio/engine/ExtraInputs.cpp new file mode 100644 index 0000000..2928a27 --- /dev/null +++ b/src/audio/engine/ExtraInputs.cpp @@ -0,0 +1,389 @@ +// ExtraInputs implementation — moved verbatim from AudioEngine.cpp (TLC plan +// phase 5 / §2.3). Member renames only: extraInputs[...] → slots[...], +// inputDeviceManager → primaryManager, engine atomics → EngineState, source +// loops → SourcePool helpers (same locking as the engine sites had). + +#include "ExtraInputs.h" + +#include +#include +#include + +namespace slopsmith { + +void ExtraInputs::slotCallback(int slot, const float* const* inputData, int numInputChannels, int numSamples) +{ + if (slot < 0 || slot >= kMaxExtraInputDevices) return; + InputDeviceSlot& s = slots[(size_t) slot]; + if (! s.active.load(std::memory_order_acquire)) return; + + const SourcePool::CallbackGuard cbGuard(pool, s.deviceKey); + + // Clamp to the per-slot scratch sized in slotAboutToStart so the hot + // loop never allocates if a reconfig race delivers a larger block. + const int cap = s.fanScratch.getNumSamples(); + if (numSamples > cap) numSamples = cap; + + juce::AudioBuffer mix; + mix.setDataToReferTo(s.fanScratch.getArrayOfWritePointers(), 2, numSamples); + pool.mixForDevice(s.deviceKey, inputData, numInputChannels, mix, s.monitorScratch, 2, numSamples); + s.ring.push(mix.getReadPointer(0), mix.getReadPointer(1), numSamples); +} + +void ExtraInputs::slotAboutToStart(int slot, juce::AudioIODevice* device) +{ + if (slot < 0 || slot >= kMaxExtraInputDevices || device == nullptr) return; + InputDeviceSlot& s = slots[(size_t) slot]; + const int bs = device->getCurrentBufferSizeSamples(); + s.blockSize.store(bs, std::memory_order_relaxed); + // Prepare against this DEVICE's actual sample rate — the source of truth. + // bind() forces it to (and verifies it equals) the engine rate, so the + // verifier (which reads the engine-wide currentSampleRate) and the detectors + // agree. Reading the device here rather than assuming currentSampleRate keeps + // the prepare correct even if a future path opens it differently. + double sr = device->getCurrentSampleRate(); + if (sr <= 0.0) sr = state.currentSampleRate.load(std::memory_order_relaxed); + s.sampleRate.store(sr, std::memory_order_relaxed); + // Size per-slot scratch generously (cold-start guard) on this device-management + // thread — never the RT thread. + const int cap = juce::jmax(bs, 2048); + s.fanScratch.setSize(2, cap, false, false, true); + s.monitorScratch.setSize(2, cap, false, false, true); + s.fanScratch.clear(); + s.monitorScratch.clear(); + s.ring.reset(); + + // Capture-latency correction: the renderer's playhead is aligned to the PRIMARY + // device's input latency, but this extra device captures with a different + // latency, so its audio sits at a different song-time than the playhead assumes. + // Set its sources' verifier offset to (extra − primary) input latency so they + // match this device's just-captured audio against the right chart notes. + int extraLatSamples = device->getInputLatencyInSamples(); + int primaryLatSamples = 0; + if (auto* pdev = primaryManager.getCurrentAudioDevice()) + primaryLatSamples = pdev->getInputLatencyInSamples(); + // (extra − primary) reported input latency. On JACK/PipeWire this is 0 (no + // latency reported); the residual per-device offset is instead dialed in by the + // user via setSourceVerifierOffset (a stable auto-measure isn't possible — the + // value is device-specific and signal-level-confounded). 0 here = no auto shift. + const double deltaSec = (sr > 0.0) ? (double) (extraLatSamples - primaryLatSamples) / sr : 0.0; + s.latencyDeltaSec.store(deltaSec, std::memory_order_relaxed); + + // Prepare each source bound to this device so its verifier/detectors run, and + // apply the latency correction. + pool.prepareDeviceSources(s.deviceKey, sr, bs, deltaSec, true); + s.active.store(true, std::memory_order_release); +} + +void ExtraInputs::slotStopped(int slot) +{ + if (slot < 0 || slot >= kMaxExtraInputDevices) return; + InputDeviceSlot& s = slots[(size_t) slot]; + // JUCE blocks for this slot's callback thread before firing this, so the + // slot's body is quiescent. Hide it from the output sum, then release ITS + // sources (no other callback touches them — they all filter by deviceKey). + s.active.store(false, std::memory_order_release); + // PERMANENT unbind (user removed this device) deactivates its sources too; + // a TRANSIENT close (stopAudio/reconfigure/unplug) only releases them so + // startAudio()'s re-open resumes them in place. Read the atomic flag (set + // by the control-thread unbind) rather than the juce::String + // desiredDeviceName, which this device-thread path must not race on. + pool.releaseDeviceSources(s.deviceKey, true, + s.permanentUnbind.load(std::memory_order_acquire)); + s.ring.resetIndices(); +} + +juce::String ExtraInputs::bind(int deviceKey, const juce::String& deviceName) +{ + if (deviceKey < 1 || deviceKey > kMaxExtraInputDevices) + return "deviceKey out of range"; + const int slot = deviceKey - 1; + InputDeviceSlot& s = slots[(size_t) slot]; + if (s.active.load(std::memory_order_acquire)) + return "device slot already bound"; + + // Reject binding the SAME physical device into a second slot. Two callbacks + // reading one interface is wasteful (and fails outright on exclusive drivers); + // multiple sources that want this device should share its one deviceKey and pick + // different channels instead. Checks both open + deferred (desired) slots. + for (int other = 0; other < kMaxExtraInputDevices; ++other) + if (other != slot && slots[(size_t) other].desiredDeviceName == deviceName) + return "device already bound to another input slot"; + + // Reject binding the device that is the PRIMARY input — it is already "Main", and + // opening it on this slot's manager too would double-open one interface on two + // managers (fatal on exclusive backends). Critically this also guards the REOPEN + // path: if the user makes a bound extra device the new main input, the preserved + // intent must NOT resurrect it as an extra (reopenDesired() then drops the + // now-invalid binding via its failure handling). + if (auto* primary = primaryManager.getCurrentAudioDevice()) + if (primary->getName() == deviceName) + return "device is the primary input — use Main, not an extra slot"; + + // An extra input device requires SPLIT mode: the output callback owns the mix + + // backing + gain and sums every device ring. In DUPLEX the primary device owns + // both directions and the output manager is closed, so we cannot just flip the + // flag — that would leave the output mix path absent (silent / unrouted). Reject + // here so the renderer reconfigures to a separate output device first. Checked + // BEFORE the deferred path below — startAudio()'s reopen also skips duplex, so a + // deferred bind in duplex would silently never come up while reporting success. + if (state.duplexMode.load(std::memory_order_relaxed)) + return "extra input requires split mode — select a separate output device first"; + + // Deregister any STALE callback BEFORE touching the manager. An earlier unplanned + // stop (USB unplug / backend restart) leaves s.callback registered; if we opened + // the manager (initialise / setAudioDeviceSetup) with it still attached, JUCE + // could dispatch it on the default/new device mid-setup — processing the wrong + // hardware, or even firing slotAboutToStart() during a stopped-engine + // validation open. Idempotent no-op when not registered. + s.manager.removeAudioCallback(&s.callback); + + // Open `deviceName` input-only on this slot's own manager. initialise first so + // the manager has a device type, then switch to the requested input device with + // all its channels (the source picks a channel within). + s.manager.initialiseWithDefaultDevices(2, 0); + + // The device name may belong to a device TYPE (ALSA / JACK / CoreAudio / …) + // different from the slot manager's default — a JACK device name won't resolve + // under ALSA and vice-versa ("No such device"). Find the type that actually + // lists this input device and switch the slot manager to it. Prefer the primary + // manager's current type (the devices the user already sees working). + juce::String chosenType; + if (auto* pt = primaryManager.getCurrentDeviceTypeObject()) + { + pt->scanForDevices(); + if (pt->getDeviceNames(true).contains(deviceName)) + chosenType = pt->getTypeName(); + } + if (chosenType.isEmpty()) + for (auto* t : s.manager.getAvailableDeviceTypes()) + { + t->scanForDevices(); + if (t->getDeviceNames(true).contains(deviceName)) { chosenType = t->getTypeName(); break; } + } + // setCurrentAudioDeviceType can THROW from inside some JUCE backends (ASIO, and + // misconfigured JACK/CoreAudio) — setAudioDevices() guards it for the primary, so + // this path must too, or a bad backend terminates the process instead of + // returning an error to the renderer. Close the slot manager on failure. + if (chosenType.isNotEmpty()) + { + try { s.manager.setCurrentAudioDeviceType(chosenType, true); } + catch (...) { s.manager.closeAudioDevice(); return "extra-input setCurrentAudioDeviceType threw"; } + } + + juce::AudioDeviceManager::AudioDeviceSetup setup; + s.manager.getAudioDeviceSetup(setup); + setup.inputDeviceName = deviceName; + setup.outputDeviceName = ""; + // Open ALL of the device's capture channels (not just the default first pair), + // so a source bound to channel 2+ of a multi-channel extra interface actually + // receives audio — mirrors the primary device's explicit full-range open. + int inputChannelCount = 0; + if (auto* t = s.manager.getCurrentDeviceTypeObject()) + { + std::unique_ptr probe(t->createDevice({}, deviceName)); + if (probe) inputChannelCount = probe->getInputChannelNames().size(); + } + if (inputChannelCount <= 0) inputChannelCount = 2; + setup.inputChannels.setRange(0, inputChannelCount, true); + setup.useDefaultInputChannels = false; + setup.useDefaultOutputChannels = false; + // Force the extra device to the ENGINE's sample rate. Each SourceChain's + // verifier/detectors read the engine-wide currentSampleRate (bound by + // reference at construction), so an extra input running at a different rate + // (e.g. a 44.1 kHz device in a 48 kHz engine) would be scored on the wrong + // clock — skewing pitch/timing for every source bound to it. Matching the + // engine rate here (the OS/driver resamples if needed) keeps them coherent; a + // device that cannot do this rate fails the setup below and is rejected. + const double engineSr = state.currentSampleRate.load(std::memory_order_relaxed); + if (engineSr > 0.0) + setup.sampleRate = engineSr; + // initialiseWithDefaultDevices above may have opened a default capture device on + // this slot manager; every failure path below must close it, or a failed bind + // leaves the interface captured until engine teardown (fatal on exclusive + // backends + breaks retries / other apps). + juce::String err; + try { err = s.manager.setAudioDeviceSetup(setup, true); } + catch (...) { s.manager.closeAudioDevice(); return "extra-input setAudioDeviceSetup threw"; } + if (err.isNotEmpty()) + { + s.manager.closeAudioDevice(); + return "extra input: " + err + (chosenType.isEmpty() ? " (no type lists this device)" : " (type " + chosenType + ")"); + } + auto* extraDev = s.manager.getCurrentAudioDevice(); + if (extraDev == nullptr) + { + s.manager.closeAudioDevice(); + return "extra input device did not open"; + } + + // Some backends accept the rate request but actually open at a different rate. + // Since the SourceChain verifier reads the engine-wide currentSampleRate, a + // mismatch would score this device on the wrong clock — reject rather than + // ship silently-wrong timing. (Tolerant of a sub-Hz rounding difference.) + if (engineSr > 0.0 && std::abs(extraDev->getCurrentSampleRate() - engineSr) > 1.0) + { + const juce::String got = juce::String(extraDev->getCurrentSampleRate()); + s.manager.closeAudioDevice(); + return "extra input opened at " + got + " Hz, not the engine rate " + juce::String(engineSr) + " Hz"; + } + + // The device opened + validated. Record the INTENT now (not before the fallible + // open above), so it drives re-open across a reconfigure without lingering after + // a failed attach. Clear the permanent-unbind flag: a future stop on this slot is + // transient (resume) until the user explicitly unbinds again. + s.desiredDeviceName = deviceName; + s.permanentUnbind.store(false, std::memory_order_release); + + // If the engine is not running, we opened only to VALIDATE eagerly (so an + // unplugged / wrong-rate device fails the bind NOW instead of silently dropping + // at the next startAudio). Close it again so a stopped engine never leaves an + // interface capturing in the background; reopenDesired() re-opens it (and + // re-attaches the callback) when the engine next starts. + if (! state.deviceRunning.load(std::memory_order_relaxed)) + { + s.manager.closeAudioDevice(); + return {}; + } + + // Attach the callback — fires slotAboutToStart (prepares + flips active). + // Any stale registration was already removed before the open above, so this + // registers exactly once. + s.manager.addAudioCallback(&s.callback); + return {}; +} + +bool ExtraInputs::unbind(int deviceKey) +{ + if (deviceKey < 1 || deviceKey > kMaxExtraInputDevices) + return false; + const int slot = deviceKey - 1; + // User-initiated unbind: mark it PERMANENT (the device thread's slotStopped + // reads the flag) and clear the intent so no restore path resurrects a device + // deliberately removed. + slots[(size_t) slot].permanentUnbind.store(true, std::memory_order_release); + slots[(size_t) slot].desiredDeviceName = {}; + // If the device is open, closing it fires slotStopped(), which — with the + // intent now cleared — deactivates this deviceKey's sources. If it was ALREADY + // closed (e.g. a prior stopAudio() kept the intent + left the sources active for + // a resume that will now never come), slotStopped() will NOT run, so we + // must deactivate them here — otherwise they linger as ghost sources stranding + // pool slots and showing in listSources(). + if (! closeSlot(slot)) + { + pool.withDeviceSources(deviceKey, [](SourceChain& s) { + s.releaseResources(); + s.setActive(false); + }); + } + return true; +} + +// Close the device open on a slot WITHOUT forgetting desiredDeviceName, so +// startAudio() re-opens it. Used by stopAudio()/reconfigure (transient close) — the +// public unbind() clears the intent first (permanent removal). +bool ExtraInputs::closeSlot(int slot) +{ + if (slot < 0 || slot >= kMaxExtraInputDevices) + return false; + InputDeviceSlot& s = slots[(size_t) slot]; + const bool wasActive = s.active.load(std::memory_order_acquire); + // Close + deregister UNCONDITIONALLY (not gated on `active`). An UNPLANNED stop + // (USB unplug / backend restart) fires slotStopped() — flipping active + // false — yet leaves the manager owning a (possibly auto-recovering) device and + // s.callback still registered. If we no-oped on !active, stopAudio()/reconfigure + // would never release it and the backend could resume callbacks after the engine + // is supposedly stopped. Both calls are idempotent when already closed/absent. + // For an ACTIVE slot, closeAudioDevice() blocks for the callback thread then fires + // audioDeviceStopped → slotStopped (releases this device's sources). + s.manager.closeAudioDevice(); + s.manager.removeAudioCallback(&s.callback); + return wasActive; +} + +// Re-open every slot that has a desiredDeviceName but is not currently active — the +// post-(re)start restore of extra inputs. No-op in duplex (extras need split) and +// when nothing is desired (the single-device path). Called from startAudio(). +void ExtraInputs::reopenDesired() +{ + if (state.duplexMode.load(std::memory_order_relaxed)) + { + // Duplex has no consumer for extra-device rings, so the desired extras cannot + // open right now. PRESERVE their intent (so a later switch back to split + // auto-restores them — setAudioDevices() promises bindings survive a device + // change) and keep their sources active to resume in place; but ZERO their + // meters so getSourceLevels() reports silence while the device is gone (the + // renderer's per-source silence gate then won't treat a temporarily-unavailable + // source as still hearing audio, and there is no false detection). The sources + // are not "ghosts": split-restore reopens the device and they resume. + for (int dk = 1; dk <= kMaxExtraInputDevices; ++dk) + { + if (slots[(size_t) (dk - 1)].desiredDeviceName.isEmpty()) + continue; + pool.withDeviceSources(dk, [](SourceChain& s) { s.resetInputMeters(); }); + } + return; + } + for (int dk = 1; dk <= kMaxExtraInputDevices; ++dk) + { + InputDeviceSlot& s = slots[(size_t) (dk - 1)]; + if (s.desiredDeviceName.isEmpty() || s.active.load(std::memory_order_acquire)) + continue; + const juce::String err = bind(dk, s.desiredDeviceName); // re-sets desired (idempotent) + if (err.isNotEmpty()) + { + // Reopen failed — the interface was unplugged, or no longer supports the + // engine rate. The transient close kept this slot's sources ACTIVE to + // resume; since they now never will, give up cleanly: drop the intent and + // deactivate them so they do not linger as ghost sources stranding pool + // slots. The renderer re-binds + re-adds if the device returns. + s.desiredDeviceName = {}; + pool.withDeviceSources(dk, [](SourceChain& src) { src.setActive(false); }); + } + } +} + +std::vector ExtraInputs::listBindable() +{ + std::vector out; + // The device already open as the primary input IS "Main" — don't offer it as + // an extra (would double-open the same hardware on two managers). + juce::String primaryName; + if (auto* dev = primaryManager.getCurrentAudioDevice()) + primaryName = dev->getName(); + // Enumerate across ALL device types, not just the primary's current one — + // bind() can open a device under any backend (JACK/ALSA/CoreAudio/…), so an + // extra interface exposed under a DIFFERENT backend than the primary must + // still be offered, or the multi-device path is unreachable from the picker. + // + // KNOWN LIMITATION: identity is the display name. JUCE opens input devices BY + // NAME, so two interfaces sharing a label (e.g. two identical USB cables) cannot + // be distinguished or independently opened without a backend-specific device-id + // rework — they collapse to one entry here. The SAME root cause makes a device + // exposed under MULTIPLE backends (e.g. ALSA + JACK/PipeWire on Linux) ambiguous: + // we dedup by name and bind() re-derives the backend (preferring the primary's), + // so we may bind the wrong backend if only another would open. A real fix needs + // (typeName, name) identity threaded through bind/reopen. Distinct-name, + // single-backend rigs (the common case, and the validated GP-5 + Spark setup) are + // unaffected. + juce::StringArray seen; + for (auto* t : primaryManager.getAvailableDeviceTypes()) + { + if (!t) continue; + t->scanForDevices(); + const juce::String typeName = t->getTypeName(); + for (const auto& name : t->getDeviceNames(true)) + { + if (name == primaryName || seen.contains(name)) continue; // dedup across backends + // Skip monitor / loopback pseudo-inputs — not instrument inputs, only + // confuse the picker. + const juce::String lower = name.toLowerCase(); + if (lower.contains("monitor") || lower.contains("loopback")) continue; + seen.add(name); + out.push_back({ typeName, name }); + } + } + return out; +} + +} // namespace slopsmith diff --git a/src/audio/engine/ExtraInputs.h b/src/audio/engine/ExtraInputs.h new file mode 100644 index 0000000..e3cfaf2 --- /dev/null +++ b/src/audio/engine/ExtraInputs.h @@ -0,0 +1,160 @@ +#pragma once + +// ExtraInputs — the additional-physical-input-device registry (TLC plan +// phase 5 / §2.3, was "Phase 2: additional input devices" inside AudioEngine). +// Each ADDITIONAL device (a 2nd/3rd USB interface) gets its own +// AudioDeviceManager + callback running on its OWN hardware clock, packing +// its sources' mixed monitor into its own SPSC ring. The engine's split +// output callback drains + sums every active ring (drop-oldest absorbs each +// device's drift independently — no cross-device resampling). deviceKey 0 = +// the primary input manager; deviceKeys 1..kMaxExtraInputDevices map to +// slots[deviceKey-1]. When any extra device is active the engine runs split. +// +// Moved verbatim from AudioEngine. The slots array stays PUBLIC so the split +// output callback keeps its ring-drain loop unchanged; sources are prepared/ +// released through the bound SourcePool; engine format/run state through +// EngineState; the primary manager reference serves the primary-device +// checks (duplicate binding, latency delta, bindable enumeration). + +#include "EngineState.h" +#include "PackedStereoRing.h" +#include "SourcePool.h" + +#include + +#include +#include +#include + +namespace slopsmith { + +class ExtraInputs +{ +public: + static constexpr int kMaxExtraInputDevices = SourcePool::kMaxExtraInputDevices; + // Ring capacity matches the engine's split-mode ring. + static constexpr int kRingFrames = 4096; + + // Forwards a JUCE device callback to the registry, tagged with the slot index. + struct SlotCallback : juce::AudioIODeviceCallback + { + ExtraInputs* owner = nullptr; + int slot = -1; // index into slots (deviceKey - 1) + void audioDeviceIOCallbackWithContext(const float* const* inputData, int numInputChannels, + float* const* outputData, int numOutputChannels, + int numSamples, + const juce::AudioIODeviceCallbackContext&) override + { + juce::ignoreUnused(outputData, numOutputChannels); + if (owner) owner->slotCallback(slot, inputData, numInputChannels, numSamples); + } + void audioDeviceAboutToStart(juce::AudioIODevice* d) override { if (owner) owner->slotAboutToStart(slot, d); } + void audioDeviceStopped() override { if (owner) owner->slotStopped(slot); } + }; + + struct InputDeviceSlot + { + juce::AudioDeviceManager manager; + SlotCallback callback; + PackedStereoRing ring; + std::atomic overflowCount{0}; + std::atomic active{false}; // a device is bound + running + std::atomic sampleRate{48000.0}; + std::atomic blockSize{256}; + // (extra input latency − primary input latency) in seconds — applied to + // this device's sources' verifiers so their capture aligns with the + // primary-corrected playhead. Computed when the device starts. + std::atomic latencyDeltaSec{0.0}; + // Audio-thread scratch — one set per slot since each slot's callback runs + // on its own thread (can't share the primary's sourceMonitorScratch). + juce::AudioBuffer fanScratch; // the 2ch mix target + juce::AudioBuffer monitorScratch; // per-source render in the N>1 path + int deviceKey = 0; // deviceKey this slot serves (slot+1) + // The device the user WANTS bound here — persistent INTENT, distinct from + // the transient `active` (currently open). Set by bind(), cleared only by a + // user unbind. stopAudio()/reconfigure close the device but keep this so + // startAudio() re-opens it; this is what survives a device change. + // Mutated + read on the control thread only. + juce::String desiredDeviceName; + // Whether the NEXT slotStopped() for this slot is a PERMANENT unbind + // (deactivate its sources) vs a transient close (keep them to resume). An + // atomic the control thread sets and the device thread reads, so the + // permanent-vs-transient decision never races on the juce::String above. + std::atomic permanentUnbind { false }; + }; + + ExtraInputs(SourcePool& sourcePool, EngineState& engineState, + juce::AudioDeviceManager& primaryInputManager) + : pool(sourcePool), state(engineState), primaryManager(primaryInputManager) + { + for (int i = 0; i < kMaxExtraInputDevices; ++i) + { + slots[(size_t) i].callback.owner = this; + slots[(size_t) i].callback.slot = i; + slots[(size_t) i].deviceKey = i + 1; + } + } + + // ── Control thread ──────────────────────────────────────────────────── + juce::String bind(int deviceKey, const juce::String& deviceName); + bool unbind(int deviceKey); + // Close a slot's device but KEEP desiredDeviceName (transient close for + // stop/reconfigure); reopenDesired() restores them after a (re)start. + bool closeSlot(int slot); + void reopenDesired(); + // Shutdown path: stop every slot device FIRST so no slot callback can fire + // into a half-destroyed engine. closeAudioDevice blocks for the callback. + void closeAllForShutdown() + { + for (auto& s : slots) + { + s.manager.closeAudioDevice(); + s.manager.removeAudioCallback(&s.callback); + } + } + + int activeCount() const + { + int n = 0; + for (const auto& s : slots) + if (s.active.load(std::memory_order_acquire)) ++n; + return n; + } + + struct Bindable { juce::String typeName; juce::String name; }; + std::vector listBindable(); + + // Resolution for SourcePool::addResolved — the per-slot readiness/format/ + // latency the pool needs, plus whether the key is usable at all. + struct Resolved { bool usable = false; bool ready = false; double sr = 0.0; int bs = 0; double latencyDelta = 0.0; }; + Resolved resolveForSource(int deviceKey) const + { + Resolved r; + if (deviceKey < 1 || deviceKey > kMaxExtraInputDevices) return r; + const InputDeviceSlot& es = slots[(size_t) (deviceKey - 1)]; + // Bound — either currently open (active) or DEFERRED (validated + desired + // while the engine is stopped, to be reopened by startAudio()). + r.usable = es.active.load(std::memory_order_acquire) || es.desiredDeviceName.isNotEmpty(); + r.ready = es.active.load(std::memory_order_acquire); + r.sr = es.sampleRate.load(std::memory_order_relaxed); + r.bs = es.blockSize.load(std::memory_order_relaxed); + r.latencyDelta = es.latencyDeltaSec.load(std::memory_order_relaxed); + return r; + } + + // PUBLIC: the split output callback drains every active slot's ring in + // place (same loop as before the move). + std::array slots; + +private: + // Per-slot device-callback hooks (audio + device-management threads). + void slotCallback(int slot, const float* const* inputData, int numInputChannels, int numSamples); + void slotAboutToStart(int slot, juce::AudioIODevice* device); + void slotStopped(int slot); + + SourcePool& pool; + EngineState& state; + juce::AudioDeviceManager& primaryManager; +}; + +} // namespace slopsmith diff --git a/src/audio/engine/PackedStereoRing.h b/src/audio/engine/PackedStereoRing.h new file mode 100644 index 0000000..fe560b9 --- /dev/null +++ b/src/audio/engine/PackedStereoRing.h @@ -0,0 +1,143 @@ +#pragma once + +// PackedStereoRing — the ONE packed-LR SPSC ring (audio-engine TLC, plan +// phase 1). Replaces the three hand-maintained copies of the same design: +// the split-mode outputPendingRing, each InputDeviceSlot's ring, the stream +// sink's ring, and the renderer-audio bus ring. +// +// Design (moved verbatim from AudioEngine.h — see git history for the +// original per-site comments): +// +// Each slot packs one stereo frame (L+R floats) into a single 64-bit atomic +// so the consumer reads both channels in one indivisible load — without +// packing, the producer's two separate atomic stores could interleave with +// the consumer's two loads during a drop-oldest wrap, surfacing as +// L_new+R_old (or vice versa) sample tears. +// +// Strict SPSC: the producer (one device/IPC thread) only ever writes +// writeIndex; the consumer is the sole writer of readIndex. Drop-oldest = +// letting writeIndex lap the buffer; the consumer advances readIndex when it +// observes (w - r) > capacity. Ordering is established by the release store +// on writeIndex (producer) / readIndex (consumer); slot stores/loads are +// relaxed. +// +// The indices and slots are deliberately PUBLIC: the renderer bus keeps its +// bespoke prefill-gate / fill-clamp consumer policy, and phase-2 units bind +// the members directly. The helpers below own only the ritual moves every +// site repeats: producer publish, reset, the w +#include +#include +#include + +namespace slopsmith { + +// Pack/unpack helpers — std::bit_cast (C++20) is constexpr + alias-safe. +inline uint64_t packLR(float l, float r) noexcept +{ + const uint32_t li = std::bit_cast(l); + const uint32_t ri = std::bit_cast(r); + return (static_cast(ri) << 32) | static_cast(li); +} +inline void unpackLR(uint64_t v, float& l, float& r) noexcept +{ + l = std::bit_cast(static_cast(v & 0xFFFFFFFFu)); + r = std::bit_cast(static_cast(v >> 32)); +} + +template +struct PackedStereoRing +{ + static_assert((NFrames & (NFrames - 1)) == 0, + "ring capacity must be a power of two for mask wraparound"); + // RT-thread reads + writes touch these slots, so a lock-based fallback + // would risk priority inversion + audible dropouts. On the platforms we + // ship (x86_64 + arm64 across Linux/macOS/Windows) atomic is + // always lock-free; this assert turns a regression into a build error + // instead of a silent latency degradation if a future platform port + // breaks the assumption. + static_assert(std::atomic::is_always_lock_free, + "PackedStereoRing requires lock-free atomic for RT safety"); + static_assert(sizeof(float) == 4, "pack/unpack assumes 32-bit float"); + + static constexpr uint64_t kMask = (uint64_t) NFrames - 1; + static constexpr uint64_t kCap = (uint64_t) NFrames; + static constexpr int kFrames = NFrames; + + std::array, NFrames> slots{}; + std::atomic writeIndex{0}; + std::atomic readIndex{0}; + + // ── Producer side ───────────────────────────────────────────────────── + // Publish a stereo block (drop-oldest by lapping; consumer catches up). + void push(const float* L, const float* R, int numSamples) noexcept + { + const uint64_t w = writeIndex.load(std::memory_order_relaxed); + for (int i = 0; i < numSamples; ++i) + slots[(size_t) ((w + (uint64_t) i) & kMask)].store(packLR(L[i], R[i]), + std::memory_order_relaxed); + writeIndex.store(w + (uint64_t) numSamples, std::memory_order_release); + } + // Frame-at-a-time producer path (renderer-bus resampler): stage frames at + // monotonically increasing indices from beginWrite(), then publish once. + uint64_t beginWrite() const noexcept { return writeIndex.load(std::memory_order_relaxed); } + void stageFrame(uint64_t index, float l, float r) noexcept + { + slots[(size_t) (index & kMask)].store(packLR(l, r), std::memory_order_relaxed); + } + void publish(uint64_t newWriteIndex) noexcept + { + writeIndex.store(newWriteIndex, std::memory_order_release); + } + + // ── Consumer side ───────────────────────────────────────────────────── + void readFrame(uint64_t index, float& l, float& r) const noexcept + { + unpackLR(slots[(size_t) (index & kMask)].load(std::memory_order_relaxed), l, r); + } + void commitRead(uint64_t newReadIndex) noexcept + { + readIndex.store(newReadIndex, std::memory_order_release); + } + // The two ritual guards at the top of every drain, exactly as each site + // wrote them by hand. `r` is the consumer's working copy of readIndex. + // + // If a stop/reset raced between the consumer's two index loads and reset + // both indices to 0, the consumer can observe w < r. Treat that as an + // empty ring and resync — without this, the unsigned (w - r) wraps into a + // huge positive value and falls into the catch-up branch reading stale + // slots. + void resyncIfIndicesReset(uint64_t& r, uint64_t w) noexcept + { + if (w < r) { r = w; readIndex.store(r, std::memory_order_relaxed); } + } + // Catch up if the producer has lapped (drop-oldest is achieved via this + // single-writer consumer-side advance, not a producer-side write to r). + // Returns true when a lap was consumed so the caller can bump its counter. + bool catchUpIfLapped(uint64_t& r, uint64_t w) noexcept + { + if ((w - r) > kCap) + { + r = w - kCap; + readIndex.store(r, std::memory_order_relaxed); + return true; + } + return false; + } + + // ── Lifecycle (control/device-management threads only) ──────────────── + void resetIndices() noexcept + { + writeIndex.store(0, std::memory_order_relaxed); + readIndex.store(0, std::memory_order_relaxed); + } + void reset() noexcept + { + resetIndices(); + for (auto& v : slots) v.store(0, std::memory_order_relaxed); + } +}; + +} // namespace slopsmith diff --git a/src/audio/engine/RateMatch.h b/src/audio/engine/RateMatch.h new file mode 100644 index 0000000..7e11406 --- /dev/null +++ b/src/audio/engine/RateMatch.h @@ -0,0 +1,33 @@ +#pragma once + +// Pure sample-rate matching math shared by probe, preflight, and post-open +// verify (TLC phase 4, deep-read §7 — previously three hand-synced copies in +// AudioEngine.cpp). JUCE-free so tests/engine_units can pin the boundary +// cases the old sites narrated in comments. + +#include + +namespace slopsmith { + +// <= 0.5 (not <): a backend reporting 47999.5 against a 48000 nominal has +// |diff| = 0.5 exactly and must pass at every stage the probe accepted it. +inline bool ratesMatch(double a, double b) noexcept +{ + return std::abs(a - b) <= 0.5; +} + +// Given a matching in/out rate pair, the clean nominal the probe surfaces to +// the UI (backends sometimes report fractional near-48000 rates; the raw +// value would fail the apply-side setAudioDeviceSetup, which expects an exact +// supported nominal). Returns false when the rounded midpoint falls outside +// tolerance of either side — a matched pair like 48000.4/48000.6 passes the +// |r-r2| check but round(48000.5)=48000/48001 can sit 0.6 from one side; the +// probe stays fail-closed on those. +inline bool nominalRateCandidate(double r, double r2, double& candidate) noexcept +{ + if (!ratesMatch(r, r2)) return false; + candidate = std::round((r + r2) * 0.5); + return ratesMatch(r, candidate) && ratesMatch(r2, candidate); +} + +} // namespace slopsmith diff --git a/src/audio/engine/RendererBus.h b/src/audio/engine/RendererBus.h new file mode 100644 index 0000000..2141de2 --- /dev/null +++ b/src/audio/engine/RendererBus.h @@ -0,0 +1,243 @@ +#pragma once + +// RendererBus — the WebAudio→engine audio bus (TLC plan phase 2 / §2.6). +// Moved verbatim from AudioEngine (see git history for the original inline +// comments' evolution): the renderer pushes its WebAudio master mix here over +// IPC so song/stem audio stays audible when the output device is +// exclusive-style (ASIO / WASAPI exclusive) and the OS mixer path is silent. +// +// SPSC: producer is the main-process IPC thread (push — includes the linear +// resampler), consumer is whichever output callback is live (pull). Sized +// generously (~1.5 s @ 48 kHz) because the producer has scheduling jitter; +// the consumer trims steady-state fill via the fill clamp. +// +// JUCE-free on purpose: pull() takes raw channel pointers, so +// tests/engine_units drives the resampler/prime/clamp logic without a device. + +#include "PackedStereoRing.h" +#include "../GainSanitize.h" + +#include +#include +#include + +namespace slopsmith { + +class RendererBus +{ +public: + static constexpr int kFrames = 65536; + // Prefill gate: consume nothing until the producer has built this cushion + // (~10.7 ms @ 48 kHz); re-armed after every underflow so stall recovery is + // one clean gap. Fill clamp: fill beyond kMaxFillFrames (~85 ms) means a + // renderer stall dumped a backlog — trim to the prime target, don't play + // the tail. + static constexpr int kPrimeFrames = 512; + static constexpr int kMaxFillFrames = 4096; + + void setEnabled(bool enabled, float gain) + { + busGain.store(sanitizeStreamGain(gain), std::memory_order_relaxed); + const bool was = busEnabled.exchange(enabled, std::memory_order_acq_rel); + if (was && !enabled) + { + // Drop buffered audio on disable so a later re-enable starts fresh + // instead of playing a stale tail. The CONSUMER honors this flag at + // its next pull (deep-read §4 fix): the old control-thread write to + // readIndex violated the ring's own SPSC discipline — a concurrent + // pull mid-drain could overwrite it with r + pull, replaying a + // stale tail after re-enable, exactly what the drop was meant to + // prevent. Only the consumer ever moves readIndex now. + // + // Snapshot WHERE to flush to rather than letting the consumer flush + // to whatever writeIndex it happens to see. If no output callback + // runs between this disable and a re-enable (a stopped device, a + // device swap), the next pull would otherwise discard the FRESH + // frames pushed since the re-enable along with the stale tail — + // silence until the bus re-primes. Pushes are gated on busEnabled, + // so nothing lands in (flushTo, re-enable) and this index is exactly + // the end of the stale tail. + flushTo.store(ring.writeIndex.load(std::memory_order_acquire), + std::memory_order_relaxed); + flushRequested.store(true, std::memory_order_release); + primed.store(false, std::memory_order_relaxed); + } + } + bool isEnabled() const { return busEnabled.load(std::memory_order_relaxed); } + + // Interleaved stereo frames at `sourceRate`, linear-resampled to + // `deviceRate` on the producer thread (fractional position + previous + // frame carried across calls). Returns false when the bus is disabled or + // the rates are unusable. Drop-oldest on overflow, counted consumer-side. + bool push(const float* interleavedLR, int frames, double sourceRate, double deviceRate) + { + if (!busEnabled.load(std::memory_order_acquire)) return false; + if (interleavedLR == nullptr || frames <= 0) return false; + // Both rates cross the JS/IPC boundary: reject NaN/Inf (a NaN + // deviceRate passes a plain `<= 0.0` check) and a step that + // underflowed to zero (subnormal source rate), either of which would + // make the resample loop index garbage or never advance. + if (!std::isfinite(deviceRate) || deviceRate <= 0.0) return false; + if (!std::isfinite(sourceRate) || sourceRate <= 0.0) sourceRate = deviceRate; + + uint64_t w = ring.beginWrite(); + + // Linear resample source→device rate on this (IPC) thread. `pos` is + // the fractional read position into the incoming chunk; index -1 + // refers to the carried last frame of the previous chunk so + // interpolation is continuous across pushes. Equal rates degenerate + // to step == 1.0 (still exact: pos stays integral, frac == 0). + const double step = sourceRate / deviceRate; + if (!std::isfinite(step) || step <= 0.0) return false; + double pos = srcPos; + uint64_t written = 0; + while (true) + { + const double ip = std::floor(pos); + const int i0 = (int) ip; + if (i0 + 1 >= frames) break; // next chunk continues from here + const float frac = (float) (pos - ip); + const float l0 = (i0 < 0) ? prevL : interleavedLR[(size_t) i0 * 2]; + const float r0 = (i0 < 0) ? prevR : interleavedLR[(size_t) i0 * 2 + 1]; + const float l1 = interleavedLR[((size_t) i0 + 1) * 2]; + const float r1 = interleavedLR[((size_t) i0 + 1) * 2 + 1]; + ring.stageFrame(w, l0 + (l1 - l0) * frac, r0 + (r1 - r0) * frac); + ++w; + ++written; + pos += step; + } + srcPos = pos - (double) frames; // relative to the next chunk + prevL = interleavedLR[((size_t) frames - 1) * 2]; + prevR = interleavedLR[((size_t) frames - 1) * 2 + 1]; + + // Publish. Overflow (producer lapping the consumer) is handled + // consumer-side with drop-oldest — only the consumer moves readIndex. + ring.publish(w); + pushedFrames.fetch_add(written, std::memory_order_relaxed); + return true; + } + + // Drain one block into dl/dr (bus gain applied). Returns numSamples on + // success, 0 when gated (disabled, priming, underflow). Single consumer — + // call exactly once per output block. + int pull(float* dl, float* dr, int numSamples) + { + // Consume a pending flush FIRST — even while disabled — so the tail + // buffered before a disable is dropped by the ring's one legitimate + // readIndex writer (this consumer), never by the control thread. Flush + // to the index captured at DISABLE time, not to the live writeIndex: + // anything pushed after a re-enable is fresh audio, not stale tail. + if (flushRequested.exchange(false, std::memory_order_acq_rel)) + { + const uint64_t target = flushTo.load(std::memory_order_relaxed); + // Guard the already-drained case: the consumer may have run past + // the snapshot before it saw the flag, and readIndex must never + // move backwards. + if (target > ring.readIndex.load(std::memory_order_relaxed)) + ring.commitRead(target); + } + if (!busEnabled.load(std::memory_order_acquire)) return 0; + const uint64_t w = ring.writeIndex.load(std::memory_order_acquire); + uint64_t r = ring.readIndex.load(std::memory_order_relaxed); + if (w - r > (uint64_t) kFrames) + { + // Producer lapped us — drop-oldest to the newest full ring. + r = w - (uint64_t) kFrames; + overflowCount.fetch_add(1, std::memory_order_relaxed); + } + uint64_t avail = w - r; + + // Fill clamp (spike finding): steady-state drift is near zero, so a + // fill beyond kMaxFillFrames only ever means a renderer stall dumped a + // backlog. Trim to the prime target instead of playing the whole tail + // at ~85+ ms behind — a latency reset, not an audible gap. + if (avail > (uint64_t) kMaxFillFrames) + { + r = w - (uint64_t) kPrimeFrames; + avail = (uint64_t) kPrimeFrames; + overflowCount.fetch_add(1, std::memory_order_relaxed); + } + + // Prefill gate (spike finding): the warmup underflow burst is the mix + // starting before the ring has a cushion. Consume nothing until the + // producer has built ~10 ms; re-arm the same gate after a real + // underflow so stall recovery is one clean gap, not a ragged refill. + if (!primed) + { + if (avail < (uint64_t) kPrimeFrames) + { + ring.commitRead(r); + return 0; + } + primed = true; + } + if (avail < (uint64_t) numSamples) + { + // Underflow: emit silence for the whole block (partial blocks + // blip), drop what's buffered, and go back to priming. + primed = false; + underflowCount.fetch_add(1, std::memory_order_relaxed); + ring.commitRead(w); + return 0; + } + + const float g = busGain.load(std::memory_order_relaxed); + for (int i = 0; i < numSamples; ++i) + { + float l, rr; + ring.readFrame(r + (uint64_t) i, l, rr); + dl[i] = l * g; + dr[i] = rr * g; + } + ring.commitRead(r + (uint64_t) numSamples); + consumedFrames.fetch_add((uint64_t) numSamples, std::memory_order_relaxed); + return numSamples; + } + + struct Metrics + { + uint64_t pushedFrames = 0, consumedFrames = 0, underflowCount = 0, overflowCount = 0; + int fillFrames = 0, capacityFrames = 0; + bool enabled = false; + }; + Metrics metrics() const + { + Metrics m; + m.pushedFrames = pushedFrames.load(std::memory_order_relaxed); + m.consumedFrames = consumedFrames.load(std::memory_order_relaxed); + m.underflowCount = underflowCount.load(std::memory_order_relaxed); + m.overflowCount = overflowCount.load(std::memory_order_relaxed); + const uint64_t w = ring.writeIndex.load(std::memory_order_acquire); + const uint64_t r = ring.readIndex.load(std::memory_order_acquire); + const uint64_t fill = w - r; + m.fillFrames = (int) (fill < (uint64_t) kFrames ? fill : (uint64_t) kFrames); + m.capacityFrames = kFrames; + m.enabled = busEnabled.load(std::memory_order_relaxed); + return m; + } + +private: + PackedStereoRing ring; + std::atomic pushedFrames{0}; + std::atomic consumedFrames{0}; + std::atomic underflowCount{0}; + std::atomic overflowCount{0}; + std::atomic busEnabled{false}; + std::atomic busGain{1.0f}; + // Consumer-side prefill-gate state. Only the live output callback touches + // it, but duplex/split hand-offs cross threads — atomic keeps that safe. + std::atomic primed{false}; + // Set by setEnabled(false) on the control thread, consumed (exchange) by + // pull() — the drop-on-disable request, honored by the single consumer. + // flushTo is the writeIndex as of that disable: the exact end of the stale + // tail, so a re-enable's fresh frames survive the pending flush. + std::atomic flushRequested{false}; + std::atomic flushTo{0}; + // Producer-thread-only linear-resampler state (fractional read position + // into the incoming chunk + the previous chunk's last frame for + // interpolation continuity across pushes). + double srcPos = 0.0; + float prevL = 0.0f, prevR = 0.0f; +}; + +} // namespace slopsmith diff --git a/src/audio/engine/SourcePool.cpp b/src/audio/engine/SourcePool.cpp new file mode 100644 index 0000000..0346973 --- /dev/null +++ b/src/audio/engine/SourcePool.cpp @@ -0,0 +1,225 @@ +// SourcePool implementation — moved verbatim from AudioEngine.cpp (TLC plan +// phase 5 / §2.2). The extra-device resolution that addSource performed +// in-line (reading the InputDeviceSlot registry) stays on the AudioEngine +// facade, which passes the resolved values into addResolved(). + +#include "SourcePool.h" + +#include +#include + +namespace slopsmith { + +int SourcePool::addResolved(int inputChannel, int deviceKey, + bool deviceReady, double sr, int bs, double latencyDeltaSec) +{ + std::lock_guard lock(mutex); + reclaimPendingLocked(); // free up any slot whose release was deferred + + // Find a free pooled slot (slot 0 is the permanent default). Skip a slot whose + // release is still pending — its chain/worker hasn't been torn down yet, so + // re-preparing it would double-start the verifier thread. + int slot = -1; + for (int i = 1; i < kMaxSources; ++i) + if (! sources[(size_t) i]->isActive() && ! pendingRelease[(size_t) i]) { slot = i; break; } + if (slot < 0) + return -1; // pool full + + SourceChain& src = *sources[(size_t) slot]; + src.setInputChannel(inputChannel); + src.setDeviceKey(deviceKey); + // Inherit the bound device's capture-latency correction (0 for the primary). + src.setVerifierAutoOffset(latencyDeltaSec); + // Clear any MANUAL offset left on this pooled chain by a previous player — a + // freshly added source starts with no user fine-tune (the renderer re-applies + // its own via setSourceVerifierOffset). releaseResources() doesn't touch it. + src.setVerifierUserOffset(0.0); + // Likewise clear stale meters so this source doesn't briefly report the previous + // player's level/peak through getSourceLevels() until fresh audio arrives. + src.resetInputMeters(); + + // Prepare fully BEFORE making it visible to the audio thread, so the first + // callback that observes active==true sees a ready chain + rings. When audio + // isn't running yet, the relevant about-to-start hook prepares it later. An + // EXTRA-device source must be prepared with ITS device's sample rate / block + // size, not the primary's — the caller resolved those. + if (deviceReady && sr > 0.0 && bs > 0) + src.prepare(sr, bs); + src.setActive(true); // release-store: now picked up by the audio callback + return slot; +} + +bool SourcePool::remove(int id) +{ + if (id <= 0 || id >= kMaxSources) + return false; // 0 is permanent; out-of-range rejected + + std::lock_guard lock(mutex); + reclaimPendingLocked(); // opportunistically reclaim earlier deferrals + + SourceChain& src = *sources[(size_t) id]; + if (! src.isActive()) + return false; + + // Hide it from the audio callback first; subsequent blocks snapshot active + // once and skip it. It is logically removed from here on, regardless of when + // its resources are reclaimed. + src.setActive(false); + + // Reclaim now if we can confirm THIS SOURCE's device callback is not executing. + // Only the callback for the source's own deviceKey can touch it; that counter is + // decremented at the callback's real exit (release store), so observing 0 + // (acquire) proves it is not inside processBlock right now; any callback that + // starts afterwards snapshots active and skips this (now-inactive) source — so + // releasing cannot race the audio thread. Keying on the source's deviceKey (not a + // global all-callbacks-idle check) is what lets removals reclaim during steady + // multi-device playback, when callbacks on independent clocks are never all idle + // at once. Bounded so a wedged device can't hang this thread. + const size_t dk = (size_t) src.getDeviceKey(); + for (int spins = 0; spins < 200; ++spins) // ~200 ms cap + { + if (callbacksInFlight[dk].load(std::memory_order_acquire) == 0) + { + src.releaseResources(); // stops its threads + releases its chain + return true; + } + std::this_thread::sleep_for(std::chrono::milliseconds(1)); + } + + // A callback stayed wedged in-flight past the wait (a >200 ms block would be a + // catastrophic stall). Do NOT force a release that could race it — DEFER it. + // The source is already inactive so no future callback touches it; reclaim it + // later (next add/remove, or a device-stopped hook) when the body is quiet. + pendingRelease[(size_t) id] = true; + return true; +} + +void SourcePool::reclaimPendingLocked() +{ + // Caller holds `mutex`. A deferred source is inactive (future callbacks skip + // it); releasing it is safe once the callback for ITS deviceKey is not in a body. + // We key per-deviceKey (decremented by each callback at its real exit) so a + // pending release frees as soon as its OWN device is quiescent — not only when + // every device callback happens to be idle simultaneously (which, on independent + // clocks during steady multi-device playback, may never occur and would strand + // the slot until full stop). On the device-stopped path the relevant callback + // already left its count at 0. + for (int i = 1; i < kMaxSources; ++i) + { + if (! pendingRelease[(size_t) i]) continue; + const size_t dk = (size_t) sources[(size_t) i]->getDeviceKey(); + if (callbacksInFlight[dk].load(std::memory_order_acquire) != 0) + continue; // this source's device is mid-body — try again later + sources[(size_t) i]->releaseResources(); + pendingRelease[(size_t) i] = false; + } +} + +std::vector SourcePool::list() const +{ + std::vector out; + for (int i = 0; i < kMaxSources; ++i) + { + const SourceChain& src = *sources[(size_t) i]; + if (! src.isActive()) continue; + Info info; + info.id = src.getId(); + info.inputChannel = src.getInputChannel(); + info.deviceKey = src.getDeviceKey(); + info.active = true; + out.push_back(info); + } + return out; +} + +void SourcePool::prepareDeviceSources(int deviceKey, double sr, int bs, + double verifierAutoOffsetSec, bool applyOffset) +{ + std::lock_guard lock(mutex); + for (auto& src : sources) + if (src->isActive() && src->getDeviceKey() == deviceKey) + { + src->prepare(sr, bs); + if (applyOffset) src->setVerifierAutoOffset(verifierAutoOffsetSec); + } +} + +void SourcePool::releaseDeviceSources(int deviceKey, bool resetMeters, bool deactivate) +{ + std::lock_guard lock(mutex); + for (auto& src : sources) + if (src->isActive() && src->getDeviceKey() == deviceKey) + { + src->releaseResources(); + // Zero the meters so getSourceLevels() reports silence while the + // device is gone — otherwise the renderer's per-source silence gate + // treats a stopped/unplugged input as still hearing audio (the last + // non-zero level latches). releaseResources() doesn't touch them. + if (resetMeters) src->resetInputMeters(); + // PERMANENT unbind: the slot will never re-open, so DEACTIVATE its + // sources too — leaving them "active" would strand pooled slots no + // callback can ever service (a ghost detector in listSources). + if (deactivate) src->setActive(false); + } + // Retry any remove() cleanup deferred waiting for callbacks to drain. With + // this device's callback now stopped, callbacksInFlight may finally be 0. + reclaimPendingLocked(); +} + +int SourcePool::mixForDevice(int deviceKey, const float* const* inputData, int numInputChannels, + juce::AudioBuffer& mixBuf, juce::AudioBuffer& monitorScratch, + int effectiveOutputChannels, int numSamples) noexcept +{ + // Snapshot each source's active flag ONCE so the count and the process/mix + // passes are consistent within this block — a concurrent add/remove flipping + // a flag between two reads must not change which branch runs. remove() waits + // for all callback bodies to drain before releasing, so a source snapshotted + // active here is safe even if deactivated an instant later. + bool act[kMaxSources]; + int firstActive = -1, activeCount = 0; + for (int i = 0; i < kMaxSources; ++i) + { + act[i] = sources[(size_t) i]->isActive() + && sources[(size_t) i]->getDeviceKey() == deviceKey; + if (act[i]) { ++activeCount; if (firstActive < 0) firstActive = i; } + } + + if (activeCount == 0) + { + // No source on this device → silence. An extra device with no bound source + // contributes nothing to the output sum. The primary always has chain 0 + // (deviceKey 0, active from construction), so it never reaches this branch. + for (int ch = 0; ch < effectiveOutputChannels; ++ch) + mixBuf.clear(ch, 0, numSamples); + return 0; + } + + if (activeCount == 1) + { + // Fast path — exactly one source: process in place on mixBuf, byte- + // identical to the single-pipeline engine (channel select / mono mix + + // input gain, metering, ML + ring feed, gate, YIN, tone chain, monitor). + sources[(size_t) firstActive] + ->processBlock(inputData, numInputChannels, mixBuf, effectiveOutputChannels, numSamples); + return 1; + } + + // Multi-source: each renders its own 2-channel monitor into monitorScratch + // (each builds its mono from its bound channel + feeds its own rings / + // detectors / verifier), summed to STEREO (0/1). A >2-channel output keeps + // channels 2+ silent in multi-source mode (the fast path still broadcasts). + for (int ch = 0; ch < effectiveOutputChannels; ++ch) + mixBuf.clear(ch, 0, numSamples); + const int mixCh = juce::jmin(effectiveOutputChannels, 2); + const int n = juce::jmin(numSamples, monitorScratch.getNumSamples()); + for (int i = 0; i < kMaxSources; ++i) + { + if (! act[i]) continue; + sources[(size_t) i]->processBlock(inputData, numInputChannels, monitorScratch, 2, n); + for (int ch = 0; ch < mixCh; ++ch) + mixBuf.addFrom(ch, 0, monitorScratch, ch, 0, n); + } + return activeCount; +} + +} // namespace slopsmith diff --git a/src/audio/engine/SourcePool.h b/src/audio/engine/SourcePool.h new file mode 100644 index 0000000..59ad5da --- /dev/null +++ b/src/audio/engine/SourcePool.h @@ -0,0 +1,148 @@ +#pragma once + +// SourcePool — the fixed pool of per-input SourceChains plus the add/remove/ +// reclaim lifecycle and the per-deviceKey callback-quiescence handshake (TLC +// plan phase 5 / §2.2). Moved verbatim from AudioEngine. +// +// Pool invariants (unchanged): +// - ALL chains are constructed up front; add/removeSource never reassigns a +// pointer the audio thread reads — they only flip an atomic `active` flag. +// - chain 0 is the permanent legacy default input, active from construction. +// - removal uses the per-deviceKey callbacksInFlight counter handshake; +// wedged callbacks defer the release (pendingRelease[]) instead of +// blocking, reclaimed when that key's body is quiescent. +// +// Boundary: device callbacks hold a CallbackGuard for their body and call +// mixForDevice(); control threads use add/remove/list/get and the +// per-deviceKey prepare/release helpers the device hooks need. + +#include "EngineState.h" +#include "../SourceChain.h" + +#include +#include +#include +#include +#include + +namespace slopsmith { + +class SourcePool +{ +public: + static constexpr int kMaxSources = 8; + // Max ADDITIONAL input devices (beyond the primary); sizes the per-key + // in-flight counters (key 0 = primary). + static constexpr int kMaxExtraInputDevices = 3; + + explicit SourcePool(EngineState& engineState) + { + // Construct the full pool up front so the audio thread never observes + // a pointer swap. Each chain reads deviceRunning / currentSampleRate + // by reference. Chain 0 active from the start; the rest inactive (no + // threads — NoteVerifier's worker only starts in prepare()). + for (int i = 0; i < kMaxSources; ++i) + sources[(size_t) i] = std::make_unique( + i, engineState.deviceRunning, engineState.currentSampleRate); + sources[0]->setActive(true); + } + + // ── RT side ─────────────────────────────────────────────────────────── + // Publishes that a device callback body is executing for `deviceKey`, so + // remove()/reclaim know when that key is quiescent. previousInFlight is + // exposed for the duplicate-registration diagnostic. + struct CallbackGuard + { + CallbackGuard(SourcePool& p, int deviceKey) + : pool(p), key((size_t) deviceKey), + previousInFlight(p.callbacksInFlight[key].fetch_add(1, std::memory_order_acq_rel)) {} + ~CallbackGuard() { pool.callbacksInFlight[key].fetch_sub(1, std::memory_order_acq_rel); } + SourcePool& pool; + const size_t key; + const int previousInFlight; + }; + + // Mix every active source bound to `deviceKey` into `mixBuf` (using the + // caller-owned `monitorScratch` for the N>1 render so concurrent device + // threads never share scratch). Returns the active source count. + int mixForDevice(int deviceKey, const float* const* inputData, int numInputChannels, + juce::AudioBuffer& mixBuf, juce::AudioBuffer& monitorScratch, + int effectiveOutputChannels, int numSamples) noexcept; + + // ── Control threads ─────────────────────────────────────────────────── + // Activate a pooled chain (device info pre-resolved by the engine facade, + // which owns the extra-device registry). Returns the slot id or -1. + int addResolved(int inputChannel, int deviceKey, + bool deviceReady, double sr, int bs, double latencyDeltaSec); + // Deactivate + release (id != 0). Defers the release when the source's + // device callback stays in-flight past the bounded wait. + bool remove(int id); + + SourceChain* get(int id) + { + if (id < 0 || id >= kMaxSources) return nullptr; + SourceChain& src = *sources[(size_t) id]; + return (id == 0 || src.isActive()) ? &src : nullptr; + } + SourceChain& chain0() { return *sources[0]; } + const SourceChain& chain0() const { return *sources[0]; } + + struct Info { int id = -1; int inputChannel = -1; int deviceKey = 0; bool active = false; }; + std::vector list() const; + + // Fan an operation to every pooled chain (active or not) — plain atomic + // stores on fixed pointers, race-free off the control thread. + template void forEach(Fn&& fn) + { + for (auto& s : sources) + if (s) fn(*s); + } + template void forEachActive(Fn&& fn) + { + for (auto& s : sources) + if (s && s->isActive()) fn(*s); + } + + // Run `fn` on every active source bound to `deviceKey`, under the pool + // lock — for the extra-device close/unbind paths' bespoke sequences. + template void withDeviceSources(int deviceKey, Fn&& fn) + { + std::lock_guard lock(mutex); + for (auto& s : sources) + if (s->isActive() && s->getDeviceKey() == deviceKey) fn(*s); + } + + // Device hooks: prepare / release every active source bound to a key, + // under the pool lock. Mirrors the per-device halves of the old + // about-to-start / stopped handlers; release also retries deferred + // reclamation (that key's callback is now quiescent). + void prepareDeviceSources(int deviceKey, double sr, int bs, double verifierAutoOffsetSec, + bool applyOffset); + void releaseDeviceSources(int deviceKey, bool resetMeters, bool deactivate); + + // Retry deferred releases whose device is quiescent. Public form takes the + // pool lock (used by the primary device-stopped path). + void reclaimPending() + { + std::lock_guard lock(mutex); + reclaimPendingLocked(); + } + +private: + void reclaimPendingLocked(); + + std::array, kMaxSources> sources; + // Serialises add/remove (control threads only — never the audio thread, + // which just reads each slot's atomic `active`). + std::mutex mutex; + // How many device-callback bodies are currently executing per deviceKey + // (0 = primary, 1.. = extras). Incremented/decremented by CallbackGuard at + // the body's real entry/exit; remove() observing 0 (acquire) proves the + // source's device is not inside processBlock. + std::array, kMaxExtraInputDevices + 1> callbacksInFlight{}; + // A remove() that timed out waiting for quiescence parks the release here; + // reclaimed under `mutex` at the next add/remove and on device-stop paths. + std::array pendingRelease{}; +}; + +} // namespace slopsmith diff --git a/src/audio/engine/StreamSink.cpp b/src/audio/engine/StreamSink.cpp new file mode 100644 index 0000000..a052b71 --- /dev/null +++ b/src/audio/engine/StreamSink.cpp @@ -0,0 +1,263 @@ +// StreamSink implementation — moved verbatim from AudioEngine.cpp (TLC plan +// phase 2 / §2.5); member names lose their streamSink./streamBus prefixes, +// logic is unchanged. See StreamSink.h for the design rationale. + +#include "StreamSink.h" + +#include +#include + +namespace slopsmith { + +void StreamSink::publish(const juce::AudioBuffer& guitarMix, + const juce::AudioBuffer* backingBuf, int backingFrames, float backingVol, + const juce::AudioBuffer* rendererBuf, int rendererFrames, + int numSamples) +{ + if (! active.load(std::memory_order_acquire)) return; + // A block larger than the entire ring can't be published atomically (it would + // wrap and overwrite unread slots before writeIndex is bumped). Skip it and + // count an overflow. Checked FIRST (before the scratch guard) so an oversized + // block is always counted — the fixed-size scratch is exactly the ring, so an + // oversized block also trips the scratch guard below and would otherwise be + // dropped silently. The split path already rejects oversized devices at setup; + // this guards the duplex path, whose block size we don't pre-validate. + if (numSamples > kRingFrames) + { + overflowCount.fetch_add(1, std::memory_order_relaxed); + return; + } + // Scratch not yet sized to the full ring (cold start before the producer's + // about-to-start ran, or a transient reconfig) — skip rather than alloc on RT. + // After warm-up the scratch is exactly the ring, so for an in-range block this + // never trips. + if (mixScratch.getNumSamples() < numSamples) return; + + const bool ig = busIncludeGuitar.load(std::memory_order_relaxed); + const bool ib = busIncludeBacking.load(std::memory_order_relaxed); + const float gain = busGain.load(std::memory_order_relaxed); + + mixScratch.clear(0, 0, numSamples); + mixScratch.clear(1, 0, numSamples); + if (ig) + for (int ch = 0; ch < 2; ++ch) + mixScratch.addFrom(ch, 0, guitarMix, + juce::jmin(ch, guitarMix.getNumChannels() - 1), 0, numSamples); + if (ib && backingBuf != nullptr && backingFrames > 0) + { + const int n = juce::jmin(backingFrames, numSamples); + for (int ch = 0; ch < 2; ++ch) + mixScratch.addFrom(ch, 0, *backingBuf, + juce::jmin(ch, backingBuf->getNumChannels() - 1), 0, n, backingVol); + } + // Renderer-fed song audio (stems / element / loopback riding the renderer + // bus) is song audio for the streamer too — without this the stream mix + // carries guitar only whenever the song bypasses the native transport + // (multi-stem under exclusive/ASIO output). Bus gain is already applied by + // pullRendererBus; only the stream gain below shapes it further. Backing + // transport and renderer bus are mutually exclusive song paths in + // practice, so this never double-carries. + if (ib && rendererBuf != nullptr && rendererFrames > 0) + { + const int n = juce::jmin(rendererFrames, numSamples); + for (int ch = 0; ch < 2; ++ch) + mixScratch.addFrom(ch, 0, *rendererBuf, + juce::jmin(ch, rendererBuf->getNumChannels() - 1), 0, n); + } + mixScratch.applyGain(0, 0, numSamples, gain); + mixScratch.applyGain(1, 0, numSamples, gain); + + const float peak = juce::jmax(mixScratch.getMagnitude(0, 0, numSamples), + mixScratch.getMagnitude(1, 0, numSamples)); + level.store(peak, std::memory_order_relaxed); + + ring.push(mixScratch.getReadPointer(0), mixScratch.getReadPointer(1), numSamples); +} + +void StreamSink::deviceCallback(float* const* outputData, int numOutputChannels, int numSamples) +{ + const juce::ScopedNoDenormals noDenormals; + if (numOutputChannels <= 0) return; + juce::AudioBuffer buffer(outputData, numOutputChannels, numSamples); + buffer.clear(); + + const int scratchCap = (int) pullScratchL.size(); + const int outSamples = juce::jmin(numSamples, scratchCap); + + uint64_t r = ring.readIndex.load(std::memory_order_relaxed); + const uint64_t w = ring.writeIndex.load(std::memory_order_acquire); + ring.resyncIfIndicesReset(r, w); + if (ring.catchUpIfLapped(r, w)) + overflowCount.fetch_add(1, std::memory_order_relaxed); + const uint64_t available = w - r; + const int pullCount = juce::jmin(outSamples, (int) available); + const int consumeCount = juce::jmin(numSamples, (int) available); + const int copyChannels = juce::jmin(numOutputChannels, 2); + for (int i = 0; i < pullCount; ++i) + { + float l, rr; + ring.readFrame(r + (uint64_t) i, l, rr); + buffer.setSample(0, i, l); + if (copyChannels > 1) buffer.setSample(1, i, rr); + } + if (pullCount < outSamples) + underflowCount.fetch_add(1, std::memory_order_relaxed); + ring.commitRead(r + (uint64_t) consumeCount); +} + +void StreamSink::deviceAboutToStart(juce::AudioIODevice* device) +{ + if (device == nullptr) return; + const int bs = device->getCurrentBufferSizeSamples(); + double sr = device->getCurrentSampleRate(); + if (sr <= 0.0) sr = state.currentSampleRate.load(std::memory_order_relaxed); + sinkBlockSize.store(bs, std::memory_order_relaxed); + sinkSampleRate.store(sr, std::memory_order_relaxed); + const int cap = juce::jmax(bs, 2048); + if ((int) pullScratchL.size() < cap) pullScratchL.assign((size_t) cap, 0.0f); + if ((int) pullScratchR.size() < cap) pullScratchR.assign((size_t) cap, 0.0f); + ring.reset(); + underflowCount.store(0, std::memory_order_relaxed); + overflowCount.store(0, std::memory_order_relaxed); +} + +void StreamSink::deviceStopped() +{ + // Fires on any stop of the stream device — an unplanned loss (unplug / driver + // reset) as well as our own teardown. Mark inactive so the producer stops + // filling a now-consumer-less ring and the meter clears; desiredTypeName/Name + // are deliberately left intact so reopenDesired() can restore it. + active.store(false, std::memory_order_release); + level.store(0.0f, std::memory_order_relaxed); +} + +juce::String StreamSink::open(const juce::String& typeName, const juce::String& deviceName) +{ + // Control-thread only. Opens an OUTPUT-only device on the stream sink's own + // AudioDeviceManager and attaches the drain callback. Mirrors applySplitSetup's + // output open. v1 requires the sink's nominal SR to match the engine rate (no + // async resampler yet — that's PR3); a mismatch is rejected with a clear error. + desiredTypeName = typeName; + desiredDeviceName = deviceName; + + // Stop the producer from writing the ring while we (re)configure the device: + // setAudioDeviceSetup() below drives deviceAboutToStart(), which resets the + // ring indices and slots. Clearing `active` first stops the main callback from + // STARTING new pushes. A producer block already past the active-check can still + // finish one push, but the device close/reopen takes far longer than a single + // audio block, so that in-flight push completes well before about-to-start runs. + // Worst case is therefore one imperfect block on the STREAM bus (never the local + // monitor) during a manual device switch — atomic, no data race, no UAF. We only + // re-arm `active` after a fully clean open. + active.store(false, std::memory_order_release); + + // Any failure below: detach/close the half-open device AND drop the desired + // intent. A deterministic failure (e.g. SR mismatch) is then NOT retried on every + // startAudio(), and the engine never reports active with no device behind it. The + // renderer keeps its own persisted choice and re-applies it, so nothing is lost. + auto fail = [this](const juce::String& msg) -> juce::String { + close(); + desiredTypeName = {}; + desiredDeviceName = {}; + return msg; + }; + + if (! initialised) + { + manager.initialise(0, 2, nullptr, false); + initialised = true; + } + + juce::AudioIODeviceType* outType = nullptr; + for (auto* t : manager.getAvailableDeviceTypes()) + if (t->getTypeName() == typeName) { outType = t; break; } + if (! outType) return fail("Stream output device type not found: " + typeName); + + try { + if (auto* cur = manager.getCurrentDeviceTypeObject()) + { + if (cur->getTypeName() != typeName) + manager.setCurrentAudioDeviceType(typeName, true); + } + else manager.setCurrentAudioDeviceType(typeName, true); + } catch (...) { return fail("setCurrentAudioDeviceType threw for stream output type '" + typeName + "'"); } + + juce::String resolved = deviceName; + if (resolved.isEmpty()) + { + auto names = outType->getDeviceNames(false); + if (names.size() > 0) resolved = names[0]; + } + + juce::AudioDeviceManager::AudioDeviceSetup setup; + setup.inputDeviceName = ""; + setup.outputDeviceName = resolved; + setup.sampleRate = state.currentSampleRate.load(std::memory_order_relaxed); + setup.bufferSize = state.outputBlockSize.load(std::memory_order_relaxed); + setup.useDefaultInputChannels = false; + setup.useDefaultOutputChannels = false; + setup.inputChannels.clear(); + setup.outputChannels.setRange(0, 2, true); + + juce::String err; + try { err = manager.setAudioDeviceSetup(setup, true); } + catch (...) { return fail("stream output setAudioDeviceSetup threw"); } + if (err.isNotEmpty()) return fail("stream output setup: " + err); + + auto* dev = manager.getCurrentAudioDevice(); + if (! dev) return fail("no stream output device after setup"); + const double devSr = dev->getCurrentSampleRate(); + const double engineSr = state.currentSampleRate.load(std::memory_order_relaxed); + if (engineSr > 0.0 && std::abs(devSr - engineSr) > 0.5) + return fail("Stream output sample rate (" + juce::String(devSr) + + ") must match the engine rate (" + juce::String(engineSr) + + "). Pick a device that supports " + juce::String(engineSr) + " Hz."); + + if (! callbackRegistered) + { + manager.addAudioCallback(&callback); + callbackRegistered = true; + } + active.store(true, std::memory_order_release); + fprintf(stderr, "[AudioEngine] stream output active: %s (%s)\n", + resolved.toRawUTF8(), typeName.toRawUTF8()); + return {}; +} + +void StreamSink::close() +{ + // Detach the drain callback and close the device, leaving desiredTypeName/Name + // intact so startAudio()/reopenDesired() can restore it. active=false + // first so the producer stops pushing; removeAudioCallback() then blocks until + // the consumer callback is no longer in flight, so the ring/device go quiescent + // before close. Idempotent — safe when nothing is open. + active.store(false, std::memory_order_release); + if (callbackRegistered) + { + manager.removeAudioCallback(&callback); + callbackRegistered = false; + } + try { manager.closeAudioDevice(); } catch (...) {} + level.store(0.0f, std::memory_order_relaxed); +} + +void StreamSink::clear() +{ + close(); + desiredTypeName = {}; + desiredDeviceName = {}; +} + +void StreamSink::reopenDesired() +{ + // Copy the intent first: open() mutates desiredTypeName/Name (and clears + // them on failure), so don't pass the members in by reference. + const juce::String t = desiredTypeName; + const juce::String d = desiredDeviceName; + if (d.isEmpty() && t.isEmpty()) return; + const juce::String err = open(t, d); + if (err.isNotEmpty()) + fprintf(stderr, "[AudioEngine] reopenDesiredStreamSink failed: %s\n", err.toRawUTF8()); +} + +} // namespace slopsmith diff --git a/src/audio/engine/StreamSink.h b/src/audio/engine/StreamSink.h new file mode 100644 index 0000000..3d7f03a --- /dev/null +++ b/src/audio/engine/StreamSink.h @@ -0,0 +1,142 @@ +#pragma once + +// StreamSink — the streamer-mix output sink (TLC plan phase 2 / §2.5, was +// "PR1" inside AudioEngine). A second OUTPUT AudioDeviceManager on its OWN +// clock that drains a dedicated SPSC ring fed by the main output path's +// composed stream submix (publish()). Mirrors the InputDeviceSlot pattern +// INVERTED to the output side: the PRODUCER is the primary/output callback, +// the CONSUMER is this extra output device's callback. Default off → no +// behaviour change. +// +// Moved verbatim from AudioEngine; the engine keeps thin facades +// (setStreamOutputDevice / clearStreamOutput / setStreamBus / metrics +// getters) so the NodeAddon surface is unchanged. Engine sample rate / output +// block size are read through the EngineState& bound at construction. + +#include "PackedStereoRing.h" +#include "EngineState.h" +#include "../GainSanitize.h" + +#include + +#include +#include +#include + +namespace slopsmith { + +class StreamSink +{ +public: + // Must match the main engine ring capacity: publish() rejects blocks + // larger than one ring (they can't be published atomically). + static constexpr int kRingFrames = 4096; + + explicit StreamSink(EngineState& engineState) : state(engineState) + { + callback.sink = this; + } + + // ── Control thread ──────────────────────────────────────────────────── + // Open an OUTPUT-only device and attach the drain callback. Empty error + // string = success. v1 requires the sink's nominal SR to match the engine + // rate (no async resampler yet); a mismatch is rejected with a clear error. + juce::String open(const juce::String& typeName, const juce::String& deviceName); + // Detach + close but KEEP desiredTypeName/Name so reopenDesired() can + // restore it after a stop/restart (intent survives). Idempotent. + void close(); + // close() + drop the desired intent (a user "no stream output"). + void clear(); + void reopenDesired(); + + // Size the producer-side scratches to the FIXED ring capacity — call from + // the engine's about-to-start hooks (device-management thread), never RT. + // Fixed cap so a hotplug about-to-start can never realloc under a live + // producer on the other clock; allocates exactly once. + void prepareProducerScratch() + { + if (mixScratch.getNumSamples() < kRingFrames) + mixScratch.setSize(2, kRingFrames, false, false, true); + } + + // Bus content: include the backing/game, include the guitar monitor mix, + // and a linear output gain. All atomic — safe to set live. Gain sanitised + // (finite, 0..8) so a NaN/Inf from JS can never reach the stream ring. + void setBus(bool includeBacking, bool includeGuitar, float gain) + { + busIncludeBacking.store(includeBacking, std::memory_order_relaxed); + busIncludeGuitar.store(includeGuitar, std::memory_order_relaxed); + busGain.store(sanitizeStreamGain(gain), std::memory_order_relaxed); + } + void setBusGain(float gain) { busGain.store(sanitizeStreamGain(gain), std::memory_order_relaxed); } + + bool isActive() const { return active.load(std::memory_order_acquire); } + juce::String getDesiredDeviceName() const { return desiredDeviceName; } + float getLevel() const { return level.load(std::memory_order_relaxed); } + uint64_t getUnderflowCount() const { return underflowCount.load(std::memory_order_relaxed); } + uint64_t getOverflowCount() const { return overflowCount.load(std::memory_order_relaxed); } + + // ── Producer (primary/output callback, RT) ──────────────────────────── + // Compose the stream submix (guitar/backing/renderer × include flags × + // gain) into the fixed scratch and pack it into the ring. + void publish(const juce::AudioBuffer& guitarMix, + const juce::AudioBuffer* backingBuf, int backingFrames, float backingVol, + const juce::AudioBuffer* rendererBuf, int rendererFrames, + int numSamples); + +private: + // Forwards the sink device's callbacks (the sink's own clock). + struct Callback : juce::AudioIODeviceCallback + { + StreamSink* sink = nullptr; + void audioDeviceIOCallbackWithContext(const float* const* inputData, int numInputChannels, + float* const* outputData, int numOutputChannels, + int numSamples, + const juce::AudioIODeviceCallbackContext&) override + { + juce::ignoreUnused(inputData, numInputChannels); + if (sink) sink->deviceCallback(outputData, numOutputChannels, numSamples); + } + void audioDeviceAboutToStart(juce::AudioIODevice* d) override { if (sink) sink->deviceAboutToStart(d); } + void audioDeviceStopped() override { if (sink) sink->deviceStopped(); } + }; + + // Consumer side (the sink device's thread). + void deviceCallback(float* const* outputData, int numOutputChannels, int numSamples); + void deviceAboutToStart(juce::AudioIODevice* device); + void deviceStopped(); + + EngineState& state; + + Callback callback; + PackedStereoRing ring; + std::atomic underflowCount{0}; + std::atomic overflowCount{0}; + std::atomic active{false}; + std::atomic sinkSampleRate{48000.0}; + std::atomic sinkBlockSize{256}; + std::vector pullScratchL, pullScratchR; // sized in deviceAboutToStart + bool callbackRegistered = false; + bool initialised = false; + + std::atomic busIncludeBacking{true}; + std::atomic busIncludeGuitar{true}; + std::atomic busGain{1.0f}; + std::atomic level{0.0f}; + // Producer-side composed submix scratch — fixed ring capacity, see + // prepareProducerScratch(). + juce::AudioBuffer mixScratch; + + // Declared LAST so it DESTRUCTS FIRST (members tear down in reverse + // declaration order): the manager's dtor closes the device and detaches + // `callback` while `callback`/`ring` are still alive — no use-after-free + // even if an explicit teardown path is ever missed. close() also tears it + // down explicitly before this. + juce::AudioDeviceManager manager; + // Persistent INTENT (control thread only): the device the user chose. + // Survives a stop/restart so reopenDesired() can re-open it. + juce::String desiredTypeName; + juce::String desiredDeviceName; +}; + +} // namespace slopsmith diff --git a/src/main/audio-bridge.ts b/src/main/audio-bridge.ts index c2d1866..c37f230 100644 --- a/src/main/audio-bridge.ts +++ b/src/main/audio-bridge.ts @@ -1194,13 +1194,17 @@ export function initAudioBridge(): void { return await audio?.replaceIR(slotId, irPath, typeof gain === 'number' ? gain : -1) ?? false; }); - ipcMain.handle('audio:removeProcessor', (_event, slotId: number) => { - audio?.removeProcessor(slotId); + // The native chain mutators are async now (they take the chain-mutation + // mutex on a worker thread rather than freezing the main process behind an + // in-flight plugin load — see ChainOps.h). Await them so a renderer that + // awaits this IPC and then re-reads the chain sees the mutation applied. + ipcMain.handle('audio:removeProcessor', async (_event, slotId: number) => { + await audio?.removeProcessor(slotId); vstSlotPaths.delete(slotId); }); - ipcMain.handle('audio:moveProcessor', (_event, from: number, to: number) => { - audio?.moveProcessor(from, to); + ipcMain.handle('audio:moveProcessor', async (_event, from: number, to: number) => { + await audio?.moveProcessor(from, to); }); ipcMain.handle('audio:setBypass', (_event, slotId: number, bypassed: boolean) => { @@ -1221,8 +1225,8 @@ export function initAudioBridge(): void { audio?.setBranchSrc?.(slotId, src); }); - ipcMain.handle('audio:clearChain', () => { - audio?.clearChain(); + ipcMain.handle('audio:clearChain', async () => { + await audio?.clearChain(); vstSlotPaths.clear(); }); diff --git a/src/main/audio-effects-executor.ts b/src/main/audio-effects-executor.ts index 2860fae..291b402 100644 --- a/src/main/audio-effects-executor.ts +++ b/src/main/audio-effects-executor.ts @@ -29,12 +29,15 @@ type AudioEffectsNativeAudio = { savePreset?: () => unknown; clearChain?: () => Promise | unknown; getChainState?: () => unknown; + getChainGeneration?: () => unknown; setBypass?: (slotId: number, bypassed: boolean) => unknown; setMultiBypass?: (changes: Array<{ slotId: number; bypassed: boolean }>) => unknown; setParameter?: (slotId: number, paramIndex: number, value: number) => unknown; setGain?: (which: string, value: number) => Promise | unknown; setMonitorMute?: (muted: boolean) => Promise | unknown; setMonitorMuteSuppressed?: (suppressed: boolean) => Promise | unknown; + acquireMonitorMuteHold?: () => Promise | unknown; + releaseMonitorMuteHold?: () => Promise | unknown; isMonitorMuted?: () => Promise | unknown; startAudio?: () => Promise | unknown; }; @@ -89,6 +92,11 @@ type RouteState = { state: string; activeSegmentId: string; stageSlots: Map; + // Native chainGeneration this route's stageSlots map was built against + // (phase 7a). A foreign writer (legacy loadPreset / clearChain) bumps the + // native counter, invalidating the slot ids; stage operations detect the + // divergence and report stale-route instead of mutating wrong slots. + chainGeneration: number; stageKinds: Map; segments: ValidSegment[]; loadedAt: string; @@ -373,16 +381,24 @@ function validatePlan(request: unknown): { ok: true; plan: ValidPlan; presetJson }; } -function normalizeLoadResult(value: unknown): { success: boolean; slotsLoaded: number; error: string } { +function normalizeLoadResult(value: unknown): { success: boolean; slotsLoaded: number; error: string; chainGeneration: number } { const record = asRecord(value); - if (!record) return { success: false, slotsLoaded: 0, error: 'Native load returned an unsupported result' }; + if (!record) return { success: false, slotsLoaded: 0, error: 'Native load returned an unsupported result', chainGeneration: -1 }; return { success: record.success === true, slotsLoaded: safeNumber(record.slotsLoaded, 0), error: bounded(record.error ?? ''), + // -1 = addon predates the counter; staleness checks then no-op. + chainGeneration: safeNumber(record.chainGeneration, -1), }; } +// Current native chainGeneration, or -1 when the addon doesn't expose it. +function currentChainGeneration(nativeAudio: AudioEffectsNativeAudio | null): number { + if (!nativeAudio || typeof nativeAudio.getChainGeneration !== 'function') return -1; + try { return safeNumber(nativeAudio.getChainGeneration(), -1); } catch { return -1; } +} + function chainSlots(nativeAudio: AudioEffectsNativeAudio | null): Dict[] { if (!nativeAudio || typeof nativeAudio.getChainState !== 'function') return []; const state = nativeAudio.getChainState(); @@ -399,23 +415,35 @@ async function restorePreset(nativeAudio: AudioEffectsNativeAudio, presetJson: u } } -async function readMonitorMuted(nativeAudio: AudioEffectsNativeAudio): Promise { - if (typeof nativeAudio.isMonitorMuted !== 'function') return null; - try { - return Boolean(await nativeAudio.isMonitorMuted()); - } catch (_) { - return null; +// Monitor-mute arbiter (TLC Part II §2): the executor no longer reads or +// writes the user's mute PREFERENCE. During a load it acquires a refcounted +// override on the native arbiter — a force-mute hold (default) or a +// suppression (dryDuringLoad: dry guitar stays audible) — and RELEASES it +// afterwards. Returns a single-fire release closure (safe to call from a +// timer even after newer loads: each load owns its own acquisition, so +// releasing can never clobber another writer's state, which is exactly the +// stale-snapshot race the old read-modify-restore had). +async function acquireMuteOverride(nativeAudio: AudioEffectsNativeAudio, dryDuringLoad: boolean): Promise<() => Promise> { + let released = false; + if (dryDuringLoad && typeof nativeAudio.setMonitorMuteSuppressed === 'function') { + try { await nativeAudio.setMonitorMuteSuppressed(true); } catch (_) { return async () => { /* never acquired */ }; } + return async () => { + if (released) return; + released = true; + try { await nativeAudio.setMonitorMuteSuppressed!(false); } catch (_) { /* best effort */ } + }; } -} - -async function trySetMonitorMute(nativeAudio: AudioEffectsNativeAudio, muted: boolean): Promise { - if (typeof nativeAudio.setMonitorMute !== 'function') return; - try { await nativeAudio.setMonitorMute(muted); } catch (_) { /* best effort */ } -} - -async function trySetMonitorMuteSuppressed(nativeAudio: AudioEffectsNativeAudio, suppressed: boolean): Promise { - if (typeof nativeAudio.setMonitorMuteSuppressed !== 'function') return; - try { await nativeAudio.setMonitorMuteSuppressed(suppressed); } catch (_) { /* best effort */ } + if (!dryDuringLoad && typeof nativeAudio.acquireMonitorMuteHold === 'function') { + try { await nativeAudio.acquireMonitorMuteHold(); } catch (_) { return async () => { /* never acquired */ }; } + return async () => { + if (released) return; + released = true; + try { await nativeAudio.releaseMonitorMuteHold?.(); } catch (_) { /* best effort */ } + }; + } + // Addon predates the arbiter — degrade to no mute forcing rather than + // reintroducing the preference-clobbering read/force/restore. + return async () => { /* nothing acquired */ }; } async function trySetGain(nativeAudio: AudioEffectsNativeAudio, which: string, value: number): Promise { @@ -435,10 +463,13 @@ async function applyGains(nativeAudio: AudioEffectsNativeAudio, gains: RouteGain return failed; } -function schedulePreloadRestore(nativeAudio: AudioEffectsNativeAudio, previousMonitorMute: boolean | null, targetGain: number, holdMs: number, shouldRestore?: () => boolean): void { +function schedulePreloadRestore(nativeAudio: AudioEffectsNativeAudio, releaseMuteOverride: (() => Promise) | null, targetGain: number, holdMs: number, shouldRestore?: () => boolean): void { const restore = async () => { + // The override release is UNCONDITIONAL: this load acquired it, this + // load must release it, even when a newer load superseded the gain + // ramp (refcounts compose — the newer load holds its own). + if (releaseMuteOverride) await releaseMuteOverride(); if (shouldRestore && !shouldRestore()) return; - if (previousMonitorMute !== null) await trySetMonitorMute(nativeAudio, previousMonitorMute); const restoreTarget = clampGain(targetGain, 1); const steps = [restoreTarget * 0.25, restoreTarget * 0.5, restoreTarget * 0.8, restoreTarget]; for (const value of steps) { @@ -475,25 +506,24 @@ export function createAudioEffectsExecutor(getAudio: NativeAudioGetter) { const started = Date.now(); const restoreVersion = ++preloadRestoreVersion; const rollbackPreset = typeof nativeAudio.savePreset === 'function' ? nativeAudio.savePreset() : null; - let previousMonitorMute: boolean | null = null; + let releaseMuteOverride: (() => Promise) | null = null; if (options.preloadMute?.enabled) { - previousMonitorMute = await readMonitorMuted(nativeAudio); await trySetGain(nativeAudio, 'chain', 0); - await trySetMonitorMute(nativeAudio, options.preloadMute.dryDuringLoad ? false : true); + releaseMuteOverride = await acquireMuteOverride(nativeAudio, options.preloadMute.dryDuringLoad === true); } - let result: { success: boolean; slotsLoaded: number; error: string }; + let result: { success: boolean; slotsLoaded: number; error: string; chainGeneration: number }; try { result = normalizeLoadResult(await nativeAudio.loadPreset(validation.presetJson)); } catch (error) { const rollbackApplied = await restorePreset(nativeAudio, rollbackPreset); - if (options.preloadMute?.enabled) schedulePreloadRestore(nativeAudio, previousMonitorMute, options.gains.chain ?? options.preloadMute.targetGain, 0, () => restoreVersion === preloadRestoreVersion); + if (options.preloadMute?.enabled) schedulePreloadRestore(nativeAudio, releaseMuteOverride, options.gains.chain ?? options.preloadMute.targetGain, 0, () => restoreVersion === preloadRestoreVersion); return safeOutcome('failed', 'Native audio-effects plan load threw', { error: bounded(error instanceof Error ? error.message : String(error)), rollbackApplied }); } const nativeStages = validation.plan.stages.filter((stage) => stage.native); if (!result.success) { const rollbackApplied = await restorePreset(nativeAudio, rollbackPreset); - if (options.preloadMute?.enabled) schedulePreloadRestore(nativeAudio, previousMonitorMute, options.gains.chain ?? options.preloadMute.targetGain, 0, () => restoreVersion === preloadRestoreVersion); + if (options.preloadMute?.enabled) schedulePreloadRestore(nativeAudio, releaseMuteOverride, options.gains.chain ?? options.preloadMute.targetGain, 0, () => restoreVersion === preloadRestoreVersion); return safeOutcome('failed', 'Native audio-effects plan load failed', { routeKey: validation.plan.routeKey, providerId: validation.plan.providerId, @@ -508,7 +538,7 @@ export function createAudioEffectsExecutor(getAudio: NativeAudioGetter) { if (result.slotsLoaded < nativeStages.length) { const rollbackApplied = await restorePreset(nativeAudio, rollbackPreset); - if (options.preloadMute?.enabled) schedulePreloadRestore(nativeAudio, previousMonitorMute, options.gains.chain ?? options.preloadMute.targetGain, 0, () => restoreVersion === preloadRestoreVersion); + if (options.preloadMute?.enabled) schedulePreloadRestore(nativeAudio, releaseMuteOverride, options.gains.chain ?? options.preloadMute.targetGain, 0, () => restoreVersion === preloadRestoreVersion); return safeOutcome('degraded', 'Native audio-effects plan partially loaded and was rolled back', { routeKey: validation.plan.routeKey, providerId: validation.plan.providerId, @@ -525,7 +555,7 @@ export function createAudioEffectsExecutor(getAudio: NativeAudioGetter) { slots = chainSlots(nativeAudio); } catch (error) { const rollbackApplied = await restorePreset(nativeAudio, rollbackPreset); - if (options.preloadMute?.enabled) schedulePreloadRestore(nativeAudio, previousMonitorMute, options.gains.chain ?? options.preloadMute.targetGain, 0, () => restoreVersion === preloadRestoreVersion); + if (options.preloadMute?.enabled) schedulePreloadRestore(nativeAudio, releaseMuteOverride, options.gains.chain ?? options.preloadMute.targetGain, 0, () => restoreVersion === preloadRestoreVersion); return safeOutcome('failed', 'Native chain-state lookup threw', { routeKey: validation.plan.routeKey, providerId: validation.plan.providerId, @@ -545,7 +575,7 @@ export function createAudioEffectsExecutor(getAudio: NativeAudioGetter) { // as handled while later stage operations silently return no-target. Roll back instead. if (stageSlots.size !== nativeStages.length) { const rollbackApplied = await restorePreset(nativeAudio, rollbackPreset); - if (options.preloadMute?.enabled) schedulePreloadRestore(nativeAudio, previousMonitorMute, options.gains.chain ?? options.preloadMute.targetGain, 0, () => restoreVersion === preloadRestoreVersion); + if (options.preloadMute?.enabled) schedulePreloadRestore(nativeAudio, releaseMuteOverride, options.gains.chain ?? options.preloadMute.targetGain, 0, () => restoreVersion === preloadRestoreVersion); return safeOutcome('degraded', 'Native slot mapping was incomplete and was rolled back', { routeKey: validation.plan.routeKey, providerId: validation.plan.providerId, @@ -556,6 +586,23 @@ export function createAudioEffectsExecutor(getAudio: NativeAudioGetter) { }); } + // Detect a foreign write between our loadPreset and the getChainState + // slot mapping above: the mapped ids would describe someone else's + // chain. Roll back rather than store a poisoned route. + const generationNow = currentChainGeneration(nativeAudio); + if (result.chainGeneration >= 0 && generationNow >= 0 && generationNow !== result.chainGeneration) { + const rollbackApplied = await restorePreset(nativeAudio, rollbackPreset); + if (options.preloadMute?.enabled) schedulePreloadRestore(nativeAudio, releaseMuteOverride, options.gains.chain ?? options.preloadMute.targetGain, 0, () => restoreVersion === preloadRestoreVersion); + return safeOutcome('degraded', 'Native chain was modified by another writer during plan load', { + routeKey: validation.plan.routeKey, + providerId: validation.plan.providerId, + planId: validation.plan.planId, + expectedGeneration: result.chainGeneration, + currentGeneration: generationNow, + rollbackApplied, + }); + } + const route: RouteState = { routeKey: validation.plan.routeKey, providerId: validation.plan.providerId, @@ -563,6 +610,7 @@ export function createAudioEffectsExecutor(getAudio: NativeAudioGetter) { state: result.slotsLoaded >= nativeStages.length ? 'loaded' : 'degraded', activeSegmentId: '', stageSlots, + chainGeneration: result.chainGeneration, stageKinds, segments: validation.plan.segments, loadedAt: now(), @@ -575,7 +623,7 @@ export function createAudioEffectsExecutor(getAudio: NativeAudioGetter) { try { await nativeAudio.startAudio(); } catch (_) { /* load succeeded; start is best-effort */ } } if (options.preloadMute?.enabled) { - schedulePreloadRestore(nativeAudio, previousMonitorMute, options.gains.chain ?? options.preloadMute.targetGain, options.preloadMute.holdMs, () => { + schedulePreloadRestore(nativeAudio, releaseMuteOverride, options.gains.chain ?? options.preloadMute.targetGain, options.preloadMute.holdMs, () => { const current = routes.get(validation.plan.routeKey); return restoreVersion === preloadRestoreVersion && current?.planId === validation.plan.planId; }); @@ -613,8 +661,12 @@ export function createAudioEffectsExecutor(getAudio: NativeAudioGetter) { } catch (error) { releaseFailure = safeOutcome('failed', 'Native route release threw', { routeKey, error: bounded(error instanceof Error ? error.message : String(error)) }); } - await trySetMonitorMute(nativeAudio, true); - await trySetMonitorMuteSuppressed(nativeAudio, false); + // Arbiter fix: releaseRoute used to FORCE monitorMute=true and clear + // suppression unconditionally — clobbering the user's persisted + // preference and any other writer's suppression window. The chain is + // cleared above, so the engine's own empty-chain dry-mute semantics + // apply; any preload override this executor still holds is released + // by its own scheduled closure. if (releaseFailure) return updateOutcome(route, releaseFailure); routes.delete(routeKey); return safeOutcome('handled', 'Audio-effects route released', { routeKey, providerId: route.providerId, planId: route.planId, cleanupFailures }); @@ -634,6 +686,23 @@ export function createAudioEffectsExecutor(getAudio: NativeAudioGetter) { return updateOutcome(route, safeOutcome('handled', 'Audio-effects route gain applied', { route: safeRoute(route), gains })); } + // Stage operations act on the stageSlots map built at load time; a foreign + // chain write since then (legacy loadPreset / clearChain — the documented + // three-writer fight) makes those slot ids describe someone else's chain. + // Detect via chainGeneration and report a stale route (the provider should + // re-load its plan) instead of flipping bypass/params on wrong slots. + function staleRouteOutcome(route: RouteState, nativeAudio: AudioEffectsNativeAudio | null, extra: Dict): SafeOutcome | null { + if (route.chainGeneration < 0) return null; // addon predates the counter + const generationNow = currentChainGeneration(nativeAudio); + if (generationNow < 0 || generationNow === route.chainGeneration) return null; + route.state = 'stale'; + return safeOutcome('no-target', 'Native chain was modified by another writer since this route loaded — re-load the plan', { + ...extra, + expectedGeneration: route.chainGeneration, + currentGeneration: generationNow, + }); + } + async function setStageBypass(request: unknown): Promise { const input = asRecord(request) || {}; const routeKey = safeId(input.routeKey ?? DEFAULT_ROUTE_KEY, DEFAULT_ROUTE_KEY); @@ -644,6 +713,8 @@ export function createAudioEffectsExecutor(getAudio: NativeAudioGetter) { if (slotId == null) return updateOutcome(route, safeOutcome('no-target', 'Audio-effects stage is not mapped to a native slot', { routeKey, stageId })); const nativeAudio = getAudio(); if (!nativeAudio || typeof nativeAudio.setBypass !== 'function') return updateOutcome(route, safeOutcome('unavailable', 'Native stage bypass is unavailable', { routeKey, stageId })); + const stale = staleRouteOutcome(route, nativeAudio, { routeKey, stageId }); + if (stale) return updateOutcome(route, stale); try { const result = await nativeAudio.setBypass(slotId, safeBool(input.bypassed, false)); if (nativeFailure(result)) return updateOutcome(route, safeOutcome('failed', 'Native stage bypass returned failure', { routeKey, stageId })); @@ -668,6 +739,8 @@ export function createAudioEffectsExecutor(getAudio: NativeAudioGetter) { } const nativeAudio = getAudio(); if (!nativeAudio || typeof nativeAudio.setParameter !== 'function') return updateOutcome(route, safeOutcome('unavailable', 'Native stage parameter control is unavailable', { routeKey, stageId })); + const stale = staleRouteOutcome(route, nativeAudio, { routeKey, stageId }); + if (stale) return updateOutcome(route, stale); try { const result = await nativeAudio.setParameter(slotId, paramIndex, value); if (nativeFailure(result)) return updateOutcome(route, safeOutcome('failed', 'Native stage parameter returned failure', { routeKey, stageId, paramIndex })); @@ -687,6 +760,8 @@ export function createAudioEffectsExecutor(getAudio: NativeAudioGetter) { if (!segment) return updateOutcome(route, safeOutcome('no-target', 'Audio-effects segment is not present in the loaded plan', { routeKey, segmentId })); const nativeAudio = getAudio(); if (!nativeAudio || typeof nativeAudio.setMultiBypass !== 'function') return updateOutcome(route, safeOutcome('unavailable', 'Native multi-bypass is unavailable', { routeKey, segmentId })); + const stale = staleRouteOutcome(route, nativeAudio, { routeKey, segmentId }); + if (stale) return updateOutcome(route, stale); const active = new Set(segment.stageIds); const changes = Array.from(route.stageSlots.entries()).map(([stageId, slotId]) => ({ slotId, diff --git a/src/renderer/screen.js b/src/renderer/screen.js index 8f449ee..b28daab 100644 --- a/src/renderer/screen.js +++ b/src/renderer/screen.js @@ -210,11 +210,14 @@ window.__feedBackDesktopAudioHooks = window.__feedBackDesktopAudioHooks || {}; } function saveDeviceSettings(settings = captureDeviceSettings()) { + // Single persistence store (TLC Part II §4): the file-backed settings + // are the only writer target. The old parallel localStorage copy meant + // a main-side migration/reset could lose the timestamp race against a + // stale browser copy and resurrect wiped settings. const snapshot = { ...cloneDeviceSettings(settings), savedAt: Date.now(), }; - try { localStorage.setItem('slopsmith-audio-device', JSON.stringify(snapshot)); } catch (_) {} pendingDeviceSave = pendingDeviceSave .catch(() => null) .then(() => { @@ -255,17 +258,38 @@ window.__feedBackDesktopAudioHooks = window.__feedBackDesktopAudioHooks || {}; } catch (e) { console.warn('[audio-engine] Failed to load file-backed device settings:', e); } + // Migration only (TLC Part II §4): 'slopsmith-audio-device' was a + // second store racing the file on savedAt. Import a strictly-newer + // browser copy into the file store ONCE, then delete the key either + // way — after this the file is the single source of truth. let browserSettings = null; try { const raw = localStorage.getItem('slopsmith-audio-device'); browserSettings = normalizeDeviceSettings(raw ? JSON.parse(raw) : null); } catch { browserSettings = null; } - if (fileSettings && browserSettings) { - return getDeviceSettingsSavedAt(browserSettings) > getDeviceSettingsSavedAt(fileSettings) - ? browserSettings - : fileSettings; + if (browserSettings !== null) { + const browserNewer = !fileSettings + || getDeviceSettingsSavedAt(browserSettings) > getDeviceSettingsSavedAt(fileSettings); + // Drop the browser copy ONLY once it is safely in the file store — + // deleting it after a failed (or unavailable) save would throw the + // user's device settings away for good. + let migrated = !browserNewer; + if (browserNewer) { + try { + if (typeof api.saveDeviceSettings === 'function') { + await api.saveDeviceSettings(browserSettings); + migrated = true; + } + } catch (e) { + console.warn('[audio-engine] device-settings migration save failed:', e); + } + } + if (migrated) { + try { localStorage.removeItem('slopsmith-audio-device'); } catch (_) {} + } + if (browserNewer) return browserSettings; } - return fileSettings || browserSettings; + return fileSettings; } function hasSettingValue(value) { @@ -4352,16 +4376,41 @@ window.__feedBackDesktopAudioHooks = window.__feedBackDesktopAudioHooks || {}; // the preload below. While the chain is empty the native engine's monitor // mute would silence the dry guitar. Suppress the mute for the rebuild // window so the guitar keeps sounding; resolve it once the chain settles. + // The native side refcounts suppressions (SourceChain's monitor-mute + // arbiter): true = acquire, false = release. This latch keeps the renderer + // to AT MOST ONE outstanding suppression, because the guard below is + // deliberately unpaired — resolveChainRebuildGuard() leaves the suppression + // on when the rebuild produced an empty chain, and returns early without + // releasing while a provider route is still resolving. Under the old latched + // bool those were self-correcting (repeated trues were idempotent, any false + // reset it). Against a refcount each one would leak a permanent +1, and + // after a couple of song loads the count could never return to zero — monitor + // mute would be silently dead for the rest of the session. + let aeMonitorMuteSuppressionHeld = false; function aeSetMonitorMuteSuppressed(suppressed) { + const want = !!suppressed; + if (want === aeMonitorMuteSuppressionHeld) return; // idempotent, like the old bool const api = window.feedBackDesktop?.audio; - // Optional-chained: a downlevel native addon simply ignores this. + // Downlevel addon (no arbiter): nothing is ever acquired, so leave the + // latch alone rather than recording a hold we don't have. + if (typeof api?.setMonitorMuteSuppressed !== 'function') return; + aeMonitorMuteSuppressionHeld = want; + // The latch mirrors the NATIVE refcount, so it may only stay flipped if + // the call actually landed. A rejected release that left the latch at + // "released" would short-circuit every later release while the native + // count stayed held — the same stuck-suppression bug, one level up. Roll + // back on failure so the next call retries (and only if no newer call + // has moved the latch on in the meantime). + const rollback = () => { + if (aeMonitorMuteSuppressionHeld === want) aeMonitorMuteSuppressionHeld = !want; + }; // setMonitorMuteSuppressed is async (ipcRenderer.invoke) — the sync - // try/catch only covers a missing method, so also swallow the - // returned promise's rejection to avoid an unhandled rejection. + // try/catch only covers a throwing call, so handle the returned + // promise's rejection too (which also avoids an unhandled rejection). try { - const r = api?.setMonitorMuteSuppressed?.(suppressed); - if (r && typeof r.catch === 'function') r.catch(() => {}); - } catch (_) { /* downlevel */ } + const r = api.setMonitorMuteSuppressed(want); + if (r && typeof r.catch === 'function') r.catch(rollback); + } catch (_) { rollback(); } } // Called by clearChainForNewSong (IIFE 1) and the preload below. window._aeBeginChainRebuildGuard = function () { aeSetMonitorMuteSuppressed(true); }; diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index 9d5cddb..3083ec0 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -13,6 +13,7 @@ endif() # Pure-helper tests (no JUCE / no platform deps) build everywhere. add_subdirectory(audio_sanitize) +add_subdirectory(engine_units) # Note: enable_testing() lives in the top-level CMakeLists.txt — calling it # only here would register tests in build/tests/CTestTestfile.cmake but diff --git a/tests/audio-effects-executor.test.js b/tests/audio-effects-executor.test.js index 410f9e3..613a5a9 100644 --- a/tests/audio-effects-executor.test.js +++ b/tests/audio-effects-executor.test.js @@ -200,18 +200,26 @@ test('audio-effects executor owns load mute, route gain, start, and release', as assert.equal(gained.outcome, 'handled'); assert.equal(released.outcome, 'handled'); assert.equal(inspected.outcome, 'no-target'); - assert.deepEqual(calls.slice(0, 7), [ - ['is-muted'], + // Monitor-mute arbiter (TLC Part II §2): the executor never reads or + // writes the user's mute preference — it acquires a suppression for the + // dry-during-load window (default) and releases exactly what it acquired. + assert.deepEqual(calls.slice(0, 6), [ ['gain', 'chain', 0], - ['monitor', false], + ['suppress', true], ['load', 2], ['gain', 'input', 8], ['start'], ['gain', 'chain', 2], ]); assert.equal(calls.some(call => call[0] === 'clear'), true); - assert.equal(calls.some(call => call[0] === 'monitor' && call[1] === true), true); - assert.equal(calls.some(call => call[0] === 'suppress' && call[1] === false), true); + // The preference API is untouched, in both directions — releaseRoute no + // longer forces monitorMute=true over the user's persisted choice. + assert.equal(calls.some(call => call[0] === 'is-muted'), false); + assert.equal(calls.some(call => call[0] === 'monitor'), false); + // The suppression is balanced: one acquire, one release — never an + // unpaired clear that would cancel another writer's window. + assert.equal(calls.filter(call => call[0] === 'suppress' && call[1] === true).length, 1); + assert.equal(calls.filter(call => call[0] === 'suppress' && call[1] === false).length, 1); assert.equal(calls.some(call => call[0] === 'gain' && call[1] === 'chain' && call[2] === 4), false); assert.equal(calls.some(call => call[0] === 'gain' && call[1] === 'chain' && call[2] === 0), true); }); @@ -368,8 +376,11 @@ test('audio-effects executor rejects coerced parameter indices', async () => { }); test('preload exposes the trusted audio-effects executor surface', () => { - const preload = fs.readFileSync(path.join(ROOT, 'src', 'main', 'preload.ts'), 'utf8'); - const bridge = fs.readFileSync(path.join(ROOT, 'src', 'main', 'audio-bridge.ts'), 'utf8'); + // Normalize line endings: the multi-line snippet assertion below uses + // \n, but a Windows checkout with core.autocrlf reads these files as + // \r\n — the test must not depend on the developer's git config. + const preload = fs.readFileSync(path.join(ROOT, 'src', 'main', 'preload.ts'), 'utf8').replace(/\r\n/g, '\n'); + const bridge = fs.readFileSync(path.join(ROOT, 'src', 'main', 'audio-bridge.ts'), 'utf8').replace(/\r\n/g, '\n'); assert.equal(preload.includes('audioEffects: {'), true); for (const method of ['loadChainPlan', 'releaseRoute', 'inspectRoute', 'activateSegment', 'setStageBypass', 'setStageParameter', 'setRouteGain']) { @@ -390,3 +401,51 @@ test('preload exposes the trusted audio-effects executor surface', () => { assert.equal(bridge.includes('vstSlotPaths.clear();\n return await audioEffects.loadChainPlan(request);'), true); assert.equal(bridge.includes('if (normalizedPayload.inputType !== normalizedPayload.outputType)'), true); }); + +test('audio-effects executor detects a foreign chain write via chainGeneration and reports a stale route', async () => { + const { createAudioEffectsExecutor } = loadExecutorModule(); + // Native stub with the phase-7a generation counter: our load lands at + // generation 5; a foreign writer (legacy loadPreset / clearChain) later + // bumps it to 6, invalidating the route's stageSlots map. + let generation = 5; + const bypassCalls = []; + const native = { + loadPreset: async presetJson => ({ success: true, slotsLoaded: JSON.parse(presetJson).chain.length, chainGeneration: generation }), + getChainState: () => [{ id: 10 }, { id: 11 }], + getChainGeneration: () => generation, + setBypass: (slotId, bypassed) => { bypassCalls.push([slotId, bypassed]); return true; }, + setMultiBypass: changes => { bypassCalls.push(['multi', changes]); return true; }, + setParameter: () => true, + }; + const executor = createAudioEffectsExecutor(() => native); + const loaded = await executor.loadChainPlan({ + authorization: 'playback-session', + plan: plan(), + assets: { + 'asset:pre': { kind: 'nam', path: tempAsset('.nam'), safeName: 'pre' }, + 'asset:cab': { kind: 'ir', path: tempAsset('.wav'), safeName: 'cab' }, + }, + }); + assert.equal(loaded.outcome, 'handled'); + + // Generation unchanged: stage ops flow normally. + const fresh = await executor.setStageBypass({ routeKey: 'desktop-main', stageId: 'pre', bypassed: true }); + assert.equal(fresh.outcome, 'handled'); + assert.equal(bypassCalls.length, 1); + + // Foreign write bumps the native counter. + generation = 6; + const stale = await executor.setStageBypass({ routeKey: 'desktop-main', stageId: 'pre', bypassed: false }); + assert.equal(stale.outcome, 'no-target'); + assert.match(stale.reason, /modified by another writer/); + assert.equal(stale.payload.expectedGeneration, 5); + assert.equal(stale.payload.currentGeneration, 6); + assert.equal(bypassCalls.length, 1, 'stale route must NOT touch native slots'); + + // Segment activation and parameters are equally guarded. + const seg = await executor.activateSegment({ routeKey: 'desktop-main', segmentId: 'lead' }); + assert.equal(seg.outcome, 'no-target'); + const param = await executor.setStageParameter({ routeKey: 'desktop-main', stageId: 'pre', paramIndex: 0, value: 0.5 }); + assert.equal(param.outcome, 'no-target'); + assert.equal(bypassCalls.length, 1); +}); diff --git a/tests/chain-mutation-storm.test.js b/tests/chain-mutation-storm.test.js new file mode 100644 index 0000000..e6301c7 --- /dev/null +++ b/tests/chain-mutation-storm.test.js @@ -0,0 +1,83 @@ +// Phase 0.b storm test (docs/audio-engine-tlc.md §4, deep-read §1): chain- +// mutating async workers (loadPreset/loadVST/loadNAM/loadIR) queue on the +// libuv threadpool with no mutual exclusion, so two overlapping loadPreset +// calls can interleave clear()/addProcessor() and merge both presets into +// garbage. This test documents that corruption today and flips to a hard gate +// once ChainOps lands the chain-mutation serializer (plan phase 7). +// +// EXPECTED-FAIL / QUARANTINED: needs the built addon, drives a known race +// repeatedly (a single clean run proves nothing for a race), and initializes +// JUCE in-process. Run explicitly with: +// CHAIN_STORM=1 node --test tests/chain-mutation-storm.test.js +'use strict'; + +const test = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const os = require('node:os'); +const path = require('node:path'); + +const ADDON = path.join(__dirname, '..', 'build', 'Release', 'slopsmith_audio.node'); +const ENABLED = process.env.CHAIN_STORM === '1'; +const ITERATIONS = 50; + +// Minimal valid 16-bit PCM mono WAV (a short impulse) — enough for IRLoader, +// so the presets need no real cab/amp assets. IR slots (type 2) are the only +// asset-cheap distinguishable payload loadPreset accepts. +function writeImpulseWav(file, numSamples) { + const dataBytes = numSamples * 2; + const buf = Buffer.alloc(44 + dataBytes); + buf.write('RIFF', 0); buf.writeUInt32LE(36 + dataBytes, 4); buf.write('WAVE', 8); + buf.write('fmt ', 12); buf.writeUInt32LE(16, 16); buf.writeUInt16LE(1, 20); + buf.writeUInt16LE(1, 22); buf.writeUInt32LE(48000, 24); buf.writeUInt32LE(96000, 28); + buf.writeUInt16LE(2, 32); buf.writeUInt16LE(16, 34); + buf.write('data', 36); buf.writeUInt32LE(dataBytes, 40); + buf.writeInt16LE(32767, 44); // unit impulse, remaining samples zero + fs.writeFileSync(file, buf); +} + +const IR_TYPE = 2; // ProcessorSlot::Type::IR + +function irPreset(irFile, slotCount) { + return JSON.stringify({ + chain: Array.from({ length: slotCount }, (_, i) => ({ + type: IR_TYPE, name: `storm-ir-${slotCount}-${i}`, path: irFile, bypassed: false, + })), + }); +} + +test('concurrent loadPreset calls end with exactly one caller\'s chain', { skip: !ENABLED && 'needs built addon — set CHAIN_STORM=1 (hard gate since the phase-7 serializer)' }, async () => { + assert.ok(fs.existsSync(ADDON), 'addon must be built (npm run build:audio)'); + const audio = require(ADDON); + audio.init(); // returns undefined; loadPreset fails "No engine" if it didn't take + + const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'chain-storm-')); + const irFile = path.join(tmp, 'impulse.wav'); + writeImpulseWav(irFile, 64); + + // Preset A loads 1 IR slot, preset B loads 2 — after both settle the chain + // must be exactly one of those (1 or 2 slots of the SAME preset's names). + // An interleaved clear/add merge shows up as 3 slots or mixed names. + const presetA = irPreset(irFile, 1); + const presetB = irPreset(irFile, 2); + + try { + for (let i = 0; i < ITERATIONS; i++) { + const [ra, rb] = await Promise.all([ + audio.loadPreset(presetA), + audio.loadPreset(presetB), + ]); + assert.ok(ra?.success && rb?.success, `iteration ${i}: a load reported failure`); + + const slots = audio.getChainState(); + const names = slots.map((s) => s.name); + const isA = names.length === 1 && names[0] === 'storm-ir-1-0'; + const isB = names.length === 2 && names[0] === 'storm-ir-2-0' && names[1] === 'storm-ir-2-1'; + assert.ok(isA || isB, `iteration ${i}: merged/corrupt chain: ${JSON.stringify(names)}`); + } + } finally { + await audio.clearChain?.(); + audio.shutdown?.(); + fs.rmSync(tmp, { recursive: true, force: true }); + } +}); diff --git a/tests/config-paths.test.js b/tests/config-paths.test.js index c194811..2e195d1 100644 --- a/tests/config-paths.test.js +++ b/tests/config-paths.test.js @@ -111,17 +111,23 @@ test('SAFETY: song library, installed plugins and ML caches are ONLY in optInExt ...cats.pluginStateAndPyDeps, ...cats.configDbsAndState, ]; - // None of the safe categories may equal or be a child of the protected dirs. + // None of the safe categories may equal or be a child of the protected + // dirs. Use the env's RESOLVED fields (exactly what production + // returns) rather than rebuilding them with host-native path.join — + // on Windows that produced backslash paths that never matched the + // forward-slash simulated envs, failing the mlCaches equality AND + // silently vacuous-passing these child checks. The simulated env + // paths are forward-slash on every platform, so '/' is the separator. const protectedRoots = [ env.dlcDir, env.pluginsDir, - path.join(env.cacheBase, 'torch'), - path.join(env.cacheBase, 'huggingface'), + env.torchHome, + env.hfHome, ]; for (const root of protectedRoots) { assert.ok(!safe.includes(root), `${name}: ${root} leaked into a safe category`); assert.ok( - !safe.some((p) => p === root || p.startsWith(root + path.sep)), + !safe.some((p) => p === root || p.startsWith(root + '/')), `${name}: a safe path lives under protected ${root}`, ); } @@ -130,7 +136,7 @@ test('SAFETY: song library, installed plugins and ML caches are ONLY in optInExt assert.deepEqual(cats.optInExtras.installedPlugins, [env.pluginsDir], `${name}: installedPlugins`); assert.deepEqual( cats.optInExtras.mlCaches, - [path.join(env.cacheBase, 'torch'), path.join(env.cacheBase, 'huggingface')], + [env.torchHome, env.hfHome], `${name}: mlCaches`, ); } diff --git a/tests/contract-check.test.js b/tests/contract-check.test.js new file mode 100644 index 0000000..aed2f44 --- /dev/null +++ b/tests/contract-check.test.js @@ -0,0 +1,34 @@ +// Phase 0.a gate (docs/audio-engine-tlc.md §4): the public audio surface — +// addon exports, IPC channels, preload API keys — must not change during the +// decomposition phases. Removals/renames fail here; deliberate additions +// require regenerating the snapshots in the same commit: +// node tests/contracts/extract.js +'use strict'; + +const test = require('node:test'); +const assert = require('node:assert'); +const fs = require('fs'); +const path = require('path'); + +const { extractAddonExports, extractIpcChannels, extractPreloadApi } = require('./contracts/extract.js'); + +function loadSnapshot(name) { + return JSON.parse(fs.readFileSync(path.join(__dirname, 'contracts', name), 'utf8')); +} + +test('addon export table matches snapshot', (t) => { + const current = extractAddonExports(); + if (!current) { + t.skip('slopsmith_audio.node not built'); + return; + } + assert.deepStrictEqual(current, loadSnapshot('addon-exports.json')); +}); + +test('audio-bridge IPC channels match snapshot', () => { + assert.deepStrictEqual(extractIpcChannels(), loadSnapshot('ipc-channels.json')); +}); + +test('preload audio/audioEffects API keys match snapshot', () => { + assert.deepStrictEqual(extractPreloadApi(), loadSnapshot('preload-audio-api.json')); +}); diff --git a/tests/contracts/addon-exports.json b/tests/contracts/addon-exports.json new file mode 100644 index 0000000..a4b7e17 --- /dev/null +++ b/tests/contracts/addon-exports.json @@ -0,0 +1,108 @@ +[ + "acquireMonitorMuteHold", + "addSource", + "bindInputDevice", + "clearChain", + "clearStreamOutput", + "closePluginEditor", + "detectNotes", + "enableFileLogging", + "getBackingDuration", + "getBackingLevel", + "getBackingPosition", + "getBufferSizes", + "getChainGeneration", + "getChainState", + "getCurrentDevice", + "getDeviceMetrics", + "getDeviceTypes", + "getKnownPlugins", + "getLatencyBreakdown", + "getLevels", + "getMonitorMuteState", + "getNoteVerdicts", + "getParameters", + "getPitchDetection", + "getRawAudioFrame", + "getRawPitchDetection", + "getRendererBusMetrics", + "getSampleRate", + "getSampleRates", + "getSourceLevels", + "getSourceNoteVerdicts", + "getSourcePitchDetection", + "getSourceRawAudioFrame", + "getSourceRawPitchDetection", + "getStreamOverflowCount", + "getStreamSinkLevel", + "getStreamUnderflowCount", + "init", + "isAudioRunning", + "isBackingPlaying", + "isMlNoteDetection", + "isMonitorMuted", + "isStreamOutputActive", + "listInputDevices", + "listSources", + "loadBackingTrack", + "loadIR", + "loadNAMModel", + "loadNoteModel", + "loadPluginList", + "loadPreset", + "loadVST", + "moveProcessor", + "openPluginEditor", + "probeDeviceOptions", + "pushRendererAudio", + "releaseMonitorMuteHold", + "removeProcessor", + "removeSource", + "replaceIR", + "resetPeaks", + "savePluginList", + "savePreset", + "scanPlugins", + "scoreChord", + "scoreSourceChord", + "seekBacking", + "sendMidiToSlot", + "setBackingSpeed", + "setBranch", + "setBranchSrc", + "setBypass", + "setChart", + "setCrashedPlugins", + "setDevice", + "setDeviceType", + "setGain", + "setInputChannel", + "setInputDeviceType", + "setMonitorKill", + "setMonitorMute", + "setMonitorMuteSuppressed", + "setMultiBypass", + "setNoiseGate", + "setNoteDetectionEnabled", + "setOutputDeviceType", + "setPan", + "setParameter", + "setPostGain", + "setRendererBus", + "setSlotState", + "setSourceChart", + "setSourceInputChannel", + "setSourceMonitorMute", + "setSourceVerifierOffset", + "setStreamBus", + "setStreamBusGain", + "setStreamOutputDevice", + "setTonePolish", + "setVstCrashSentinelPath", + "shutdown", + "startAudio", + "startBacking", + "stopAudio", + "stopBacking", + "unbindInputDevice" +] diff --git a/tests/contracts/extract.js b/tests/contracts/extract.js new file mode 100644 index 0000000..b4ada84 --- /dev/null +++ b/tests/contracts/extract.js @@ -0,0 +1,86 @@ +// Contract-surface extraction for the audio engine TLC refactor (Phase 0.a, +// docs/audio-engine-tlc.md §4). Each extractor returns a sorted, stable JSON +// snapshot of one public surface. contract-check.test.js diffs these against +// the committed snapshots so a decomposition phase cannot silently change the +// public API. Regenerate deliberately with: node tests/contracts/extract.js +'use strict'; + +const fs = require('fs'); +const path = require('path'); + +const repoRoot = path.join(__dirname, '..', '..'); + +// Export table of slopsmith_audio.node. Loads the real binary; returns null +// when it hasn't been built (contract-check skips with a warning then). +function extractAddonExports() { + const addonPath = path.join(repoRoot, 'build', 'Release', 'slopsmith_audio.node'); + if (!fs.existsSync(addonPath)) return null; + const addon = require(addonPath); + return Object.keys(addon).sort(); +} + +// Every ipcMain.handle / ipcMain.on channel registered in audio-bridge.ts. +function extractIpcChannels() { + const src = fs.readFileSync(path.join(repoRoot, 'src', 'main', 'audio-bridge.ts'), 'utf8'); + const channels = new Set(); + const re = /ipcMain\.(?:handle|on)\(\s*'([^']+)'/g; + let m; + while ((m = re.exec(src)) !== null) channels.add(m[1]); + return [...channels].sort(); +} + +// Top-level method keys of the `audio:` and `audioEffects:` object literals in +// preload.ts — the surface the renderer (and every plugin) programs against. +// Brace-depth walk: record keys only at depth 1 inside the target literal. +function extractPreloadKeys(objectName, src) { + const start = src.indexOf(`${objectName}: {`); + if (start === -1) throw new Error(`preload.ts: '${objectName}: {' not found`); + let i = src.indexOf('{', start); + let depth = 0; + let parenDepth = 0; // multi-line parameter lists must not yield keys + const keys = []; + let lineStart = i; + for (; i < src.length; i++) { + const c = src[i]; + if (c === '{') depth++; + else if (c === '}') { + depth--; + if (depth === 0) break; + } else if (c === '(') parenDepth++; + else if (c === ')') parenDepth--; + else if (c === '\n') { + lineStart = i + 1; + } else if (depth === 1 && parenDepth === 0) { + // At a key position: line begins (after whitespace) with `name:` + if (i === lineStart) { + const line = src.slice(lineStart, src.indexOf('\n', lineStart)); + const km = line.match(/^\s*([A-Za-z_$][\w$]*)\s*:/); + if (km) keys.push(km[1]); + } + } + } + return keys.sort(); +} + +function extractPreloadApi() { + const src = fs.readFileSync(path.join(repoRoot, 'src', 'main', 'preload.ts'), 'utf8'); + return { + audio: extractPreloadKeys('audio', src), + audioEffects: extractPreloadKeys('audioEffects', src), + }; +} + +function writeSnapshot(name, data) { + fs.writeFileSync(path.join(__dirname, name), JSON.stringify(data, null, 2) + '\n'); +} + +module.exports = { extractAddonExports, extractIpcChannels, extractPreloadApi }; + +if (require.main === module) { + const addonExports = extractAddonExports(); + if (addonExports) writeSnapshot('addon-exports.json', addonExports); + else console.warn('addon not built — skipping addon-exports.json'); + writeSnapshot('ipc-channels.json', extractIpcChannels()); + writeSnapshot('preload-audio-api.json', extractPreloadApi()); + console.log('contract snapshots written to tests/contracts/'); +} diff --git a/tests/contracts/ipc-channels.json b/tests/contracts/ipc-channels.json new file mode 100644 index 0000000..9b9dee0 --- /dev/null +++ b/tests/contracts/ipc-channels.json @@ -0,0 +1,107 @@ +[ + "audio-effects:activateSegment", + "audio-effects:inspectRoute", + "audio-effects:loadChainPlan", + "audio-effects:releaseRoute", + "audio-effects:setRouteGain", + "audio-effects:setStageBypass", + "audio-effects:setStageParameter", + "audio:addSource", + "audio:bindInputDevice", + "audio:clearChain", + "audio:clearStreamOutput", + "audio:closePluginEditor", + "audio:detectNotes", + "audio:getBackingDuration", + "audio:getBackingLevel", + "audio:getBackingPosition", + "audio:getBufferSizes", + "audio:getChainState", + "audio:getCurrentDevice", + "audio:getDeviceMetrics", + "audio:getDeviceTypes", + "audio:getKnownPlugins", + "audio:getLevels", + "audio:getNoteVerdicts", + "audio:getParameters", + "audio:getPitchDetection", + "audio:getRawAudioFrame", + "audio:getRawPitch", + "audio:getRendererBusMetrics", + "audio:getSampleRate", + "audio:getSampleRates", + "audio:getSourceLevels", + "audio:getSourceNoteVerdicts", + "audio:getSourcePitchDetection", + "audio:getSourceRawAudioFrame", + "audio:getSourceRawPitch", + "audio:getStreamOverflowCount", + "audio:getStreamSinkLevel", + "audio:getStreamUnderflowCount", + "audio:isAudioRunning", + "audio:isAvailable", + "audio:isBackingPlaying", + "audio:isMlNoteDetection", + "audio:isMonitorMuted", + "audio:isStreamOutputActive", + "audio:listInputDevices", + "audio:listSources", + "audio:loadBackingTrack", + "audio:loadDeviceSettings", + "audio:loadIR", + "audio:loadNAMModel", + "audio:loadPluginList", + "audio:loadPreset", + "audio:loadVST", + "audio:moveProcessor", + "audio:openPluginEditor", + "audio:probeDeviceOptions", + "audio:pushRendererAudio", + "audio:removeProcessor", + "audio:removeSource", + "audio:replaceIR", + "audio:resetPeaks", + "audio:saveDeviceSettings", + "audio:savePluginList", + "audio:savePreset", + "audio:scanPlugins", + "audio:scoreChord", + "audio:scoreSourceChord", + "audio:seekBacking", + "audio:sendMidiToSlot", + "audio:setBackingSpeed", + "audio:setBranch", + "audio:setBranchSrc", + "audio:setBypass", + "audio:setChart", + "audio:setDevice", + "audio:setDeviceType", + "audio:setGain", + "audio:setInputChannel", + "audio:setMonitorKill", + "audio:setMonitorMute", + "audio:setMonitorMuteSuppressed", + "audio:setMultiBypass", + "audio:setNoiseGate", + "audio:setNoteDetectionEnabled", + "audio:setOutputDeviceType", + "audio:setPan", + "audio:setParameter", + "audio:setPostGain", + "audio:setRendererBus", + "audio:setSlotState", + "audio:setSourceChart", + "audio:setSourceInputChannel", + "audio:setSourceMonitorMute", + "audio:setSourceVerifierOffset", + "audio:setStreamBus", + "audio:setStreamBusGain", + "audio:setStreamOutputDevice", + "audio:setTonePolish", + "audio:startAudio", + "audio:startBacking", + "audio:stopAudio", + "audio:stopBacking", + "audio:unbindInputDevice", + "debug:isEnabled" +] diff --git a/tests/contracts/preload-audio-api.json b/tests/contracts/preload-audio-api.json new file mode 100644 index 0000000..50e124c --- /dev/null +++ b/tests/contracts/preload-audio-api.json @@ -0,0 +1,112 @@ +{ + "audio": [ + "addSource", + "bindInputDevice", + "clearChain", + "clearStreamOutput", + "closePluginEditor", + "debugEnabled", + "detectNotes", + "getBackingDuration", + "getBackingLevel", + "getBackingPosition", + "getBufferSizes", + "getChainState", + "getCurrentDevice", + "getDeviceMetrics", + "getDeviceTypes", + "getKnownPlugins", + "getLevels", + "getNoteVerdicts", + "getParameters", + "getPitchDetection", + "getRawAudioFrame", + "getRawPitch", + "getRendererBusMetrics", + "getSampleRate", + "getSampleRates", + "getSourceLevels", + "getSourceNoteVerdicts", + "getSourcePitchDetection", + "getSourceRawAudioFrame", + "getSourceRawPitch", + "getStreamOverflowCount", + "getStreamSinkLevel", + "getStreamUnderflowCount", + "isAudioRunning", + "isAvailable", + "isBackingPlaying", + "isMlNoteDetection", + "isMonitorMuted", + "isStreamOutputActive", + "listInputDevices", + "listSources", + "loadBackingTrack", + "loadDeviceSettings", + "loadIR", + "loadNAMModel", + "loadPluginList", + "loadPreset", + "loadVST", + "moveProcessor", + "openPluginEditor", + "probeDeviceOptions", + "pushRendererAudio", + "removeProcessor", + "removeSource", + "replaceIR", + "resetPeaks", + "saveDeviceSettings", + "savePluginList", + "savePreset", + "scanPlugins", + "scoreChord", + "scoreSourceChord", + "seekBacking", + "sendMidiToSlot", + "setBackingSpeed", + "setBranch", + "setBranchSrc", + "setBypass", + "setChart", + "setDevice", + "setDeviceType", + "setGain", + "setInputChannel", + "setMonitorKill", + "setMonitorMute", + "setMonitorMuteSuppressed", + "setMultiBypass", + "setNoiseGate", + "setNoteDetectionEnabled", + "setOutputDeviceType", + "setPageMuted", + "setPan", + "setParameter", + "setPostGain", + "setRendererBus", + "setSlotState", + "setSourceChart", + "setSourceInputChannel", + "setSourceMonitorMute", + "setSourceVerifierOffset", + "setStreamBus", + "setStreamBusGain", + "setStreamOutputDevice", + "setTonePolish", + "startAudio", + "startBacking", + "stopAudio", + "stopBacking", + "unbindInputDevice" + ], + "audioEffects": [ + "activateSegment", + "inspectRoute", + "loadChainPlan", + "releaseRoute", + "setRouteGain", + "setStageBypass", + "setStageParameter" + ] +} diff --git a/tests/engine_units/CMakeLists.txt b/tests/engine_units/CMakeLists.txt new file mode 100644 index 0000000..b838a8f --- /dev/null +++ b/tests/engine_units/CMakeLists.txt @@ -0,0 +1,26 @@ +# engine_units — home for the per-unit tests of the audio-engine decomposition +# (docs/audio-engine-tlc.md Part IV §4/0.c). JUCE-free targets only: units are +# extracted so they can be tested against a state struct + fake ring, without a +# real device. One executable per unit keeps failures attributable. +add_executable(gain_sanitize_test gain_sanitize_test.cpp) +target_compile_features(gain_sanitize_test PRIVATE cxx_std_17) +add_test(NAME gain_sanitize COMMAND gain_sanitize_test) + +# PackedStereoRing uses std::bit_cast (C++20) and std::thread. +add_executable(packed_stereo_ring_test packed_stereo_ring_test.cpp) +target_compile_features(packed_stereo_ring_test PRIVATE cxx_std_20) +find_package(Threads REQUIRED) +target_link_libraries(packed_stereo_ring_test PRIVATE Threads::Threads) +add_test(NAME packed_stereo_ring COMMAND packed_stereo_ring_test) + +add_executable(engine_state_test engine_state_test.cpp) +target_compile_features(engine_state_test PRIVATE cxx_std_17) +add_test(NAME engine_state COMMAND engine_state_test) + +add_executable(renderer_bus_test renderer_bus_test.cpp) +target_compile_features(renderer_bus_test PRIVATE cxx_std_20) +add_test(NAME renderer_bus COMMAND renderer_bus_test) + +add_executable(rate_match_test rate_match_test.cpp) +target_compile_features(rate_match_test PRIVATE cxx_std_17) +add_test(NAME rate_match COMMAND rate_match_test) diff --git a/tests/engine_units/engine_state_test.cpp b/tests/engine_units/engine_state_test.cpp new file mode 100644 index 0000000..4b5e882 --- /dev/null +++ b/tests/engine_units/engine_state_test.cpp @@ -0,0 +1,53 @@ +// Phase 1 unit test for EngineState (docs/audio-engine-tlc.md §5): the +// intent/state transition table. Mirrors how AudioEngine drives the two +// flags — startAudio/stopAudio write BOTH (intent + state), the device +// callbacks write deviceRunning ONLY — and pins the Phase 0.b compat +// decision that isAudioRunning() reports DEVICE STATE: a transient device +// stop flips it false even though the user never pressed Stop. + +#include "../../src/audio/engine/EngineState.h" + +#include +#include + +using slopsmith::EngineState; + +// The write sets, as AudioEngine performs them. +static void userStart(EngineState& s) { s.userWantsAudio.store(true); s.deviceRunning.store(true); } +static void userStop(EngineState& s) { s.userWantsAudio.store(false); s.deviceRunning.store(false); } +static void deviceAboutToStart(EngineState& s) { s.deviceRunning.store(true); } +static void deviceStopped(EngineState& s) { s.deviceRunning.store(false); } +// isAudioRunning() facade == deviceRunning (compat pin). +static bool isAudioRunning(const EngineState& s) { return s.deviceRunning.load(); } + +int main() +{ + EngineState s; + assert(!s.userWantsAudio.load() && !isAudioRunning(s)); + + // User starts audio. + userStart(s); + assert(s.userWantsAudio.load() && isAudioRunning(s)); + + // Transient device stop (WASAPI exclusive mid-start hiccup): device state + // drops, intent survives — this is the split that fixes deep-read §3. + deviceStopped(s); + assert(s.userWantsAudio.load() && "transient stop must not erase user intent"); + assert(!isAudioRunning(s) && "compat pin: isAudioRunning reports device state"); + + // JUCE auto-restart brings the device back without user action. + deviceAboutToStart(s); + assert(s.userWantsAudio.load() && isAudioRunning(s)); + + // Explicit user stop clears both. + userStop(s); + assert(!s.userWantsAudio.load() && !isAudioRunning(s)); + + // A stray device start (auto-restart after user stop) must not fabricate + // intent: device state true, intent still false. + deviceAboutToStart(s); + assert(!s.userWantsAudio.load() && isAudioRunning(s)); + + std::puts("engine_state: all transitions passed"); + return 0; +} diff --git a/tests/engine_units/gain_sanitize_test.cpp b/tests/engine_units/gain_sanitize_test.cpp new file mode 100644 index 0000000..6747bd4 --- /dev/null +++ b/tests/engine_units/gain_sanitize_test.cpp @@ -0,0 +1,48 @@ +// Pins the Phase 0.b compat decision (docs/audio-engine-tlc.md §4): native +// gain clamp bounds are 0..32 — matching the audio-effects executor's JS-side +// clampGain so a legit high rig gain is never under-shot — with NaN/Inf +// rejected universally; stream/renderer-bus gains keep the tighter 0..8. + +#include "../../src/audio/GainSanitize.h" + +#include +#include +#include +#include + +int main() +{ + using slopsmith::sanitizeMasterGain; + using slopsmith::sanitizeStreamGain; + + const float nan = std::numeric_limits::quiet_NaN(); + const float inf = std::numeric_limits::infinity(); + + struct Case { float in, master, stream; }; + const Case cases[] = { + { 0.0f, 0.0f, 0.0f }, + { 1.0f, 1.0f, 1.0f }, + { 8.0f, 8.0f, 8.0f }, + { 8.5f, 8.5f, 8.0f }, // executor range beyond the stream clamp + { 32.0f, 32.0f, 8.0f }, // upper compat bound must not under-shoot + { 33.0f, 32.0f, 8.0f }, + { 1e9f, 32.0f, 8.0f }, + { -1.0f, 0.0f, 0.0f }, + { -0.0f, 0.0f, 0.0f }, + { nan, 0.0f, 0.0f }, // non-finite → silence, never a poisoned mix + { inf, 0.0f, 0.0f }, + { -inf, 0.0f, 0.0f }, + }; + + for (const auto& c : cases) + { + const float m = sanitizeMasterGain(c.in); + const float s = sanitizeStreamGain(c.in); + assert(std::isfinite(m) && std::isfinite(s)); + assert(m == c.master); + assert(s == c.stream); + } + + std::puts("gain_sanitize: all cases passed"); + return 0; +} diff --git a/tests/engine_units/packed_stereo_ring_test.cpp b/tests/engine_units/packed_stereo_ring_test.cpp new file mode 100644 index 0000000..a83a651 --- /dev/null +++ b/tests/engine_units/packed_stereo_ring_test.cpp @@ -0,0 +1,171 @@ +// Phase 1 unit tests for PackedStereoRing (docs/audio-engine-tlc.md §5): +// pack/unpack round-trip, wrap + drop-oldest lap, w +#include +#include +#include +#include +#include + +using slopsmith::PackedStereoRing; +using slopsmith::packLR; +using slopsmith::unpackLR; + +static void testPackRoundTrip() +{ + const float values[] = { 0.0f, -0.0f, 1.0f, -1.0f, 3.14159f, 1e-30f, -1e30f }; + for (float l : values) + for (float r : values) + { + float ol, orr; + unpackLR(packLR(l, r), ol, orr); + // Bit-exact round trip (including -0.0f). + assert(std::memcmp(&ol, &l, 4) == 0 && std::memcmp(&orr, &r, 4) == 0); + } +} + +static void testPushPullBasic() +{ + PackedStereoRing<64> ring; + float L[16], R[16]; + for (int i = 0; i < 16; ++i) { L[i] = (float) i; R[i] = (float) -i; } + ring.push(L, R, 16); + + uint64_t r = ring.readIndex.load(); + const uint64_t w = ring.writeIndex.load(); + assert(w - r == 16); + for (int i = 0; i < 16; ++i) + { + float l, rr; + ring.readFrame(r + (uint64_t) i, l, rr); + assert(l == (float) i && rr == (float) -i); + } + ring.commitRead(r + 16); + assert(ring.writeIndex.load() - ring.readIndex.load() == 0); +} + +static void testLapCatchUp() +{ + PackedStereoRing<64> ring; + float L[64], R[64]; + // Push 3 laps' worth without consuming: consumer must catch up to newest + // full ring, exactly once per drain regardless of how far it was lapped. + for (int block = 0; block < 3; ++block) + { + for (int i = 0; i < 64; ++i) { L[i] = (float) (block * 64 + i); R[i] = 0.0f; } + ring.push(L, R, 64); + } + uint64_t r = ring.readIndex.load(); + const uint64_t w = ring.writeIndex.load(); + assert(w - r == 192); + const bool lapped = ring.catchUpIfLapped(r, w); + assert(lapped); + assert(w - r == 64); // newest full ring only + float l, rr; + ring.readFrame(r, l, rr); + assert(l == 128.0f); // oldest surviving frame = start of last lap + // Not lapped anymore: second call is a no-op. + assert(!ring.catchUpIfLapped(r, w)); +} + +static void testResyncAfterReset() +{ + PackedStereoRing<64> ring; + float L[32] = {}, R[32] = {}; + ring.push(L, R, 32); + ring.commitRead(20); + uint64_t r = ring.readIndex.load(); + // A stop raced in and reset the indices; consumer still holds r == 20. + ring.resetIndices(); + const uint64_t w = ring.writeIndex.load(); + ring.resyncIfIndicesReset(r, w); + assert(r == 0 && w == 0); // treated as empty, no wrapped (w - r) monster +} + +// Producer at one block size laps a slower consumer at another; every frame +// the consumer reads must have L == -R (the producer invariant), proving the +// packed single-atomic store never tears a frame. +static void testConcurrentTearFreedom() +{ + PackedStereoRing<256> ring; + std::atomic stop{false}; + std::atomic laps{0}; + + std::thread producer([&] { + float L[48], R[48]; + uint64_t n = 0; + while (!stop.load(std::memory_order_relaxed)) + { + for (int i = 0; i < 48; ++i) + { + const float v = (float) ((n + (uint64_t) i) & 0xFFFFF); + L[i] = v; R[i] = -v; + } + ring.push(L, R, 48); + n += 48; + } + }); + + uint64_t checked = 0; + while (checked < 2'000'000) + { + uint64_t r = ring.readIndex.load(std::memory_order_relaxed); + const uint64_t w = ring.writeIndex.load(std::memory_order_acquire); + ring.resyncIfIndicesReset(r, w); + if (ring.catchUpIfLapped(r, w)) laps.fetch_add(1); + const uint64_t avail = w - r; + const int pull = (int) (avail < 32 ? avail : 32); + for (int i = 0; i < pull; ++i) + { + float l, rr; + ring.readFrame(r + (uint64_t) i, l, rr); + assert(l == -rr && "L/R tear: channels from different frames"); + } + ring.commitRead(r + (uint64_t) pull); + checked += (uint64_t) pull; + } + stop.store(true); + producer.join(); + std::printf("packed_stereo_ring: tear-check ok (%llu frames, %llu laps)\n", + (unsigned long long) checked, (unsigned long long) laps.load()); + assert(laps.load() > 0 && "stress never lapped — laps path untested, tune sizes"); +} + +// The split output path pulls min(outSamples, avail) into scratch but +// consumes min(numSamples, avail) so a scratch-clamped block doesn't +// accumulate ring/output-clock skew. Pin that index arithmetic. +static void testPullVsConsumeSkew() +{ + PackedStereoRing<64> ring; + float L[40], R[40]; + for (int i = 0; i < 40; ++i) { L[i] = (float) i; R[i] = 0.0f; } + ring.push(L, R, 40); + + const int numSamples = 40; // device block + const int outSamples = 32; // scratch-clamped + uint64_t r = ring.readIndex.load(); + const uint64_t w = ring.writeIndex.load(); + const uint64_t avail = w - r; + const int pull = (int) (avail < (uint64_t) outSamples ? avail : (uint64_t) outSamples); + const int consume = (int) (avail < (uint64_t) numSamples ? avail : (uint64_t) numSamples); + assert(pull == 32 && consume == 40); + ring.commitRead(r + (uint64_t) consume); + assert(ring.writeIndex.load() - ring.readIndex.load() == 0); // no skew left queued +} + +int main() +{ + testPackRoundTrip(); + testPushPullBasic(); + testLapCatchUp(); + testResyncAfterReset(); + testPullVsConsumeSkew(); + testConcurrentTearFreedom(); + std::puts("packed_stereo_ring: all cases passed"); + return 0; +} diff --git a/tests/engine_units/rate_match_test.cpp b/tests/engine_units/rate_match_test.cpp new file mode 100644 index 0000000..e9c9293 --- /dev/null +++ b/tests/engine_units/rate_match_test.cpp @@ -0,0 +1,46 @@ +// Phase 4 unit tests (docs/audio-engine-tlc.md §5): the rate-tolerance and +// midpoint-rounding boundary cases the three previously hand-synced sites in +// AudioEngine.cpp narrated in comments, now pinned against the one shared +// implementation in engine/RateMatch.h. + +#include "../../src/audio/engine/RateMatch.h" + +#include +#include + +using slopsmith::ratesMatch; +using slopsmith::nominalRateCandidate; + +int main() +{ + // Tolerance is <= 0.5 (not <): a backend reporting 47999.5 against a + // 48000 nominal sits exactly on the boundary and MUST pass — the probe + // accepted it, so preflight and post-open verify must too. + assert(ratesMatch(47999.5, 48000.0)); + assert(ratesMatch(48000.0, 47999.5)); + assert(ratesMatch(48000.0, 48000.0)); + assert(!ratesMatch(47999.4, 48000.0)); // 0.6 apart → reject + assert(!ratesMatch(44100.0, 48000.0)); + + double c = 0.0; + + // Exact pair → exact nominal. + assert(nominalRateCandidate(48000.0, 48000.0, c) && c == 48000.0); + + // Fractional drift on both sides rounds to the clean nominal. + assert(nominalRateCandidate(47999.5, 48000.0, c) && c == 48000.0); + assert(nominalRateCandidate(48000.4, 48000.1, c) && c == 48000.0); + + // Fail-closed midpoint case from the original comment: 48000.4/48000.6 + // passes the pair check (diff 0.2) but rounds to 48001 (midpoint 48000.5 + // rounds up), which is 0.6 from 48000.4 — outside tolerance of one side, + // so no candidate is surfaced. + const bool ok = nominalRateCandidate(48000.4, 48000.6, c); + assert(!ok && "midpoint-rounding must stay fail-closed"); + + // Non-matching pair → no candidate at all. + assert(!nominalRateCandidate(44100.0, 48000.0, c)); + + std::puts("rate_match: all cases passed"); + return 0; +} diff --git a/tests/engine_units/renderer_bus_test.cpp b/tests/engine_units/renderer_bus_test.cpp new file mode 100644 index 0000000..b26f8f9 --- /dev/null +++ b/tests/engine_units/renderer_bus_test.cpp @@ -0,0 +1,237 @@ +// Phase 2 unit tests for RendererBus (docs/audio-engine-tlc.md §5): +// resampler continuity across pushes, equal-rate bit-exactness, the prime +// gate, underflow → silence + re-prime, fill clamp, and metrics arithmetic. +// The flush-on-disable test flips once the phase-8 flush-flag fix lands. + +#include "../../src/audio/engine/RendererBus.h" + +#include +#include +#include +#include +#include + +using slopsmith::RendererBus; + +static std::vector rampChunk(int frames, float start, float step) +{ + std::vector v((size_t) frames * 2); + for (int i = 0; i < frames; ++i) + { + v[(size_t) i * 2] = start + step * (float) i; + v[(size_t) i * 2 + 1] = -(start + step * (float) i); + } + return v; +} + +// Equal rates degenerate to step == 1.0 — frames must come out bit-exact +// (minus the one-frame interpolation carry at each chunk boundary). +static void testEqualRateBitExact() +{ + RendererBus bus; + bus.setEnabled(true, 1.0f); + const auto c1 = rampChunk(512, 0.0f, 1.0f); + const auto c2 = rampChunk(512, 512.0f, 1.0f); + assert(bus.push(c1.data(), 512, 48000.0, 48000.0)); + assert(bus.push(c2.data(), 512, 48000.0, 48000.0)); + + std::vector dl(512), dr(512); + assert(bus.pull(dl.data(), dr.data(), 512) == 512); + for (int i = 0; i < 512; ++i) + { + // First chunk's frame 0 is consumed as interpolation carry (pos + // starts at 0 with prev=0 carry → exact frame i lands at output i). + assert(dl[(size_t) i] == (float) i && dr[(size_t) i] == -(float) i); + } +} + +// Downsampling 2:1 across a chunk seam must be continuous: the interpolated +// ramp has no discontinuity where one push ends and the next begins. +static void testResampleContinuityAcrossPushes() +{ + RendererBus bus; + bus.setEnabled(true, 1.0f); + const double src = 96000.0, dev = 48000.0; + // Two chunks big enough that the 2:1 output (~1023 frames) clears the + // prime gate; the seam sits at output frame ~512. + const auto c1 = rampChunk(1024, 0.0f, 1.0f); + const auto c2 = rampChunk(1024, 1024.0f, 1.0f); + bus.push(c1.data(), 1024, src, dev); + bus.push(c2.data(), 1024, src, dev); + + std::vector dl(768), dr(768); + assert(bus.pull(dl.data(), dr.data(), 768) == 768); + for (int i = 1; i < 768; ++i) + { + const float d = dl[(size_t) i] - dl[(size_t) i - 1]; + // A linear ramp resampled 2:1 must step by ~2 everywhere, including + // across the seam at output frame ~128. + assert(std::fabs(d - 2.0f) < 1e-3f && "discontinuity at chunk seam"); + } +} + +// Prime gate: nothing comes out until ~kPrimeFrames are buffered. +static void testPrimeGate() +{ + RendererBus bus; + bus.setEnabled(true, 1.0f); + std::vector dl(64), dr(64); + const auto tiny = rampChunk(RendererBus::kPrimeFrames / 2, 1.0f, 0.0f); + bus.push(tiny.data(), RendererBus::kPrimeFrames / 2, 48000.0, 48000.0); + assert(bus.pull(dl.data(), dr.data(), 64) == 0 && "must gate until primed"); + bus.push(tiny.data(), RendererBus::kPrimeFrames / 2, 48000.0, 48000.0); + // Cushion built (minus the 1-frame carry per push) — next pull flows. + bus.push(tiny.data(), RendererBus::kPrimeFrames / 2, 48000.0, 48000.0); + assert(bus.pull(dl.data(), dr.data(), 64) == 64); +} + +// Underflow: whole-block silence, buffered tail dropped, back to priming. +static void testUnderflowReprimes() +{ + RendererBus bus; + bus.setEnabled(true, 1.0f); + const auto chunk = rampChunk(RendererBus::kPrimeFrames + 64, 1.0f, 0.0f); + bus.push(chunk.data(), RendererBus::kPrimeFrames + 64, 48000.0, 48000.0); + std::vector dl(512), dr(512); + assert(bus.pull(dl.data(), dr.data(), 512) == 512); + // Ring now nearly empty → this pull underflows. + assert(bus.pull(dl.data(), dr.data(), 512) == 0); + assert(bus.metrics().underflowCount == 1); + // And the gate re-armed: a sub-prime refill still gates. + const auto tiny = rampChunk(64, 1.0f, 0.0f); + bus.push(tiny.data(), 64, 48000.0, 48000.0); + assert(bus.pull(dl.data(), dr.data(), 32) == 0 && "must re-prime after underflow"); +} + +// Fill clamp: a dumped backlog beyond kMaxFillFrames is trimmed to the prime +// target instead of being played ~85 ms late. +static void testFillClampTrimsBacklog() +{ + RendererBus bus; + bus.setEnabled(true, 1.0f); + const int backlog = RendererBus::kMaxFillFrames + 2048; + const auto chunk = rampChunk(backlog + 1, 1.0f, 0.0f); + bus.push(chunk.data(), backlog + 1, 48000.0, 48000.0); + std::vector dl(256), dr(256); + assert(bus.pull(dl.data(), dr.data(), 256) == 256); + const auto m = bus.metrics(); + assert(m.overflowCount == 1 && "fill clamp must count as overflow"); + assert(m.fillFrames <= RendererBus::kPrimeFrames && "backlog must be trimmed to prime target"); +} + +// Disabled bus: push and pull are inert. +static void testDisabledIsInert() +{ + RendererBus bus; + const auto chunk = rampChunk(128, 1.0f, 0.0f); + assert(!bus.push(chunk.data(), 128, 48000.0, 48000.0)); + std::vector dl(64), dr(64); + assert(bus.pull(dl.data(), dr.data(), 64) == 0); + assert(!bus.metrics().enabled); +} + +// Gain is applied consumer-side and sanitized (0..8, non-finite → 0). +static void testGainApplied() +{ + RendererBus bus; + bus.setEnabled(true, 2.0f); + const auto chunk = rampChunk(RendererBus::kPrimeFrames + 65, 1.0f, 0.0f); + bus.push(chunk.data(), RendererBus::kPrimeFrames + 65, 48000.0, 48000.0); + std::vector dl(64), dr(64); + assert(bus.pull(dl.data(), dr.data(), 64) == 64); + assert(dl[0] == 2.0f && dr[0] == -2.0f); +} + +// Disable drops the buffered tail — via the consumer-honored flush flag +// (deep-read §4 fix), so a re-enable never replays stale audio. +static void testFlushOnDisable() +{ + RendererBus bus; + bus.setEnabled(true, 1.0f); + const auto chunk = rampChunk(RendererBus::kPrimeFrames * 2, 5.0f, 0.0f); + bus.push(chunk.data(), RendererBus::kPrimeFrames * 2, 48000.0, 48000.0); + bus.setEnabled(false, 1.0f); // requests the flush; consumer performs it + bus.setEnabled(true, 1.0f); + std::vector dl(64), dr(64); + // First pull consumes the flush: the pre-disable tail is gone, so the bus + // is empty and (re-)priming — nothing plays. + assert(bus.pull(dl.data(), dr.data(), 64) == 0 && "stale tail must not replay"); + assert(bus.metrics().fillFrames == 0 && "flush must drop the buffered tail"); + // Fresh audio after the re-enable flows once primed. + const auto fresh = rampChunk(RendererBus::kPrimeFrames + 65, 7.0f, 0.0f); + bus.push(fresh.data(), RendererBus::kPrimeFrames + 65, 48000.0, 48000.0); + assert(bus.pull(dl.data(), dr.data(), 64) == 64); + // Frame 0 is the resampler's one-frame interpolation carry (by design); + // everything after must be the fresh push, not the flushed 5.0 tail. + assert(dl[1] == 7.0f && "post-re-enable audio must be the fresh push"); +} + +// A pending flush must drop the STALE tail only. If no output callback runs +// between the disable and a re-enable (stopped device, device swap), the +// flush is still pending when fresh audio arrives — flushing to the live +// writeIndex at that point would discard the re-enabled bus's first frames +// too, silencing it until it re-primed. The flush target is snapshotted at +// disable time instead. +static void testFlushSparesPostReEnableAudio() +{ + RendererBus bus; + bus.setEnabled(true, 1.0f); + const auto stale = rampChunk(RendererBus::kPrimeFrames * 2, 5.0f, 0.0f); + bus.push(stale.data(), RendererBus::kPrimeFrames * 2, 48000.0, 48000.0); + + // Disable + re-enable with NO pull in between: the flush is still pending. + bus.setEnabled(false, 1.0f); + bus.setEnabled(true, 1.0f); + + // Fresh audio pushed while the flush is still pending must survive it. + const auto fresh = rampChunk(RendererBus::kPrimeFrames + 65, 7.0f, 0.0f); + bus.push(fresh.data(), RendererBus::kPrimeFrames + 65, 48000.0, 48000.0); + + std::vector dl(64), dr(64); + assert(bus.pull(dl.data(), dr.data(), 64) == 64 + && "fresh post-re-enable audio must not be flushed away with the stale tail"); + // Frame 0 is the resampler's one-frame interpolation carry (by design); + // everything after must be the fresh push, never the flushed 5.0 tail. + assert(dl[1] == 7.0f && "flush must drop only the pre-disable tail"); +} + +// Rate validation (PR #107 review): non-finite rates cross the JS/IPC +// boundary; NaN passes a plain `<= 0` check, and a subnormal source rate can +// underflow step to 0 — both must be rejected before the resample loop. +// A bad sourceRate falls back to deviceRate (documented behaviour). +static void testRejectsUnusableRates() +{ + RendererBus bus; + bus.setEnabled(true, 1.0f); + const auto chunk = rampChunk(128, 1.0f, 0.0f); + const double nan = std::nan(""); + const double inf = std::numeric_limits::infinity(); + assert(!bus.push(chunk.data(), 128, 48000.0, nan)); + assert(!bus.push(chunk.data(), 128, 48000.0, inf)); + assert(!bus.push(chunk.data(), 128, 48000.0, -48000.0)); + assert(!bus.push(chunk.data(), 128, 48000.0, 0.0)); + // step underflow: denormal source over huge device rate → step == 0. + assert(!bus.push(chunk.data(), 128, 5e-324, 1e308)); + assert(bus.metrics().pushedFrames == 0 && "rejected pushes must stage nothing"); + // NaN/Inf/negative SOURCE rate falls back to deviceRate (step == 1). + assert(bus.push(chunk.data(), 128, nan, 48000.0)); + assert(bus.push(chunk.data(), 128, inf, 48000.0)); + assert(bus.push(chunk.data(), 128, -1.0, 48000.0)); + assert(bus.metrics().pushedFrames > 0); +} + +int main() +{ + testEqualRateBitExact(); + testRejectsUnusableRates(); + testResampleContinuityAcrossPushes(); + testPrimeGate(); + testUnderflowReprimes(); + testFillClampTrimsBacklog(); + testDisabledIsInert(); + testGainApplied(); + testFlushOnDisable(); + testFlushSparesPostReEnableAudio(); + std::puts("renderer_bus: all cases passed"); + return 0; +} diff --git a/tests/monitor-mute-suppression.test.js b/tests/monitor-mute-suppression.test.js new file mode 100644 index 0000000..a3799a4 --- /dev/null +++ b/tests/monitor-mute-suppression.test.js @@ -0,0 +1,174 @@ +// PR #107 review: the native monitor-mute arbiter REFCOUNTS suppressions +// (SourceChain::setMonitorMuteSuppressed — true = acquire, false = release), +// but it kept the old boolean signature. The renderer's rebuild guard is +// deliberately unpaired: resolveChainRebuildGuard() leaves the suppression on +// when a rebuild produced an empty chain, and returns early without releasing +// while a provider route is still resolving. Against the old LATCHED BOOL that +// was self-correcting (repeated trues were idempotent, any false reset it); +// against a refcount every unpaired call is a permanent +1, so after a couple +// of song loads the count can never return to zero and monitor mute is silently +// dead for the rest of the session. +// +// aeSetMonitorMuteSuppressed() therefore holds AT MOST ONE native suppression. +// These cases pin that, plus the rollback: the latch mirrors the native +// refcount, so it may only stay flipped if the IPC actually landed. + +'use strict'; + +const test = require('node:test'); +const assert = require('node:assert/strict'); +const path = require('node:path'); +const fs = require('node:fs'); +const vm = require('node:vm'); + +const ROOT = path.join(__dirname, '..'); +const SCREEN_JS = fs.readFileSync(path.join(ROOT, 'src', 'renderer', 'screen.js'), 'utf8'); + +function extractFunction(src, name) { + const sig = `function ${name}(`; + const start = src.indexOf(sig); + assert.ok(start !== -1, `function '${name}' not found`); + let i = src.indexOf('{', src.indexOf(')', start)); + let depth = 1; + i++; + while (i < src.length && depth > 0) { + if (src[i] === '{') depth++; + else if (src[i] === '}') depth--; + i++; + } + assert.ok(depth === 0, `unbalanced braces in '${name}'`); + return src.slice(start, i); +} + +// Build a sandbox with the real function plus a fake native api that records +// every acquire/release and can be made to fail. `calls` is the ground truth +// for what the native refcount would have done. +function makeHarness({ mode = 'ok' } = {}) { + const calls = []; + const setMonitorMuteSuppressed = (suppressed) => { + if (mode === 'throw') { calls.push({ suppressed, outcome: 'threw' }); throw new Error('sync boom'); } + if (mode === 'reject') { + calls.push({ suppressed, outcome: 'rejected' }); + return Promise.reject(new Error('ipc boom')); + } + calls.push({ suppressed, outcome: 'ok' }); + return Promise.resolve(); + }; + const audio = mode === 'downlevel' ? {} : { setMonitorMuteSuppressed }; + const ctx = { + window: { feedBackDesktop: { audio } }, + // The native refcount, simulated: clamped at 0 exactly like SourceChain's + // compare_exchange loop, so an unpaired release can't underflow. + nativeCount: 0, + }; + vm.createContext(ctx); + vm.runInContext( + 'let aeMonitorMuteSuppressionHeld = false;\n' + + extractFunction(SCREEN_JS, 'aeSetMonitorMuteSuppressed') + + '\nglobalThis.__call = aeSetMonitorMuteSuppressed;' + + '\nglobalThis.__held = () => aeMonitorMuteSuppressionHeld;', + ctx, + ); + return { + calls, + set: (v) => ctx.__call(v), + held: () => ctx.__held(), + // Replay the recorded calls through the native refcount semantics. + nativeCount: () => calls.reduce((n, c) => { + if (c.outcome !== 'ok') return n; // never reached the engine + return c.suppressed ? n + 1 : Math.max(0, n - 1); + }, 0), + }; +} + +const flush = () => new Promise((r) => setImmediate(r)); + +test('repeated unpaired acquires hold at most ONE native suppression', async () => { + const h = makeHarness(); + // Three song loads whose guard never releases (empty-chain / provider-pending + // branches). Under a raw refcount this would be +3 and never recoverable. + h.set(true); h.set(true); h.set(true); + await flush(); + assert.equal(h.calls.filter((c) => c.suppressed).length, 1, 'only one acquire may reach the engine'); + assert.equal(h.nativeCount(), 1); + + // ...and one release still returns the count to zero, so monitor mute works. + h.set(false); + await flush(); + assert.equal(h.nativeCount(), 0, 'a single release must fully un-suppress'); + assert.equal(h.held(), false); +}); + +test('acquire/release cycles stay balanced across many song loads', async () => { + const h = makeHarness(); + for (let i = 0; i < 25; i++) { + h.set(true); // clearChainForNewSong + preload both call the guard + h.set(true); + await flush(); + h.set(false); // resolveChainRebuildGuard + await flush(); + } + assert.equal(h.nativeCount(), 0, 'refcount must not drift across sessions'); + assert.equal(h.held(), false); +}); + +test('a rejected release rolls the latch back so the next release retries', async () => { + // The bug this guards: if the latch flipped to "released" on an IPC that + // never landed, every later release would short-circuit while the native + // count stayed held — stuck suppression, one level up from the C++ leak. + const calls = []; + let failNext = false; + const ctx = { + window: { + feedBackDesktop: { + audio: { + setMonitorMuteSuppressed: (s) => { + if (failNext) { calls.push({ suppressed: s, outcome: 'rejected' }); return Promise.reject(new Error('boom')); } + calls.push({ suppressed: s, outcome: 'ok' }); + return Promise.resolve(); + }, + }, + }, + }, + }; + vm.createContext(ctx); + vm.runInContext( + 'let aeMonitorMuteSuppressionHeld = false;\n' + + extractFunction(SCREEN_JS, 'aeSetMonitorMuteSuppressed') + + '\nglobalThis.__call = aeSetMonitorMuteSuppressed;' + + '\nglobalThis.__held = () => aeMonitorMuteSuppressionHeld;', + ctx, + ); + + ctx.__call(true); // acquire lands: native = 1 + await flush(); + assert.equal(ctx.__held(), true); + + failNext = true; + ctx.__call(false); // release REJECTS: native still 1 + await flush(); + assert.equal(ctx.__held(), true, 'a failed release must not leave the latch "released"'); + + failNext = false; + ctx.__call(false); // retry must actually be attempted + await flush(); + const releases = calls.filter((c) => !c.suppressed); + assert.equal(releases.length, 2, 'the retry must reach the engine, not short-circuit'); + assert.equal(releases.at(-1).outcome, 'ok'); + assert.equal(ctx.__held(), false); +}); + +test('a downlevel addon without the arbiter is a clean no-op', async () => { + const h = makeHarness({ mode: 'downlevel' }); + assert.doesNotThrow(() => { h.set(true); h.set(false); }); + await flush(); + assert.equal(h.calls.length, 0); + assert.equal(h.held(), false, 'nothing was acquired, so nothing may be recorded as held'); +}); + +test('a synchronously throwing bridge rolls the latch back', async () => { + const h = makeHarness({ mode: 'throw' }); + assert.doesNotThrow(() => h.set(true)); + await flush(); + assert.equal(h.held(), false, 'a throw means nothing was acquired'); +}); diff --git a/tests/napi-arg-fuzz.test.js b/tests/napi-arg-fuzz.test.js new file mode 100644 index 0000000..3ac9f40 --- /dev/null +++ b/tests/napi-arg-fuzz.test.js @@ -0,0 +1,130 @@ +// Phase 6 gate (docs/audio-engine-tlc.md §5, deep-read §2): table-driven +// argument fuzz against the real addon. Every chain-mutating binding must +// treat NaN/Infinity/negative/string/missing/object arguments as a clean +// no-op — historically Int32Value() coerced NaN → 0 and mutated SLOT 0. +// Quarantined behind the addon being built (CI native lane), auto-skips +// otherwise. Uses no audio device (engine constructed, never started). +'use strict'; + +const test = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const os = require('node:os'); +const path = require('node:path'); + +const ADDON = path.join(__dirname, '..', 'build', 'Release', 'slopsmith_audio.node'); +const HAVE_ADDON = fs.existsSync(ADDON); + +function writeImpulseWav(file) { + const buf = Buffer.alloc(44 + 128); + buf.write('RIFF', 0); buf.writeUInt32LE(36 + 128, 4); buf.write('WAVE', 8); + buf.write('fmt ', 12); buf.writeUInt32LE(16, 16); buf.writeUInt16LE(1, 20); + buf.writeUInt16LE(1, 22); buf.writeUInt32LE(48000, 24); buf.writeUInt32LE(96000, 28); + buf.writeUInt16LE(2, 32); buf.writeUInt16LE(16, 34); + buf.write('data', 36); buf.writeUInt32LE(128, 40); + buf.writeInt16LE(32767, 44); + fs.writeFileSync(file, buf); +} + +// NB 2**31 (not 4097): a slot id is a monotonic HANDLE from nextSlotId, which +// clear() never resets, so a long session legitimately hands out ids past any +// small ceiling — see the slot-id-handle test below. What must be rejected is +// the NaN/Inf/fractional/negative/non-number class, plus ids that don't fit an +// int32 at all. +const GARBAGE = [NaN, Infinity, -Infinity, -1, 1.5, 2 ** 31, 'x', null, undefined, {}, []]; + +test('chain-mutating bindings no-op on garbage args and never touch slot 0', { skip: !HAVE_ADDON && 'addon not built' }, async () => { + const audio = require(ADDON); + audio.init(); + const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'arg-fuzz-')); + const ir = path.join(tmp, 'i.wav'); + writeImpulseWav(ir); + try { + const res = await audio.loadPreset(JSON.stringify({ + chain: [{ type: 2, name: 'fuzz-anchor', path: ir, bypassed: false }], + })); + assert.ok(res?.success, 'anchor preset must load'); + const before = JSON.stringify(audio.getChainState()); + + for (const g of GARBAGE) { + audio.setBypass(g, true); + audio.setBypass(0 /* valid id shape */, g); + audio.removeProcessor(g); + audio.moveProcessor(g, 0); + audio.moveProcessor(0, g); + audio.setParameter(g, 0, 0.5); + audio.setParameter(1, g, 0.5); + audio.setParameter(1, 0, g); + audio.setPan?.(g, 0); + audio.sendMidiToSlot(g, 0, 1, 0); + audio.sendMidiToSlot(1, g, 1, 0); + audio.sendMidiToSlot(1, 0, g, 0); + audio.sendMidiToSlot(1, 0, 1, g); + audio.setMultiBypass([{ slotId: g, bypassed: true }, g, null]); + audio.setGain('output', g); + audio.setGain(g, 1); + } + + const after = JSON.stringify(audio.getChainState()); + assert.equal(after, before, 'garbage args must not mutate any slot'); + // Sanity: a VALID call still works after the fuzz storm. + audio.setBypass(1, true); + const st = audio.getChainState(); + assert.equal(st[0]?.bypassed, true, 'valid call after fuzz must apply'); + audio.setBypass(1, false); + } finally { + await audio.clearChain?.(); + audio.shutdown?.(); + fs.rmSync(tmp, { recursive: true, force: true }); + } +}); + +// PR #107 review: slot ids are monotonic HANDLES (SignalChain::nextSlotId, +// never reset by clear()), not bounded indices. A ceiling in the N-API arg +// guard meant that once a session had created its 4096th processor — a few +// hundred song loads / tone switches, each rebuilding a chainful — EVERY +// guarded binding (setBypass, setParameter, remove/moveProcessor, open/close +// PluginEditor) silently no-opped for the rest of the run, with no error. +// +// Deliberately slow (~40s): the only way to observe the bug through the public +// surface is to actually push nextSlotId past the old ceiling and then drive a +// real slot. Batched as 21 x 210-slot presets so only 210 IRLoaders are ever +// live at once. +test('slot ids are handles, not indices — bindings still work past the old 4096 ceiling', + { skip: !HAVE_ADDON && 'addon not built' }, async () => { + const audio = require(ADDON); + audio.init(); + const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'slot-handle-')); + const ir = path.join(tmp, 'i.wav'); + writeImpulseWav(ir); + const SLOTS = 210, LOADS = 21; // 4410 ids > the old 4096 ceiling + try { + let slots = []; + for (let i = 0; i < LOADS; i++) { + const res = await audio.loadPreset(JSON.stringify({ + chain: Array.from({ length: SLOTS }, (_, k) => ({ + type: 2, name: `handle-${k}`, path: ir, bypassed: false, + })), + })); + assert.ok(res?.success, `preset ${i} must load`); + slots = audio.getChainState(); + } + + const maxId = Math.max(...slots.map((s) => s.id)); + assert.ok(maxId > 4096, `expected a slot id past the old ceiling, got ${maxId}`); + + // The regression: with an index ceiling on the arg guard this was a + // silent no-op and bypassed stayed false. + audio.setBypass(maxId, true); + assert.equal(audio.getChainState().find((s) => s.id === maxId)?.bypassed, true, + 'setBypass on a >4096 slot id must apply, not silently no-op'); + + await audio.removeProcessor(maxId); + assert.equal(audio.getChainState().find((s) => s.id === maxId), undefined, + 'removeProcessor on a >4096 slot id must apply'); + } finally { + await audio.clearChain?.(); + audio.shutdown?.(); + fs.rmSync(tmp, { recursive: true, force: true }); + } +});