mirror of
https://github.com/got-feedBack/feedBack-desktop.git
synced 2026-09-11 01: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
|
#pragma once
|
||||||
#include "SourceChain.h"
|
#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 "BackingLeveler.h"
|
||||||
#include "signalsmith-stretch.h"
|
#include "signalsmith-stretch.h"
|
||||||
#include <juce_audio_devices/juce_audio_devices.h>
|
#include <juce_audio_devices/juce_audio_devices.h>
|
||||||
@@ -62,39 +71,12 @@ public:
|
|||||||
juce::StringArray inputDevices;
|
juce::StringArray inputDevices;
|
||||||
juce::StringArray outputDevices;
|
juce::StringArray outputDevices;
|
||||||
};
|
};
|
||||||
struct DeviceOptions
|
// Device-config shapes moved to engine/DeviceSetup.h (TLC phase 4);
|
||||||
{
|
// aliased so the AudioEngine::DeviceOptions etc. spelling NodeAddon uses
|
||||||
juce::String type; // legacy alias = inputType
|
// is unchanged.
|
||||||
juce::String inputType;
|
using DeviceOptions = slopsmith::DeviceOptions;
|
||||||
juce::String outputType;
|
using DeviceConfig = slopsmith::DeviceConfig;
|
||||||
juce::String input;
|
using DeviceConfigResult = slopsmith::DeviceConfigResult;
|
||||||
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;
|
|
||||||
};
|
|
||||||
|
|
||||||
struct DeviceMetrics
|
struct DeviceMetrics
|
||||||
{
|
{
|
||||||
@@ -115,7 +97,7 @@ public:
|
|||||||
// with an ALSA primary), minus the device already open as the primary (that's
|
// 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
|
// "Main") and minus monitor/loopback pseudo-inputs. Keeps the per-panel device
|
||||||
// picker to a compatible, sensible set instead of every capture node.
|
// 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();
|
std::vector<BindableInput> getBindableInputDevices();
|
||||||
|
|
||||||
juce::Array<double> getSampleRates();
|
juce::Array<double> getSampleRates();
|
||||||
@@ -154,7 +136,10 @@ public:
|
|||||||
// Gain controls. Input + chain-output gain are per-source (sources[0]);
|
// Gain controls. Input + chain-output gain are per-source (sources[0]);
|
||||||
// output gain is the post-mix master and stays engine-global.
|
// output gain is the post-mix master and stays engine-global.
|
||||||
void setInputGain(float gain) { source0().setInputGain(gain); }
|
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 getInputGain() const { return source0().getInputGain(); }
|
||||||
float getOutputGain() const { return outputGain.load(); }
|
float getOutputGain() const { return outputGain.load(); }
|
||||||
|
|
||||||
@@ -180,6 +165,11 @@ public:
|
|||||||
// so the brief empty-chain window doesn't silence the player's guitar.
|
// so the brief empty-chain window doesn't silence the player's guitar.
|
||||||
void setMonitorMuteSuppressed(bool suppressed) { source0().setMonitorMuteSuppressed(suppressed); }
|
void setMonitorMuteSuppressed(bool suppressed) { source0().setMonitorMuteSuppressed(suppressed); }
|
||||||
bool isMonitorMuteSuppressed() const { return source0().isMonitorMuteSuppressed(); }
|
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),
|
// Full monitor kill — silences the guitar bus entirely (dry + processed),
|
||||||
// for monitoring through an external rig. Unlike the per-source mute/gain
|
// 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.
|
// off the control thread is race-free. Default off; see SourceChain.
|
||||||
void setMonitorKill(bool kill)
|
void setMonitorKill(bool kill)
|
||||||
{
|
{
|
||||||
for (auto& s : sources)
|
pool.forEach([kill](SourceChain& s) { s.setMonitorKill(kill); });
|
||||||
if (s) s->setMonitorKill(kill);
|
|
||||||
}
|
}
|
||||||
bool isMonitorKilled() const { return source0().isMonitorKilled(); }
|
bool isMonitorKilled() const { return source0().isMonitorKilled(); }
|
||||||
|
|
||||||
@@ -215,17 +204,26 @@ public:
|
|||||||
// renderer exposes a per-preset toggle.
|
// renderer exposes a per-preset toggle.
|
||||||
void setTonePolishEnabled(bool enabled) { source0().setTonePolishEnabled(enabled); }
|
void setTonePolishEnabled(bool enabled) { source0().setTonePolishEnabled(enabled); }
|
||||||
|
|
||||||
// Backing track
|
// Backing track — transport moved to engine/BackingPlayer (TLC phase 3);
|
||||||
void setBackingVolume(float vol) { backingVolume.store(vol); }
|
// the volume fader + level meter stay engine-side (mix policy).
|
||||||
bool loadBackingTrack(const juce::File& file);
|
void setBackingVolume(float vol) { backingVolume.store(slopsmith::sanitizeMasterGain(vol)); }
|
||||||
void setBackingPosition(double seconds);
|
bool loadBackingTrack(const juce::File& file)
|
||||||
void startBacking();
|
{
|
||||||
void stopBacking();
|
currentBackingLevel.store(0.0f);
|
||||||
void setBackingSpeed(double speed);
|
return backing.load(file);
|
||||||
// Non-blocking reads — do not acquire backingLock and never block the audio callback
|
}
|
||||||
bool isBackingPlaying() const { return backingPlaying.load(); }
|
void setBackingPosition(double seconds) { backing.setPosition(seconds); }
|
||||||
double getBackingPosition() const { return cachedBackingPosition.load(); }
|
void startBacking() { backing.start(); }
|
||||||
double getBackingDuration() const { return cachedBackingDuration.load(); }
|
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
|
// Metering (read from any thread — atomic). Input level/peak are per-source
|
||||||
// (sources[0]); output level/peak are the post-mix master, engine-global.
|
// (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.
|
// setStreamOutputDevice returns "" on success or an error string.
|
||||||
juce::String setStreamOutputDevice(const juce::String& typeName, const juce::String& deviceName);
|
juce::String setStreamOutputDevice(const juce::String& typeName, const juce::String& deviceName);
|
||||||
void clearStreamOutput();
|
void clearStreamOutput();
|
||||||
bool isStreamOutputActive() const { return streamSink.active.load(std::memory_order_acquire); }
|
bool isStreamOutputActive() const { return streamSink.isActive(); }
|
||||||
juce::String getStreamOutputDeviceName() const { return streamSink.desiredDeviceName; }
|
juce::String getStreamOutputDeviceName() const { return streamSink.getDesiredDeviceName(); }
|
||||||
// Bus content: include the backing/game, include the guitar monitor mix, and a
|
// 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
|
// 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.
|
// (finite, clamped 0..8) so a NaN/Inf from JS can never reach the stream ring.
|
||||||
void setStreamBus(bool includeBacking, bool includeGuitar, float gain)
|
void setStreamBus(bool includeBacking, bool includeGuitar, float gain) { streamSink.setBus(includeBacking, includeGuitar, gain); }
|
||||||
{
|
void setStreamBusGain(float gain) { streamSink.setBusGain(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); }
|
|
||||||
|
|
||||||
// ── Renderer-audio bus (Phase 2: WebAudio master → engine output) ─────────
|
// ── Renderer-audio bus (Phase 2: WebAudio master → engine output) ─────────
|
||||||
// The renderer pushes its WebAudio master mix here (via IPC) so song/stem
|
// 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,
|
// mixer path is silenced. SPSC: producer is the main-process IPC thread,
|
||||||
// consumer is whichever output callback is live (duplex or split). Default
|
// consumer is whichever output callback is live (duplex or split). Default
|
||||||
// off → zero behaviour change.
|
// off → zero behaviour change.
|
||||||
void setRendererBus(bool enabled, float gain)
|
void setRendererBus(bool enabled, float gain) { rendererBus.setEnabled(enabled, 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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// Interleaved stereo frames at `sourceRate`; linear-resampled to the device
|
// Interleaved stereo frames at `sourceRate`; linear-resampled to the device
|
||||||
// rate on the producer thread (fractional position + previous frame carried
|
// rate on the producer thread (fractional position + previous frame carried
|
||||||
// across calls). Returns false when the bus is disabled or the engine is
|
// across calls). Returns false when the bus is disabled or the engine is
|
||||||
@@ -291,15 +271,34 @@ public:
|
|||||||
};
|
};
|
||||||
RendererBusMetrics getRendererBusMetrics() const;
|
RendererBusMetrics getRendererBusMetrics() const;
|
||||||
|
|
||||||
float getStreamSinkLevel() const { return streamSinkLevel.load(std::memory_order_relaxed); }
|
float getStreamSinkLevel() const { return streamSink.getLevel(); }
|
||||||
uint64_t getStreamUnderflowCount() const { return streamSink.underflowCount.load(std::memory_order_relaxed); }
|
uint64_t getStreamUnderflowCount() const { return streamSink.getUnderflowCount(); }
|
||||||
// Producer overflow (drop-oldest): the consumer fell a full ring behind and
|
// Producer overflow (drop-oldest): the consumer fell a full ring behind and
|
||||||
// frames were skipped. Exposed alongside underflow for stream drift diagnosis.
|
// 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
|
// Latency
|
||||||
double getLatencyMs() const;
|
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
|
// 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
|
// 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
|
// power-of-two capacity constants) now live on SourceChain. Default snapshot
|
||||||
@@ -375,10 +374,10 @@ public:
|
|||||||
|
|
||||||
private:
|
private:
|
||||||
// sources[0] is the legacy default input chain; always present + active.
|
// sources[0] is the legacy default input chain; always present + active.
|
||||||
SourceChain& source0() { return *sources[0]; }
|
SourceChain& source0() { return pool.chain0(); }
|
||||||
const SourceChain& source0() const { return *sources[0]; }
|
const SourceChain& source0() const { return pool.chain0(); }
|
||||||
// Input-device callback. In duplex it writes outputData directly; in split
|
// 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,
|
void audioDeviceIOCallbackWithContext(const float* const* inputData,
|
||||||
int numInputChannels,
|
int numInputChannels,
|
||||||
float* const* outputData,
|
float* const* outputData,
|
||||||
@@ -387,18 +386,8 @@ private:
|
|||||||
const juce::AudioIODeviceCallbackContext& context) override;
|
const juce::AudioIODeviceCallbackContext& context) override;
|
||||||
void audioDeviceAboutToStart(juce::AudioIODevice* device) override;
|
void audioDeviceAboutToStart(juce::AudioIODevice* device) override;
|
||||||
void audioDeviceStopped() override;
|
void audioDeviceStopped() override;
|
||||||
void stopBackingNoLock(); // caller holds backingLock
|
|
||||||
|
|
||||||
// Renders one block of the backing track into backingBuffer (1x bypass or
|
// Split-mode only: drains outputRing, mixes backing, writes to device.
|
||||||
// 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.
|
|
||||||
void audioOutputCallback(const float* const* inputData,
|
void audioOutputCallback(const float* const* inputData,
|
||||||
int numInputChannels,
|
int numInputChannels,
|
||||||
float* const* outputData,
|
float* const* outputData,
|
||||||
@@ -427,11 +416,8 @@ private:
|
|||||||
};
|
};
|
||||||
OutputCallback outputCallback{ *this };
|
OutputCallback outputCallback{ *this };
|
||||||
|
|
||||||
juce::String applyDuplexSetup(const juce::String& inputName,
|
// Probe/apply/teardown moved to engine/DeviceSetup (TLC phase 4);
|
||||||
const juce::String& outputName,
|
// setAudioDevices stays here as the orchestrator.
|
||||||
double sampleRate,
|
|
||||||
int bufferSize);
|
|
||||||
DeviceConfigResult applySplitSetup(const DeviceConfig& config);
|
|
||||||
void teardownSplitMode();
|
void teardownSplitMode();
|
||||||
|
|
||||||
// Duplex mode: inputDeviceManager owns both directions, outputDeviceManager idle.
|
// Duplex mode: inputDeviceManager owns both directions, outputDeviceManager idle.
|
||||||
@@ -439,59 +425,31 @@ private:
|
|||||||
// with an SPSC ring between them.
|
// with an SPSC ring between them.
|
||||||
juce::AudioDeviceManager inputDeviceManager;
|
juce::AudioDeviceManager inputDeviceManager;
|
||||||
juce::AudioDeviceManager outputDeviceManager;
|
juce::AudioDeviceManager outputDeviceManager;
|
||||||
std::atomic<bool> duplexMode{true};
|
|
||||||
|
|
||||||
// Per-input capture+detect+monitor chains. A FIXED pool, all constructed up
|
// Shared run-state atomics (TLC phase 1) — the members below are
|
||||||
// front, so adding/removing a source never reassigns a pointer the audio
|
// reference aliases under their historical names so call sites are
|
||||||
// thread is reading — addSource/removeSource only flip an atomic `active`
|
// untouched; extracted units take `state` (EngineState&) directly.
|
||||||
// flag (and prepare/release the chain). sources[0] is the legacy default,
|
slopsmith::EngineState state;
|
||||||
// active from construction and bound to the primary input device. The audio
|
std::atomic<bool>& duplexMode = state.duplexMode;
|
||||||
// callback fans device channels out to each active source and fans their
|
// Probe/apply/teardown component (TLC phase 4). Holds references only.
|
||||||
// monitor signals into the output mix. SourceChain reads the engine's
|
slopsmith::DeviceSetup deviceSetup{ inputDeviceManager, outputDeviceManager, state };
|
||||||
// audioRunning / currentSampleRate atomics through references bound at
|
|
||||||
// construction.
|
// Per-input capture+detect+monitor chains + the add/remove/reclaim
|
||||||
static constexpr int kMaxSources = 8;
|
// lifecycle + per-deviceKey quiescence handshake — moved to
|
||||||
// Max ADDITIONAL input devices (beyond the primary). Declared here — ahead of the
|
// engine/SourcePool (TLC phase 5). Constants mirrored for the members
|
||||||
// members that size arrays by it (e.g. callbacksInFlight) — though the extra-input
|
// that size arrays by them (extraInputs, and NodeAddon range checks).
|
||||||
// slot registry that uses it lives further below.
|
static constexpr int kMaxSources = slopsmith::SourcePool::kMaxSources;
|
||||||
static constexpr int kMaxExtraInputDevices = 3;
|
static constexpr int kMaxExtraInputDevices = slopsmith::SourcePool::kMaxExtraInputDevices;
|
||||||
std::array<std::unique_ptr<SourceChain>, kMaxSources> sources;
|
slopsmith::SourcePool pool{ state };
|
||||||
// Serialises addSource/removeSource (control threads only — never the audio
|
// Audio-thread scratch for the multi-source mix on the PRIMARY callback:
|
||||||
// thread, which just reads each slot's atomic `active`).
|
// each active source renders its 2-channel monitor here in turn, then it
|
||||||
std::mutex sourcesMutex;
|
// is summed into the output. Pre-sized in audioDeviceAboutToStart so the
|
||||||
// Audio-thread scratch for the multi-source mix: each active source renders
|
// hot loop never allocates. (Extra devices carry their own scratch.)
|
||||||
// its 2-channel monitor here in turn, then it is summed into the output.
|
|
||||||
// Pre-sized in audioDeviceAboutToStart so the hot loop never allocates.
|
|
||||||
juce::AudioBuffer<float> sourceMonitorScratch;
|
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.
|
// Master output (post-mix) — engine-global, not per-source.
|
||||||
std::atomic<float> outputGain{1.0f};
|
std::atomic<float> outputGain{1.0f};
|
||||||
std::atomic<float> backingVolume{0.8f};
|
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};
|
std::atomic<float> currentOutputLevel{0.0f};
|
||||||
// Per-block RMS of the backing-track mix bus, written by the audio thread
|
// 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
|
// 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> currentBackingLevel{0.0f};
|
||||||
std::atomic<float> outputPeak{0.0f};
|
std::atomic<float> outputPeak{0.0f};
|
||||||
|
|
||||||
// Backing track
|
// Backing track — transport/stretch/leveler moved to engine/BackingPlayer
|
||||||
// Read-ahead worker that fills the transport's buffer off the audio thread
|
// (TLC phase 3). Declared after `state` (bound by reference).
|
||||||
// (see loadBackingTrack). Declared BEFORE backingTransport so it is destroyed
|
slopsmith::BackingPlayer backing{state};
|
||||||
// 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;
|
|
||||||
|
|
||||||
// Toggled from startAudio()/stopAudio() (main / device-management
|
// audioRunning keeps its historical DEVICE-STATE semantics (isAudioRunning
|
||||||
// threads) and read from isAudioRunning() on the JS thread via the
|
// compat pin); the intent half is state.userWantsAudio — see EngineState.h.
|
||||||
// audio-bridge dispatch loop. Plain bool would be a data race;
|
std::atomic<bool>& audioRunning = state.deviceRunning;
|
||||||
// relaxed-atomic is well-defined and compiles to a plain MOV.
|
std::atomic<double>& currentSampleRate = state.currentSampleRate;
|
||||||
std::atomic<bool> audioRunning{false};
|
std::atomic<int>& inputBlockSize = state.inputBlockSize;
|
||||||
// Sample rate is written from the JUCE device callbacks (audio
|
std::atomic<int>& outputBlockSize = state.outputBlockSize;
|
||||||
// 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};
|
|
||||||
|
|
||||||
// The per-input lock-free SPSC rings (pre-gate getInputFrame ring + post-gate
|
// The per-input lock-free SPSC rings (pre-gate getInputFrame ring + post-gate
|
||||||
// getRawAudioFrame ring), the YIN/ML detectors, and the zero-output capture
|
// getRawAudioFrame ring), the YIN/ML detectors, and the zero-output capture
|
||||||
// scratch now live on SourceChain — one set per input source. See
|
// scratch now live on SourceChain — one set per input source. See
|
||||||
// SourceChain.h for the full lock-free / power-of-two / cold-start rationale.
|
// 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
|
// Split-mode SPSC ring (unused in duplex). Packed-LR single-atomic frames
|
||||||
// (L+R floats) into a single 64-bit atomic so the consumer reads both
|
// — see engine/PackedStereoRing.h for the tear/lock-free rationale (moved
|
||||||
// channels in one indivisible load — without packing, the producer's two
|
// there in TLC phase 1). ~85 ms @ 48 kHz — absorbs clock drift over
|
||||||
// separate atomic stores could interleave with the consumer's two loads
|
// typical sessions.
|
||||||
// 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.
|
|
||||||
static constexpr int kOutputRingFrames = 4096;
|
static constexpr int kOutputRingFrames = 4096;
|
||||||
std::array<std::atomic<uint64_t>, kOutputRingFrames> outputPendingRing{};
|
slopsmith::PackedStereoRing<kOutputRingFrames> outputRing;
|
||||||
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");
|
|
||||||
|
|
||||||
// Pack/unpack helpers — std::bit_cast (C++20) is constexpr + alias-safe.
|
// ── Renderer-audio bus (see engine/RendererBus.h — moved in TLC phase 2)
|
||||||
static inline uint64_t packLR(float l, float r) noexcept
|
slopsmith::RendererBus rendererBus;
|
||||||
{
|
|
||||||
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;
|
|
||||||
// Shared consumer step for the duplex and split output paths: drain one
|
// Shared consumer step for the duplex and split output paths: drain one
|
||||||
// block from the renderer-bus ring into `dest` (stereo, bus gain applied,
|
// block from the renderer-bus ring into `dest` (stereo, bus gain applied,
|
||||||
// dest cleared first). Returns numSamples on success, 0 when gated
|
// 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).
|
// in about-to-start next to the stream scratches (same no-realloc rule).
|
||||||
juce::AudioBuffer<float> rendererBusPullScratch;
|
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> outputUnderflowCount{0};
|
||||||
std::atomic<uint64_t> inputOverflowCount{0};
|
std::atomic<uint64_t> inputOverflowCount{0};
|
||||||
|
|
||||||
@@ -650,170 +510,24 @@ private:
|
|||||||
// leave a live registration behind after stopAudio()'s single remove.
|
// leave a live registration behind after stopAudio()'s single remove.
|
||||||
bool inputCallbackRegistered = false;
|
bool inputCallbackRegistered = false;
|
||||||
|
|
||||||
// ── Phase 2: additional input devices ────────────────────────────────────
|
// ── Additional input devices — moved to engine/ExtraInputs.{h,cpp}
|
||||||
// Each ADDITIONAL physical input device (a 2nd/3rd USB interface, e.g. two
|
// (TLC phase 5). The split output callback drains extraInputs.slots
|
||||||
// separate cables) gets its own AudioDeviceManager + callback running on its
|
// directly; declared after pool/state (bound by reference).
|
||||||
// OWN hardware clock, packing its sources' mixed monitor into its own SPSC
|
slopsmith::ExtraInputs extraInputs{ pool, state, inputDeviceManager };
|
||||||
// ring. audioOutputCallback drains+sums every active ring (drop-oldest wrap
|
using InputDeviceSlot = slopsmith::ExtraInputs::InputDeviceSlot;
|
||||||
// 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.)
|
|
||||||
|
|
||||||
// Forwards a JUCE device callback to the engine, tagged with the slot index.
|
// (mixSourcesForDevice moved to SourcePool::mixForDevice — TLC phase 5.)
|
||||||
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); }
|
|
||||||
};
|
|
||||||
|
|
||||||
struct InputDeviceSlot
|
// ── Streamer mix output sink — moved to engine/StreamSink.{h,cpp} (TLC
|
||||||
{
|
// phase 2). Declared after `state` (bound by reference).
|
||||||
juce::AudioDeviceManager manager;
|
slopsmith::StreamSink streamSink{state};
|
||||||
InputSlotCallback callback;
|
// Producer-side guitar monitor-mix snapshot (pre-backing), written by the
|
||||||
std::array<std::atomic<uint64_t>, kOutputRingFrames> ring{};
|
// primary/output callback and handed to streamSink.publish(). Sized in
|
||||||
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
|
|
||||||
// audioDeviceAboutToStart / audioOutputAboutToStart alongside the other scratch.
|
// audioDeviceAboutToStart / audioOutputAboutToStart alongside the other scratch.
|
||||||
juce::AudioBuffer<float> streamGuitarScratch;
|
juce::AudioBuffer<float> streamGuitarScratch;
|
||||||
juce::AudioBuffer<float> streamMixScratch;
|
|
||||||
|
|
||||||
// Clamp a requested stream gain to a finite, sane range so a NaN/Inf (or a
|
// 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.
|
// 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; }
|
static float sanitizeStreamGain(float g) { return slopsmith::sanitizeStreamGain(g); }
|
||||||
|
|
||||||
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);
|
|
||||||
|
|
||||||
JUCE_DECLARE_NON_COPYABLE_WITH_LEAK_DETECTOR(AudioEngine)
|
JUCE_DECLARE_NON_COPYABLE_WITH_LEAK_DETECTOR(AudioEngine)
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -6,6 +6,19 @@ set(AUDIO_SOURCES
|
|||||||
NoiseGate.cpp
|
NoiseGate.cpp
|
||||||
TonePolish.cpp
|
TonePolish.cpp
|
||||||
AudioEngine.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
|
SourceChain.cpp
|
||||||
SignalChain.cpp
|
SignalChain.cpp
|
||||||
VSTHost.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;
|
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);
|
const juce::ScopedLock sl(lock);
|
||||||
|
result.reserve((size_t) slots.size());
|
||||||
for (auto* slot : slots)
|
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;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+21
-1
@@ -2,6 +2,7 @@
|
|||||||
#include <juce_audio_processors/juce_audio_processors.h>
|
#include <juce_audio_processors/juce_audio_processors.h>
|
||||||
#include <juce_dsp/juce_dsp.h>
|
#include <juce_dsp/juce_dsp.h>
|
||||||
#include <array>
|
#include <array>
|
||||||
|
#include <vector>
|
||||||
|
|
||||||
// Represents a single processor slot in the signal chain.
|
// Represents a single processor slot in the signal chain.
|
||||||
// Can hold a VST3/AU/LV2 plugin, NAM model, or IR loader.
|
// Can hold a VST3/AU/LV2 plugin, NAM model, or IR loader.
|
||||||
@@ -95,7 +96,26 @@ public:
|
|||||||
// Info
|
// Info
|
||||||
int getNumSlots() const;
|
int getNumSlots() const;
|
||||||
const ProcessorSlot* getSlot(int slotId) 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
|
// Current prepared playback format — used to prepare a processor that is
|
||||||
// swapped in mid-session (replaceProcessor) at the same rate as the chain.
|
// swapped in mid-session (replaceProcessor) at the same rate as the chain.
|
||||||
double getCurrentSampleRate() const { return currentSampleRate; }
|
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 yet. Backing track still plays through. Suppressed during a song-load
|
||||||
// chain rebuild so the brief (or failed) empty-chain window doesn't silence
|
// chain rebuild so the brief (or failed) empty-chain window doesn't silence
|
||||||
// the guitar.
|
// 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();
|
buffer.clear();
|
||||||
|
|
||||||
// Full monitor kill: silence the guitar bus unconditionally — dry AND the
|
// Full monitor kill: silence the guitar bus unconditionally — dry AND the
|
||||||
|
|||||||
+42
-8
@@ -3,6 +3,7 @@
|
|||||||
#include "NoiseGate.h"
|
#include "NoiseGate.h"
|
||||||
#include "TonePolish.h"
|
#include "TonePolish.h"
|
||||||
#include "SignalChain.h"
|
#include "SignalChain.h"
|
||||||
|
#include "GainSanitize.h"
|
||||||
#include "PitchDetector.h"
|
#include "PitchDetector.h"
|
||||||
#include "ChordScorer.h"
|
#include "ChordScorer.h"
|
||||||
#include "MlNoteDetector.h"
|
#include "MlNoteDetector.h"
|
||||||
@@ -125,9 +126,11 @@ public:
|
|||||||
}
|
}
|
||||||
void setTonePolishEnabled(bool enabled) { tonePolish.setEnabled(enabled); }
|
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(); }
|
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(); }
|
float getChainOutputGain() const { return chainOutputGain.load(); }
|
||||||
void setInputChannel(int channel) { selectedInputChannel.store(channel); }
|
void setInputChannel(int channel) { selectedInputChannel.store(channel); }
|
||||||
int getInputChannel() const { return selectedInputChannel.load(); }
|
int getInputChannel() const { return selectedInputChannel.load(); }
|
||||||
@@ -139,10 +142,40 @@ public:
|
|||||||
// then a channel index WITHIN the bound device.
|
// then a channel index WITHIN the bound device.
|
||||||
void setDeviceKey(int key) { deviceKey.store(key, std::memory_order_release); }
|
void setDeviceKey(int key) { deviceKey.store(key, std::memory_order_release); }
|
||||||
int getDeviceKey() const { return deviceKey.load(std::memory_order_acquire); }
|
int getDeviceKey() const { return deviceKey.load(std::memory_order_acquire); }
|
||||||
void setMonitorMute(bool mute) { monitorMuted.store(mute); }
|
// ── Monitor-mute arbiter (TLC Part II §2 fix) ─────────────────────────────
|
||||||
bool isMonitorMuted() const { return monitorMuted.load(); }
|
// The old single monitorMuted atomic had FIVE writers (settings checkbox,
|
||||||
void setMonitorMuteSuppressed(bool s) { monitorMuteSuppressed.store(s); }
|
// startup restore, executor preload-mute, executor releaseRoute, renderer
|
||||||
bool isMonitorMuteSuppressed() const { return monitorMuteSuppressed.load(); }
|
// 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
|
// Full monitor kill — silences the guitar bus UNCONDITIONALLY (dry AND the
|
||||||
// processed/amp-sim signal), unlike setMonitorMute which only mutes the dry
|
// processed/amp-sim signal), unlike setMonitorMute which only mutes the dry
|
||||||
// pass-through when no processors are loaded. For users who monitor through
|
// 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<int> deviceKey{0}; // 0 = primary input device
|
||||||
std::atomic<double> verifierAutoOffset{0.0}; // engine: device-latency delta
|
std::atomic<double> verifierAutoOffset{0.0}; // engine: device-latency delta
|
||||||
std::atomic<double> verifierUserOffset{0.0}; // renderer: manual fine-tune
|
std::atomic<double> verifierUserOffset{0.0}; // renderer: manual fine-tune
|
||||||
std::atomic<bool> monitorMuted{true};
|
std::atomic<bool> userMonitorMute{true};
|
||||||
std::atomic<bool> monitorMuteSuppressed{false};
|
std::atomic<int> monitorMuteHolds{0};
|
||||||
|
std::atomic<int> monitorMuteSuppress{0};
|
||||||
std::atomic<bool> monitorKill{false};
|
std::atomic<bool> monitorKill{false};
|
||||||
std::atomic<uint32_t> nonFiniteChainBlocks{0};
|
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;
|
return await audio?.replaceIR(slotId, irPath, typeof gain === 'number' ? gain : -1) ?? false;
|
||||||
});
|
});
|
||||||
|
|
||||||
ipcMain.handle('audio:removeProcessor', (_event, slotId: number) => {
|
// The native chain mutators are async now (they take the chain-mutation
|
||||||
audio?.removeProcessor(slotId);
|
// 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);
|
vstSlotPaths.delete(slotId);
|
||||||
});
|
});
|
||||||
|
|
||||||
ipcMain.handle('audio:moveProcessor', (_event, from: number, to: number) => {
|
ipcMain.handle('audio:moveProcessor', async (_event, from: number, to: number) => {
|
||||||
audio?.moveProcessor(from, to);
|
await audio?.moveProcessor(from, to);
|
||||||
});
|
});
|
||||||
|
|
||||||
ipcMain.handle('audio:setBypass', (_event, slotId: number, bypassed: boolean) => {
|
ipcMain.handle('audio:setBypass', (_event, slotId: number, bypassed: boolean) => {
|
||||||
@@ -1221,8 +1225,8 @@ export function initAudioBridge(): void {
|
|||||||
audio?.setBranchSrc?.(slotId, src);
|
audio?.setBranchSrc?.(slotId, src);
|
||||||
});
|
});
|
||||||
|
|
||||||
ipcMain.handle('audio:clearChain', () => {
|
ipcMain.handle('audio:clearChain', async () => {
|
||||||
audio?.clearChain();
|
await audio?.clearChain();
|
||||||
vstSlotPaths.clear();
|
vstSlotPaths.clear();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -29,12 +29,15 @@ type AudioEffectsNativeAudio = {
|
|||||||
savePreset?: () => unknown;
|
savePreset?: () => unknown;
|
||||||
clearChain?: () => Promise<unknown> | unknown;
|
clearChain?: () => Promise<unknown> | unknown;
|
||||||
getChainState?: () => unknown;
|
getChainState?: () => unknown;
|
||||||
|
getChainGeneration?: () => unknown;
|
||||||
setBypass?: (slotId: number, bypassed: boolean) => unknown;
|
setBypass?: (slotId: number, bypassed: boolean) => unknown;
|
||||||
setMultiBypass?: (changes: Array<{ slotId: number; bypassed: boolean }>) => unknown;
|
setMultiBypass?: (changes: Array<{ slotId: number; bypassed: boolean }>) => unknown;
|
||||||
setParameter?: (slotId: number, paramIndex: number, value: number) => unknown;
|
setParameter?: (slotId: number, paramIndex: number, value: number) => unknown;
|
||||||
setGain?: (which: string, value: number) => Promise<unknown> | unknown;
|
setGain?: (which: string, value: number) => Promise<unknown> | unknown;
|
||||||
setMonitorMute?: (muted: boolean) => Promise<unknown> | unknown;
|
setMonitorMute?: (muted: boolean) => Promise<unknown> | unknown;
|
||||||
setMonitorMuteSuppressed?: (suppressed: boolean) => Promise<unknown> | unknown;
|
setMonitorMuteSuppressed?: (suppressed: boolean) => Promise<unknown> | unknown;
|
||||||
|
acquireMonitorMuteHold?: () => Promise<unknown> | unknown;
|
||||||
|
releaseMonitorMuteHold?: () => Promise<unknown> | unknown;
|
||||||
isMonitorMuted?: () => Promise<unknown> | unknown;
|
isMonitorMuted?: () => Promise<unknown> | unknown;
|
||||||
startAudio?: () => Promise<unknown> | unknown;
|
startAudio?: () => Promise<unknown> | unknown;
|
||||||
};
|
};
|
||||||
@@ -89,6 +92,11 @@ type RouteState = {
|
|||||||
state: string;
|
state: string;
|
||||||
activeSegmentId: string;
|
activeSegmentId: string;
|
||||||
stageSlots: Map<string, number>;
|
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>;
|
stageKinds: Map<string, string>;
|
||||||
segments: ValidSegment[];
|
segments: ValidSegment[];
|
||||||
loadedAt: string;
|
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);
|
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 {
|
return {
|
||||||
success: record.success === true,
|
success: record.success === true,
|
||||||
slotsLoaded: safeNumber(record.slotsLoaded, 0),
|
slotsLoaded: safeNumber(record.slotsLoaded, 0),
|
||||||
error: bounded(record.error ?? ''),
|
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[] {
|
function chainSlots(nativeAudio: AudioEffectsNativeAudio | null): Dict[] {
|
||||||
if (!nativeAudio || typeof nativeAudio.getChainState !== 'function') return [];
|
if (!nativeAudio || typeof nativeAudio.getChainState !== 'function') return [];
|
||||||
const state = nativeAudio.getChainState();
|
const state = nativeAudio.getChainState();
|
||||||
@@ -399,23 +415,35 @@ async function restorePreset(nativeAudio: AudioEffectsNativeAudio, presetJson: u
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function readMonitorMuted(nativeAudio: AudioEffectsNativeAudio): Promise<boolean | null> {
|
// Monitor-mute arbiter (TLC Part II §2): the executor no longer reads or
|
||||||
if (typeof nativeAudio.isMonitorMuted !== 'function') return null;
|
// writes the user's mute PREFERENCE. During a load it acquires a refcounted
|
||||||
try {
|
// override on the native arbiter — a force-mute hold (default) or a
|
||||||
return Boolean(await nativeAudio.isMonitorMuted());
|
// suppression (dryDuringLoad: dry guitar stays audible) — and RELEASES it
|
||||||
} catch (_) {
|
// afterwards. Returns a single-fire release closure (safe to call from a
|
||||||
return null;
|
// 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 */ }
|
||||||
|
};
|
||||||
}
|
}
|
||||||
}
|
if (!dryDuringLoad && typeof nativeAudio.acquireMonitorMuteHold === 'function') {
|
||||||
|
try { await nativeAudio.acquireMonitorMuteHold(); } catch (_) { return async () => { /* never acquired */ }; }
|
||||||
async function trySetMonitorMute(nativeAudio: AudioEffectsNativeAudio, muted: boolean): Promise<void> {
|
return async () => {
|
||||||
if (typeof nativeAudio.setMonitorMute !== 'function') return;
|
if (released) return;
|
||||||
try { await nativeAudio.setMonitorMute(muted); } catch (_) { /* best effort */ }
|
released = true;
|
||||||
}
|
try { await nativeAudio.releaseMonitorMuteHold?.(); } catch (_) { /* best effort */ }
|
||||||
|
};
|
||||||
async function trySetMonitorMuteSuppressed(nativeAudio: AudioEffectsNativeAudio, suppressed: boolean): Promise<void> {
|
}
|
||||||
if (typeof nativeAudio.setMonitorMuteSuppressed !== 'function') return;
|
// Addon predates the arbiter — degrade to no mute forcing rather than
|
||||||
try { await nativeAudio.setMonitorMuteSuppressed(suppressed); } catch (_) { /* best effort */ }
|
// reintroducing the preference-clobbering read/force/restore.
|
||||||
|
return async () => { /* nothing acquired */ };
|
||||||
}
|
}
|
||||||
|
|
||||||
async function trySetGain(nativeAudio: AudioEffectsNativeAudio, which: string, value: number): Promise<boolean> {
|
async function trySetGain(nativeAudio: AudioEffectsNativeAudio, which: string, value: number): Promise<boolean> {
|
||||||
@@ -435,10 +463,13 @@ async function applyGains(nativeAudio: AudioEffectsNativeAudio, gains: RouteGain
|
|||||||
return failed;
|
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 () => {
|
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 (shouldRestore && !shouldRestore()) return;
|
||||||
if (previousMonitorMute !== null) await trySetMonitorMute(nativeAudio, previousMonitorMute);
|
|
||||||
const restoreTarget = clampGain(targetGain, 1);
|
const restoreTarget = clampGain(targetGain, 1);
|
||||||
const steps = [restoreTarget * 0.25, restoreTarget * 0.5, restoreTarget * 0.8, restoreTarget];
|
const steps = [restoreTarget * 0.25, restoreTarget * 0.5, restoreTarget * 0.8, restoreTarget];
|
||||||
for (const value of steps) {
|
for (const value of steps) {
|
||||||
@@ -475,25 +506,24 @@ export function createAudioEffectsExecutor(getAudio: NativeAudioGetter) {
|
|||||||
const started = Date.now();
|
const started = Date.now();
|
||||||
const restoreVersion = ++preloadRestoreVersion;
|
const restoreVersion = ++preloadRestoreVersion;
|
||||||
const rollbackPreset = typeof nativeAudio.savePreset === 'function' ? nativeAudio.savePreset() : null;
|
const rollbackPreset = typeof nativeAudio.savePreset === 'function' ? nativeAudio.savePreset() : null;
|
||||||
let previousMonitorMute: boolean | null = null;
|
let releaseMuteOverride: (() => Promise<void>) | null = null;
|
||||||
if (options.preloadMute?.enabled) {
|
if (options.preloadMute?.enabled) {
|
||||||
previousMonitorMute = await readMonitorMuted(nativeAudio);
|
|
||||||
await trySetGain(nativeAudio, 'chain', 0);
|
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 {
|
try {
|
||||||
result = normalizeLoadResult(await nativeAudio.loadPreset(validation.presetJson));
|
result = normalizeLoadResult(await nativeAudio.loadPreset(validation.presetJson));
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const rollbackApplied = await restorePreset(nativeAudio, rollbackPreset);
|
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 });
|
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);
|
const nativeStages = validation.plan.stages.filter((stage) => stage.native);
|
||||||
if (!result.success) {
|
if (!result.success) {
|
||||||
const rollbackApplied = await restorePreset(nativeAudio, rollbackPreset);
|
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', {
|
return safeOutcome('failed', 'Native audio-effects plan load failed', {
|
||||||
routeKey: validation.plan.routeKey,
|
routeKey: validation.plan.routeKey,
|
||||||
providerId: validation.plan.providerId,
|
providerId: validation.plan.providerId,
|
||||||
@@ -508,7 +538,7 @@ export function createAudioEffectsExecutor(getAudio: NativeAudioGetter) {
|
|||||||
|
|
||||||
if (result.slotsLoaded < nativeStages.length) {
|
if (result.slotsLoaded < nativeStages.length) {
|
||||||
const rollbackApplied = await restorePreset(nativeAudio, rollbackPreset);
|
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', {
|
return safeOutcome('degraded', 'Native audio-effects plan partially loaded and was rolled back', {
|
||||||
routeKey: validation.plan.routeKey,
|
routeKey: validation.plan.routeKey,
|
||||||
providerId: validation.plan.providerId,
|
providerId: validation.plan.providerId,
|
||||||
@@ -525,7 +555,7 @@ export function createAudioEffectsExecutor(getAudio: NativeAudioGetter) {
|
|||||||
slots = chainSlots(nativeAudio);
|
slots = chainSlots(nativeAudio);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const rollbackApplied = await restorePreset(nativeAudio, rollbackPreset);
|
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', {
|
return safeOutcome('failed', 'Native chain-state lookup threw', {
|
||||||
routeKey: validation.plan.routeKey,
|
routeKey: validation.plan.routeKey,
|
||||||
providerId: validation.plan.providerId,
|
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.
|
// as handled while later stage operations silently return no-target. Roll back instead.
|
||||||
if (stageSlots.size !== nativeStages.length) {
|
if (stageSlots.size !== nativeStages.length) {
|
||||||
const rollbackApplied = await restorePreset(nativeAudio, rollbackPreset);
|
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', {
|
return safeOutcome('degraded', 'Native slot mapping was incomplete and was rolled back', {
|
||||||
routeKey: validation.plan.routeKey,
|
routeKey: validation.plan.routeKey,
|
||||||
providerId: validation.plan.providerId,
|
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 = {
|
const route: RouteState = {
|
||||||
routeKey: validation.plan.routeKey,
|
routeKey: validation.plan.routeKey,
|
||||||
providerId: validation.plan.providerId,
|
providerId: validation.plan.providerId,
|
||||||
@@ -563,6 +610,7 @@ export function createAudioEffectsExecutor(getAudio: NativeAudioGetter) {
|
|||||||
state: result.slotsLoaded >= nativeStages.length ? 'loaded' : 'degraded',
|
state: result.slotsLoaded >= nativeStages.length ? 'loaded' : 'degraded',
|
||||||
activeSegmentId: '',
|
activeSegmentId: '',
|
||||||
stageSlots,
|
stageSlots,
|
||||||
|
chainGeneration: result.chainGeneration,
|
||||||
stageKinds,
|
stageKinds,
|
||||||
segments: validation.plan.segments,
|
segments: validation.plan.segments,
|
||||||
loadedAt: now(),
|
loadedAt: now(),
|
||||||
@@ -575,7 +623,7 @@ export function createAudioEffectsExecutor(getAudio: NativeAudioGetter) {
|
|||||||
try { await nativeAudio.startAudio(); } catch (_) { /* load succeeded; start is best-effort */ }
|
try { await nativeAudio.startAudio(); } catch (_) { /* load succeeded; start is best-effort */ }
|
||||||
}
|
}
|
||||||
if (options.preloadMute?.enabled) {
|
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);
|
const current = routes.get(validation.plan.routeKey);
|
||||||
return restoreVersion === preloadRestoreVersion && current?.planId === validation.plan.planId;
|
return restoreVersion === preloadRestoreVersion && current?.planId === validation.plan.planId;
|
||||||
});
|
});
|
||||||
@@ -613,8 +661,12 @@ export function createAudioEffectsExecutor(getAudio: NativeAudioGetter) {
|
|||||||
} catch (error) {
|
} catch (error) {
|
||||||
releaseFailure = safeOutcome('failed', 'Native route release threw', { routeKey, error: bounded(error instanceof Error ? error.message : String(error)) });
|
releaseFailure = safeOutcome('failed', 'Native route release threw', { routeKey, error: bounded(error instanceof Error ? error.message : String(error)) });
|
||||||
}
|
}
|
||||||
await trySetMonitorMute(nativeAudio, true);
|
// Arbiter fix: releaseRoute used to FORCE monitorMute=true and clear
|
||||||
await trySetMonitorMuteSuppressed(nativeAudio, false);
|
// 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);
|
if (releaseFailure) return updateOutcome(route, releaseFailure);
|
||||||
routes.delete(routeKey);
|
routes.delete(routeKey);
|
||||||
return safeOutcome('handled', 'Audio-effects route released', { routeKey, providerId: route.providerId, planId: route.planId, cleanupFailures });
|
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 }));
|
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> {
|
async function setStageBypass(request: unknown): Promise<SafeOutcome> {
|
||||||
const input = asRecord(request) || {};
|
const input = asRecord(request) || {};
|
||||||
const routeKey = safeId(input.routeKey ?? DEFAULT_ROUTE_KEY, DEFAULT_ROUTE_KEY);
|
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 }));
|
if (slotId == null) return updateOutcome(route, safeOutcome('no-target', 'Audio-effects stage is not mapped to a native slot', { routeKey, stageId }));
|
||||||
const nativeAudio = getAudio();
|
const nativeAudio = getAudio();
|
||||||
if (!nativeAudio || typeof nativeAudio.setBypass !== 'function') return updateOutcome(route, safeOutcome('unavailable', 'Native stage bypass is unavailable', { routeKey, stageId }));
|
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 {
|
try {
|
||||||
const result = await nativeAudio.setBypass(slotId, safeBool(input.bypassed, false));
|
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 }));
|
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();
|
const nativeAudio = getAudio();
|
||||||
if (!nativeAudio || typeof nativeAudio.setParameter !== 'function') return updateOutcome(route, safeOutcome('unavailable', 'Native stage parameter control is unavailable', { routeKey, stageId }));
|
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 {
|
try {
|
||||||
const result = await nativeAudio.setParameter(slotId, paramIndex, value);
|
const result = await nativeAudio.setParameter(slotId, paramIndex, value);
|
||||||
if (nativeFailure(result)) return updateOutcome(route, safeOutcome('failed', 'Native stage parameter returned failure', { routeKey, stageId, paramIndex }));
|
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 }));
|
if (!segment) return updateOutcome(route, safeOutcome('no-target', 'Audio-effects segment is not present in the loaded plan', { routeKey, segmentId }));
|
||||||
const nativeAudio = getAudio();
|
const nativeAudio = getAudio();
|
||||||
if (!nativeAudio || typeof nativeAudio.setMultiBypass !== 'function') return updateOutcome(route, safeOutcome('unavailable', 'Native multi-bypass is unavailable', { routeKey, segmentId }));
|
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 active = new Set(segment.stageIds);
|
||||||
const changes = Array.from(route.stageSlots.entries()).map(([stageId, slotId]) => ({
|
const changes = Array.from(route.stageSlots.entries()).map(([stageId, slotId]) => ({
|
||||||
slotId,
|
slotId,
|
||||||
|
|||||||
+61
-12
@@ -210,11 +210,14 @@ window.__feedBackDesktopAudioHooks = window.__feedBackDesktopAudioHooks || {};
|
|||||||
}
|
}
|
||||||
|
|
||||||
function saveDeviceSettings(settings = captureDeviceSettings()) {
|
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 = {
|
const snapshot = {
|
||||||
...cloneDeviceSettings(settings),
|
...cloneDeviceSettings(settings),
|
||||||
savedAt: Date.now(),
|
savedAt: Date.now(),
|
||||||
};
|
};
|
||||||
try { localStorage.setItem('slopsmith-audio-device', JSON.stringify(snapshot)); } catch (_) {}
|
|
||||||
pendingDeviceSave = pendingDeviceSave
|
pendingDeviceSave = pendingDeviceSave
|
||||||
.catch(() => null)
|
.catch(() => null)
|
||||||
.then(() => {
|
.then(() => {
|
||||||
@@ -255,17 +258,38 @@ window.__feedBackDesktopAudioHooks = window.__feedBackDesktopAudioHooks || {};
|
|||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.warn('[audio-engine] Failed to load file-backed device settings:', 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;
|
let browserSettings = null;
|
||||||
try {
|
try {
|
||||||
const raw = localStorage.getItem('slopsmith-audio-device');
|
const raw = localStorage.getItem('slopsmith-audio-device');
|
||||||
browserSettings = normalizeDeviceSettings(raw ? JSON.parse(raw) : null);
|
browserSettings = normalizeDeviceSettings(raw ? JSON.parse(raw) : null);
|
||||||
} catch { browserSettings = null; }
|
} catch { browserSettings = null; }
|
||||||
if (fileSettings && browserSettings) {
|
if (browserSettings !== null) {
|
||||||
return getDeviceSettingsSavedAt(browserSettings) > getDeviceSettingsSavedAt(fileSettings)
|
const browserNewer = !fileSettings
|
||||||
? browserSettings
|
|| getDeviceSettingsSavedAt(browserSettings) > getDeviceSettingsSavedAt(fileSettings);
|
||||||
: 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) {
|
function hasSettingValue(value) {
|
||||||
@@ -4352,16 +4376,41 @@ window.__feedBackDesktopAudioHooks = window.__feedBackDesktopAudioHooks || {};
|
|||||||
// the preload below. While the chain is empty the native engine's monitor
|
// 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
|
// mute would silence the dry guitar. Suppress the mute for the rebuild
|
||||||
// window so the guitar keeps sounding; resolve it once the chain settles.
|
// 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) {
|
function aeSetMonitorMuteSuppressed(suppressed) {
|
||||||
|
const want = !!suppressed;
|
||||||
|
if (want === aeMonitorMuteSuppressionHeld) return; // idempotent, like the old bool
|
||||||
const api = window.feedBackDesktop?.audio;
|
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
|
// setMonitorMuteSuppressed is async (ipcRenderer.invoke) — the sync
|
||||||
// try/catch only covers a missing method, so also swallow the
|
// try/catch only covers a throwing call, so handle the returned
|
||||||
// returned promise's rejection to avoid an unhandled rejection.
|
// promise's rejection too (which also avoids an unhandled rejection).
|
||||||
try {
|
try {
|
||||||
const r = api?.setMonitorMuteSuppressed?.(suppressed);
|
const r = api.setMonitorMuteSuppressed(want);
|
||||||
if (r && typeof r.catch === 'function') r.catch(() => {});
|
if (r && typeof r.catch === 'function') r.catch(rollback);
|
||||||
} catch (_) { /* downlevel */ }
|
} catch (_) { rollback(); }
|
||||||
}
|
}
|
||||||
// Called by clearChainForNewSong (IIFE 1) and the preload below.
|
// Called by clearChainForNewSong (IIFE 1) and the preload below.
|
||||||
window._aeBeginChainRebuildGuard = function () { aeSetMonitorMuteSuppressed(true); };
|
window._aeBeginChainRebuildGuard = function () { aeSetMonitorMuteSuppressed(true); };
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ endif()
|
|||||||
|
|
||||||
# Pure-helper tests (no JUCE / no platform deps) build everywhere.
|
# Pure-helper tests (no JUCE / no platform deps) build everywhere.
|
||||||
add_subdirectory(audio_sanitize)
|
add_subdirectory(audio_sanitize)
|
||||||
|
add_subdirectory(engine_units)
|
||||||
|
|
||||||
# Note: enable_testing() lives in the top-level CMakeLists.txt — calling it
|
# Note: enable_testing() lives in the top-level CMakeLists.txt — calling it
|
||||||
# only here would register tests in build/tests/CTestTestfile.cmake but
|
# 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(gained.outcome, 'handled');
|
||||||
assert.equal(released.outcome, 'handled');
|
assert.equal(released.outcome, 'handled');
|
||||||
assert.equal(inspected.outcome, 'no-target');
|
assert.equal(inspected.outcome, 'no-target');
|
||||||
assert.deepEqual(calls.slice(0, 7), [
|
// Monitor-mute arbiter (TLC Part II §2): the executor never reads or
|
||||||
['is-muted'],
|
// 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],
|
['gain', 'chain', 0],
|
||||||
['monitor', false],
|
['suppress', true],
|
||||||
['load', 2],
|
['load', 2],
|
||||||
['gain', 'input', 8],
|
['gain', 'input', 8],
|
||||||
['start'],
|
['start'],
|
||||||
['gain', 'chain', 2],
|
['gain', 'chain', 2],
|
||||||
]);
|
]);
|
||||||
assert.equal(calls.some(call => call[0] === 'clear'), true);
|
assert.equal(calls.some(call => call[0] === 'clear'), true);
|
||||||
assert.equal(calls.some(call => call[0] === 'monitor' && call[1] === true), true);
|
// The preference API is untouched, in both directions — releaseRoute no
|
||||||
assert.equal(calls.some(call => call[0] === 'suppress' && call[1] === false), true);
|
// 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] === 4), false);
|
||||||
assert.equal(calls.some(call => call[0] === 'gain' && call[1] === 'chain' && call[2] === 0), true);
|
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', () => {
|
test('preload exposes the trusted audio-effects executor surface', () => {
|
||||||
const preload = fs.readFileSync(path.join(ROOT, 'src', 'main', 'preload.ts'), 'utf8');
|
// Normalize line endings: the multi-line snippet assertion below uses
|
||||||
const bridge = fs.readFileSync(path.join(ROOT, 'src', 'main', 'audio-bridge.ts'), 'utf8');
|
// \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);
|
assert.equal(preload.includes('audioEffects: {'), true);
|
||||||
for (const method of ['loadChainPlan', 'releaseRoute', 'inspectRoute', 'activateSegment', 'setStageBypass', 'setStageParameter', 'setRouteGain']) {
|
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('vstSlotPaths.clear();\n return await audioEffects.loadChainPlan(request);'), true);
|
||||||
assert.equal(bridge.includes('if (normalizedPayload.inputType !== normalizedPayload.outputType)'), 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.pluginStateAndPyDeps,
|
||||||
...cats.configDbsAndState,
|
...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 = [
|
const protectedRoots = [
|
||||||
env.dlcDir,
|
env.dlcDir,
|
||||||
env.pluginsDir,
|
env.pluginsDir,
|
||||||
path.join(env.cacheBase, 'torch'),
|
env.torchHome,
|
||||||
path.join(env.cacheBase, 'huggingface'),
|
env.hfHome,
|
||||||
];
|
];
|
||||||
for (const root of protectedRoots) {
|
for (const root of protectedRoots) {
|
||||||
assert.ok(!safe.includes(root), `${name}: ${root} leaked into a safe category`);
|
assert.ok(!safe.includes(root), `${name}: ${root} leaked into a safe category`);
|
||||||
assert.ok(
|
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}`,
|
`${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.installedPlugins, [env.pluginsDir], `${name}: installedPlugins`);
|
||||||
assert.deepEqual(
|
assert.deepEqual(
|
||||||
cats.optInExtras.mlCaches,
|
cats.optInExtras.mlCaches,
|
||||||
[path.join(env.cacheBase, 'torch'), path.join(env.cacheBase, 'huggingface')],
|
[env.torchHome, env.hfHome],
|
||||||
`${name}: mlCaches`,
|
`${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