mirror of
https://github.com/got-feedBack/feedBack-desktop.git
synced 2026-09-10 22:14:10 +00:00
Merge pull request #107 from got-feedBack/refactor/audio-engine-tlc
Audio engine TLC: decompose the AudioEngine/NodeAddon monoliths + deep-read fixes
This commit is contained in:
@@ -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 1–2** (`PackedStereoRing`, `EngineState` with intent/state split,
|
||||
`RendererBus`, `StreamSink`) — low risk, creates the homes.
|
||||
3. **Fix: renderer-bus flush flag** (III §4) and **gain/slot-id sanitization** (III §2) —
|
||||
small, kills the NaN-master-gain and stale-tail bug classes.
|
||||
4. **Refactor phases 3–5** (`BackingPlayer`, `DeviceSetup`, `SourcePool` + `ExtraInputs`).
|
||||
5. **Fix: `wasRunning` intent read in setAudioDevices** (III §3) — now trivial on the split
|
||||
intent/state atomics.
|
||||
6. **Refactor phases 6–7** (`AddonContext`, `NapiHelpers`, `ChainOps` + serializer +
|
||||
`chainGeneration`, `EditorWindows`, binding split).
|
||||
7. **Ownership work (Part II)** — single chain owner via executor (needs `chainGeneration`),
|
||||
refcounted monitor-state arbiter, one persistence store per setting, tone-switch
|
||||
single-slot replace instead of editor nuke.
|
||||
8. **Long tail** — latency breakdown API, (typeName, name) device identity,
|
||||
slopsmith→feedBack alias deprecation.
|
||||
+165
-2056
File diff suppressed because it is too large
Load Diff
+126
-412
@@ -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)
|
||||
};
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
File diff suppressed because it is too large
Load Diff
@@ -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
@@ -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; }
|
||||
|
||||
@@ -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
@@ -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};
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
@@ -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
|
||||
@@ -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
|
||||
@@ -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 = [¬eObj](const char* key) {
|
||||
if (!noteObj.Has(key)) return false;
|
||||
auto val = noteObj.Get(key);
|
||||
return val.ToBoolean().Value();
|
||||
};
|
||||
n.hammerOn = truthy("ho");
|
||||
n.pullOff = truthy("po");
|
||||
n.bend = truthy("b");
|
||||
n.slide = truthy("sl");
|
||||
n.harmonic = truthy("hm");
|
||||
req.notes.push_back(n);
|
||||
}
|
||||
|
||||
auto result = target->scoreChord(req);
|
||||
|
||||
auto out = Napi::Object::New(env);
|
||||
out.Set("score", result.score);
|
||||
out.Set("hitStrings", result.hitStrings);
|
||||
out.Set("totalStrings", result.totalStrings);
|
||||
out.Set("isHit", result.isHit);
|
||||
auto resultsArr = Napi::Array::New(env, result.results.size());
|
||||
for (size_t i = 0; i < result.results.size(); ++i)
|
||||
{
|
||||
const auto& r = result.results[i];
|
||||
auto entry = Napi::Object::New(env);
|
||||
entry.Set("s", r.string);
|
||||
entry.Set("f", r.fret);
|
||||
entry.Set("hit", r.hit);
|
||||
entry.Set("bandEnergy", r.bandEnergy);
|
||||
// Mirror the JS result shape: when cents weren't measured the
|
||||
// fields are present-but-null so the renderer can distinguish
|
||||
// "no pitch check ran" (null) from "pitch check said 0"
|
||||
// (numeric 0).
|
||||
if (r.hasCents)
|
||||
{
|
||||
entry.Set("centsDiff", r.centsDiff);
|
||||
entry.Set("centsError", r.centsError);
|
||||
}
|
||||
else
|
||||
{
|
||||
entry.Set("centsDiff", env.Null());
|
||||
entry.Set("centsError", env.Null());
|
||||
}
|
||||
resultsArr.Set(i, entry);
|
||||
}
|
||||
out.Set("results", resultsArr);
|
||||
return out;
|
||||
}
|
||||
|
||||
// Legacy: scoreChord(req) — targets sources[0]. Backward-compatible.
|
||||
Napi::Value ScoreChord(const Napi::CallbackInfo& info)
|
||||
{
|
||||
auto env = info.Env();
|
||||
auto liveEngine = snapshotEngine();
|
||||
auto noRequestFailure = [&env]() {
|
||||
auto failure = Napi::Object::New(env);
|
||||
failure.Set("score", 0.0);
|
||||
failure.Set("hitStrings", 0);
|
||||
failure.Set("totalStrings", 0);
|
||||
failure.Set("isHit", false);
|
||||
failure.Set("results", Napi::Array::New(env, 0));
|
||||
return failure;
|
||||
};
|
||||
if (!liveEngine || info.Length() < 1 || !info[0].IsObject())
|
||||
return noRequestFailure();
|
||||
return scoreChordCore(env, info[0].As<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
|
||||
@@ -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 = [¬eObj](const char* key) {
|
||||
if (!noteObj.Has(key)) return false;
|
||||
return noteObj.Get(key).ToBoolean().Value();
|
||||
};
|
||||
n.ho = truthy("ho");
|
||||
n.po = truthy("po");
|
||||
n.b = truthy("b");
|
||||
n.sl = truthy("sl");
|
||||
n.hm = truthy("hm");
|
||||
chart.notes.push_back(std::move(n));
|
||||
}
|
||||
|
||||
target->setChart(chart);
|
||||
return Napi::Boolean::New(env, true);
|
||||
}
|
||||
|
||||
// Legacy: setChart(chart) — targets sources[0]. Backward-compatible.
|
||||
Napi::Value SetChart(const Napi::CallbackInfo& info)
|
||||
{
|
||||
auto env = info.Env();
|
||||
auto liveEngine = snapshotEngine();
|
||||
if (!liveEngine || info.Length() < 1 || !info[0].IsObject())
|
||||
{
|
||||
if (liveEngine) liveEngine->clearChart();
|
||||
return Napi::Boolean::New(env, false);
|
||||
}
|
||||
return setChartCore(env, info[0].As<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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
|
||||
@@ -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
@@ -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); };
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
});
|
||||
@@ -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`,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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'));
|
||||
});
|
||||
@@ -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"
|
||||
]
|
||||
@@ -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/');
|
||||
}
|
||||
@@ -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"
|
||||
]
|
||||
@@ -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"
|
||||
]
|
||||
}
|
||||
@@ -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)
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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');
|
||||
});
|
||||
@@ -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 });
|
||||
}
|
||||
});
|
||||
Reference in New Issue
Block a user