Merge pull request #107 from got-feedBack/refactor/audio-engine-tlc
Addon CI / addon (arm64, macos-14, mac) (push) Waiting to run
Addon CI / addon (x64, ubuntu-22.04, linux) (push) Waiting to run
Addon CI / addon (x64, windows-latest, win) (push) Waiting to run
Ship CI / CI (push) Waiting to run

Audio engine TLC: decompose the AudioEngine/NodeAddon monoliths + deep-read fixes
This commit is contained in:
Byron Gamatos
2026-07-14 16:15:07 +02:00
committed by GitHub
57 changed files with 10515 additions and 5901 deletions
+940
View File
@@ -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<uint64_t>` (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<juce::AudioProcessor>`. 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<Number>` 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<Frames>` — first, everything else builds on it
Template over capacity; owns `array<atomic<uint64_t>>`, 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<int> argSlotId(info, i); // finite integer, >= 0
std::optional<float> argGain(info, i); // finite, clamped 0..8 (one policy)
std::optional<float> argParamValue(info, i); // finite, clamped 0..1
std::optional<bool> 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/<name>/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 12** (`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 35** (`BackingPlayer`, `DeviceSetup`, `SourcePool` + `ExtraInputs`).
5. **Fix: `wasRunning` intent read in setAudioDevices** (III §3) — now trivial on the split
intent/state atomics.
6. **Refactor phases 67** (`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.
+165 -2056
View File
File diff suppressed because it is too large Load Diff
+126 -412
View File
@@ -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 <juce_audio_devices/juce_audio_devices.h>
@@ -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<double> sampleRates; // intersection when dual-type
juce::Array<int> 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<BindableInput> getBindableInputDevices();
juce::Array<double> 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<bool> 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<std::unique_ptr<SourceChain>, 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<bool>& 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<float> 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<std::atomic<int>, 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<bool, kMaxSources> 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<float> outputGain{1.0f};
std::atomic<float> 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<float> 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<float> currentBackingLevel{0.0f};
std::atomic<float> 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<juce::AudioFormatReaderSource> backingSource;
std::unique_ptr<juce::AudioTransportSource> backingTransport;
signalsmith::stretch::SignalsmithStretch<float> backingStretch;
juce::AudioBuffer<float> backingInputBuffer; // pulled from transport at device rate
juce::AudioBuffer<float> backingBuffer; // stretch output, mixed into device buffer
std::atomic<int> backingStretchLatencySamples{0};
std::atomic<bool> backingPlaying{false};
std::atomic<double> cachedBackingPosition{0.0};
std::atomic<double> 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<double> 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<double> 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<double> backingPendingSpeed{1.0};
std::atomic<bool> 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<bool> 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<double>
// 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<double> 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<int> inputBlockSize{256};
std::atomic<int> outputBlockSize{256};
// audioRunning keeps its historical DEVICE-STATE semantics (isAudioRunning
// compat pin); the intent half is state.userWantsAudio — see EngineState.h.
std::atomic<bool>& audioRunning = state.deviceRunning;
std::atomic<double>& currentSampleRate = state.currentSampleRate;
std::atomic<int>& inputBlockSize = state.inputBlockSize;
std::atomic<int>& 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<std::atomic<uint64_t>, 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<uint64_t> 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<uint64_t>::is_always_lock_free,
"outputPendingRing requires lock-free atomic<uint64_t> for RT safety");
static_assert(sizeof(float) == 4,
"outputPendingRing pack/unpack assumes 32-bit float");
slopsmith::PackedStereoRing<kOutputRingFrames> 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<uint32_t>(l);
const uint32_t ri = std::bit_cast<uint32_t>(r);
return (static_cast<uint64_t>(ri) << 32) | static_cast<uint64_t>(li);
}
static inline void unpackLR(uint64_t v, float& l, float& r) noexcept
{
l = std::bit_cast<float>(static_cast<uint32_t>(v & 0xFFFFFFFFu));
r = std::bit_cast<float>(static_cast<uint32_t>(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<std::atomic<uint64_t>, kRendererBusFrames> rendererBusRing{};
std::atomic<uint64_t> rendererBusWriteIndex{0};
std::atomic<uint64_t> rendererBusReadIndex{0};
std::atomic<uint64_t> rendererBusPushedFrames{0};
std::atomic<uint64_t> rendererBusConsumedFrames{0};
std::atomic<uint64_t> rendererBusUnderflowCount{0};
std::atomic<uint64_t> rendererBusOverflowCount{0};
std::atomic<bool> rendererBusEnabled{false};
std::atomic<float> 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<bool> 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<float> rendererBusPullScratch;
std::atomic<uint64_t> outputRingWriteIndex{0};
std::atomic<uint64_t> outputRingReadIndex{0};
std::atomic<uint64_t> outputUnderflowCount{0};
std::atomic<uint64_t> 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<std::atomic<uint64_t>, kOutputRingFrames> ring{};
std::atomic<uint64_t> writeIndex{0};
std::atomic<uint64_t> readIndex{0};
std::atomic<uint64_t> overflowCount{0};
std::atomic<bool> active{false}; // a device is bound + running
std::atomic<double> sampleRate{48000.0};
std::atomic<int> 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<double> 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<float> fanScratch; // the 2ch mix target
juce::AudioBuffer<float> 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<bool> permanentUnbind { false };
};
std::array<InputDeviceSlot, kMaxExtraInputDevices> 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<float>& mixBuf, juce::AudioBuffer<float>& monitorScratch,
int effectiveOutputChannels, int numSamples);
// Pack a stereo block into a packed-uint64 SPSC ring (producer side).
void packStereoIntoRing(const juce::AudioBuffer<float>& buf, int numSamples,
std::array<std::atomic<uint64_t>, kOutputRingFrames>& ring,
std::atomic<uint64_t>& 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<std::atomic<uint64_t>, kOutputRingFrames> ring{};
std::atomic<uint64_t> writeIndex{0};
std::atomic<uint64_t> readIndex{0};
std::atomic<uint64_t> underflowCount{0};
std::atomic<uint64_t> overflowCount{0};
std::atomic<bool> active{false};
std::atomic<double> sampleRate{48000.0};
std::atomic<int> blockSize{256};
std::vector<float> 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<bool> streamBusIncludeBacking{true};
std::atomic<bool> streamBusIncludeGuitar{true};
std::atomic<float> streamBusGain{1.0f};
std::atomic<float> 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<float> streamGuitarScratch;
juce::AudioBuffer<float> 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<float>& guitarMix,
const juce::AudioBuffer<float>* backingBuf,
int backingFrames, float backingVol,
const juce::AudioBuffer<float>* rendererBuf,
int rendererFrames, int numSamples);
static float sanitizeStreamGain(float g) { return slopsmith::sanitizeStreamGain(g); }
JUCE_DECLARE_NON_COPYABLE_WITH_LEAK_DETECTOR(AudioEngine)
};
+13
View File
@@ -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
+36
View File
@@ -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 <cmath>
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
+126 -3358
View File
File diff suppressed because it is too large Load Diff
+21 -3
View File
@@ -700,12 +700,30 @@ const ProcessorSlot* SignalChain::getSlot(int slotId) const
return idx >= 0 ? slots[idx] : nullptr;
}
juce::Array<const ProcessorSlot*> SignalChain::getAllSlots() const
std::vector<SignalChain::SlotSummary> SignalChain::getSlotSummaries() const
{
juce::Array<const ProcessorSlot*> result;
std::vector<SlotSummary> 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;
}
+21 -1
View File
@@ -2,6 +2,7 @@
#include <juce_audio_processors/juce_audio_processors.h>
#include <juce_dsp/juce_dsp.h>
#include <array>
#include <vector>
// 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<const ProcessorSlot*> 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<SlotSummary> 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; }
+3 -1
View File
@@ -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
+42 -8
View File
@@ -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<int> deviceKey{0}; // 0 = primary input device
std::atomic<double> verifierAutoOffset{0.0}; // engine: device-latency delta
std::atomic<double> verifierUserOffset{0.0}; // renderer: manual fine-tune
std::atomic<bool> monitorMuted{true};
std::atomic<bool> monitorMuteSuppressed{false};
std::atomic<bool> userMonitorMute{true};
std::atomic<int> monitorMuteHolds{0};
std::atomic<int> monitorMuteSuppress{0};
std::atomic<bool> monitorKill{false};
std::atomic<uint32_t> nonFiniteChainBlocks{0};
+266
View File
@@ -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 <chrono>
#include <cstdio>
#include <set>
#include <thread>
namespace slopsmith::addon {
static std::shared_ptr<AudioEngine> engine;
static std::mutex engineMutex;
static std::shared_ptr<VSTHost> vstHost;
static std::mutex vstHostMutex;
static std::thread juceMessageThread;
static std::atomic<bool> juceRunning{false};
static std::atomic<bool> 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<void()> shutdownUiTeardown;
std::shared_ptr<AudioEngine> snapshotEngine()
{
std::lock_guard<std::mutex> lock(engineMutex);
return engine;
}
std::shared_ptr<VSTHost> snapshotVstHost()
{
std::lock_guard<std::mutex> 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<void()> 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<juce::WaitableEvent>();
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<std::shared_ptr<juce::WaitableEvent>> pendingLoads;
bool isShuttingDown()
{
return alreadyShutDown.load(std::memory_order_acquire);
}
void registerPendingLoad(std::shared_ptr<juce::WaitableEvent> evt)
{
std::lock_guard<std::mutex> lock(pendingLoadsMutex);
pendingLoads.insert(std::move(evt));
}
void unregisterPendingLoad(const std::shared_ptr<juce::WaitableEvent>& evt)
{
std::lock_guard<std::mutex> lock(pendingLoadsMutex);
pendingLoads.erase(evt);
}
void cancelAllPendingLoads()
{
std::lock_guard<std::mutex> lock(pendingLoadsMutex);
for (auto& evt : pendingLoads) evt->signal();
pendingLoads.clear();
}
// ── Lifecycle ────────────────────────────────────────────────────────────────
void initialize(std::function<void()> 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<AudioEngine> liveEngine;
{
std::lock_guard<std::mutex> lock(engineMutex);
engine = std::make_shared<AudioEngine>();
liveEngine = engine;
}
{
std::lock_guard<std::mutex> lock(vstHostMutex);
vstHost = std::make_shared<VSTHost>();
}
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<std::mutex> lock(engineMutex);
engine.reset();
}
{
std::lock_guard<std::mutex> 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
+70
View File
@@ -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 <juce_events/juce_events.h>
#include <atomic>
#include <functional>
#include <memory>
#include <mutex>
namespace slopsmith::addon {
std::shared_ptr<AudioEngine> snapshotEngine();
std::shared_ptr<VSTHost> 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<void()> 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<void()> func);
template <typename Func>
inline bool dispatchOnMessageThread(Func&& func)
{
return dispatchOnMessageThreadImpl(std::function<void()>(std::forward<Func>(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<juce::WaitableEvent> evt);
void unregisterPendingLoad(const std::shared_ptr<juce::WaitableEvent>& evt);
void cancelAllPendingLoads();
} // namespace slopsmith::addon
+91
View File
@@ -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 <cmath>
#include <cstdio>
#include <limits>
#include <string>
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<Napi::String>().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<Napi::Number>().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<Napi::Number>().DoubleValue());
return env.Undefined();
}
} // namespace slopsmith::addon
+113
View File
@@ -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 <napi.h>
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
+307
View File
@@ -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 <cmath>
#include <cstdio>
#include <limits>
#include <string>
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<Napi::String>().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<Napi::Array>();
juce::Array<std::pair<int, bool>> 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<Napi::Object>();
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<Napi::Number>().DoubleValue();
if (!std::isfinite(raw) || raw != std::floor(raw)
|| raw < 0.0 || raw > (double) std::numeric_limits<int>::max()) continue;
changes.add({ (int) raw, bypVal.As<Napi::Boolean>().Value() });
}
liveEngine->getSignalChain().setMultiBypass(changes);
return Napi::Boolean::New(env, true);
}
} // namespace slopsmith::addon
File diff suppressed because it is too large Load Diff
+114
View File
@@ -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 <napi.h>
#include <juce_core/juce_core.h>
#include <cstdint>
#include <functional>
#include <memory>
#include <mutex>
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<std::mutex> 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<void(AudioEngine&)> 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<int(AudioEngine&)> 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<juce::AudioProcessor> 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
+367
View File
@@ -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 <cmath>
#include <cstdio>
#include <limits>
#include <string>
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<Napi::String>().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<Napi::Number>().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<Napi::Boolean>().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<Napi::Boolean>().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<Napi::Boolean>().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<Napi::Boolean>().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<Napi::Object>();
bool enabled = false;
if (o.Has("enabled"))
{
auto v = o.Get("enabled");
if (v.IsBoolean())
enabled = v.As<Napi::Boolean>().Value();
else if (v.IsNumber())
enabled = v.As<Napi::Number>().DoubleValue() != 0.0;
}
float thresholdDb = -60.0f;
if (o.Has("thresholdDb") && o.Get("thresholdDb").IsNumber())
thresholdDb = (float)o.Get("thresholdDb").As<Napi::Number>().DoubleValue();
float releaseMs = 100.0f;
if (o.Has("releaseMs") && o.Get("releaseMs").IsNumber())
releaseMs = (float)o.Get("releaseMs").As<Napi::Number>().DoubleValue();
float depthDb = -60.0f;
if (o.Has("depthDb") && o.Get("depthDb").IsNumber())
depthDb = (float)o.Get("depthDb").As<Napi::Number>().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<Napi::Object>();
bool enabled = true;
if (o.Has("enabled"))
{
auto v = o.Get("enabled");
if (v.IsBoolean())
enabled = v.As<Napi::Boolean>().Value();
else if (v.IsNumber())
enabled = v.As<Napi::Number>().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<Napi::String>().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<Napi::String>().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
+589
View File
@@ -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 <cmath>
#include <cstdio>
#include <limits>
#include <string>
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<Napi::Number>().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<Napi::String>().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<Napi::Number>().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<Napi::Array>();
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<Napi::Object>();
if (o.Has("s") && o.Get("s").IsNumber())
s = o.Get("s").As<Napi::Number>().Int32Value();
if (o.Has("f") && o.Get("f").IsNumber())
f = o.Get("f").As<Napi::Number>().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<Napi::Number>().Int32Value();
if (reqObj.Has("arrangement") && reqObj.Get("arrangement").IsString())
req.arrangement = reqObj.Get("arrangement").As<Napi::String>().Utf8Value();
if (reqObj.Has("stringCount") && reqObj.Get("stringCount").IsNumber())
req.stringCount = reqObj.Get("stringCount").As<Napi::Number>().Int32Value();
if (reqObj.Has("capo") && reqObj.Get("capo").IsNumber())
req.capo = reqObj.Get("capo").As<Napi::Number>().Int32Value();
if (reqObj.Has("pitchCheckCents") && reqObj.Get("pitchCheckCents").IsNumber())
req.pitchCheckCents = reqObj.Get("pitchCheckCents").As<Napi::Number>().FloatValue();
if (reqObj.Has("minHitRatio") && reqObj.Get("minHitRatio").IsNumber())
req.minHitRatio = reqObj.Get("minHitRatio").As<Napi::Number>().FloatValue();
if (reqObj.Has("bypassMl") && reqObj.Get("bypassMl").IsBoolean())
req.bypassMl = reqObj.Get("bypassMl").As<Napi::Boolean>().Value();
if (reqObj.Has("harmonicVerify") && reqObj.Get("harmonicVerify").IsBoolean())
req.harmonicVerify = reqObj.Get("harmonicVerify").As<Napi::Boolean>().Value();
if (reqObj.Has("harmonicSnr") && reqObj.Get("harmonicSnr").IsNumber())
req.harmonicSnr = reqObj.Get("harmonicSnr").As<Napi::Number>().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<Napi::Number>().FloatValue();
if (std::isfinite(v)) req.fundamentalRatio = v;
}
if (reqObj.Has("offsets") && reqObj.Get("offsets").IsArray())
{
auto arr = reqObj.Get("offsets").As<Napi::Array>();
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<Napi::Number>().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<Napi::Object>();
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<Napi::Number>().Int32Value();
n.fret = noteObj.Get("f").As<Napi::Number>().Int32Value();
// Technique flags are truthy/falsy in JS; coerce to bool
// here so an unset value cleanly becomes false.
auto truthy = [&noteObj](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<Napi::Object>(), 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<Napi::Object>(), 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<Napi::Number>().Int32Value();
int deviceKey = 0; // default: primary input device
if (info.Length() > 1 && info[1].IsNumber())
{
const int k = info[1].As<Napi::Number>().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<Napi::Number>().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<Napi::Number>().Int32Value();
const std::string name = info[1].As<Napi::String>().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<Napi::Number>().Int32Value()));
}
} // namespace slopsmith::addon
+852
View File
@@ -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 <cmath>
#include <cstdio>
#include <limits>
#include <string>
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<Napi::String>().Utf8Value() : "";
auto arg1 = info.Length() > 1 && info[1].IsString() ? info[1].As<Napi::String>().Utf8Value() : "";
auto arg2 = info.Length() > 2 && info[2].IsString() ? info[2].As<Napi::String>().Utf8Value() : "";
auto arg3 = info.Length() > 3 && info[3].IsString() ? info[3].As<Napi::String>().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<double>(m.inputOverflowCount));
obj.Set("outputUnderflowCount", static_cast<double>(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<Napi::String>().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<Napi::String>().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<Napi::Object>();
auto readStr = [&](const char* key) -> std::string {
if (obj.Has(key) && obj.Get(key).IsString()) return obj.Get(key).As<Napi::String>().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<Napi::Number>().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<int>::max) ())
cfg.bufferSize = (int) bsd;
else
cfg.bufferSize = 256;
}
}
else
{
auto input = info.Length() > 0 && info[0].IsString() ? info[0].As<Napi::String>().Utf8Value() : "";
auto output = info.Length() > 1 && info[1].IsString() ? info[1].As<Napi::String>().Utf8Value() : "";
double sr = info.Length() > 2 && info[2].IsNumber() ? info[2].As<Napi::Number>().DoubleValue() : 48000.0;
int bs = info.Length() > 3 && info[3].IsNumber() ? info[3].As<Napi::Number>().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<Napi::String>().Utf8Value();
const std::string devName = info[1].As<Napi::String>().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<Napi::Boolean>().Value(),
info[1].As<Napi::Boolean>().Value(),
(float) info[2].As<Napi::Number>().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<Napi::Number>().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<Napi::Boolean>().Value(),
(float) info[1].As<Napi::Number>().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<Napi::TypedArray>();
if (ta.TypedArrayType() != napi_float32_array)
return Napi::Boolean::New(env, false);
auto f32 = info[0].As<Napi::Float32Array>();
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<Napi::Number>().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<Napi::Number>().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<Napi::Number>().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<Napi::Boolean>().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<Napi::Number>().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<Napi::Number>().DoubleValue();
if (std::isfinite(songTime))
s->setPlayhead(songTime, info[2].As<Napi::Boolean>().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<Napi::String>().Utf8Value();
if (reqObj.Has("stringCount") && reqObj.Get("stringCount").IsNumber())
chart.stringCount = reqObj.Get("stringCount").As<Napi::Number>().Int32Value();
if (reqObj.Has("capo") && reqObj.Get("capo").IsNumber())
chart.capo = reqObj.Get("capo").As<Napi::Number>().Int32Value();
if (reqObj.Has("pitchCheckCents") && reqObj.Get("pitchCheckCents").IsNumber())
chart.pitchCheckCents = reqObj.Get("pitchCheckCents").As<Napi::Number>().FloatValue();
if (reqObj.Has("harmonicSnr") && reqObj.Get("harmonicSnr").IsNumber())
chart.harmonicSnr = reqObj.Get("harmonicSnr").As<Napi::Number>().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<Napi::Number>().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<Napi::Number>().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<Napi::Number>().DoubleValue();
if (reqObj.Has("tuningOffsets") && reqObj.Get("tuningOffsets").IsArray())
{
auto arr = reqObj.Get("tuningOffsets").As<Napi::Array>();
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<Napi::Number>().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<Napi::Array>();
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<Napi::Object>();
// 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<Napi::String>().Utf8Value();
n.t = noteObj.Get("t").As<Napi::Number>().DoubleValue();
n.string = noteObj.Get("s").As<Napi::Number>().Int32Value();
n.fret = noteObj.Get("f").As<Napi::Number>().Int32Value();
n.sus = noteObj.Get("sus").As<Napi::Number>().DoubleValue();
auto truthy = [&noteObj](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<Napi::Object>(), 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<Napi::Object>(), 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<Napi::Number>().DoubleValue();
if (std::isfinite(songTime))
liveEngine->setPlayhead(songTime, info[1].As<Napi::Boolean>().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
+427
View File
@@ -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 <cstdio>
#include <map>
#include <memory>
#include <mutex>
namespace slopsmith::addon {
class PluginEditorWindow;
static std::map<int, std::unique_ptr<PluginEditorWindow>> 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<juce::WaitableEvent>();
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<std::mutex> 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<slopsmith::sandbox::SandboxedProcessor*>(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<std::mutex> 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<slopsmith::sandbox::SandboxedProcessor*>(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<std::mutex> 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<slopsmith::sandbox::SandboxedProcessor*>(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<PluginEditorWindow>(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<std::mutex> 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<slopsmith::sandbox::SandboxedProcessor*>(slot->processor.get()))
sb->requestCloseEditor();
#endif
});
return Napi::Boolean::New(env, queued);
}
} // namespace slopsmith::addon
+31
View File
@@ -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 <napi.h>
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
+77
View File
@@ -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<Napi::Number>() path to copy.
#include <napi.h>
#include <cmath>
#include <limits>
#include <optional>
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<int> 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<Napi::Number>().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<int> argSlotId(const Napi::CallbackInfo& info, size_t i)
{
return argInt(info, i, 0, std::numeric_limits<int>::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<float> argFiniteFloat(const Napi::CallbackInfo& info, size_t i)
{
if (i >= info.Length() || ! info[i].IsNumber()) return std::nullopt;
const double raw = info[i].As<Napi::Number>().DoubleValue();
if (! std::isfinite(raw)) return std::nullopt;
return (float) raw;
}
inline std::optional<bool> argBool(const Napi::CallbackInfo& info, size_t i)
{
if (i >= info.Length() || ! info[i].IsBoolean()) return std::nullopt;
return info[i].As<Napi::Boolean>().Value();
}
// MIDI channel: JUCE expects 1..16 and asserts otherwise.
inline std::optional<int> 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<int> argMidiByte(const Napi::CallbackInfo& info, size_t i)
{
return argInt(info, i, 0, 127);
}
} // namespace slopsmith::addon
+325
View File
@@ -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 <cmath>
#include <iostream>
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<juce::AudioFormatReaderSource>(reader, true);
transport = std::make_unique<juce::AudioTransportSource>();
// 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<double>(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
+121
View File
@@ -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 <juce_audio_devices/juce_audio_devices.h>
#include <juce_audio_formats/juce_audio_formats.h>
#include <atomic>
#include <memory>
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<float>& 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<juce::AudioFormatReaderSource> readerSource;
std::unique_ptr<juce::AudioTransportSource> transport;
signalsmith::stretch::SignalsmithStretch<float> stretch;
juce::AudioBuffer<float> inputBuffer; // pulled from transport at device rate
juce::AudioBuffer<float> outputBuffer; // stretch output, mixed by the callbacks
std::atomic<int> stretchLatencySamples{0};
std::atomic<bool> playing{false};
std::atomic<double> cachedPosition{0.0};
std::atomic<double> 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<double> 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<double> 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<double> pendingSpeed{1.0};
std::atomic<bool> 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
+623
View File
@@ -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 <cmath>
#include <cstdio>
#include <memory>
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<juce::AudioIODevice> 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<juce::AudioIODevice> 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<juce::AudioIODevice> inDev(
inputType->createDevice({}, probeInputName));
std::unique_ptr<juce::AudioIODevice> 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<juce::AudioIODevice>(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<uint64_t>& outputUnderflowCount,
std::atomic<uint64_t>& 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<juce::AudioIODevice> 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<juce::AudioIODevice> 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
+124
View File
@@ -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 <juce_audio_devices/juce_audio_devices.h>
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<double> sampleRates; // intersection when dual-type
juce::Array<int> 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<kOutputRingFrames>;
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<uint64_t>& outputUnderflowCount,
std::atomic<uint64_t>& 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
+55
View File
@@ -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 <atomic>
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<double>
// 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<double> 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<int> inputBlockSize{256};
std::atomic<int> outputBlockSize{256};
// Duplex: one device manager owns both directions. Split: input-only +
// output-only managers with an SPSC ring between them.
std::atomic<bool> duplexMode{true};
// Intent: the user asked for audio to run. start/stopAudio only.
std::atomic<bool> 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<bool> deviceRunning{false};
};
} // namespace slopsmith
+389
View File
@@ -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 <cmath>
#include <cstdio>
#include <memory>
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<float> 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<juce::AudioIODevice> 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::Bindable> ExtraInputs::listBindable()
{
std::vector<Bindable> 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
+160
View File
@@ -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 <juce_audio_devices/juce_audio_devices.h>
#include <array>
#include <atomic>
#include <vector>
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<kRingFrames> ring;
std::atomic<uint64_t> overflowCount{0};
std::atomic<bool> active{false}; // a device is bound + running
std::atomic<double> sampleRate{48000.0};
std::atomic<int> 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<double> 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<float> fanScratch; // the 2ch mix target
juce::AudioBuffer<float> 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<bool> 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<Bindable> 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<InputDeviceSlot, kMaxExtraInputDevices> 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
+143
View File
@@ -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<r resync after an index
// reset, and the lapped catch-up with its overflow counter.
#include <array>
#include <atomic>
#include <bit>
#include <cstdint>
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<uint32_t>(l);
const uint32_t ri = std::bit_cast<uint32_t>(r);
return (static_cast<uint64_t>(ri) << 32) | static_cast<uint64_t>(li);
}
inline void unpackLR(uint64_t v, float& l, float& r) noexcept
{
l = std::bit_cast<float>(static_cast<uint32_t>(v & 0xFFFFFFFFu));
r = std::bit_cast<float>(static_cast<uint32_t>(v >> 32));
}
template <int NFrames>
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<uint64_t> 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<uint64_t>::is_always_lock_free,
"PackedStereoRing requires lock-free atomic<uint64_t> 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<std::atomic<uint64_t>, NFrames> slots{};
std::atomic<uint64_t> writeIndex{0};
std::atomic<uint64_t> 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
+33
View File
@@ -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 <cmath>
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
+243
View File
@@ -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 <atomic>
#include <cmath>
#include <cstdint>
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<kFrames> ring;
std::atomic<uint64_t> pushedFrames{0};
std::atomic<uint64_t> consumedFrames{0};
std::atomic<uint64_t> underflowCount{0};
std::atomic<uint64_t> overflowCount{0};
std::atomic<bool> busEnabled{false};
std::atomic<float> 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<bool> 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<bool> flushRequested{false};
std::atomic<uint64_t> 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
+225
View File
@@ -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 <chrono>
#include <thread>
namespace slopsmith {
int SourcePool::addResolved(int inputChannel, int deviceKey,
bool deviceReady, double sr, int bs, double latencyDeltaSec)
{
std::lock_guard<std::mutex> 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<std::mutex> 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::Info> SourcePool::list() const
{
std::vector<Info> 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<std::mutex> 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<std::mutex> 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<float>& mixBuf, juce::AudioBuffer<float>& 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
+148
View File
@@ -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 <array>
#include <atomic>
#include <memory>
#include <mutex>
#include <vector>
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<SourceChain>(
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<float>& mixBuf, juce::AudioBuffer<float>& 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<Info> 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 <typename Fn> void forEach(Fn&& fn)
{
for (auto& s : sources)
if (s) fn(*s);
}
template <typename Fn> 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 <typename Fn> void withDeviceSources(int deviceKey, Fn&& fn)
{
std::lock_guard<std::mutex> 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<std::mutex> lock(mutex);
reclaimPendingLocked();
}
private:
void reclaimPendingLocked();
std::array<std::unique_ptr<SourceChain>, 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<std::atomic<int>, 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<bool, kMaxSources> pendingRelease{};
};
} // namespace slopsmith
+263
View File
@@ -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 <cmath>
#include <cstdio>
namespace slopsmith {
void StreamSink::publish(const juce::AudioBuffer<float>& guitarMix,
const juce::AudioBuffer<float>* backingBuf, int backingFrames, float backingVol,
const juce::AudioBuffer<float>* 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<float> 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
+142
View File
@@ -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 <juce_audio_devices/juce_audio_devices.h>
#include <atomic>
#include <cstdint>
#include <vector>
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<float>& guitarMix,
const juce::AudioBuffer<float>* backingBuf, int backingFrames, float backingVol,
const juce::AudioBuffer<float>* 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<kRingFrames> ring;
std::atomic<uint64_t> underflowCount{0};
std::atomic<uint64_t> overflowCount{0};
std::atomic<bool> active{false};
std::atomic<double> sinkSampleRate{48000.0};
std::atomic<int> sinkBlockSize{256};
std::vector<float> pullScratchL, pullScratchR; // sized in deviceAboutToStart
bool callbackRegistered = false;
bool initialised = false;
std::atomic<bool> busIncludeBacking{true};
std::atomic<bool> busIncludeGuitar{true};
std::atomic<float> busGain{1.0f};
std::atomic<float> level{0.0f};
// Producer-side composed submix scratch — fixed ring capacity, see
// prepareProducerScratch().
juce::AudioBuffer<float> 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
+10 -6
View File
@@ -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();
});
+107 -32
View File
@@ -29,12 +29,15 @@ type AudioEffectsNativeAudio = {
savePreset?: () => unknown;
clearChain?: () => Promise<unknown> | 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> | unknown;
setMonitorMute?: (muted: boolean) => Promise<unknown> | unknown;
setMonitorMuteSuppressed?: (suppressed: boolean) => Promise<unknown> | unknown;
acquireMonitorMuteHold?: () => Promise<unknown> | unknown;
releaseMonitorMuteHold?: () => Promise<unknown> | unknown;
isMonitorMuted?: () => Promise<unknown> | unknown;
startAudio?: () => Promise<unknown> | unknown;
};
@@ -89,6 +92,11 @@ type RouteState = {
state: string;
activeSegmentId: string;
stageSlots: Map<string, number>;
// 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<string, string>;
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<boolean | null> {
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<void>> {
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<void> {
if (typeof nativeAudio.setMonitorMute !== 'function') return;
try { await nativeAudio.setMonitorMute(muted); } catch (_) { /* best effort */ }
}
async function trySetMonitorMuteSuppressed(nativeAudio: AudioEffectsNativeAudio, suppressed: boolean): Promise<void> {
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<boolean> {
@@ -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<void>) | 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<void>) | 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<SafeOutcome> {
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,
+61 -12
View File
@@ -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); };
+1
View File
@@ -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
+66 -7
View File
@@ -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);
});
+83
View File
@@ -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 });
}
});
+11 -5
View File
@@ -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`,
);
}
+34
View File
@@ -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'));
});
+108
View File
@@ -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"
]
+86
View File
@@ -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/');
}
+107
View File
@@ -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"
]
+112
View File
@@ -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"
]
}
+26
View File
@@ -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)
+53
View File
@@ -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 <cassert>
#include <cstdio>
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;
}
+48
View File
@@ -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 <cassert>
#include <cmath>
#include <cstdio>
#include <limits>
int main()
{
using slopsmith::sanitizeMasterGain;
using slopsmith::sanitizeStreamGain;
const float nan = std::numeric_limits<float>::quiet_NaN();
const float inf = std::numeric_limits<float>::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;
}
@@ -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<r resync after an index
// reset, L/R tear check under a concurrent producer lapping the consumer,
// and the pull-vs-consume skew pattern the split output path relies on.
#include "../../src/audio/engine/PackedStereoRing.h"
#include <cassert>
#include <cmath>
#include <cstring>
#include <cstdio>
#include <thread>
#include <vector>
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<bool> stop{false};
std::atomic<uint64_t> 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;
}
+46
View File
@@ -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 <cassert>
#include <cstdio>
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;
}
+237
View File
@@ -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 <cassert>
#include <cmath>
#include <cstdio>
#include <limits>
#include <vector>
using slopsmith::RendererBus;
static std::vector<float> rampChunk(int frames, float start, float step)
{
std::vector<float> 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<float> 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<float> 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<float> 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<float> 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<float> 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<float> 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<float> 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<float> 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<float> 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<double>::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;
}
+174
View File
@@ -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');
});
+130
View File
@@ -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 });
}
});