From bbb3b58db8493e9cc600b236d9cb5e5acca268ac Mon Sep 17 00:00:00 2001 From: OmikronApex Date: Mon, 13 Jul 2026 23:21:21 +0200 Subject: [PATCH 01/28] test(contracts): Phase 0.a contract snapshots for audio surface MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Snapshot the three public surfaces the audio-engine decomposition must not change (docs/audio-engine-tlc.md Part IV §4): addon export table, audio-bridge IPC channels, preload audio/audioEffects API keys. contract-check.test.js diffs regenerated surfaces against the committed snapshots. result-shapes.json (golden result key/type shapes) is deferred until the engine_units harness can run the addon against a null device. Co-Authored-By: Claude Fable 5 --- tests/contract-check.test.js | 34 ++++++++ tests/contracts/addon-exports.json | 103 +++++++++++++++++++++++ tests/contracts/extract.js | 86 +++++++++++++++++++ tests/contracts/ipc-channels.json | 107 +++++++++++++++++++++++ tests/contracts/preload-audio-api.json | 112 +++++++++++++++++++++++++ 5 files changed, 442 insertions(+) create mode 100644 tests/contract-check.test.js create mode 100644 tests/contracts/addon-exports.json create mode 100644 tests/contracts/extract.js create mode 100644 tests/contracts/ipc-channels.json create mode 100644 tests/contracts/preload-audio-api.json diff --git a/tests/contract-check.test.js b/tests/contract-check.test.js new file mode 100644 index 0000000..aed2f44 --- /dev/null +++ b/tests/contract-check.test.js @@ -0,0 +1,34 @@ +// Phase 0.a gate (docs/audio-engine-tlc.md §4): the public audio surface — +// addon exports, IPC channels, preload API keys — must not change during the +// decomposition phases. Removals/renames fail here; deliberate additions +// require regenerating the snapshots in the same commit: +// node tests/contracts/extract.js +'use strict'; + +const test = require('node:test'); +const assert = require('node:assert'); +const fs = require('fs'); +const path = require('path'); + +const { extractAddonExports, extractIpcChannels, extractPreloadApi } = require('./contracts/extract.js'); + +function loadSnapshot(name) { + return JSON.parse(fs.readFileSync(path.join(__dirname, 'contracts', name), 'utf8')); +} + +test('addon export table matches snapshot', (t) => { + const current = extractAddonExports(); + if (!current) { + t.skip('slopsmith_audio.node not built'); + return; + } + assert.deepStrictEqual(current, loadSnapshot('addon-exports.json')); +}); + +test('audio-bridge IPC channels match snapshot', () => { + assert.deepStrictEqual(extractIpcChannels(), loadSnapshot('ipc-channels.json')); +}); + +test('preload audio/audioEffects API keys match snapshot', () => { + assert.deepStrictEqual(extractPreloadApi(), loadSnapshot('preload-audio-api.json')); +}); diff --git a/tests/contracts/addon-exports.json b/tests/contracts/addon-exports.json new file mode 100644 index 0000000..c1a6da0 --- /dev/null +++ b/tests/contracts/addon-exports.json @@ -0,0 +1,103 @@ +[ + "addSource", + "bindInputDevice", + "clearChain", + "clearStreamOutput", + "closePluginEditor", + "detectNotes", + "enableFileLogging", + "getBackingDuration", + "getBackingLevel", + "getBackingPosition", + "getBufferSizes", + "getChainState", + "getCurrentDevice", + "getDeviceMetrics", + "getDeviceTypes", + "getKnownPlugins", + "getLevels", + "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", + "removeProcessor", + "removeSource", + "replaceIR", + "resetPeaks", + "savePluginList", + "savePreset", + "scanPlugins", + "scoreChord", + "scoreSourceChord", + "seekBacking", + "sendMidiToSlot", + "setBackingSpeed", + "setBranch", + "setBranchSrc", + "setBypass", + "setChart", + "setCrashedPlugins", + "setDevice", + "setDeviceType", + "setGain", + "setInputChannel", + "setInputDeviceType", + "setMonitorKill", + "setMonitorMute", + "setMonitorMuteSuppressed", + "setMultiBypass", + "setNoiseGate", + "setNoteDetectionEnabled", + "setOutputDeviceType", + "setPan", + "setParameter", + "setPostGain", + "setRendererBus", + "setSlotState", + "setSourceChart", + "setSourceInputChannel", + "setSourceMonitorMute", + "setSourceVerifierOffset", + "setStreamBus", + "setStreamBusGain", + "setStreamOutputDevice", + "setTonePolish", + "setVstCrashSentinelPath", + "shutdown", + "startAudio", + "startBacking", + "stopAudio", + "stopBacking", + "unbindInputDevice" +] diff --git a/tests/contracts/extract.js b/tests/contracts/extract.js new file mode 100644 index 0000000..b4ada84 --- /dev/null +++ b/tests/contracts/extract.js @@ -0,0 +1,86 @@ +// Contract-surface extraction for the audio engine TLC refactor (Phase 0.a, +// docs/audio-engine-tlc.md §4). Each extractor returns a sorted, stable JSON +// snapshot of one public surface. contract-check.test.js diffs these against +// the committed snapshots so a decomposition phase cannot silently change the +// public API. Regenerate deliberately with: node tests/contracts/extract.js +'use strict'; + +const fs = require('fs'); +const path = require('path'); + +const repoRoot = path.join(__dirname, '..', '..'); + +// Export table of slopsmith_audio.node. Loads the real binary; returns null +// when it hasn't been built (contract-check skips with a warning then). +function extractAddonExports() { + const addonPath = path.join(repoRoot, 'build', 'Release', 'slopsmith_audio.node'); + if (!fs.existsSync(addonPath)) return null; + const addon = require(addonPath); + return Object.keys(addon).sort(); +} + +// Every ipcMain.handle / ipcMain.on channel registered in audio-bridge.ts. +function extractIpcChannels() { + const src = fs.readFileSync(path.join(repoRoot, 'src', 'main', 'audio-bridge.ts'), 'utf8'); + const channels = new Set(); + const re = /ipcMain\.(?:handle|on)\(\s*'([^']+)'/g; + let m; + while ((m = re.exec(src)) !== null) channels.add(m[1]); + return [...channels].sort(); +} + +// Top-level method keys of the `audio:` and `audioEffects:` object literals in +// preload.ts — the surface the renderer (and every plugin) programs against. +// Brace-depth walk: record keys only at depth 1 inside the target literal. +function extractPreloadKeys(objectName, src) { + const start = src.indexOf(`${objectName}: {`); + if (start === -1) throw new Error(`preload.ts: '${objectName}: {' not found`); + let i = src.indexOf('{', start); + let depth = 0; + let parenDepth = 0; // multi-line parameter lists must not yield keys + const keys = []; + let lineStart = i; + for (; i < src.length; i++) { + const c = src[i]; + if (c === '{') depth++; + else if (c === '}') { + depth--; + if (depth === 0) break; + } else if (c === '(') parenDepth++; + else if (c === ')') parenDepth--; + else if (c === '\n') { + lineStart = i + 1; + } else if (depth === 1 && parenDepth === 0) { + // At a key position: line begins (after whitespace) with `name:` + if (i === lineStart) { + const line = src.slice(lineStart, src.indexOf('\n', lineStart)); + const km = line.match(/^\s*([A-Za-z_$][\w$]*)\s*:/); + if (km) keys.push(km[1]); + } + } + } + return keys.sort(); +} + +function extractPreloadApi() { + const src = fs.readFileSync(path.join(repoRoot, 'src', 'main', 'preload.ts'), 'utf8'); + return { + audio: extractPreloadKeys('audio', src), + audioEffects: extractPreloadKeys('audioEffects', src), + }; +} + +function writeSnapshot(name, data) { + fs.writeFileSync(path.join(__dirname, name), JSON.stringify(data, null, 2) + '\n'); +} + +module.exports = { extractAddonExports, extractIpcChannels, extractPreloadApi }; + +if (require.main === module) { + const addonExports = extractAddonExports(); + if (addonExports) writeSnapshot('addon-exports.json', addonExports); + else console.warn('addon not built — skipping addon-exports.json'); + writeSnapshot('ipc-channels.json', extractIpcChannels()); + writeSnapshot('preload-audio-api.json', extractPreloadApi()); + console.log('contract snapshots written to tests/contracts/'); +} diff --git a/tests/contracts/ipc-channels.json b/tests/contracts/ipc-channels.json new file mode 100644 index 0000000..9b9dee0 --- /dev/null +++ b/tests/contracts/ipc-channels.json @@ -0,0 +1,107 @@ +[ + "audio-effects:activateSegment", + "audio-effects:inspectRoute", + "audio-effects:loadChainPlan", + "audio-effects:releaseRoute", + "audio-effects:setRouteGain", + "audio-effects:setStageBypass", + "audio-effects:setStageParameter", + "audio:addSource", + "audio:bindInputDevice", + "audio:clearChain", + "audio:clearStreamOutput", + "audio:closePluginEditor", + "audio:detectNotes", + "audio:getBackingDuration", + "audio:getBackingLevel", + "audio:getBackingPosition", + "audio:getBufferSizes", + "audio:getChainState", + "audio:getCurrentDevice", + "audio:getDeviceMetrics", + "audio:getDeviceTypes", + "audio:getKnownPlugins", + "audio:getLevels", + "audio:getNoteVerdicts", + "audio:getParameters", + "audio:getPitchDetection", + "audio:getRawAudioFrame", + "audio:getRawPitch", + "audio:getRendererBusMetrics", + "audio:getSampleRate", + "audio:getSampleRates", + "audio:getSourceLevels", + "audio:getSourceNoteVerdicts", + "audio:getSourcePitchDetection", + "audio:getSourceRawAudioFrame", + "audio:getSourceRawPitch", + "audio:getStreamOverflowCount", + "audio:getStreamSinkLevel", + "audio:getStreamUnderflowCount", + "audio:isAudioRunning", + "audio:isAvailable", + "audio:isBackingPlaying", + "audio:isMlNoteDetection", + "audio:isMonitorMuted", + "audio:isStreamOutputActive", + "audio:listInputDevices", + "audio:listSources", + "audio:loadBackingTrack", + "audio:loadDeviceSettings", + "audio:loadIR", + "audio:loadNAMModel", + "audio:loadPluginList", + "audio:loadPreset", + "audio:loadVST", + "audio:moveProcessor", + "audio:openPluginEditor", + "audio:probeDeviceOptions", + "audio:pushRendererAudio", + "audio:removeProcessor", + "audio:removeSource", + "audio:replaceIR", + "audio:resetPeaks", + "audio:saveDeviceSettings", + "audio:savePluginList", + "audio:savePreset", + "audio:scanPlugins", + "audio:scoreChord", + "audio:scoreSourceChord", + "audio:seekBacking", + "audio:sendMidiToSlot", + "audio:setBackingSpeed", + "audio:setBranch", + "audio:setBranchSrc", + "audio:setBypass", + "audio:setChart", + "audio:setDevice", + "audio:setDeviceType", + "audio:setGain", + "audio:setInputChannel", + "audio:setMonitorKill", + "audio:setMonitorMute", + "audio:setMonitorMuteSuppressed", + "audio:setMultiBypass", + "audio:setNoiseGate", + "audio:setNoteDetectionEnabled", + "audio:setOutputDeviceType", + "audio:setPan", + "audio:setParameter", + "audio:setPostGain", + "audio:setRendererBus", + "audio:setSlotState", + "audio:setSourceChart", + "audio:setSourceInputChannel", + "audio:setSourceMonitorMute", + "audio:setSourceVerifierOffset", + "audio:setStreamBus", + "audio:setStreamBusGain", + "audio:setStreamOutputDevice", + "audio:setTonePolish", + "audio:startAudio", + "audio:startBacking", + "audio:stopAudio", + "audio:stopBacking", + "audio:unbindInputDevice", + "debug:isEnabled" +] diff --git a/tests/contracts/preload-audio-api.json b/tests/contracts/preload-audio-api.json new file mode 100644 index 0000000..50e124c --- /dev/null +++ b/tests/contracts/preload-audio-api.json @@ -0,0 +1,112 @@ +{ + "audio": [ + "addSource", + "bindInputDevice", + "clearChain", + "clearStreamOutput", + "closePluginEditor", + "debugEnabled", + "detectNotes", + "getBackingDuration", + "getBackingLevel", + "getBackingPosition", + "getBufferSizes", + "getChainState", + "getCurrentDevice", + "getDeviceMetrics", + "getDeviceTypes", + "getKnownPlugins", + "getLevels", + "getNoteVerdicts", + "getParameters", + "getPitchDetection", + "getRawAudioFrame", + "getRawPitch", + "getRendererBusMetrics", + "getSampleRate", + "getSampleRates", + "getSourceLevels", + "getSourceNoteVerdicts", + "getSourcePitchDetection", + "getSourceRawAudioFrame", + "getSourceRawPitch", + "getStreamOverflowCount", + "getStreamSinkLevel", + "getStreamUnderflowCount", + "isAudioRunning", + "isAvailable", + "isBackingPlaying", + "isMlNoteDetection", + "isMonitorMuted", + "isStreamOutputActive", + "listInputDevices", + "listSources", + "loadBackingTrack", + "loadDeviceSettings", + "loadIR", + "loadNAMModel", + "loadPluginList", + "loadPreset", + "loadVST", + "moveProcessor", + "openPluginEditor", + "probeDeviceOptions", + "pushRendererAudio", + "removeProcessor", + "removeSource", + "replaceIR", + "resetPeaks", + "saveDeviceSettings", + "savePluginList", + "savePreset", + "scanPlugins", + "scoreChord", + "scoreSourceChord", + "seekBacking", + "sendMidiToSlot", + "setBackingSpeed", + "setBranch", + "setBranchSrc", + "setBypass", + "setChart", + "setDevice", + "setDeviceType", + "setGain", + "setInputChannel", + "setMonitorKill", + "setMonitorMute", + "setMonitorMuteSuppressed", + "setMultiBypass", + "setNoiseGate", + "setNoteDetectionEnabled", + "setOutputDeviceType", + "setPageMuted", + "setPan", + "setParameter", + "setPostGain", + "setRendererBus", + "setSlotState", + "setSourceChart", + "setSourceInputChannel", + "setSourceMonitorMute", + "setSourceVerifierOffset", + "setStreamBus", + "setStreamBusGain", + "setStreamOutputDevice", + "setTonePolish", + "startAudio", + "startBacking", + "stopAudio", + "stopBacking", + "unbindInputDevice" + ], + "audioEffects": [ + "activateSegment", + "inspectRoute", + "loadChainPlan", + "releaseRoute", + "setRouteGain", + "setStageBypass", + "setStageParameter" + ] +} From 3c8dd62ecbebd96cc95d99b0a37ff6c49659268a Mon Sep 17 00:00:00 2001 From: OmikronApex Date: Mon, 13 Jul 2026 23:28:34 +0200 Subject: [PATCH 02/28] fix(audio): sanitize input/chain/output/backing gains at the engine setters MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit NaN/Inf from any JS caller (audio:setGain does no validation) previously reached the gain atomics raw; a NaN master gain multiplies the whole device output to NaN and poisons the peak meters (TLC deep-read §2). Clamp at the four setters — the single choke point covering the legacy facade, the source-indexed API, and the audio-effects executor. Bounds 0..32 match the executor's clampGain (Phase 0.b compat pin); stream/ renderer-bus keep their historical 0..8 via the same JUCE-free helper, now testable in the new tests/engine_units target (Phase 0.c harness). Co-Authored-By: Claude Fable 5 --- src/audio/AudioEngine.h | 10 +++-- src/audio/GainSanitize.h | 36 +++++++++++++++++ src/audio/SourceChain.h | 7 +++- tests/CMakeLists.txt | 1 + tests/engine_units/CMakeLists.txt | 7 ++++ tests/engine_units/gain_sanitize_test.cpp | 48 +++++++++++++++++++++++ 6 files changed, 104 insertions(+), 5 deletions(-) create mode 100644 src/audio/GainSanitize.h create mode 100644 tests/engine_units/CMakeLists.txt create mode 100644 tests/engine_units/gain_sanitize_test.cpp diff --git a/src/audio/AudioEngine.h b/src/audio/AudioEngine.h index a384ae5..7aa975a 100644 --- a/src/audio/AudioEngine.h +++ b/src/audio/AudioEngine.h @@ -1,5 +1,6 @@ #pragma once #include "SourceChain.h" +#include "GainSanitize.h" #include "BackingLeveler.h" #include "signalsmith-stretch.h" #include @@ -154,7 +155,10 @@ public: // Gain controls. Input + chain-output gain are per-source (sources[0]); // output gain is the post-mix master and stays engine-global. void setInputGain(float gain) { source0().setInputGain(gain); } - void setOutputGain(float gain) { outputGain.store(gain); } + // Sanitized (see GainSanitize.h): a NaN/Inf master gain from JS would + // multiply the whole device output to NaN downstream of the per-source + // scrub — clamp at the store so every caller is covered. + void setOutputGain(float gain) { outputGain.store(slopsmith::sanitizeMasterGain(gain)); } float getInputGain() const { return source0().getInputGain(); } float getOutputGain() const { return outputGain.load(); } @@ -216,7 +220,7 @@ public: void setTonePolishEnabled(bool enabled) { source0().setTonePolishEnabled(enabled); } // Backing track - void setBackingVolume(float vol) { backingVolume.store(vol); } + void setBackingVolume(float vol) { backingVolume.store(slopsmith::sanitizeMasterGain(vol)); } bool loadBackingTrack(const juce::File& file); void setBackingPosition(double seconds); void startBacking(); @@ -792,7 +796,7 @@ private: // Clamp a requested stream gain to a finite, sane range so a NaN/Inf (or a // wild value) from the JS bridge can never be packed into the stream ring. - static float sanitizeStreamGain(float g) { return std::isfinite(g) ? juce::jlimit(0.0f, 8.0f, g) : 0.0f; } + static float sanitizeStreamGain(float g) { return slopsmith::sanitizeStreamGain(g); } void streamSinkCallback(float* const* outputData, int numOutputChannels, int numSamples); void streamSinkAboutToStart(juce::AudioIODevice* device); diff --git a/src/audio/GainSanitize.h b/src/audio/GainSanitize.h new file mode 100644 index 0000000..9f19310 --- /dev/null +++ b/src/audio/GainSanitize.h @@ -0,0 +1,36 @@ +#pragma once + +// Gain-argument containment (audio-engine TLC, deep-read §2). +// +// N-API's Number coercion lets NaN/Infinity from JS reach the engine's gain +// atomics raw — a NaN master gain multiplies the whole device output to NaN +// (buffer.applyGain) and poisons the peak meters, and nothing downstream +// scrubs it (the per-source NaN scrub runs before the master gain). Clamping +// at the engine setters is the single choke point that fixes every caller: +// audio:setGain, the source-indexed API, and the audio-effects executor. +// +// Bounds: 0..32 for input/chain/output/backing — matching the executor's +// JS-side clampGain so a legit high rig gain is never under-shot (compat pin, +// docs/audio-engine-tlc.md Phase 0.b). The stream/renderer-bus gains keep +// their tighter historical 0..8 (previously sanitizeStreamGain). +// +// JUCE-free on purpose, like AudioSanitize.h, so tests/engine_units can test +// it without a device. + +#include + +namespace slopsmith { + +// Non-finite → 0 (silence beats a poisoned mix); otherwise clamp to [0, max]. +inline float sanitizeGain(float g, float maxGain) noexcept +{ + if (!std::isfinite(g)) return 0.0f; + if (g < 0.0f) return 0.0f; + if (g > maxGain) return maxGain; + return g; +} + +inline float sanitizeMasterGain(float g) noexcept { return sanitizeGain(g, 32.0f); } +inline float sanitizeStreamGain(float g) noexcept { return sanitizeGain(g, 8.0f); } + +} // namespace slopsmith diff --git a/src/audio/SourceChain.h b/src/audio/SourceChain.h index 47c86a7..5f8fee6 100644 --- a/src/audio/SourceChain.h +++ b/src/audio/SourceChain.h @@ -3,6 +3,7 @@ #include "NoiseGate.h" #include "TonePolish.h" #include "SignalChain.h" +#include "GainSanitize.h" #include "PitchDetector.h" #include "ChordScorer.h" #include "MlNoteDetector.h" @@ -125,9 +126,11 @@ public: } void setTonePolishEnabled(bool enabled) { tonePolish.setEnabled(enabled); } - void setInputGain(float gain) { inputGain.store(gain); } + // Sanitized (see GainSanitize.h) so NaN/Inf from the JS bridge can't + // reach the audio thread via either the legacy or the indexed API. + void setInputGain(float gain) { inputGain.store(slopsmith::sanitizeMasterGain(gain)); } float getInputGain() const { return inputGain.load(); } - void setChainOutputGain(float gain) { chainOutputGain.store(gain); } + void setChainOutputGain(float gain) { chainOutputGain.store(slopsmith::sanitizeMasterGain(gain)); } float getChainOutputGain() const { return chainOutputGain.load(); } void setInputChannel(int channel) { selectedInputChannel.store(channel); } int getInputChannel() const { return selectedInputChannel.load(); } diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index 9d5cddb..3083ec0 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -13,6 +13,7 @@ endif() # Pure-helper tests (no JUCE / no platform deps) build everywhere. add_subdirectory(audio_sanitize) +add_subdirectory(engine_units) # Note: enable_testing() lives in the top-level CMakeLists.txt — calling it # only here would register tests in build/tests/CTestTestfile.cmake but diff --git a/tests/engine_units/CMakeLists.txt b/tests/engine_units/CMakeLists.txt new file mode 100644 index 0000000..71e8632 --- /dev/null +++ b/tests/engine_units/CMakeLists.txt @@ -0,0 +1,7 @@ +# 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) diff --git a/tests/engine_units/gain_sanitize_test.cpp b/tests/engine_units/gain_sanitize_test.cpp new file mode 100644 index 0000000..6747bd4 --- /dev/null +++ b/tests/engine_units/gain_sanitize_test.cpp @@ -0,0 +1,48 @@ +// Pins the Phase 0.b compat decision (docs/audio-engine-tlc.md §4): native +// gain clamp bounds are 0..32 — matching the audio-effects executor's JS-side +// clampGain so a legit high rig gain is never under-shot — with NaN/Inf +// rejected universally; stream/renderer-bus gains keep the tighter 0..8. + +#include "../../src/audio/GainSanitize.h" + +#include +#include +#include +#include + +int main() +{ + using slopsmith::sanitizeMasterGain; + using slopsmith::sanitizeStreamGain; + + const float nan = std::numeric_limits::quiet_NaN(); + const float inf = std::numeric_limits::infinity(); + + struct Case { float in, master, stream; }; + const Case cases[] = { + { 0.0f, 0.0f, 0.0f }, + { 1.0f, 1.0f, 1.0f }, + { 8.0f, 8.0f, 8.0f }, + { 8.5f, 8.5f, 8.0f }, // executor range beyond the stream clamp + { 32.0f, 32.0f, 8.0f }, // upper compat bound must not under-shoot + { 33.0f, 32.0f, 8.0f }, + { 1e9f, 32.0f, 8.0f }, + { -1.0f, 0.0f, 0.0f }, + { -0.0f, 0.0f, 0.0f }, + { nan, 0.0f, 0.0f }, // non-finite → silence, never a poisoned mix + { inf, 0.0f, 0.0f }, + { -inf, 0.0f, 0.0f }, + }; + + for (const auto& c : cases) + { + const float m = sanitizeMasterGain(c.in); + const float s = sanitizeStreamGain(c.in); + assert(std::isfinite(m) && std::isfinite(s)); + assert(m == c.master); + assert(s == c.stream); + } + + std::puts("gain_sanitize: all cases passed"); + return 0; +} From 3e449c03181dd051d0de129173102cdf9e4cf1c0 Mon Sep 17 00:00:00 2001 From: OmikronApex Date: Mon, 13 Jul 2026 23:28:34 +0200 Subject: [PATCH 03/28] test(audio): quarantined chain-mutation storm test (expected-fail) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Documents TLC deep-read §1: concurrent loadPreset workers interleave clear()/addProcessor() and merge both presets — reproduces first iteration ([storm-ir-1-0, storm-ir-2-0, storm-ir-2-1]). Quarantined behind CHAIN_STORM=1; flips to a hard gate when ChainOps lands the serializer (plan phase 7). Co-Authored-By: Claude Fable 5 --- tests/chain-mutation-storm.test.js | 83 ++++++++++++++++++++++++++++++ 1 file changed, 83 insertions(+) create mode 100644 tests/chain-mutation-storm.test.js diff --git a/tests/chain-mutation-storm.test.js b/tests/chain-mutation-storm.test.js new file mode 100644 index 0000000..cb6c547 --- /dev/null +++ b/tests/chain-mutation-storm.test.js @@ -0,0 +1,83 @@ +// Phase 0.b storm test (docs/audio-engine-tlc.md §4, deep-read §1): chain- +// mutating async workers (loadPreset/loadVST/loadNAM/loadIR) queue on the +// libuv threadpool with no mutual exclusion, so two overlapping loadPreset +// calls can interleave clear()/addProcessor() and merge both presets into +// garbage. This test documents that corruption today and flips to a hard gate +// once ChainOps lands the chain-mutation serializer (plan phase 7). +// +// EXPECTED-FAIL / QUARANTINED: needs the built addon, drives a known race +// repeatedly (a single clean run proves nothing for a race), and initializes +// JUCE in-process. Run explicitly with: +// CHAIN_STORM=1 node --test tests/chain-mutation-storm.test.js +'use strict'; + +const test = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const os = require('node:os'); +const path = require('node:path'); + +const ADDON = path.join(__dirname, '..', 'build', 'Release', 'slopsmith_audio.node'); +const ENABLED = process.env.CHAIN_STORM === '1'; +const ITERATIONS = 50; + +// Minimal valid 16-bit PCM mono WAV (a short impulse) — enough for IRLoader, +// so the presets need no real cab/amp assets. IR slots (type 2) are the only +// asset-cheap distinguishable payload loadPreset accepts. +function writeImpulseWav(file, numSamples) { + const dataBytes = numSamples * 2; + const buf = Buffer.alloc(44 + dataBytes); + buf.write('RIFF', 0); buf.writeUInt32LE(36 + dataBytes, 4); buf.write('WAVE', 8); + buf.write('fmt ', 12); buf.writeUInt32LE(16, 16); buf.writeUInt16LE(1, 20); + buf.writeUInt16LE(1, 22); buf.writeUInt32LE(48000, 24); buf.writeUInt32LE(96000, 28); + buf.writeUInt16LE(2, 32); buf.writeUInt16LE(16, 34); + buf.write('data', 36); buf.writeUInt32LE(dataBytes, 40); + buf.writeInt16LE(32767, 44); // unit impulse, remaining samples zero + fs.writeFileSync(file, buf); +} + +const IR_TYPE = 2; // ProcessorSlot::Type::IR + +function irPreset(irFile, slotCount) { + return JSON.stringify({ + chain: Array.from({ length: slotCount }, (_, i) => ({ + type: IR_TYPE, name: `storm-ir-${slotCount}-${i}`, path: irFile, bypassed: false, + })), + }); +} + +test('concurrent loadPreset calls end with exactly one caller\'s chain', { skip: !ENABLED && 'quarantined — set CHAIN_STORM=1 (expected-fail until ChainOps 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 }); + } +}); From eeb83cbdbc356847b7bc746da41b56fb238b2b42 Mon Sep 17 00:00:00 2001 From: OmikronApex Date: Mon, 13 Jul 2026 23:38:56 +0200 Subject: [PATCH 04/28] =?UTF-8?q?refactor(audio):=20extract=20PackedStereo?= =?UTF-8?q?Ring=20=E2=80=94=20one=20SPSC=20ring=20template=20(phase=201)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replaces the four hand-maintained copies of the packed-LR SPSC design (split-mode output ring, per-InputDeviceSlot rings, stream-sink ring, renderer-bus ring) with slopsmith::PackedStereoRing (src/audio/engine/PackedStereoRing.h). The template owns the storage, power-of-two/lock-free asserts, pack/unpack, producer publish, reset, the w --- src/audio/AudioEngine.cpp | 158 +++++----------- src/audio/AudioEngine.h | 68 ++----- src/audio/engine/PackedStereoRing.h | 143 +++++++++++++++ tests/engine_units/CMakeLists.txt | 7 + .../engine_units/packed_stereo_ring_test.cpp | 171 ++++++++++++++++++ 5 files changed, 383 insertions(+), 164 deletions(-) create mode 100644 src/audio/engine/PackedStereoRing.h create mode 100644 tests/engine_units/packed_stereo_ring_test.cpp diff --git a/src/audio/AudioEngine.cpp b/src/audio/AudioEngine.cpp index 8f4d881..8100f15 100644 --- a/src/audio/AudioEngine.cpp +++ b/src/audio/AudioEngine.cpp @@ -458,8 +458,8 @@ AudioEngine::DeviceMetrics AudioEngine::getDeviceMetrics() const // (w - r) larger than capacity. Clamp uint64 → int via the // capacity ceiling so the consumer-facing field never overflows // or goes negative. - const uint64_t w = outputRingWriteIndex.load(std::memory_order_acquire); - const uint64_t r = outputRingReadIndex.load(std::memory_order_acquire); + const uint64_t w = outputRing.writeIndex.load(std::memory_order_acquire); + const uint64_t r = outputRing.readIndex.load(std::memory_order_acquire); const uint64_t fill = (w >= r) ? (w - r) : 0; m.outputRingFillFrames = (int) std::min(fill, (uint64_t) kOutputRingFrames); } @@ -1157,12 +1157,9 @@ AudioEngine::DeviceConfigResult AudioEngine::applySplitSetup(const DeviceConfig& fprintf(stderr, "[AudioEngine] Split mode configured: inSr=%.0f inBs=%d outSr=%.0f outBs=%d\n", inSr, inBs, outSr, outBs); - outputRingWriteIndex.store(0, std::memory_order_relaxed); - outputRingReadIndex.store(0, std::memory_order_relaxed); + outputRing.reset(); outputUnderflowCount.store(0, std::memory_order_relaxed); inputOverflowCount.store(0, std::memory_order_relaxed); - for (auto& slot : outputPendingRing) - slot.store(0u, std::memory_order_relaxed); source0().prepareMonitorChain(inSr, inBs); @@ -1184,10 +1181,7 @@ void AudioEngine::teardownSplitMode() try { outputDeviceManager.closeAudioDevice(); } catch (...) { fprintf(stderr, "[AudioEngine] teardownSplitMode: output close threw\n"); } - outputRingWriteIndex.store(0, std::memory_order_relaxed); - outputRingReadIndex.store(0, std::memory_order_relaxed); - for (auto& slot : outputPendingRing) - slot.store(0u, std::memory_order_relaxed); + outputRing.reset(); } // ── Audio Control ───────────────────────────────────────────────────────────── @@ -1894,8 +1888,7 @@ void AudioEngine::audioDeviceStopped() // (an extra-device callback could still be mid-block). reclaimPendingReleases(); } - outputRingWriteIndex.store(0, std::memory_order_relaxed); - outputRingReadIndex.store(0, std::memory_order_relaxed); + outputRing.resetIndices(); currentBackingLevel.store(0.0f); // Note on split-mode lifecycle: we deliberately do NOT detach the @@ -1904,7 +1897,7 @@ void AudioEngine::audioDeviceStopped() // doesn't re-add the output callback, so detaching would break // automatic recovery (output stays silent until a manual reconfigure). // While input is down, the guitar/DSP side of the output goes silent - // (no producer feeding outputPendingRing, so the consumer's underflow + // (no producer feeding outputRing, so the consumer's underflow // branch zero-fills), but the backing track keeps playing — the output // callback mixes backingTransport independently of ring state. That's // intentional UX: a user unplugging their interface mid-song doesn't @@ -2061,7 +2054,8 @@ void AudioEngine::composeAndPushStreamMix(const juce::AudioBuffer& guitar streamMixScratch.getMagnitude(1, 0, numSamples)); streamSinkLevel.store(peak, std::memory_order_relaxed); - packStereoIntoRing(streamMixScratch, numSamples, streamSink.ring, streamSink.writeIndex); + streamSink.ring.push(streamMixScratch.getReadPointer(0), + streamMixScratch.getReadPointer(1), numSamples); } void AudioEngine::streamSinkCallback(float* const* outputData, int numOutputChannels, int numSamples) @@ -2071,35 +2065,29 @@ void AudioEngine::streamSinkCallback(float* const* outputData, int numOutputChan juce::AudioBuffer buffer(outputData, numOutputChannels, numSamples); buffer.clear(); - constexpr uint64_t kMask = (uint64_t) kOutputRingFrames - 1; - constexpr uint64_t kCap = (uint64_t) kOutputRingFrames; const int scratchCap = (int) streamSink.pullScratchL.size(); const int outSamples = juce::jmin(numSamples, scratchCap); - uint64_t r = streamSink.readIndex.load(std::memory_order_relaxed); - const uint64_t w = streamSink.writeIndex.load(std::memory_order_acquire); - if (w < r) { r = w; streamSink.readIndex.store(r, std::memory_order_relaxed); } - if ((w - r) > kCap) - { - r = w - kCap; - streamSink.readIndex.store(r, std::memory_order_relaxed); + auto& ring = streamSink.ring; + 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)) streamSink.overflowCount.fetch_add(1, std::memory_order_relaxed); - } const uint64_t available = w - r; const int pullCount = juce::jmin(outSamples, (int) available); const int consumeCount = juce::jmin(numSamples, (int) available); const int copyChannels = juce::jmin(numOutputChannels, 2); for (int i = 0; i < pullCount; ++i) { - const uint64_t slot = (r + (uint64_t) i) & kMask; float l, rr; - unpackLR(streamSink.ring[(size_t) slot].load(std::memory_order_relaxed), l, rr); + ring.readFrame(r + (uint64_t) i, l, rr); buffer.setSample(0, i, l); if (copyChannels > 1) buffer.setSample(1, i, rr); } if (pullCount < outSamples) streamSink.underflowCount.fetch_add(1, std::memory_order_relaxed); - streamSink.readIndex.store(r + (uint64_t) consumeCount, std::memory_order_release); + ring.commitRead(r + (uint64_t) consumeCount); } void AudioEngine::streamSinkAboutToStart(juce::AudioIODevice* device) @@ -2113,11 +2101,9 @@ void AudioEngine::streamSinkAboutToStart(juce::AudioIODevice* device) const int cap = juce::jmax(bs, 2048); if ((int) streamSink.pullScratchL.size() < cap) streamSink.pullScratchL.assign((size_t) cap, 0.0f); if ((int) streamSink.pullScratchR.size() < cap) streamSink.pullScratchR.assign((size_t) cap, 0.0f); - streamSink.writeIndex.store(0, std::memory_order_relaxed); - streamSink.readIndex.store(0, std::memory_order_relaxed); + streamSink.ring.reset(); streamSink.underflowCount.store(0, std::memory_order_relaxed); streamSink.overflowCount.store(0, std::memory_order_relaxed); - for (auto& v : streamSink.ring) v.store(0, std::memory_order_relaxed); } void AudioEngine::streamSinkStopped() @@ -2295,7 +2281,7 @@ void AudioEngine::audioDeviceIOCallbackWithContext( const bool duplex = duplexMode.load(std::memory_order_relaxed); // Duplex writes outputData directly. Split runs DSP into a private 2-channel - // scratch and pushes the result to outputPendingRing for OutputCallback. + // scratch and pushes the result to outputRing for OutputCallback. juce::AudioBuffer buffer; if (duplex) { @@ -2419,7 +2405,7 @@ void AudioEngine::audioDeviceIOCallbackWithContext( // Split: push processed stereo (pre-backing, pre-output-gain) into the // primary ring. OutputCallback adds backing + output gain on its own clock // and sums every extra-device ring alongside this one. - packStereoIntoRing(buffer, numSamples, outputPendingRing, outputRingWriteIndex); + outputRing.push(buffer.getReadPointer(0), buffer.getReadPointer(1), numSamples); } // Body done — this callback is no longer processing a source. Pairs with @@ -2483,28 +2469,6 @@ int AudioEngine::mixSourcesForDevice(int deviceKey, const float* const* inputDat return activeCount; } -void AudioEngine::packStereoIntoRing(const juce::AudioBuffer& buf, int numSamples, - std::array, kOutputRingFrames>& ring, - std::atomic& writeIndex) -{ - // Strict SPSC: the producer (one device callback) only ever writes writeIndex; - // the consumer (audioOutputCallback) is the sole writer of the paired - // readIndex. Drop-oldest = letting writeIndex lap the buffer; the consumer - // advances readIndex when it observes (w - r) > cap. The single packed store - // prevents an L/R tear when the producer wraps mid-callback (relaxed because - // ordering is established by the release on writeIndex below). - constexpr uint64_t kMask = (uint64_t) kOutputRingFrames - 1; - const uint64_t w = writeIndex.load(std::memory_order_relaxed); - const float* L = buf.getReadPointer(0); - const float* R = buf.getReadPointer(1); - for (int i = 0; i < numSamples; ++i) - { - const uint64_t slot = (w + (uint64_t) i) & kMask; - ring[slot].store(packLR(L[i], R[i]), std::memory_order_relaxed); - } - writeIndex.store(w + (uint64_t) numSamples, std::memory_order_release); -} - void AudioEngine::extraInputCallback(int slot, const float* const* inputData, int numInputChannels, int numSamples) { if (slot < 0 || slot >= kMaxExtraInputDevices) return; @@ -2521,7 +2485,7 @@ void AudioEngine::extraInputCallback(int slot, const float* const* inputData, in juce::AudioBuffer mix; mix.setDataToReferTo(s.fanScratch.getArrayOfWritePointers(), 2, numSamples); mixSourcesForDevice(s.deviceKey, inputData, numInputChannels, mix, s.monitorScratch, 2, numSamples); - packStereoIntoRing(mix, numSamples, s.ring, s.writeIndex); + s.ring.push(mix.getReadPointer(0), mix.getReadPointer(1), numSamples); callbacksInFlight[(size_t) s.deviceKey].fetch_sub(1, std::memory_order_acq_rel); } @@ -2547,9 +2511,7 @@ void AudioEngine::extraInputAboutToStart(int slot, juce::AudioIODevice* device) s.monitorScratch.setSize(2, cap, false, false, true); s.fanScratch.clear(); s.monitorScratch.clear(); - s.writeIndex.store(0, std::memory_order_relaxed); - s.readIndex.store(0, std::memory_order_relaxed); - for (auto& v : s.ring) v.store(0, std::memory_order_relaxed); + 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 @@ -2617,8 +2579,7 @@ void AudioEngine::extraInputStopped(int slot) // the next add/remove. reclaimPendingReleases(); } - s.writeIndex.store(0, std::memory_order_relaxed); - s.readIndex.store(0, std::memory_order_relaxed); + s.ring.resetIndices(); } int AudioEngine::activeExtraInputCount() const @@ -2905,9 +2866,6 @@ void AudioEngine::audioOutputCallback(const float* const* /*inputData*/, if (numOutputChannels <= 0) return; - constexpr uint64_t kMask = (uint64_t) kOutputRingFrames - 1; - constexpr uint64_t kCap = (uint64_t) kOutputRingFrames; - if ((int) outputPullScratchL.size() < numSamples && audiodiag::firstN(audiodiag::outputOversized)) fprintf(stderr, "[diag] output callback OVERSIZED block: numSamples=%d > scratch=%d\n", numSamples, (int) outputPullScratchL.size()); @@ -2919,27 +2877,15 @@ void AudioEngine::audioOutputCallback(const float* const* /*inputData*/, const int scratchCap = (int) outputPullScratchL.size(); const int outSamples = juce::jmin(numSamples, scratchCap); - uint64_t r = outputRingReadIndex.load(std::memory_order_relaxed); - const uint64_t w = outputRingWriteIndex.load(std::memory_order_acquire); + uint64_t r = outputRing.readIndex.load(std::memory_order_relaxed); + const uint64_t w = outputRing.writeIndex.load(std::memory_order_acquire); - // If audioDeviceStopped() raced between our two loads and reset both - // indices to 0, we can observe w < r. Treat that as an empty ring - // and resync — without this, the unsigned (w - r) wraps into a huge - // positive value and falls into the catch-up branch reading stale slots. - if (w < r) - { - r = w; - outputRingReadIndex.store(r, std::memory_order_relaxed); - } - - // Catch up if the producer has lapped (drop-oldest is achieved via this - // single-writer consumer-side advance, not a producer-side write to r). - if ((w - r) > kCap) - { - r = w - kCap; - outputRingReadIndex.store(r, std::memory_order_relaxed); + // Resync if audioDeviceStopped() raced between our two loads and reset + // the indices; catch up (drop-oldest) if the producer lapped — both moves + // live on PackedStereoRing now, same semantics as before. + outputRing.resyncIfIndicesReset(r, w); + if (outputRing.catchUpIfLapped(r, w)) inputOverflowCount.fetch_add(1, std::memory_order_relaxed); - } const uint64_t available = w - r; const int pullCount = juce::jmin(outSamples, (int) available); @@ -2954,11 +2900,10 @@ void AudioEngine::audioOutputCallback(const float* const* /*inputData*/, for (int i = 0; i < pullCount; ++i) { - const uint64_t slot = (r + (uint64_t) i) & kMask; // Single atomic load → atomic unpack of both channels (matches the // producer's packed store) so L and R always belong to the same frame. float l, rr; - unpackLR(outputPendingRing[slot].load(std::memory_order_relaxed), l, rr); + outputRing.readFrame(r + (uint64_t) i, l, rr); outputPullScratchL[(size_t) i] = l; outputPullScratchR[(size_t) i] = rr; } @@ -2971,7 +2916,7 @@ void AudioEngine::audioOutputCallback(const float* const* /*inputData*/, } outputUnderflowCount.fetch_add(1, std::memory_order_relaxed); } - outputRingReadIndex.store(r + (uint64_t) consumeCount, std::memory_order_release); + outputRing.commitRead(r + (uint64_t) consumeCount); buffer.clear(); const int copyChannels = juce::jmin(numOutputChannels, 2); @@ -2989,27 +2934,22 @@ void AudioEngine::audioOutputCallback(const float* const* /*inputData*/, for (auto& s : extraInputs) { if (! s.active.load(std::memory_order_acquire)) continue; - uint64_t er = s.readIndex.load(std::memory_order_relaxed); - const uint64_t ew = s.writeIndex.load(std::memory_order_acquire); - if (ew < er) { er = ew; s.readIndex.store(er, std::memory_order_relaxed); } - if ((ew - er) > kCap) - { - er = ew - kCap; - s.readIndex.store(er, std::memory_order_relaxed); + uint64_t er = s.ring.readIndex.load(std::memory_order_relaxed); + const uint64_t ew = s.ring.writeIndex.load(std::memory_order_acquire); + s.ring.resyncIfIndicesReset(er, ew); + if (s.ring.catchUpIfLapped(er, ew)) s.overflowCount.fetch_add(1, std::memory_order_relaxed); - } const uint64_t eAvail = ew - er; const int ePull = juce::jmin(outSamples, (int) eAvail); const int eConsume = juce::jmin(numSamples, (int) eAvail); for (int i = 0; i < ePull; ++i) { - const uint64_t slot = (er + (uint64_t) i) & kMask; float l, rr; - unpackLR(s.ring[(size_t) slot].load(std::memory_order_relaxed), l, rr); + s.ring.readFrame(er + (uint64_t) i, l, rr); buffer.addSample(0, i, l); if (copyChannels > 1) buffer.addSample(1, i, rr); } - s.readIndex.store(er + (uint64_t) eConsume, std::memory_order_release); + s.ring.commitRead(er + (uint64_t) eConsume); } // Stream sink (producer, split clock): snapshot the full guitar mix (primary @@ -3100,8 +3040,7 @@ bool AudioEngine::pushRendererAudio(const float* interleavedLR, int frames, doub if (deviceRate <= 0.0) return false; if (!(sourceRate > 0.0)) sourceRate = deviceRate; - constexpr uint64_t kMask = kRendererBusFrames - 1; - uint64_t w = rendererBusWriteIndex.load(std::memory_order_relaxed); + uint64_t w = rendererBusRing.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 @@ -3121,9 +3060,7 @@ bool AudioEngine::pushRendererAudio(const float* interleavedLR, int frames, doub const float r0 = (i0 < 0) ? rendererBusPrevR : interleavedLR[(size_t) i0 * 2 + 1]; const float l1 = interleavedLR[((size_t) i0 + 1) * 2]; const float r1 = interleavedLR[((size_t) i0 + 1) * 2 + 1]; - rendererBusRing[(size_t) (w & kMask)].store( - packLR(l0 + (l1 - l0) * frac, r0 + (r1 - r0) * frac), - std::memory_order_relaxed); + rendererBusRing.stageFrame(w, l0 + (l1 - l0) * frac, r0 + (r1 - r0) * frac); ++w; ++written; pos += step; @@ -3135,7 +3072,7 @@ bool AudioEngine::pushRendererAudio(const float* interleavedLR, int frames, doub // Publish. Overflow (producer lapping the consumer) is handled consumer- // side with drop-oldest — same contract as the extra-input rings — so only // the consumer ever moves readIndex. - rendererBusWriteIndex.store(w, std::memory_order_release); + rendererBusRing.publish(w); rendererBusPushedFrames.fetch_add(written, std::memory_order_relaxed); return true; } @@ -3146,9 +3083,8 @@ int AudioEngine::pullRendererBus(juce::AudioBuffer& dest, int numSamples) // Cold start before about-to-start sized the scratch — skip, never alloc // on the RT thread (same rule as the stream scratches). if (dest.getNumSamples() < numSamples || dest.getNumChannels() < 2) return 0; - constexpr uint64_t kMask = kRendererBusFrames - 1; - const uint64_t w = rendererBusWriteIndex.load(std::memory_order_acquire); - uint64_t r = rendererBusReadIndex.load(std::memory_order_relaxed); + const uint64_t w = rendererBusRing.writeIndex.load(std::memory_order_acquire); + uint64_t r = rendererBusRing.readIndex.load(std::memory_order_relaxed); if (w - r > (uint64_t) kRendererBusFrames) { // Producer lapped us — drop-oldest to the newest full ring. @@ -3176,7 +3112,7 @@ int AudioEngine::pullRendererBus(juce::AudioBuffer& dest, int numSamples) { if (avail < (uint64_t) kRendererBusPrimeFrames) { - rendererBusReadIndex.store(r, std::memory_order_release); + rendererBusRing.commitRead(r); return 0; } rendererBusPrimed = true; @@ -3187,7 +3123,7 @@ int AudioEngine::pullRendererBus(juce::AudioBuffer& dest, int numSamples) // drop what's buffered, and go back to priming. rendererBusPrimed = false; rendererBusUnderflowCount.fetch_add(1, std::memory_order_relaxed); - rendererBusReadIndex.store(w, std::memory_order_release); + rendererBusRing.commitRead(w); return 0; } @@ -3198,11 +3134,11 @@ int AudioEngine::pullRendererBus(juce::AudioBuffer& dest, int numSamples) for (int i = 0; i < pull; ++i) { float l, rr; - unpackLR(rendererBusRing[(size_t) ((r + (uint64_t) i) & kMask)].load(std::memory_order_relaxed), l, rr); + rendererBusRing.readFrame(r + (uint64_t) i, l, rr); dl[i] = l * g; dr[i] = rr * g; } - rendererBusReadIndex.store(r + (uint64_t) pull, std::memory_order_release); + rendererBusRing.commitRead(r + (uint64_t) pull); rendererBusConsumedFrames.fetch_add((uint64_t) pull, std::memory_order_relaxed); return pull; } @@ -3214,8 +3150,8 @@ AudioEngine::RendererBusMetrics AudioEngine::getRendererBusMetrics() const m.consumedFrames = rendererBusConsumedFrames.load(std::memory_order_relaxed); m.underflowCount = rendererBusUnderflowCount.load(std::memory_order_relaxed); m.overflowCount = rendererBusOverflowCount.load(std::memory_order_relaxed); - const uint64_t w = rendererBusWriteIndex.load(std::memory_order_acquire); - const uint64_t r = rendererBusReadIndex.load(std::memory_order_acquire); + const uint64_t w = rendererBusRing.writeIndex.load(std::memory_order_acquire); + const uint64_t r = rendererBusRing.readIndex.load(std::memory_order_acquire); m.fillFrames = (int) juce::jmin(w - r, (uint64_t) kRendererBusFrames); m.capacityFrames = kRendererBusFrames; m.enabled = rendererBusEnabled.load(std::memory_order_relaxed); diff --git a/src/audio/AudioEngine.h b/src/audio/AudioEngine.h index 7aa975a..3382ade 100644 --- a/src/audio/AudioEngine.h +++ b/src/audio/AudioEngine.h @@ -1,6 +1,7 @@ #pragma once #include "SourceChain.h" #include "GainSanitize.h" +#include "engine/PackedStereoRing.h" #include "BackingLeveler.h" #include "signalsmith-stretch.h" #include @@ -276,8 +277,8 @@ public: { // 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), + rendererBusRing.readIndex.store( + rendererBusRing.writeIndex.load(std::memory_order_acquire), std::memory_order_release); rendererBusPrimed.store(false, std::memory_order_relaxed); } @@ -382,7 +383,7 @@ private: SourceChain& source0() { return *sources[0]; } const SourceChain& source0() const { return *sources[0]; } // Input-device callback. In duplex it writes outputData directly; in split - // it pushes processed stereo into outputPendingRing for OutputCallback. + // it pushes processed stereo into outputRing for OutputCallback. void audioDeviceIOCallbackWithContext(const float* const* inputData, int numInputChannels, float* const* outputData, @@ -402,7 +403,7 @@ private: // backingTransport && backingPlaying. int renderBackingBlockLocked(int numSamples); - // Split-mode only: drains outputPendingRing, mixes backing, writes to device. + // Split-mode only: drains outputRing, mixes backing, writes to device. void audioOutputCallback(const float* const* inputData, int numInputChannels, float* const* outputData, @@ -560,43 +561,16 @@ private: // scratch now live on SourceChain — one set per input source. See // SourceChain.h for the full lock-free / power-of-two / cold-start rationale. - // Split-mode SPSC ring (unused in duplex). Each slot packs one stereo frame - // (L+R floats) into a single 64-bit atomic so the consumer reads both - // channels in one indivisible load — without packing, the producer's two - // separate atomic stores could interleave with the consumer's two loads - // during a drop-oldest wrap, surfacing as L_new+R_old (or vice versa) - // sample tears. ~85 ms @ 48 kHz — absorbs clock drift over typical sessions. + // Split-mode SPSC ring (unused in duplex). Packed-LR single-atomic frames + // — see engine/PackedStereoRing.h for the tear/lock-free rationale (moved + // there in TLC phase 1). ~85 ms @ 48 kHz — absorbs clock drift over + // typical sessions. static constexpr int kOutputRingFrames = 4096; - std::array, kOutputRingFrames> outputPendingRing{}; - static_assert((kOutputRingFrames & (kOutputRingFrames - 1)) == 0, - "kOutputRingFrames must be a power of two for mask wraparound"); - // RT-thread reads + writes touch these slots, so a lock-based fallback - // would risk priority inversion + audible dropouts. On the platforms we - // ship (x86_64 + arm64 across Linux/macOS/Windows) atomic is - // always lock-free; this assert turns a regression into a build error - // instead of a silent latency degradation if a future platform port - // breaks the assumption. - static_assert(std::atomic::is_always_lock_free, - "outputPendingRing requires lock-free atomic for RT safety"); - static_assert(sizeof(float) == 4, - "outputPendingRing pack/unpack assumes 32-bit float"); - - // Pack/unpack helpers — std::bit_cast (C++20) is constexpr + alias-safe. - static inline uint64_t packLR(float l, float r) noexcept - { - const uint32_t li = std::bit_cast(l); - const uint32_t ri = std::bit_cast(r); - return (static_cast(ri) << 32) | static_cast(li); - } - static inline void unpackLR(uint64_t v, float& l, float& r) noexcept - { - l = std::bit_cast(static_cast(v & 0xFFFFFFFFu)); - r = std::bit_cast(static_cast(v >> 32)); - } + slopsmith::PackedStereoRing outputRing; // ── 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 + // Same packed-LR SPSC design as outputRing. Sized generously + // (~1.5 s @ 48 kHz — vs outputRing'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; @@ -608,9 +582,7 @@ private: // stall dumped a backlog — trim to the prime target, don't play the tail. static constexpr int kRendererBusPrimeFrames = 512; static constexpr int kRendererBusMaxFillFrames = 4096; - std::array, kRendererBusFrames> rendererBusRing{}; - std::atomic rendererBusWriteIndex{0}; - std::atomic rendererBusReadIndex{0}; + slopsmith::PackedStereoRing rendererBusRing; std::atomic rendererBusPushedFrames{0}; std::atomic rendererBusConsumedFrames{0}; std::atomic rendererBusUnderflowCount{0}; @@ -637,8 +609,6 @@ private: // in about-to-start next to the stream scratches (same no-realloc rule). juce::AudioBuffer rendererBusPullScratch; - std::atomic outputRingWriteIndex{0}; - std::atomic outputRingReadIndex{0}; std::atomic outputUnderflowCount{0}; std::atomic inputOverflowCount{0}; @@ -687,9 +657,7 @@ private: { juce::AudioDeviceManager manager; InputSlotCallback callback; - std::array, kOutputRingFrames> ring{}; - std::atomic writeIndex{0}; - std::atomic readIndex{0}; + slopsmith::PackedStereoRing ring; std::atomic overflowCount{0}; std::atomic active{false}; // a device is bound + running std::atomic sampleRate{48000.0}; @@ -733,10 +701,6 @@ private: int mixSourcesForDevice(int deviceKey, const float* const* inputData, int numInputChannels, juce::AudioBuffer& mixBuf, juce::AudioBuffer& monitorScratch, int effectiveOutputChannels, int numSamples); - // Pack a stereo block into a packed-uint64 SPSC ring (producer side). - void packStereoIntoRing(const juce::AudioBuffer& buf, int numSamples, - std::array, kOutputRingFrames>& ring, - std::atomic& writeIndex); // ── Streamer mix output sink (PR1) ─────────────────────────────────────── // A second OUTPUT AudioDeviceManager on its OWN clock that drains a dedicated @@ -761,9 +725,7 @@ private: struct StreamSink { StreamSinkCallback callback; - std::array, kOutputRingFrames> ring{}; - std::atomic writeIndex{0}; - std::atomic readIndex{0}; + slopsmith::PackedStereoRing ring; std::atomic underflowCount{0}; std::atomic overflowCount{0}; std::atomic active{false}; diff --git a/src/audio/engine/PackedStereoRing.h b/src/audio/engine/PackedStereoRing.h new file mode 100644 index 0000000..fe560b9 --- /dev/null +++ b/src/audio/engine/PackedStereoRing.h @@ -0,0 +1,143 @@ +#pragma once + +// PackedStereoRing — the ONE packed-LR SPSC ring (audio-engine TLC, plan +// phase 1). Replaces the three hand-maintained copies of the same design: +// the split-mode outputPendingRing, each InputDeviceSlot's ring, the stream +// sink's ring, and the renderer-audio bus ring. +// +// Design (moved verbatim from AudioEngine.h — see git history for the +// original per-site comments): +// +// Each slot packs one stereo frame (L+R floats) into a single 64-bit atomic +// so the consumer reads both channels in one indivisible load — without +// packing, the producer's two separate atomic stores could interleave with +// the consumer's two loads during a drop-oldest wrap, surfacing as +// L_new+R_old (or vice versa) sample tears. +// +// Strict SPSC: the producer (one device/IPC thread) only ever writes +// writeIndex; the consumer is the sole writer of readIndex. Drop-oldest = +// letting writeIndex lap the buffer; the consumer advances readIndex when it +// observes (w - r) > capacity. Ordering is established by the release store +// on writeIndex (producer) / readIndex (consumer); slot stores/loads are +// relaxed. +// +// The indices and slots are deliberately PUBLIC: the renderer bus keeps its +// bespoke prefill-gate / fill-clamp consumer policy, and phase-2 units bind +// the members directly. The helpers below own only the ritual moves every +// site repeats: producer publish, reset, the w +#include +#include +#include + +namespace slopsmith { + +// Pack/unpack helpers — std::bit_cast (C++20) is constexpr + alias-safe. +inline uint64_t packLR(float l, float r) noexcept +{ + const uint32_t li = std::bit_cast(l); + const uint32_t ri = std::bit_cast(r); + return (static_cast(ri) << 32) | static_cast(li); +} +inline void unpackLR(uint64_t v, float& l, float& r) noexcept +{ + l = std::bit_cast(static_cast(v & 0xFFFFFFFFu)); + r = std::bit_cast(static_cast(v >> 32)); +} + +template +struct PackedStereoRing +{ + static_assert((NFrames & (NFrames - 1)) == 0, + "ring capacity must be a power of two for mask wraparound"); + // RT-thread reads + writes touch these slots, so a lock-based fallback + // would risk priority inversion + audible dropouts. On the platforms we + // ship (x86_64 + arm64 across Linux/macOS/Windows) atomic is + // always lock-free; this assert turns a regression into a build error + // instead of a silent latency degradation if a future platform port + // breaks the assumption. + static_assert(std::atomic::is_always_lock_free, + "PackedStereoRing requires lock-free atomic for RT safety"); + static_assert(sizeof(float) == 4, "pack/unpack assumes 32-bit float"); + + static constexpr uint64_t kMask = (uint64_t) NFrames - 1; + static constexpr uint64_t kCap = (uint64_t) NFrames; + static constexpr int kFrames = NFrames; + + std::array, NFrames> slots{}; + std::atomic writeIndex{0}; + std::atomic readIndex{0}; + + // ── Producer side ───────────────────────────────────────────────────── + // Publish a stereo block (drop-oldest by lapping; consumer catches up). + void push(const float* L, const float* R, int numSamples) noexcept + { + const uint64_t w = writeIndex.load(std::memory_order_relaxed); + for (int i = 0; i < numSamples; ++i) + slots[(size_t) ((w + (uint64_t) i) & kMask)].store(packLR(L[i], R[i]), + std::memory_order_relaxed); + writeIndex.store(w + (uint64_t) numSamples, std::memory_order_release); + } + // Frame-at-a-time producer path (renderer-bus resampler): stage frames at + // monotonically increasing indices from beginWrite(), then publish once. + uint64_t beginWrite() const noexcept { return writeIndex.load(std::memory_order_relaxed); } + void stageFrame(uint64_t index, float l, float r) noexcept + { + slots[(size_t) (index & kMask)].store(packLR(l, r), std::memory_order_relaxed); + } + void publish(uint64_t newWriteIndex) noexcept + { + writeIndex.store(newWriteIndex, std::memory_order_release); + } + + // ── Consumer side ───────────────────────────────────────────────────── + void readFrame(uint64_t index, float& l, float& r) const noexcept + { + unpackLR(slots[(size_t) (index & kMask)].load(std::memory_order_relaxed), l, r); + } + void commitRead(uint64_t newReadIndex) noexcept + { + readIndex.store(newReadIndex, std::memory_order_release); + } + // The two ritual guards at the top of every drain, exactly as each site + // wrote them by hand. `r` is the consumer's working copy of readIndex. + // + // If a stop/reset raced between the consumer's two index loads and reset + // both indices to 0, the consumer can observe w < r. Treat that as an + // empty ring and resync — without this, the unsigned (w - r) wraps into a + // huge positive value and falls into the catch-up branch reading stale + // slots. + void resyncIfIndicesReset(uint64_t& r, uint64_t w) noexcept + { + if (w < r) { r = w; readIndex.store(r, std::memory_order_relaxed); } + } + // Catch up if the producer has lapped (drop-oldest is achieved via this + // single-writer consumer-side advance, not a producer-side write to r). + // Returns true when a lap was consumed so the caller can bump its counter. + bool catchUpIfLapped(uint64_t& r, uint64_t w) noexcept + { + if ((w - r) > kCap) + { + r = w - kCap; + readIndex.store(r, std::memory_order_relaxed); + return true; + } + return false; + } + + // ── Lifecycle (control/device-management threads only) ──────────────── + void resetIndices() noexcept + { + writeIndex.store(0, std::memory_order_relaxed); + readIndex.store(0, std::memory_order_relaxed); + } + void reset() noexcept + { + resetIndices(); + for (auto& v : slots) v.store(0, std::memory_order_relaxed); + } +}; + +} // namespace slopsmith diff --git a/tests/engine_units/CMakeLists.txt b/tests/engine_units/CMakeLists.txt index 71e8632..88447f1 100644 --- a/tests/engine_units/CMakeLists.txt +++ b/tests/engine_units/CMakeLists.txt @@ -5,3 +5,10 @@ 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) diff --git a/tests/engine_units/packed_stereo_ring_test.cpp b/tests/engine_units/packed_stereo_ring_test.cpp new file mode 100644 index 0000000..a83a651 --- /dev/null +++ b/tests/engine_units/packed_stereo_ring_test.cpp @@ -0,0 +1,171 @@ +// Phase 1 unit tests for PackedStereoRing (docs/audio-engine-tlc.md §5): +// pack/unpack round-trip, wrap + drop-oldest lap, w +#include +#include +#include +#include +#include + +using slopsmith::PackedStereoRing; +using slopsmith::packLR; +using slopsmith::unpackLR; + +static void testPackRoundTrip() +{ + const float values[] = { 0.0f, -0.0f, 1.0f, -1.0f, 3.14159f, 1e-30f, -1e30f }; + for (float l : values) + for (float r : values) + { + float ol, orr; + unpackLR(packLR(l, r), ol, orr); + // Bit-exact round trip (including -0.0f). + assert(std::memcmp(&ol, &l, 4) == 0 && std::memcmp(&orr, &r, 4) == 0); + } +} + +static void testPushPullBasic() +{ + PackedStereoRing<64> ring; + float L[16], R[16]; + for (int i = 0; i < 16; ++i) { L[i] = (float) i; R[i] = (float) -i; } + ring.push(L, R, 16); + + uint64_t r = ring.readIndex.load(); + const uint64_t w = ring.writeIndex.load(); + assert(w - r == 16); + for (int i = 0; i < 16; ++i) + { + float l, rr; + ring.readFrame(r + (uint64_t) i, l, rr); + assert(l == (float) i && rr == (float) -i); + } + ring.commitRead(r + 16); + assert(ring.writeIndex.load() - ring.readIndex.load() == 0); +} + +static void testLapCatchUp() +{ + PackedStereoRing<64> ring; + float L[64], R[64]; + // Push 3 laps' worth without consuming: consumer must catch up to newest + // full ring, exactly once per drain regardless of how far it was lapped. + for (int block = 0; block < 3; ++block) + { + for (int i = 0; i < 64; ++i) { L[i] = (float) (block * 64 + i); R[i] = 0.0f; } + ring.push(L, R, 64); + } + uint64_t r = ring.readIndex.load(); + const uint64_t w = ring.writeIndex.load(); + assert(w - r == 192); + const bool lapped = ring.catchUpIfLapped(r, w); + assert(lapped); + assert(w - r == 64); // newest full ring only + float l, rr; + ring.readFrame(r, l, rr); + assert(l == 128.0f); // oldest surviving frame = start of last lap + // Not lapped anymore: second call is a no-op. + assert(!ring.catchUpIfLapped(r, w)); +} + +static void testResyncAfterReset() +{ + PackedStereoRing<64> ring; + float L[32] = {}, R[32] = {}; + ring.push(L, R, 32); + ring.commitRead(20); + uint64_t r = ring.readIndex.load(); + // A stop raced in and reset the indices; consumer still holds r == 20. + ring.resetIndices(); + const uint64_t w = ring.writeIndex.load(); + ring.resyncIfIndicesReset(r, w); + assert(r == 0 && w == 0); // treated as empty, no wrapped (w - r) monster +} + +// Producer at one block size laps a slower consumer at another; every frame +// the consumer reads must have L == -R (the producer invariant), proving the +// packed single-atomic store never tears a frame. +static void testConcurrentTearFreedom() +{ + PackedStereoRing<256> ring; + std::atomic stop{false}; + std::atomic laps{0}; + + std::thread producer([&] { + float L[48], R[48]; + uint64_t n = 0; + while (!stop.load(std::memory_order_relaxed)) + { + for (int i = 0; i < 48; ++i) + { + const float v = (float) ((n + (uint64_t) i) & 0xFFFFF); + L[i] = v; R[i] = -v; + } + ring.push(L, R, 48); + n += 48; + } + }); + + uint64_t checked = 0; + while (checked < 2'000'000) + { + uint64_t r = ring.readIndex.load(std::memory_order_relaxed); + const uint64_t w = ring.writeIndex.load(std::memory_order_acquire); + ring.resyncIfIndicesReset(r, w); + if (ring.catchUpIfLapped(r, w)) laps.fetch_add(1); + const uint64_t avail = w - r; + const int pull = (int) (avail < 32 ? avail : 32); + for (int i = 0; i < pull; ++i) + { + float l, rr; + ring.readFrame(r + (uint64_t) i, l, rr); + assert(l == -rr && "L/R tear: channels from different frames"); + } + ring.commitRead(r + (uint64_t) pull); + checked += (uint64_t) pull; + } + stop.store(true); + producer.join(); + std::printf("packed_stereo_ring: tear-check ok (%llu frames, %llu laps)\n", + (unsigned long long) checked, (unsigned long long) laps.load()); + assert(laps.load() > 0 && "stress never lapped — laps path untested, tune sizes"); +} + +// The split output path pulls min(outSamples, avail) into scratch but +// consumes min(numSamples, avail) so a scratch-clamped block doesn't +// accumulate ring/output-clock skew. Pin that index arithmetic. +static void testPullVsConsumeSkew() +{ + PackedStereoRing<64> ring; + float L[40], R[40]; + for (int i = 0; i < 40; ++i) { L[i] = (float) i; R[i] = 0.0f; } + ring.push(L, R, 40); + + const int numSamples = 40; // device block + const int outSamples = 32; // scratch-clamped + uint64_t r = ring.readIndex.load(); + const uint64_t w = ring.writeIndex.load(); + const uint64_t avail = w - r; + const int pull = (int) (avail < (uint64_t) outSamples ? avail : (uint64_t) outSamples); + const int consume = (int) (avail < (uint64_t) numSamples ? avail : (uint64_t) numSamples); + assert(pull == 32 && consume == 40); + ring.commitRead(r + (uint64_t) consume); + assert(ring.writeIndex.load() - ring.readIndex.load() == 0); // no skew left queued +} + +int main() +{ + testPackRoundTrip(); + testPushPullBasic(); + testLapCatchUp(); + testResyncAfterReset(); + testPullVsConsumeSkew(); + testConcurrentTearFreedom(); + std::puts("packed_stereo_ring: all cases passed"); + return 0; +} From eb40b87deaf7e408188c916f0d4a3ec5e04e5991 Mon Sep 17 00:00:00 2001 From: OmikronApex Date: Mon, 13 Jul 2026 23:42:29 +0200 Subject: [PATCH 05/28] refactor(audio): extract EngineState with intent/state split (phase 1) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Moves the shared run-state atomics (currentSampleRate, block sizes, duplexMode, run flags) into slopsmith::EngineState (src/audio/engine/) so later extracted units take EngineState& and stay unit-testable without JUCE devices. AudioEngine binds the members back by reference under their historical names — zero call-site churn, behavior-identical. The old audioRunning conflated user intent with device state (deep-read §3/§6); it is now state.deviceRunning (same semantics, isAudioRunning compat pinned) plus a new state.userWantsAudio written only by startAudio/stopAudio. Nothing reads the intent flag yet — phase 8 flips setAudioDevices' restart decision onto it. Co-Authored-By: Claude Fable 5 --- src/audio/AudioEngine.cpp | 5 +++ src/audio/AudioEngine.h | 31 ++++++------- src/audio/engine/EngineState.h | 55 ++++++++++++++++++++++++ tests/engine_units/CMakeLists.txt | 4 ++ tests/engine_units/engine_state_test.cpp | 53 +++++++++++++++++++++++ 5 files changed, 130 insertions(+), 18 deletions(-) create mode 100644 src/audio/engine/EngineState.h create mode 100644 tests/engine_units/engine_state_test.cpp diff --git a/src/audio/AudioEngine.cpp b/src/audio/AudioEngine.cpp index 8100f15..d0fba8f 100644 --- a/src/audio/AudioEngine.cpp +++ b/src/audio/AudioEngine.cpp @@ -1188,6 +1188,10 @@ void AudioEngine::teardownSplitMode() void AudioEngine::startAudio() { + // Intent flag first — even if the device open below fails or a transient + // stop races in, "the user wants audio" survives (read by phase 8's + // setAudioDevices restart fix; see EngineState.h). + state.userWantsAudio.store(true, std::memory_order_relaxed); if (audioRunning.load(std::memory_order_relaxed)) { fprintf(stderr, "[AudioEngine] startAudio: already running\n"); @@ -1244,6 +1248,7 @@ void AudioEngine::startAudio() void AudioEngine::stopAudio() { + state.userWantsAudio.store(false, std::memory_order_relaxed); if (slopsmith_vst_trace::isEnabled()) fprintf(stderr, "[diag] stopAudio: audioRunning=%d inputCbReg=%d outputCbReg=%d\n", (int) audioRunning.load(std::memory_order_relaxed), diff --git a/src/audio/AudioEngine.h b/src/audio/AudioEngine.h index 3382ade..4933777 100644 --- a/src/audio/AudioEngine.h +++ b/src/audio/AudioEngine.h @@ -2,6 +2,7 @@ #include "SourceChain.h" #include "GainSanitize.h" #include "engine/PackedStereoRing.h" +#include "engine/EngineState.h" #include "BackingLeveler.h" #include "signalsmith-stretch.h" #include @@ -444,7 +445,12 @@ private: // with an SPSC ring between them. juce::AudioDeviceManager inputDeviceManager; juce::AudioDeviceManager outputDeviceManager; - std::atomic duplexMode{true}; + + // Shared run-state atomics (TLC phase 1) — the members below are + // reference aliases under their historical names so call sites are + // untouched; extracted units take `state` (EngineState&) directly. + slopsmith::EngineState state; + std::atomic& duplexMode = state.duplexMode; // Per-input capture+detect+monitor chains. A FIXED pool, all constructed up // front, so adding/removing a source never reassigns a pointer the audio @@ -538,23 +544,12 @@ private: std::atomic backingSpeedChangePending{false}; juce::CriticalSection backingLock; - // Toggled from startAudio()/stopAudio() (main / device-management - // threads) and read from isAudioRunning() on the JS thread via the - // audio-bridge dispatch loop. Plain bool would be a data race; - // relaxed-atomic is well-defined and compiles to a plain MOV. - std::atomic audioRunning{false}; - // Sample rate is written from the JUCE device callbacks (audio - // thread / device-management thread) and read from arbitrary - // callers including the JS thread via getCurrentSampleRate(), - // so a plain double would be a C++ data race. std::atomic - // is well-defined and lock-free on the platforms we ship; the - // hot reads use relaxed since the consumer just wants the latest - // observable value, not a synchronization point. - std::atomic currentSampleRate{48000.0}; - // Split mode allows different input vs output block sizes; the ring absorbs - // the asymmetry. DSP prepares against input; backing resampler against output. - std::atomic inputBlockSize{256}; - std::atomic outputBlockSize{256}; + // audioRunning keeps its historical DEVICE-STATE semantics (isAudioRunning + // compat pin); the intent half is state.userWantsAudio — see EngineState.h. + std::atomic& audioRunning = state.deviceRunning; + std::atomic& currentSampleRate = state.currentSampleRate; + std::atomic& inputBlockSize = state.inputBlockSize; + std::atomic& outputBlockSize = state.outputBlockSize; // The per-input lock-free SPSC rings (pre-gate getInputFrame ring + post-gate // getRawAudioFrame ring), the YIN/ML detectors, and the zero-output capture diff --git a/src/audio/engine/EngineState.h b/src/audio/engine/EngineState.h new file mode 100644 index 0000000..7dcc1ef --- /dev/null +++ b/src/audio/engine/EngineState.h @@ -0,0 +1,55 @@ +#pragma once + +// EngineState — the audio engine's shared run-state atomics (TLC plan +// phase 1 / §2.8). Every extracted engine unit takes an EngineState& instead +// of reaching back into AudioEngine, which is what keeps them unit-testable +// without JUCE devices. AudioEngine itself binds these members by reference +// under their historical names, so existing call sites are untouched. +// +// The deliberate fix homed here (deep-read §3/§6): the old single +// `audioRunning` flag conflated USER INTENT ("the user pressed Start") with +// DEVICE STATE ("a device callback is live") — audioDeviceStopped() clears it +// on transient stops (WASAPI exclusive opens routinely fire one mid-start), +// so setAudioDevices' restart decision read a racy answer. The two are now +// separate atomics: +// +// userWantsAudio — intent. Written ONLY by startAudio()/stopAudio(). +// deviceRunning — state. Written by startAudio()/stopAudio() AND the +// device callbacks (aboutToStart/stopped), i.e. the exact +// semantics the old audioRunning had. isAudioRunning() +// keeps reporting THIS one (Phase 0.b compat pin). +// +// Until the phase-8 fix, nothing reads userWantsAudio — writing it here first +// keeps that later commit a one-line read-side change in setAudioDevices. + +#include + +namespace slopsmith { + +struct EngineState +{ + // Sample rate is written from the JUCE device callbacks (audio thread / + // device-management thread) and read from arbitrary callers including the + // JS thread, so a plain double would be a C++ data race. atomic + // is lock-free on the platforms we ship; hot reads use relaxed since the + // consumer just wants the latest observable value, not a sync point. + std::atomic currentSampleRate{48000.0}; + // Split mode allows different input vs output block sizes; the ring + // absorbs the asymmetry. DSP prepares against input; backing resampler + // against output. + std::atomic inputBlockSize{256}; + std::atomic outputBlockSize{256}; + // Duplex: one device manager owns both directions. Split: input-only + + // output-only managers with an SPSC ring between them. + std::atomic duplexMode{true}; + + // Intent: the user asked for audio to run. start/stopAudio only. + std::atomic userWantsAudio{false}; + // State: toggled from startAudio()/stopAudio() (main/device-management + // threads) and the device callbacks, read from isAudioRunning() on the JS + // thread. Plain bool would be a data race; relaxed-atomic compiles to a + // plain MOV. + std::atomic deviceRunning{false}; +}; + +} // namespace slopsmith diff --git a/tests/engine_units/CMakeLists.txt b/tests/engine_units/CMakeLists.txt index 88447f1..b56b047 100644 --- a/tests/engine_units/CMakeLists.txt +++ b/tests/engine_units/CMakeLists.txt @@ -12,3 +12,7 @@ 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) diff --git a/tests/engine_units/engine_state_test.cpp b/tests/engine_units/engine_state_test.cpp new file mode 100644 index 0000000..4b5e882 --- /dev/null +++ b/tests/engine_units/engine_state_test.cpp @@ -0,0 +1,53 @@ +// Phase 1 unit test for EngineState (docs/audio-engine-tlc.md §5): the +// intent/state transition table. Mirrors how AudioEngine drives the two +// flags — startAudio/stopAudio write BOTH (intent + state), the device +// callbacks write deviceRunning ONLY — and pins the Phase 0.b compat +// decision that isAudioRunning() reports DEVICE STATE: a transient device +// stop flips it false even though the user never pressed Stop. + +#include "../../src/audio/engine/EngineState.h" + +#include +#include + +using slopsmith::EngineState; + +// The write sets, as AudioEngine performs them. +static void userStart(EngineState& s) { s.userWantsAudio.store(true); s.deviceRunning.store(true); } +static void userStop(EngineState& s) { s.userWantsAudio.store(false); s.deviceRunning.store(false); } +static void deviceAboutToStart(EngineState& s) { s.deviceRunning.store(true); } +static void deviceStopped(EngineState& s) { s.deviceRunning.store(false); } +// isAudioRunning() facade == deviceRunning (compat pin). +static bool isAudioRunning(const EngineState& s) { return s.deviceRunning.load(); } + +int main() +{ + EngineState s; + assert(!s.userWantsAudio.load() && !isAudioRunning(s)); + + // User starts audio. + userStart(s); + assert(s.userWantsAudio.load() && isAudioRunning(s)); + + // Transient device stop (WASAPI exclusive mid-start hiccup): device state + // drops, intent survives — this is the split that fixes deep-read §3. + deviceStopped(s); + assert(s.userWantsAudio.load() && "transient stop must not erase user intent"); + assert(!isAudioRunning(s) && "compat pin: isAudioRunning reports device state"); + + // JUCE auto-restart brings the device back without user action. + deviceAboutToStart(s); + assert(s.userWantsAudio.load() && isAudioRunning(s)); + + // Explicit user stop clears both. + userStop(s); + assert(!s.userWantsAudio.load() && !isAudioRunning(s)); + + // A stray device start (auto-restart after user stop) must not fabricate + // intent: device state true, intent still false. + deviceAboutToStart(s); + assert(!s.userWantsAudio.load() && isAudioRunning(s)); + + std::puts("engine_state: all transitions passed"); + return 0; +} From 70f3316094d26daa0810e7795aca31d467849c50 Mon Sep 17 00:00:00 2001 From: OmikronApex Date: Mon, 13 Jul 2026 23:46:55 +0200 Subject: [PATCH 06/28] refactor(audio): extract RendererBus (phase 2) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Moves the WebAudio→engine bus — ring, producer-side linear resampler, prefill gate, fill clamp, metrics — verbatim into src/audio/engine/RendererBus.h. AudioEngine keeps thin facades (setRendererBus/pushRendererAudio/pullRendererBus/getRendererBusMetrics) so the NodeAddon surface is unchanged. JUCE-free: pull() takes raw channel pointers, which is what lets tests/engine_units drive the resampler continuity, prime/underflow/clamp, and metrics cases without a device. The control-thread readIndex write on disable (deep-read §4) is preserved verbatim and marked; its flush-flag fix lands as the phase-8 commit. Co-Authored-By: Claude Fable 5 --- src/audio/AudioEngine.cpp | 120 ++----------- src/audio/AudioEngine.h | 47 +----- src/audio/engine/RendererBus.h | 206 +++++++++++++++++++++++ tests/engine_units/CMakeLists.txt | 4 + tests/engine_units/renderer_bus_test.cpp | 155 +++++++++++++++++ 5 files changed, 380 insertions(+), 152 deletions(-) create mode 100644 src/audio/engine/RendererBus.h create mode 100644 tests/engine_units/renderer_bus_test.cpp diff --git a/src/audio/AudioEngine.cpp b/src/audio/AudioEngine.cpp index d0fba8f..ecb060f 100644 --- a/src/audio/AudioEngine.cpp +++ b/src/audio/AudioEngine.cpp @@ -3039,126 +3039,28 @@ void AudioEngine::audioOutputCallback(const float* const* /*inputData*/, bool AudioEngine::pushRendererAudio(const float* interleavedLR, int frames, double sourceRate) { - if (!rendererBusEnabled.load(std::memory_order_acquire)) return false; - if (interleavedLR == nullptr || frames <= 0) return false; - const double deviceRate = getCurrentSampleRate(); - if (deviceRate <= 0.0) return false; - if (!(sourceRate > 0.0)) sourceRate = deviceRate; - - uint64_t w = rendererBusRing.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; - double pos = rendererBusSrcPos; - uint64_t written = 0; - while (true) - { - const double ip = std::floor(pos); - const int i0 = (int) ip; - if (i0 + 1 >= frames) break; // next chunk continues from here - const float frac = (float) (pos - ip); - const float l0 = (i0 < 0) ? rendererBusPrevL : interleavedLR[(size_t) i0 * 2]; - const float r0 = (i0 < 0) ? rendererBusPrevR : interleavedLR[(size_t) i0 * 2 + 1]; - const float l1 = interleavedLR[((size_t) i0 + 1) * 2]; - const float r1 = interleavedLR[((size_t) i0 + 1) * 2 + 1]; - rendererBusRing.stageFrame(w, l0 + (l1 - l0) * frac, r0 + (r1 - r0) * frac); - ++w; - ++written; - pos += step; - } - rendererBusSrcPos = pos - (double) frames; // relative to the next chunk - rendererBusPrevL = interleavedLR[((size_t) frames - 1) * 2]; - rendererBusPrevR = interleavedLR[((size_t) frames - 1) * 2 + 1]; - - // Publish. Overflow (producer lapping the consumer) is handled consumer- - // side with drop-oldest — same contract as the extra-input rings — so only - // the consumer ever moves readIndex. - rendererBusRing.publish(w); - rendererBusPushedFrames.fetch_add(written, std::memory_order_relaxed); - return true; + // Producer-side resample + publish live on RendererBus (engine/RendererBus.h). + return rendererBus.push(interleavedLR, frames, sourceRate, getCurrentSampleRate()); } int AudioEngine::pullRendererBus(juce::AudioBuffer& dest, int numSamples) { - if (!rendererBusEnabled.load(std::memory_order_acquire)) return 0; // Cold start before about-to-start sized the scratch — skip, never alloc // on the RT thread (same rule as the stream scratches). if (dest.getNumSamples() < numSamples || dest.getNumChannels() < 2) return 0; - const uint64_t w = rendererBusRing.writeIndex.load(std::memory_order_acquire); - uint64_t r = rendererBusRing.readIndex.load(std::memory_order_relaxed); - if (w - r > (uint64_t) kRendererBusFrames) - { - // Producer lapped us — drop-oldest to the newest full ring. - r = w - (uint64_t) kRendererBusFrames; - rendererBusOverflowCount.fetch_add(1, std::memory_order_relaxed); - } - uint64_t avail = w - r; - - // Fill clamp (spike finding): steady-state drift is near zero, so a fill - // beyond kRendererBusMaxFill only ever means a renderer stall dumped a - // backlog. Trim to the prime target instead of playing the whole tail at - // ~85+ ms behind — a latency reset, not an audible gap. - if (avail > (uint64_t) kRendererBusMaxFillFrames) - { - r = w - (uint64_t) kRendererBusPrimeFrames; - avail = (uint64_t) kRendererBusPrimeFrames; - rendererBusOverflowCount.fetch_add(1, std::memory_order_relaxed); - } - - // Prefill gate (spike finding): the warmup underflow burst is the mix - // starting before the ring has a cushion. Consume nothing until the - // producer has built ~10 ms; re-arm the same gate after a real underflow - // so stall recovery is one clean gap, not a ragged refill. - if (!rendererBusPrimed) - { - if (avail < (uint64_t) kRendererBusPrimeFrames) - { - rendererBusRing.commitRead(r); - return 0; - } - rendererBusPrimed = true; - } - if (avail < (uint64_t) numSamples) - { - // Underflow: emit silence for the whole block (partial blocks blip), - // drop what's buffered, and go back to priming. - rendererBusPrimed = false; - rendererBusUnderflowCount.fetch_add(1, std::memory_order_relaxed); - rendererBusRing.commitRead(w); - return 0; - } - - const int pull = numSamples; - const float g = rendererBusGain.load(std::memory_order_relaxed); - float* dl = dest.getWritePointer(0); - float* dr = dest.getWritePointer(1); - for (int i = 0; i < pull; ++i) - { - float l, rr; - rendererBusRing.readFrame(r + (uint64_t) i, l, rr); - dl[i] = l * g; - dr[i] = rr * g; - } - rendererBusRing.commitRead(r + (uint64_t) pull); - rendererBusConsumedFrames.fetch_add((uint64_t) pull, std::memory_order_relaxed); - return pull; + return rendererBus.pull(dest.getWritePointer(0), dest.getWritePointer(1), numSamples); } AudioEngine::RendererBusMetrics AudioEngine::getRendererBusMetrics() const { + const auto bm = rendererBus.metrics(); RendererBusMetrics m; - m.pushedFrames = rendererBusPushedFrames.load(std::memory_order_relaxed); - m.consumedFrames = rendererBusConsumedFrames.load(std::memory_order_relaxed); - m.underflowCount = rendererBusUnderflowCount.load(std::memory_order_relaxed); - m.overflowCount = rendererBusOverflowCount.load(std::memory_order_relaxed); - const uint64_t w = rendererBusRing.writeIndex.load(std::memory_order_acquire); - const uint64_t r = rendererBusRing.readIndex.load(std::memory_order_acquire); - m.fillFrames = (int) juce::jmin(w - r, (uint64_t) kRendererBusFrames); - m.capacityFrames = kRendererBusFrames; - m.enabled = rendererBusEnabled.load(std::memory_order_relaxed); + m.pushedFrames = bm.pushedFrames; + m.consumedFrames = bm.consumedFrames; + m.underflowCount = bm.underflowCount; + m.overflowCount = bm.overflowCount; + m.fillFrames = bm.fillFrames; + m.capacityFrames = bm.capacityFrames; + m.enabled = bm.enabled; return m; } diff --git a/src/audio/AudioEngine.h b/src/audio/AudioEngine.h index 4933777..81e580c 100644 --- a/src/audio/AudioEngine.h +++ b/src/audio/AudioEngine.h @@ -3,6 +3,7 @@ #include "GainSanitize.h" #include "engine/PackedStereoRing.h" #include "engine/EngineState.h" +#include "engine/RendererBus.h" #include "BackingLeveler.h" #include "signalsmith-stretch.h" #include @@ -270,20 +271,7 @@ public: // mixer path is silenced. SPSC: producer is the main-process IPC thread, // consumer is whichever output callback is live (duplex or split). Default // off → zero behaviour change. - void setRendererBus(bool enabled, float gain) - { - rendererBusGain.store(sanitizeStreamGain(gain), std::memory_order_relaxed); - const bool was = rendererBusEnabled.exchange(enabled, std::memory_order_acq_rel); - if (was && !enabled) - { - // Drop buffered audio on disable so a later re-enable starts fresh - // instead of playing a stale tail. Consumer tolerates the jump. - rendererBusRing.readIndex.store( - rendererBusRing.writeIndex.load(std::memory_order_acquire), - std::memory_order_release); - rendererBusPrimed.store(false, std::memory_order_relaxed); - } - } + void setRendererBus(bool enabled, float gain) { rendererBus.setEnabled(enabled, gain); } // Interleaved stereo frames at `sourceRate`; linear-resampled to the device // rate on the producer thread (fractional position + previous frame carried // across calls). Returns false when the bus is disabled or the engine is @@ -563,35 +551,8 @@ private: static constexpr int kOutputRingFrames = 4096; slopsmith::PackedStereoRing outputRing; - // ── Renderer-audio bus ring (see setRendererBus/pushRendererAudio) ─────── - // Same packed-LR SPSC design as outputRing. Sized generously - // (~1.5 s @ 48 kHz — vs outputRing'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; - slopsmith::PackedStereoRing rendererBusRing; - std::atomic rendererBusPushedFrames{0}; - std::atomic rendererBusConsumedFrames{0}; - std::atomic rendererBusUnderflowCount{0}; - std::atomic rendererBusOverflowCount{0}; - std::atomic rendererBusEnabled{false}; - std::atomic rendererBusGain{1.0f}; - // Consumer-side prefill-gate state. Only the live output callback touches - // it, but duplex/split hand-offs cross threads — atomic keeps that safe. - std::atomic rendererBusPrimed{false}; - // Producer-thread-only linear-resampler state (fractional read position - // into the incoming chunk + the previous chunk's last frame for - // interpolation continuity across pushes). - double rendererBusSrcPos = 0.0; - float rendererBusPrevL = 0.0f, rendererBusPrevR = 0.0f; + // ── Renderer-audio bus (see engine/RendererBus.h — moved in TLC phase 2) + slopsmith::RendererBus rendererBus; // Shared consumer step for the duplex and split output paths: drain one // block from the renderer-bus ring into `dest` (stereo, bus gain applied, // dest cleared first). Returns numSamples on success, 0 when gated diff --git a/src/audio/engine/RendererBus.h b/src/audio/engine/RendererBus.h new file mode 100644 index 0000000..a01c6c8 --- /dev/null +++ b/src/audio/engine/RendererBus.h @@ -0,0 +1,206 @@ +#pragma once + +// RendererBus — the WebAudio→engine audio bus (TLC plan phase 2 / §2.6). +// Moved verbatim from AudioEngine (see git history for the original inline +// comments' evolution): the renderer pushes its WebAudio master mix here over +// IPC so song/stem audio stays audible when the output device is +// exclusive-style (ASIO / WASAPI exclusive) and the OS mixer path is silent. +// +// SPSC: producer is the main-process IPC thread (push — includes the linear +// resampler), consumer is whichever output callback is live (pull). Sized +// generously (~1.5 s @ 48 kHz) because the producer has scheduling jitter; +// the consumer trims steady-state fill via the fill clamp. +// +// JUCE-free on purpose: pull() takes raw channel pointers, so +// tests/engine_units drives the resampler/prime/clamp logic without a device. + +#include "PackedStereoRing.h" +#include "../GainSanitize.h" + +#include +#include +#include + +namespace slopsmith { + +class RendererBus +{ +public: + static constexpr int kFrames = 65536; + // Prefill gate: consume nothing until the producer has built this cushion + // (~10.7 ms @ 48 kHz); re-armed after every underflow so stall recovery is + // one clean gap. Fill clamp: fill beyond kMaxFillFrames (~85 ms) means a + // renderer stall dumped a backlog — trim to the prime target, don't play + // the tail. + static constexpr int kPrimeFrames = 512; + static constexpr int kMaxFillFrames = 4096; + + void setEnabled(bool enabled, float gain) + { + busGain.store(sanitizeStreamGain(gain), std::memory_order_relaxed); + const bool was = busEnabled.exchange(enabled, std::memory_order_acq_rel); + if (was && !enabled) + { + // Drop buffered audio on disable so a later re-enable starts fresh + // instead of playing a stale tail. Consumer tolerates the jump. + // KNOWN ISSUE (deep-read §4, fixed in the follow-up commit): this + // writes readIndex from the control thread while pull() is the + // designated consumer-side writer. + ring.readIndex.store(ring.writeIndex.load(std::memory_order_acquire), + 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; + if (deviceRate <= 0.0) return false; + if (!(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; + 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) + { + if (!busEnabled.load(std::memory_order_acquire)) return 0; + const uint64_t w = ring.writeIndex.load(std::memory_order_acquire); + uint64_t r = ring.readIndex.load(std::memory_order_relaxed); + if (w - r > (uint64_t) kFrames) + { + // Producer lapped us — drop-oldest to the newest full ring. + r = w - (uint64_t) kFrames; + overflowCount.fetch_add(1, std::memory_order_relaxed); + } + uint64_t avail = w - r; + + // Fill clamp (spike finding): steady-state drift is near zero, so a + // fill beyond kMaxFillFrames only ever means a renderer stall dumped a + // backlog. Trim to the prime target instead of playing the whole tail + // at ~85+ ms behind — a latency reset, not an audible gap. + if (avail > (uint64_t) kMaxFillFrames) + { + r = w - (uint64_t) kPrimeFrames; + avail = (uint64_t) kPrimeFrames; + overflowCount.fetch_add(1, std::memory_order_relaxed); + } + + // Prefill gate (spike finding): the warmup underflow burst is the mix + // starting before the ring has a cushion. Consume nothing until the + // producer has built ~10 ms; re-arm the same gate after a real + // underflow so stall recovery is one clean gap, not a ragged refill. + if (!primed) + { + if (avail < (uint64_t) kPrimeFrames) + { + ring.commitRead(r); + return 0; + } + primed = true; + } + if (avail < (uint64_t) numSamples) + { + // Underflow: emit silence for the whole block (partial blocks + // blip), drop what's buffered, and go back to priming. + primed = false; + underflowCount.fetch_add(1, std::memory_order_relaxed); + ring.commitRead(w); + return 0; + } + + const float g = busGain.load(std::memory_order_relaxed); + for (int i = 0; i < numSamples; ++i) + { + float l, rr; + ring.readFrame(r + (uint64_t) i, l, rr); + dl[i] = l * g; + dr[i] = rr * g; + } + ring.commitRead(r + (uint64_t) numSamples); + consumedFrames.fetch_add((uint64_t) numSamples, std::memory_order_relaxed); + return numSamples; + } + + struct Metrics + { + uint64_t pushedFrames = 0, consumedFrames = 0, underflowCount = 0, overflowCount = 0; + int fillFrames = 0, capacityFrames = 0; + bool enabled = false; + }; + Metrics metrics() const + { + Metrics m; + m.pushedFrames = pushedFrames.load(std::memory_order_relaxed); + m.consumedFrames = consumedFrames.load(std::memory_order_relaxed); + m.underflowCount = underflowCount.load(std::memory_order_relaxed); + m.overflowCount = overflowCount.load(std::memory_order_relaxed); + const uint64_t w = ring.writeIndex.load(std::memory_order_acquire); + const uint64_t r = ring.readIndex.load(std::memory_order_acquire); + const uint64_t fill = w - r; + m.fillFrames = (int) (fill < (uint64_t) kFrames ? fill : (uint64_t) kFrames); + m.capacityFrames = kFrames; + m.enabled = busEnabled.load(std::memory_order_relaxed); + return m; + } + +private: + PackedStereoRing ring; + std::atomic pushedFrames{0}; + std::atomic consumedFrames{0}; + std::atomic underflowCount{0}; + std::atomic overflowCount{0}; + std::atomic busEnabled{false}; + std::atomic busGain{1.0f}; + // Consumer-side prefill-gate state. Only the live output callback touches + // it, but duplex/split hand-offs cross threads — atomic keeps that safe. + std::atomic primed{false}; + // Producer-thread-only linear-resampler state (fractional read position + // into the incoming chunk + the previous chunk's last frame for + // interpolation continuity across pushes). + double srcPos = 0.0; + float prevL = 0.0f, prevR = 0.0f; +}; + +} // namespace slopsmith diff --git a/tests/engine_units/CMakeLists.txt b/tests/engine_units/CMakeLists.txt index b56b047..6eca508 100644 --- a/tests/engine_units/CMakeLists.txt +++ b/tests/engine_units/CMakeLists.txt @@ -16,3 +16,7 @@ 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) diff --git a/tests/engine_units/renderer_bus_test.cpp b/tests/engine_units/renderer_bus_test.cpp new file mode 100644 index 0000000..51cbf82 --- /dev/null +++ b/tests/engine_units/renderer_bus_test.cpp @@ -0,0 +1,155 @@ +// Phase 2 unit tests for RendererBus (docs/audio-engine-tlc.md §5): +// resampler continuity across pushes, equal-rate bit-exactness, the prime +// gate, underflow → silence + re-prime, fill clamp, and metrics arithmetic. +// The flush-on-disable test flips once the phase-8 flush-flag fix lands. + +#include "../../src/audio/engine/RendererBus.h" + +#include +#include +#include +#include + +using slopsmith::RendererBus; + +static std::vector rampChunk(int frames, float start, float step) +{ + std::vector v((size_t) frames * 2); + for (int i = 0; i < frames; ++i) + { + v[(size_t) i * 2] = start + step * (float) i; + v[(size_t) i * 2 + 1] = -(start + step * (float) i); + } + return v; +} + +// Equal rates degenerate to step == 1.0 — frames must come out bit-exact +// (minus the one-frame interpolation carry at each chunk boundary). +static void testEqualRateBitExact() +{ + RendererBus bus; + bus.setEnabled(true, 1.0f); + const auto c1 = rampChunk(512, 0.0f, 1.0f); + const auto c2 = rampChunk(512, 512.0f, 1.0f); + assert(bus.push(c1.data(), 512, 48000.0, 48000.0)); + assert(bus.push(c2.data(), 512, 48000.0, 48000.0)); + + std::vector dl(512), dr(512); + assert(bus.pull(dl.data(), dr.data(), 512) == 512); + for (int i = 0; i < 512; ++i) + { + // First chunk's frame 0 is consumed as interpolation carry (pos + // starts at 0 with prev=0 carry → exact frame i lands at output i). + assert(dl[(size_t) i] == (float) i && dr[(size_t) i] == -(float) i); + } +} + +// Downsampling 2:1 across a chunk seam must be continuous: the interpolated +// ramp has no discontinuity where one push ends and the next begins. +static void testResampleContinuityAcrossPushes() +{ + RendererBus bus; + bus.setEnabled(true, 1.0f); + const double src = 96000.0, dev = 48000.0; + // Two chunks big enough that the 2:1 output (~1023 frames) clears the + // prime gate; the seam sits at output frame ~512. + const auto c1 = rampChunk(1024, 0.0f, 1.0f); + const auto c2 = rampChunk(1024, 1024.0f, 1.0f); + bus.push(c1.data(), 1024, src, dev); + bus.push(c2.data(), 1024, src, dev); + + std::vector dl(768), dr(768); + assert(bus.pull(dl.data(), dr.data(), 768) == 768); + for (int i = 1; i < 768; ++i) + { + const float d = dl[(size_t) i] - dl[(size_t) i - 1]; + // A linear ramp resampled 2:1 must step by ~2 everywhere, including + // across the seam at output frame ~128. + assert(std::fabs(d - 2.0f) < 1e-3f && "discontinuity at chunk seam"); + } +} + +// Prime gate: nothing comes out until ~kPrimeFrames are buffered. +static void testPrimeGate() +{ + RendererBus bus; + bus.setEnabled(true, 1.0f); + std::vector dl(64), dr(64); + const auto tiny = rampChunk(RendererBus::kPrimeFrames / 2, 1.0f, 0.0f); + bus.push(tiny.data(), RendererBus::kPrimeFrames / 2, 48000.0, 48000.0); + assert(bus.pull(dl.data(), dr.data(), 64) == 0 && "must gate until primed"); + bus.push(tiny.data(), RendererBus::kPrimeFrames / 2, 48000.0, 48000.0); + // Cushion built (minus the 1-frame carry per push) — next pull flows. + bus.push(tiny.data(), RendererBus::kPrimeFrames / 2, 48000.0, 48000.0); + assert(bus.pull(dl.data(), dr.data(), 64) == 64); +} + +// Underflow: whole-block silence, buffered tail dropped, back to priming. +static void testUnderflowReprimes() +{ + RendererBus bus; + bus.setEnabled(true, 1.0f); + const auto chunk = rampChunk(RendererBus::kPrimeFrames + 64, 1.0f, 0.0f); + bus.push(chunk.data(), RendererBus::kPrimeFrames + 64, 48000.0, 48000.0); + std::vector dl(512), dr(512); + assert(bus.pull(dl.data(), dr.data(), 512) == 512); + // Ring now nearly empty → this pull underflows. + assert(bus.pull(dl.data(), dr.data(), 512) == 0); + assert(bus.metrics().underflowCount == 1); + // And the gate re-armed: a sub-prime refill still gates. + const auto tiny = rampChunk(64, 1.0f, 0.0f); + bus.push(tiny.data(), 64, 48000.0, 48000.0); + assert(bus.pull(dl.data(), dr.data(), 32) == 0 && "must re-prime after underflow"); +} + +// Fill clamp: a dumped backlog beyond kMaxFillFrames is trimmed to the prime +// target instead of being played ~85 ms late. +static void testFillClampTrimsBacklog() +{ + RendererBus bus; + bus.setEnabled(true, 1.0f); + const int backlog = RendererBus::kMaxFillFrames + 2048; + const auto chunk = rampChunk(backlog + 1, 1.0f, 0.0f); + bus.push(chunk.data(), backlog + 1, 48000.0, 48000.0); + std::vector dl(256), dr(256); + assert(bus.pull(dl.data(), dr.data(), 256) == 256); + const auto m = bus.metrics(); + assert(m.overflowCount == 1 && "fill clamp must count as overflow"); + assert(m.fillFrames <= RendererBus::kPrimeFrames && "backlog must be trimmed to prime target"); +} + +// Disabled bus: push and pull are inert. +static void testDisabledIsInert() +{ + RendererBus bus; + const auto chunk = rampChunk(128, 1.0f, 0.0f); + assert(!bus.push(chunk.data(), 128, 48000.0, 48000.0)); + std::vector dl(64), dr(64); + assert(bus.pull(dl.data(), dr.data(), 64) == 0); + assert(!bus.metrics().enabled); +} + +// Gain is applied consumer-side and sanitized (0..8, non-finite → 0). +static void testGainApplied() +{ + RendererBus bus; + bus.setEnabled(true, 2.0f); + const auto chunk = rampChunk(RendererBus::kPrimeFrames + 65, 1.0f, 0.0f); + bus.push(chunk.data(), RendererBus::kPrimeFrames + 65, 48000.0, 48000.0); + std::vector dl(64), dr(64); + assert(bus.pull(dl.data(), dr.data(), 64) == 64); + assert(dl[0] == 2.0f && dr[0] == -2.0f); +} + +int main() +{ + testEqualRateBitExact(); + testResampleContinuityAcrossPushes(); + testPrimeGate(); + testUnderflowReprimes(); + testFillClampTrimsBacklog(); + testDisabledIsInert(); + testGainApplied(); + std::puts("renderer_bus: all cases passed"); + return 0; +} From 797501e5ff5b8c3005d099227586ba4f61bb5079 Mon Sep 17 00:00:00 2001 From: OmikronApex Date: Mon, 13 Jul 2026 23:53:27 +0200 Subject: [PATCH 07/28] refactor(audio): extract StreamSink (phase 2) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Promotes the streamer-mix output sink to a class owning its AudioDeviceManager, drain callback, ring, scratches, submix compose (publish, was composeAndPushStreamMix), and open/close/clear/reopen lifecycle — moved verbatim into src/audio/engine/StreamSink.{h,cpp}. Bus flags (includeBacking/includeGuitar/gain) and the level meter move in; engine sample rate / output block size are read through the bound EngineState&. AudioEngine keeps thin facades so the NodeAddon surface is unchanged; the guitar-snapshot scratch stays on the engine (it snapshots the engine's own mix). Compose-matrix unit tests are deferred: they need juce::AudioBuffer, which the JUCE-free engine_units harness doesn't link — covered meanwhile by the stream under/overflow counters + level meter over IPC and the OBS-capture manual smoke. Co-Authored-By: Claude Fable 5 --- src/audio/AudioEngine.cpp | 271 ++------------------------------ src/audio/AudioEngine.h | 98 ++---------- src/audio/CMakeLists.txt | 1 + src/audio/engine/StreamSink.cpp | 263 +++++++++++++++++++++++++++++++ src/audio/engine/StreamSink.h | 142 +++++++++++++++++ 5 files changed, 431 insertions(+), 344 deletions(-) create mode 100644 src/audio/engine/StreamSink.cpp create mode 100644 src/audio/engine/StreamSink.h diff --git a/src/audio/AudioEngine.cpp b/src/audio/AudioEngine.cpp index ecb060f..e79c83c 100644 --- a/src/audio/AudioEngine.cpp +++ b/src/audio/AudioEngine.cpp @@ -1243,7 +1243,7 @@ void AudioEngine::startAudio() // Restore the streamer-mix output device too (same intent-survives-restart // pattern). Best-effort — a failure leaves the sink inactive, never blocks. - reopenDesiredStreamSink(); + streamSink.reopenDesired(); } void AudioEngine::stopAudio() @@ -1277,7 +1277,7 @@ void AudioEngine::stopAudio() // 2nd output device keeps running and underflowing while the engine is "stopped", // and the destructor (which calls stopAudio()) would be the only path that ever // closes it — closing the device here also makes that destructor teardown safe. - closeStreamSinkDevice(); + streamSink.close(); audioRunning.store(false, std::memory_order_relaxed); currentBackingLevel.store(0.0f); } @@ -1836,7 +1836,7 @@ void AudioEngine::audioDeviceAboutToStart(juce::AudioIODevice* device) // once: it can never realloc under a live producer, for any later block size. constexpr int streamScratchCap = (int) kOutputRingFrames; if (streamGuitarScratch.getNumSamples() < streamScratchCap) streamGuitarScratch.setSize(2, streamScratchCap, false, false, true); - if (streamMixScratch.getNumSamples() < streamScratchCap) streamMixScratch.setSize(2, streamScratchCap, false, false, true); + streamSink.prepareProducerScratch(); if (rendererBusPullScratch.getNumSamples() < streamScratchCap) rendererBusPullScratch.setSize(2, streamScratchCap, false, false, true); // Prepare each ACTIVE PRIMARY-device source's DSP and reset its rings for a @@ -1927,7 +1927,7 @@ void AudioEngine::audioOutputAboutToStart(juce::AudioIODevice* device) // note in audioDeviceAboutToStart(). constexpr int streamScratchCap = (int) kOutputRingFrames; if (streamGuitarScratch.getNumSamples() < streamScratchCap) streamGuitarScratch.setSize(2, streamScratchCap, false, false, true); - if (streamMixScratch.getNumSamples() < streamScratchCap) streamMixScratch.setSize(2, streamScratchCap, false, false, true); + streamSink.prepareProducerScratch(); if (rendererBusPullScratch.getNumSamples() < streamScratchCap) rendererBusPullScratch.setSize(2, streamScratchCap, false, false, true); // NOTE: outputBackingBuffer is sized by audioDeviceAboutToStart() from the // INPUT device's block size — it's the split-input DSP scratch, not an @@ -1991,264 +1991,17 @@ void AudioEngine::audioOutputStopped() // ring invariants being re-established, which is harder to reason about. } -// ── Streamer mix output sink (PR1) ────────────────────────────────────────── -// Producer (primary/output callback): compose the stream submix and pack it into -// the sink's ring. Consumer (streamSinkCallback): drain + write to the device. -// Mirrors the InputDeviceSlot ring discipline, inverted to the output side. - -void AudioEngine::composeAndPushStreamMix(const juce::AudioBuffer& guitarMix, - const juce::AudioBuffer* backingBuf, - int backingFrames, float backingVol, - const juce::AudioBuffer* rendererBuf, - int rendererFrames, int numSamples) -{ - if (! streamSink.active.load(std::memory_order_acquire)) return; - // A block larger than the entire ring can't be published atomically (it would - // wrap and overwrite unread slots before writeIndex is bumped). Skip it and - // count an overflow. Checked FIRST (before the scratch guard) so an oversized - // block is always counted — the fixed-size scratch is exactly the ring, so an - // oversized block also trips the scratch guard below and would otherwise be - // dropped silently. The split path already rejects oversized devices at setup; - // this guards the duplex path, whose block size we don't pre-validate. - if (numSamples > (int) kOutputRingFrames) - { - streamSink.overflowCount.fetch_add(1, std::memory_order_relaxed); - return; - } - // Scratch not yet sized to the full ring (cold start before the producer's - // about-to-start ran, or a transient reconfig) — skip rather than alloc on RT. - // After warm-up the scratch is exactly the ring, so for an in-range block this - // never trips. - if (streamMixScratch.getNumSamples() < numSamples) return; - - const bool ig = streamBusIncludeGuitar.load(std::memory_order_relaxed); - const bool ib = streamBusIncludeBacking.load(std::memory_order_relaxed); - const float gain = streamBusGain.load(std::memory_order_relaxed); - - streamMixScratch.clear(0, 0, numSamples); - streamMixScratch.clear(1, 0, numSamples); - if (ig) - for (int ch = 0; ch < 2; ++ch) - streamMixScratch.addFrom(ch, 0, guitarMix, - juce::jmin(ch, guitarMix.getNumChannels() - 1), 0, numSamples); - if (ib && backingBuf != nullptr && backingFrames > 0) - { - const int n = juce::jmin(backingFrames, numSamples); - for (int ch = 0; ch < 2; ++ch) - streamMixScratch.addFrom(ch, 0, *backingBuf, - juce::jmin(ch, backingBuf->getNumChannels() - 1), 0, n, backingVol); - } - // Renderer-fed song audio (stems / element / loopback riding the renderer - // bus) is song audio for the streamer too — without this the stream mix - // carries guitar only whenever the song bypasses the native transport - // (multi-stem under exclusive/ASIO output). Bus gain is already applied by - // pullRendererBus; only the stream gain below shapes it further. Backing - // transport and renderer bus are mutually exclusive song paths in - // practice, so this never double-carries. - if (ib && rendererBuf != nullptr && rendererFrames > 0) - { - const int n = juce::jmin(rendererFrames, numSamples); - for (int ch = 0; ch < 2; ++ch) - streamMixScratch.addFrom(ch, 0, *rendererBuf, - juce::jmin(ch, rendererBuf->getNumChannels() - 1), 0, n); - } - streamMixScratch.applyGain(0, 0, numSamples, gain); - streamMixScratch.applyGain(1, 0, numSamples, gain); - - const float peak = juce::jmax(streamMixScratch.getMagnitude(0, 0, numSamples), - streamMixScratch.getMagnitude(1, 0, numSamples)); - streamSinkLevel.store(peak, std::memory_order_relaxed); - - streamSink.ring.push(streamMixScratch.getReadPointer(0), - streamMixScratch.getReadPointer(1), numSamples); -} - -void AudioEngine::streamSinkCallback(float* const* outputData, int numOutputChannels, int numSamples) -{ - const juce::ScopedNoDenormals noDenormals; - if (numOutputChannels <= 0) return; - juce::AudioBuffer buffer(outputData, numOutputChannels, numSamples); - buffer.clear(); - - const int scratchCap = (int) streamSink.pullScratchL.size(); - const int outSamples = juce::jmin(numSamples, scratchCap); - - auto& ring = streamSink.ring; - 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)) - streamSink.overflowCount.fetch_add(1, std::memory_order_relaxed); - const uint64_t available = w - r; - const int pullCount = juce::jmin(outSamples, (int) available); - const int consumeCount = juce::jmin(numSamples, (int) available); - const int copyChannels = juce::jmin(numOutputChannels, 2); - for (int i = 0; i < pullCount; ++i) - { - 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) - streamSink.underflowCount.fetch_add(1, std::memory_order_relaxed); - ring.commitRead(r + (uint64_t) consumeCount); -} - -void AudioEngine::streamSinkAboutToStart(juce::AudioIODevice* device) -{ - if (device == nullptr) return; - const int bs = device->getCurrentBufferSizeSamples(); - double sr = device->getCurrentSampleRate(); - if (sr <= 0.0) sr = currentSampleRate.load(std::memory_order_relaxed); - streamSink.blockSize.store(bs, std::memory_order_relaxed); - streamSink.sampleRate.store(sr, std::memory_order_relaxed); - const int cap = juce::jmax(bs, 2048); - if ((int) streamSink.pullScratchL.size() < cap) streamSink.pullScratchL.assign((size_t) cap, 0.0f); - if ((int) streamSink.pullScratchR.size() < cap) streamSink.pullScratchR.assign((size_t) cap, 0.0f); - streamSink.ring.reset(); - streamSink.underflowCount.store(0, std::memory_order_relaxed); - streamSink.overflowCount.store(0, std::memory_order_relaxed); -} - -void AudioEngine::streamSinkStopped() -{ - // Fires on any stop of the stream device — an unplanned loss (unplug / driver - // reset) as well as our own teardown. Mark inactive so the producer stops - // filling a now-consumer-less ring and the meter clears; desiredTypeName/Name - // are deliberately left intact so reopenDesiredStreamSink() can restore it. - streamSink.active.store(false, std::memory_order_release); - streamSinkLevel.store(0.0f, std::memory_order_relaxed); -} +// ── Streamer mix output sink — moved to engine/StreamSink.{h,cpp} (TLC +// phase 2). Facades below keep the NodeAddon-visible surface unchanged. juce::String AudioEngine::setStreamOutputDevice(const juce::String& typeName, const juce::String& deviceName) { - // Control-thread only. Opens an OUTPUT-only device on the stream sink's own - // AudioDeviceManager and attaches the drain callback. Mirrors applySplitSetup's - // output open. v1 requires the sink's nominal SR to match the engine rate (no - // async resampler yet — that's PR3); a mismatch is rejected with a clear error. - streamSink.desiredTypeName = typeName; - streamSink.desiredDeviceName = deviceName; - - // Stop the producer from writing the ring while we (re)configure the device: - // setAudioDeviceSetup() below drives streamSinkAboutToStart(), which resets the - // ring indices and slots. Clearing `active` first stops the main callback from - // STARTING new pushes. A producer block already past the active-check can still - // finish one push, but the device close/reopen takes far longer than a single - // audio block, so that in-flight push completes well before about-to-start runs. - // Worst case is therefore one imperfect block on the STREAM bus (never the local - // monitor) during a manual device switch — atomic, no data race, no UAF. We only - // re-arm `active` after a fully clean open. - streamSink.active.store(false, std::memory_order_release); - - // Any failure below: detach/close the half-open device AND drop the desired - // intent. A deterministic failure (e.g. SR mismatch) is then NOT retried on every - // startAudio(), and the engine never reports active with no device behind it. The - // renderer keeps its own persisted choice and re-applies it, so nothing is lost. - auto fail = [this](const juce::String& msg) -> juce::String { - closeStreamSinkDevice(); - streamSink.desiredTypeName = {}; - streamSink.desiredDeviceName = {}; - return msg; - }; - - if (! streamSink.initialised) - { - streamSink.manager.initialise(0, 2, nullptr, false); - streamSink.initialised = true; - } - - juce::AudioIODeviceType* outType = nullptr; - for (auto* t : streamSink.manager.getAvailableDeviceTypes()) - if (t->getTypeName() == typeName) { outType = t; break; } - if (! outType) return fail("Stream output device type not found: " + typeName); - - try { - if (auto* cur = streamSink.manager.getCurrentDeviceTypeObject()) - { - if (cur->getTypeName() != typeName) - streamSink.manager.setCurrentAudioDeviceType(typeName, true); - } - else streamSink.manager.setCurrentAudioDeviceType(typeName, true); - } catch (...) { return fail("setCurrentAudioDeviceType threw for stream output type '" + typeName + "'"); } - - juce::String resolved = deviceName; - if (resolved.isEmpty()) - { - auto names = outType->getDeviceNames(false); - if (names.size() > 0) resolved = names[0]; - } - - juce::AudioDeviceManager::AudioDeviceSetup setup; - setup.inputDeviceName = ""; - setup.outputDeviceName = resolved; - setup.sampleRate = currentSampleRate.load(std::memory_order_relaxed); - setup.bufferSize = outputBlockSize.load(std::memory_order_relaxed); - setup.useDefaultInputChannels = false; - setup.useDefaultOutputChannels = false; - setup.inputChannels.clear(); - setup.outputChannels.setRange(0, 2, true); - - juce::String err; - try { err = streamSink.manager.setAudioDeviceSetup(setup, true); } - catch (...) { return fail("stream output setAudioDeviceSetup threw"); } - if (err.isNotEmpty()) return fail("stream output setup: " + err); - - auto* dev = streamSink.manager.getCurrentAudioDevice(); - if (! dev) return fail("no stream output device after setup"); - const double devSr = dev->getCurrentSampleRate(); - const double engineSr = currentSampleRate.load(std::memory_order_relaxed); - if (engineSr > 0.0 && std::abs(devSr - engineSr) > 0.5) - return fail("Stream output sample rate (" + juce::String(devSr) - + ") must match the engine rate (" + juce::String(engineSr) - + "). Pick a device that supports " + juce::String(engineSr) + " Hz."); - - streamSink.callback.engine = this; - if (! streamSink.callbackRegistered) - { - streamSink.manager.addAudioCallback(&streamSink.callback); - streamSink.callbackRegistered = true; - } - streamSink.active.store(true, std::memory_order_release); - fprintf(stderr, "[AudioEngine] stream output active: %s (%s)\n", - resolved.toRawUTF8(), typeName.toRawUTF8()); - return {}; -} - -void AudioEngine::closeStreamSinkDevice() -{ - // Detach the drain callback and close the device, leaving desiredTypeName/Name - // intact so startAudio()/reopenDesiredStreamSink() can restore it. active=false - // first so the producer stops pushing; removeAudioCallback() then blocks until - // the consumer callback is no longer in flight, so the ring/device go quiescent - // before close. Idempotent — safe when nothing is open. - streamSink.active.store(false, std::memory_order_release); - if (streamSink.callbackRegistered) - { - streamSink.manager.removeAudioCallback(&streamSink.callback); - streamSink.callbackRegistered = false; - } - try { streamSink.manager.closeAudioDevice(); } catch (...) {} - streamSinkLevel.store(0.0f, std::memory_order_relaxed); + return streamSink.open(typeName, deviceName); } void AudioEngine::clearStreamOutput() { - closeStreamSinkDevice(); - streamSink.desiredTypeName = {}; - streamSink.desiredDeviceName = {}; -} - -void AudioEngine::reopenDesiredStreamSink() -{ - // Copy the intent first: setStreamOutputDevice() mutates desiredTypeName/Name - // (and clears them on failure), so don't pass the members in by reference. - const juce::String t = streamSink.desiredTypeName; - const juce::String d = streamSink.desiredDeviceName; - if (d.isEmpty() && t.isEmpty()) return; - const juce::String err = setStreamOutputDevice(t, d); - if (err.isNotEmpty()) - fprintf(stderr, "[AudioEngine] reopenDesiredStreamSink failed: %s\n", err.toRawUTF8()); + streamSink.clear(); } void AudioEngine::audioDeviceIOCallbackWithContext( @@ -2335,7 +2088,7 @@ void AudioEngine::audioDeviceIOCallbackWithContext( { // Stream sink (producer): snapshot the guitar monitor mix BEFORE backing is // added, so the stream submix can carry guitar independent of the local mix. - const bool streamActive = streamSink.active.load(std::memory_order_acquire); + const bool streamActive = streamSink.isActive(); int streamBackingFrames = 0; float streamBackingVol = 0.0f; bool streamBackingOn = false; @@ -2383,7 +2136,7 @@ void AudioEngine::audioDeviceIOCallbackWithContext( // backing + renderer-bus song audio) BEFORE the local master output // gain, so the stream level is independent. if (streamActive) - composeAndPushStreamMix(streamGuitarScratch, + streamSink.publish(streamGuitarScratch, streamBackingOn ? &backingBuffer : nullptr, streamBackingFrames, streamBackingVol, rendererFrames > 0 ? &rendererBusPullScratch : nullptr, @@ -2959,7 +2712,7 @@ void AudioEngine::audioOutputCallback(const float* const* /*inputData*/, // Stream sink (producer, split clock): snapshot the full guitar mix (primary // ring + extra inputs) BEFORE backing is added. - const bool streamActive = streamSink.active.load(std::memory_order_acquire); + const bool streamActive = streamSink.isActive(); int streamBackingFrames = 0; float streamBackingVol = 0.0f; bool streamBackingOn = false; @@ -3013,7 +2766,7 @@ void AudioEngine::audioOutputCallback(const float* const* /*inputData*/, // path. When the tryLock failed (streamBackingOn=false) we pass a null backing // pointer and never touch backingBuffer, so there is nothing to protect. if (streamActive) - composeAndPushStreamMix(streamGuitarScratch, + streamSink.publish(streamGuitarScratch, streamBackingOn ? &backingBuffer : nullptr, streamBackingFrames, streamBackingVol, rendererFrames > 0 ? &rendererBusPullScratch : nullptr, diff --git a/src/audio/AudioEngine.h b/src/audio/AudioEngine.h index 81e580c..aab50a0 100644 --- a/src/audio/AudioEngine.h +++ b/src/audio/AudioEngine.h @@ -4,6 +4,7 @@ #include "engine/PackedStereoRing.h" #include "engine/EngineState.h" #include "engine/RendererBus.h" +#include "engine/StreamSink.h" #include "BackingLeveler.h" #include "signalsmith-stretch.h" #include @@ -252,18 +253,13 @@ public: // setStreamOutputDevice returns "" on success or an error string. juce::String setStreamOutputDevice(const juce::String& typeName, const juce::String& deviceName); void clearStreamOutput(); - bool isStreamOutputActive() const { return streamSink.active.load(std::memory_order_acquire); } - juce::String getStreamOutputDeviceName() const { return streamSink.desiredDeviceName; } + bool isStreamOutputActive() const { return streamSink.isActive(); } + juce::String getStreamOutputDeviceName() const { return streamSink.getDesiredDeviceName(); } // Bus content: include the backing/game, include the guitar monitor mix, and a // linear output gain. All atomic — safe to set live. Gain is sanitised // (finite, clamped 0..8) so a NaN/Inf from JS can never reach the stream ring. - void setStreamBus(bool includeBacking, bool includeGuitar, float gain) - { - streamBusIncludeBacking.store(includeBacking, std::memory_order_relaxed); - streamBusIncludeGuitar.store(includeGuitar, std::memory_order_relaxed); - streamBusGain.store(sanitizeStreamGain(gain), std::memory_order_relaxed); - } - void setStreamBusGain(float gain) { streamBusGain.store(sanitizeStreamGain(gain), std::memory_order_relaxed); } + void setStreamBus(bool includeBacking, bool includeGuitar, float gain) { streamSink.setBus(includeBacking, includeGuitar, gain); } + void setStreamBusGain(float gain) { streamSink.setBusGain(gain); } // ── Renderer-audio bus (Phase 2: WebAudio master → engine output) ───────── // The renderer pushes its WebAudio master mix here (via IPC) so song/stem @@ -285,11 +281,11 @@ public: }; RendererBusMetrics getRendererBusMetrics() const; - float getStreamSinkLevel() const { return streamSinkLevel.load(std::memory_order_relaxed); } - uint64_t getStreamUnderflowCount() const { return streamSink.underflowCount.load(std::memory_order_relaxed); } + float getStreamSinkLevel() const { return streamSink.getLevel(); } + uint64_t getStreamUnderflowCount() const { return streamSink.getUnderflowCount(); } // Producer overflow (drop-oldest): the consumer fell a full ring behind and // frames were skipped. Exposed alongside underflow for stream drift diagnosis. - uint64_t getStreamOverflowCount() const { return streamSink.overflowCount.load(std::memory_order_relaxed); } + uint64_t getStreamOverflowCount() const { return streamSink.getOverflowCount(); } // Latency double getLatencyMs() const; @@ -658,84 +654,16 @@ private: juce::AudioBuffer& mixBuf, juce::AudioBuffer& monitorScratch, int effectiveOutputChannels, int numSamples); - // ── 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; - slopsmith::PackedStereoRing ring; - std::atomic underflowCount{0}; - std::atomic overflowCount{0}; - std::atomic active{false}; - std::atomic sampleRate{48000.0}; - std::atomic blockSize{256}; - std::vector pullScratchL, pullScratchR; // sized in streamSinkAboutToStart - bool callbackRegistered = false; - bool initialised = false; - // Declared LAST so it DESTRUCTS FIRST (members tear down in reverse - // declaration order): the manager's dtor closes the device and detaches - // `callback` while `callback`/`ring` are still alive — no use-after-free - // even if an explicit teardown path is ever missed. stopAudio() / - // closeStreamSinkDevice() also tear it down explicitly before this. - juce::AudioDeviceManager manager; - // Persistent INTENT (control thread only): the device the user chose. - // Survives a stop/restart so reopenDesiredStreamSink() can re-open it. - juce::String desiredTypeName; - juce::String desiredDeviceName; - }; - StreamSink streamSink; - std::atomic streamBusIncludeBacking{true}; - std::atomic streamBusIncludeGuitar{true}; - std::atomic streamBusGain{1.0f}; - std::atomic streamSinkLevel{0.0f}; - // Producer-side scratch (written by the primary/output callback): the guitar - // monitor-mix snapshot (pre-backing) and the composed stream submix. Sized in + // ── Streamer mix output sink — moved to engine/StreamSink.{h,cpp} (TLC + // phase 2). Declared after `state` (bound by reference). + slopsmith::StreamSink streamSink{state}; + // Producer-side guitar monitor-mix snapshot (pre-backing), written by the + // primary/output callback and handed to streamSink.publish(). Sized in // audioDeviceAboutToStart / audioOutputAboutToStart alongside the other scratch. juce::AudioBuffer streamGuitarScratch; - juce::AudioBuffer streamMixScratch; // Clamp a requested stream gain to a finite, sane range so a NaN/Inf (or a // wild value) from the JS bridge can never be packed into the stream ring. static float sanitizeStreamGain(float g) { return 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& guitarMix, - const juce::AudioBuffer* backingBuf, - int backingFrames, float backingVol, - const juce::AudioBuffer* rendererBuf, - int rendererFrames, int numSamples); - JUCE_DECLARE_NON_COPYABLE_WITH_LEAK_DETECTOR(AudioEngine) }; diff --git a/src/audio/CMakeLists.txt b/src/audio/CMakeLists.txt index b4870d6..7e6175e 100644 --- a/src/audio/CMakeLists.txt +++ b/src/audio/CMakeLists.txt @@ -6,6 +6,7 @@ set(AUDIO_SOURCES NoiseGate.cpp TonePolish.cpp AudioEngine.cpp + engine/StreamSink.cpp SourceChain.cpp SignalChain.cpp VSTHost.cpp diff --git a/src/audio/engine/StreamSink.cpp b/src/audio/engine/StreamSink.cpp new file mode 100644 index 0000000..a052b71 --- /dev/null +++ b/src/audio/engine/StreamSink.cpp @@ -0,0 +1,263 @@ +// StreamSink implementation — moved verbatim from AudioEngine.cpp (TLC plan +// phase 2 / §2.5); member names lose their streamSink./streamBus prefixes, +// logic is unchanged. See StreamSink.h for the design rationale. + +#include "StreamSink.h" + +#include +#include + +namespace slopsmith { + +void StreamSink::publish(const juce::AudioBuffer& guitarMix, + const juce::AudioBuffer* backingBuf, int backingFrames, float backingVol, + const juce::AudioBuffer* rendererBuf, int rendererFrames, + int numSamples) +{ + if (! active.load(std::memory_order_acquire)) return; + // A block larger than the entire ring can't be published atomically (it would + // wrap and overwrite unread slots before writeIndex is bumped). Skip it and + // count an overflow. Checked FIRST (before the scratch guard) so an oversized + // block is always counted — the fixed-size scratch is exactly the ring, so an + // oversized block also trips the scratch guard below and would otherwise be + // dropped silently. The split path already rejects oversized devices at setup; + // this guards the duplex path, whose block size we don't pre-validate. + if (numSamples > kRingFrames) + { + overflowCount.fetch_add(1, std::memory_order_relaxed); + return; + } + // Scratch not yet sized to the full ring (cold start before the producer's + // about-to-start ran, or a transient reconfig) — skip rather than alloc on RT. + // After warm-up the scratch is exactly the ring, so for an in-range block this + // never trips. + if (mixScratch.getNumSamples() < numSamples) return; + + const bool ig = busIncludeGuitar.load(std::memory_order_relaxed); + const bool ib = busIncludeBacking.load(std::memory_order_relaxed); + const float gain = busGain.load(std::memory_order_relaxed); + + mixScratch.clear(0, 0, numSamples); + mixScratch.clear(1, 0, numSamples); + if (ig) + for (int ch = 0; ch < 2; ++ch) + mixScratch.addFrom(ch, 0, guitarMix, + juce::jmin(ch, guitarMix.getNumChannels() - 1), 0, numSamples); + if (ib && backingBuf != nullptr && backingFrames > 0) + { + const int n = juce::jmin(backingFrames, numSamples); + for (int ch = 0; ch < 2; ++ch) + mixScratch.addFrom(ch, 0, *backingBuf, + juce::jmin(ch, backingBuf->getNumChannels() - 1), 0, n, backingVol); + } + // Renderer-fed song audio (stems / element / loopback riding the renderer + // bus) is song audio for the streamer too — without this the stream mix + // carries guitar only whenever the song bypasses the native transport + // (multi-stem under exclusive/ASIO output). Bus gain is already applied by + // pullRendererBus; only the stream gain below shapes it further. Backing + // transport and renderer bus are mutually exclusive song paths in + // practice, so this never double-carries. + if (ib && rendererBuf != nullptr && rendererFrames > 0) + { + const int n = juce::jmin(rendererFrames, numSamples); + for (int ch = 0; ch < 2; ++ch) + mixScratch.addFrom(ch, 0, *rendererBuf, + juce::jmin(ch, rendererBuf->getNumChannels() - 1), 0, n); + } + mixScratch.applyGain(0, 0, numSamples, gain); + mixScratch.applyGain(1, 0, numSamples, gain); + + const float peak = juce::jmax(mixScratch.getMagnitude(0, 0, numSamples), + mixScratch.getMagnitude(1, 0, numSamples)); + level.store(peak, std::memory_order_relaxed); + + ring.push(mixScratch.getReadPointer(0), mixScratch.getReadPointer(1), numSamples); +} + +void StreamSink::deviceCallback(float* const* outputData, int numOutputChannels, int numSamples) +{ + const juce::ScopedNoDenormals noDenormals; + if (numOutputChannels <= 0) return; + juce::AudioBuffer buffer(outputData, numOutputChannels, numSamples); + buffer.clear(); + + const int scratchCap = (int) pullScratchL.size(); + const int outSamples = juce::jmin(numSamples, scratchCap); + + uint64_t r = ring.readIndex.load(std::memory_order_relaxed); + const uint64_t w = ring.writeIndex.load(std::memory_order_acquire); + ring.resyncIfIndicesReset(r, w); + if (ring.catchUpIfLapped(r, w)) + overflowCount.fetch_add(1, std::memory_order_relaxed); + const uint64_t available = w - r; + const int pullCount = juce::jmin(outSamples, (int) available); + const int consumeCount = juce::jmin(numSamples, (int) available); + const int copyChannels = juce::jmin(numOutputChannels, 2); + for (int i = 0; i < pullCount; ++i) + { + float l, rr; + ring.readFrame(r + (uint64_t) i, l, rr); + buffer.setSample(0, i, l); + if (copyChannels > 1) buffer.setSample(1, i, rr); + } + if (pullCount < outSamples) + underflowCount.fetch_add(1, std::memory_order_relaxed); + ring.commitRead(r + (uint64_t) consumeCount); +} + +void StreamSink::deviceAboutToStart(juce::AudioIODevice* device) +{ + if (device == nullptr) return; + const int bs = device->getCurrentBufferSizeSamples(); + double sr = device->getCurrentSampleRate(); + if (sr <= 0.0) sr = state.currentSampleRate.load(std::memory_order_relaxed); + sinkBlockSize.store(bs, std::memory_order_relaxed); + sinkSampleRate.store(sr, std::memory_order_relaxed); + const int cap = juce::jmax(bs, 2048); + if ((int) pullScratchL.size() < cap) pullScratchL.assign((size_t) cap, 0.0f); + if ((int) pullScratchR.size() < cap) pullScratchR.assign((size_t) cap, 0.0f); + ring.reset(); + underflowCount.store(0, std::memory_order_relaxed); + overflowCount.store(0, std::memory_order_relaxed); +} + +void StreamSink::deviceStopped() +{ + // Fires on any stop of the stream device — an unplanned loss (unplug / driver + // reset) as well as our own teardown. Mark inactive so the producer stops + // filling a now-consumer-less ring and the meter clears; desiredTypeName/Name + // are deliberately left intact so reopenDesired() can restore it. + active.store(false, std::memory_order_release); + level.store(0.0f, std::memory_order_relaxed); +} + +juce::String StreamSink::open(const juce::String& typeName, const juce::String& deviceName) +{ + // Control-thread only. Opens an OUTPUT-only device on the stream sink's own + // AudioDeviceManager and attaches the drain callback. Mirrors applySplitSetup's + // output open. v1 requires the sink's nominal SR to match the engine rate (no + // async resampler yet — that's PR3); a mismatch is rejected with a clear error. + desiredTypeName = typeName; + desiredDeviceName = deviceName; + + // Stop the producer from writing the ring while we (re)configure the device: + // setAudioDeviceSetup() below drives deviceAboutToStart(), which resets the + // ring indices and slots. Clearing `active` first stops the main callback from + // STARTING new pushes. A producer block already past the active-check can still + // finish one push, but the device close/reopen takes far longer than a single + // audio block, so that in-flight push completes well before about-to-start runs. + // Worst case is therefore one imperfect block on the STREAM bus (never the local + // monitor) during a manual device switch — atomic, no data race, no UAF. We only + // re-arm `active` after a fully clean open. + active.store(false, std::memory_order_release); + + // Any failure below: detach/close the half-open device AND drop the desired + // intent. A deterministic failure (e.g. SR mismatch) is then NOT retried on every + // startAudio(), and the engine never reports active with no device behind it. The + // renderer keeps its own persisted choice and re-applies it, so nothing is lost. + auto fail = [this](const juce::String& msg) -> juce::String { + close(); + desiredTypeName = {}; + desiredDeviceName = {}; + return msg; + }; + + if (! initialised) + { + manager.initialise(0, 2, nullptr, false); + initialised = true; + } + + juce::AudioIODeviceType* outType = nullptr; + for (auto* t : manager.getAvailableDeviceTypes()) + if (t->getTypeName() == typeName) { outType = t; break; } + if (! outType) return fail("Stream output device type not found: " + typeName); + + try { + if (auto* cur = manager.getCurrentDeviceTypeObject()) + { + if (cur->getTypeName() != typeName) + manager.setCurrentAudioDeviceType(typeName, true); + } + else manager.setCurrentAudioDeviceType(typeName, true); + } catch (...) { return fail("setCurrentAudioDeviceType threw for stream output type '" + typeName + "'"); } + + juce::String resolved = deviceName; + if (resolved.isEmpty()) + { + auto names = outType->getDeviceNames(false); + if (names.size() > 0) resolved = names[0]; + } + + juce::AudioDeviceManager::AudioDeviceSetup setup; + setup.inputDeviceName = ""; + setup.outputDeviceName = resolved; + setup.sampleRate = state.currentSampleRate.load(std::memory_order_relaxed); + setup.bufferSize = state.outputBlockSize.load(std::memory_order_relaxed); + setup.useDefaultInputChannels = false; + setup.useDefaultOutputChannels = false; + setup.inputChannels.clear(); + setup.outputChannels.setRange(0, 2, true); + + juce::String err; + try { err = manager.setAudioDeviceSetup(setup, true); } + catch (...) { return fail("stream output setAudioDeviceSetup threw"); } + if (err.isNotEmpty()) return fail("stream output setup: " + err); + + auto* dev = manager.getCurrentAudioDevice(); + if (! dev) return fail("no stream output device after setup"); + const double devSr = dev->getCurrentSampleRate(); + const double engineSr = state.currentSampleRate.load(std::memory_order_relaxed); + if (engineSr > 0.0 && std::abs(devSr - engineSr) > 0.5) + return fail("Stream output sample rate (" + juce::String(devSr) + + ") must match the engine rate (" + juce::String(engineSr) + + "). Pick a device that supports " + juce::String(engineSr) + " Hz."); + + if (! callbackRegistered) + { + manager.addAudioCallback(&callback); + callbackRegistered = true; + } + active.store(true, std::memory_order_release); + fprintf(stderr, "[AudioEngine] stream output active: %s (%s)\n", + resolved.toRawUTF8(), typeName.toRawUTF8()); + return {}; +} + +void StreamSink::close() +{ + // Detach the drain callback and close the device, leaving desiredTypeName/Name + // intact so startAudio()/reopenDesired() can restore it. active=false + // first so the producer stops pushing; removeAudioCallback() then blocks until + // the consumer callback is no longer in flight, so the ring/device go quiescent + // before close. Idempotent — safe when nothing is open. + active.store(false, std::memory_order_release); + if (callbackRegistered) + { + manager.removeAudioCallback(&callback); + callbackRegistered = false; + } + try { manager.closeAudioDevice(); } catch (...) {} + level.store(0.0f, std::memory_order_relaxed); +} + +void StreamSink::clear() +{ + close(); + desiredTypeName = {}; + desiredDeviceName = {}; +} + +void StreamSink::reopenDesired() +{ + // Copy the intent first: open() mutates desiredTypeName/Name (and clears + // them on failure), so don't pass the members in by reference. + const juce::String t = desiredTypeName; + const juce::String d = desiredDeviceName; + if (d.isEmpty() && t.isEmpty()) return; + const juce::String err = open(t, d); + if (err.isNotEmpty()) + fprintf(stderr, "[AudioEngine] reopenDesiredStreamSink failed: %s\n", err.toRawUTF8()); +} + +} // namespace slopsmith diff --git a/src/audio/engine/StreamSink.h b/src/audio/engine/StreamSink.h new file mode 100644 index 0000000..3d7f03a --- /dev/null +++ b/src/audio/engine/StreamSink.h @@ -0,0 +1,142 @@ +#pragma once + +// StreamSink — the streamer-mix output sink (TLC plan phase 2 / §2.5, was +// "PR1" inside AudioEngine). A second OUTPUT AudioDeviceManager on its OWN +// clock that drains a dedicated SPSC ring fed by the main output path's +// composed stream submix (publish()). Mirrors the InputDeviceSlot pattern +// INVERTED to the output side: the PRODUCER is the primary/output callback, +// the CONSUMER is this extra output device's callback. Default off → no +// behaviour change. +// +// Moved verbatim from AudioEngine; the engine keeps thin facades +// (setStreamOutputDevice / clearStreamOutput / setStreamBus / metrics +// getters) so the NodeAddon surface is unchanged. Engine sample rate / output +// block size are read through the EngineState& bound at construction. + +#include "PackedStereoRing.h" +#include "EngineState.h" +#include "../GainSanitize.h" + +#include + +#include +#include +#include + +namespace slopsmith { + +class StreamSink +{ +public: + // Must match the main engine ring capacity: publish() rejects blocks + // larger than one ring (they can't be published atomically). + static constexpr int kRingFrames = 4096; + + explicit StreamSink(EngineState& engineState) : state(engineState) + { + callback.sink = this; + } + + // ── Control thread ──────────────────────────────────────────────────── + // Open an OUTPUT-only device and attach the drain callback. Empty error + // string = success. v1 requires the sink's nominal SR to match the engine + // rate (no async resampler yet); a mismatch is rejected with a clear error. + juce::String open(const juce::String& typeName, const juce::String& deviceName); + // Detach + close but KEEP desiredTypeName/Name so reopenDesired() can + // restore it after a stop/restart (intent survives). Idempotent. + void close(); + // close() + drop the desired intent (a user "no stream output"). + void clear(); + void reopenDesired(); + + // Size the producer-side scratches to the FIXED ring capacity — call from + // the engine's about-to-start hooks (device-management thread), never RT. + // Fixed cap so a hotplug about-to-start can never realloc under a live + // producer on the other clock; allocates exactly once. + void prepareProducerScratch() + { + if (mixScratch.getNumSamples() < kRingFrames) + mixScratch.setSize(2, kRingFrames, false, false, true); + } + + // Bus content: include the backing/game, include the guitar monitor mix, + // and a linear output gain. All atomic — safe to set live. Gain sanitised + // (finite, 0..8) so a NaN/Inf from JS can never reach the stream ring. + void setBus(bool includeBacking, bool includeGuitar, float gain) + { + busIncludeBacking.store(includeBacking, std::memory_order_relaxed); + busIncludeGuitar.store(includeGuitar, std::memory_order_relaxed); + busGain.store(sanitizeStreamGain(gain), std::memory_order_relaxed); + } + void setBusGain(float gain) { busGain.store(sanitizeStreamGain(gain), std::memory_order_relaxed); } + + bool isActive() const { return active.load(std::memory_order_acquire); } + juce::String getDesiredDeviceName() const { return desiredDeviceName; } + float getLevel() const { return level.load(std::memory_order_relaxed); } + uint64_t getUnderflowCount() const { return underflowCount.load(std::memory_order_relaxed); } + uint64_t getOverflowCount() const { return overflowCount.load(std::memory_order_relaxed); } + + // ── Producer (primary/output callback, RT) ──────────────────────────── + // Compose the stream submix (guitar/backing/renderer × include flags × + // gain) into the fixed scratch and pack it into the ring. + void publish(const juce::AudioBuffer& guitarMix, + const juce::AudioBuffer* backingBuf, int backingFrames, float backingVol, + const juce::AudioBuffer* rendererBuf, int rendererFrames, + int numSamples); + +private: + // Forwards the sink device's callbacks (the sink's own clock). + struct Callback : juce::AudioIODeviceCallback + { + StreamSink* sink = nullptr; + void audioDeviceIOCallbackWithContext(const float* const* inputData, int numInputChannels, + float* const* outputData, int numOutputChannels, + int numSamples, + const juce::AudioIODeviceCallbackContext&) override + { + juce::ignoreUnused(inputData, numInputChannels); + if (sink) sink->deviceCallback(outputData, numOutputChannels, numSamples); + } + void audioDeviceAboutToStart(juce::AudioIODevice* d) override { if (sink) sink->deviceAboutToStart(d); } + void audioDeviceStopped() override { if (sink) sink->deviceStopped(); } + }; + + // Consumer side (the sink device's thread). + void deviceCallback(float* const* outputData, int numOutputChannels, int numSamples); + void deviceAboutToStart(juce::AudioIODevice* device); + void deviceStopped(); + + EngineState& state; + + Callback callback; + PackedStereoRing ring; + std::atomic underflowCount{0}; + std::atomic overflowCount{0}; + std::atomic active{false}; + std::atomic sinkSampleRate{48000.0}; + std::atomic sinkBlockSize{256}; + std::vector pullScratchL, pullScratchR; // sized in deviceAboutToStart + bool callbackRegistered = false; + bool initialised = false; + + std::atomic busIncludeBacking{true}; + std::atomic busIncludeGuitar{true}; + std::atomic busGain{1.0f}; + std::atomic level{0.0f}; + // Producer-side composed submix scratch — fixed ring capacity, see + // prepareProducerScratch(). + juce::AudioBuffer mixScratch; + + // Declared LAST so it DESTRUCTS FIRST (members tear down in reverse + // declaration order): the manager's dtor closes the device and detaches + // `callback` while `callback`/`ring` are still alive — no use-after-free + // even if an explicit teardown path is ever missed. close() also tears it + // down explicitly before this. + juce::AudioDeviceManager manager; + // Persistent INTENT (control thread only): the device the user chose. + // Survives a stop/restart so reopenDesired() can re-open it. + juce::String desiredTypeName; + juce::String desiredDeviceName; +}; + +} // namespace slopsmith From d8f5784c635fc9a4e7fb1e3ebbd38fb20ccb07c6 Mon Sep 17 00:00:00 2001 From: OmikronApex Date: Tue, 14 Jul 2026 00:19:53 +0200 Subject: [PATCH 08/28] refactor(audio): extract BackingPlayer (phase 3) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Moves the backing-track cluster — AudioFormatManager/reader/transport, TimeSliceThread read-ahead, signalsmith-stretch state, lock-free speed hand-off, BackingLeveler, playhead caches, and renderBackingBlockLocked — verbatim into src/audio/engine/BackingPlayer.{h,cpp}. Boundary per the plan (§2.4): control-thread lifecycle + non-blocking getters live on the class; the RT mix POLICY (try-lock pattern, RMS metering, volume fader, stream-submix capture) stays in the engine's output callbacks via getLock()/readyLocked()/renderBlockLocked()/renderBuffer() — both callbacks keep holding the try-lock through their stream publish, so the render buffer is never read while prepare() can resize it. The volume fader atomic and level meter stay engine-side. Synthetic-reader unit tests deferred (JUCE-linked, same constraint as StreamSink); covered by the backing play/seek/speed integration surface. Co-Authored-By: Claude Fable 5 --- src/audio/AudioEngine.cpp | 364 ++--------------------------- src/audio/AudioEngine.h | 81 ++----- src/audio/CMakeLists.txt | 1 + src/audio/engine/BackingPlayer.cpp | 325 ++++++++++++++++++++++++++ src/audio/engine/BackingPlayer.h | 121 ++++++++++ 5 files changed, 486 insertions(+), 406 deletions(-) create mode 100644 src/audio/engine/BackingPlayer.cpp create mode 100644 src/audio/engine/BackingPlayer.h diff --git a/src/audio/AudioEngine.cpp b/src/audio/AudioEngine.cpp index e79c83c..2069e79 100644 --- a/src/audio/AudioEngine.cpp +++ b/src/audio/AudioEngine.cpp @@ -20,9 +20,7 @@ inline bool firstN(std::atomic& c) { return c.fetch_add(1, std::memory } // Hard ceiling on backing playback speed. This drives input buffer sizing and runtime clamp. -static constexpr double kMaxBackingSpeed = 4.0; // Transparent full-speed path — skip the stretcher when rate is effectively 1×. -static constexpr double kBackingSpeedBypassEpsilon = 1.0e-4; // On Windows, ASIO drivers can crash with access violations. // We catch C++ exceptions but can't easily catch SEH in functions with dtors. @@ -31,12 +29,10 @@ static constexpr double kBackingSpeedBypassEpsilon = 1.0e-4; AudioEngine::AudioEngine() { - formatManager.registerBasicFormats(); // Start the backing read-ahead worker so the transport's BufferingAudioSource // always has a live thread to pull decoded audio on. It sleeps while idle and // costs nothing until a track is loaded. - backingReadThread.startThread(); // Construct the full source pool up front so addSource/removeSource never // reassign a pointer the audio thread reads — they only flip `active`. Each @@ -1284,187 +1280,6 @@ void AudioEngine::stopAudio() // ── Backing Track ───────────────────────────────────────────────────────────── -bool AudioEngine::loadBackingTrack(const juce::File& file) -{ - const juce::ScopedLock sl(backingLock); - stopBackingNoLock(); - backingTransport.reset(); - backingSource.reset(); - - const bool exists = file.existsAsFile(); - std::cerr << "[AudioEngine] loadBackingTrack path=" - << file.getFullPathName().toStdString() - << " exists=" << exists - << " size=" << (exists ? (long long)file.getSize() : -1) - << std::endl; - - auto* reader = formatManager.createReaderFor(file); - if (!reader) - { - std::cerr << "[AudioEngine] loadBackingTrack: no reader for ext='" - << file.getFileExtension().toStdString() - << "' (registered formats=" << formatManager.getNumKnownFormats() - << ")" << std::endl; - // Transport/source already reset above; clear cached state so the renderer - // doesn't keep displaying the previous track's position/duration. - cachedBackingPosition.store(0.0); - cachedBackingDuration.store(0.0); - return false; - } - - const double readerSampleRate = reader->sampleRate; - const juce::int64 readerLengthInSamples = reader->lengthInSamples; - const double sr = currentSampleRate.load(std::memory_order_relaxed); - // Backing audio plays through the output device in both modes, so size - // against outputBlockSize. In duplex mode outputBlockSize == inputBlockSize; - // in split mode the output device's clock drives the backing pull. - const int bs = outputBlockSize.load(std::memory_order_relaxed); - - backingSource = std::make_unique(reader, true); - backingTransport = std::make_unique(); - // Read-ahead on backingReadThread so the RT audio thread normally never - // touches the disk or the format codec. Previously this passed - // (…, 0, nullptr, …): with no read-ahead buffer the transport decoded the - // file synchronously inside getNextAudioBlock ON the audio callback, so any - // disk seek / decode spike (worst for compressed formats) blew the block - // budget → underruns heard as glitches or brief mutes while a song plays. - // 32768 source frames ≈ 0.68 s @ 48k of look-ahead absorbs those spikes. - // - // Known residual (accepted): juce::BufferingAudioSource is not fully - // RT-safe — readBufferSection() holds callbackLock across the decode of one - // refill chunk, and the callback's getNextAudioBlock() takes the same lock, - // so the RT thread can still block behind an in-flight chunk decode. The - // window is bounded (JUCE caps chunks at 2048 source frames) and only hit - // when a refill is mid-decode, vs. the old guaranteed full decode on every - // block; a truly lock-free ring would mean replacing the JUCE transport - // stack and isn't worth it here. - // The 4th arg makes AudioTransportSource SRC the file to device rate. - // Stretch always sees device-rate audio so that its presetDefault parameters match. - constexpr int kBackingReadAheadSamples = 32768; - backingTransport->setSource(backingSource.get(), kBackingReadAheadSamples, - &backingReadThread, readerSampleRate); - - // Loading a backing track before the audio device has started leaves - // sr/bs at zero. presetDefault(2, 0.0f) would seed the stretcher with - // undefined internal timing, and prepareToPlay(0, 0) is similarly - // ill-defined. Defer the stretcher + buffer setup; the relevant - // audio*AboutToStart() re-runs the same block once a real sample - // rate / block size are known (audioDeviceAboutToStart for duplex, - // audioOutputAboutToStart for split). - if (sr > 0.0 && bs > 0) - { - // prepareToPlay's first arg is an upper bound on subsequent - // getNextAudioBlock requests, per the juce::AudioSource contract. - // The RT callback can pull ceil(bs * kMaxBackingSpeed) frames in a - // single block when the speed is above 1×, so prepare for that - // worst case — preparing with just `bs` would risk JUCE internal - // buffer overruns/asserts on the first faster-than-1× block. - const int maxInputFrames = (int) std::ceil(bs * kMaxBackingSpeed) + 64; - backingTransport->prepareToPlay(maxInputFrames, sr); - - backingStretch.presetDefault(2, (float) sr); - backingStretch.reset(); - backingStretchLatencySamples.store(backingStretch.outputLatency(), std::memory_order_relaxed); - - backingInputBuffer.setSize(2, maxInputFrames, false, false, true); - backingBuffer.setSize(2, bs, false, false, true); - } - - cachedBackingDuration.store(backingTransport->getLengthInSeconds()); - cachedBackingPosition.store(0.0); - backingHeardPositionSec.store(0.0, std::memory_order_relaxed); - - // Reset the loudness leveler for the new song: clearing the cached sample - // rate forces renderBackingBlockLocked() to re-prepare() it on the next - // block, dropping the previous track's AGC gain + limiter state. Otherwise - // the ~300 ms gain follower would carry over and briefly mis-level the start - // of a much louder/quieter next song. Safe here — loadBackingTrack holds - // backingLock, the same lock the render path runs under. - backingLevelerSr = 0.0; - std::cerr << "[AudioEngine] loadBackingTrack OK sr=" << readerSampleRate - << " len=" << readerLengthInSamples - << std::endl; - return true; -} - -void AudioEngine::setBackingPosition(double seconds) -{ - const juce::ScopedLock sl(backingLock); - if (backingTransport) - { - backingTransport->setPosition(seconds); - backingStretch.reset(); - // Read back the actual position; the transport may clamp (e.g. negative or past EOF). - const double pos = backingTransport->getCurrentPosition(); - cachedBackingPosition.store(pos); - backingHeardPositionSec.store(pos, std::memory_order_relaxed); - } -} - -void AudioEngine::startBacking() -{ - const juce::ScopedLock sl(backingLock); - if (backingTransport) - { - backingTransport->start(); - backingPlaying.store(true); - backingHeardPositionSec.store(backingTransport->getCurrentPosition(), - std::memory_order_relaxed); - } -} - -void AudioEngine::stopBackingNoLock() -{ - if (backingTransport) - { - backingTransport->stop(); - backingStretch.reset(); - backingPlaying.store(false); - } - currentBackingLevel.store(0.0f); -} - -void AudioEngine::stopBacking() -{ - const juce::ScopedLock sl(backingLock); - stopBackingNoLock(); -} - -void AudioEngine::setBackingSpeed(double speed) -{ - if (!std::isfinite(speed) || speed <= 0.0) - { - return; - } - - const double clamped = juce::jlimit(0.01, kMaxBackingSpeed, speed); - // Dead-zone against the last *requested* rate to coalesce rapid slider - // ticks — but never skip a change that crosses the 1× bypass boundary, or a - // request just shy of 1× (e.g. 0.9995 -> 1.0, diff < 0.001) would leave the - // stretcher path engaged when the caller actually asked for transparent - // full speed. - const double prev = backingPendingSpeed.load(std::memory_order_relaxed); - const bool prevBypass = std::abs(prev - 1.0) < kBackingSpeedBypassEpsilon; - const bool newBypass = std::abs(clamped - 1.0) < kBackingSpeedBypassEpsilon; - if (std::abs(clamped - prev) < 0.001 && prevBypass == newBypass) - { - return; - } - - // Lock-free hand-off to the audio thread. Publish the requested rate, then - // raise the pending flag with release so the RT thread is guaranteed to see - // the new rate once it observes the flag. renderBackingBlockLocked() adopts - // the rate and resets the stretcher together, on the audio thread, so: - // * a control-thread caller (e.g. a speed slider at 30-60 Hz) never takes - // backingLock and so never starves the RT tryLock into dropping a block; - // * the new rate is never processed with stale stretch state — the reset - // and the rate adoption happen in the same RT block (see PR #237). - // Multiple updates before the RT consumes them coalesce (latest wins), which - // naturally throttles stretcher resets during a drag. - backingPendingSpeed.store(clamped, std::memory_order_relaxed); - backingSpeedChangePending.store(true, std::memory_order_release); -} - void AudioEngine::resetPeaks() { // Input peak is per-source — clear EVERY active source (getSourceLevels() exposes @@ -1666,122 +1481,6 @@ std::vector AudioEngine::listSources() const return out; } -int AudioEngine::renderBackingBlockLocked(int numSamples) -{ - // Adopt any speed change requested since the last block (set lock-free by - // setBackingSpeed). Common (no-change) path is a plain acquire load — no - // locked RMW, so the flag's cache line stays shared and isn't bounced to - // this core every callback. Only the rare block that actually consumes a - // change does the exchange (clearing the flag atomically so a concurrent - // setBackingSpeed can't lose an update). The acquire pairs with the - // release-store in setBackingSpeed so the new rate is visible here. Reset - // the stretcher and re-anchor the heard position in the SAME block we adopt - // the rate, so a block is never processed at the new rate with stale stretch - // state. reset() only clears state (no allocation), so it's audio-thread safe. - if (backingSpeedChangePending.load(std::memory_order_acquire)) - { - backingSpeedChangePending.exchange(false, std::memory_order_acquire); - backingSpeed.store(juce::jlimit(0.01, kMaxBackingSpeed, - backingPendingSpeed.load(std::memory_order_relaxed)), - std::memory_order_relaxed); - backingStretch.reset(); - backingHeardPositionSec.store(backingTransport->getCurrentPosition(), - std::memory_order_relaxed); - } - - const double rate = juce::jlimit(0.01, kMaxBackingSpeed, backingSpeed.load(std::memory_order_relaxed)); - - // Defensive clamp: the buffers are sized in audioDeviceAboutToStart() / - // audioOutputAboutToStart() from the device's nominal block size, but a - // callback can deliver a larger numSamples on a device-reconfig race. Drop - // the excess frames silently rather than reading/writing past the allocated - // span; the next callback after reconfig arrives at the new nominal size. - const int outCap = backingBuffer.getNumSamples(); - const int inCap = backingInputBuffer.getNumSamples(); - const int outSamples = juce::jmin(numSamples, outCap); - const double sr = currentSampleRate.load(std::memory_order_relaxed); - const bool bypassStretch = std::abs(rate - 1.0) < kBackingSpeedBypassEpsilon; - - int sourceFramesPulled = 0; - - if (bypassStretch) - { - // 1× — direct transport read, no phase-vocoder path. (The transport - // still sample-rate-converts the file to the device rate, so this is - // "no time-stretch", not necessarily bit-perfect.) - backingBuffer.clear(0, outSamples); - juce::AudioSourceChannelInfo info(&backingBuffer, 0, outSamples); - backingTransport->getNextAudioBlock(info); - sourceFramesPulled = outSamples; - } - else - { - // Slow/fast path — pull only the source frames needed for this output - // block (output * rate), then stretch in-process to fill outSamples. - const int inputFrames = juce::jmin((int) std::ceil(outSamples * rate), inCap); - - backingInputBuffer.clear(0, inputFrames); - juce::AudioSourceChannelInfo info(&backingInputBuffer, 0, inputFrames); - backingTransport->getNextAudioBlock(info); - sourceFramesPulled = inputFrames; - - backingBuffer.clear(0, outSamples); - - const float* const* inPtrs = backingInputBuffer.getArrayOfReadPointers(); - float* const* outPtrs = backingBuffer.getArrayOfWritePointers(); - backingStretch.process(inPtrs, inputFrames, outPtrs, outSamples); - } - - const double transportPos = backingTransport->getCurrentPosition(); - if (sr > 0.0 && sourceFramesPulled > 0) - { - // Accumulate the heard (source) position, but clamp to the transport's - // actual position. sourceFramesPulled is the requested block size; a - // short read (e.g. at EOF, where the transport returns fewer real frames - // and zero-pads) would otherwise advance the playhead past the true - // source point and report progress beyond the track duration before - // backingPlaying flips false. getCurrentPosition() stays clamped to the - // real source position. - double heard = backingHeardPositionSec.load(std::memory_order_relaxed) - + static_cast(sourceFramesPulled) / sr; - heard = juce::jmin(heard, transportPos); - backingHeardPositionSec.store(heard, std::memory_order_relaxed); - - // Bypass reads straight from the transport — no phase-vocoder output - // latency to compensate for. Only the stretch path adds latency. - const double latencyInputSec = bypassStretch - ? 0.0 - : (backingStretchLatencySamples.load(std::memory_order_relaxed) * rate) / sr; - cachedBackingPosition.store(juce::jmax(0.0, heard - latencyInputSec)); - } - else - { - // currentSampleRate is transiently 0 during device teardown/reconfig. - // We can't accumulate (no Hz to divide by), so anchor both the heard - // accumulator and the published playhead to the real transport position - // rather than leaving a stale value visible to the UI. - backingHeardPositionSec.store(transportPos, std::memory_order_relaxed); - cachedBackingPosition.store(juce::jmax(0.0, transportPos)); - } - - // Sync the flag if transport stopped at EOF. - if (!backingTransport->isPlaying()) - backingPlaying.store(false); - - // Normalize the backing track to a consistent target loudness (-12 LUFS) - // BEFORE the mixer's backing-volume fader is applied (later in the RT - // callback), so every song sits at the same level while the fader still - // attenuates it. Standard BS.1770 K-weighting (full-mix music) + a brickwall - // limiter to keep boosted peaks safe. RT-safe (no allocation). - if (outSamples > 0 && sr > 0.0) - { - if (sr != backingLevelerSr) { backingLeveler.prepare(sr); backingLevelerSr = sr; } - backingLeveler.process(backingBuffer, outSamples, -12.0f); - } - - return outSamples; -} - // setNoiseGate / setTonePolishEnabled are now inline forwarders to sources[0] // (see AudioEngine.h). @@ -1853,22 +1552,7 @@ void AudioEngine::audioDeviceAboutToStart(juce::AudioIODevice* device) // plays on, and pulls from backingTransport at the output device's // block size. if (duplexMode.load(std::memory_order_relaxed)) - { - const juce::ScopedLock sl(backingLock); - if (backingTransport) - { - // See loadBackingTrack() for why prepareToPlay uses maxInputFrames - // rather than bs: the RT callback can pull ceil(bs * kMaxBackingSpeed) - // frames in a single block at faster-than-1× speeds. - const int maxInputFrames = (int) std::ceil(bs * kMaxBackingSpeed) + 64; - backingTransport->prepareToPlay(maxInputFrames, sr); - backingStretch.presetDefault(2, (float) sr); - backingStretch.reset(); - backingStretchLatencySamples.store(backingStretch.outputLatency(), std::memory_order_relaxed); - backingInputBuffer.setSize(2, maxInputFrames, false, false, true); - backingBuffer.setSize(2, bs, false, false, true); - } - } + backing.prepare(sr, bs); } void AudioEngine::audioDeviceStopped() @@ -1957,25 +1641,9 @@ void AudioEngine::audioOutputAboutToStart(juce::AudioIODevice* device) // device-side rate change (sleep/resume, format change) would leave // currentSampleRate stuck at the input-side seed value. if (sr > 0.0) currentSampleRate.store(sr, std::memory_order_relaxed); - { - const juce::ScopedLock sl(backingLock); - if (backingTransport && sr > 0.0 && bs > 0) - { - // Mirror loadBackingTrack() / audioDeviceAboutToStart() — the - // output device drives backing playback in split mode, so this - // is where the stretcher gets sized for that side. prepareToPlay - // upper-bounds future getNextAudioBlock requests, and the - // RT callback can pull ceil(bs * kMaxBackingSpeed) at faster - // speeds. - const int maxInputFrames = (int) std::ceil(bs * kMaxBackingSpeed) + 64; - backingTransport->prepareToPlay(maxInputFrames, sr); - backingStretch.presetDefault(2, (float) sr); - backingStretch.reset(); - backingStretchLatencySamples.store(backingStretch.outputLatency(), std::memory_order_relaxed); - backingInputBuffer.setSize(2, maxInputFrames, false, false, true); - backingBuffer.setSize(2, bs, false, false, true); - } - } + // The output device drives backing playback in split mode, so this is + // where the stretcher gets sized for that side. + backing.prepare(sr, bs); } void AudioEngine::audioOutputStopped() @@ -2097,17 +1765,17 @@ void AudioEngine::audioDeviceIOCallbackWithContext( streamGuitarScratch.copyFrom(ch, 0, buffer, juce::jmin(ch, buffer.getNumChannels() - 1), 0, numSamples); - const juce::ScopedTryLock sl(backingLock); - if (sl.isLocked() && backingTransport && backingPlaying.load()) + const juce::ScopedTryLock sl(backing.getLock()); + if (sl.isLocked() && backing.readyLocked()) { - const int outSamples = renderBackingBlockLocked(numSamples); + const int outSamples = backing.renderBlockLocked(numSamples); const float bVol = backingVolume.load(); streamBackingFrames = outSamples; streamBackingVol = bVol; streamBackingOn = true; const int mixChannels = juce::jmin(numOutputChannels, 2); float backingLevelSq = 0.0f; for (int ch = 0; ch < mixChannels; ++ch) { - const float* const src = backingBuffer.getReadPointer(ch); + const float* const src = backing.renderBuffer().getReadPointer(ch); float sumSquares = 0.0f; for (int i = 0; i < outSamples; ++i) sumSquares += src[i] * src[i]; @@ -2116,7 +1784,7 @@ void AudioEngine::audioDeviceIOCallbackWithContext( ? std::sqrt(sumSquares / outSamples) * bVol : 0.0f; backingLevelSq += channelRms * channelRms; - buffer.addFrom(ch, 0, backingBuffer, ch, 0, outSamples, bVol); + buffer.addFrom(ch, 0, backing.renderBuffer(), ch, 0, outSamples, bVol); } currentBackingLevel.store((mixChannels > 0) ? std::sqrt(backingLevelSq / mixChannels) @@ -2137,7 +1805,7 @@ void AudioEngine::audioDeviceIOCallbackWithContext( // gain, so the stream level is independent. if (streamActive) streamSink.publish(streamGuitarScratch, - streamBackingOn ? &backingBuffer : nullptr, + streamBackingOn ? &backing.renderBuffer() : nullptr, streamBackingFrames, streamBackingVol, rendererFrames > 0 ? &rendererBusPullScratch : nullptr, rendererFrames, numSamples); @@ -2722,11 +2390,11 @@ void AudioEngine::audioOutputCallback(const float* const* /*inputData*/, juce::jmin(ch, buffer.getNumChannels() - 1), 0, numSamples); { - const juce::ScopedTryLock sl(backingLock); - if (sl.isLocked() && backingTransport && backingPlaying.load()) + const juce::ScopedTryLock sl(backing.getLock()); + if (sl.isLocked() && backing.readyLocked()) { // Shared with the duplex path so the two callbacks can't drift. - const int backingOut = renderBackingBlockLocked(numSamples); + const int backingOut = backing.renderBlockLocked(numSamples); const float bVol = backingVolume.load(); streamBackingFrames = backingOut; streamBackingVol = bVol; streamBackingOn = true; // RMS, computed identically to the duplex path so getBackingLevel() @@ -2735,7 +2403,7 @@ void AudioEngine::audioOutputCallback(const float* const* /*inputData*/, float backingLevelSq = 0.0f; for (int ch = 0; ch < copyChannels; ++ch) { - const float* const src = backingBuffer.getReadPointer(ch); + const float* const src = backing.renderBuffer().getReadPointer(ch); float sumSquares = 0.0f; for (int i = 0; i < backingOut; ++i) sumSquares += src[i] * src[i]; @@ -2744,7 +2412,7 @@ void AudioEngine::audioOutputCallback(const float* const* /*inputData*/, ? std::sqrt(sumSquares / backingOut) * bVol : 0.0f; backingLevelSq += channelRms * channelRms; - buffer.addFrom(ch, 0, backingBuffer, ch, 0, backingOut, bVol); + buffer.addFrom(ch, 0, backing.renderBuffer(), ch, 0, backingOut, bVol); } currentBackingLevel.store((copyChannels > 0) ? std::sqrt(backingLevelSq / copyChannels) @@ -2767,7 +2435,7 @@ void AudioEngine::audioOutputCallback(const float* const* /*inputData*/, // pointer and never touch backingBuffer, so there is nothing to protect. if (streamActive) streamSink.publish(streamGuitarScratch, - streamBackingOn ? &backingBuffer : nullptr, + streamBackingOn ? &backing.renderBuffer() : nullptr, streamBackingFrames, streamBackingVol, rendererFrames > 0 ? &rendererBusPullScratch : nullptr, rendererFrames, numSamples); diff --git a/src/audio/AudioEngine.h b/src/audio/AudioEngine.h index aab50a0..f74d05b 100644 --- a/src/audio/AudioEngine.h +++ b/src/audio/AudioEngine.h @@ -5,6 +5,7 @@ #include "engine/EngineState.h" #include "engine/RendererBus.h" #include "engine/StreamSink.h" +#include "engine/BackingPlayer.h" #include "BackingLeveler.h" #include "signalsmith-stretch.h" #include @@ -223,17 +224,26 @@ public: // renderer exposes a per-preset toggle. void setTonePolishEnabled(bool enabled) { source0().setTonePolishEnabled(enabled); } - // Backing track + // Backing track — transport moved to engine/BackingPlayer (TLC phase 3); + // the volume fader + level meter stay engine-side (mix policy). void setBackingVolume(float vol) { backingVolume.store(slopsmith::sanitizeMasterGain(vol)); } - bool loadBackingTrack(const juce::File& file); - void setBackingPosition(double seconds); - void startBacking(); - void stopBacking(); - void setBackingSpeed(double speed); - // Non-blocking reads — do not acquire backingLock and never block the audio callback - bool isBackingPlaying() const { return backingPlaying.load(); } - double getBackingPosition() const { return cachedBackingPosition.load(); } - double getBackingDuration() const { return cachedBackingDuration.load(); } + bool loadBackingTrack(const juce::File& file) + { + currentBackingLevel.store(0.0f); + return backing.load(file); + } + void setBackingPosition(double seconds) { backing.setPosition(seconds); } + void startBacking() { backing.start(); } + void stopBacking() + { + backing.stop(); + currentBackingLevel.store(0.0f); + } + void setBackingSpeed(double speed) { backing.setSpeed(speed); } + // Non-blocking reads — never acquire the backing lock / block the audio callback + bool isBackingPlaying() const { return backing.isPlaying(); } + double getBackingPosition() const { return backing.getPosition(); } + double getBackingDuration() const { return backing.getDuration(); } // Metering (read from any thread — atomic). Input level/peak are per-source // (sources[0]); output level/peak are the post-mix master, engine-global. @@ -377,16 +387,6 @@ private: const juce::AudioIODeviceCallbackContext& context) override; void audioDeviceAboutToStart(juce::AudioIODevice* device) override; void audioDeviceStopped() override; - void stopBackingNoLock(); // caller holds backingLock - - // Renders one block of the backing track into backingBuffer (1x bypass or - // phase-vocoder stretch), advances backingHeardPositionSec / - // cachedBackingPosition, and clears backingPlaying at EOF. Returns the - // number of output frames written (== jmin(numSamples, backingBuffer cap)). - // Shared by the duplex and split output callbacks so the two paths can't - // drift. Precondition: caller holds backingLock and has verified - // backingTransport && backingPlaying. - int renderBackingBlockLocked(int numSamples); // Split-mode only: drains outputRing, mixes backing, writes to device. void audioOutputCallback(const float* const* inputData, @@ -478,15 +478,9 @@ private: // sourcesMutex (or is the device-stop path, where the callback is gone). void reclaimPendingReleases(); - juce::AudioFormatManager formatManager; - // Master output (post-mix) — engine-global, not per-source. std::atomic outputGain{1.0f}; std::atomic backingVolume{0.8f}; - // Per-song loudness normalizer for the backing track (applied in - // renderBackingBlockLocked, pre-fader). Owned + driven by the audio thread. - BackingLeveler backingLeveler; - double backingLevelerSr = 0.0; std::atomic currentOutputLevel{0.0f}; // Per-block RMS of the backing-track mix bus, written by the audio thread // and read on the main/JS thread via getBackingLevel(). Computed after the @@ -495,38 +489,9 @@ private: std::atomic currentBackingLevel{0.0f}; std::atomic outputPeak{0.0f}; - // Backing track - // Read-ahead worker that fills the transport's buffer off the audio thread - // (see loadBackingTrack). Declared BEFORE backingTransport so it is destroyed - // AFTER it — the transport's BufferingAudioSource holds a pointer to this - // thread and must be torn down before the thread goes away. - juce::TimeSliceThread backingReadThread { "BackingReadAhead" }; - std::unique_ptr backingSource; - std::unique_ptr backingTransport; - signalsmith::stretch::SignalsmithStretch backingStretch; - juce::AudioBuffer backingInputBuffer; // pulled from transport at device rate - juce::AudioBuffer backingBuffer; // stretch output, mixed into device buffer - std::atomic backingStretchLatencySamples{0}; - std::atomic backingPlaying{false}; - std::atomic cachedBackingPosition{0.0}; - std::atomic cachedBackingDuration{0.0}; - // Heard playhead: accumulates the source frames consumed each block, then - // clamped to backingTransport->getCurrentPosition() so a short read at EOF - // can't push it past the real source point. cachedBackingPosition is this - // value minus the stretcher output latency (zero on the 1x bypass path). - std::atomic backingHeardPositionSec{0.0}; - // Active playback rate. Mutated ONLY by the audio thread (in - // renderBackingBlockLocked), coupled with the stretcher reset, so a block - // is never processed at a new rate with stale stretch state. - std::atomic backingSpeed{1.0}; - // Lock-free speed hand-off: setBackingSpeed (control thread) publishes the - // requested rate here and raises backingSpeedChangePending; the audio - // thread adopts it on the next block. Avoids the control thread blocking on - // backingLock and starving the RT tryLock (which would drop a backing block - // mid-slider-drag). - std::atomic backingPendingSpeed{1.0}; - std::atomic backingSpeedChangePending{false}; - juce::CriticalSection backingLock; + // Backing track — transport/stretch/leveler moved to engine/BackingPlayer + // (TLC phase 3). Declared after `state` (bound by reference). + slopsmith::BackingPlayer backing{state}; // audioRunning keeps its historical DEVICE-STATE semantics (isAudioRunning // compat pin); the intent half is state.userWantsAudio — see EngineState.h. diff --git a/src/audio/CMakeLists.txt b/src/audio/CMakeLists.txt index 7e6175e..9da8379 100644 --- a/src/audio/CMakeLists.txt +++ b/src/audio/CMakeLists.txt @@ -7,6 +7,7 @@ set(AUDIO_SOURCES TonePolish.cpp AudioEngine.cpp engine/StreamSink.cpp + engine/BackingPlayer.cpp SourceChain.cpp SignalChain.cpp VSTHost.cpp diff --git a/src/audio/engine/BackingPlayer.cpp b/src/audio/engine/BackingPlayer.cpp new file mode 100644 index 0000000..c6a0161 --- /dev/null +++ b/src/audio/engine/BackingPlayer.cpp @@ -0,0 +1,325 @@ +// BackingPlayer implementation — moved verbatim from AudioEngine.cpp (TLC +// plan phase 3 / §2.4); member names lose their backing prefixes, logic is +// unchanged. See BackingPlayer.h for the boundary rationale. + +#include "BackingPlayer.h" + +#include +#include + +namespace slopsmith { + +bool BackingPlayer::load(const juce::File& file) +{ + const juce::ScopedLock sl(lock); + stopNoLock(); + transport.reset(); + readerSource.reset(); + + const bool exists = file.existsAsFile(); + std::cerr << "[AudioEngine] loadBackingTrack path=" + << file.getFullPathName().toStdString() + << " exists=" << exists + << " size=" << (exists ? (long long) file.getSize() : -1) + << std::endl; + + auto* reader = formatManager.createReaderFor(file); + if (!reader) + { + std::cerr << "[AudioEngine] loadBackingTrack: no reader for ext='" + << file.getFileExtension().toStdString() + << "' (registered formats=" << formatManager.getNumKnownFormats() + << ")" << std::endl; + // Transport/source already reset above; clear cached state so the renderer + // doesn't keep displaying the previous track's position/duration. + cachedPosition.store(0.0); + cachedDuration.store(0.0); + return false; + } + + const double readerSampleRate = reader->sampleRate; + const juce::int64 readerLengthInSamples = reader->lengthInSamples; + const double sr = state.currentSampleRate.load(std::memory_order_relaxed); + // Backing audio plays through the output device in both modes, so size + // against outputBlockSize. In duplex mode outputBlockSize == inputBlockSize; + // in split mode the output device's clock drives the backing pull. + const int bs = state.outputBlockSize.load(std::memory_order_relaxed); + + readerSource = std::make_unique(reader, true); + transport = std::make_unique(); + // Read-ahead on readThread so the RT audio thread normally never touches + // the disk or the format codec. Previously this passed (…, 0, nullptr, …): + // with no read-ahead buffer the transport decoded the file synchronously + // inside getNextAudioBlock ON the audio callback, so any disk seek / + // decode spike (worst for compressed formats) blew the block budget → + // underruns heard as glitches or brief mutes while a song plays. + // 32768 source frames ≈ 0.68 s @ 48k of look-ahead absorbs those spikes. + // + // Known residual (accepted): juce::BufferingAudioSource is not fully + // RT-safe — readBufferSection() holds callbackLock across the decode of one + // refill chunk, and the callback's getNextAudioBlock() takes the same lock, + // so the RT thread can still block behind an in-flight chunk decode. The + // window is bounded (JUCE caps chunks at 2048 source frames) and only hit + // when a refill is mid-decode, vs. the old guaranteed full decode on every + // block; a truly lock-free ring would mean replacing the JUCE transport + // stack and isn't worth it here. + // The 4th arg makes AudioTransportSource SRC the file to device rate. + // Stretch always sees device-rate audio so that its presetDefault parameters match. + constexpr int kReadAheadSamples = 32768; + transport->setSource(readerSource.get(), kReadAheadSamples, + &readThread, readerSampleRate); + + // Loading a backing track before the audio device has started leaves + // sr/bs at zero. presetDefault(2, 0.0f) would seed the stretcher with + // undefined internal timing, and prepareToPlay(0, 0) is similarly + // ill-defined. Defer the stretcher + buffer setup; the relevant + // audio*AboutToStart() re-runs the same block (via prepare()) once a real + // sample rate / block size are known. + if (sr > 0.0 && bs > 0) + { + // prepareToPlay's first arg is an upper bound on subsequent + // getNextAudioBlock requests, per the juce::AudioSource contract. + // The RT callback can pull ceil(bs * kMaxSpeed) frames in a single + // block when the speed is above 1×, so prepare for that worst case — + // preparing with just `bs` would risk JUCE internal buffer + // overruns/asserts on the first faster-than-1× block. + const int maxInputFrames = (int) std::ceil(bs * kMaxSpeed) + 64; + transport->prepareToPlay(maxInputFrames, sr); + + stretch.presetDefault(2, (float) sr); + stretch.reset(); + stretchLatencySamples.store(stretch.outputLatency(), std::memory_order_relaxed); + + inputBuffer.setSize(2, maxInputFrames, false, false, true); + outputBuffer.setSize(2, bs, false, false, true); + } + + cachedDuration.store(transport->getLengthInSeconds()); + cachedPosition.store(0.0); + heardPositionSec.store(0.0, std::memory_order_relaxed); + + // Reset the loudness leveler for the new song: clearing the cached sample + // rate forces renderBlockLocked() to re-prepare() it on the next block, + // dropping the previous track's AGC gain + limiter state. Otherwise the + // ~300 ms gain follower would carry over and briefly mis-level the start + // of a much louder/quieter next song. Safe here — load holds the lock, + // the same lock the render path runs under. + levelerSr = 0.0; + std::cerr << "[AudioEngine] loadBackingTrack OK sr=" << readerSampleRate + << " len=" << readerLengthInSamples + << std::endl; + return true; +} + +void BackingPlayer::setPosition(double seconds) +{ + const juce::ScopedLock sl(lock); + if (transport) + { + transport->setPosition(seconds); + stretch.reset(); + // Read back the actual position; the transport may clamp (e.g. negative or past EOF). + const double pos = transport->getCurrentPosition(); + cachedPosition.store(pos); + heardPositionSec.store(pos, std::memory_order_relaxed); + } +} + +void BackingPlayer::start() +{ + const juce::ScopedLock sl(lock); + if (transport) + { + transport->start(); + playing.store(true); + heardPositionSec.store(transport->getCurrentPosition(), + std::memory_order_relaxed); + } +} + +void BackingPlayer::stopNoLock() +{ + if (transport) + { + transport->stop(); + stretch.reset(); + playing.store(false); + } +} + +void BackingPlayer::stop() +{ + const juce::ScopedLock sl(lock); + stopNoLock(); +} + +void BackingPlayer::setSpeed(double newSpeed) +{ + if (!std::isfinite(newSpeed) || newSpeed <= 0.0) + { + return; + } + + const double clamped = juce::jlimit(0.01, kMaxSpeed, newSpeed); + // Dead-zone against the last *requested* rate to coalesce rapid slider + // ticks — but never skip a change that crosses the 1× bypass boundary, or a + // request just shy of 1× (e.g. 0.9995 -> 1.0, diff < 0.001) would leave the + // stretcher path engaged when the caller actually asked for transparent + // full speed. + const double prev = pendingSpeed.load(std::memory_order_relaxed); + const bool prevBypass = std::abs(prev - 1.0) < kSpeedBypassEpsilon; + const bool newBypass = std::abs(clamped - 1.0) < kSpeedBypassEpsilon; + if (std::abs(clamped - prev) < 0.001 && prevBypass == newBypass) + { + return; + } + + // Lock-free hand-off to the audio thread. Publish the requested rate, then + // raise the pending flag with release so the RT thread is guaranteed to see + // the new rate once it observes the flag. renderBlockLocked() adopts the + // rate and resets the stretcher together, on the audio thread, so: + // * a control-thread caller (e.g. a speed slider at 30-60 Hz) never takes + // the lock and so never starves the RT tryLock into dropping a block; + // * the new rate is never processed with stale stretch state — the reset + // and the rate adoption happen in the same RT block (see PR #237). + // Multiple updates before the RT consumes them coalesce (latest wins), which + // naturally throttles stretcher resets during a drag. + pendingSpeed.store(clamped, std::memory_order_relaxed); + speedChangePending.store(true, std::memory_order_release); +} + +void BackingPlayer::prepare(double sr, int bs) +{ + const juce::ScopedLock sl(lock); + if (transport && sr > 0.0 && bs > 0) + { + // See load() for why prepareToPlay uses maxInputFrames rather than bs: + // the RT callback can pull ceil(bs * kMaxSpeed) frames in a single + // block at faster-than-1× speeds. + const int maxInputFrames = (int) std::ceil(bs * kMaxSpeed) + 64; + transport->prepareToPlay(maxInputFrames, sr); + stretch.presetDefault(2, (float) sr); + stretch.reset(); + stretchLatencySamples.store(stretch.outputLatency(), std::memory_order_relaxed); + inputBuffer.setSize(2, maxInputFrames, false, false, true); + outputBuffer.setSize(2, bs, false, false, true); + } +} + +int BackingPlayer::renderBlockLocked(int numSamples) +{ + // Adopt any speed change requested since the last block (set lock-free by + // setSpeed). Common (no-change) path is a plain acquire load — no locked + // RMW, so the flag's cache line stays shared and isn't bounced to this + // core every callback. Only the rare block that actually consumes a change + // does the exchange (clearing the flag atomically so a concurrent setSpeed + // can't lose an update). The acquire pairs with the release-store in + // setSpeed so the new rate is visible here. Reset the stretcher and + // re-anchor the heard position in the SAME block we adopt the rate, so a + // block is never processed at the new rate with stale stretch state. + // reset() only clears state (no allocation), so it's audio-thread safe. + if (speedChangePending.load(std::memory_order_acquire)) + { + speedChangePending.exchange(false, std::memory_order_acquire); + speed.store(juce::jlimit(0.01, kMaxSpeed, + pendingSpeed.load(std::memory_order_relaxed)), + std::memory_order_relaxed); + stretch.reset(); + heardPositionSec.store(transport->getCurrentPosition(), + std::memory_order_relaxed); + } + + const double rate = juce::jlimit(0.01, kMaxSpeed, speed.load(std::memory_order_relaxed)); + + // Defensive clamp: the buffers are sized by prepare() from the device's + // nominal block size, but a callback can deliver a larger numSamples on a + // device-reconfig race. Drop the excess frames silently rather than + // reading/writing past the allocated span; the next callback after + // reconfig arrives at the new nominal size. + const int outCap = outputBuffer.getNumSamples(); + const int inCap = inputBuffer.getNumSamples(); + const int outSamples = juce::jmin(numSamples, outCap); + const double sr = state.currentSampleRate.load(std::memory_order_relaxed); + const bool bypassStretch = std::abs(rate - 1.0) < kSpeedBypassEpsilon; + + int sourceFramesPulled = 0; + + if (bypassStretch) + { + // 1× — direct transport read, no phase-vocoder path. (The transport + // still sample-rate-converts the file to the device rate, so this is + // "no time-stretch", not necessarily bit-perfect.) + outputBuffer.clear(0, outSamples); + juce::AudioSourceChannelInfo info(&outputBuffer, 0, outSamples); + transport->getNextAudioBlock(info); + sourceFramesPulled = outSamples; + } + else + { + // Slow/fast path — pull only the source frames needed for this output + // block (output * rate), then stretch in-process to fill outSamples. + const int inputFrames = juce::jmin((int) std::ceil(outSamples * rate), inCap); + + inputBuffer.clear(0, inputFrames); + juce::AudioSourceChannelInfo info(&inputBuffer, 0, inputFrames); + transport->getNextAudioBlock(info); + sourceFramesPulled = inputFrames; + + outputBuffer.clear(0, outSamples); + + const float* const* inPtrs = inputBuffer.getArrayOfReadPointers(); + float* const* outPtrs = outputBuffer.getArrayOfWritePointers(); + stretch.process(inPtrs, inputFrames, outPtrs, outSamples); + } + + const double transportPos = transport->getCurrentPosition(); + if (sr > 0.0 && sourceFramesPulled > 0) + { + // Accumulate the heard (source) position, but clamp to the transport's + // actual position. sourceFramesPulled is the requested block size; a + // short read (e.g. at EOF, where the transport returns fewer real frames + // and zero-pads) would otherwise advance the playhead past the true + // source point and report progress beyond the track duration before + // `playing` flips false. getCurrentPosition() stays clamped to the + // real source position. + double heard = heardPositionSec.load(std::memory_order_relaxed) + + static_cast(sourceFramesPulled) / sr; + heard = juce::jmin(heard, transportPos); + heardPositionSec.store(heard, std::memory_order_relaxed); + + // Bypass reads straight from the transport — no phase-vocoder output + // latency to compensate for. Only the stretch path adds latency. + const double latencyInputSec = bypassStretch + ? 0.0 + : (stretchLatencySamples.load(std::memory_order_relaxed) * rate) / sr; + cachedPosition.store(juce::jmax(0.0, heard - latencyInputSec)); + } + else + { + // currentSampleRate is transiently 0 during device teardown/reconfig. + // We can't accumulate (no Hz to divide by), so anchor both the heard + // accumulator and the published playhead to the real transport position + // rather than leaving a stale value visible to the UI. + heardPositionSec.store(transportPos, std::memory_order_relaxed); + cachedPosition.store(juce::jmax(0.0, transportPos)); + } + + // Sync the flag if transport stopped at EOF. + if (!transport->isPlaying()) + playing.store(false); + + // Normalize the backing track to a consistent target loudness (-12 LUFS) + // BEFORE the mixer's backing-volume fader is applied (later in the RT + // callback), so every song sits at the same level while the fader still + // attenuates it. Standard BS.1770 K-weighting (full-mix music) + a brickwall + // limiter to keep boosted peaks safe. RT-safe (no allocation). + if (outSamples > 0 && sr > 0.0) + { + if (sr != levelerSr) { leveler.prepare(sr); levelerSr = sr; } + leveler.process(outputBuffer, outSamples, -12.0f); + } + + return outSamples; +} + +} // namespace slopsmith diff --git a/src/audio/engine/BackingPlayer.h b/src/audio/engine/BackingPlayer.h new file mode 100644 index 0000000..289cae1 --- /dev/null +++ b/src/audio/engine/BackingPlayer.h @@ -0,0 +1,121 @@ +#pragma once + +// BackingPlayer — the backing-track transport (TLC plan phase 3 / §2.4). +// Moved verbatim from AudioEngine: JUCE AudioFormatReaderSource → +// AudioTransportSource buffered by a TimeSliceThread read-ahead → optional +// signalsmith-stretch phase vocoder for speed change (1× bypass path), the +// per-song BackingLeveler loudness normalizer, and the playhead caches. +// +// Boundary: control-thread lifecycle (load/start/stop/seek/setSpeed) and +// non-blocking cached getters live here; the RT mix POLICY (try-lock, RMS +// metering, volume fader, stream-submix capture) stays in the engine's +// output callbacks, which use the primitives getLock() / readyLocked() / +// renderBlockLocked() / renderBuffer() exactly as they open-coded them +// before. Both callbacks hold the try-lock through their stream publish so +// renderBuffer() is never read while prepare() can resize it. + +#include "EngineState.h" +#include "../BackingLeveler.h" +#include "signalsmith-stretch.h" // resolved via SS_STRETCH_DIR include path + +#include +#include + +#include +#include + +namespace slopsmith { + +class BackingPlayer +{ +public: + static constexpr double kMaxSpeed = 4.0; + // |rate - 1| below this uses the direct transport path (no phase vocoder). + static constexpr double kSpeedBypassEpsilon = 1.0e-4; + + explicit BackingPlayer(EngineState& engineState) : state(engineState) + { + formatManager.registerBasicFormats(); + readThread.startThread(); + } + + // ── Control thread ──────────────────────────────────────────────────── + bool load(const juce::File& file); + void setPosition(double seconds); + void start(); + void stop(); + void setSpeed(double speed); + + // Non-blocking reads — do not acquire the lock, never block the audio + // callback. + bool isPlaying() const { return playing.load(); } + double getPosition() const { return cachedPosition.load(); } + double getDuration() const { return cachedDuration.load(); } + + // Re-prepare the transport + stretcher + buffers at a (new) device format. + // Call from the about-to-start hook that owns backing playback (duplex: + // input manager; split: output manager). No-op when nothing is loaded. + void prepare(double sr, int bs); + + // ── RT primitives (output callbacks) ────────────────────────────────── + // Usage pattern (unchanged from the open-coded version): + // const juce::ScopedTryLock sl(backing.getLock()); + // if (sl.isLocked() && backing.readyLocked()) { + // const int n = backing.renderBlockLocked(numSamples); + // ... mix backing.renderBuffer() with the fader, meter RMS ... + // } + juce::CriticalSection& getLock() { return lock; } + bool readyLocked() const { return transport != nullptr && playing.load(); } + // Renders one block (1× bypass or phase-vocoder stretch) into the render + // buffer, advances heard/cached playheads, runs the loudness leveler, and + // clears `playing` at EOF. Returns output frames written + // (== jmin(numSamples, render-buffer cap)). Precondition: caller holds + // the lock and has verified readyLocked(). + int renderBlockLocked(int numSamples); + const juce::AudioBuffer& renderBuffer() const { return outputBuffer; } + +private: + void stopNoLock(); + + EngineState& state; + + juce::AudioFormatManager formatManager; + + // Read-ahead worker that fills the transport's buffer off the audio thread + // (see load()). Declared BEFORE transport so it is destroyed AFTER it — + // the transport's BufferingAudioSource holds a pointer to this thread and + // must be torn down before the thread goes away. + juce::TimeSliceThread readThread { "BackingReadAhead" }; + std::unique_ptr readerSource; + std::unique_ptr transport; + signalsmith::stretch::SignalsmithStretch stretch; + juce::AudioBuffer inputBuffer; // pulled from transport at device rate + juce::AudioBuffer outputBuffer; // stretch output, mixed by the callbacks + std::atomic stretchLatencySamples{0}; + std::atomic playing{false}; + std::atomic cachedPosition{0.0}; + std::atomic cachedDuration{0.0}; + // Heard playhead: accumulates the source frames consumed each block, then + // clamped to transport->getCurrentPosition() so a short read at EOF can't + // push it past the real source point. cachedPosition is this value minus + // the stretcher output latency (zero on the 1× bypass path). + std::atomic heardPositionSec{0.0}; + // Active playback rate. Mutated ONLY by the audio thread (in + // renderBlockLocked), coupled with the stretcher reset, so a block is + // never processed at a new rate with stale stretch state. + std::atomic speed{1.0}; + // Lock-free speed hand-off: setSpeed (control thread) publishes the + // requested rate here and raises speedChangePending; the audio thread + // adopts it on the next block. Avoids the control thread blocking on the + // lock and starving the RT tryLock (which would drop a backing block + // mid-slider-drag). + std::atomic pendingSpeed{1.0}; + std::atomic speedChangePending{false}; + // Per-song loudness normalizer (applied in renderBlockLocked, pre-fader). + // Owned + driven by the audio thread. + BackingLeveler leveler; + double levelerSr = 0.0; + juce::CriticalSection lock; +}; + +} // namespace slopsmith From 6ace5a209a29a93fd16741a4255304c194f4e211 Mon Sep 17 00:00:00 2001 From: OmikronApex Date: Tue, 14 Jul 2026 00:27:51 +0200 Subject: [PATCH 09/28] refactor(audio): extract DeviceSetup + shared rate-match helpers (phase 4) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Moves probeDeviceOptionsDual, applyDuplexSetup, applySplitSetup, and teardownSplitMode verbatim into src/audio/engine/DeviceSetup.{h,cpp}. The component holds references to the two device managers + EngineState and owns no lifetime; engine-owned collaborators (monitor chain, split output ring + counters, output callback registration) are passed by reference per call. setAudioDevices stays on the facade as the orchestrator. The public DeviceOptions/DeviceConfig/DeviceConfigResult shapes move to the slopsmith namespace with using-aliases on AudioEngine, so the NodeAddon spelling is unchanged. Lands the deep-read §7 dedupe structurally: the <=0.5 rate tolerance, midpoint-rounding fail-closed candidate, and empty-name→first-enumerated resolution now exist once (RateMatch.h — JUCE-free + unit-tested boundary cases — and DeviceSetup::resolveDeviceName/rateSupportedBy) instead of three hand-synced copies. Full device-matrix validation (WASAPI shared/exclusive, ASIO, dual-type split) rides the next tester build per the plan's phase-4 gate. Co-Authored-By: Claude Fable 5 --- src/audio/AudioEngine.cpp | 625 +------------------------ src/audio/AudioEngine.h | 49 +- src/audio/CMakeLists.txt | 1 + src/audio/engine/DeviceSetup.cpp | 623 ++++++++++++++++++++++++ src/audio/engine/DeviceSetup.h | 124 +++++ src/audio/engine/RateMatch.h | 33 ++ tests/engine_units/CMakeLists.txt | 4 + tests/engine_units/rate_match_test.cpp | 46 ++ 8 files changed, 850 insertions(+), 655 deletions(-) create mode 100644 src/audio/engine/DeviceSetup.cpp create mode 100644 src/audio/engine/DeviceSetup.h create mode 100644 src/audio/engine/RateMatch.h create mode 100644 tests/engine_units/rate_match_test.cpp diff --git a/src/audio/AudioEngine.cpp b/src/audio/AudioEngine.cpp index 2069e79..2be1ca2 100644 --- a/src/audio/AudioEngine.cpp +++ b/src/audio/AudioEngine.cpp @@ -190,212 +190,7 @@ AudioEngine::DeviceOptions AudioEngine::probeDeviceOptionsDual(const juce::Strin const juce::String& outputTypeName, const juce::String& outputName) { - DeviceOptions options; - options.inputType = inputTypeName; - options.outputType = outputTypeName.isEmpty() ? inputTypeName : outputTypeName; - options.type = options.inputType; // legacy alias - - // Resolve each side from its own manager so probe stays consistent with - // applySplitSetup()/setOutputDeviceType(), which mutate the manager that - // owns the side they're configuring. Using inputDeviceManager for the - // output lookup would silently fall back to whatever input has scanned, - // which can miss output-only backends. - auto findType = [](juce::AudioDeviceManager& manager, - const juce::String& wanted) -> juce::AudioIODeviceType* { - juce::AudioIODeviceType* match = nullptr; - for (auto* type : manager.getAvailableDeviceTypes()) - { - if ((wanted.isNotEmpty() && type->getTypeName() == wanted) - || (wanted.isEmpty() && match == nullptr)) - { - match = type; - if (wanted.isNotEmpty()) break; - } - } - return match; - }; - - auto* inputType = findType(inputDeviceManager, options.inputType); - - // Match setAudioDevices's resolution: when the caller didn't specify - // an output type, default it to the SAME type the input side resolved - // to (using the type's name, looked up in outputDeviceManager). Without - // this, an empty `options.outputType` would let findType pick whatever - // outputDeviceManager enumerates first — potentially a different - // backend than inputDeviceManager picked from the empty string, which - // then disagrees with the apply path's duplex classification. - juce::String effectiveOutputTypeName = options.outputType; - if (effectiveOutputTypeName.isEmpty() && inputType != nullptr) - effectiveOutputTypeName = inputType->getTypeName(); - auto* outputType = findType(outputDeviceManager, effectiveOutputTypeName); - - if (inputType == nullptr) - { - options.error = "Input device type not found"; - options.compatible = false; - return options; - } - if (outputType == nullptr) - { - options.error = "Output device type not found"; - options.compatible = false; - return options; - } - - try - { - options.inputType = inputType->getTypeName(); - options.outputType = outputType->getTypeName(); - options.type = options.inputType; - - options.input = inputName; - options.output = outputName; - - // For probing we still need a concrete device to instantiate. - // Resolve empty names to first-enumerated ONLY for the probe-device - // creation below — DON'T write back into options.input/options.output; - // those flow to the UI and the apply path, which treat empty as - // "OS default" per side. - auto inputs = inputType->getDeviceNames(true); - auto outputs = outputType->getDeviceNames(false); - const juce::String probeInputName = - options.input.isEmpty() && inputs.size() > 0 ? inputs[0] : options.input; - const juce::String probeOutputName = - options.output.isEmpty() && outputs.size() > 0 ? outputs[0] : options.output; - - // Probe the SAME way setAudioDevices() will actually apply, or the - // startup auto-apply mis-fires: init() fail-closes on this probe's - // `compatible` verdict, so if the probe measures a combined duplex device - // but apply then opens split (or vice-versa), the verdict describes a - // config that won't be the one used — the classic symptom being "no audio - // until I press Apply". Duplex is only attempted for the SAME physical - // endpoint (a true single-clock device); two different endpoints of the - // same backend (USB cable in + separate speakers out) are two clocks and - // go split. Mirror setAudioDevices()'s sameEndpointIntent exactly. - bool isDuplex = (options.inputType == options.outputType) - && (options.input == options.output); - - if (isDuplex) - { - std::unique_ptr dev( - inputType->createDevice(probeOutputName, probeInputName)); - if (dev) - { - options.inputChannels = dev->getInputChannelNames(); - options.outputChannels = dev->getOutputChannelNames(); - for (auto rate : dev->getAvailableSampleRates()) - options.sampleRates.addIfNotAlreadyThere(rate); - for (auto size : dev->getAvailableBufferSizes()) - options.bufferSizes.addIfNotAlreadyThere(size); - } - else - { - isDuplex = false; - } - } - if (!isDuplex) - { - std::unique_ptr inDev( - inputType->createDevice({}, probeInputName)); - std::unique_ptr outDev( - outputType->createDevice(probeOutputName, {})); - if (!inDev || !outDev) - { - options.error = "Could not create dual probe devices"; - options.compatible = false; - return options; - } - - options.inputChannels = inDev->getInputChannelNames(); - options.outputChannels = outDev->getOutputChannelNames(); - - // Tolerance covers backends that report fractional drift around the nominal rate. - const auto inRates = inDev->getAvailableSampleRates(); - const auto outRates = outDev->getAvailableSampleRates(); - for (auto r : inRates) - { - for (auto r2 : outRates) - { - // <= 0.5 (not <) to match applySplitSetup's rateSupportedBy - // check. A backend reporting 47999.5 on both sides has - // |r - r2| = 0 (matches anyway) but a backend mixing - // 47999.5 in / 48000.0 out has |diff| = 0.5 exactly, which - // < 0.5 would reject from the probe even though the - // apply-side check accepts it. - if (std::abs(r - r2) <= 0.5) - { - // Round the midpoint to a clean nominal rate - // (backends sometimes report fractional near-48000 - // rates; surfacing the raw value would fail the - // apply-side setAudioDeviceSetup, which expects an - // exact supported nominal). Re-check the rounded - // candidate is within tolerance of BOTH sides — a - // matched pair like 48000.4/48000.6 passes the |r-r2| - // check but std::round(48000.4)=48000 would fall - // outside tolerance of 48000.6 (diff 0.6). Skip - // those so the probe stays fail-closed. - const double candidate = std::round((r + r2) * 0.5); - if (std::abs(r - candidate) <= 0.5 - && std::abs(r2 - candidate) <= 0.5) - { - options.sampleRates.addIfNotAlreadyThere(candidate); - } - break; - } - } - } - if (options.sampleRates.isEmpty()) - { - options.error = "Input and output devices share no common sample rate"; - options.compatible = false; - } - - // Split mode opens both sides with the same bufferSize, so the - // UI should only see sizes the intersection of both devices - // supports — a union would let the user pick a value that - // predictably fails at apply time on one side. - const auto inBufs = inDev->getAvailableBufferSizes(); - const auto outBufs = outDev->getAvailableBufferSizes(); - for (auto b : inBufs) - { - for (auto b2 : outBufs) - { - if (b == b2) - { - options.bufferSizes.addIfNotAlreadyThere(b); - break; - } - } - } - // An empty intersection means there's no buffer size both sides - // accept; setting compatible=false stops the UI from re-enabling - // Apply against a guaranteed-fail config. - if (options.bufferSizes.isEmpty() && options.error.isEmpty()) - { - options.error = "Input and output devices share no common buffer size"; - options.compatible = false; - } - } - - fprintf(stderr, "[AudioEngine] Probed device options: inType='%s' outType='%s' in='%s' out='%s' " - "duplex=%d inputs=%d outputs=%d rates=%d buffers=%d compatible=%d\n", - options.inputType.toRawUTF8(), options.outputType.toRawUTF8(), - options.input.toRawUTF8(), options.output.toRawUTF8(), - (int) isDuplex, options.inputChannels.size(), options.outputChannels.size(), - options.sampleRates.size(), options.bufferSizes.size(), (int) options.compatible); - } - catch (const std::exception& e) - { - options.error = e.what(); - options.compatible = false; - } - catch (...) - { - options.error = "Probe failed"; - options.compatible = false; - } - - return options; + return deviceSetup.probeDual(inputTypeName, inputName, outputTypeName, outputName); } juce::String AudioEngine::getCurrentDeviceType() @@ -719,8 +514,9 @@ AudioEngine::DeviceConfigResult AudioEngine::setAudioDevices(const DeviceConfig& { teardownSplitMode(); - const juce::String err = applyDuplexSetup(resolvedInput, resolvedOutput, - requestedSampleRate, requestedBufferSize); + const juce::String err = deviceSetup.applyDuplex(resolvedInput, resolvedOutput, + requestedSampleRate, requestedBufferSize, + source0()); if (err.isEmpty()) { duplexMode.store(true, std::memory_order_relaxed); @@ -754,7 +550,9 @@ AudioEngine::DeviceConfigResult AudioEngine::setAudioDevices(const DeviceConfig& resolved.sampleRate = requestedSampleRate; resolved.bufferSize = requestedBufferSize; - res = applySplitSetup(resolved); + res = deviceSetup.applySplit(resolved, source0(), outputRing, + outputUnderflowCount, inputOverflowCount, + outputCallback, outputCallbackRegistered); if (!res.ok) return res; duplexMode.store(false, std::memory_order_relaxed); @@ -768,416 +566,9 @@ AudioEngine::DeviceConfigResult AudioEngine::setAudioDevices(const DeviceConfig& return res; } -juce::String AudioEngine::applyDuplexSetup(const juce::String& inputName, - const juce::String& outputName, - double sampleRate, int bufferSize) -{ - juce::AudioDeviceManager::AudioDeviceSetup setup; - setup.inputDeviceName = inputName; - setup.outputDeviceName = outputName; - setup.sampleRate = sampleRate > 0 ? sampleRate : 48000.0; - setup.bufferSize = bufferSize > 0 ? bufferSize : 256; - setup.useDefaultInputChannels = inputName.isEmpty(); - setup.useDefaultOutputChannels = outputName.isEmpty(); - - // Channel masks must match too — high-numbered selectedInputChannel needs - // the expanded mask that an older session may not have opened. - if (auto* currentDevice = inputDeviceManager.getCurrentAudioDevice()) - { - try - { - juce::AudioDeviceManager::AudioDeviceSetup current; - inputDeviceManager.getAudioDeviceSetup(current); - - const int advertisedInputs = currentDevice->getInputChannelNames().size(); - juce::BigInteger expectedInputs; - expectedInputs.setRange(0, advertisedInputs > 0 ? advertisedInputs : 2, true); - - const int advertisedOutputs = currentDevice->getOutputChannelNames().size(); - juce::BigInteger expectedOutputs; - expectedOutputs.setRange(0, juce::jmin(advertisedOutputs > 0 ? advertisedOutputs : 2, 2), true); - - if (current.inputDeviceName == setup.inputDeviceName - && current.outputDeviceName == setup.outputDeviceName - && current.sampleRate == setup.sampleRate - && current.bufferSize == setup.bufferSize - && current.useDefaultInputChannels == setup.useDefaultInputChannels - && current.useDefaultOutputChannels == setup.useDefaultOutputChannels - && current.inputChannels == expectedInputs - && current.outputChannels == expectedOutputs - && duplexMode.load(std::memory_order_relaxed)) - { - fprintf(stderr, "[AudioEngine] Duplex device already configured with same settings, skipping\n"); - return {}; - } - } - catch (const std::exception& e) - { - fprintf(stderr, "[AudioEngine] Current device channel check failed: %s\n", e.what()); - } - catch (...) - { - fprintf(stderr, "[AudioEngine] Current device channel check failed (unknown)\n"); - } - } - - // ALSA deadlocks on reconfigure unless we fully close first. WASAPI - // reconfigures in place and is much slower if closed. -#if JUCE_LINUX - juce::String currentTypeName; - if (auto* currentType = inputDeviceManager.getCurrentDeviceTypeObject()) - currentTypeName = currentType->getTypeName(); - if (inputDeviceManager.getCurrentAudioDevice() != nullptr) - { - try { - inputDeviceManager.closeAudioDevice(); - fprintf(stderr, "[AudioEngine] Closed device for reconfiguration\n"); - if (currentTypeName.isNotEmpty()) - inputDeviceManager.setCurrentAudioDeviceType(currentTypeName, true); - } catch (...) { - fprintf(stderr, "[AudioEngine] closeAudioDevice crashed, continuing\n"); - } - } -#endif - - int inputChannelCount = 0; - int outputChannelCount = 0; - if (auto* type = inputDeviceManager.getCurrentDeviceTypeObject()) - { - try - { - if (auto probe = std::unique_ptr(type->createDevice(outputName, inputName))) - { - inputChannelCount = probe->getInputChannelNames().size(); - outputChannelCount = probe->getOutputChannelNames().size(); - } - } - catch (const std::exception& e) - { - fprintf(stderr, "[AudioEngine] Channel probe failed: %s\n", e.what()); - } - catch (...) - { - fprintf(stderr, "[AudioEngine] Channel probe failed (unknown)\n"); - } - } - if (inputChannelCount <= 0) inputChannelCount = 2; - if (outputChannelCount <= 0) outputChannelCount = 2; - - setup.inputChannels.setRange(0, inputChannelCount, true); - setup.outputChannels.setRange(0, juce::jmin(outputChannelCount, 2), true); - - juce::String result; - try { - result = inputDeviceManager.setAudioDeviceSetup(setup, true); - } catch (...) { - return "setAudioDeviceSetup threw"; - } - if (result.isNotEmpty()) - { - fprintf(stderr, "[AudioEngine] Device setup error: %s\n", result.toRawUTF8()); - try { - result = inputDeviceManager.initialiseWithDefaultDevices(2, 2); - } catch (...) { - return "fallback initialiseWithDefaultDevices threw"; - } - if (result.isNotEmpty()) - return "device setup failed: " + result; - } - - if (auto* configuredDevice = inputDeviceManager.getCurrentAudioDevice()) - { - const double sr = configuredDevice->getCurrentSampleRate(); - const int bs = configuredDevice->getCurrentBufferSizeSamples(); - currentSampleRate.store(sr, std::memory_order_relaxed); - inputBlockSize.store(bs, std::memory_order_relaxed); - outputBlockSize.store(bs, std::memory_order_relaxed); - - fprintf(stderr, "[AudioEngine] Duplex device configured OK. Current device: %s\n", - configuredDevice->getName().toRawUTF8()); - fprintf(stderr, "[AudioEngine] Actual device setup: sr=%.0f bs=%d (requested bs=%d)\n", - sr, bs, bufferSize); - - source0().prepareMonitorChain(sr, bs); - return {}; - } - currentSampleRate.store(0.0, std::memory_order_relaxed); - inputBlockSize.store(0, std::memory_order_relaxed); - outputBlockSize.store(0, std::memory_order_relaxed); - source0().releaseMonitorChain(); - return "no current device after setup"; -} - -AudioEngine::DeviceConfigResult AudioEngine::applySplitSetup(const DeviceConfig& config) -{ - DeviceConfigResult res; - res.duplex = false; - - // The split-mode output ring is fixed at kOutputRingFrames samples - // (~85ms @ 48kHz). A single callback at bufferSize > kOutputRingFrames - // would overrun the ring in one go, guaranteeing immediate - // overwrite/wrap and audible glitches. Reject those configurations up - // front — duplex still works fine since it bypasses the ring entirely. - if (config.bufferSize > kOutputRingFrames) - { - res.error = "Buffer size " + juce::String(config.bufferSize) - + " exceeds split-mode ring capacity (" - + juce::String(kOutputRingFrames) + "). Pick a smaller buffer size or use duplex."; - return res; - } - - // setCurrentAudioDeviceType can throw from JUCE backends (ASIO). - // Catch so the failure surfaces as a structured error rather than an - // exception crossing the N-API boundary. - try - { - if (auto* current = outputDeviceManager.getCurrentDeviceTypeObject()) - { - if (current->getTypeName() != config.outputType) - outputDeviceManager.setCurrentAudioDeviceType(config.outputType, true); - } - else - { - outputDeviceManager.setCurrentAudioDeviceType(config.outputType, true); - } - } - catch (...) - { - res.error = "setCurrentAudioDeviceType threw for output type '" + config.outputType + "'"; - return res; - } - - // v1 forces matching nominal SR — no adaptive resampler yet. - // Resolve empty name to first-enumerated for the createDevice probe - // call (matches probeDeviceOptionsDual's strategy). createDevice("") - // is implementation-defined per backend — some return the default, - // some return null. Using first-enumerated keeps probe and apply - // checking the SAME concrete device, so an empty-name config can't - // pass the UI probe and then fail this check. - auto rateSupportedBy = [&](juce::AudioIODeviceType* t, - const juce::String& dev, bool isInput, double sr) { - if (!t) return false; - juce::String resolved = dev; - if (resolved.isEmpty()) - { - auto names = t->getDeviceNames(isInput); - if (names.size() > 0) resolved = names[0]; - } - std::unique_ptr probe( - isInput ? t->createDevice({}, resolved) : t->createDevice(resolved, {})); - if (!probe) return false; - // Tolerance matches the probe-side rounding: probeDeviceOptionsDual - // rounds the matched rate to the nearest integer (see :208), so a - // backend reporting e.g. 47999.5 surfaces 48000 in the UI. If we - // kept `< 0.5` here, the round-trip would fail at apply time because - // |47999.5 - 48000.0| is exactly 0.5. Use `<= 0.5` so the boundary - // case the probe accepted is also accepted at apply. - for (auto r : probe->getAvailableSampleRates()) - if (std::abs(r - sr) <= 0.5) return true; - return false; - }; - juce::AudioIODeviceType* inputType = nullptr; - juce::AudioIODeviceType* outputType = nullptr; - for (auto* t : inputDeviceManager.getAvailableDeviceTypes()) - if (t->getTypeName() == config.inputType) { inputType = t; break; } - for (auto* t : outputDeviceManager.getAvailableDeviceTypes()) - if (t->getTypeName() == config.outputType) { outputType = t; break; } - if (!inputType || !outputType) - { - res.error = "Device type not found"; - return res; - } - if (!rateSupportedBy(inputType, config.inputDevice, true, config.sampleRate) - || !rateSupportedBy(outputType, config.outputDevice, false, config.sampleRate)) - { - res.error = "Sample rate not supported by both input and output devices"; - return res; - } - - juce::AudioDeviceManager::AudioDeviceSetup inSetup; - // Resolve empty name to first-enumerated input device — matches the - // rateSupportedBy preflight above AND probeDeviceOptionsDual. Using - // empty + useDefault*Channels here would make JUCE open the OS - // default, which can differ from inputs[0] on platforms where the - // OS-default differs from JUCE's enumeration order. The probe + SR - // preflight + actual open all need to agree on the same concrete - // device for the apply path to behave consistently with what the UI - // showed the user. - juce::String resolvedInputName = config.inputDevice; - if (resolvedInputName.isEmpty()) - { - auto names = inputType->getDeviceNames(true); - if (names.size() > 0) resolvedInputName = names[0]; - } - - inSetup.inputDeviceName = resolvedInputName; - inSetup.outputDeviceName = ""; - inSetup.sampleRate = config.sampleRate; - inSetup.bufferSize = config.bufferSize; - inSetup.useDefaultInputChannels = false; - inSetup.useDefaultOutputChannels = false; - - int inputChannelCount = 0; - { - try { - std::unique_ptr probe(inputType->createDevice({}, resolvedInputName)); - if (probe) inputChannelCount = probe->getInputChannelNames().size(); - } catch (...) {} - } - if (inputChannelCount <= 0) inputChannelCount = 2; - inSetup.inputChannels.setRange(0, inputChannelCount, true); - inSetup.outputChannels.clear(); - - // Rollback helper: on any failure path after a side has been opened, - // close both managers' devices so we don't leave the OS audio resource - // held (sometimes exclusively, e.g. ASIO) while setDevice reports a - // failure. closeAudioDevice is idempotent so unconditional calls are - // safe even when only the input or neither side opened. - auto rollbackOpenedDevices = [&]() { - // Drop any callback we already attached to the output manager — - // closeAudioDevice() does not invoke removeAudioCallback, and leaving - // outputCallbackRegistered=true would cause the next startAudio() - // to skip the re-attach (it gates on !outputCallbackRegistered), - // leaving split-mode output silent after a partial-open failure. - if (outputCallbackRegistered) - { - try { outputDeviceManager.removeAudioCallback(&outputCallback); } catch (...) {} - outputCallbackRegistered = false; - } - try { inputDeviceManager.closeAudioDevice(); } catch (...) {} - try { outputDeviceManager.closeAudioDevice(); } catch (...) {} - }; - - // Mirror applyDuplexSetup's JUCE_LINUX close-before-reconfigure pattern: - // ALSA deadlocks if we let setAudioDeviceSetup mutate a live device. The - // device type is re-asserted afterwards so the close doesn't drop us back - // to whatever JUCE picked at startup. closeAudioDevice/setCurrentAudioDeviceType - // throwing is non-fatal — we still try the setup below and surface its error. -#if JUCE_LINUX - { - juce::String currentInputTypeName; - if (auto* currentType = inputDeviceManager.getCurrentDeviceTypeObject()) - currentInputTypeName = currentType->getTypeName(); - if (inputDeviceManager.getCurrentAudioDevice() != nullptr) - { - try { - inputDeviceManager.closeAudioDevice(); - if (currentInputTypeName.isNotEmpty()) - inputDeviceManager.setCurrentAudioDeviceType(currentInputTypeName, true); - } catch (...) { - fprintf(stderr, "[AudioEngine] split-mode input close threw, continuing\n"); - } - } - } -#endif - - juce::String inErr; - try { inErr = inputDeviceManager.setAudioDeviceSetup(inSetup, true); } - catch (...) { res.error = "input setAudioDeviceSetup threw"; rollbackOpenedDevices(); return res; } - if (inErr.isNotEmpty()) { res.error = "input setup: " + inErr; rollbackOpenedDevices(); return res; } - - auto* inDev = inputDeviceManager.getCurrentAudioDevice(); - if (!inDev) { res.error = "no input device after setup"; rollbackOpenedDevices(); return res; } - const double inSr = inDev->getCurrentSampleRate(); - const int inBs = inDev->getCurrentBufferSizeSamples(); - - // Same first-enumerated resolution on the output side — see input note - // above for why this matches the probe + SR preflight strategy. - juce::String resolvedOutputName = config.outputDevice; - if (resolvedOutputName.isEmpty()) - { - auto names = outputType->getDeviceNames(false); - if (names.size() > 0) resolvedOutputName = names[0]; - } - - juce::AudioDeviceManager::AudioDeviceSetup outSetup; - outSetup.inputDeviceName = ""; - outSetup.outputDeviceName = resolvedOutputName; - outSetup.sampleRate = config.sampleRate; - outSetup.bufferSize = config.bufferSize; - outSetup.useDefaultInputChannels = false; - outSetup.useDefaultOutputChannels = false; - - int outputChannelCount = 0; - { - try { - std::unique_ptr probe(outputType->createDevice(resolvedOutputName, {})); - if (probe) outputChannelCount = probe->getOutputChannelNames().size(); - } catch (...) {} - } - if (outputChannelCount <= 0) outputChannelCount = 2; - outSetup.inputChannels.clear(); - outSetup.outputChannels.setRange(0, juce::jmin(outputChannelCount, 2), true); - - // Same JUCE_LINUX close-before-reconfigure as the input side above — also - // protects when split mode is re-applied with a different output device. -#if JUCE_LINUX - { - juce::String currentOutputTypeName; - if (auto* currentType = outputDeviceManager.getCurrentDeviceTypeObject()) - currentOutputTypeName = currentType->getTypeName(); - if (outputDeviceManager.getCurrentAudioDevice() != nullptr) - { - try { - outputDeviceManager.closeAudioDevice(); - if (currentOutputTypeName.isNotEmpty()) - outputDeviceManager.setCurrentAudioDeviceType(currentOutputTypeName, true); - } catch (...) { - fprintf(stderr, "[AudioEngine] split-mode output close threw, continuing\n"); - } - } - } -#endif - - juce::String outErr; - try { outErr = outputDeviceManager.setAudioDeviceSetup(outSetup, true); } - catch (...) { res.error = "output setAudioDeviceSetup threw"; rollbackOpenedDevices(); return res; } - if (outErr.isNotEmpty()) { res.error = "output setup: " + outErr; rollbackOpenedDevices(); return res; } - - auto* outDev = outputDeviceManager.getCurrentAudioDevice(); - if (!outDev) { res.error = "no output device after setup"; rollbackOpenedDevices(); return res; } - const double outSr = outDev->getCurrentSampleRate(); - const int outBs = outDev->getCurrentBufferSizeSamples(); - - if (std::abs(inSr - outSr) > 0.5) - { - res.error = "Input and output devices opened at different sample rates"; - rollbackOpenedDevices(); - return res; - } - - currentSampleRate.store(inSr, std::memory_order_relaxed); - inputBlockSize.store(inBs, std::memory_order_relaxed); - outputBlockSize.store(outBs, std::memory_order_relaxed); - - fprintf(stderr, "[AudioEngine] Split mode configured: inSr=%.0f inBs=%d outSr=%.0f outBs=%d\n", - inSr, inBs, outSr, outBs); - - outputRing.reset(); - outputUnderflowCount.store(0, std::memory_order_relaxed); - inputOverflowCount.store(0, std::memory_order_relaxed); - - source0().prepareMonitorChain(inSr, inBs); - - res.ok = true; - res.sampleRate = inSr; - res.inputBlockSize = inBs; - res.outputBlockSize = outBs; - return res; -} - void AudioEngine::teardownSplitMode() { - // Unconditional remove — JUCE's removeAudioCallback is idempotent - // (no-op if the callback isn't registered), so we don't need the - // outputCallbackRegistered guard here. This makes teardown robust - // against a stale flag left over from a previous failed split setup. - outputDeviceManager.removeAudioCallback(&outputCallback); - outputCallbackRegistered = false; - try { outputDeviceManager.closeAudioDevice(); } - catch (...) { fprintf(stderr, "[AudioEngine] teardownSplitMode: output close threw\n"); } - - outputRing.reset(); + deviceSetup.teardownSplit(outputRing, outputCallback, outputCallbackRegistered); } // ── Audio Control ───────────────────────────────────────────────────────────── diff --git a/src/audio/AudioEngine.h b/src/audio/AudioEngine.h index f74d05b..0f3912b 100644 --- a/src/audio/AudioEngine.h +++ b/src/audio/AudioEngine.h @@ -6,6 +6,7 @@ #include "engine/RendererBus.h" #include "engine/StreamSink.h" #include "engine/BackingPlayer.h" +#include "engine/DeviceSetup.h" #include "BackingLeveler.h" #include "signalsmith-stretch.h" #include @@ -68,39 +69,12 @@ public: juce::StringArray inputDevices; juce::StringArray outputDevices; }; - struct DeviceOptions - { - juce::String type; // legacy alias = inputType - juce::String inputType; - juce::String outputType; - juce::String input; - juce::String output; - juce::StringArray inputChannels; - juce::StringArray outputChannels; - juce::Array sampleRates; // intersection when dual-type - juce::Array bufferSizes; - bool compatible = true; // false when types share no usable sample rate - juce::String error; - }; - - struct DeviceConfig - { - juce::String inputType; - juce::String inputDevice; - juce::String outputType; - juce::String outputDevice; - double sampleRate = 48000.0; - int bufferSize = 256; - }; - struct DeviceConfigResult - { - bool ok = false; - juce::String error; - double sampleRate = 0.0; - int inputBlockSize = 0; - int outputBlockSize = 0; - bool duplex = true; - }; + // Device-config shapes moved to engine/DeviceSetup.h (TLC phase 4); + // aliased so the AudioEngine::DeviceOptions etc. spelling NodeAddon uses + // is unchanged. + using DeviceOptions = slopsmith::DeviceOptions; + using DeviceConfig = slopsmith::DeviceConfig; + using DeviceConfigResult = slopsmith::DeviceConfigResult; struct DeviceMetrics { @@ -417,11 +391,8 @@ private: }; OutputCallback outputCallback{ *this }; - juce::String applyDuplexSetup(const juce::String& inputName, - const juce::String& outputName, - double sampleRate, - int bufferSize); - DeviceConfigResult applySplitSetup(const DeviceConfig& config); + // Probe/apply/teardown moved to engine/DeviceSetup (TLC phase 4); + // setAudioDevices stays here as the orchestrator. void teardownSplitMode(); // Duplex mode: inputDeviceManager owns both directions, outputDeviceManager idle. @@ -435,6 +406,8 @@ private: // untouched; extracted units take `state` (EngineState&) directly. slopsmith::EngineState state; std::atomic& duplexMode = state.duplexMode; + // Probe/apply/teardown component (TLC phase 4). Holds references only. + slopsmith::DeviceSetup deviceSetup{ inputDeviceManager, outputDeviceManager, state }; // Per-input capture+detect+monitor chains. A FIXED pool, all constructed up // front, so adding/removing a source never reassigns a pointer the audio diff --git a/src/audio/CMakeLists.txt b/src/audio/CMakeLists.txt index 9da8379..629e1af 100644 --- a/src/audio/CMakeLists.txt +++ b/src/audio/CMakeLists.txt @@ -8,6 +8,7 @@ set(AUDIO_SOURCES AudioEngine.cpp engine/StreamSink.cpp engine/BackingPlayer.cpp + engine/DeviceSetup.cpp SourceChain.cpp SignalChain.cpp VSTHost.cpp diff --git a/src/audio/engine/DeviceSetup.cpp b/src/audio/engine/DeviceSetup.cpp new file mode 100644 index 0000000..07160fd --- /dev/null +++ b/src/audio/engine/DeviceSetup.cpp @@ -0,0 +1,623 @@ +// DeviceSetup implementation — moved verbatim from AudioEngine.cpp (TLC plan +// phase 4 / §2.7). The only edits beyond member renames are the extraction of +// the three previously hand-synced helpers (ratesMatch / resolveDeviceName / +// rateSupportedBy), which each site now calls instead of open-coding. + +#include "DeviceSetup.h" + +#include +#include +#include + +namespace slopsmith { + +juce::String DeviceSetup::resolveDeviceName(juce::AudioIODeviceType* t, + bool isInput, const juce::String& name) +{ + if (t == nullptr || name.isNotEmpty()) return name; + auto names = t->getDeviceNames(isInput); + return names.size() > 0 ? names[0] : name; +} + +bool DeviceSetup::rateSupportedBy(juce::AudioIODeviceType* t, const juce::String& dev, + bool isInput, double sr) +{ + // v1 forces matching nominal SR — no adaptive resampler yet. Resolve empty + // name to first-enumerated for the createDevice probe call (matches + // probeDual's strategy). createDevice("") is implementation-defined per + // backend — some return the default, some return null. Using + // first-enumerated keeps probe and apply checking the SAME concrete + // device, so an empty-name config can't pass the UI probe and then fail + // this check. + if (!t) return false; + const juce::String resolved = resolveDeviceName(t, isInput, dev); + std::unique_ptr probe( + isInput ? t->createDevice({}, resolved) : t->createDevice(resolved, {})); + if (!probe) return false; + // Tolerance matches the probe-side rounding: probeDual rounds the matched + // rate to the nearest integer, so a backend reporting e.g. 47999.5 + // surfaces 48000 in the UI. If we kept `< 0.5` here, the round-trip would + // fail at apply time because |47999.5 - 48000.0| is exactly 0.5. + for (auto r : probe->getAvailableSampleRates()) + if (ratesMatch(r, sr)) return true; + return false; +} + +DeviceOptions DeviceSetup::probeDual(const juce::String& inputTypeName, + const juce::String& inputName, + const juce::String& outputTypeName, + const juce::String& outputName) +{ + DeviceOptions options; + options.inputType = inputTypeName; + options.outputType = outputTypeName.isEmpty() ? inputTypeName : outputTypeName; + options.type = options.inputType; // legacy alias + + // Resolve each side from its own manager so probe stays consistent with + // applySplit()/setOutputDeviceType(), which mutate the manager that owns + // the side they're configuring. Using the input manager for the output + // lookup would silently fall back to whatever input has scanned, which + // can miss output-only backends. + auto findType = [](juce::AudioDeviceManager& manager, + const juce::String& wanted) -> juce::AudioIODeviceType* { + juce::AudioIODeviceType* match = nullptr; + for (auto* type : manager.getAvailableDeviceTypes()) + { + if ((wanted.isNotEmpty() && type->getTypeName() == wanted) + || (wanted.isEmpty() && match == nullptr)) + { + match = type; + if (wanted.isNotEmpty()) break; + } + } + return match; + }; + + auto* inputType = findType(inMgr, options.inputType); + + // Match setAudioDevices's resolution: when the caller didn't specify + // an output type, default it to the SAME type the input side resolved + // to (using the type's name, looked up in the output manager). Without + // this, an empty `options.outputType` would let findType pick whatever + // the output manager enumerates first — potentially a different backend + // than the input manager picked from the empty string, which then + // disagrees with the apply path's duplex classification. + juce::String effectiveOutputTypeName = options.outputType; + if (effectiveOutputTypeName.isEmpty() && inputType != nullptr) + effectiveOutputTypeName = inputType->getTypeName(); + auto* outputType = findType(outMgr, effectiveOutputTypeName); + + if (inputType == nullptr) + { + options.error = "Input device type not found"; + options.compatible = false; + return options; + } + if (outputType == nullptr) + { + options.error = "Output device type not found"; + options.compatible = false; + return options; + } + + try + { + options.inputType = inputType->getTypeName(); + options.outputType = outputType->getTypeName(); + options.type = options.inputType; + + options.input = inputName; + options.output = outputName; + + // For probing we still need a concrete device to instantiate. + // Resolve empty names to first-enumerated ONLY for the probe-device + // creation below — DON'T write back into options.input/options.output; + // those flow to the UI and the apply path, which treat empty as + // "OS default" per side. + const juce::String probeInputName = resolveDeviceName(inputType, true, options.input); + const juce::String probeOutputName = resolveDeviceName(outputType, false, options.output); + + // Probe the SAME way setAudioDevices() will actually apply, or the + // startup auto-apply mis-fires: init() fail-closes on this probe's + // `compatible` verdict, so if the probe measures a combined duplex device + // but apply then opens split (or vice-versa), the verdict describes a + // config that won't be the one used — the classic symptom being "no audio + // until I press Apply". Duplex is only attempted for the SAME physical + // endpoint (a true single-clock device); two different endpoints of the + // same backend (USB cable in + separate speakers out) are two clocks and + // go split. Mirror setAudioDevices()'s sameEndpointIntent exactly. + bool isDuplex = (options.inputType == options.outputType) + && (options.input == options.output); + + if (isDuplex) + { + std::unique_ptr dev( + inputType->createDevice(probeOutputName, probeInputName)); + if (dev) + { + options.inputChannels = dev->getInputChannelNames(); + options.outputChannels = dev->getOutputChannelNames(); + for (auto rate : dev->getAvailableSampleRates()) + options.sampleRates.addIfNotAlreadyThere(rate); + for (auto size : dev->getAvailableBufferSizes()) + options.bufferSizes.addIfNotAlreadyThere(size); + } + else + { + isDuplex = false; + } + } + if (!isDuplex) + { + std::unique_ptr inDev( + inputType->createDevice({}, probeInputName)); + std::unique_ptr outDev( + outputType->createDevice(probeOutputName, {})); + if (!inDev || !outDev) + { + options.error = "Could not create dual probe devices"; + options.compatible = false; + return options; + } + + options.inputChannels = inDev->getInputChannelNames(); + options.outputChannels = outDev->getOutputChannelNames(); + + // Tolerance covers backends that report fractional drift around + // the nominal rate — ratesMatch is the same <= 0.5 the apply-side + // rateSupportedBy check uses, so the probe can't reject a + // boundary case the apply would accept (or vice versa). + const auto inRates = inDev->getAvailableSampleRates(); + const auto outRates = outDev->getAvailableSampleRates(); + for (auto r : inRates) + { + for (auto r2 : outRates) + { + if (ratesMatch(r, r2)) + { + // Midpoint-rounded clean nominal, fail-closed when the + // rounded value falls outside tolerance of either side + // — see nominalRateCandidate (RateMatch.h). + double candidate = 0.0; + if (nominalRateCandidate(r, r2, candidate)) + options.sampleRates.addIfNotAlreadyThere(candidate); + break; + } + } + } + if (options.sampleRates.isEmpty()) + { + options.error = "Input and output devices share no common sample rate"; + options.compatible = false; + } + + // Split mode opens both sides with the same bufferSize, so the + // UI should only see sizes the intersection of both devices + // supports — a union would let the user pick a value that + // predictably fails at apply time on one side. + const auto inBufs = inDev->getAvailableBufferSizes(); + const auto outBufs = outDev->getAvailableBufferSizes(); + for (auto b : inBufs) + { + for (auto b2 : outBufs) + { + if (b == b2) + { + options.bufferSizes.addIfNotAlreadyThere(b); + break; + } + } + } + // An empty intersection means there's no buffer size both sides + // accept; setting compatible=false stops the UI from re-enabling + // Apply against a guaranteed-fail config. + if (options.bufferSizes.isEmpty() && options.error.isEmpty()) + { + options.error = "Input and output devices share no common buffer size"; + options.compatible = false; + } + } + + fprintf(stderr, "[AudioEngine] Probed device options: inType='%s' outType='%s' in='%s' out='%s' " + "duplex=%d inputs=%d outputs=%d rates=%d buffers=%d compatible=%d\n", + options.inputType.toRawUTF8(), options.outputType.toRawUTF8(), + options.input.toRawUTF8(), options.output.toRawUTF8(), + (int) isDuplex, options.inputChannels.size(), options.outputChannels.size(), + options.sampleRates.size(), options.bufferSizes.size(), (int) options.compatible); + } + catch (const std::exception& e) + { + options.error = e.what(); + options.compatible = false; + } + catch (...) + { + options.error = "Probe failed"; + options.compatible = false; + } + + return options; +} + +juce::String DeviceSetup::applyDuplex(const juce::String& inputName, + const juce::String& outputName, + double sampleRate, int bufferSize, + SourceChain& monitorChain) +{ + juce::AudioDeviceManager::AudioDeviceSetup setup; + setup.inputDeviceName = inputName; + setup.outputDeviceName = outputName; + setup.sampleRate = sampleRate > 0 ? sampleRate : 48000.0; + setup.bufferSize = bufferSize > 0 ? bufferSize : 256; + setup.useDefaultInputChannels = inputName.isEmpty(); + setup.useDefaultOutputChannels = outputName.isEmpty(); + + // Channel masks must match too — high-numbered selectedInputChannel needs + // the expanded mask that an older session may not have opened. + if (auto* currentDevice = inMgr.getCurrentAudioDevice()) + { + try + { + juce::AudioDeviceManager::AudioDeviceSetup current; + inMgr.getAudioDeviceSetup(current); + + const int advertisedInputs = currentDevice->getInputChannelNames().size(); + juce::BigInteger expectedInputs; + expectedInputs.setRange(0, advertisedInputs > 0 ? advertisedInputs : 2, true); + + const int advertisedOutputs = currentDevice->getOutputChannelNames().size(); + juce::BigInteger expectedOutputs; + expectedOutputs.setRange(0, juce::jmin(advertisedOutputs > 0 ? advertisedOutputs : 2, 2), true); + + if (current.inputDeviceName == setup.inputDeviceName + && current.outputDeviceName == setup.outputDeviceName + && current.sampleRate == setup.sampleRate + && current.bufferSize == setup.bufferSize + && current.useDefaultInputChannels == setup.useDefaultInputChannels + && current.useDefaultOutputChannels == setup.useDefaultOutputChannels + && current.inputChannels == expectedInputs + && current.outputChannels == expectedOutputs + && state.duplexMode.load(std::memory_order_relaxed)) + { + fprintf(stderr, "[AudioEngine] Duplex device already configured with same settings, skipping\n"); + return {}; + } + } + catch (const std::exception& e) + { + fprintf(stderr, "[AudioEngine] Current device channel check failed: %s\n", e.what()); + } + catch (...) + { + fprintf(stderr, "[AudioEngine] Current device channel check failed (unknown)\n"); + } + } + + // ALSA deadlocks on reconfigure unless we fully close first. WASAPI + // reconfigures in place and is much slower if closed. +#if JUCE_LINUX + juce::String currentTypeName; + if (auto* currentType = inMgr.getCurrentDeviceTypeObject()) + currentTypeName = currentType->getTypeName(); + if (inMgr.getCurrentAudioDevice() != nullptr) + { + try { + inMgr.closeAudioDevice(); + fprintf(stderr, "[AudioEngine] Closed device for reconfiguration\n"); + if (currentTypeName.isNotEmpty()) + inMgr.setCurrentAudioDeviceType(currentTypeName, true); + } catch (...) { + fprintf(stderr, "[AudioEngine] closeAudioDevice crashed, continuing\n"); + } + } +#endif + + int inputChannelCount = 0; + int outputChannelCount = 0; + if (auto* type = inMgr.getCurrentDeviceTypeObject()) + { + try + { + if (auto probe = std::unique_ptr(type->createDevice(outputName, inputName))) + { + inputChannelCount = probe->getInputChannelNames().size(); + outputChannelCount = probe->getOutputChannelNames().size(); + } + } + catch (const std::exception& e) + { + fprintf(stderr, "[AudioEngine] Channel probe failed: %s\n", e.what()); + } + catch (...) + { + fprintf(stderr, "[AudioEngine] Channel probe failed (unknown)\n"); + } + } + if (inputChannelCount <= 0) inputChannelCount = 2; + if (outputChannelCount <= 0) outputChannelCount = 2; + + setup.inputChannels.setRange(0, inputChannelCount, true); + setup.outputChannels.setRange(0, juce::jmin(outputChannelCount, 2), true); + + juce::String result; + try { + result = inMgr.setAudioDeviceSetup(setup, true); + } catch (...) { + return "setAudioDeviceSetup threw"; + } + if (result.isNotEmpty()) + { + fprintf(stderr, "[AudioEngine] Device setup error: %s\n", result.toRawUTF8()); + try { + result = inMgr.initialiseWithDefaultDevices(2, 2); + } catch (...) { + return "fallback initialiseWithDefaultDevices threw"; + } + if (result.isNotEmpty()) + return "device setup failed: " + result; + } + + if (auto* configuredDevice = inMgr.getCurrentAudioDevice()) + { + const double sr = configuredDevice->getCurrentSampleRate(); + const int bs = configuredDevice->getCurrentBufferSizeSamples(); + state.currentSampleRate.store(sr, std::memory_order_relaxed); + state.inputBlockSize.store(bs, std::memory_order_relaxed); + state.outputBlockSize.store(bs, std::memory_order_relaxed); + + fprintf(stderr, "[AudioEngine] Duplex device configured OK. Current device: %s\n", + configuredDevice->getName().toRawUTF8()); + fprintf(stderr, "[AudioEngine] Actual device setup: sr=%.0f bs=%d (requested bs=%d)\n", + sr, bs, bufferSize); + + monitorChain.prepareMonitorChain(sr, bs); + return {}; + } + state.currentSampleRate.store(0.0, std::memory_order_relaxed); + state.inputBlockSize.store(0, std::memory_order_relaxed); + state.outputBlockSize.store(0, std::memory_order_relaxed); + monitorChain.releaseMonitorChain(); + return "no current device after setup"; +} + +DeviceConfigResult DeviceSetup::applySplit(const DeviceConfig& config, + SourceChain& monitorChain, + OutputRing& outputRing, + std::atomic& outputUnderflowCount, + std::atomic& inputOverflowCount, + juce::AudioIODeviceCallback& outputCallback, + bool& outputCallbackRegistered) +{ + DeviceConfigResult res; + res.duplex = false; + + // The split-mode output ring is fixed at kOutputRingFrames samples + // (~85ms @ 48kHz). A single callback at bufferSize > kOutputRingFrames + // would overrun the ring in one go, guaranteeing immediate + // overwrite/wrap and audible glitches. Reject those configurations up + // front — duplex still works fine since it bypasses the ring entirely. + if (config.bufferSize > kOutputRingFrames) + { + res.error = "Buffer size " + juce::String(config.bufferSize) + + " exceeds split-mode ring capacity (" + + juce::String(kOutputRingFrames) + "). Pick a smaller buffer size or use duplex."; + return res; + } + + // setCurrentAudioDeviceType can throw from JUCE backends (ASIO). + // Catch so the failure surfaces as a structured error rather than an + // exception crossing the N-API boundary. + try + { + if (auto* current = outMgr.getCurrentDeviceTypeObject()) + { + if (current->getTypeName() != config.outputType) + outMgr.setCurrentAudioDeviceType(config.outputType, true); + } + else + { + outMgr.setCurrentAudioDeviceType(config.outputType, true); + } + } + catch (...) + { + res.error = "setCurrentAudioDeviceType threw for output type '" + config.outputType + "'"; + return res; + } + + juce::AudioIODeviceType* inputType = nullptr; + juce::AudioIODeviceType* outputType = nullptr; + for (auto* t : inMgr.getAvailableDeviceTypes()) + if (t->getTypeName() == config.inputType) { inputType = t; break; } + for (auto* t : outMgr.getAvailableDeviceTypes()) + if (t->getTypeName() == config.outputType) { outputType = t; break; } + if (!inputType || !outputType) + { + res.error = "Device type not found"; + return res; + } + if (!rateSupportedBy(inputType, config.inputDevice, true, config.sampleRate) + || !rateSupportedBy(outputType, config.outputDevice, false, config.sampleRate)) + { + res.error = "Sample rate not supported by both input and output devices"; + return res; + } + + juce::AudioDeviceManager::AudioDeviceSetup inSetup; + // Resolve empty name to first-enumerated input device — matches the + // rateSupportedBy preflight above AND probeDual. Using empty + + // useDefault*Channels here would make JUCE open the OS default, which can + // differ from inputs[0] on platforms where the OS-default differs from + // JUCE's enumeration order. The probe + SR preflight + actual open all + // need to agree on the same concrete device for the apply path to behave + // consistently with what the UI showed the user. + const juce::String resolvedInputName = resolveDeviceName(inputType, true, config.inputDevice); + + inSetup.inputDeviceName = resolvedInputName; + inSetup.outputDeviceName = ""; + inSetup.sampleRate = config.sampleRate; + inSetup.bufferSize = config.bufferSize; + inSetup.useDefaultInputChannels = false; + inSetup.useDefaultOutputChannels = false; + + int inputChannelCount = 0; + { + try { + std::unique_ptr probe(inputType->createDevice({}, resolvedInputName)); + if (probe) inputChannelCount = probe->getInputChannelNames().size(); + } catch (...) {} + } + if (inputChannelCount <= 0) inputChannelCount = 2; + inSetup.inputChannels.setRange(0, inputChannelCount, true); + inSetup.outputChannels.clear(); + + // Rollback helper: on any failure path after a side has been opened, + // close both managers' devices so we don't leave the OS audio resource + // held (sometimes exclusively, e.g. ASIO) while setDevice reports a + // failure. closeAudioDevice is idempotent so unconditional calls are + // safe even when only the input or neither side opened. + auto rollbackOpenedDevices = [&]() { + // Drop any callback we already attached to the output manager — + // closeAudioDevice() does not invoke removeAudioCallback, and leaving + // outputCallbackRegistered=true would cause the next startAudio() + // to skip the re-attach (it gates on !outputCallbackRegistered), + // leaving split-mode output silent after a partial-open failure. + if (outputCallbackRegistered) + { + try { outMgr.removeAudioCallback(&outputCallback); } catch (...) {} + outputCallbackRegistered = false; + } + try { inMgr.closeAudioDevice(); } catch (...) {} + try { outMgr.closeAudioDevice(); } catch (...) {} + }; + + // Mirror applyDuplex's JUCE_LINUX close-before-reconfigure pattern: + // ALSA deadlocks if we let setAudioDeviceSetup mutate a live device. The + // device type is re-asserted afterwards so the close doesn't drop us back + // to whatever JUCE picked at startup. closeAudioDevice/setCurrentAudioDeviceType + // throwing is non-fatal — we still try the setup below and surface its error. +#if JUCE_LINUX + { + juce::String currentInputTypeName; + if (auto* currentType = inMgr.getCurrentDeviceTypeObject()) + currentInputTypeName = currentType->getTypeName(); + if (inMgr.getCurrentAudioDevice() != nullptr) + { + try { + inMgr.closeAudioDevice(); + if (currentInputTypeName.isNotEmpty()) + inMgr.setCurrentAudioDeviceType(currentInputTypeName, true); + } catch (...) { + fprintf(stderr, "[AudioEngine] split-mode input close threw, continuing\n"); + } + } + } +#endif + + juce::String inErr; + try { inErr = inMgr.setAudioDeviceSetup(inSetup, true); } + catch (...) { res.error = "input setAudioDeviceSetup threw"; rollbackOpenedDevices(); return res; } + if (inErr.isNotEmpty()) { res.error = "input setup: " + inErr; rollbackOpenedDevices(); return res; } + + auto* inDev = inMgr.getCurrentAudioDevice(); + if (!inDev) { res.error = "no input device after setup"; rollbackOpenedDevices(); return res; } + const double inSr = inDev->getCurrentSampleRate(); + const int inBs = inDev->getCurrentBufferSizeSamples(); + + // Same first-enumerated resolution on the output side — see input note + // above for why this matches the probe + SR preflight strategy. + const juce::String resolvedOutputName = resolveDeviceName(outputType, false, config.outputDevice); + + juce::AudioDeviceManager::AudioDeviceSetup outSetup; + outSetup.inputDeviceName = ""; + outSetup.outputDeviceName = resolvedOutputName; + outSetup.sampleRate = config.sampleRate; + outSetup.bufferSize = config.bufferSize; + outSetup.useDefaultInputChannels = false; + outSetup.useDefaultOutputChannels = false; + + int outputChannelCount = 0; + { + try { + std::unique_ptr probe(outputType->createDevice(resolvedOutputName, {})); + if (probe) outputChannelCount = probe->getOutputChannelNames().size(); + } catch (...) {} + } + if (outputChannelCount <= 0) outputChannelCount = 2; + outSetup.inputChannels.clear(); + outSetup.outputChannels.setRange(0, juce::jmin(outputChannelCount, 2), true); + + // Same JUCE_LINUX close-before-reconfigure as the input side above — also + // protects when split mode is re-applied with a different output device. +#if JUCE_LINUX + { + juce::String currentOutputTypeName; + if (auto* currentType = outMgr.getCurrentDeviceTypeObject()) + currentOutputTypeName = currentType->getTypeName(); + if (outMgr.getCurrentAudioDevice() != nullptr) + { + try { + outMgr.closeAudioDevice(); + if (currentOutputTypeName.isNotEmpty()) + outMgr.setCurrentAudioDeviceType(currentOutputTypeName, true); + } catch (...) { + fprintf(stderr, "[AudioEngine] split-mode output close threw, continuing\n"); + } + } + } +#endif + + juce::String outErr; + try { outErr = outMgr.setAudioDeviceSetup(outSetup, true); } + catch (...) { res.error = "output setAudioDeviceSetup threw"; rollbackOpenedDevices(); return res; } + if (outErr.isNotEmpty()) { res.error = "output setup: " + outErr; rollbackOpenedDevices(); return res; } + + auto* outDev = outMgr.getCurrentAudioDevice(); + if (!outDev) { res.error = "no output device after setup"; rollbackOpenedDevices(); return res; } + const double outSr = outDev->getCurrentSampleRate(); + const int outBs = outDev->getCurrentBufferSizeSamples(); + + if (!ratesMatch(inSr, outSr)) + { + res.error = "Input and output devices opened at different sample rates"; + rollbackOpenedDevices(); + return res; + } + + state.currentSampleRate.store(inSr, std::memory_order_relaxed); + state.inputBlockSize.store(inBs, std::memory_order_relaxed); + state.outputBlockSize.store(outBs, std::memory_order_relaxed); + + fprintf(stderr, "[AudioEngine] Split mode configured: inSr=%.0f inBs=%d outSr=%.0f outBs=%d\n", + inSr, inBs, outSr, outBs); + + outputRing.reset(); + outputUnderflowCount.store(0, std::memory_order_relaxed); + inputOverflowCount.store(0, std::memory_order_relaxed); + + monitorChain.prepareMonitorChain(inSr, inBs); + + res.ok = true; + res.sampleRate = inSr; + res.inputBlockSize = inBs; + res.outputBlockSize = outBs; + return res; +} + +void DeviceSetup::teardownSplit(OutputRing& outputRing, + juce::AudioIODeviceCallback& outputCallback, + bool& outputCallbackRegistered) +{ + // Unconditional remove — JUCE's removeAudioCallback is idempotent + // (no-op if the callback isn't registered), so we don't need the + // outputCallbackRegistered guard here. This makes teardown robust + // against a stale flag left over from a previous failed split setup. + outMgr.removeAudioCallback(&outputCallback); + outputCallbackRegistered = false; + try { outMgr.closeAudioDevice(); } + catch (...) { fprintf(stderr, "[AudioEngine] teardownSplitMode: output close threw\n"); } + + outputRing.reset(); +} + +} // namespace slopsmith diff --git a/src/audio/engine/DeviceSetup.h b/src/audio/engine/DeviceSetup.h new file mode 100644 index 0000000..3921e42 --- /dev/null +++ b/src/audio/engine/DeviceSetup.h @@ -0,0 +1,124 @@ +#pragma once + +// DeviceSetup — probe/apply/teardown for duplex + split device configs (TLC +// plan phase 4 / §2.7). Moved verbatim from AudioEngine; owns no lifetime — +// it holds references to the engine's two AudioDeviceManagers and its +// EngineState, and the engine-owned collaborators a specific operation needs +// (monitor chain, split output ring, output callback registration) are passed +// by reference at the call. setAudioDevices stays on the AudioEngine facade +// as the orchestrator (stop → resolve → duplex-or-split → restart). +// +// The rate-tolerance (`<= 0.5`, probe/preflight/verify), midpoint-rounding, +// and empty-name→first-enumerated resolution logic that used to live in three +// hand-synced copies is extracted into the shared helpers at the bottom — +// the deep-read §7 dedupe, landed structurally by this move. + +#include "EngineState.h" +#include "PackedStereoRing.h" +#include "RateMatch.h" +#include "../SourceChain.h" + +#include + +namespace slopsmith { + +// Public device-config shapes — aliased back as AudioEngine::DeviceOptions +// etc., so the NodeAddon surface is unchanged. +struct DeviceOptions +{ + juce::String type; // legacy alias = inputType + juce::String inputType; + juce::String outputType; + juce::String input; + juce::String output; + juce::StringArray inputChannels; + juce::StringArray outputChannels; + juce::Array sampleRates; // intersection when dual-type + juce::Array bufferSizes; + bool compatible = true; // false when types share no usable sample rate + juce::String error; +}; + +struct DeviceConfig +{ + juce::String inputType; + juce::String inputDevice; + juce::String outputType; + juce::String outputDevice; + double sampleRate = 48000.0; + int bufferSize = 256; +}; + +struct DeviceConfigResult +{ + bool ok = false; + juce::String error; + double sampleRate = 0.0; + int inputBlockSize = 0; + int outputBlockSize = 0; + bool duplex = true; +}; + +class DeviceSetup +{ +public: + // Must equal the engine's split-mode ring capacity. + static constexpr int kOutputRingFrames = 4096; + using OutputRing = PackedStereoRing; + + DeviceSetup(juce::AudioDeviceManager& inputManager, + juce::AudioDeviceManager& outputManager, + EngineState& engineState) + : inMgr(inputManager), outMgr(outputManager), state(engineState) {} + + // Probe what a (typeName, deviceName) pair supports — duplex when input + // and output are the same endpoint, else the dual/split intersection. + DeviceOptions probeDual(const juce::String& inputTypeName, + const juce::String& inputName, + const juce::String& outputTypeName, + const juce::String& outputName); + + // Open the combined (single-clock) duplex device on the input manager. + // Empty error string = success; on success stores the achieved format + // into EngineState and prepares `monitorChain`. + juce::String applyDuplex(const juce::String& inputName, + const juce::String& outputName, + double sampleRate, int bufferSize, + SourceChain& monitorChain); + + // Open input-only + output-only devices at a shared nominal rate. On + // success stores the achieved format, resets the split ring + counters, + // and prepares `monitorChain`. `outputCallback`/`outputCallbackRegistered` + // are needed by the partial-open rollback (a failure after the callback + // was attached must detach it, or the next startAudio() skips re-attach). + DeviceConfigResult applySplit(const DeviceConfig& config, + SourceChain& monitorChain, + OutputRing& outputRing, + std::atomic& outputUnderflowCount, + std::atomic& inputOverflowCount, + juce::AudioIODeviceCallback& outputCallback, + bool& outputCallbackRegistered); + + // Detach the output callback + close the output device + drain the ring. + void teardownSplit(OutputRing& outputRing, + juce::AudioIODeviceCallback& outputCallback, + bool& outputCallbackRegistered); + + // ── Shared helpers (the three previously hand-synced copies) ────────── + // ratesMatch / nominalRateCandidate live in RateMatch.h (JUCE-free, unit- + // tested); the device-name resolution helpers below need JUCE types. + // Empty device name → first-enumerated for that type/direction (probe, + // SR preflight, and split open must all check the SAME concrete device). + static juce::String resolveDeviceName(juce::AudioIODeviceType* t, + bool isInput, const juce::String& name); + // Whether `dev` (resolved) supports `sr` within tolerance. + static bool rateSupportedBy(juce::AudioIODeviceType* t, const juce::String& dev, + bool isInput, double sr); + +private: + juce::AudioDeviceManager& inMgr; + juce::AudioDeviceManager& outMgr; + EngineState& state; +}; + +} // namespace slopsmith diff --git a/src/audio/engine/RateMatch.h b/src/audio/engine/RateMatch.h new file mode 100644 index 0000000..7e11406 --- /dev/null +++ b/src/audio/engine/RateMatch.h @@ -0,0 +1,33 @@ +#pragma once + +// Pure sample-rate matching math shared by probe, preflight, and post-open +// verify (TLC phase 4, deep-read §7 — previously three hand-synced copies in +// AudioEngine.cpp). JUCE-free so tests/engine_units can pin the boundary +// cases the old sites narrated in comments. + +#include + +namespace slopsmith { + +// <= 0.5 (not <): a backend reporting 47999.5 against a 48000 nominal has +// |diff| = 0.5 exactly and must pass at every stage the probe accepted it. +inline bool ratesMatch(double a, double b) noexcept +{ + return std::abs(a - b) <= 0.5; +} + +// Given a matching in/out rate pair, the clean nominal the probe surfaces to +// the UI (backends sometimes report fractional near-48000 rates; the raw +// value would fail the apply-side setAudioDeviceSetup, which expects an exact +// supported nominal). Returns false when the rounded midpoint falls outside +// tolerance of either side — a matched pair like 48000.4/48000.6 passes the +// |r-r2| check but round(48000.5)=48000/48001 can sit 0.6 from one side; the +// probe stays fail-closed on those. +inline bool nominalRateCandidate(double r, double r2, double& candidate) noexcept +{ + if (!ratesMatch(r, r2)) return false; + candidate = std::round((r + r2) * 0.5); + return ratesMatch(r, candidate) && ratesMatch(r2, candidate); +} + +} // namespace slopsmith diff --git a/tests/engine_units/CMakeLists.txt b/tests/engine_units/CMakeLists.txt index 6eca508..b838a8f 100644 --- a/tests/engine_units/CMakeLists.txt +++ b/tests/engine_units/CMakeLists.txt @@ -20,3 +20,7 @@ add_test(NAME engine_state COMMAND engine_state_test) add_executable(renderer_bus_test renderer_bus_test.cpp) target_compile_features(renderer_bus_test PRIVATE cxx_std_20) add_test(NAME renderer_bus COMMAND renderer_bus_test) + +add_executable(rate_match_test rate_match_test.cpp) +target_compile_features(rate_match_test PRIVATE cxx_std_17) +add_test(NAME rate_match COMMAND rate_match_test) diff --git a/tests/engine_units/rate_match_test.cpp b/tests/engine_units/rate_match_test.cpp new file mode 100644 index 0000000..e9c9293 --- /dev/null +++ b/tests/engine_units/rate_match_test.cpp @@ -0,0 +1,46 @@ +// Phase 4 unit tests (docs/audio-engine-tlc.md §5): the rate-tolerance and +// midpoint-rounding boundary cases the three previously hand-synced sites in +// AudioEngine.cpp narrated in comments, now pinned against the one shared +// implementation in engine/RateMatch.h. + +#include "../../src/audio/engine/RateMatch.h" + +#include +#include + +using slopsmith::ratesMatch; +using slopsmith::nominalRateCandidate; + +int main() +{ + // Tolerance is <= 0.5 (not <): a backend reporting 47999.5 against a + // 48000 nominal sits exactly on the boundary and MUST pass — the probe + // accepted it, so preflight and post-open verify must too. + assert(ratesMatch(47999.5, 48000.0)); + assert(ratesMatch(48000.0, 47999.5)); + assert(ratesMatch(48000.0, 48000.0)); + assert(!ratesMatch(47999.4, 48000.0)); // 0.6 apart → reject + assert(!ratesMatch(44100.0, 48000.0)); + + double c = 0.0; + + // Exact pair → exact nominal. + assert(nominalRateCandidate(48000.0, 48000.0, c) && c == 48000.0); + + // Fractional drift on both sides rounds to the clean nominal. + assert(nominalRateCandidate(47999.5, 48000.0, c) && c == 48000.0); + assert(nominalRateCandidate(48000.4, 48000.1, c) && c == 48000.0); + + // Fail-closed midpoint case from the original comment: 48000.4/48000.6 + // passes the pair check (diff 0.2) but rounds to 48001 (midpoint 48000.5 + // rounds up), which is 0.6 from 48000.4 — outside tolerance of one side, + // so no candidate is surfaced. + const bool ok = nominalRateCandidate(48000.4, 48000.6, c); + assert(!ok && "midpoint-rounding must stay fail-closed"); + + // Non-matching pair → no candidate at all. + assert(!nominalRateCandidate(44100.0, 48000.0, c)); + + std::puts("rate_match: all cases passed"); + return 0; +} From 827f02b4b45364a141a5928ebd3e710d545c1068 Mon Sep 17 00:00:00 2001 From: OmikronApex Date: Tue, 14 Jul 2026 01:23:55 +0200 Subject: [PATCH 10/28] refactor(audio): extract SourcePool (phase 5a) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Moves the fixed SourceChain pool, add/remove/reclaim lifecycle, the per-deviceKey callbacksInFlight quiescence handshake, deferred-release parking, and mixSourcesForDevice verbatim into src/audio/engine/SourcePool.{h,cpp}. Device callbacks now hold an RAII CallbackGuard (identical increment/decrement points — no early returns existed between them) and call pool.mixForDevice(); the device hooks use prepare/releaseDeviceSources and withDeviceSources, preserving each site's original locking (the primary about-to-start prepare loop stays deliberately lockless, as before). addSource's extra-device resolution (registry reads) stays on the engine facade, which passes resolved readiness/format/latency into pool.addResolved() — the pool has no dependency on the InputDeviceSlot registry, which phase 5b extracts next. Threaded storm unit test deferred (SourceChain is JUCE-linked; TSAN unavailable on MSVC) — multi-source.test.js covers the pool through the addon and is green against the rebuilt binary. Co-Authored-By: Claude Fable 5 --- src/audio/AudioEngine.cpp | 299 ++++---------------------------- src/audio/AudioEngine.h | 67 ++----- src/audio/CMakeLists.txt | 1 + src/audio/engine/SourcePool.cpp | 225 ++++++++++++++++++++++++ src/audio/engine/SourcePool.h | 148 ++++++++++++++++ 5 files changed, 427 insertions(+), 313 deletions(-) create mode 100644 src/audio/engine/SourcePool.cpp create mode 100644 src/audio/engine/SourcePool.h diff --git a/src/audio/AudioEngine.cpp b/src/audio/AudioEngine.cpp index 2be1ca2..595e0b3 100644 --- a/src/audio/AudioEngine.cpp +++ b/src/audio/AudioEngine.cpp @@ -34,15 +34,7 @@ AudioEngine::AudioEngine() // always has a live thread to pull decoded audio on. It sleeps while idle and // costs nothing until a track is loaded. - // Construct the full source pool up front so addSource/removeSource never - // reassign a pointer the audio thread reads — they only flip `active`. Each - // chain reads the engine's audioRunning / currentSampleRate atomics by - // reference (both already constructed as members before this body runs). - // sources[0] is the permanent default input, active from the start; the rest - // are inactive (no threads — NoteVerifier's worker only starts in prepare()). - for (int i = 0; i < kMaxSources; ++i) - sources[(size_t) i] = std::make_unique(i, audioRunning, currentSampleRate); - sources[0]->setActive(true); + // The source pool (chains, quiescence handshake) lives on SourcePool now. // Phase 2: tag each additional-input slot with its identity so its JUCE // callback can route back to the engine. deviceKey = slot index + 1 (0 is the @@ -675,9 +667,7 @@ void AudioEngine::resetPeaks() { // Input peak is per-source — clear EVERY active source (getSourceLevels() exposes // each one's peak), not just source 0, or extra-device peaks latch forever. - for (auto& src : sources) - if (src->isActive()) - src->resetInputPeak(); + pool.forEachActive([](SourceChain& s) { s.resetInputPeak(); }); outputPeak.store(0.0f); } @@ -705,121 +695,26 @@ int AudioEngine::addSource(int inputChannel, int deviceKey) return -1; } - std::lock_guard lock(sourcesMutex); - reclaimPendingReleases(); // free up any slot whose release was deferred - - // Find a free pooled slot (slot 0 is the permanent default). Skip a slot whose - // release is still pending — its chain/worker hasn't been torn down yet, so - // re-preparing it would double-start the verifier thread. - int slot = -1; - for (int i = 1; i < kMaxSources; ++i) - if (! sources[(size_t) i]->isActive() && ! pendingRelease[(size_t) i]) { slot = i; break; } - if (slot < 0) - return -1; // pool full - - SourceChain& src = *sources[(size_t) slot]; - src.setInputChannel(inputChannel); - src.setDeviceKey(deviceKey); - // Inherit the bound device's capture-latency correction (0 for the primary). - // The device usually started before the source was created (the user picks the - // device, then enables detect), so its delta is already known. - if (deviceKey >= 1 && deviceKey <= kMaxExtraInputDevices) - src.setVerifierAutoOffset(extraInputs[(size_t) (deviceKey - 1)].latencyDeltaSec.load(std::memory_order_relaxed)); - else - src.setVerifierAutoOffset(0.0); - // Clear any MANUAL offset left on this pooled chain by a previous player — a - // freshly added source starts with no user fine-tune (the renderer re-applies - // its own via setSourceVerifierOffset). releaseResources() doesn't touch it. - src.setVerifierUserOffset(0.0); - // Likewise clear stale meters so this source doesn't briefly report the previous - // player's level/peak through getSourceLevels() until fresh audio arrives. - src.resetInputMeters(); - - // Prepare fully BEFORE making it visible to the audio thread, so the first - // callback that observes active==true sees a ready chain + rings. When audio - // isn't running yet, audioDeviceAboutToStart (primary) / extraInputAboutToStart - // (extra) prepares it later. An EXTRA-device source must be prepared with ITS - // device's sample rate / block size, not the primary's — the two can differ. + // Resolve device readiness/format/latency for the pool (the extra-device + // registry is engine-owned; the pool takes resolved values). bool deviceReady = audioRunning.load(std::memory_order_relaxed); double sr = currentSampleRate.load(std::memory_order_relaxed); int bs = inputBlockSize.load(std::memory_order_relaxed); + double latencyDeltaSec = 0.0; if (deviceKey >= 1 && deviceKey <= kMaxExtraInputDevices) { const InputDeviceSlot& es = extraInputs[(size_t) (deviceKey - 1)]; deviceReady = es.active.load(std::memory_order_acquire); sr = es.sampleRate.load(std::memory_order_relaxed); bs = es.blockSize.load(std::memory_order_relaxed); + latencyDeltaSec = es.latencyDeltaSec.load(std::memory_order_relaxed); } - if (deviceReady && sr > 0.0 && bs > 0) - src.prepare(sr, bs); - src.setActive(true); // release-store: now picked up by the audio callback - return slot; + return pool.addResolved(inputChannel, deviceKey, deviceReady, sr, bs, latencyDeltaSec); } bool AudioEngine::removeSource(int id) { - if (id <= 0 || id >= kMaxSources) - return false; // 0 is permanent; out-of-range rejected - - std::lock_guard lock(sourcesMutex); - reclaimPendingReleases(); // opportunistically reclaim earlier deferrals - - SourceChain& src = *sources[(size_t) id]; - if (! src.isActive()) - return false; - - // Hide it from the audio callback first; subsequent blocks snapshot active - // once and skip it. It is logically removed from here on, regardless of when - // its resources are reclaimed. - src.setActive(false); - - // Reclaim now if we can confirm THIS SOURCE's device callback is not executing. - // Only the callback for the source's own deviceKey can touch it; that counter is - // decremented at the callback's real exit (release store), so observing 0 - // (acquire) proves it is not inside processBlock right now; any callback that - // starts afterwards snapshots active and skips this (now-inactive) source — so - // releasing cannot race the audio thread. Keying on the source's deviceKey (not a - // global all-callbacks-idle check) is what lets removals reclaim during steady - // multi-device playback, when callbacks on independent clocks are never all idle - // at once. Bounded so a wedged device can't hang this thread. - const size_t dk = (size_t) src.getDeviceKey(); - for (int spins = 0; spins < 200; ++spins) // ~200 ms cap - { - if (callbacksInFlight[dk].load(std::memory_order_acquire) == 0) - { - src.releaseResources(); // stops its threads + releases its chain - return true; - } - std::this_thread::sleep_for(std::chrono::milliseconds(1)); - } - - // A callback stayed wedged in-flight past the wait (a >200 ms block would be a - // catastrophic stall). Do NOT force a release that could race it — DEFER it. - // The source is already inactive so no future callback touches it; reclaim it - // later (next add/removeSource, or audioDeviceStopped) when the body is quiet. - pendingRelease[(size_t) id] = true; - return true; -} - -void AudioEngine::reclaimPendingReleases() -{ - // Caller holds sourcesMutex. A deferred source is inactive (future callbacks skip - // it); releasing it is safe once the callback for ITS deviceKey is not in a body. - // We key per-deviceKey (decremented by each callback at its real exit) so a - // pending release frees as soon as its OWN device is quiescent — not only when - // every device callback happens to be idle simultaneously (which, on independent - // clocks during steady multi-device playback, may never occur and would strand - // the slot until full stop). On the audioDeviceStopped path the relevant callback - // already left its count at 0. - for (int i = 1; i < kMaxSources; ++i) - { - if (! pendingRelease[(size_t) i]) continue; - const size_t dk = (size_t) sources[(size_t) i]->getDeviceKey(); - if (callbacksInFlight[dk].load(std::memory_order_acquire) != 0) - continue; // this source's device is mid-body — try again later - sources[(size_t) i]->releaseResources(); - pendingRelease[(size_t) i] = false; - } + return pool.remove(id); } // Lifetime/threading of getSource() + the NodeAddon *Source* methods: @@ -839,10 +734,7 @@ void AudioEngine::reclaimPendingReleases() // new race class versus the original single-source engine. SourceChain* AudioEngine::getSource(int id) { - if (id < 0 || id >= kMaxSources) - return nullptr; - SourceChain& src = *sources[(size_t) id]; - return (id == 0 || src.isActive()) ? &src : nullptr; + return pool.get(id); } void AudioEngine::setMlNoteDetectionEnabled(bool e) @@ -851,24 +743,14 @@ void AudioEngine::setMlNoteDetectionEnabled(bool e) // activated later inherits the current arm state instead of silently // staying dormant. Each MlNoteDetector::setEnabled is a cheap atomic + a // cold-state clear on a real transition. - for (int i = 0; i < kMaxSources; ++i) - sources[(size_t) i]->getMlNoteDetector().setEnabled(e); + pool.forEach([e](SourceChain& s) { s.getMlNoteDetector().setEnabled(e); }); } std::vector AudioEngine::listSources() const { std::vector out; - for (int i = 0; i < kMaxSources; ++i) - { - const SourceChain& src = *sources[(size_t) i]; - if (! src.isActive()) continue; - SourceInfo info; - info.id = src.getId(); - info.inputChannel = src.getInputChannel(); - info.deviceKey = src.getDeviceKey(); - info.active = true; - out.push_back(info); - } + for (const auto& i : pool.list()) + out.push_back({ i.id, i.inputChannel, i.deviceKey, i.active }); return out; } @@ -934,9 +816,7 @@ void AudioEngine::audioDeviceAboutToStart(juce::AudioIODevice* device) // device sources (deviceKey > 0) are prepared by their own extraInputAboutToStart // with THAT device's format — a primary restart must not clobber them with the // primary's sample rate / block size (they run on a different hardware clock). - for (auto& src : sources) - if (src->isActive() && src->getDeviceKey() == 0) - src->prepare(sr, bs); + pool.forEachActive([&](SourceChain& s) { if (s.getDeviceKey() == 0) s.prepare(sr, bs); }); // Split mode preps the backing stretcher in audioOutputAboutToStart // instead — that callback owns the device the backing audio actually @@ -957,17 +837,9 @@ void AudioEngine::audioDeviceStopped() if (slopsmith_vst_trace::isEnabled()) fprintf(stderr, "[diag] audioDeviceStopped (audioRunning cleared; callbacks stay attached for JUCE auto-restart)\n"); audioRunning.store(false, std::memory_order_relaxed); - { - std::lock_guard lock(sourcesMutex); - // Release each ACTIVE primary-device source's chain and zero its rings. - // Inactive pooled chains were never prepared. - for (auto& src : sources) - if (src->isActive() && src->getDeviceKey() == 0) - src->releaseResources(); - // Reclaim deferred removals only when ALL callback bodies are quiescent - // (an extra-device callback could still be mid-block). - reclaimPendingReleases(); - } + // Release each ACTIVE primary-device source's chain and zero its rings; + // retries deferred removals now that the primary body is quiescent. + pool.releaseDeviceSources(0, false, false); outputRing.resetIndices(); currentBackingLevel.store(0.0f); @@ -1078,7 +950,8 @@ void AudioEngine::audioDeviceIOCallbackWithContext( // Publish that the callback body is executing so removeSource() and deferred- // release reclamation know when no source is being processed (the body is // quiescent) and a removed source can be safely released. Index 0 = primary. - const int inFlightBefore = callbacksInFlight[0].fetch_add(1, std::memory_order_acq_rel); + const slopsmith::SourcePool::CallbackGuard cbGuard(pool, 0); + const int inFlightBefore = cbGuard.previousInFlight; // DIAG: two primary callback bodies at once = the input callback is // registered twice on the device manager (the half-speed/garble bug) or a @@ -1138,8 +1011,8 @@ void AudioEngine::audioDeviceIOCallbackWithContext( // clock — never here. With no extra device every source is deviceKey 0, so // this is the single-device path unchanged. See mixSourcesForDevice for the // fast-path / multi-source rationale; it is shared with each extra callback. - mixSourcesForDevice(0, inputData, numInputChannels, buffer, sourceMonitorScratch, - effectiveOutputChannels, numSamples); + pool.mixForDevice(0, inputData, numInputChannels, buffer, sourceMonitorScratch, + effectiveOutputChannels, numSamples); // Duplex mixes backing + applies output gain + meters here. // Split defers all three to OutputCallback (output device's clock). @@ -1225,65 +1098,7 @@ void AudioEngine::audioDeviceIOCallbackWithContext( outputRing.push(buffer.getReadPointer(0), buffer.getReadPointer(1), numSamples); } - // Body done — this callback is no longer processing a source. Pairs with - // removeSource()/reclaimPendingReleases() acquire loads. Index 0 = primary. - callbacksInFlight[0].fetch_sub(1, std::memory_order_acq_rel); -} - -int AudioEngine::mixSourcesForDevice(int deviceKey, const float* const* inputData, int numInputChannels, - juce::AudioBuffer& mixBuf, juce::AudioBuffer& monitorScratch, - int effectiveOutputChannels, int numSamples) -{ - // Snapshot each source's active flag ONCE so the count and the process/mix - // passes are consistent within this block — a concurrent add/removeSource - // flipping a flag between two reads must not change which branch runs. - // removeSource waits for all callback bodies to drain before releasing, so a - // source snapshotted active here is safe even if deactivated an instant later. - bool act[kMaxSources]; - int firstActive = -1, activeCount = 0; - for (int i = 0; i < kMaxSources; ++i) - { - act[i] = sources[(size_t) i]->isActive() - && sources[(size_t) i]->getDeviceKey() == deviceKey; - if (act[i]) { ++activeCount; if (firstActive < 0) firstActive = i; } - } - - if (activeCount == 0) - { - // No source on this device → silence. An extra device with no bound source - // contributes nothing to the output sum. The primary always has sources[0] - // (deviceKey 0, active from construction), so it never reaches this branch. - for (int ch = 0; ch < effectiveOutputChannels; ++ch) - mixBuf.clear(ch, 0, numSamples); - return 0; - } - - if (activeCount == 1) - { - // Fast path — exactly one source: process in place on mixBuf, byte- - // identical to the single-pipeline engine (channel select / mono mix + - // input gain, metering, ML + ring feed, gate, YIN, tone chain, monitor). - sources[(size_t) firstActive] - ->processBlock(inputData, numInputChannels, mixBuf, effectiveOutputChannels, numSamples); - return 1; - } - - // Multi-source: each renders its own 2-channel monitor into monitorScratch - // (each builds its mono from its bound channel + feeds its own rings / - // detectors / verifier), summed to STEREO (0/1). A >2-channel output keeps - // channels 2+ silent in multi-source mode (the fast path still broadcasts). - for (int ch = 0; ch < effectiveOutputChannels; ++ch) - mixBuf.clear(ch, 0, numSamples); - const int mixCh = juce::jmin(effectiveOutputChannels, 2); - const int n = juce::jmin(numSamples, monitorScratch.getNumSamples()); - for (int i = 0; i < kMaxSources; ++i) - { - if (! act[i]) continue; - sources[(size_t) i]->processBlock(inputData, numInputChannels, monitorScratch, 2, n); - for (int ch = 0; ch < mixCh; ++ch) - mixBuf.addFrom(ch, 0, monitorScratch, ch, 0, n); - } - return activeCount; + // Body done (CallbackGuard dtor) — pairs with remove()/reclaim acquire loads. } void AudioEngine::extraInputCallback(int slot, const float* const* inputData, int numInputChannels, int numSamples) @@ -1292,7 +1107,7 @@ void AudioEngine::extraInputCallback(int slot, const float* const* inputData, in InputDeviceSlot& s = extraInputs[(size_t) slot]; if (! s.active.load(std::memory_order_acquire)) return; - callbacksInFlight[(size_t) s.deviceKey].fetch_add(1, std::memory_order_acq_rel); + const slopsmith::SourcePool::CallbackGuard cbGuard(pool, s.deviceKey); // Clamp to the per-slot scratch sized in extraInputAboutToStart so the hot // loop never allocates if a reconfig race delivers a larger block. @@ -1301,10 +1116,8 @@ void AudioEngine::extraInputCallback(int slot, const float* const* inputData, in juce::AudioBuffer mix; mix.setDataToReferTo(s.fanScratch.getArrayOfWritePointers(), 2, numSamples); - mixSourcesForDevice(s.deviceKey, inputData, numInputChannels, mix, s.monitorScratch, 2, numSamples); + pool.mixForDevice(s.deviceKey, inputData, numInputChannels, mix, s.monitorScratch, 2, numSamples); s.ring.push(mix.getReadPointer(0), mix.getReadPointer(1), numSamples); - - callbacksInFlight[(size_t) s.deviceKey].fetch_sub(1, std::memory_order_acq_rel); } void AudioEngine::extraInputAboutToStart(int slot, juce::AudioIODevice* device) @@ -1348,15 +1161,7 @@ void AudioEngine::extraInputAboutToStart(int slot, juce::AudioIODevice* device) // Prepare each source bound to this device so its verifier/detectors run, and // apply the latency correction. - { - std::lock_guard lock(sourcesMutex); - for (auto& src : sources) - if (src->isActive() && src->getDeviceKey() == s.deviceKey) - { - src->prepare(sr, bs); - src->setVerifierAutoOffset(deltaSec); - } - } + pool.prepareDeviceSources(s.deviceKey, sr, bs, deltaSec, true); s.active.store(true, std::memory_order_release); } @@ -1368,34 +1173,13 @@ void AudioEngine::extraInputStopped(int slot) // slot's body is quiescent. Hide it from the output sum, then release ITS // sources (no other callback touches them — they all filter by deviceKey). s.active.store(false, std::memory_order_release); - { - std::lock_guard lock(sourcesMutex); - // PERMANENT unbind (the user removed this device): the slot will never - // re-open, so DEACTIVATE its sources too — leaving them "active" would strand - // pooled slots no callback can ever service (a ghost detector in listSources). - // A TRANSIENT close (stopAudio/reconfigure/unplug) only releases them, so - // startAudio()'s re-open resumes them in place. Read the atomic flag (set by - // the control-thread unbind) rather than the juce::String desiredDeviceName, - // which this device-thread path must not race on. - const bool permanent = s.permanentUnbind.load(std::memory_order_acquire); - for (auto& src : sources) - if (src->isActive() && src->getDeviceKey() == s.deviceKey) - { - src->releaseResources(); - // Zero the meters so getSourceLevels() reports silence while the - // device is gone — otherwise the renderer's per-source silence gate - // treats a stopped/unplugged input as still hearing audio (the last - // non-zero level latches). releaseResources() doesn't touch them. - src->resetInputMeters(); - if (permanent) src->setActive(false); - } - // Retry any removeSource() cleanup that was deferred waiting for callbacks to - // drain. With this slot's callback now stopped, callbacksInFlight may finally - // be 0; audioDeviceStopped() (primary) might never observe that window during - // multi-device playback, so reclaim here too or a pending slot can leak until - // the next add/remove. - reclaimPendingReleases(); - } + // 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(); } @@ -1586,13 +1370,10 @@ bool AudioEngine::unbindInputDevice(int deviceKey) // pool slots and showing in listSources(). if (! closeExtraInputDevice(slot)) { - std::lock_guard lock(sourcesMutex); - for (auto& src : sources) - if (src->isActive() && src->getDeviceKey() == deviceKey) - { - src->releaseResources(); - src->setActive(false); - } + pool.withDeviceSources(deviceKey, [](SourceChain& s) { + s.releaseResources(); + s.setActive(false); + }); } return true; } @@ -1638,10 +1419,7 @@ void AudioEngine::reopenDesiredExtraInputs() { if (extraInputs[(size_t) (dk - 1)].desiredDeviceName.isEmpty()) continue; - std::lock_guard lock(sourcesMutex); - for (auto& src : sources) - if (src->isActive() && src->getDeviceKey() == dk) - src->resetInputMeters(); + pool.withDeviceSources(dk, [](SourceChain& s) { s.resetInputMeters(); }); } return; } @@ -1659,10 +1437,7 @@ void AudioEngine::reopenDesiredExtraInputs() // deactivate them so they do not linger as ghost sources stranding pool // slots. The renderer re-binds + re-adds if the device returns. s.desiredDeviceName = {}; - std::lock_guard lock(sourcesMutex); - for (auto& src : sources) - if (src->isActive() && src->getDeviceKey() == dk) - src->setActive(false); + pool.withDeviceSources(dk, [](SourceChain& src) { src.setActive(false); }); } } } diff --git a/src/audio/AudioEngine.h b/src/audio/AudioEngine.h index 0f3912b..e75266c 100644 --- a/src/audio/AudioEngine.h +++ b/src/audio/AudioEngine.h @@ -7,6 +7,7 @@ #include "engine/StreamSink.h" #include "engine/BackingPlayer.h" #include "engine/DeviceSetup.h" +#include "engine/SourcePool.h" #include "BackingLeveler.h" #include "signalsmith-stretch.h" #include @@ -174,8 +175,7 @@ public: // off the control thread is race-free. Default off; see SourceChain. void setMonitorKill(bool kill) { - for (auto& s : sources) - if (s) s->setMonitorKill(kill); + pool.forEach([kill](SourceChain& s) { s.setMonitorKill(kill); }); } bool isMonitorKilled() const { return source0().isMonitorKilled(); } @@ -349,8 +349,8 @@ public: private: // sources[0] is the legacy default input chain; always present + active. - SourceChain& source0() { return *sources[0]; } - const SourceChain& source0() const { return *sources[0]; } + SourceChain& source0() { return pool.chain0(); } + const SourceChain& source0() const { return pool.chain0(); } // Input-device callback. In duplex it writes outputData directly; in split // it pushes processed stereo into outputRing for OutputCallback. void audioDeviceIOCallbackWithContext(const float* const* inputData, @@ -409,47 +409,18 @@ private: // Probe/apply/teardown component (TLC phase 4). Holds references only. slopsmith::DeviceSetup deviceSetup{ inputDeviceManager, outputDeviceManager, state }; - // Per-input capture+detect+monitor chains. A FIXED pool, all constructed up - // front, so adding/removing a source never reassigns a pointer the audio - // thread is reading — addSource/removeSource only flip an atomic `active` - // flag (and prepare/release the chain). sources[0] is the legacy default, - // active from construction and bound to the primary input device. The audio - // callback fans device channels out to each active source and fans their - // monitor signals into the output mix. SourceChain reads the engine's - // audioRunning / currentSampleRate atomics through references bound at - // construction. - static constexpr int kMaxSources = 8; - // Max ADDITIONAL input devices (beyond the primary). Declared here — ahead of the - // members that size arrays by it (e.g. callbacksInFlight) — though the extra-input - // slot registry that uses it lives further below. - static constexpr int kMaxExtraInputDevices = 3; - std::array, kMaxSources> sources; - // Serialises addSource/removeSource (control threads only — never the audio - // thread, which just reads each slot's atomic `active`). - std::mutex sourcesMutex; - // Audio-thread scratch for the multi-source mix: each active source renders - // its 2-channel monitor here in turn, then it is summed into the output. - // Pre-sized in audioDeviceAboutToStart so the hot loop never allocates. + // Per-input capture+detect+monitor chains + the add/remove/reclaim + // lifecycle + per-deviceKey quiescence handshake — moved to + // engine/SourcePool (TLC phase 5). Constants mirrored for the members + // that size arrays by them (extraInputs, and NodeAddon range checks). + static constexpr int kMaxSources = slopsmith::SourcePool::kMaxSources; + static constexpr int kMaxExtraInputDevices = slopsmith::SourcePool::kMaxExtraInputDevices; + slopsmith::SourcePool pool{ state }; + // Audio-thread scratch for the multi-source mix on the PRIMARY callback: + // each active source renders its 2-channel monitor here in turn, then it + // is summed into the output. Pre-sized in audioDeviceAboutToStart so the + // hot loop never allocates. (Extra devices carry their own scratch.) juce::AudioBuffer sourceMonitorScratch; - // Count of device callback bodies currently executing, PER deviceKey (index 0 = - // primary input, 1..kMaxExtraInputDevices = each extra-input slot). Each device - // callback increments its own key on entry and decrements at its real exit. - // removeSource() flips a source inactive (future callbacks snapshot active once - // and skip it), then waits to observe THIS SOURCE's deviceKey count == 0 — at - // that instant no callback that could touch this source is inside processBlock, - // so it is safe to release. Keying per-deviceKey (not a single global counter) is - // essential: with the primary + extra inputs on independent clocks they are - // rarely ALL idle at once, so a global check would strand removals during steady - // multi-device playback. A wedged callback past the bounded wait DEFERS the - // release via pendingRelease[], reclaimed later when that key's body is quiescent. - std::array, kMaxExtraInputDevices + 1> callbacksInFlight{}; - // Sources whose release was deferred (handshake timed out). Reclaimed under - // sourcesMutex by reclaimPendingReleases() at the next add/removeSource and on - // device stop, once it is safe (audio stopped or no callback in flight). - std::array pendingRelease{}; - // Release any deferred sources that are now safe to reclaim. Caller holds - // sourcesMutex (or is the device-stop path, where the callback is gone). - void reclaimPendingReleases(); // Master output (post-mix) — engine-global, not per-source. std::atomic outputGain{1.0f}; @@ -584,13 +555,7 @@ private: bool closeExtraInputDevice(int slot); void reopenDesiredExtraInputs(); - // Shared fan-out used by both the primary and each extra device's callback: - // mix every active source bound to `deviceKey` into `mixBuf` (using the - // caller-owned `monitorScratch` for the N>1 render so concurrent device - // threads never share scratch). Returns the active source count for that key. - int mixSourcesForDevice(int deviceKey, const float* const* inputData, int numInputChannels, - juce::AudioBuffer& mixBuf, juce::AudioBuffer& monitorScratch, - int effectiveOutputChannels, int numSamples); + // (mixSourcesForDevice moved to SourcePool::mixForDevice — TLC phase 5.) // ── Streamer mix output sink — moved to engine/StreamSink.{h,cpp} (TLC // phase 2). Declared after `state` (bound by reference). diff --git a/src/audio/CMakeLists.txt b/src/audio/CMakeLists.txt index 629e1af..954e764 100644 --- a/src/audio/CMakeLists.txt +++ b/src/audio/CMakeLists.txt @@ -9,6 +9,7 @@ set(AUDIO_SOURCES engine/StreamSink.cpp engine/BackingPlayer.cpp engine/DeviceSetup.cpp + engine/SourcePool.cpp SourceChain.cpp SignalChain.cpp VSTHost.cpp diff --git a/src/audio/engine/SourcePool.cpp b/src/audio/engine/SourcePool.cpp new file mode 100644 index 0000000..0346973 --- /dev/null +++ b/src/audio/engine/SourcePool.cpp @@ -0,0 +1,225 @@ +// SourcePool implementation — moved verbatim from AudioEngine.cpp (TLC plan +// phase 5 / §2.2). The extra-device resolution that addSource performed +// in-line (reading the InputDeviceSlot registry) stays on the AudioEngine +// facade, which passes the resolved values into addResolved(). + +#include "SourcePool.h" + +#include +#include + +namespace slopsmith { + +int SourcePool::addResolved(int inputChannel, int deviceKey, + bool deviceReady, double sr, int bs, double latencyDeltaSec) +{ + std::lock_guard lock(mutex); + reclaimPendingLocked(); // free up any slot whose release was deferred + + // Find a free pooled slot (slot 0 is the permanent default). Skip a slot whose + // release is still pending — its chain/worker hasn't been torn down yet, so + // re-preparing it would double-start the verifier thread. + int slot = -1; + for (int i = 1; i < kMaxSources; ++i) + if (! sources[(size_t) i]->isActive() && ! pendingRelease[(size_t) i]) { slot = i; break; } + if (slot < 0) + return -1; // pool full + + SourceChain& src = *sources[(size_t) slot]; + src.setInputChannel(inputChannel); + src.setDeviceKey(deviceKey); + // Inherit the bound device's capture-latency correction (0 for the primary). + src.setVerifierAutoOffset(latencyDeltaSec); + // Clear any MANUAL offset left on this pooled chain by a previous player — a + // freshly added source starts with no user fine-tune (the renderer re-applies + // its own via setSourceVerifierOffset). releaseResources() doesn't touch it. + src.setVerifierUserOffset(0.0); + // Likewise clear stale meters so this source doesn't briefly report the previous + // player's level/peak through getSourceLevels() until fresh audio arrives. + src.resetInputMeters(); + + // Prepare fully BEFORE making it visible to the audio thread, so the first + // callback that observes active==true sees a ready chain + rings. When audio + // isn't running yet, the relevant about-to-start hook prepares it later. An + // EXTRA-device source must be prepared with ITS device's sample rate / block + // size, not the primary's — the caller resolved those. + if (deviceReady && sr > 0.0 && bs > 0) + src.prepare(sr, bs); + src.setActive(true); // release-store: now picked up by the audio callback + return slot; +} + +bool SourcePool::remove(int id) +{ + if (id <= 0 || id >= kMaxSources) + return false; // 0 is permanent; out-of-range rejected + + std::lock_guard lock(mutex); + reclaimPendingLocked(); // opportunistically reclaim earlier deferrals + + SourceChain& src = *sources[(size_t) id]; + if (! src.isActive()) + return false; + + // Hide it from the audio callback first; subsequent blocks snapshot active + // once and skip it. It is logically removed from here on, regardless of when + // its resources are reclaimed. + src.setActive(false); + + // Reclaim now if we can confirm THIS SOURCE's device callback is not executing. + // Only the callback for the source's own deviceKey can touch it; that counter is + // decremented at the callback's real exit (release store), so observing 0 + // (acquire) proves it is not inside processBlock right now; any callback that + // starts afterwards snapshots active and skips this (now-inactive) source — so + // releasing cannot race the audio thread. Keying on the source's deviceKey (not a + // global all-callbacks-idle check) is what lets removals reclaim during steady + // multi-device playback, when callbacks on independent clocks are never all idle + // at once. Bounded so a wedged device can't hang this thread. + const size_t dk = (size_t) src.getDeviceKey(); + for (int spins = 0; spins < 200; ++spins) // ~200 ms cap + { + if (callbacksInFlight[dk].load(std::memory_order_acquire) == 0) + { + src.releaseResources(); // stops its threads + releases its chain + return true; + } + std::this_thread::sleep_for(std::chrono::milliseconds(1)); + } + + // A callback stayed wedged in-flight past the wait (a >200 ms block would be a + // catastrophic stall). Do NOT force a release that could race it — DEFER it. + // The source is already inactive so no future callback touches it; reclaim it + // later (next add/remove, or a device-stopped hook) when the body is quiet. + pendingRelease[(size_t) id] = true; + return true; +} + +void SourcePool::reclaimPendingLocked() +{ + // Caller holds `mutex`. A deferred source is inactive (future callbacks skip + // it); releasing it is safe once the callback for ITS deviceKey is not in a body. + // We key per-deviceKey (decremented by each callback at its real exit) so a + // pending release frees as soon as its OWN device is quiescent — not only when + // every device callback happens to be idle simultaneously (which, on independent + // clocks during steady multi-device playback, may never occur and would strand + // the slot until full stop). On the device-stopped path the relevant callback + // already left its count at 0. + for (int i = 1; i < kMaxSources; ++i) + { + if (! pendingRelease[(size_t) i]) continue; + const size_t dk = (size_t) sources[(size_t) i]->getDeviceKey(); + if (callbacksInFlight[dk].load(std::memory_order_acquire) != 0) + continue; // this source's device is mid-body — try again later + sources[(size_t) i]->releaseResources(); + pendingRelease[(size_t) i] = false; + } +} + +std::vector SourcePool::list() const +{ + std::vector out; + for (int i = 0; i < kMaxSources; ++i) + { + const SourceChain& src = *sources[(size_t) i]; + if (! src.isActive()) continue; + Info info; + info.id = src.getId(); + info.inputChannel = src.getInputChannel(); + info.deviceKey = src.getDeviceKey(); + info.active = true; + out.push_back(info); + } + return out; +} + +void SourcePool::prepareDeviceSources(int deviceKey, double sr, int bs, + double verifierAutoOffsetSec, bool applyOffset) +{ + std::lock_guard lock(mutex); + for (auto& src : sources) + if (src->isActive() && src->getDeviceKey() == deviceKey) + { + src->prepare(sr, bs); + if (applyOffset) src->setVerifierAutoOffset(verifierAutoOffsetSec); + } +} + +void SourcePool::releaseDeviceSources(int deviceKey, bool resetMeters, bool deactivate) +{ + std::lock_guard lock(mutex); + for (auto& src : sources) + if (src->isActive() && src->getDeviceKey() == deviceKey) + { + src->releaseResources(); + // Zero the meters so getSourceLevels() reports silence while the + // device is gone — otherwise the renderer's per-source silence gate + // treats a stopped/unplugged input as still hearing audio (the last + // non-zero level latches). releaseResources() doesn't touch them. + if (resetMeters) src->resetInputMeters(); + // PERMANENT unbind: the slot will never re-open, so DEACTIVATE its + // sources too — leaving them "active" would strand pooled slots no + // callback can ever service (a ghost detector in listSources). + if (deactivate) src->setActive(false); + } + // Retry any remove() cleanup deferred waiting for callbacks to drain. With + // this device's callback now stopped, callbacksInFlight may finally be 0. + reclaimPendingLocked(); +} + +int SourcePool::mixForDevice(int deviceKey, const float* const* inputData, int numInputChannels, + juce::AudioBuffer& mixBuf, juce::AudioBuffer& monitorScratch, + int effectiveOutputChannels, int numSamples) noexcept +{ + // Snapshot each source's active flag ONCE so the count and the process/mix + // passes are consistent within this block — a concurrent add/remove flipping + // a flag between two reads must not change which branch runs. remove() waits + // for all callback bodies to drain before releasing, so a source snapshotted + // active here is safe even if deactivated an instant later. + bool act[kMaxSources]; + int firstActive = -1, activeCount = 0; + for (int i = 0; i < kMaxSources; ++i) + { + act[i] = sources[(size_t) i]->isActive() + && sources[(size_t) i]->getDeviceKey() == deviceKey; + if (act[i]) { ++activeCount; if (firstActive < 0) firstActive = i; } + } + + if (activeCount == 0) + { + // No source on this device → silence. An extra device with no bound source + // contributes nothing to the output sum. The primary always has chain 0 + // (deviceKey 0, active from construction), so it never reaches this branch. + for (int ch = 0; ch < effectiveOutputChannels; ++ch) + mixBuf.clear(ch, 0, numSamples); + return 0; + } + + if (activeCount == 1) + { + // Fast path — exactly one source: process in place on mixBuf, byte- + // identical to the single-pipeline engine (channel select / mono mix + + // input gain, metering, ML + ring feed, gate, YIN, tone chain, monitor). + sources[(size_t) firstActive] + ->processBlock(inputData, numInputChannels, mixBuf, effectiveOutputChannels, numSamples); + return 1; + } + + // Multi-source: each renders its own 2-channel monitor into monitorScratch + // (each builds its mono from its bound channel + feeds its own rings / + // detectors / verifier), summed to STEREO (0/1). A >2-channel output keeps + // channels 2+ silent in multi-source mode (the fast path still broadcasts). + for (int ch = 0; ch < effectiveOutputChannels; ++ch) + mixBuf.clear(ch, 0, numSamples); + const int mixCh = juce::jmin(effectiveOutputChannels, 2); + const int n = juce::jmin(numSamples, monitorScratch.getNumSamples()); + for (int i = 0; i < kMaxSources; ++i) + { + if (! act[i]) continue; + sources[(size_t) i]->processBlock(inputData, numInputChannels, monitorScratch, 2, n); + for (int ch = 0; ch < mixCh; ++ch) + mixBuf.addFrom(ch, 0, monitorScratch, ch, 0, n); + } + return activeCount; +} + +} // namespace slopsmith diff --git a/src/audio/engine/SourcePool.h b/src/audio/engine/SourcePool.h new file mode 100644 index 0000000..59ad5da --- /dev/null +++ b/src/audio/engine/SourcePool.h @@ -0,0 +1,148 @@ +#pragma once + +// SourcePool — the fixed pool of per-input SourceChains plus the add/remove/ +// reclaim lifecycle and the per-deviceKey callback-quiescence handshake (TLC +// plan phase 5 / §2.2). Moved verbatim from AudioEngine. +// +// Pool invariants (unchanged): +// - ALL chains are constructed up front; add/removeSource never reassigns a +// pointer the audio thread reads — they only flip an atomic `active` flag. +// - chain 0 is the permanent legacy default input, active from construction. +// - removal uses the per-deviceKey callbacksInFlight counter handshake; +// wedged callbacks defer the release (pendingRelease[]) instead of +// blocking, reclaimed when that key's body is quiescent. +// +// Boundary: device callbacks hold a CallbackGuard for their body and call +// mixForDevice(); control threads use add/remove/list/get and the +// per-deviceKey prepare/release helpers the device hooks need. + +#include "EngineState.h" +#include "../SourceChain.h" + +#include +#include +#include +#include +#include + +namespace slopsmith { + +class SourcePool +{ +public: + static constexpr int kMaxSources = 8; + // Max ADDITIONAL input devices (beyond the primary); sizes the per-key + // in-flight counters (key 0 = primary). + static constexpr int kMaxExtraInputDevices = 3; + + explicit SourcePool(EngineState& engineState) + { + // Construct the full pool up front so the audio thread never observes + // a pointer swap. Each chain reads deviceRunning / currentSampleRate + // by reference. Chain 0 active from the start; the rest inactive (no + // threads — NoteVerifier's worker only starts in prepare()). + for (int i = 0; i < kMaxSources; ++i) + sources[(size_t) i] = std::make_unique( + i, engineState.deviceRunning, engineState.currentSampleRate); + sources[0]->setActive(true); + } + + // ── RT side ─────────────────────────────────────────────────────────── + // Publishes that a device callback body is executing for `deviceKey`, so + // remove()/reclaim know when that key is quiescent. previousInFlight is + // exposed for the duplicate-registration diagnostic. + struct CallbackGuard + { + CallbackGuard(SourcePool& p, int deviceKey) + : pool(p), key((size_t) deviceKey), + previousInFlight(p.callbacksInFlight[key].fetch_add(1, std::memory_order_acq_rel)) {} + ~CallbackGuard() { pool.callbacksInFlight[key].fetch_sub(1, std::memory_order_acq_rel); } + SourcePool& pool; + const size_t key; + const int previousInFlight; + }; + + // Mix every active source bound to `deviceKey` into `mixBuf` (using the + // caller-owned `monitorScratch` for the N>1 render so concurrent device + // threads never share scratch). Returns the active source count. + int mixForDevice(int deviceKey, const float* const* inputData, int numInputChannels, + juce::AudioBuffer& mixBuf, juce::AudioBuffer& monitorScratch, + int effectiveOutputChannels, int numSamples) noexcept; + + // ── Control threads ─────────────────────────────────────────────────── + // Activate a pooled chain (device info pre-resolved by the engine facade, + // which owns the extra-device registry). Returns the slot id or -1. + int addResolved(int inputChannel, int deviceKey, + bool deviceReady, double sr, int bs, double latencyDeltaSec); + // Deactivate + release (id != 0). Defers the release when the source's + // device callback stays in-flight past the bounded wait. + bool remove(int id); + + SourceChain* get(int id) + { + if (id < 0 || id >= kMaxSources) return nullptr; + SourceChain& src = *sources[(size_t) id]; + return (id == 0 || src.isActive()) ? &src : nullptr; + } + SourceChain& chain0() { return *sources[0]; } + const SourceChain& chain0() const { return *sources[0]; } + + struct Info { int id = -1; int inputChannel = -1; int deviceKey = 0; bool active = false; }; + std::vector list() const; + + // Fan an operation to every pooled chain (active or not) — plain atomic + // stores on fixed pointers, race-free off the control thread. + template void forEach(Fn&& fn) + { + for (auto& s : sources) + if (s) fn(*s); + } + template void forEachActive(Fn&& fn) + { + for (auto& s : sources) + if (s && s->isActive()) fn(*s); + } + + // Run `fn` on every active source bound to `deviceKey`, under the pool + // lock — for the extra-device close/unbind paths' bespoke sequences. + template void withDeviceSources(int deviceKey, Fn&& fn) + { + std::lock_guard lock(mutex); + for (auto& s : sources) + if (s->isActive() && s->getDeviceKey() == deviceKey) fn(*s); + } + + // Device hooks: prepare / release every active source bound to a key, + // under the pool lock. Mirrors the per-device halves of the old + // about-to-start / stopped handlers; release also retries deferred + // reclamation (that key's callback is now quiescent). + void prepareDeviceSources(int deviceKey, double sr, int bs, double verifierAutoOffsetSec, + bool applyOffset); + void releaseDeviceSources(int deviceKey, bool resetMeters, bool deactivate); + + // Retry deferred releases whose device is quiescent. Public form takes the + // pool lock (used by the primary device-stopped path). + void reclaimPending() + { + std::lock_guard lock(mutex); + reclaimPendingLocked(); + } + +private: + void reclaimPendingLocked(); + + std::array, kMaxSources> sources; + // Serialises add/remove (control threads only — never the audio thread, + // which just reads each slot's atomic `active`). + std::mutex mutex; + // How many device-callback bodies are currently executing per deviceKey + // (0 = primary, 1.. = extras). Incremented/decremented by CallbackGuard at + // the body's real entry/exit; remove() observing 0 (acquire) proves the + // source's device is not inside processBlock. + std::array, kMaxExtraInputDevices + 1> callbacksInFlight{}; + // A remove() that timed out waiting for quiescence parks the release here; + // reclaimed under `mutex` at the next add/remove and on device-stop paths. + std::array pendingRelease{}; +}; + +} // namespace slopsmith From 6b0bfb7be3d562beba67da1faad3bbb5f5d20da6 Mon Sep 17 00:00:00 2001 From: OmikronApex Date: Tue, 14 Jul 2026 01:29:16 +0200 Subject: [PATCH 11/28] refactor(audio): extract ExtraInputs (phase 5b) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Moves the additional-input-device registry — InputDeviceSlot (manager, callback, ring, scratches, latency delta, desired-name intent, permanent-unbind flag), bind/unbind/closeSlot/reopenDesired, the bindable enumeration, and the per-slot device-callback trio — verbatim into src/audio/engine/ExtraInputs.{h,cpp}. Sources are prepared/released through the bound SourcePool (same locking as before); the primary manager reference serves the duplicate-binding check, latency delta, and enumeration. The slots array stays public so the split output callback's ring-drain loop is unchanged; addSource resolves per-slot readiness via resolveForSource(). The (typeName, name) device-identity limitation moves with its honest comment — its fix lands here later without touching the engine again (plan §2.3). Completes phase 5; live 28-stage split-mode probe on real devices behaves identically to pre-move. Co-Authored-By: Claude Fable 5 --- src/audio/AudioEngine.cpp | 407 ++----------------------------- src/audio/AudioEngine.h | 77 +----- src/audio/CMakeLists.txt | 1 + src/audio/engine/ExtraInputs.cpp | 389 +++++++++++++++++++++++++++++ src/audio/engine/ExtraInputs.h | 160 ++++++++++++ 5 files changed, 572 insertions(+), 462 deletions(-) create mode 100644 src/audio/engine/ExtraInputs.cpp create mode 100644 src/audio/engine/ExtraInputs.h diff --git a/src/audio/AudioEngine.cpp b/src/audio/AudioEngine.cpp index 595e0b3..2015263 100644 --- a/src/audio/AudioEngine.cpp +++ b/src/audio/AudioEngine.cpp @@ -36,15 +36,7 @@ AudioEngine::AudioEngine() // The source pool (chains, quiescence handshake) lives on SourcePool now. - // Phase 2: tag each additional-input slot with its identity so its JUCE - // callback can route back to the engine. deviceKey = slot index + 1 (0 is the - // primary inputDeviceManager). The managers stay idle until bindInputDevice. - for (int i = 0; i < kMaxExtraInputDevices; ++i) - { - extraInputs[(size_t) i].callback.engine = this; - extraInputs[(size_t) i].callback.slot = i; - extraInputs[(size_t) i].deviceKey = i + 1; - } + // Extra-input slot registry lives on ExtraInputs now. auto result = inputDeviceManager.initialiseWithDefaultDevices(2, 2); if (result.isNotEmpty()) @@ -75,11 +67,7 @@ AudioEngine::~AudioEngine() { // Stop every extra input device FIRST so no slot callback can fire into a // half-destroyed engine. closeAudioDevice blocks for the callback thread. - for (int i = 0; i < kMaxExtraInputDevices; ++i) - { - extraInputs[(size_t) i].manager.closeAudioDevice(); - extraInputs[(size_t) i].manager.removeAudioCallback(&extraInputs[(size_t) i].callback); - } + extraInputs.closeAllForShutdown(); stopAudio(); stopBacking(); } @@ -107,45 +95,7 @@ juce::Array AudioEngine::getDeviceTypes() std::vector AudioEngine::getBindableInputDevices() { - std::vector out; - // The device already open as the primary input IS "Main" — don't offer it as - // an extra (would double-open the same hardware on two managers). - juce::String primaryName; - if (auto* dev = inputDeviceManager.getCurrentAudioDevice()) - primaryName = dev->getName(); - // Enumerate across ALL device types, not just the primary's current one — - // bindInputDevice() can open a device under any backend (JACK/ALSA/CoreAudio/…), - // so an extra interface exposed under a DIFFERENT backend than the primary must - // still be offered, or the multi-device path is unreachable from the picker. - // - // KNOWN LIMITATION: identity is the display name. JUCE opens input devices BY - // NAME, so two interfaces sharing a label (e.g. two identical USB cables) cannot - // be distinguished or independently opened without a backend-specific device-id - // rework — they collapse to one entry here. The SAME root cause makes a device - // exposed under MULTIPLE backends (e.g. ALSA + JACK/PipeWire on Linux) ambiguous: - // we dedup by name and bindInputDevice() re-derives the backend (preferring the - // primary's), so we may bind the wrong backend if only another would open. A real - // fix needs (typeName, name) identity threaded through bind/reopen. Distinct-name, - // single-backend rigs (the common case, and the validated GP-5 + Spark setup) are - // unaffected. - juce::StringArray seen; - for (auto* t : inputDeviceManager.getAvailableDeviceTypes()) - { - if (!t) continue; - t->scanForDevices(); - const juce::String typeName = t->getTypeName(); - for (const auto& name : t->getDeviceNames(true)) - { - if (name == primaryName || seen.contains(name)) continue; // dedup across backends - // Skip monitor / loopback pseudo-inputs — not instrument inputs, only - // confuse the picker. - const juce::String lower = name.toLowerCase(); - if (lower.contains("monitor") || lower.contains("loopback")) continue; - seen.add(name); - out.push_back({ typeName, name }); - } - } - return out; + return extraInputs.listBindable(); } juce::Array AudioEngine::getSampleRates() @@ -618,7 +568,7 @@ void AudioEngine::startAudio() // Restore any extra input devices the user still wants bound (stopAudio closed // them but kept the intent). This is what makes a stop/start cycle or a device // reconfigure transparently resume multi-input detection. - reopenDesiredExtraInputs(); + extraInputs.reopenDesired(); // Restore the streamer-mix output device too (same intent-survives-restart // pattern). Best-effort — a failure leaves the sink inactive, never blocks. @@ -650,7 +600,7 @@ void AudioEngine::stopAudio() // or a device reconfigure, restores extra inputs automatically). No-op when none // are bound — the single-device path is unchanged. for (int dk = 1; dk <= kMaxExtraInputDevices; ++dk) - closeExtraInputDevice(dk - 1); + extraInputs.closeSlot(dk - 1); // Tear down the streamer-mix OUTPUT device too — KEEP its desired intent so the // next startAudio() reopens it (same pattern as extra inputs). Without this the // 2nd output device keeps running and underflowing while the engine is "stopped", @@ -690,8 +640,7 @@ int AudioEngine::addSource(int inputChannel, int deviceKey) // configure its detector even though the device was successfully (deferred-)bound. if (deviceKey >= 1) { - const InputDeviceSlot& es = extraInputs[(size_t) (deviceKey - 1)]; - if (! es.active.load(std::memory_order_acquire) && es.desiredDeviceName.isEmpty()) + if (! extraInputs.resolveForSource(deviceKey).usable) return -1; } @@ -703,11 +652,11 @@ int AudioEngine::addSource(int inputChannel, int deviceKey) double latencyDeltaSec = 0.0; if (deviceKey >= 1 && deviceKey <= kMaxExtraInputDevices) { - const InputDeviceSlot& es = extraInputs[(size_t) (deviceKey - 1)]; - deviceReady = es.active.load(std::memory_order_acquire); - sr = es.sampleRate.load(std::memory_order_relaxed); - bs = es.blockSize.load(std::memory_order_relaxed); - latencyDeltaSec = es.latencyDeltaSec.load(std::memory_order_relaxed); + const auto r = extraInputs.resolveForSource(deviceKey); + deviceReady = r.ready; + sr = r.sr; + bs = r.bs; + latencyDeltaSec = r.latencyDelta; } return pool.addResolved(inputChannel, deviceKey, deviceReady, sr, bs, latencyDeltaSec); } @@ -1101,345 +1050,19 @@ void AudioEngine::audioDeviceIOCallbackWithContext( // Body done (CallbackGuard dtor) — pairs with remove()/reclaim acquire loads. } -void AudioEngine::extraInputCallback(int slot, const float* const* inputData, int numInputChannels, int numSamples) -{ - if (slot < 0 || slot >= kMaxExtraInputDevices) return; - InputDeviceSlot& s = extraInputs[(size_t) slot]; - if (! s.active.load(std::memory_order_acquire)) return; - - const slopsmith::SourcePool::CallbackGuard cbGuard(pool, s.deviceKey); - - // Clamp to the per-slot scratch sized in extraInputAboutToStart so the hot - // loop never allocates if a reconfig race delivers a larger block. - const int cap = s.fanScratch.getNumSamples(); - if (numSamples > cap) numSamples = cap; - - juce::AudioBuffer mix; - mix.setDataToReferTo(s.fanScratch.getArrayOfWritePointers(), 2, numSamples); - pool.mixForDevice(s.deviceKey, inputData, numInputChannels, mix, s.monitorScratch, 2, numSamples); - s.ring.push(mix.getReadPointer(0), mix.getReadPointer(1), numSamples); -} - -void AudioEngine::extraInputAboutToStart(int slot, juce::AudioIODevice* device) -{ - if (slot < 0 || slot >= kMaxExtraInputDevices || device == nullptr) return; - InputDeviceSlot& s = extraInputs[(size_t) slot]; - const int bs = device->getCurrentBufferSizeSamples(); - s.blockSize.store(bs, std::memory_order_relaxed); - // Prepare against this DEVICE's actual sample rate — the source of truth. - // bindInputDevice forces it to (and verifies it equals) the engine rate, so the - // verifier (which reads the engine-wide currentSampleRate) and the detectors - // agree. Reading the device here rather than assuming currentSampleRate keeps - // the prepare correct even if a future path opens it differently. - double sr = device->getCurrentSampleRate(); - if (sr <= 0.0) sr = currentSampleRate.load(std::memory_order_relaxed); - s.sampleRate.store(sr, std::memory_order_relaxed); - // Size per-slot scratch generously (cold-start guard) on this device-management - // thread — never the RT thread. - const int cap = juce::jmax(bs, 2048); - s.fanScratch.setSize(2, cap, false, false, true); - s.monitorScratch.setSize(2, cap, false, false, true); - s.fanScratch.clear(); - s.monitorScratch.clear(); - s.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 = inputDeviceManager.getCurrentAudioDevice()) - primaryLatSamples = pdev->getInputLatencyInSamples(); - // (extra − primary) reported input latency. On JACK/PipeWire this is 0 (no - // latency reported); the residual per-device offset is instead dialed in by the - // user via setSourceVerifierOffset (a stable auto-measure isn't possible — the - // value is device-specific and signal-level-confounded). 0 here = no auto shift. - const double deltaSec = (sr > 0.0) ? (double) (extraLatSamples - primaryLatSamples) / sr : 0.0; - s.latencyDeltaSec.store(deltaSec, std::memory_order_relaxed); - - // Prepare each source bound to this device so its verifier/detectors run, and - // apply the latency correction. - pool.prepareDeviceSources(s.deviceKey, sr, bs, deltaSec, true); - s.active.store(true, std::memory_order_release); -} - -void AudioEngine::extraInputStopped(int slot) -{ - if (slot < 0 || slot >= kMaxExtraInputDevices) return; - InputDeviceSlot& s = extraInputs[(size_t) slot]; - // JUCE blocks for this slot's callback thread before firing this, so the - // slot's body is quiescent. Hide it from the output sum, then release ITS - // sources (no other callback touches them — they all filter by deviceKey). - s.active.store(false, std::memory_order_release); - // 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(); -} - int AudioEngine::activeExtraInputCount() const { - int n = 0; - for (const auto& s : extraInputs) - if (s.active.load(std::memory_order_acquire)) ++n; - return n; + return extraInputs.activeCount(); } juce::String AudioEngine::bindInputDevice(int deviceKey, const juce::String& deviceName) { - if (deviceKey < 1 || deviceKey > kMaxExtraInputDevices) - return "deviceKey out of range"; - const int slot = deviceKey - 1; - InputDeviceSlot& s = extraInputs[(size_t) slot]; - if (s.active.load(std::memory_order_acquire)) - return "device slot already bound"; - - // Reject binding the SAME physical device into a second slot. Two callbacks - // reading one interface is wasteful (and fails outright on exclusive drivers); - // multiple sources that want this device should share its one deviceKey and pick - // different channels instead. Checks both open + deferred (desired) slots. - for (int other = 0; other < kMaxExtraInputDevices; ++other) - if (other != slot && extraInputs[(size_t) other].desiredDeviceName == deviceName) - return "device already bound to another input slot"; - - // Reject binding the device that is the PRIMARY input — it is already "Main", and - // opening it on this slot's manager too would double-open one interface on two - // managers (fatal on exclusive backends). Critically this also guards the REOPEN - // path: if the user makes a bound extra device the new main input, the preserved - // intent must NOT resurrect it as an extra (reopenDesiredExtraInputs() then drops - // the now-invalid binding via its failure handling). - if (auto* primary = inputDeviceManager.getCurrentAudioDevice()) - if (primary->getName() == deviceName) - return "device is the primary input — use Main, not an extra slot"; - - // An extra input device requires SPLIT mode: the output callback owns the mix + - // backing + gain and sums every device ring. In DUPLEX the primary device owns - // both directions and the output manager is closed, so we cannot just flip the - // flag — that would leave the output mix path absent (silent / unrouted). Reject - // here so the renderer reconfigures to a separate output device first. Checked - // BEFORE the deferred path below — startAudio()'s reopen also skips duplex, so a - // deferred bind in duplex would silently never come up while reporting success. - if (duplexMode.load(std::memory_order_relaxed)) - return "extra input requires split mode — select a separate output device first"; - - // Deregister any STALE callback BEFORE touching the manager. An earlier unplanned - // stop (USB unplug / backend restart) leaves s.callback registered; if we opened - // the manager (initialise / setAudioDeviceSetup) with it still attached, JUCE - // could dispatch it on the default/new device mid-setup — processing the wrong - // hardware, or even firing extraInputAboutToStart() during a stopped-engine - // validation open. Idempotent no-op when not registered. - s.manager.removeAudioCallback(&s.callback); - - // Open `deviceName` input-only on this slot's own manager. initialise first so - // the manager has a device type, then switch to the requested input device with - // all its channels (the source picks a channel within). - s.manager.initialiseWithDefaultDevices(2, 0); - - // The device name may belong to a device TYPE (ALSA / JACK / CoreAudio / …) - // different from the slot manager's default — a JACK device name won't resolve - // under ALSA and vice-versa ("No such device"). Find the type that actually - // lists this input device and switch the slot manager to it. Prefer the primary - // manager's current type (the devices the user already sees working). - juce::String chosenType; - if (auto* pt = inputDeviceManager.getCurrentDeviceTypeObject()) - { - pt->scanForDevices(); - if (pt->getDeviceNames(true).contains(deviceName)) - chosenType = pt->getTypeName(); - } - if (chosenType.isEmpty()) - for (auto* t : s.manager.getAvailableDeviceTypes()) - { - t->scanForDevices(); - if (t->getDeviceNames(true).contains(deviceName)) { chosenType = t->getTypeName(); break; } - } - // setCurrentAudioDeviceType can THROW from inside some JUCE backends (ASIO, and - // misconfigured JACK/CoreAudio) — setAudioDevices() guards it for the primary, so - // this path must too, or a bad backend terminates the process instead of - // returning an error to the renderer. Close the slot manager on failure. - if (chosenType.isNotEmpty()) - { - try { s.manager.setCurrentAudioDeviceType(chosenType, true); } - catch (...) { s.manager.closeAudioDevice(); return "extra-input setCurrentAudioDeviceType threw"; } - } - - juce::AudioDeviceManager::AudioDeviceSetup setup; - s.manager.getAudioDeviceSetup(setup); - setup.inputDeviceName = deviceName; - setup.outputDeviceName = ""; - // Open ALL of the device's capture channels (not just the default first pair), - // so a source bound to channel 2+ of a multi-channel extra interface actually - // receives audio — mirrors the primary device's explicit full-range open. - int inputChannelCount = 0; - if (auto* t = s.manager.getCurrentDeviceTypeObject()) - { - std::unique_ptr probe(t->createDevice({}, deviceName)); - if (probe) inputChannelCount = probe->getInputChannelNames().size(); - } - if (inputChannelCount <= 0) inputChannelCount = 2; - setup.inputChannels.setRange(0, inputChannelCount, true); - setup.useDefaultInputChannels = false; - setup.useDefaultOutputChannels = false; - // Force the extra device to the ENGINE's sample rate. Each SourceChain's - // verifier/detectors read the engine-wide currentSampleRate (bound by - // reference at construction), so an extra input running at a different rate - // (e.g. a 44.1 kHz device in a 48 kHz engine) would be scored on the wrong - // clock — skewing pitch/timing for every source bound to it. Matching the - // engine rate here (the OS/driver resamples if needed) keeps them coherent; a - // device that cannot do this rate fails the setup below and is rejected. - const double engineSr = currentSampleRate.load(std::memory_order_relaxed); - if (engineSr > 0.0) - setup.sampleRate = engineSr; - // initialiseWithDefaultDevices above may have opened a default capture device on - // this slot manager; every failure path below must close it, or a failed bind - // leaves the interface captured until engine teardown (fatal on exclusive - // backends + breaks retries / other apps). - juce::String err; - try { err = s.manager.setAudioDeviceSetup(setup, true); } - catch (...) { s.manager.closeAudioDevice(); return "extra-input setAudioDeviceSetup threw"; } - if (err.isNotEmpty()) - { - s.manager.closeAudioDevice(); - return "extra input: " + err + (chosenType.isEmpty() ? " (no type lists this device)" : " (type " + chosenType + ")"); - } - auto* extraDev = s.manager.getCurrentAudioDevice(); - if (extraDev == nullptr) - { - s.manager.closeAudioDevice(); - return "extra input device did not open"; - } - - // Some backends accept the rate request but actually open at a different rate. - // Since the SourceChain verifier reads the engine-wide currentSampleRate, a - // mismatch would score this device on the wrong clock — reject rather than - // ship silently-wrong timing. (Tolerant of a sub-Hz rounding difference.) - if (engineSr > 0.0 && std::abs(extraDev->getCurrentSampleRate() - engineSr) > 1.0) - { - const juce::String got = juce::String(extraDev->getCurrentSampleRate()); - s.manager.closeAudioDevice(); - return "extra input opened at " + got + " Hz, not the engine rate " + juce::String(engineSr) + " Hz"; - } - - // The device opened + validated. Record the INTENT now (not before the fallible - // open above), so it drives re-open across a reconfigure without lingering after - // a failed attach. Clear the permanent-unbind flag: a future stop on this slot is - // transient (resume) until the user explicitly unbinds again. - s.desiredDeviceName = deviceName; - s.permanentUnbind.store(false, std::memory_order_release); - - // If the engine is not running, we opened only to VALIDATE eagerly (so an - // unplugged / wrong-rate device fails the bind NOW instead of silently dropping - // at the next startAudio). Close it again so a stopped engine never leaves an - // interface capturing in the background; reopenDesiredExtraInputs() re-opens it - // (and re-attaches the callback) when the engine next starts. - if (! audioRunning.load(std::memory_order_relaxed)) - { - s.manager.closeAudioDevice(); - return {}; - } - - // Attach the callback — fires extraInputAboutToStart (prepares + flips active). - // Any stale registration was already removed before the open above, so this - // registers exactly once. - s.manager.addAudioCallback(&s.callback); - return {}; + return extraInputs.bind(deviceKey, deviceName); } bool AudioEngine::unbindInputDevice(int deviceKey) { - if (deviceKey < 1 || deviceKey > kMaxExtraInputDevices) - return false; - const int slot = deviceKey - 1; - // User-initiated unbind: mark it PERMANENT (the device thread's extraInputStopped - // reads this atomic to deactivate the slot's sources) BEFORE closing, and forget - // the INTENT so a later startAudio() does not resurrect a device the user - // deliberately removed. - extraInputs[(size_t) slot].permanentUnbind.store(true, std::memory_order_release); - extraInputs[(size_t) slot].desiredDeviceName = {}; - // If the device is open, closing it fires extraInputStopped(), which — with the - // intent now cleared — deactivates this deviceKey's sources. If it was ALREADY - // closed (e.g. a prior stopAudio() kept the intent + left the sources active for - // a resume that will now never come), extraInputStopped() will NOT run, so we - // must deactivate them here — otherwise they linger as ghost sources stranding - // pool slots and showing in listSources(). - if (! closeExtraInputDevice(slot)) - { - 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 unbindInputDevice() clears the intent first (permanent removal). -bool AudioEngine::closeExtraInputDevice(int slot) -{ - if (slot < 0 || slot >= kMaxExtraInputDevices) - return false; - InputDeviceSlot& s = extraInputs[(size_t) slot]; - const bool wasActive = s.active.load(std::memory_order_acquire); - // Close + deregister UNCONDITIONALLY (not gated on `active`). An UNPLANNED stop - // (USB unplug / backend restart) fires extraInputStopped() — flipping active - // false — yet leaves the manager owning a (possibly auto-recovering) device and - // s.callback still registered. If we no-oped on !active, stopAudio()/reconfigure - // would never release it and the backend could resume callbacks after the engine - // is supposedly stopped. Both calls are idempotent when already closed/absent. - // For an ACTIVE slot, closeAudioDevice() blocks for the callback thread then fires - // audioDeviceStopped → extraInputStopped (releases this device's sources). - s.manager.closeAudioDevice(); - s.manager.removeAudioCallback(&s.callback); - return wasActive; -} - -// Re-open every slot that has a desiredDeviceName but is not currently active — the -// post-(re)start restore of extra inputs. No-op in duplex (extras need split) and -// when nothing is desired (the single-device path). Called from startAudio(). -void AudioEngine::reopenDesiredExtraInputs() -{ - if (duplexMode.load(std::memory_order_relaxed)) - { - // Duplex has no consumer for extra-device rings, so the desired extras cannot - // open right now. PRESERVE their intent (so a later switch back to split - // auto-restores them — setAudioDevices() promises bindings survive a device - // change) and keep their sources active to resume in place; but ZERO their - // meters so getSourceLevels() reports silence while the device is gone (the - // renderer's per-source silence gate then won't treat a temporarily-unavailable - // source as still hearing audio, and there is no false detection). The sources - // are not "ghosts": split-restore reopens the device and they resume. - for (int dk = 1; dk <= kMaxExtraInputDevices; ++dk) - { - if (extraInputs[(size_t) (dk - 1)].desiredDeviceName.isEmpty()) - continue; - pool.withDeviceSources(dk, [](SourceChain& s) { s.resetInputMeters(); }); - } - return; - } - for (int dk = 1; dk <= kMaxExtraInputDevices; ++dk) - { - InputDeviceSlot& s = extraInputs[(size_t) (dk - 1)]; - if (s.desiredDeviceName.isEmpty() || s.active.load(std::memory_order_acquire)) - continue; - const juce::String err = bindInputDevice(dk, s.desiredDeviceName); // re-sets desired (idempotent) - if (err.isNotEmpty()) - { - // Reopen failed — the interface was unplugged, or no longer supports the - // engine rate. The transient close kept this slot's sources ACTIVE to - // resume; since they now never will, give up cleanly: drop the intent and - // deactivate them so they do not linger as ghost sources stranding pool - // slots. The renderer re-binds + re-adds if the device returns. - s.desiredDeviceName = {}; - pool.withDeviceSources(dk, [](SourceChain& src) { src.setActive(false); }); - } - } + return extraInputs.unbind(deviceKey); } void AudioEngine::audioOutputCallback(const float* const* /*inputData*/, @@ -1523,7 +1146,7 @@ void AudioEngine::audioOutputCallback(const float* const* /*inputData*/, // output. Each is an independent SPSC ring fed by that device's own callback at // its own hardware clock; the same drop-oldest catch-up absorbs its drift, so // two separate interfaces mix cleanly with no cross-device resampling. - for (auto& s : extraInputs) + for (auto& s : extraInputs.slots) { if (! s.active.load(std::memory_order_acquire)) continue; uint64_t er = s.ring.readIndex.load(std::memory_order_relaxed); diff --git a/src/audio/AudioEngine.h b/src/audio/AudioEngine.h index e75266c..c578afc 100644 --- a/src/audio/AudioEngine.h +++ b/src/audio/AudioEngine.h @@ -8,6 +8,7 @@ #include "engine/BackingPlayer.h" #include "engine/DeviceSetup.h" #include "engine/SourcePool.h" +#include "engine/ExtraInputs.h" #include "BackingLeveler.h" #include "signalsmith-stretch.h" #include @@ -96,7 +97,7 @@ public: // with an ALSA primary), minus the device already open as the primary (that's // "Main") and minus monitor/loopback pseudo-inputs. Keeps the per-panel device // picker to a compatible, sensible set instead of every capture node. - struct BindableInput { juce::String typeName; juce::String name; }; + using BindableInput = slopsmith::ExtraInputs::Bindable; std::vector getBindableInputDevices(); juce::Array getSampleRates(); @@ -485,75 +486,11 @@ private: // leave a live registration behind after stopAudio()'s single remove. bool inputCallbackRegistered = false; - // ── Phase 2: additional input devices ──────────────────────────────────── - // Each ADDITIONAL physical input device (a 2nd/3rd USB interface, e.g. two - // separate cables) gets its own AudioDeviceManager + callback running on its - // OWN hardware clock, packing its sources' mixed monitor into its own SPSC - // ring. audioOutputCallback drains+sums every active ring (drop-oldest wrap - // absorbs each device's drift independently — no cross-device resampling, the - // failure mode that corrupts a software combine). deviceKey 0 = the primary - // inputDeviceManager above; deviceKeys 1..kMaxExtraInputDevices map to - // extraInputs[deviceKey-1]. When any extra device is active the engine runs - // split (the primary also uses its ring) so the output sum is uniform. - // (kMaxExtraInputDevices is declared up top, near kMaxSources.) - - // Forwards a JUCE device callback to the engine, tagged with the slot index. - struct InputSlotCallback : juce::AudioIODeviceCallback - { - AudioEngine* engine = nullptr; - int slot = -1; // index into extraInputs (deviceKey - 1) - void audioDeviceIOCallbackWithContext(const float* const* inputData, int numInputChannels, - float* const* outputData, int numOutputChannels, - int numSamples, - const juce::AudioIODeviceCallbackContext&) override - { - juce::ignoreUnused(outputData, numOutputChannels); - if (engine) engine->extraInputCallback(slot, inputData, numInputChannels, numSamples); - } - void audioDeviceAboutToStart(juce::AudioIODevice* d) override { if (engine) engine->extraInputAboutToStart(slot, d); } - void audioDeviceStopped() override { if (engine) engine->extraInputStopped(slot); } - }; - - struct InputDeviceSlot - { - juce::AudioDeviceManager manager; - InputSlotCallback callback; - slopsmith::PackedStereoRing ring; - std::atomic overflowCount{0}; - std::atomic active{false}; // a device is bound + running - std::atomic sampleRate{48000.0}; - std::atomic blockSize{256}; - // (extra input latency − primary input latency) in seconds — applied to - // this device's sources' verifiers so their capture aligns with the - // primary-corrected playhead. Computed when the device starts. - std::atomic latencyDeltaSec{0.0}; - // Audio-thread scratch — one set per slot since each slot's callback runs - // on its own thread (can't share the primary's sourceMonitorScratch). - juce::AudioBuffer fanScratch; // the 2ch mix target - juce::AudioBuffer monitorScratch; // per-source render in the N>1 path - int deviceKey = 0; // deviceKey this slot serves (slot+1) - // The device the user WANTS bound here — persistent INTENT, distinct from - // the transient `active` (currently open). Set by bindInputDevice, cleared - // only by a user unbind. stopAudio()/reconfigure close the device but keep - // this so startAudio() re-opens it; this is what survives a device change. - // Mutated + read on the control thread only. - juce::String desiredDeviceName; - // Whether the NEXT extraInputStopped() for this slot is a PERMANENT unbind - // (deactivate its sources) vs a transient close (keep them to resume). An - // atomic the control thread sets and the device thread reads, so the - // permanent-vs-transient decision never races on the juce::String above. - std::atomic permanentUnbind { false }; - }; - std::array extraInputs; - - // Per-slot callback hooks (audio + device-management threads). - void extraInputCallback(int slot, const float* const* inputData, int numInputChannels, int numSamples); - void extraInputAboutToStart(int slot, juce::AudioIODevice* device); - void extraInputStopped(int slot); - // Close an extra device but KEEP its desiredDeviceName (transient close for - // stop/reconfigure); reopenDesiredExtraInputs() restores them after a (re)start. - bool closeExtraInputDevice(int slot); - void reopenDesiredExtraInputs(); + // ── Additional input devices — moved to engine/ExtraInputs.{h,cpp} + // (TLC phase 5). The split output callback drains extraInputs.slots + // directly; declared after pool/state (bound by reference). + slopsmith::ExtraInputs extraInputs{ pool, state, inputDeviceManager }; + using InputDeviceSlot = slopsmith::ExtraInputs::InputDeviceSlot; // (mixSourcesForDevice moved to SourcePool::mixForDevice — TLC phase 5.) diff --git a/src/audio/CMakeLists.txt b/src/audio/CMakeLists.txt index 954e764..1c44e19 100644 --- a/src/audio/CMakeLists.txt +++ b/src/audio/CMakeLists.txt @@ -10,6 +10,7 @@ set(AUDIO_SOURCES engine/BackingPlayer.cpp engine/DeviceSetup.cpp engine/SourcePool.cpp + engine/ExtraInputs.cpp SourceChain.cpp SignalChain.cpp VSTHost.cpp diff --git a/src/audio/engine/ExtraInputs.cpp b/src/audio/engine/ExtraInputs.cpp new file mode 100644 index 0000000..2928a27 --- /dev/null +++ b/src/audio/engine/ExtraInputs.cpp @@ -0,0 +1,389 @@ +// ExtraInputs implementation — moved verbatim from AudioEngine.cpp (TLC plan +// phase 5 / §2.3). Member renames only: extraInputs[...] → slots[...], +// inputDeviceManager → primaryManager, engine atomics → EngineState, source +// loops → SourcePool helpers (same locking as the engine sites had). + +#include "ExtraInputs.h" + +#include +#include +#include + +namespace slopsmith { + +void ExtraInputs::slotCallback(int slot, const float* const* inputData, int numInputChannels, int numSamples) +{ + if (slot < 0 || slot >= kMaxExtraInputDevices) return; + InputDeviceSlot& s = slots[(size_t) slot]; + if (! s.active.load(std::memory_order_acquire)) return; + + const SourcePool::CallbackGuard cbGuard(pool, s.deviceKey); + + // Clamp to the per-slot scratch sized in slotAboutToStart so the hot + // loop never allocates if a reconfig race delivers a larger block. + const int cap = s.fanScratch.getNumSamples(); + if (numSamples > cap) numSamples = cap; + + juce::AudioBuffer mix; + mix.setDataToReferTo(s.fanScratch.getArrayOfWritePointers(), 2, numSamples); + pool.mixForDevice(s.deviceKey, inputData, numInputChannels, mix, s.monitorScratch, 2, numSamples); + s.ring.push(mix.getReadPointer(0), mix.getReadPointer(1), numSamples); +} + +void ExtraInputs::slotAboutToStart(int slot, juce::AudioIODevice* device) +{ + if (slot < 0 || slot >= kMaxExtraInputDevices || device == nullptr) return; + InputDeviceSlot& s = slots[(size_t) slot]; + const int bs = device->getCurrentBufferSizeSamples(); + s.blockSize.store(bs, std::memory_order_relaxed); + // Prepare against this DEVICE's actual sample rate — the source of truth. + // bind() forces it to (and verifies it equals) the engine rate, so the + // verifier (which reads the engine-wide currentSampleRate) and the detectors + // agree. Reading the device here rather than assuming currentSampleRate keeps + // the prepare correct even if a future path opens it differently. + double sr = device->getCurrentSampleRate(); + if (sr <= 0.0) sr = state.currentSampleRate.load(std::memory_order_relaxed); + s.sampleRate.store(sr, std::memory_order_relaxed); + // Size per-slot scratch generously (cold-start guard) on this device-management + // thread — never the RT thread. + const int cap = juce::jmax(bs, 2048); + s.fanScratch.setSize(2, cap, false, false, true); + s.monitorScratch.setSize(2, cap, false, false, true); + s.fanScratch.clear(); + s.monitorScratch.clear(); + s.ring.reset(); + + // Capture-latency correction: the renderer's playhead is aligned to the PRIMARY + // device's input latency, but this extra device captures with a different + // latency, so its audio sits at a different song-time than the playhead assumes. + // Set its sources' verifier offset to (extra − primary) input latency so they + // match this device's just-captured audio against the right chart notes. + int extraLatSamples = device->getInputLatencyInSamples(); + int primaryLatSamples = 0; + if (auto* pdev = primaryManager.getCurrentAudioDevice()) + primaryLatSamples = pdev->getInputLatencyInSamples(); + // (extra − primary) reported input latency. On JACK/PipeWire this is 0 (no + // latency reported); the residual per-device offset is instead dialed in by the + // user via setSourceVerifierOffset (a stable auto-measure isn't possible — the + // value is device-specific and signal-level-confounded). 0 here = no auto shift. + const double deltaSec = (sr > 0.0) ? (double) (extraLatSamples - primaryLatSamples) / sr : 0.0; + s.latencyDeltaSec.store(deltaSec, std::memory_order_relaxed); + + // Prepare each source bound to this device so its verifier/detectors run, and + // apply the latency correction. + pool.prepareDeviceSources(s.deviceKey, sr, bs, deltaSec, true); + s.active.store(true, std::memory_order_release); +} + +void ExtraInputs::slotStopped(int slot) +{ + if (slot < 0 || slot >= kMaxExtraInputDevices) return; + InputDeviceSlot& s = slots[(size_t) slot]; + // JUCE blocks for this slot's callback thread before firing this, so the + // slot's body is quiescent. Hide it from the output sum, then release ITS + // sources (no other callback touches them — they all filter by deviceKey). + s.active.store(false, std::memory_order_release); + // PERMANENT unbind (user removed this device) deactivates its sources too; + // a TRANSIENT close (stopAudio/reconfigure/unplug) only releases them so + // startAudio()'s re-open resumes them in place. Read the atomic flag (set + // by the control-thread unbind) rather than the juce::String + // desiredDeviceName, which this device-thread path must not race on. + pool.releaseDeviceSources(s.deviceKey, true, + s.permanentUnbind.load(std::memory_order_acquire)); + s.ring.resetIndices(); +} + +juce::String ExtraInputs::bind(int deviceKey, const juce::String& deviceName) +{ + if (deviceKey < 1 || deviceKey > kMaxExtraInputDevices) + return "deviceKey out of range"; + const int slot = deviceKey - 1; + InputDeviceSlot& s = slots[(size_t) slot]; + if (s.active.load(std::memory_order_acquire)) + return "device slot already bound"; + + // Reject binding the SAME physical device into a second slot. Two callbacks + // reading one interface is wasteful (and fails outright on exclusive drivers); + // multiple sources that want this device should share its one deviceKey and pick + // different channels instead. Checks both open + deferred (desired) slots. + for (int other = 0; other < kMaxExtraInputDevices; ++other) + if (other != slot && slots[(size_t) other].desiredDeviceName == deviceName) + return "device already bound to another input slot"; + + // Reject binding the device that is the PRIMARY input — it is already "Main", and + // opening it on this slot's manager too would double-open one interface on two + // managers (fatal on exclusive backends). Critically this also guards the REOPEN + // path: if the user makes a bound extra device the new main input, the preserved + // intent must NOT resurrect it as an extra (reopenDesired() then drops the + // now-invalid binding via its failure handling). + if (auto* primary = primaryManager.getCurrentAudioDevice()) + if (primary->getName() == deviceName) + return "device is the primary input — use Main, not an extra slot"; + + // An extra input device requires SPLIT mode: the output callback owns the mix + + // backing + gain and sums every device ring. In DUPLEX the primary device owns + // both directions and the output manager is closed, so we cannot just flip the + // flag — that would leave the output mix path absent (silent / unrouted). Reject + // here so the renderer reconfigures to a separate output device first. Checked + // BEFORE the deferred path below — startAudio()'s reopen also skips duplex, so a + // deferred bind in duplex would silently never come up while reporting success. + if (state.duplexMode.load(std::memory_order_relaxed)) + return "extra input requires split mode — select a separate output device first"; + + // Deregister any STALE callback BEFORE touching the manager. An earlier unplanned + // stop (USB unplug / backend restart) leaves s.callback registered; if we opened + // the manager (initialise / setAudioDeviceSetup) with it still attached, JUCE + // could dispatch it on the default/new device mid-setup — processing the wrong + // hardware, or even firing slotAboutToStart() during a stopped-engine + // validation open. Idempotent no-op when not registered. + s.manager.removeAudioCallback(&s.callback); + + // Open `deviceName` input-only on this slot's own manager. initialise first so + // the manager has a device type, then switch to the requested input device with + // all its channels (the source picks a channel within). + s.manager.initialiseWithDefaultDevices(2, 0); + + // The device name may belong to a device TYPE (ALSA / JACK / CoreAudio / …) + // different from the slot manager's default — a JACK device name won't resolve + // under ALSA and vice-versa ("No such device"). Find the type that actually + // lists this input device and switch the slot manager to it. Prefer the primary + // manager's current type (the devices the user already sees working). + juce::String chosenType; + if (auto* pt = primaryManager.getCurrentDeviceTypeObject()) + { + pt->scanForDevices(); + if (pt->getDeviceNames(true).contains(deviceName)) + chosenType = pt->getTypeName(); + } + if (chosenType.isEmpty()) + for (auto* t : s.manager.getAvailableDeviceTypes()) + { + t->scanForDevices(); + if (t->getDeviceNames(true).contains(deviceName)) { chosenType = t->getTypeName(); break; } + } + // setCurrentAudioDeviceType can THROW from inside some JUCE backends (ASIO, and + // misconfigured JACK/CoreAudio) — setAudioDevices() guards it for the primary, so + // this path must too, or a bad backend terminates the process instead of + // returning an error to the renderer. Close the slot manager on failure. + if (chosenType.isNotEmpty()) + { + try { s.manager.setCurrentAudioDeviceType(chosenType, true); } + catch (...) { s.manager.closeAudioDevice(); return "extra-input setCurrentAudioDeviceType threw"; } + } + + juce::AudioDeviceManager::AudioDeviceSetup setup; + s.manager.getAudioDeviceSetup(setup); + setup.inputDeviceName = deviceName; + setup.outputDeviceName = ""; + // Open ALL of the device's capture channels (not just the default first pair), + // so a source bound to channel 2+ of a multi-channel extra interface actually + // receives audio — mirrors the primary device's explicit full-range open. + int inputChannelCount = 0; + if (auto* t = s.manager.getCurrentDeviceTypeObject()) + { + std::unique_ptr probe(t->createDevice({}, deviceName)); + if (probe) inputChannelCount = probe->getInputChannelNames().size(); + } + if (inputChannelCount <= 0) inputChannelCount = 2; + setup.inputChannels.setRange(0, inputChannelCount, true); + setup.useDefaultInputChannels = false; + setup.useDefaultOutputChannels = false; + // Force the extra device to the ENGINE's sample rate. Each SourceChain's + // verifier/detectors read the engine-wide currentSampleRate (bound by + // reference at construction), so an extra input running at a different rate + // (e.g. a 44.1 kHz device in a 48 kHz engine) would be scored on the wrong + // clock — skewing pitch/timing for every source bound to it. Matching the + // engine rate here (the OS/driver resamples if needed) keeps them coherent; a + // device that cannot do this rate fails the setup below and is rejected. + const double engineSr = state.currentSampleRate.load(std::memory_order_relaxed); + if (engineSr > 0.0) + setup.sampleRate = engineSr; + // initialiseWithDefaultDevices above may have opened a default capture device on + // this slot manager; every failure path below must close it, or a failed bind + // leaves the interface captured until engine teardown (fatal on exclusive + // backends + breaks retries / other apps). + juce::String err; + try { err = s.manager.setAudioDeviceSetup(setup, true); } + catch (...) { s.manager.closeAudioDevice(); return "extra-input setAudioDeviceSetup threw"; } + if (err.isNotEmpty()) + { + s.manager.closeAudioDevice(); + return "extra input: " + err + (chosenType.isEmpty() ? " (no type lists this device)" : " (type " + chosenType + ")"); + } + auto* extraDev = s.manager.getCurrentAudioDevice(); + if (extraDev == nullptr) + { + s.manager.closeAudioDevice(); + return "extra input device did not open"; + } + + // Some backends accept the rate request but actually open at a different rate. + // Since the SourceChain verifier reads the engine-wide currentSampleRate, a + // mismatch would score this device on the wrong clock — reject rather than + // ship silently-wrong timing. (Tolerant of a sub-Hz rounding difference.) + if (engineSr > 0.0 && std::abs(extraDev->getCurrentSampleRate() - engineSr) > 1.0) + { + const juce::String got = juce::String(extraDev->getCurrentSampleRate()); + s.manager.closeAudioDevice(); + return "extra input opened at " + got + " Hz, not the engine rate " + juce::String(engineSr) + " Hz"; + } + + // The device opened + validated. Record the INTENT now (not before the fallible + // open above), so it drives re-open across a reconfigure without lingering after + // a failed attach. Clear the permanent-unbind flag: a future stop on this slot is + // transient (resume) until the user explicitly unbinds again. + s.desiredDeviceName = deviceName; + s.permanentUnbind.store(false, std::memory_order_release); + + // If the engine is not running, we opened only to VALIDATE eagerly (so an + // unplugged / wrong-rate device fails the bind NOW instead of silently dropping + // at the next startAudio). Close it again so a stopped engine never leaves an + // interface capturing in the background; reopenDesired() re-opens it (and + // re-attaches the callback) when the engine next starts. + if (! state.deviceRunning.load(std::memory_order_relaxed)) + { + s.manager.closeAudioDevice(); + return {}; + } + + // Attach the callback — fires slotAboutToStart (prepares + flips active). + // Any stale registration was already removed before the open above, so this + // registers exactly once. + s.manager.addAudioCallback(&s.callback); + return {}; +} + +bool ExtraInputs::unbind(int deviceKey) +{ + if (deviceKey < 1 || deviceKey > kMaxExtraInputDevices) + return false; + const int slot = deviceKey - 1; + // User-initiated unbind: mark it PERMANENT (the device thread's slotStopped + // reads the flag) and clear the intent so no restore path resurrects a device + // deliberately removed. + slots[(size_t) slot].permanentUnbind.store(true, std::memory_order_release); + slots[(size_t) slot].desiredDeviceName = {}; + // If the device is open, closing it fires slotStopped(), which — with the + // intent now cleared — deactivates this deviceKey's sources. If it was ALREADY + // closed (e.g. a prior stopAudio() kept the intent + left the sources active for + // a resume that will now never come), slotStopped() will NOT run, so we + // must deactivate them here — otherwise they linger as ghost sources stranding + // pool slots and showing in listSources(). + if (! closeSlot(slot)) + { + pool.withDeviceSources(deviceKey, [](SourceChain& s) { + s.releaseResources(); + s.setActive(false); + }); + } + return true; +} + +// Close the device open on a slot WITHOUT forgetting desiredDeviceName, so +// startAudio() re-opens it. Used by stopAudio()/reconfigure (transient close) — the +// public unbind() clears the intent first (permanent removal). +bool ExtraInputs::closeSlot(int slot) +{ + if (slot < 0 || slot >= kMaxExtraInputDevices) + return false; + InputDeviceSlot& s = slots[(size_t) slot]; + const bool wasActive = s.active.load(std::memory_order_acquire); + // Close + deregister UNCONDITIONALLY (not gated on `active`). An UNPLANNED stop + // (USB unplug / backend restart) fires slotStopped() — flipping active + // false — yet leaves the manager owning a (possibly auto-recovering) device and + // s.callback still registered. If we no-oped on !active, stopAudio()/reconfigure + // would never release it and the backend could resume callbacks after the engine + // is supposedly stopped. Both calls are idempotent when already closed/absent. + // For an ACTIVE slot, closeAudioDevice() blocks for the callback thread then fires + // audioDeviceStopped → slotStopped (releases this device's sources). + s.manager.closeAudioDevice(); + s.manager.removeAudioCallback(&s.callback); + return wasActive; +} + +// Re-open every slot that has a desiredDeviceName but is not currently active — the +// post-(re)start restore of extra inputs. No-op in duplex (extras need split) and +// when nothing is desired (the single-device path). Called from startAudio(). +void ExtraInputs::reopenDesired() +{ + if (state.duplexMode.load(std::memory_order_relaxed)) + { + // Duplex has no consumer for extra-device rings, so the desired extras cannot + // open right now. PRESERVE their intent (so a later switch back to split + // auto-restores them — setAudioDevices() promises bindings survive a device + // change) and keep their sources active to resume in place; but ZERO their + // meters so getSourceLevels() reports silence while the device is gone (the + // renderer's per-source silence gate then won't treat a temporarily-unavailable + // source as still hearing audio, and there is no false detection). The sources + // are not "ghosts": split-restore reopens the device and they resume. + for (int dk = 1; dk <= kMaxExtraInputDevices; ++dk) + { + if (slots[(size_t) (dk - 1)].desiredDeviceName.isEmpty()) + continue; + pool.withDeviceSources(dk, [](SourceChain& s) { s.resetInputMeters(); }); + } + return; + } + for (int dk = 1; dk <= kMaxExtraInputDevices; ++dk) + { + InputDeviceSlot& s = slots[(size_t) (dk - 1)]; + if (s.desiredDeviceName.isEmpty() || s.active.load(std::memory_order_acquire)) + continue; + const juce::String err = bind(dk, s.desiredDeviceName); // re-sets desired (idempotent) + if (err.isNotEmpty()) + { + // Reopen failed — the interface was unplugged, or no longer supports the + // engine rate. The transient close kept this slot's sources ACTIVE to + // resume; since they now never will, give up cleanly: drop the intent and + // deactivate them so they do not linger as ghost sources stranding pool + // slots. The renderer re-binds + re-adds if the device returns. + s.desiredDeviceName = {}; + pool.withDeviceSources(dk, [](SourceChain& src) { src.setActive(false); }); + } + } +} + +std::vector ExtraInputs::listBindable() +{ + std::vector out; + // The device already open as the primary input IS "Main" — don't offer it as + // an extra (would double-open the same hardware on two managers). + juce::String primaryName; + if (auto* dev = primaryManager.getCurrentAudioDevice()) + primaryName = dev->getName(); + // Enumerate across ALL device types, not just the primary's current one — + // bind() can open a device under any backend (JACK/ALSA/CoreAudio/…), so an + // extra interface exposed under a DIFFERENT backend than the primary must + // still be offered, or the multi-device path is unreachable from the picker. + // + // KNOWN LIMITATION: identity is the display name. JUCE opens input devices BY + // NAME, so two interfaces sharing a label (e.g. two identical USB cables) cannot + // be distinguished or independently opened without a backend-specific device-id + // rework — they collapse to one entry here. The SAME root cause makes a device + // exposed under MULTIPLE backends (e.g. ALSA + JACK/PipeWire on Linux) ambiguous: + // we dedup by name and bind() re-derives the backend (preferring the primary's), + // so we may bind the wrong backend if only another would open. A real fix needs + // (typeName, name) identity threaded through bind/reopen. Distinct-name, + // single-backend rigs (the common case, and the validated GP-5 + Spark setup) are + // unaffected. + juce::StringArray seen; + for (auto* t : primaryManager.getAvailableDeviceTypes()) + { + if (!t) continue; + t->scanForDevices(); + const juce::String typeName = t->getTypeName(); + for (const auto& name : t->getDeviceNames(true)) + { + if (name == primaryName || seen.contains(name)) continue; // dedup across backends + // Skip monitor / loopback pseudo-inputs — not instrument inputs, only + // confuse the picker. + const juce::String lower = name.toLowerCase(); + if (lower.contains("monitor") || lower.contains("loopback")) continue; + seen.add(name); + out.push_back({ typeName, name }); + } + } + return out; +} + +} // namespace slopsmith diff --git a/src/audio/engine/ExtraInputs.h b/src/audio/engine/ExtraInputs.h new file mode 100644 index 0000000..e3cfaf2 --- /dev/null +++ b/src/audio/engine/ExtraInputs.h @@ -0,0 +1,160 @@ +#pragma once + +// ExtraInputs — the additional-physical-input-device registry (TLC plan +// phase 5 / §2.3, was "Phase 2: additional input devices" inside AudioEngine). +// Each ADDITIONAL device (a 2nd/3rd USB interface) gets its own +// AudioDeviceManager + callback running on its OWN hardware clock, packing +// its sources' mixed monitor into its own SPSC ring. The engine's split +// output callback drains + sums every active ring (drop-oldest absorbs each +// device's drift independently — no cross-device resampling). deviceKey 0 = +// the primary input manager; deviceKeys 1..kMaxExtraInputDevices map to +// slots[deviceKey-1]. When any extra device is active the engine runs split. +// +// Moved verbatim from AudioEngine. The slots array stays PUBLIC so the split +// output callback keeps its ring-drain loop unchanged; sources are prepared/ +// released through the bound SourcePool; engine format/run state through +// EngineState; the primary manager reference serves the primary-device +// checks (duplicate binding, latency delta, bindable enumeration). + +#include "EngineState.h" +#include "PackedStereoRing.h" +#include "SourcePool.h" + +#include + +#include +#include +#include + +namespace slopsmith { + +class ExtraInputs +{ +public: + static constexpr int kMaxExtraInputDevices = SourcePool::kMaxExtraInputDevices; + // Ring capacity matches the engine's split-mode ring. + static constexpr int kRingFrames = 4096; + + // Forwards a JUCE device callback to the registry, tagged with the slot index. + struct SlotCallback : juce::AudioIODeviceCallback + { + ExtraInputs* owner = nullptr; + int slot = -1; // index into slots (deviceKey - 1) + void audioDeviceIOCallbackWithContext(const float* const* inputData, int numInputChannels, + float* const* outputData, int numOutputChannels, + int numSamples, + const juce::AudioIODeviceCallbackContext&) override + { + juce::ignoreUnused(outputData, numOutputChannels); + if (owner) owner->slotCallback(slot, inputData, numInputChannels, numSamples); + } + void audioDeviceAboutToStart(juce::AudioIODevice* d) override { if (owner) owner->slotAboutToStart(slot, d); } + void audioDeviceStopped() override { if (owner) owner->slotStopped(slot); } + }; + + struct InputDeviceSlot + { + juce::AudioDeviceManager manager; + SlotCallback callback; + PackedStereoRing ring; + std::atomic overflowCount{0}; + std::atomic active{false}; // a device is bound + running + std::atomic sampleRate{48000.0}; + std::atomic blockSize{256}; + // (extra input latency − primary input latency) in seconds — applied to + // this device's sources' verifiers so their capture aligns with the + // primary-corrected playhead. Computed when the device starts. + std::atomic latencyDeltaSec{0.0}; + // Audio-thread scratch — one set per slot since each slot's callback runs + // on its own thread (can't share the primary's sourceMonitorScratch). + juce::AudioBuffer fanScratch; // the 2ch mix target + juce::AudioBuffer monitorScratch; // per-source render in the N>1 path + int deviceKey = 0; // deviceKey this slot serves (slot+1) + // The device the user WANTS bound here — persistent INTENT, distinct from + // the transient `active` (currently open). Set by bind(), cleared only by a + // user unbind. stopAudio()/reconfigure close the device but keep this so + // startAudio() re-opens it; this is what survives a device change. + // Mutated + read on the control thread only. + juce::String desiredDeviceName; + // Whether the NEXT slotStopped() for this slot is a PERMANENT unbind + // (deactivate its sources) vs a transient close (keep them to resume). An + // atomic the control thread sets and the device thread reads, so the + // permanent-vs-transient decision never races on the juce::String above. + std::atomic permanentUnbind { false }; + }; + + ExtraInputs(SourcePool& sourcePool, EngineState& engineState, + juce::AudioDeviceManager& primaryInputManager) + : pool(sourcePool), state(engineState), primaryManager(primaryInputManager) + { + for (int i = 0; i < kMaxExtraInputDevices; ++i) + { + slots[(size_t) i].callback.owner = this; + slots[(size_t) i].callback.slot = i; + slots[(size_t) i].deviceKey = i + 1; + } + } + + // ── Control thread ──────────────────────────────────────────────────── + juce::String bind(int deviceKey, const juce::String& deviceName); + bool unbind(int deviceKey); + // Close a slot's device but KEEP desiredDeviceName (transient close for + // stop/reconfigure); reopenDesired() restores them after a (re)start. + bool closeSlot(int slot); + void reopenDesired(); + // Shutdown path: stop every slot device FIRST so no slot callback can fire + // into a half-destroyed engine. closeAudioDevice blocks for the callback. + void closeAllForShutdown() + { + for (auto& s : slots) + { + s.manager.closeAudioDevice(); + s.manager.removeAudioCallback(&s.callback); + } + } + + int activeCount() const + { + int n = 0; + for (const auto& s : slots) + if (s.active.load(std::memory_order_acquire)) ++n; + return n; + } + + struct Bindable { juce::String typeName; juce::String name; }; + std::vector listBindable(); + + // Resolution for SourcePool::addResolved — the per-slot readiness/format/ + // latency the pool needs, plus whether the key is usable at all. + struct Resolved { bool usable = false; bool ready = false; double sr = 0.0; int bs = 0; double latencyDelta = 0.0; }; + Resolved resolveForSource(int deviceKey) const + { + Resolved r; + if (deviceKey < 1 || deviceKey > kMaxExtraInputDevices) return r; + const InputDeviceSlot& es = slots[(size_t) (deviceKey - 1)]; + // Bound — either currently open (active) or DEFERRED (validated + desired + // while the engine is stopped, to be reopened by startAudio()). + r.usable = es.active.load(std::memory_order_acquire) || es.desiredDeviceName.isNotEmpty(); + r.ready = es.active.load(std::memory_order_acquire); + r.sr = es.sampleRate.load(std::memory_order_relaxed); + r.bs = es.blockSize.load(std::memory_order_relaxed); + r.latencyDelta = es.latencyDeltaSec.load(std::memory_order_relaxed); + return r; + } + + // PUBLIC: the split output callback drains every active slot's ring in + // place (same loop as before the move). + std::array slots; + +private: + // Per-slot device-callback hooks (audio + device-management threads). + void slotCallback(int slot, const float* const* inputData, int numInputChannels, int numSamples); + void slotAboutToStart(int slot, juce::AudioIODevice* device); + void slotStopped(int slot); + + SourcePool& pool; + EngineState& state; + juce::AudioDeviceManager& primaryManager; +}; + +} // namespace slopsmith From d4e0bfc272487849d891c77ba507aeb8ebb22a24 Mon Sep 17 00:00:00 2001 From: OmikronApex Date: Tue, 14 Jul 2026 01:41:07 +0200 Subject: [PATCH 12/28] refactor(audio): extract AddonContext + NapiHelpers, guard raw N-API args (phase 6) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit AddonContext (src/audio/addon/): engine/vstHost lifetime + snapshot rule, the JUCE message thread with the macOS no-pump fork quarantined into ONE file, the shutdown latch (exposed as isShuttingDown), doShutdown with a UI teardown hook (NodeAddon points it at the editor-window nuke, #56), and the pending-async-load registry. NodeAddon keeps using-declarations so the binding bodies are unchanged. Also fixes SetBackingSpeed's bare `engine` dereference — the one binding that dodged the file's own snapshot rule. NapiHelpers (typed extractors argInt/argSlotId/argFiniteFloat/argBool/ argMidiChannel/argMidiByte) + rewrites of the unguarded bindings — the deep-read §2 fix, done once: SetParameter/SetBypass/RemoveProcessor/ MoveProcessor/SetMultiBypass/SendMidiToSlot/SetGain plus the St-1 routing quartet (SetPan/SetPostGain/SetBranch/SetBranchSrc). NaN slot ids no longer coerce to slot 0; MIDI channel/program are range-checked before JUCE. New gate: tests/napi-arg-fuzz.test.js — table-driven garbage (NaN/Inf/ negative/string/missing/object) against the real addon; chain state must be byte-identical after the storm and a valid call must still apply. Co-Authored-By: Claude Fable 5 --- src/audio/CMakeLists.txt | 1 + src/audio/NodeAddon.cpp | 386 ++++++++----------------------- src/audio/addon/AddonContext.cpp | 228 ++++++++++++++++++ src/audio/addon/AddonContext.h | 66 ++++++ src/audio/addon/NapiHelpers.h | 66 ++++++ tests/napi-arg-fuzz.test.js | 75 ++++++ 6 files changed, 527 insertions(+), 295 deletions(-) create mode 100644 src/audio/addon/AddonContext.cpp create mode 100644 src/audio/addon/AddonContext.h create mode 100644 src/audio/addon/NapiHelpers.h create mode 100644 tests/napi-arg-fuzz.test.js diff --git a/src/audio/CMakeLists.txt b/src/audio/CMakeLists.txt index 1c44e19..f20a6a1 100644 --- a/src/audio/CMakeLists.txt +++ b/src/audio/CMakeLists.txt @@ -11,6 +11,7 @@ set(AUDIO_SOURCES engine/DeviceSetup.cpp engine/SourcePool.cpp engine/ExtraInputs.cpp + addon/AddonContext.cpp SourceChain.cpp SignalChain.cpp VSTHost.cpp diff --git a/src/audio/NodeAddon.cpp b/src/audio/NodeAddon.cpp index 358e193..766da8a 100644 --- a/src/audio/NodeAddon.cpp +++ b/src/audio/NodeAddon.cpp @@ -18,10 +18,6 @@ #include "AudioEngine.h" #include "VSTHost.h" -// Forward declaration — defined alongside loadVstSandboxAware further down. -// doShutdown (below) needs it to release any LoadVSTWorker / LoadPreset- -// Worker blocked on a pending async load before the message thread stops. -static void cancelAllPendingLoads(); #include "VSTTrace.h" #include "NAMProcessor.h" #include "IRLoader.h" @@ -30,29 +26,18 @@ static void cancelAllPendingLoads(); #include -// engine / vstHost — shared_ptr (not unique_ptr) so worker threads can take -// a stable snapshot that keeps the object alive for the duration of their -// work, even if the message thread reassigns the global mid-operation. This -// matters most for the async VST load: createPluginInstanceAsync's JUCE -// continuation must not have VSTHost / its formatManager torn out from -// under it mid-load. -// -// snapshotEngine() / snapshotVstHost() take the global under the matching -// mutex and return a private copy. The only code permitted to touch the -// bare `engine` / `vstHost` globals is the snapshot helpers below and the -// mutex-guarded writes in Init / doShutdown — the message thread mutates -// the globals there while worker threads and the napi handlers read them. -// -// Enforced rule: every *dereference* of engine / vstHost goes through a -// local snapshot. A napi handler takes that snapshot at the top, null- -// checks it, and uses only the local — either for the rest of its body, -// or (for the handlers that hand off to an AsyncWorker — LoadVST, -// LoadNAMModel, LoadIR, LoadPreset) purely as the availability guard -// before queuing, with the worker re-snapshotting on its own thread. -// Either way a concurrent doShutdown reset can never pull the object out -// from under an in-flight dereference. -static std::shared_ptr engine; -static std::mutex engineMutex; +#include "addon/AddonContext.h" +#include "addon/NapiHelpers.h" + +// Lifetime/threading moved to addon/AddonContext (TLC phase 6); the usings +// keep the 100+ existing binding bodies unchanged. +using slopsmith::addon::snapshotEngine; +using slopsmith::addon::snapshotVstHost; +using slopsmith::addon::dispatchOnMessageThread; +using slopsmith::addon::registerPendingLoad; +using slopsmith::addon::unregisterPendingLoad; +using slopsmith::addon::cancelAllPendingLoads; +using slopsmith::addon::doShutdown; // Decode a state blob that may be in EITHER base64 flavour. JUCE's // MemoryBlock::fromBase64Encoding only understands JUCE's own proprietary @@ -80,12 +65,6 @@ static bool decodeStateBlob(const juce::String& s, juce::MemoryBlock& mb, return juce::Base64::convertFromBase64(mo, s) && mb.getSize() > 0; } -static std::shared_ptr snapshotEngine() -{ - std::lock_guard lock(engineMutex); - return engine; -} - // 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 @@ -102,15 +81,6 @@ static SourceChain* getValidatedSource(AudioEngine* eng, const Napi::CallbackInf return eng->getSource((int) raw); } -static std::shared_ptr vstHost; -static std::mutex vstHostMutex; - -static std::shared_ptr snapshotVstHost() -{ - std::lock_guard lock(vstHostMutex); - return vstHost; -} - static double loadSafeSampleRate(const AudioEngine& eng) { const double sr = eng.getCurrentSampleRate(); @@ -123,79 +93,6 @@ static int loadSafeBlockSize(const AudioEngine& eng) return bs > 0 ? bs : 256; } -static std::thread juceMessageThread; -static std::atomic juceRunning{false}; -static std::atomic alreadyShutDown{false}; - -// ── JUCE Message Thread ─────────────────────────────────────────────────────── -// JUCE requires a message thread for plugin loading, audio device management, etc. -// We pump it in a dedicated thread. - -static void startJuceMessageThread() -{ - if (juceRunning.load()) return; - juceRunning.store(true); - -#if JUCE_MAC - // On macOS, JUCE's MessageManager::runDispatchLoopUntil internally calls - // `-[NSApplication _nextEventMatchingEventMask:...]`, which AppKit asserts - // must run on the true main thread. Node.js already owns the main thread - // (running libuv's event loop), so we can't spawn a second NS event pump - // without hitting `nextEventMatchingMask should only be called from the - // Main Thread!` and aborting. - // - // Workaround: designate Node's current thread as JUCE's message thread and - // skip the dispatch loop. callAsync()'d callbacks will still queue; we - // drain them from the Node thread via a libuv timer created below. - juce::MessageManager::getInstance(); -#else - juceMessageThread = std::thread([]() { - juce::MessageManager::getInstance(); - while (juceRunning.load()) - { - juce::MessageManager::getInstance()->runDispatchLoopUntil(50); - } - juce::MessageManager::deleteInstance(); - }); -#endif -} - -static void stopJuceMessageThread() -{ - juceRunning.store(false); -#if !JUCE_MAC - if (juceMessageThread.joinable()) - juceMessageThread.join(); -#else - juce::MessageManager::deleteInstance(); -#endif -} - -// ── Helper: dispatch on JUCE message thread ─────────────────────────────────── - -template -static void dispatchOnMessageThread(Func&& func) -{ -#if JUCE_MAC - // No background message thread on macOS — execute inline on caller thread. - // Audio device / NAM / IR init is thread-safe for our use; VST/AU plugin - // instantiation (which genuinely requires a message thread on macOS) is - // the one capability we give up until a proper libuv-based pump lands. - func(); -#else - // Heap-allocate the WaitableEvent and capture by value so the queued - // callAsync closure can outlive this stack frame. Without this, a 15 s - // timeout (rare, but possible during shutdown when the message thread is - // busy) leaves the lambda running on freed `done` storage — a real UAF. - auto done = std::make_shared(); - juce::MessageManager::callAsync([func = std::forward(func), done]() mutable { - func(); - done->signal(); - }); - done->wait(15000); -#endif -} - // Destroys every in-process plugin editor window. MUST be called on the message // thread (editorWindows holds JUCE GUI objects). Defined far below, after the // editorWindows map; forward-declared here so doShutdown — which already runs on @@ -207,99 +104,10 @@ static void destroyAllPluginEditorWindowsOnMessageThread(); static Napi::Value Init(const Napi::CallbackInfo& info) { - auto env = info.Env(); - - // Reset the shutdown latch so a JS-level init→shutdown→init cycle (e.g. - // a test harness recreating the engine) actually runs shutdown again - // instead of treating it as already-done. - alreadyShutDown.store(false, std::memory_order_release); - - // Start JUCE message thread first (no-op on macOS — see startJuceMessageThread) - startJuceMessageThread(); - -#if !JUCE_MAC - // Small delay to ensure message thread is pumping - std::this_thread::sleep_for(std::chrono::milliseconds(200)); -#endif - - // Create engine on the JUCE message thread (or inline on macOS) - dispatchOnMessageThread([]() { - std::shared_ptr liveEngine; - { - std::lock_guard lock(engineMutex); - engine = std::make_shared(); - liveEngine = engine; - } - { - std::lock_guard lock(vstHostMutex); - vstHost = std::make_shared(); - } - - auto types = liveEngine->getDeviceTypes(); - fprintf(stderr, "[audio-native] Init complete. Device types: %d\n", types.size()); - for (int i = 0; i < types.size(); ++i) - fprintf(stderr, "[audio-native] %s: %d inputs, %d outputs\n", - types[i].name.toRawUTF8(), - types[i].inputDevices.size(), - types[i].outputDevices.size()); - }); - - return env.Undefined(); -} - -static void doShutdown() -{ - // The latch is flipped at the TOP rather than the bottom so a - // re-entrant call (e.g. env-cleanup-hook firing while a JS-level - // shutdown is mid-flight) bails immediately rather than racing on - // the same teardown sequence. Assumed serialisation invariants: - // - dispatchOnMessageThread is single-writer to engine/vstHost - // (both unique_ptrs touched only here or from Init); - // - stopJuceMessageThread is idempotent and safe to call when the - // thread was never started (defensive checks inside). - // If a future caller mutates engine/vstHost between this latch and - // the dispatch (or the dispatch's 15s wait times out), THIS call's - // body may not finish before returning — but the re-entrant - // cleanup-hook will then no-op via the latch and the dispatch - // queue itself unwinds whatever's pending. Net result: at-most- - // once execution of the gated body, even under teardown races. - bool expected = false; - if (!alreadyShutDown.compare_exchange_strong(expected, true)) return; - - // Release any LoadVSTWorker / LoadPresetWorker currently blocked on a - // pending async load. Without this they'd wait forever on the - // WaitableEvent — the createPluginInstanceAsync callback can't fire - // once the message thread is gone. Forward-declared above; the - // implementation lives near loadVstSandboxAware. - cancelAllPendingLoads(); - - if (juceRunning.load() || snapshotEngine() || snapshotVstHost()) - { - dispatchOnMessageThread([]() { - // Editors reference their slot's processor; engine.reset() below - // frees the whole chain, so destroy the editor windows first (#56). - // Already on the message thread here — call the inline variant - // directly (closeAllPluginEditorWindows() would reach the same code - // via its message-thread branch; this just skips the thread check). - destroyAllPluginEditorWindowsOnMessageThread(); - if (auto liveEngine = snapshotEngine()) - liveEngine->stopAudio(); - { - std::lock_guard lock(engineMutex); - engine.reset(); - } - { - std::lock_guard lock(vstHostMutex); - vstHost.reset(); - } - }); - } - - stopJuceMessageThread(); - - // Restore the previous top-level exception filter — the addon (and thus our - // unhandledFilter's code) may be unloaded, so it must not stay installed. - slopsmith::sandbox::uninstallVstCrashAttribution(); + // Engine/vstHost creation + message-thread start live on AddonContext; + // the UI teardown hook runs at shutdown BEFORE engine.reset() (#56). + slopsmith::addon::initialize([] { destroyAllPluginEditorWindowsOnMessageThread(); }); + return info.Env().Undefined(); } static Napi::Value Shutdown(const Napi::CallbackInfo& info) @@ -628,8 +436,11 @@ static Napi::Value SetGain(const Napi::CallbackInfo& info) auto liveEngine = snapshotEngine(); if (!liveEngine || info.Length() < 2) return env.Undefined(); + if (!info[0].IsString()) return env.Undefined(); auto which = info[0].As().Utf8Value(); - float value = info[1].As().FloatValue(); + 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); @@ -2028,28 +1839,6 @@ static Napi::Value SetVstCrashSentinelPath(const Napi::CallbackInfo& info) // signals them all so the workers unblock and return a clean "cancelled" // error instead of hanging forever when the JUCE message thread is about // to be stopped (and any unfired callback would never arrive). -static std::mutex pendingLoadsMutex; -static std::set> pendingLoads; - -static void registerPendingLoad(std::shared_ptr evt) -{ - std::lock_guard lock(pendingLoadsMutex); - pendingLoads.insert(std::move(evt)); -} - -static void unregisterPendingLoad(const std::shared_ptr& evt) -{ - std::lock_guard lock(pendingLoadsMutex); - pendingLoads.erase(evt); -} - -static void cancelAllPendingLoads() -{ - std::lock_guard lock(pendingLoadsMutex); - for (auto& evt : pendingLoads) evt->signal(); - pendingLoads.clear(); -} - // Load a VST3, routing it through the out-of-process sandbox when // shouldSandbox() says so (the filename pre-seed or the runtime crash // blocklist), otherwise loading it in-process. The in-process load uses @@ -2171,7 +1960,7 @@ static std::unique_ptr loadVstSandboxAware( // Check alreadyShutDown after registering to catch the inverse race // (shutdown ran before we registered): if it's already set, the // shutdown won't see this event and we must bail ourselves. - if (alreadyShutDown.load(std::memory_order_acquire)) + if (slopsmith::addon::isShuttingDown()) { unregisterPendingLoad(done); error = "shutdown in flight"; @@ -2195,7 +1984,7 @@ static std::unique_ptr loadVstSandboxAware( // lambda and the message thread picking it up. Bail before // kicking off another in-flight createPluginInstanceAsync // that the shutdown would otherwise have to wait on. - if (alreadyShutDown.load(std::memory_order_acquire)) + if (slopsmith::addon::isShuttingDown()) { *loadError = "shutdown in flight"; done->signal(); @@ -2282,7 +2071,7 @@ public: // mid-load. The atomic alreadyShutDown gate is the early-out: once // it's set, the dispatched reset is on its way and there's no point // continuing. - if (alreadyShutDown.load(std::memory_order_acquire)) + if (slopsmith::addon::isShuttingDown()) { error_ = "shutdown in flight"; return; @@ -2333,7 +2122,7 @@ public: // dispatched reset of engine/vstHost is on its way and any use of // the pointers from this worker thread is racy. The atomic check is // the authoritative "should I still be touching engine?" signal. - if (alreadyShutDown.load(std::memory_order_acquire)) + if (slopsmith::addon::isShuttingDown()) { error_ = "engine torn down during load"; return; @@ -2620,36 +2409,32 @@ static Napi::Value ReplaceIR(const Napi::CallbackInfo& info) static 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 liveEngine = snapshotEngine(); - if (liveEngine && info.Length() > 0) - { - int slotId = info[0].As().Int32Value(); - liveEngine->getSignalChain().removeProcessor(slotId); - } + const auto slotId = slopsmith::addon::argSlotId(info, 0); + if (liveEngine && slotId) + liveEngine->getSignalChain().removeProcessor(*slotId); return info.Env().Undefined(); } static Napi::Value MoveProcessor(const Napi::CallbackInfo& info) { auto liveEngine = snapshotEngine(); - if (liveEngine && info.Length() >= 2) - { - int from = info[0].As().Int32Value(); - int to = info[1].As().Int32Value(); - liveEngine->getSignalChain().moveProcessor(from, to); - } + const auto from = slopsmith::addon::argSlotId(info, 0); + const auto to = slopsmith::addon::argSlotId(info, 1); + if (liveEngine && from && to) + liveEngine->getSignalChain().moveProcessor(*from, *to); return info.Env().Undefined(); } static Napi::Value SetBypass(const Napi::CallbackInfo& info) { auto liveEngine = snapshotEngine(); - if (liveEngine && info.Length() >= 2) - { - int slotId = info[0].As().Int32Value(); - bool bypassed = info[1].As().Value(); - liveEngine->getSignalChain().setBypass(slotId, bypassed); - } + 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(); } @@ -2676,9 +2461,9 @@ static Napi::Value SetPan(const Napi::CallbackInfo& info) auto liveEngine = snapshotEngine(); if (liveEngine && info.Length() >= 2) { - int slotId = info[0].As().Int32Value(); - float pan = (float) info[1].As().DoubleValue(); - liveEngine->getSignalChain().setPan(slotId, pan); + 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(); } @@ -2688,9 +2473,9 @@ static Napi::Value SetPostGain(const Napi::CallbackInfo& info) auto liveEngine = snapshotEngine(); if (liveEngine && info.Length() >= 2) { - int slotId = info[0].As().Int32Value(); - float gain = (float) info[1].As().DoubleValue(); - liveEngine->getSignalChain().setPostGain(slotId, gain); + 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(); } @@ -2700,9 +2485,9 @@ static Napi::Value SetBranch(const Napi::CallbackInfo& info) auto liveEngine = snapshotEngine(); if (liveEngine && info.Length() >= 2) { - int slotId = info[0].As().Int32Value(); - int branch = info[1].As().Int32Value(); - liveEngine->getSignalChain().setBranch(slotId, branch); + 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(); } @@ -2713,9 +2498,9 @@ static Napi::Value SetBranchSrc(const Napi::CallbackInfo& info) auto liveEngine = snapshotEngine(); if (liveEngine && info.Length() >= 2) { - int slotId = info[0].As().Int32Value(); - int src = info[1].As().Int32Value(); - liveEngine->getSignalChain().setBranchSrc(slotId, src); + 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(); } @@ -3138,13 +2923,11 @@ static Napi::Value GetParameters(const Napi::CallbackInfo& info) static Napi::Value SetParameter(const Napi::CallbackInfo& info) { auto liveEngine = snapshotEngine(); - if (liveEngine && info.Length() >= 3) - { - int slotId = info[0].As().Int32Value(); - int paramIdx = info[1].As().Int32Value(); - float value = info[2].As().FloatValue(); - liveEngine->getSignalChain().setParameter(slotId, paramIdx, value); - } + 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(); } @@ -3178,26 +2961,30 @@ static Napi::Value SendMidiToSlot(const Napi::CallbackInfo& info) if (!liveEngine || info.Length() < 4) return Napi::Boolean::New(env, false); - int slotId = info[0].As().Int32Value(); - int msgType = info[1].As().Int32Value(); - int channel = info[2].As().Int32Value(); - - juce::MidiMessage midiMsg; - if (msgType == 0) // Program Change - { - int program = info[3].As().Int32Value(); - midiMsg = juce::MidiMessage::programChange(channel, program); - } - else if (msgType == 1) // Control Change - { - int controller = info[3].As().Int32Value(); - int value = info.Length() > 4 ? info[4].As().Int32Value() : 0; - midiMsg = juce::MidiMessage::controllerEvent(channel, controller, value); - } - else + // 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); - liveEngine->getSignalChain().queueMidiMessage(slotId, midiMsg); + 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); } @@ -3264,8 +3051,10 @@ static Napi::Value SetBackingSpeed(const Napi::CallbackInfo& info) .ThrowAsJavaScriptException(); return env.Undefined(); } - if (engine) - engine->setBackingSpeed(info[0].As().DoubleValue()); + // (Was a bare `engine` dereference — the one binding that dodged the + // file's own snapshot rule; surfaced by the phase-6 move.) + if (auto liveEngine = snapshotEngine()) + liveEngine->setBackingSpeed(info[0].As().DoubleValue()); return env.Undefined(); } @@ -3469,10 +3258,17 @@ static Napi::Value SetMultiBypass(const Napi::CallbackInfo& info) for (uint32_t i = 0; i < arr.Length(); i++) { - auto item = arr.Get(i).As(); - int slotId = item.Get("slotId").As().Int32Value(); - bool bypassed = item.Get("bypassed").As().Value(); - changes.add({ slotId, bypassed }); + // Per-item type guards (deep-read §2): a malformed entry is skipped + // instead of coercing NaN to slot 0. + auto itemVal = arr.Get(i); + if (!itemVal.IsObject()) continue; + auto item = itemVal.As(); + auto slotVal = item.Get("slotId"); + auto bypVal = item.Get("bypassed"); + if (!slotVal.IsNumber() || !bypVal.IsBoolean()) continue; + const double raw = slotVal.As().DoubleValue(); + if (!std::isfinite(raw) || raw != std::floor(raw) || raw < 0.0 || raw > 4096.0) continue; + changes.add({ (int) raw, bypVal.As().Value() }); } liveEngine->getSignalChain().setMultiBypass(changes); diff --git a/src/audio/addon/AddonContext.cpp b/src/audio/addon/AddonContext.cpp new file mode 100644 index 0000000..b2050e5 --- /dev/null +++ b/src/audio/addon/AddonContext.cpp @@ -0,0 +1,228 @@ +// AddonContext implementation — moved verbatim from NodeAddon.cpp (TLC plan +// phase 6 / §3.1). See AddonContext.h for the lifetime rules. + +#include "AddonContext.h" + +#include "../Sandbox/CrashAttribution.h" + +#include +#include +#include +#include + +namespace slopsmith::addon { + +static std::shared_ptr engine; +static std::mutex engineMutex; +static std::shared_ptr vstHost; +static std::mutex vstHostMutex; + +static std::thread juceMessageThread; +static std::atomic juceRunning{false}; +static std::atomic alreadyShutDown{false}; + +// Runs on the message thread at the start of shutdown, before engine.reset() +// frees the processors any editor windows point at (#56). Set by initialize(). +static std::function shutdownUiTeardown; + +std::shared_ptr snapshotEngine() +{ + std::lock_guard lock(engineMutex); + return engine; +} + +std::shared_ptr snapshotVstHost() +{ + std::lock_guard lock(vstHostMutex); + return vstHost; +} + +// ── JUCE Message Thread ────────────────────────────────────────────────────── +// JUCE requires a message thread for plugin loading, audio device management, +// etc. We pump it in a dedicated thread. + +static void startJuceMessageThread() +{ + if (juceRunning.load()) return; + juceRunning.store(true); + +#if JUCE_MAC + // On macOS, JUCE's MessageManager::runDispatchLoopUntil internally calls + // `-[NSApplication _nextEventMatchingEventMask:...]`, which AppKit asserts + // must run on the true main thread. Node.js already owns the main thread + // (running libuv's event loop), so we can't spawn a second NS event pump + // without hitting `nextEventMatchingMask should only be called from the + // Main Thread!` and aborting. + // + // Workaround: designate Node's current thread as JUCE's message thread and + // skip the dispatch loop. callAsync()'d callbacks will still queue; we + // drain them from the Node thread via a libuv timer created below. + juce::MessageManager::getInstance(); +#else + juceMessageThread = std::thread([]() { + juce::MessageManager::getInstance(); + while (juceRunning.load()) + { + juce::MessageManager::getInstance()->runDispatchLoopUntil(50); + } + juce::MessageManager::deleteInstance(); + }); +#endif +} + +static void stopJuceMessageThread() +{ + juceRunning.store(false); +#if !JUCE_MAC + if (juceMessageThread.joinable()) + juceMessageThread.join(); +#else + juce::MessageManager::deleteInstance(); +#endif +} + +void dispatchOnMessageThreadImpl(std::function func) +{ +#if JUCE_MAC + // No background message thread on macOS — execute inline on caller thread. + // Audio device / NAM / IR init is thread-safe for our use; VST/AU plugin + // instantiation (which genuinely requires a message thread on macOS) is + // the one capability we give up until a proper libuv-based pump lands. + func(); +#else + // Heap-allocate the WaitableEvent and capture by value so the queued + // callAsync closure can outlive this stack frame. Without this, a 15 s + // timeout (rare, but possible during shutdown when the message thread is + // busy) leaves the lambda running on freed `done` storage — a real UAF. + auto done = std::make_shared(); + juce::MessageManager::callAsync([func = std::move(func), done]() mutable { + func(); + done->signal(); + }); + done->wait(15000); +#endif +} + +// ── Pending async loads ────────────────────────────────────────────────────── + +static std::mutex pendingLoadsMutex; +static std::set> pendingLoads; + +bool isShuttingDown() +{ + return alreadyShutDown.load(std::memory_order_acquire); +} + +void registerPendingLoad(std::shared_ptr evt) +{ + std::lock_guard lock(pendingLoadsMutex); + pendingLoads.insert(std::move(evt)); +} + +void unregisterPendingLoad(const std::shared_ptr& evt) +{ + std::lock_guard lock(pendingLoadsMutex); + pendingLoads.erase(evt); +} + +void cancelAllPendingLoads() +{ + std::lock_guard lock(pendingLoadsMutex); + for (auto& evt : pendingLoads) evt->signal(); + pendingLoads.clear(); +} + +// ── Lifecycle ──────────────────────────────────────────────────────────────── + +void initialize(std::function uiTeardownHook) +{ + shutdownUiTeardown = std::move(uiTeardownHook); + + // Reset the shutdown latch so a JS-level init→shutdown→init cycle (e.g. + // a test harness recreating the engine) actually runs shutdown again + // instead of treating it as already-done. + alreadyShutDown.store(false, std::memory_order_release); + + // Start JUCE message thread first (no-op on macOS) + startJuceMessageThread(); + +#if !JUCE_MAC + // Small delay to ensure message thread is pumping + std::this_thread::sleep_for(std::chrono::milliseconds(200)); +#endif + + // Create engine on the JUCE message thread (or inline on macOS) + dispatchOnMessageThread([]() { + std::shared_ptr liveEngine; + { + std::lock_guard lock(engineMutex); + engine = std::make_shared(); + liveEngine = engine; + } + { + std::lock_guard lock(vstHostMutex); + vstHost = std::make_shared(); + } + + auto types = liveEngine->getDeviceTypes(); + fprintf(stderr, "[audio-native] Init complete. Device types: %d\n", types.size()); + for (int i = 0; i < types.size(); ++i) + fprintf(stderr, "[audio-native] %s: %d inputs, %d outputs\n", + types[i].name.toRawUTF8(), + types[i].inputDevices.size(), + types[i].outputDevices.size()); + }); +} + +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()) + { + dispatchOnMessageThread([]() { + // Editors reference their slot's processor; engine.reset() below + // frees the whole chain, so destroy the editor windows first (#56). + if (shutdownUiTeardown) shutdownUiTeardown(); + if (auto liveEngine = snapshotEngine()) + liveEngine->stopAudio(); + { + std::lock_guard lock(engineMutex); + engine.reset(); + } + { + std::lock_guard lock(vstHostMutex); + vstHost.reset(); + } + }); + } + + stopJuceMessageThread(); + + // Restore the previous top-level exception filter — the addon (and thus our + // unhandledFilter's code) may be unloaded, so it must not stay installed. + slopsmith::sandbox::uninstallVstCrashAttribution(); +} + +} // namespace slopsmith::addon diff --git a/src/audio/addon/AddonContext.h b/src/audio/addon/AddonContext.h new file mode 100644 index 0000000..879da2a --- /dev/null +++ b/src/audio/addon/AddonContext.h @@ -0,0 +1,66 @@ +#pragma once + +// AddonContext — engine/vstHost lifetime, the JUCE message thread, the +// shutdown latch, and the pending-async-load registry (TLC plan phase 6 / +// §3.1). Moved verbatim from NodeAddon.cpp; this quarantines the JUCE_MAC +// platform fork (no dispatch loop — see startJuceMessageThread) into ONE +// file instead of a branch inside every load path. +// +// engine / vstHost — shared_ptr (not unique_ptr) so worker threads can take +// a stable snapshot that keeps the object alive for the duration of their +// work, even if the message thread reassigns the global mid-operation. This +// matters most for the async VST load: createPluginInstanceAsync's JUCE +// continuation must not have VSTHost / its formatManager torn out from +// under it mid-load. +// +// Enforced rule: every *dereference* of engine / vstHost goes through a +// local snapshot (snapshotEngine / snapshotVstHost). The only code touching +// the bare globals is the snapshot helpers and the mutex-guarded writes in +// initialize / doShutdown. + +#include "../AudioEngine.h" +#include "../VSTHost.h" + +#include + +#include +#include +#include +#include + +namespace slopsmith::addon { + +std::shared_ptr snapshotEngine(); +std::shared_ptr snapshotVstHost(); + +// Start the pump + create engine/vstHost on the message thread (inline on +// macOS). `uiTeardownHook` runs on the message thread at the START of +// shutdown, BEFORE engine.reset() frees the processors — NodeAddon points it +// at destroyAllPluginEditorWindowsOnMessageThread (use-after-free; #56). +void initialize(std::function uiTeardownHook); +void doShutdown(); + +// Dispatch `func` on the JUCE message thread and wait (bounded 15 s). +// macOS: executes inline on the caller thread — no background pump exists +// (AppKit owns the real main thread; see the fork note in the .cpp). +void dispatchOnMessageThreadImpl(std::function func); +template +inline void dispatchOnMessageThread(Func&& func) +{ + dispatchOnMessageThreadImpl(std::function(std::forward(func))); +} + +// Pending-async-load registry: LoadVSTWorker / LoadPresetWorker block on a +// WaitableEvent until the message-thread continuation fires; doShutdown +// signals every registered event so no worker waits forever once the pump +// is gone. +// Whether doShutdown has begun (acquire). The load workers gate on this +// after registering their pending event, catching the register-vs-shutdown +// race in both directions. +bool isShuttingDown(); + +void registerPendingLoad(std::shared_ptr evt); +void unregisterPendingLoad(const std::shared_ptr& evt); +void cancelAllPendingLoads(); + +} // namespace slopsmith::addon diff --git a/src/audio/addon/NapiHelpers.h b/src/audio/addon/NapiHelpers.h new file mode 100644 index 0000000..e52511d --- /dev/null +++ b/src/audio/addon/NapiHelpers.h @@ -0,0 +1,66 @@ +#pragma once + +// NapiHelpers — typed N-API argument extractors (TLC plan phase 6 / §3.2). +// Generalizes the getValidatedSource pattern so argument validation is +// structural, not per-binding: Int32Value() silently coerces NaN/Infinity +// into a valid index (NaN → 0), which let a malformed slot id hit a REAL +// slot (deep-read §2). Every extractor returns nullopt for a missing / +// non-Number / non-finite / out-of-range argument, and the binding no-ops — +// fail-soft, matching the addon's NAPI_DISABLE_CPP_EXCEPTIONS posture. +// +// New bindings should have no raw As() path to copy. + +#include + +#include +#include + +namespace slopsmith::addon { + +// Finite integer in [minV, maxV]. The 4096 default ceiling keeps the cast +// well-defined for id-shaped args (slot ids, source ids, indices). +inline std::optional argInt(const Napi::CallbackInfo& info, size_t i, + int minV = 0, int maxV = 4096) +{ + if (i >= info.Length() || ! info[i].IsNumber()) return std::nullopt; + const double raw = info[i].As().DoubleValue(); + if (! std::isfinite(raw) || raw != std::floor(raw)) return std::nullopt; + if (raw < (double) minV || raw > (double) maxV) return std::nullopt; + return (int) raw; +} + +// Slot / source / param-index ids: finite non-negative integers. +inline std::optional argSlotId(const Napi::CallbackInfo& info, size_t i) +{ + return argInt(info, i); +} + +// Finite float (parameter values, gains, pans). Range clamping stays with +// the engine-side sanitizers (GainSanitize.h) — this only rejects the +// NaN/Inf class that coercion would otherwise let through. +inline std::optional argFiniteFloat(const Napi::CallbackInfo& info, size_t i) +{ + if (i >= info.Length() || ! info[i].IsNumber()) return std::nullopt; + const double raw = info[i].As().DoubleValue(); + if (! std::isfinite(raw)) return std::nullopt; + return (float) raw; +} + +inline std::optional argBool(const Napi::CallbackInfo& info, size_t i) +{ + if (i >= info.Length() || ! info[i].IsBoolean()) return std::nullopt; + return info[i].As().Value(); +} + +// MIDI channel: JUCE expects 1..16 and asserts otherwise. +inline std::optional argMidiChannel(const Napi::CallbackInfo& info, size_t i) +{ + return argInt(info, i, 1, 16); +} +// MIDI data byte (program / controller / value): 0..127. +inline std::optional argMidiByte(const Napi::CallbackInfo& info, size_t i) +{ + return argInt(info, i, 0, 127); +} + +} // namespace slopsmith::addon diff --git a/tests/napi-arg-fuzz.test.js b/tests/napi-arg-fuzz.test.js new file mode 100644 index 0000000..1beb593 --- /dev/null +++ b/tests/napi-arg-fuzz.test.js @@ -0,0 +1,75 @@ +// 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); +} + +const GARBAGE = [NaN, Infinity, -Infinity, -1, 1.5, 4097, '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 }); + } +}); From db337eaf2992a5d19a88f89f42471879970bf176 Mon Sep 17 00:00:00 2001 From: OmikronApex Date: Tue, 14 Jul 2026 01:45:01 +0200 Subject: [PATCH 13/28] fix(audio): serialize chain mutations + chainGeneration (phase 7a) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The single highest-value fix of the TLC pass (deep-read §1): one native chain-mutation mutex (addon/ChainOps) held across the FULL Execute() of every chain worker (LoadPreset/LoadVST/LoadNAM/LoadIR/ReplaceIR) and the synchronous mutators (clearChain/removeProcessor/moveProcessor). Two overlapping loadPreset calls can no longer interleave clear()/addProcessor() into a merged-garbage chain — the plugin-vs-plugin fight becomes last-writer-wins. chainGeneration (monotonic, bumped under the mutex) is returned in loadPreset results and exposed as getChainGeneration (new export, snapshot regenerated), so the audio-effects executor can detect a foreign write invalidated its stageSlots map and re-sync instead of flipping bypass/params on wrong slots — the prerequisite for the single-chain-owner ownership track. Also rides here: LoadPresetWorker's slot-state restore goes through setSlotState() instead of const_cast (deep-read §9). The phase-0 storm test flips from expected-fail to a hard gate: 50 iterations of concurrent loadPreset now always end with exactly one caller's chain. Co-Authored-By: Claude Fable 5 --- src/audio/CMakeLists.txt | 1 + src/audio/NodeAddon.cpp | 51 ++++++++++++++++++++++++++++-- src/audio/addon/ChainOps.cpp | 25 +++++++++++++++ src/audio/addon/ChainOps.h | 44 ++++++++++++++++++++++++++ tests/chain-mutation-storm.test.js | 2 +- tests/contracts/addon-exports.json | 1 + 6 files changed, 120 insertions(+), 4 deletions(-) create mode 100644 src/audio/addon/ChainOps.cpp create mode 100644 src/audio/addon/ChainOps.h diff --git a/src/audio/CMakeLists.txt b/src/audio/CMakeLists.txt index f20a6a1..e2306bd 100644 --- a/src/audio/CMakeLists.txt +++ b/src/audio/CMakeLists.txt @@ -12,6 +12,7 @@ set(AUDIO_SOURCES engine/SourcePool.cpp engine/ExtraInputs.cpp addon/AddonContext.cpp + addon/ChainOps.cpp SourceChain.cpp SignalChain.cpp VSTHost.cpp diff --git a/src/audio/NodeAddon.cpp b/src/audio/NodeAddon.cpp index 766da8a..46fdb48 100644 --- a/src/audio/NodeAddon.cpp +++ b/src/audio/NodeAddon.cpp @@ -28,6 +28,7 @@ #include "addon/AddonContext.h" #include "addon/NapiHelpers.h" +#include "addon/ChainOps.h" // Lifetime/threading moved to addon/AddonContext (TLC phase 6); the usings // keep the 100+ existing binding bodies unchanged. @@ -2064,6 +2065,9 @@ public: void Execute() override { + // Serialize the FULL mutation (TLC deep-read 1): overlapping chain + // workers on the libuv pool must not interleave clear()/addProcessor(). + std::lock_guard chainLock(slopsmith::addon::chainMutationMutex()); // Snapshot engine + vstHost through their mutex-protected helpers so // shutdown's reset on the message thread can't race the worker's // dereferences below. The shared_ptr locals keep both objects alive @@ -2250,6 +2254,9 @@ public: void Execute() override { + // Serialize the FULL mutation (TLC deep-read 1): overlapping chain + // workers on the libuv pool must not interleave clear()/addProcessor(). + std::lock_guard chainLock(slopsmith::addon::chainMutationMutex()); auto liveEngine = snapshotEngine(); if (!liveEngine) { slotId_ = -1; return; } @@ -2298,6 +2305,9 @@ public: void Execute() override { + // Serialize the FULL mutation (TLC deep-read 1): overlapping chain + // workers on the libuv pool must not interleave clear()/addProcessor(). + std::lock_guard chainLock(slopsmith::addon::chainMutationMutex()); auto liveEngine = snapshotEngine(); if (!liveEngine) { slotId_ = -1; return; } @@ -2357,6 +2367,9 @@ public: void Execute() override { + // Serialize the FULL mutation (TLC deep-read 1): overlapping chain + // workers on the libuv pool must not interleave clear()/addProcessor(). + std::lock_guard chainLock(slopsmith::addon::chainMutationMutex()); auto liveEngine = snapshotEngine(); if (!liveEngine) { ok_ = false; return; } @@ -2414,7 +2427,11 @@ static Napi::Value RemoveProcessor(const Napi::CallbackInfo& info) auto liveEngine = snapshotEngine(); const auto slotId = slopsmith::addon::argSlotId(info, 0); if (liveEngine && slotId) + { + std::lock_guard chainLock(slopsmith::addon::chainMutationMutex()); liveEngine->getSignalChain().removeProcessor(*slotId); + slopsmith::addon::bumpChainGeneration(); + } return info.Env().Undefined(); } @@ -2424,7 +2441,11 @@ static Napi::Value MoveProcessor(const Napi::CallbackInfo& info) const auto from = slopsmith::addon::argSlotId(info, 0); const auto to = slopsmith::addon::argSlotId(info, 1); if (liveEngine && from && to) + { + std::lock_guard chainLock(slopsmith::addon::chainMutationMutex()); liveEngine->getSignalChain().moveProcessor(*from, *to); + slopsmith::addon::bumpChainGeneration(); + } return info.Env().Undefined(); } @@ -2451,7 +2472,15 @@ static Napi::Value ClearChain(const Napi::CallbackInfo& info) { // Tear editors down before their processors are freed just below (#56). closeAllPluginEditorWindows(); - if (auto liveEngine = snapshotEngine()) liveEngine->getSignalChain().clear(); + if (auto liveEngine = snapshotEngine()) + { + // Serialized with the async chain workers (deep-read 1). May block + // briefly behind an in-flight preset/VST load -- that wait IS the fix + // for the interleaved clear-vs-rebuild corruption. + std::lock_guard chainLock(slopsmith::addon::chainMutationMutex()); + liveEngine->getSignalChain().clear(); + slopsmith::addon::bumpChainGeneration(); + } return info.Env().Undefined(); } @@ -2507,6 +2536,14 @@ static Napi::Value SetBranchSrc(const Napi::CallbackInfo& info) // ── 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. +static Napi::Value GetChainGeneration(const Napi::CallbackInfo& info) +{ + return Napi::Number::New(info.Env(), (double) slopsmith::addon::currentChainGeneration()); +} + static Napi::Value GetChainState(const Napi::CallbackInfo& info) { auto env = info.Env(); @@ -3077,6 +3114,9 @@ public: void Execute() override { + // Serialize the FULL mutation (TLC deep-read 1): overlapping chain + // workers on the libuv pool must not interleave clear()/addProcessor(). + std::lock_guard chainLock(slopsmith::addon::chainMutationMutex()); auto liveEngine = snapshotEngine(); if (!liveEngine) { success_ = false; error_ = "No engine"; return; } @@ -3188,8 +3228,9 @@ public: juce::MemoryBlock state; if (decodeStateBlob(stateB64, state, allowStandard)) { - auto* slot = const_cast(liveEngine->getSignalChain().getSlot(slotId)); - if (slot) slot->setState(state); + // Through the class's own synchronized API (deep-read 9) -- + // no more const_cast around setSlotState's locking. + liveEngine->getSignalChain().setSlotState(slotId, state); } } @@ -3197,6 +3238,7 @@ public: } success_ = true; + generation_ = slopsmith::addon::bumpChainGeneration(); // still under chainLock } void OnOK() override @@ -3204,6 +3246,7 @@ public: auto obj = Napi::Object::New(Env()); obj.Set("success", success_); obj.Set("slotsLoaded", slotsLoaded_); + obj.Set("chainGeneration", (double) generation_); if (!success_) obj.Set("error", error_); deferred_.Resolve(obj); } @@ -3212,6 +3255,7 @@ public: private: Napi::Promise::Deferred deferred_; std::string presetJson_; + uint64_t generation_ = 0; bool success_ = false; std::string error_; int slotsLoaded_ = 0; @@ -3458,6 +3502,7 @@ static Napi::Object InitModule(Napi::Env env, Napi::Object exports) exports.Set("setBranchSrc", Napi::Function::New(env, SetBranchSrc)); exports.Set("clearChain", Napi::Function::New(env, ClearChain)); exports.Set("getChainState", Napi::Function::New(env, GetChainState)); + exports.Set("getChainGeneration", Napi::Function::New(env, GetChainGeneration)); exports.Set("openPluginEditor", Napi::Function::New(env, OpenPluginEditor)); exports.Set("closePluginEditor", Napi::Function::New(env, ClosePluginEditor)); diff --git a/src/audio/addon/ChainOps.cpp b/src/audio/addon/ChainOps.cpp new file mode 100644 index 0000000..3dbe091 --- /dev/null +++ b/src/audio/addon/ChainOps.cpp @@ -0,0 +1,25 @@ +#include "ChainOps.h" + +#include + +namespace slopsmith::addon { + +std::mutex& chainMutationMutex() +{ + static std::mutex m; + return m; +} + +static std::atomic chainGeneration{0}; + +uint64_t bumpChainGeneration() +{ + return chainGeneration.fetch_add(1, std::memory_order_acq_rel) + 1; +} + +uint64_t currentChainGeneration() +{ + return chainGeneration.load(std::memory_order_acquire); +} + +} // namespace slopsmith::addon diff --git a/src/audio/addon/ChainOps.h b/src/audio/addon/ChainOps.h new file mode 100644 index 0000000..207636b --- /dev/null +++ b/src/audio/addon/ChainOps.h @@ -0,0 +1,44 @@ +#pragma once + +// ChainOps — the native chain-mutation serialization point (TLC plan phase 7 +// / §3.3, deep-read §1). +// +// The five chain-mutating async workers (LoadPreset/LoadVST/LoadNAM/LoadIR/ +// ReplaceIR) queue on the libuv threadpool with no mutual exclusion, and +// SignalChain locks per-operation only — so two overlapping loadPreset calls +// could interleave clear()/addProcessor() and merge both presets into +// garbage (the documented rig_builder-vs-bundle "~1ms later" race). One +// mutex held across each worker's FULL Execute() — and across the +// synchronous mutators (clearChain / remove / move) — converts that +// corruption into last-writer-wins. +// +// chainGeneration is bumped on every completed mutation and returned in the +// load results (and via getChainGeneration), so JS-side owners (the +// audio-effects executor's stageSlots map) can detect that another writer +// changed the chain under them and re-sync instead of flipping bypass/params +// on the wrong slots. +// +// The full worker bodies migrate into this unit with the phase-7 binding +// split; the serializer lands first so the storm gate flips. + +#include +#include + +namespace slopsmith::addon { + +// Held for the FULL clear+rebuild (or single-slot mutation). Control/worker +// threads only — never the audio thread. +std::mutex& chainMutationMutex(); + +// Monotonic, bumped AFTER a completed mutation (under the mutex). 0 = never +// mutated. +uint64_t bumpChainGeneration(); +uint64_t currentChainGeneration(); + +// Usage in a mutator: +// std::lock_guard chainLock(chainMutationMutex()); +// ... clear/rebuild/add ... +// const uint64_t gen = bumpChainGeneration(); // still under the lock +// (return gen in the result object) + +} // namespace slopsmith::addon diff --git a/tests/chain-mutation-storm.test.js b/tests/chain-mutation-storm.test.js index cb6c547..e6301c7 100644 --- a/tests/chain-mutation-storm.test.js +++ b/tests/chain-mutation-storm.test.js @@ -46,7 +46,7 @@ function irPreset(irFile, slotCount) { }); } -test('concurrent loadPreset calls end with exactly one caller\'s chain', { skip: !ENABLED && 'quarantined — set CHAIN_STORM=1 (expected-fail until ChainOps serializer)' }, async () => { +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 diff --git a/tests/contracts/addon-exports.json b/tests/contracts/addon-exports.json index c1a6da0..aac4fa5 100644 --- a/tests/contracts/addon-exports.json +++ b/tests/contracts/addon-exports.json @@ -10,6 +10,7 @@ "getBackingLevel", "getBackingPosition", "getBufferSizes", + "getChainGeneration", "getChainState", "getCurrentDevice", "getDeviceMetrics", From dd40b2f2272ec702f65464e0a1e23345eb866fdc Mon Sep 17 00:00:00 2001 From: OmikronApex Date: Tue, 14 Jul 2026 01:47:32 +0200 Subject: [PATCH 14/28] fix(audio): renderer-bus flush flag + reconfigure reads user intent (phase 8) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two deep-read fixes now homed in their phase-1/2 units: RendererBus (§4): setEnabled(false) no longer writes readIndex from the control thread — the ring's designated consumer-side writer is pull(). The drop-on-disable is now a flushRequested atomic the consumer honors at its next pull, closing the last SPSC-discipline hole (a concurrent pull mid-drain could overwrite the control thread's store and replay a stale tail after re-enable). New unit test pins flush-then-fresh-audio. setAudioDevices (§3): the restart decision reads state.userWantsAudio (intent, written only by start/stopAudio) instead of the racy device-state flag that transient audioDeviceStopped() fires clear — a reconfigure landing inside a transient-stop window no longer leaves the engine configured but stopped ('no audio until Start/Apply is pressed again'). Co-Authored-By: Claude Fable 5 --- src/audio/AudioEngine.cpp | 10 +++++++++- src/audio/engine/RendererBus.h | 21 ++++++++++++++------ tests/engine_units/renderer_bus_test.cpp | 25 ++++++++++++++++++++++++ 3 files changed, 49 insertions(+), 7 deletions(-) diff --git a/src/audio/AudioEngine.cpp b/src/audio/AudioEngine.cpp index 2015263..84949a0 100644 --- a/src/audio/AudioEngine.cpp +++ b/src/audio/AudioEngine.cpp @@ -388,7 +388,15 @@ AudioEngine::DeviceConfigResult AudioEngine::setAudioDevices(const DeviceConfig& // stale output callback attached. stopAudio() is itself idempotent // (R9 fix — removeAudioCallback is a no-op when not registered), so // running it unconditionally is safe regardless of audioRunning. - const bool wasRunning = audioRunning.load(std::memory_order_relaxed); + // Read USER INTENT, not device state (deep-read §3 fix): audioRunning + // (deviceRunning) is cleared by transient audioDeviceStopped() fires — + // WASAPI exclusive opens routinely fire one mid-start — so a reconfigure + // racing that window used to see false and leave the engine configured + // but stopped ("no audio until Start/Apply is pressed again"). + // userWantsAudio is written only by start/stopAudio, so it answers the + // question this restart decision actually asks. NOTE: stopAudio() below + // clears the intent flag, hence the capture BEFORE it. + const bool wasRunning = state.userWantsAudio.load(std::memory_order_relaxed); // stopAudio() closes every extra input device but KEEPS its desiredDeviceName; // the startAudio() below re-opens them at the new config (so panels using a diff --git a/src/audio/engine/RendererBus.h b/src/audio/engine/RendererBus.h index a01c6c8..765b2e3 100644 --- a/src/audio/engine/RendererBus.h +++ b/src/audio/engine/RendererBus.h @@ -42,12 +42,13 @@ public: 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. - // KNOWN ISSUE (deep-read §4, fixed in the follow-up commit): this - // writes readIndex from the control thread while pull() is the - // designated consumer-side writer. - ring.readIndex.store(ring.writeIndex.load(std::memory_order_acquire), - std::memory_order_release); + // 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. + flushRequested.store(true, std::memory_order_release); primed.store(false, std::memory_order_relaxed); } } @@ -105,6 +106,11 @@ public: // 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. + if (flushRequested.exchange(false, std::memory_order_acq_rel)) + ring.commitRead(ring.writeIndex.load(std::memory_order_acquire)); 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); @@ -196,6 +202,9 @@ private: // Consumer-side prefill-gate state. Only the live output callback touches // it, but duplex/split hand-offs cross threads — atomic keeps that safe. std::atomic primed{false}; + // Set by setEnabled(false) on the control thread, consumed (exchange) by + // pull() — the drop-on-disable request, honored by the single consumer. + std::atomic flushRequested{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). diff --git a/tests/engine_units/renderer_bus_test.cpp b/tests/engine_units/renderer_bus_test.cpp index 51cbf82..6dcffe0 100644 --- a/tests/engine_units/renderer_bus_test.cpp +++ b/tests/engine_units/renderer_bus_test.cpp @@ -141,6 +141,30 @@ static void testGainApplied() assert(dl[0] == 2.0f && dr[0] == -2.0f); } +// Disable drops the buffered tail — via the consumer-honored flush flag +// (deep-read §4 fix), so a re-enable never replays stale audio. +static void testFlushOnDisable() +{ + RendererBus bus; + bus.setEnabled(true, 1.0f); + const auto chunk = rampChunk(RendererBus::kPrimeFrames * 2, 5.0f, 0.0f); + bus.push(chunk.data(), RendererBus::kPrimeFrames * 2, 48000.0, 48000.0); + bus.setEnabled(false, 1.0f); // requests the flush; consumer performs it + bus.setEnabled(true, 1.0f); + std::vector dl(64), dr(64); + // First pull consumes the flush: the pre-disable tail is gone, so the bus + // is empty and (re-)priming — nothing plays. + assert(bus.pull(dl.data(), dr.data(), 64) == 0 && "stale tail must not replay"); + assert(bus.metrics().fillFrames == 0 && "flush must drop the buffered tail"); + // Fresh audio after the re-enable flows once primed. + const auto fresh = rampChunk(RendererBus::kPrimeFrames + 65, 7.0f, 0.0f); + bus.push(fresh.data(), RendererBus::kPrimeFrames + 65, 48000.0, 48000.0); + assert(bus.pull(dl.data(), dr.data(), 64) == 64); + // Frame 0 is the resampler's one-frame interpolation carry (by design); + // everything after must be the fresh push, not the flushed 5.0 tail. + assert(dl[1] == 7.0f && "post-re-enable audio must be the fresh push"); +} + int main() { testEqualRateBitExact(); @@ -150,6 +174,7 @@ int main() testFillClampTrimsBacklog(); testDisabledIsInert(); testGainApplied(); + testFlushOnDisable(); std::puts("renderer_bus: all cases passed"); return 0; } From 7b729075034813056725ed7da18721f7b373ee95 Mon Sep 17 00:00:00 2001 From: OmikronApex Date: Tue, 14 Jul 2026 02:19:42 +0200 Subject: [PATCH 15/28] fix(audio-effects): executor detects foreign chain writes via chainGeneration MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The JS half of the phase-7a serializer (TLC Part II §1, executor-state hazard): the executor's stageSlots map (stageId → native slotId) is built at load time, but any direct loadPreset/clearChain from the audio_engine bundle or rig_builder's legacy path silently invalidated it — subsequent setStageBypass/setStageParameter/activateSegment flipped bypass/params on the WRONG slots or returned no-target with nothing detecting the divergence. Now: the route records the chainGeneration its load returned; every stage operation compares it against getChainGeneration() first and reports a stale-route no-target ('re-load the plan', with expected/current generations) instead of mutating someone else's chain. loadChainPlan also verifies the generation didn't move between its loadPreset and the getChainState slot mapping, rolling back if a foreign write landed in that window. Old addons without the counter degrade gracefully (checks no-op). Pinned by a new executor test: fresh route flows, foreign bump → all three stage ops refuse without touching native slots. Co-Authored-By: Claude Fable 5 --- src/main/audio-effects-executor.ts | 61 ++++++++++++++++++++++++++-- tests/audio-effects-executor.test.js | 48 ++++++++++++++++++++++ 2 files changed, 106 insertions(+), 3 deletions(-) diff --git a/src/main/audio-effects-executor.ts b/src/main/audio-effects-executor.ts index 2860fae..ac02a89 100644 --- a/src/main/audio-effects-executor.ts +++ b/src/main/audio-effects-executor.ts @@ -29,6 +29,7 @@ type AudioEffectsNativeAudio = { savePreset?: () => unknown; clearChain?: () => Promise | unknown; getChainState?: () => unknown; + getChainGeneration?: () => unknown; setBypass?: (slotId: number, bypassed: boolean) => unknown; setMultiBypass?: (changes: Array<{ slotId: number; bypassed: boolean }>) => unknown; setParameter?: (slotId: number, paramIndex: number, value: number) => unknown; @@ -89,6 +90,11 @@ type RouteState = { state: string; activeSegmentId: string; stageSlots: Map; + // Native chainGeneration this route's stageSlots map was built against + // (phase 7a). A foreign writer (legacy loadPreset / clearChain) bumps the + // native counter, invalidating the slot ids; stage operations detect the + // divergence and report stale-route instead of mutating wrong slots. + chainGeneration: number; stageKinds: Map; segments: ValidSegment[]; loadedAt: string; @@ -373,16 +379,24 @@ function validatePlan(request: unknown): { ok: true; plan: ValidPlan; presetJson }; } -function normalizeLoadResult(value: unknown): { success: boolean; slotsLoaded: number; error: string } { +function normalizeLoadResult(value: unknown): { success: boolean; slotsLoaded: number; error: string; chainGeneration: number } { const record = asRecord(value); - if (!record) return { success: false, slotsLoaded: 0, error: 'Native load returned an unsupported result' }; + if (!record) return { success: false, slotsLoaded: 0, error: 'Native load returned an unsupported result', chainGeneration: -1 }; return { success: record.success === true, slotsLoaded: safeNumber(record.slotsLoaded, 0), error: bounded(record.error ?? ''), + // -1 = addon predates the counter; staleness checks then no-op. + chainGeneration: safeNumber(record.chainGeneration, -1), }; } +// Current native chainGeneration, or -1 when the addon doesn't expose it. +function currentChainGeneration(nativeAudio: AudioEffectsNativeAudio | null): number { + if (!nativeAudio || typeof nativeAudio.getChainGeneration !== 'function') return -1; + try { return safeNumber(nativeAudio.getChainGeneration(), -1); } catch { return -1; } +} + function chainSlots(nativeAudio: AudioEffectsNativeAudio | null): Dict[] { if (!nativeAudio || typeof nativeAudio.getChainState !== 'function') return []; const state = nativeAudio.getChainState(); @@ -481,7 +495,7 @@ export function createAudioEffectsExecutor(getAudio: NativeAudioGetter) { await trySetGain(nativeAudio, 'chain', 0); await trySetMonitorMute(nativeAudio, options.preloadMute.dryDuringLoad ? false : true); } - let result: { success: boolean; slotsLoaded: number; error: string }; + let result: { success: boolean; slotsLoaded: number; error: string; chainGeneration: number }; try { result = normalizeLoadResult(await nativeAudio.loadPreset(validation.presetJson)); } catch (error) { @@ -556,6 +570,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, previousMonitorMute, options.gains.chain ?? options.preloadMute.targetGain, 0, () => restoreVersion === preloadRestoreVersion); + return safeOutcome('degraded', 'Native chain was modified by another writer during plan load', { + routeKey: validation.plan.routeKey, + providerId: validation.plan.providerId, + planId: validation.plan.planId, + expectedGeneration: result.chainGeneration, + currentGeneration: generationNow, + rollbackApplied, + }); + } + const route: RouteState = { routeKey: validation.plan.routeKey, providerId: validation.plan.providerId, @@ -563,6 +594,7 @@ export function createAudioEffectsExecutor(getAudio: NativeAudioGetter) { state: result.slotsLoaded >= nativeStages.length ? 'loaded' : 'degraded', activeSegmentId: '', stageSlots, + chainGeneration: result.chainGeneration, stageKinds, segments: validation.plan.segments, loadedAt: now(), @@ -634,6 +666,23 @@ export function createAudioEffectsExecutor(getAudio: NativeAudioGetter) { return updateOutcome(route, safeOutcome('handled', 'Audio-effects route gain applied', { route: safeRoute(route), gains })); } + // Stage operations act on the stageSlots map built at load time; a foreign + // chain write since then (legacy loadPreset / clearChain — the documented + // three-writer fight) makes those slot ids describe someone else's chain. + // Detect via chainGeneration and report a stale route (the provider should + // re-load its plan) instead of flipping bypass/params on wrong slots. + function staleRouteOutcome(route: RouteState, nativeAudio: AudioEffectsNativeAudio | null, extra: Dict): SafeOutcome | null { + if (route.chainGeneration < 0) return null; // addon predates the counter + const generationNow = currentChainGeneration(nativeAudio); + if (generationNow < 0 || generationNow === route.chainGeneration) return null; + route.state = 'stale'; + return safeOutcome('no-target', 'Native chain was modified by another writer since this route loaded — re-load the plan', { + ...extra, + expectedGeneration: route.chainGeneration, + currentGeneration: generationNow, + }); + } + async function setStageBypass(request: unknown): Promise { const input = asRecord(request) || {}; const routeKey = safeId(input.routeKey ?? DEFAULT_ROUTE_KEY, DEFAULT_ROUTE_KEY); @@ -644,6 +693,8 @@ export function createAudioEffectsExecutor(getAudio: NativeAudioGetter) { if (slotId == null) return updateOutcome(route, safeOutcome('no-target', 'Audio-effects stage is not mapped to a native slot', { routeKey, stageId })); const nativeAudio = getAudio(); if (!nativeAudio || typeof nativeAudio.setBypass !== 'function') return updateOutcome(route, safeOutcome('unavailable', 'Native stage bypass is unavailable', { routeKey, stageId })); + const stale = staleRouteOutcome(route, nativeAudio, { routeKey, stageId }); + if (stale) return updateOutcome(route, stale); try { const result = await nativeAudio.setBypass(slotId, safeBool(input.bypassed, false)); if (nativeFailure(result)) return updateOutcome(route, safeOutcome('failed', 'Native stage bypass returned failure', { routeKey, stageId })); @@ -668,6 +719,8 @@ export function createAudioEffectsExecutor(getAudio: NativeAudioGetter) { } const nativeAudio = getAudio(); if (!nativeAudio || typeof nativeAudio.setParameter !== 'function') return updateOutcome(route, safeOutcome('unavailable', 'Native stage parameter control is unavailable', { routeKey, stageId })); + const stale = staleRouteOutcome(route, nativeAudio, { routeKey, stageId }); + if (stale) return updateOutcome(route, stale); try { const result = await nativeAudio.setParameter(slotId, paramIndex, value); if (nativeFailure(result)) return updateOutcome(route, safeOutcome('failed', 'Native stage parameter returned failure', { routeKey, stageId, paramIndex })); @@ -687,6 +740,8 @@ export function createAudioEffectsExecutor(getAudio: NativeAudioGetter) { if (!segment) return updateOutcome(route, safeOutcome('no-target', 'Audio-effects segment is not present in the loaded plan', { routeKey, segmentId })); const nativeAudio = getAudio(); if (!nativeAudio || typeof nativeAudio.setMultiBypass !== 'function') return updateOutcome(route, safeOutcome('unavailable', 'Native multi-bypass is unavailable', { routeKey, segmentId })); + const stale = staleRouteOutcome(route, nativeAudio, { routeKey, segmentId }); + if (stale) return updateOutcome(route, stale); const active = new Set(segment.stageIds); const changes = Array.from(route.stageSlots.entries()).map(([stageId, slotId]) => ({ slotId, diff --git a/tests/audio-effects-executor.test.js b/tests/audio-effects-executor.test.js index 410f9e3..e072a06 100644 --- a/tests/audio-effects-executor.test.js +++ b/tests/audio-effects-executor.test.js @@ -390,3 +390,51 @@ test('preload exposes the trusted audio-effects executor surface', () => { assert.equal(bridge.includes('vstSlotPaths.clear();\n return await audioEffects.loadChainPlan(request);'), true); assert.equal(bridge.includes('if (normalizedPayload.inputType !== normalizedPayload.outputType)'), true); }); + +test('audio-effects executor detects a foreign chain write via chainGeneration and reports a stale route', async () => { + const { createAudioEffectsExecutor } = loadExecutorModule(); + // Native stub with the phase-7a generation counter: our load lands at + // generation 5; a foreign writer (legacy loadPreset / clearChain) later + // bumps it to 6, invalidating the route's stageSlots map. + let generation = 5; + const bypassCalls = []; + const native = { + loadPreset: async presetJson => ({ success: true, slotsLoaded: JSON.parse(presetJson).chain.length, chainGeneration: generation }), + getChainState: () => [{ id: 10 }, { id: 11 }], + getChainGeneration: () => generation, + setBypass: (slotId, bypassed) => { bypassCalls.push([slotId, bypassed]); return true; }, + setMultiBypass: changes => { bypassCalls.push(['multi', changes]); return true; }, + setParameter: () => true, + }; + const executor = createAudioEffectsExecutor(() => native); + const loaded = await executor.loadChainPlan({ + authorization: 'playback-session', + plan: plan(), + assets: { + 'asset:pre': { kind: 'nam', path: tempAsset('.nam'), safeName: 'pre' }, + 'asset:cab': { kind: 'ir', path: tempAsset('.wav'), safeName: 'cab' }, + }, + }); + assert.equal(loaded.outcome, 'handled'); + + // Generation unchanged: stage ops flow normally. + const fresh = await executor.setStageBypass({ routeKey: 'desktop-main', stageId: 'pre', bypassed: true }); + assert.equal(fresh.outcome, 'handled'); + assert.equal(bypassCalls.length, 1); + + // Foreign write bumps the native counter. + generation = 6; + const stale = await executor.setStageBypass({ routeKey: 'desktop-main', stageId: 'pre', bypassed: false }); + assert.equal(stale.outcome, 'no-target'); + assert.match(stale.reason, /modified by another writer/); + assert.equal(stale.payload.expectedGeneration, 5); + assert.equal(stale.payload.currentGeneration, 6); + assert.equal(bypassCalls.length, 1, 'stale route must NOT touch native slots'); + + // Segment activation and parameters are equally guarded. + const seg = await executor.activateSegment({ routeKey: 'desktop-main', segmentId: 'lead' }); + assert.equal(seg.outcome, 'no-target'); + const param = await executor.setStageParameter({ routeKey: 'desktop-main', stageId: 'pre', paramIndex: 0, value: 0.5 }); + assert.equal(param.outcome, 'no-target'); + assert.equal(bypassCalls.length, 1); +}); From 95b32ba1607543fecacaf98d0af6db7f17761194 Mon Sep 17 00:00:00 2001 From: OmikronApex Date: Tue, 14 Jul 2026 02:23:09 +0200 Subject: [PATCH 16/28] refactor(audio): extract EditorWindows (phase 7b) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Moves the in-process plugin editor cluster — PluginEditorWindow, the slotId→window map, the message-thread teardown pair (closeAll / destroyAll, the #56 use-after-free guards), OpenPluginEditor with the full Windows sandbox-promotion flow, and ClosePluginEditor — verbatim into src/audio/addon/EditorWindows.{h,cpp}. NodeAddon keeps using-declarations; the export table and ClearChain/LoadPreset teardown calls are unchanged. The two bindings pick up NapiHelpers slot-id validation while moving (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). Co-Authored-By: Claude Fable 5 --- src/audio/CMakeLists.txt | 1 + src/audio/NodeAddon.cpp | 371 +---------------------------- src/audio/addon/EditorWindows.cpp | 377 ++++++++++++++++++++++++++++++ src/audio/addon/EditorWindows.h | 28 +++ 4 files changed, 414 insertions(+), 363 deletions(-) create mode 100644 src/audio/addon/EditorWindows.cpp create mode 100644 src/audio/addon/EditorWindows.h diff --git a/src/audio/CMakeLists.txt b/src/audio/CMakeLists.txt index e2306bd..1284418 100644 --- a/src/audio/CMakeLists.txt +++ b/src/audio/CMakeLists.txt @@ -13,6 +13,7 @@ set(AUDIO_SOURCES engine/ExtraInputs.cpp addon/AddonContext.cpp addon/ChainOps.cpp + addon/EditorWindows.cpp SourceChain.cpp SignalChain.cpp VSTHost.cpp diff --git a/src/audio/NodeAddon.cpp b/src/audio/NodeAddon.cpp index 46fdb48..cce63b5 100644 --- a/src/audio/NodeAddon.cpp +++ b/src/audio/NodeAddon.cpp @@ -29,6 +29,12 @@ #include "addon/AddonContext.h" #include "addon/NapiHelpers.h" #include "addon/ChainOps.h" +#include "addon/EditorWindows.h" + +using slopsmith::addon::closeAllPluginEditorWindows; +using slopsmith::addon::destroyAllPluginEditorWindowsOnMessageThread; +using slopsmith::addon::OpenPluginEditor; +using slopsmith::addon::ClosePluginEditor; // Lifetime/threading moved to addon/AddonContext (TLC phase 6); the usings // keep the 100+ existing binding bodies unchanged. @@ -95,11 +101,7 @@ static int loadSafeBlockSize(const AudioEngine& eng) } // Destroys every in-process plugin editor window. MUST be called on the message -// thread (editorWindows holds JUCE GUI objects). Defined far below, after the -// editorWindows map; forward-declared here so doShutdown — which already runs on -// the message thread — can tear editors down before engine.reset() frees the -// processors those editors point at (use-after-free; feedBack-desktop#56). -static void destroyAllPluginEditorWindowsOnMessageThread(); +// thread — lives in addon/EditorWindows now (TLC phase 7). // ── Lifecycle ───────────────────────────────────────────────────────────────── @@ -2465,8 +2467,7 @@ static Napi::Value SetBypass(const Napi::CallbackInfo& info) // owns an AudioProcessorEditor bound to its slot's processor, so if the // processor is freed first the editor's next timer/paint callback dereferences // freed memory (use-after-free → DEP-execute crash seconds after pause; -// feedBack-desktop#56). Defined below, after the editorWindows map. -static void closeAllPluginEditorWindows(); +// feedBack-desktop#56). Lives in addon/EditorWindows now. static Napi::Value ClearChain(const Napi::CallbackInfo& info) { @@ -2575,362 +2576,6 @@ static Napi::Value GetChainState(const Napi::CallbackInfo& info) // ── Plugin Editor Window ────────────────────────────────────────────────────── -class PluginEditorWindow; -static std::map> editorWindows; - -class PluginEditorWindow : public juce::DocumentWindow -{ -public: - PluginEditorWindow(juce::AudioProcessorEditor* ed, const juce::String& title) - : DocumentWindow(title, juce::Colours::darkgrey, DocumentWindow::closeButton) - { - setContentOwned(ed, true); - setResizable(true, false); - setUsingNativeTitleBar(true); - centreWithSize(ed->getWidth(), ed->getHeight()); - setVisible(true); - toFront(true); - } - - void closeButtonPressed() override - { - // Remove from map so editor can be reopened - for (auto it = editorWindows.begin(); it != editorWindows.end(); ++it) - { - if (it->second.get() == this) - { - auto slotId = it->first; - juce::MessageManager::callAsync([slotId]() { - editorWindows.erase(slotId); - }); - break; - } - } - setVisible(false); - } -}; - -// Inline teardown: destroys every editor window. Caller MUST already be on the -// message thread (editorWindows holds JUCE GUI objects). Forward-declared near -// Init for doShutdown's use. -static void destroyAllPluginEditorWindowsOnMessageThread() -{ - // Fails fast in assertion-enabled builds if a caller violates the - // precondition. Compiled out here under -DJUCE_DISABLE_ASSERTIONS, so it is - // documentation + a debug-build tripwire, never runtime cost. - JUCE_ASSERT_MESSAGE_THREAD - editorWindows.clear(); -} - -// See the forward declaration above ClearChain for why this exists. Tears down -// the in-process editor windows so they are destroyed before the caller frees -// the processors those editors point at. Clearing an empty map is cheap, so -// calling this on every teardown is fine even when no editor is open. -// -// IMPORTANT: every caller runs on a MAIN-thread / message-thread context — -// ClearChain and LoadPreset are N-API calls on the Node thread, doShutdown uses -// the inline variant directly. This is NOT called from a libuv worker (that is -// why LoadPreset closes editors before queuing LoadPresetWorker, rather than -// letting the worker do it). Given that: -// - Already on the message thread (doShutdown; ClearChain / LoadPreset on -// macOS, where Node's main thread IS the JUCE message thread) → tear down -// inline; posting-and-waiting on ourselves would deadlock. -// - Otherwise (ClearChain / LoadPreset on Linux/Windows, where the JUCE -// message thread is a dedicated std::thread) → post to that thread and block -// until the editors are gone. Its 50ms dispatch loop drains this promptly, -// so there is no macOS-style stall here. Report a refused post / wait -// timeout so a lingering-editor UAF stays diagnosable. -static void closeAllPluginEditorWindows() -{ - auto* mm = juce::MessageManager::getInstanceWithoutCreating(); - if (mm != nullptr && mm->isThisTheMessageThread()) - { - destroyAllPluginEditorWindowsOnMessageThread(); - return; - } - - auto done = std::make_shared(); - const bool posted = juce::MessageManager::callAsync([done]() - { - destroyAllPluginEditorWindowsOnMessageThread(); - done->signal(); - }); - if (!posted) - { - fprintf(stderr, "[audio-native] closeAllPluginEditorWindows: message queue refused the post; " - "editors may briefly outlive their processors\n"); - return; - } - if (!done->wait(15000)) - fprintf(stderr, "[audio-native] closeAllPluginEditorWindows: editor teardown did not complete " - "within 15s; proceeding\n"); -} - -static Napi::Value OpenPluginEditor(const Napi::CallbackInfo& info) -{ - auto env = info.Env(); - auto liveEngine = snapshotEngine(); - if (!liveEngine || info.Length() < 1) - return Napi::Boolean::New(env, false); - - int slotId = info[0].As().Int32Value(); - - auto slot = liveEngine->getSignalChain().getSlot(slotId); - if (!slot || !slot->processor || !slot->processor->hasEditor()) - return Napi::Boolean::New(env, false); - - // Sandboxed plugins: the editor is a top-level window owned by the - // sandbox child process. No host-side PluginEditorWindow and no - // cross-process SetParent reparent — that path produced a blank - // rendered surface for D3D / OpenGL plugins (Neural DSP Archetypes, - // etc.) because their render context lives in the child. The child's - // kOpenEditor handler brings the existing window to front on a repeat - // click, so re-entry is cheap and we don't track host-side state. - // - // Dispatch off the N-API call thread: requestOpenEditor() uses a - // blocking control->request (kDefaultReplyTimeoutMs = 10s), which on - // a slow or hung sandbox would otherwise stall V8's JS thread for - // the full timeout. Capture slotId rather than a raw processor - // pointer and re-resolve inside the message-thread lambda — that - // closes a UAF window where the slot could be removed (or the engine - // torn down) between this call returning and the async firing. - // Return optimistically; matches the in-process path below. - // - // SandboxedProcessor is compiled on all desktop platforms now (the POSIX - // sandbox runtime is active — see src/audio/CMakeLists.txt), so the - // editor-open IPC routes to the sandbox child on macOS/Linux too. The - // child owns a floating editor window (Reaper-style); the host only tracks - // the open/closed bit. -#if defined(SLOPSMITH_AUDIO_ADDON) - if (auto* sb = dynamic_cast(slot->processor.get())) - { - // Synchronous gate: if the sandbox child is already gone (crashed - // or shut down) there's no point scheduling the IPC. Return false - // so the renderer can surface "editor unavailable" rather than - // toggling its UI into a fake-open state that no event will ever - // contradict. hasEditor() above already gated on isAlive() but a - // crash between then and now is possible — re-check here. - if (!sb->isAlive()) - return Napi::Boolean::New(env, false); - const bool queued = juce::MessageManager::callAsync([slotId]() - { - auto liveEngine = snapshotEngine(); - if (!liveEngine) return; - if (auto* slot = liveEngine->getSignalChain().getSlot(slotId)) - if (auto* sb = dynamic_cast(slot->processor.get())) - sb->requestOpenEditor(); - }); - if (!queued) - { - // Message queue refused the post — typically only during - // shutdown. Surface the failure so the renderer doesn't - // toggle its UI into a fake-open state. - return Napi::Boolean::New(env, false); - } - return Napi::Boolean::New(env, true); - } -#endif - - // In-process plugin — host-side PluginEditorWindow flow. If a window - // already exists for this slot, bring it to front rather than creating - // a duplicate. - auto it = editorWindows.find(slotId); - if (it != editorWindows.end() && it->second) - { - if (it->second->isVisible()) - { - it->second->toFront(true); - return Napi::Boolean::New(env, true); - } - // Window was hidden/closed, remove stale entry - editorWindows.erase(it); - } - - // Create editor on the message thread. Capture slotId only — re-resolve - // the slot via snapshotEngine() + getSlot(slotId) inside the lambda so a - // SignalChain::removeProcessor() between this call returning and the - // async firing can't leave us calling createEditorAndMakeActive() on a - // dangling juce::AudioProcessor*. Mirrors the sandbox branch's pattern. - const bool queued = juce::MessageManager::callAsync([slotId]() - { - auto liveEngine = snapshotEngine(); - if (!liveEngine) return; - auto& chain = liveEngine->getSignalChain(); - auto* slot = chain.getSlot(slotId); - if (!slot || !slot->processor) return; - - // ── Windows editor-crash class fix ─────────────────────────────────── - // An in-process VST3 editor is created on JUCE's BACKGROUND message - // thread (V8 owns the OS main thread inside a Node addon). On Windows a - // Qt-using / window-on-init plugin then faults via USER32->WndProc on - // WM_ACTIVATEAPP with NO host frame on the stack, so the SignalChain SEH - // guard can't catch it and the whole app dies (0xC0000005 / 0xC0000409). - // Fix: never open a VST3 editor in-process on Windows — promote the slot - // to the out-of-process sandbox (which hosts the editor on a real - // top-level message thread, the environment the plugin needs) and open - // it there. Compiled on every platform so the swap path keeps building; - // gated to Windows at runtime since the in-process editor is fine on - // macOS/Linux (no WndProc) and the sandbox hop is pure overhead there. - static constexpr bool kPromoteEditorToSandbox = - #if JUCE_WINDOWS - true; - #else - false; - #endif - if (kPromoteEditorToSandbox) - { - // Decide + snapshot state SAFELY. captureVstStateForPromotion runs - // hasEditor()/getStateInformation() under the audio lock and the SEH - // guard (see its contract), so they neither race process()'s - // processBlock nor fault the app — an UNguarded getStateInformation on - // the very plugins this promotion targets would reintroduce the editor - // crash on the message thread. It returns true only for a non-sandboxed - // in-process VST3 that actually has an editor. - juce::MemoryBlock state; - if (chain.captureVstStateForPromotion(slotId, state)) - { - const juce::String path = slot->path; // immutable; message-thread only - fprintf(stderr, "[AudioEngine] editor-open: promoting in-process VST3 to sandbox: slot %d '%s'\n", - slotId, path.toRawUTF8()); - - juce::PluginDescription desc; - desc.fileOrIdentifier = path; - desc.name = juce::File(path).getFileNameWithoutExtension(); - - // tryLoadSandboxed only accepts a plugin that shouldSandbox() - // approves, so pin this path to the runtime sandbox list first. - // Remember whether it was ALREADY pinned: if the promotion fails - // we undo only OUR pin below, so a healthy, never-crashed plugin - // isn't left permanently forced to a sandbox that just proved - // unavailable (while a pre-existing/real blocklist entry stays). - const bool wasAlreadyPinned = slopsmith::sandbox::isCrashedPlugin(path); - slopsmith::sandbox::addCrashedPlugin(path); - - bool promoted = false; - juce::String err; - auto sandboxed = slopsmith::sandbox::tryLoadSandboxed( - desc, chain.getCurrentSampleRate(), chain.getCurrentBlockSize(), err); - if (sandboxed) - { - if (state.getSize() > 0) - sandboxed->setStateInformation(state.getData(), (int) state.getSize()); - if (chain.replaceProcessor(slotId, std::move(sandboxed))) - { - promoted = true; - bool editorOpened = false; - if (auto* slot2 = chain.getSlot(slotId)) - if (auto* sb = dynamic_cast(slot2->processor.get())) - editorOpened = sb->requestOpenEditor(); - fprintf(stderr, "[AudioEngine] editor-open: sandbox promotion OK for slot %d (editor %s)\n", - slotId, editorOpened ? "opened" : "FAILED to open"); - } - else - { - fprintf(stderr, "[AudioEngine] editor-open: replaceProcessor failed for slot %d\n", slotId); - } - } - else - { - fprintf(stderr, "[AudioEngine] editor-open: sandbox promotion failed for '%s': %s\n", - path.toRawUTF8(), err.toRawUTF8()); - } - - // Undo our transient pin on failure so a plugin that never crashed - // isn't stranded on the (evidently unavailable) sandbox route. - if (! promoted && ! wasAlreadyPinned) - slopsmith::sandbox::removeCrashedPlugin(path); - - // Promoted or not, never fall through to the in-process editor on - // Windows — that is the WndProc/Qt crash path this branch exists - // to avoid. - return; - } - // Not promotable (non-VST / editor-less / already-sandboxed, or the - // guarded capture faulted and released the processor). Fall through to - // the in-process branch below, which is safe for all of those cases - // (an already-sandboxed slot opens its editor out-of-process; an - // editor-less or released processor simply opens no window). - } - - // In-process editor: non-VST3, editor-less, already-sandboxed, or POSIX - // (where the in-process editor is safe). - auto* processor = slot->processor.get(); - auto name = slot->name; - juce::AudioProcessorEditor* editor = nullptr; - try { - editor = processor->createEditorAndMakeActive(); - } catch (const std::exception& e) { - fprintf(stderr, "[AudioEngine] createEditorAndMakeActive crashed for '%s': %s\n", name.toRawUTF8(), e.what()); - } catch (...) { - fprintf(stderr, "[AudioEngine] createEditorAndMakeActive crashed for '%s': unknown error\n", name.toRawUTF8()); - } - if (editor) - { - editorWindows[slotId] = std::make_unique(editor, name); - fprintf(stderr, "[AudioEngine] Opened editor for slot %d: %s (%dx%d)\n", - slotId, name.toRawUTF8(), editor->getWidth(), editor->getHeight()); - } - }); - - return Napi::Boolean::New(env, queued); -} - -static Napi::Value ClosePluginEditor(const Napi::CallbackInfo& info) -{ - auto env = info.Env(); - if (info.Length() < 1) return Napi::Boolean::New(env, false); - - int slotId = info[0].As().Int32Value(); - - // Sandboxed plugins: route the close to the sandbox child via IPC. - // No host-side PluginEditorWindow exists for these. - // - // Same shape as the open path: dispatch off the N-API thread and - // re-resolve the slot inside the lambda. requestCloseEditor() - // ultimately writes to the control pipe (writeFrame can block up - // to ~5s on a stalled reader), so running it synchronously here - // would freeze JS / the renderer UI on a slow sandbox; the - // re-resolve guards against slot-removal UAF between the napi call - // and the async firing. - // - // All desktop platforms: route the close to the sandbox child via IPC - // (SandboxedProcessor is compiled everywhere now). In-process plugins fall - // through to the host-side editor-window teardown below. -#if defined(SLOPSMITH_AUDIO_ADDON) - if (auto liveEngine = snapshotEngine()) - { - if (auto* slot = liveEngine->getSignalChain().getSlot(slotId)) - { - if (slot->processor - && dynamic_cast(slot->processor.get())) - { - const bool queued = juce::MessageManager::callAsync([slotId]() - { - auto liveEngine = snapshotEngine(); - if (!liveEngine) return; - if (auto* slot = liveEngine->getSignalChain().getSlot(slotId)) - if (auto* sb = dynamic_cast(slot->processor.get())) - sb->requestCloseEditor(); - }); - return Napi::Boolean::New(env, queued); - } - } - } -#endif - - // In-process plugin — tear down the host-side editor window. - auto it = editorWindows.find(slotId); - if (it != editorWindows.end()) - { - juce::MessageManager::callAsync([slotId]() - { - editorWindows.erase(slotId); - }); - return Napi::Boolean::New(env, true); - } - return Napi::Boolean::New(env, false); -} - // ── Parameters ──────────────────────────────────────────────────────────────── static Napi::Value GetParameters(const Napi::CallbackInfo& info) diff --git a/src/audio/addon/EditorWindows.cpp b/src/audio/addon/EditorWindows.cpp new file mode 100644 index 0000000..d74ac59 --- /dev/null +++ b/src/audio/addon/EditorWindows.cpp @@ -0,0 +1,377 @@ +// 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 "../Sandbox/SandboxedProcessor.h" +#include "../Sandbox/CrashAttribution.h" + +#include +#include +#include + +namespace slopsmith::addon { + +class PluginEditorWindow; +static std::map> editorWindows; + +class PluginEditorWindow : public juce::DocumentWindow +{ +public: + PluginEditorWindow(juce::AudioProcessorEditor* ed, const juce::String& title) + : DocumentWindow(title, juce::Colours::darkgrey, DocumentWindow::closeButton) + { + setContentOwned(ed, true); + setResizable(true, false); + setUsingNativeTitleBar(true); + centreWithSize(ed->getWidth(), ed->getHeight()); + setVisible(true); + toFront(true); + } + + void closeButtonPressed() override + { + // Remove from map so editor can be reopened + for (auto it = editorWindows.begin(); it != editorWindows.end(); ++it) + { + if (it->second.get() == this) + { + auto slotId = it->first; + juce::MessageManager::callAsync([slotId]() { + editorWindows.erase(slotId); + }); + break; + } + } + setVisible(false); + } +}; + +// Inline teardown: destroys every editor window. Caller MUST already be on the +// message thread (editorWindows holds JUCE GUI objects). Forward-declared near +// Init for doShutdown's use. +void destroyAllPluginEditorWindowsOnMessageThread() +{ + // Fails fast in assertion-enabled builds if a caller violates the + // precondition. Compiled out here under -DJUCE_DISABLE_ASSERTIONS, so it is + // documentation + a debug-build tripwire, never runtime cost. + JUCE_ASSERT_MESSAGE_THREAD + editorWindows.clear(); +} + +// See the forward declaration above ClearChain for why this exists. Tears down +// the in-process editor windows so they are destroyed before the caller frees +// the processors those editors point at. Clearing an empty map is cheap, so +// calling this on every teardown is fine even when no editor is open. +// +// IMPORTANT: every caller runs on a MAIN-thread / message-thread context — +// ClearChain and LoadPreset are N-API calls on the Node thread, doShutdown uses +// the inline variant directly. This is NOT called from a libuv worker (that is +// why LoadPreset closes editors before queuing LoadPresetWorker, rather than +// letting the worker do it). Given that: +// - Already on the message thread (doShutdown; ClearChain / LoadPreset on +// macOS, where Node's main thread IS the JUCE message thread) → tear down +// inline; posting-and-waiting on ourselves would deadlock. +// - Otherwise (ClearChain / LoadPreset on Linux/Windows, where the JUCE +// message thread is a dedicated std::thread) → post to that thread and block +// until the editors are gone. Its 50ms dispatch loop drains this promptly, +// so there is no macOS-style stall here. Report a refused post / wait +// timeout so a lingering-editor UAF stays diagnosable. +void closeAllPluginEditorWindows() +{ + auto* mm = juce::MessageManager::getInstanceWithoutCreating(); + if (mm != nullptr && mm->isThisTheMessageThread()) + { + destroyAllPluginEditorWindowsOnMessageThread(); + return; + } + + auto done = std::make_shared(); + const bool posted = juce::MessageManager::callAsync([done]() + { + destroyAllPluginEditorWindowsOnMessageThread(); + done->signal(); + }); + if (!posted) + { + fprintf(stderr, "[audio-native] closeAllPluginEditorWindows: message queue refused the post; " + "editors may briefly outlive their processors\n"); + return; + } + if (!done->wait(15000)) + fprintf(stderr, "[audio-native] closeAllPluginEditorWindows: editor teardown did not complete " + "within 15s; proceeding\n"); +} + +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; + + auto slot = liveEngine->getSignalChain().getSlot(slotId); + if (!slot || !slot->processor || !slot->processor->hasEditor()) + return Napi::Boolean::New(env, false); + + // Sandboxed plugins: the editor is a top-level window owned by the + // sandbox child process. No host-side PluginEditorWindow and no + // cross-process SetParent reparent — that path produced a blank + // rendered surface for D3D / OpenGL plugins (Neural DSP Archetypes, + // etc.) because their render context lives in the child. The child's + // kOpenEditor handler brings the existing window to front on a repeat + // click, so re-entry is cheap and we don't track host-side state. + // + // Dispatch off the N-API call thread: requestOpenEditor() uses a + // blocking control->request (kDefaultReplyTimeoutMs = 10s), which on + // a slow or hung sandbox would otherwise stall V8's JS thread for + // the full timeout. Capture slotId rather than a raw processor + // pointer and re-resolve inside the message-thread lambda — that + // closes a UAF window where the slot could be removed (or the engine + // torn down) between this call returning and the async firing. + // Return optimistically; matches the in-process path below. + // + // SandboxedProcessor is compiled on all desktop platforms now (the POSIX + // sandbox runtime is active — see src/audio/CMakeLists.txt), so the + // editor-open IPC routes to the sandbox child on macOS/Linux too. The + // child owns a floating editor window (Reaper-style); the host only tracks + // the open/closed bit. +#if defined(SLOPSMITH_AUDIO_ADDON) + if (auto* sb = dynamic_cast(slot->processor.get())) + { + // Synchronous gate: if the sandbox child is already gone (crashed + // or shut down) there's no point scheduling the IPC. Return false + // so the renderer can surface "editor unavailable" rather than + // toggling its UI into a fake-open state that no event will ever + // contradict. hasEditor() above already gated on isAlive() but a + // crash between then and now is possible — re-check here. + if (!sb->isAlive()) + return Napi::Boolean::New(env, false); + const bool queued = juce::MessageManager::callAsync([slotId]() + { + auto liveEngine = snapshotEngine(); + if (!liveEngine) return; + if (auto* slot = liveEngine->getSignalChain().getSlot(slotId)) + if (auto* sb = dynamic_cast(slot->processor.get())) + sb->requestOpenEditor(); + }); + if (!queued) + { + // Message queue refused the post — typically only during + // shutdown. Surface the failure so the renderer doesn't + // toggle its UI into a fake-open state. + return Napi::Boolean::New(env, false); + } + return Napi::Boolean::New(env, true); + } +#endif + + // In-process plugin — host-side PluginEditorWindow flow. If a window + // already exists for this slot, bring it to front rather than creating + // a duplicate. + auto it = editorWindows.find(slotId); + if (it != editorWindows.end() && it->second) + { + if (it->second->isVisible()) + { + it->second->toFront(true); + return Napi::Boolean::New(env, true); + } + // Window was hidden/closed, remove stale entry + editorWindows.erase(it); + } + + // Create editor on the message thread. Capture slotId only — re-resolve + // the slot via snapshotEngine() + getSlot(slotId) inside the lambda so a + // SignalChain::removeProcessor() between this call returning and the + // async firing can't leave us calling createEditorAndMakeActive() on a + // dangling juce::AudioProcessor*. Mirrors the sandbox branch's pattern. + const bool queued = juce::MessageManager::callAsync([slotId]() + { + auto liveEngine = snapshotEngine(); + if (!liveEngine) return; + auto& chain = liveEngine->getSignalChain(); + auto* slot = chain.getSlot(slotId); + if (!slot || !slot->processor) return; + + // ── Windows editor-crash class fix ─────────────────────────────────── + // An in-process VST3 editor is created on JUCE's BACKGROUND message + // thread (V8 owns the OS main thread inside a Node addon). On Windows a + // Qt-using / window-on-init plugin then faults via USER32->WndProc on + // WM_ACTIVATEAPP with NO host frame on the stack, so the SignalChain SEH + // guard can't catch it and the whole app dies (0xC0000005 / 0xC0000409). + // Fix: never open a VST3 editor in-process on Windows — promote the slot + // to the out-of-process sandbox (which hosts the editor on a real + // top-level message thread, the environment the plugin needs) and open + // it there. Compiled on every platform so the swap path keeps building; + // gated to Windows at runtime since the in-process editor is fine on + // macOS/Linux (no WndProc) and the sandbox hop is pure overhead there. + static constexpr bool kPromoteEditorToSandbox = + #if JUCE_WINDOWS + true; + #else + false; + #endif + if (kPromoteEditorToSandbox) + { + // Decide + snapshot state SAFELY. captureVstStateForPromotion runs + // hasEditor()/getStateInformation() under the audio lock and the SEH + // guard (see its contract), so they neither race process()'s + // processBlock nor fault the app — an UNguarded getStateInformation on + // the very plugins this promotion targets would reintroduce the editor + // crash on the message thread. It returns true only for a non-sandboxed + // in-process VST3 that actually has an editor. + juce::MemoryBlock state; + if (chain.captureVstStateForPromotion(slotId, state)) + { + const juce::String path = slot->path; // immutable; message-thread only + fprintf(stderr, "[AudioEngine] editor-open: promoting in-process VST3 to sandbox: slot %d '%s'\n", + slotId, path.toRawUTF8()); + + juce::PluginDescription desc; + desc.fileOrIdentifier = path; + desc.name = juce::File(path).getFileNameWithoutExtension(); + + // tryLoadSandboxed only accepts a plugin that shouldSandbox() + // approves, so pin this path to the runtime sandbox list first. + // Remember whether it was ALREADY pinned: if the promotion fails + // we undo only OUR pin below, so a healthy, never-crashed plugin + // isn't left permanently forced to a sandbox that just proved + // unavailable (while a pre-existing/real blocklist entry stays). + const bool wasAlreadyPinned = slopsmith::sandbox::isCrashedPlugin(path); + slopsmith::sandbox::addCrashedPlugin(path); + + bool promoted = false; + juce::String err; + auto sandboxed = slopsmith::sandbox::tryLoadSandboxed( + desc, chain.getCurrentSampleRate(), chain.getCurrentBlockSize(), err); + if (sandboxed) + { + if (state.getSize() > 0) + sandboxed->setStateInformation(state.getData(), (int) state.getSize()); + if (chain.replaceProcessor(slotId, std::move(sandboxed))) + { + promoted = true; + bool editorOpened = false; + if (auto* slot2 = chain.getSlot(slotId)) + if (auto* sb = dynamic_cast(slot2->processor.get())) + editorOpened = sb->requestOpenEditor(); + fprintf(stderr, "[AudioEngine] editor-open: sandbox promotion OK for slot %d (editor %s)\n", + slotId, editorOpened ? "opened" : "FAILED to open"); + } + else + { + fprintf(stderr, "[AudioEngine] editor-open: replaceProcessor failed for slot %d\n", slotId); + } + } + else + { + fprintf(stderr, "[AudioEngine] editor-open: sandbox promotion failed for '%s': %s\n", + path.toRawUTF8(), err.toRawUTF8()); + } + + // Undo our transient pin on failure so a plugin that never crashed + // isn't stranded on the (evidently unavailable) sandbox route. + if (! promoted && ! wasAlreadyPinned) + slopsmith::sandbox::removeCrashedPlugin(path); + + // Promoted or not, never fall through to the in-process editor on + // Windows — that is the WndProc/Qt crash path this branch exists + // to avoid. + return; + } + // Not promotable (non-VST / editor-less / already-sandboxed, or the + // guarded capture faulted and released the processor). Fall through to + // the in-process branch below, which is safe for all of those cases + // (an already-sandboxed slot opens its editor out-of-process; an + // editor-less or released processor simply opens no window). + } + + // In-process editor: non-VST3, editor-less, already-sandboxed, or POSIX + // (where the in-process editor is safe). + auto* processor = slot->processor.get(); + auto name = slot->name; + juce::AudioProcessorEditor* editor = nullptr; + try { + editor = processor->createEditorAndMakeActive(); + } catch (const std::exception& e) { + fprintf(stderr, "[AudioEngine] createEditorAndMakeActive crashed for '%s': %s\n", name.toRawUTF8(), e.what()); + } catch (...) { + fprintf(stderr, "[AudioEngine] createEditorAndMakeActive crashed for '%s': unknown error\n", name.toRawUTF8()); + } + if (editor) + { + editorWindows[slotId] = std::make_unique(editor, name); + fprintf(stderr, "[AudioEngine] Opened editor for slot %d: %s (%dx%d)\n", + slotId, name.toRawUTF8(), editor->getWidth(), editor->getHeight()); + } + }); + + return Napi::Boolean::New(env, queued); +} + +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; + + // Sandboxed plugins: route the close to the sandbox child via IPC. + // No host-side PluginEditorWindow exists for these. + // + // Same shape as the open path: dispatch off the N-API thread and + // re-resolve the slot inside the lambda. requestCloseEditor() + // ultimately writes to the control pipe (writeFrame can block up + // to ~5s on a stalled reader), so running it synchronously here + // would freeze JS / the renderer UI on a slow sandbox; the + // re-resolve guards against slot-removal UAF between the napi call + // and the async firing. + // + // All desktop platforms: route the close to the sandbox child via IPC + // (SandboxedProcessor is compiled everywhere now). In-process plugins fall + // through to the host-side editor-window teardown below. +#if defined(SLOPSMITH_AUDIO_ADDON) + if (auto liveEngine = snapshotEngine()) + { + if (auto* slot = liveEngine->getSignalChain().getSlot(slotId)) + { + if (slot->processor + && dynamic_cast(slot->processor.get())) + { + const bool queued = juce::MessageManager::callAsync([slotId]() + { + auto liveEngine = snapshotEngine(); + if (!liveEngine) return; + if (auto* slot = liveEngine->getSignalChain().getSlot(slotId)) + if (auto* sb = dynamic_cast(slot->processor.get())) + sb->requestCloseEditor(); + }); + return Napi::Boolean::New(env, queued); + } + } + } +#endif + + // In-process plugin — tear down the host-side editor window. + auto it = editorWindows.find(slotId); + if (it != editorWindows.end()) + { + juce::MessageManager::callAsync([slotId]() + { + editorWindows.erase(slotId); + }); + return Napi::Boolean::New(env, true); + } + return Napi::Boolean::New(env, false); +} + + +} // namespace slopsmith::addon diff --git a/src/audio/addon/EditorWindows.h b/src/audio/addon/EditorWindows.h new file mode 100644 index 0000000..3afe80b --- /dev/null +++ b/src/audio/addon/EditorWindows.h @@ -0,0 +1,28 @@ +#pragma once + +// EditorWindows — in-process plugin editor windows + the open/close bindings +// and the Windows sandbox-promotion flow (TLC plan phase 7 / §3.4). Moved +// verbatim from NodeAddon.cpp. Owns the slotId→window map (message-thread +// only) and the teardown helpers every chain-clearing path must run BEFORE +// freeing slot processors (use-after-free; feedBack-desktop#56). + +#include + +namespace slopsmith::addon { + +// Inline teardown: destroys every editor window. Caller MUST already be on +// the message thread (the window map holds JUCE GUI objects). doShutdown's +// UI teardown hook points here. +void destroyAllPluginEditorWindowsOnMessageThread(); + +// Tears down the in-process editor windows so they are destroyed before the +// caller frees the processors those editors point at. Safe from the Node +// thread (posts to the message thread and blocks, bounded) or the message +// thread itself (inline). Clearing an empty map is cheap. +void 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 From f473aad920d2498ac4f8343f36515a0e1b8677ea Mon Sep 17 00:00:00 2001 From: OmikronApex Date: Tue, 14 Jul 2026 02:29:16 +0200 Subject: [PATCH 17/28] refactor(audio): move chain workers into ChainOps.cpp (phase 7b) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Moves the five chain-mutating async workers (LoadPreset/LoadVST/LoadNAM/ LoadIR/ReplaceIR), their N-API handlers, loadVstSandboxAware, and the shared load helpers (decodeStateBlob, loadSafeSampleRate/BlockSize) verbatim into src/audio/addon/ChainOps.cpp — joining the phase-7a serialization primitives in their planned home (§3.3). NodeAddon keeps using-declarations; the export table is unchanged. Storm and arg-fuzz gates stay green. Co-Authored-By: Claude Fable 5 --- src/audio/NodeAddon.cpp | 805 +-------------------------------- src/audio/addon/ChainOps.cpp | 835 +++++++++++++++++++++++++++++++++++ src/audio/addon/ChainOps.h | 28 ++ 3 files changed, 869 insertions(+), 799 deletions(-) diff --git a/src/audio/NodeAddon.cpp b/src/audio/NodeAddon.cpp index cce63b5..9532876 100644 --- a/src/audio/NodeAddon.cpp +++ b/src/audio/NodeAddon.cpp @@ -35,6 +35,12 @@ using slopsmith::addon::closeAllPluginEditorWindows; using slopsmith::addon::destroyAllPluginEditorWindowsOnMessageThread; using slopsmith::addon::OpenPluginEditor; using slopsmith::addon::ClosePluginEditor; +using slopsmith::addon::decodeStateBlob; +using slopsmith::addon::LoadVST; +using slopsmith::addon::LoadNAMModel; +using slopsmith::addon::LoadIR; +using slopsmith::addon::ReplaceIR; +using slopsmith::addon::LoadPreset; // Lifetime/threading moved to addon/AddonContext (TLC phase 6); the usings // keep the 100+ existing binding bodies unchanged. @@ -46,31 +52,6 @@ using slopsmith::addon::unregisterPendingLoad; using slopsmith::addon::cancelAllPendingLoads; using slopsmith::addon::doShutdown; -// Decode a state blob that may be in EITHER base64 flavour. JUCE's -// MemoryBlock::fromBase64Encoding only understands JUCE's own proprietary -// format (".") and returns false for standard RFC-4648 -// base64 — which is what the Python-side plugins (rig_builder et al.) emit -// for per-slot state. That silent false meant setState() was never called -// for those slots: IR stages lost their per-stage `gain` (the cab loudness -// makeup and amp trims never reached the engine). Try the JUCE format first -// (engine-native saves), then fall back to standard b64. -// -// `allowStandard` is only set for IR/NAM slots: their processors take a JSON -// state ({"irPath","gain"} / model path), which is exactly what the plugins -// emit. VST slots keep the JUCE-only decode — their plugin-emitted blobs are -// metadata wrappers, not real setStateInformation() chunks, and feeding those -// to a VST3 for the first time would be an unasked-for behaviour change. -static bool decodeStateBlob(const juce::String& s, juce::MemoryBlock& mb, - bool allowStandard) -{ - if (mb.fromBase64Encoding(s) && mb.getSize() > 0) - return true; - if (!allowStandard) - return false; - mb.reset(); - juce::MemoryOutputStream mo(mb, false); - return juce::Base64::convertFromBase64(mo, s) && mb.getSize() > 0; -} // 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 @@ -88,17 +69,7 @@ static SourceChain* getValidatedSource(AudioEngine* eng, const Napi::CallbackInf return eng->getSource((int) raw); } -static double loadSafeSampleRate(const AudioEngine& eng) -{ - const double sr = eng.getCurrentSampleRate(); - return (std::isfinite(sr) && sr > 0.0) ? sr : 48000.0; -} -static int loadSafeBlockSize(const AudioEngine& eng) -{ - const int bs = eng.getCurrentBlockSize(); - return bs > 0 ? bs : 256; -} // Destroys every in-process plugin editor window. MUST be called on the message // thread — lives in addon/EditorWindows now (TLC phase 7). @@ -1842,586 +1813,6 @@ static Napi::Value SetVstCrashSentinelPath(const Napi::CallbackInfo& info) // signals them all so the workers unblock and return a clean "cancelled" // error instead of hanging forever when the JUCE message thread is about // to be stopped (and any unfired callback would never arrive). -// Load a VST3, routing it through the out-of-process sandbox when -// shouldSandbox() says so (the filename pre-seed or the runtime crash -// blocklist), otherwise loading it in-process. The in-process load uses -// VSTHost::loadPluginAsync so the JUCE message thread keeps pumping during -// the plugin's init — critical for plugins like AmpliTube that post WM_USER -// / WM_TIMER messages to themselves while initialising. The sync -// createPluginInstance would block the pump, those self-messages would -// queue forever, and the plugin would end up half-wired (a pointer that -// only gets written by a queued message stays null, and the editor crashes -// on its first WindowProc dispatch — the AmpliTube failure signature). -// -// Threading: on !JUCE_MAC must be called from a libuv worker thread (NOT -// the JS main thread, NOT the JUCE message thread) — the done->wait below -// has to be on a thread that *isn't* the one running JUCE's pump or the -// load can't complete. On JUCE_MAC the inline sync fallback is used and -// the caller can be the Node/main thread (which is also JUCE's message -// thread there); LoadVST does exactly that, while LoadPresetWorker still -// hits this from a worker (a pre-existing macOS limitation). -// -// On a *required*-sandbox failure (the plugin matched shouldSandbox but the -// sandbox couldn't spawn) this returns nullptr with `error` set and -// `sandboxRequired` true, so the caller can choose how to surface it — -// LoadVSTWorker throws to JS, LoadPresetWorker just skips the slot. -static std::unique_ptr loadVstSandboxAware( - const juce::String& pluginPath, double sr, int bs, - juce::String& error, bool& sandboxRequired) -{ - sandboxRequired = false; - - // A plugin persisted in a signal-chain preset can be uninstalled or - // deleted between runs. Instantiating a VST3 whose module is gone from - // disk faults deep inside the format loader (a stack-buffer-overrun / - // 0xC0000409 on Windows) and takes the whole app down on startup — before - // the crash blocklist or sandbox can ever intervene, because the preset is - // restored independently of those guards. A native access violation also - // can't be caught by the renderer's JS try/catch around loadPreset. So - // pre-flight a cheap existence check here, the single choke point shared by - // every load path (direct LoadVST and preset restore, in-process and - // sandboxed, all platforms), and fail soft when the file is missing. - // - // Only filesystem paths are judged: VST3/LV2 fileOrIdentifiers are absolute - // paths (File::exists covers both a .vst3 file and a bundle directory), - // whereas macOS AudioUnit identifiers ("AudioUnit:...") are not absolute - // paths and must not be rejected here. - if (juce::File::isAbsolutePath(pluginPath) && ! juce::File(pluginPath).exists()) - { - error = "Plugin file not found: " + pluginPath; - VST_TRACE("loadVstSandboxAware: missing plugin file '%s' — skipping load", - pluginPath.toRawUTF8()); - return nullptr; - } - - juce::PluginDescription probeDesc; - probeDesc.fileOrIdentifier = pluginPath; - probeDesc.name = juce::File(pluginPath).getFileNameWithoutExtension(); - - if (slopsmith::sandbox::shouldSandbox(probeDesc)) - { - sandboxRequired = true; - juce::String sandboxErr; - auto processor = slopsmith::sandbox::tryLoadSandboxed( - probeDesc, sr, bs, sandboxErr); - if (!processor) - { - error = "sandbox load failed: " - + (sandboxErr.isEmpty() ? juce::String("unknown error") - : sandboxErr); - VST_TRACE("loadVstSandboxAware: sandbox path declined/failed: %s", - sandboxErr.toRawUTF8()); - } - return processor; - } - - #if JUCE_MAC - // macOS has no separate JUCE message thread (see startJuceMessageThread / - // dispatchOnMessageThread): the JUCE MessageManager is bound to the - // Node/main thread, and dispatchOnMessageThread historically ran inline - // on the caller. A callAsync + done->wait pattern would queue a callback - // to a pump that may never run in this calling context. - // - // Fall back to the sync loadPlugin, executed on whichever thread called - // in — the Node/main thread for LoadVST's JUCE_MAC branch (correct: that - // *is* the MessageManager thread on macOS), or a libuv worker thread for - // LoadPresetWorker (the pre-existing macOS constraint). Caveat: the - // existing dispatchOnMessageThread block on macOS already documents - // that "VST/AU plugin instantiation (which genuinely requires a message - // thread on macOS) is the one capability we give up until a proper - // libuv-based pump lands." LoadPresetWorker has called loadVstSandbox- - // Aware on a worker thread for ages under exactly the same constraint; - // moving LoadVST to AsyncWorker brings direct loads under the same - // (pre-existing) limitation. The AmpliTube-class self-message problem - // this PR targets is Windows-specific (Electron owns the OS main - // thread, forcing JUCE's MessageManager onto a background thread that - // createPluginInstance then blocks); macOS doesn't have that mismatch. - auto host = snapshotVstHost(); - if (! host) { error = "vstHost not initialised"; return nullptr; } - juce::String err; - auto instance = host->loadPlugin(pluginPath, sr, bs, err); - if (! instance) error = err.isNotEmpty() ? err : juce::String("load failed"); - return instance; - #else - // In-process: kick off createPluginInstanceAsync on the message thread, - // block *this* (libuv worker) thread on a WaitableEvent until the load - // callback fires. The message thread keeps pumping during the wait so - // the plugin's self-posted init messages dispatch and its state finishes - // wiring up before the editor is ever opened. - // - // All state passed across the thread hop is held by shared_ptr so it - // outlives the lambda even on an unexpected destructor / scope exit. - auto instance = std::make_shared>(); - auto loadError = std::make_shared(); - auto done = std::make_shared(); - - // Register BEFORE scheduling so a shutdown that lands between callAsync - // and the wait below can't miss us — cancelAllPendingLoads would - // otherwise see an empty set and the worker would block forever. - registerPendingLoad(done); - - // Check alreadyShutDown after registering to catch the inverse race - // (shutdown ran before we registered): if it's already set, the - // shutdown won't see this event and we must bail ourselves. - if (slopsmith::addon::isShuttingDown()) - { - unregisterPendingLoad(done); - error = "shutdown in flight"; - return nullptr; - } - - // Snapshot a shared_ptr to vstHost so the async load and its inner - // continuation can keep VSTHost (and thus formatManager) alive even if - // shutdown resets the global mid-load. The inner callback captures the - // same hostKeeper, so JUCE retains it until createPluginInstanceAsync - // completes; once the callback destructs, the keeper drops, and if the - // global has been reset by then the VSTHost destructor runs safely - // (no work in flight). The snapshot itself goes through vstHostMutex - // so the shared_ptr copy can't race with shutdown's vstHost.reset(). - auto hostKeeper = snapshotVstHost(); - - const bool scheduled = juce::MessageManager::callAsync( - [hostKeeper, pluginPath, sr, bs, instance, loadError, done]() - { - // Shutdown may have fired between callAsync queueing this - // lambda and the message thread picking it up. Bail before - // kicking off another in-flight createPluginInstanceAsync - // that the shutdown would otherwise have to wait on. - if (slopsmith::addon::isShuttingDown()) - { - *loadError = "shutdown in flight"; - done->signal(); - return; - } - if (! hostKeeper) - { - *loadError = "vstHost not initialised"; - done->signal(); - return; - } - hostKeeper->loadPluginAsync( - pluginPath, sr, bs, - [hostKeeper, instance, loadError, done] - (std::unique_ptr inst, juce::String err) - { - *instance = std::move(inst); - *loadError = std::move(err); - done->signal(); - }); - }); - - if (! scheduled) - { - // The message queue is gone (typically: shutdown in flight). The - // lambda will never run, so done would never signal — surface the - // failure rather than hanging the worker forever. - unregisterPendingLoad(done); - error = "message manager unavailable (shutdown?)"; - return nullptr; - } - - // No timeout: createPluginInstanceAsync is genuinely async (the message - // thread keeps pumping), so a slow first-run plugin (e.g. one doing a - // license check that exceeds 15 s) is allowed to take however long it - // takes. The old 15-second timeout in dispatchOnMessageThread could - // return early while the lambda was still running, then the lambda - // would construct a fully-initialised plugin only for it to immediately - // destruct because no one held a reference — running VST teardown on - // the message thread while the user had already moved on. That race is - // gone with this design. - // - // Tradeoff: this call holds a libuv threadpool worker for the duration - // of the plugin's init. Multiple concurrent hung loads could in theory - // starve other AsyncWorkers (fs / crypto). In practice plugin loads are - // user-driven and serialised (LoadPresetWorker loads slots one at a - // time), and a truly stuck load is bounded by app shutdown via - // cancelAllPendingLoads. A proper "fire-and-forget with a TSFN - // completion callback" model would eliminate the block entirely but - // requires a bigger API restructure than this PR's scope. - done->wait(); - unregisterPendingLoad(done); - - // Distinguish "shutdown cancelled us before the callback fired" - // (instance null AND error empty) from a normal load failure (instance - // null with error set) and a normal success. - if (! *instance && loadError->isEmpty()) - { - error = "load cancelled (shutdown)"; - return nullptr; - } - error = *loadError; - return std::move(*instance); - #endif -} - -// AsyncWorker wrapper for LoadVST. Execute() runs on a libuv worker thread, -// so loadVstSandboxAware can block-wait on the async load without freezing -// the JS main thread or deadlocking the JUCE message thread. -class LoadVSTWorker : public Napi::AsyncWorker -{ -public: - LoadVSTWorker(Napi::Env env, Napi::Promise::Deferred deferred, std::string path) - : Napi::AsyncWorker(env) - , deferred_(deferred) - , pluginPath_(std::move(path)) {} - - void Execute() override - { - // Serialize the FULL mutation (TLC deep-read 1): overlapping chain - // workers on the libuv pool must not interleave clear()/addProcessor(). - std::lock_guard chainLock(slopsmith::addon::chainMutationMutex()); - // Snapshot engine + vstHost through their mutex-protected helpers so - // shutdown's reset on the message thread can't race the worker's - // dereferences below. The shared_ptr locals keep both objects alive - // for the duration of this worker even if the globals get reset - // mid-load. The atomic alreadyShutDown gate is the early-out: once - // it's set, the dispatched reset is on its way and there's no point - // continuing. - if (slopsmith::addon::isShuttingDown()) - { - error_ = "shutdown in flight"; - return; - } - auto engineKeeper = snapshotEngine(); - auto hostSnap = snapshotVstHost(); - if (!engineKeeper || !hostSnap) - { - error_ = "engine not initialised"; - return; - } - - const auto sr = loadSafeSampleRate(*engineKeeper); - const auto bs = loadSafeBlockSize(*engineKeeper); - const auto path = juce::String(pluginPath_); - VST_TRACE("LoadVSTWorker: path='%s' sr=%.0f bs=%d", - pluginPath_.c_str(), sr, bs); - - bool sandboxRequired = false; - juce::String err; - auto processor = loadVstSandboxAware(path, sr, bs, err, sandboxRequired); - - if (sandboxRequired && !processor) - { - // The plugin's on the denylist and the sandbox couldn't spawn — - // falling back to in-process is what crashed the addon to begin - // with. Surface as a JS exception (handled in OnOK). - fprintf(stderr, "[LoadVST] Failed: %s\n", err.toRawUTF8()); - error_ = err; - sandboxFailed_ = true; - return; - } - - if (!processor) - { - fprintf(stderr, "[LoadVST] Failed: %s\n", err.toRawUTF8()); - error_ = err; - return; - } - - // Engine may have been torn down while we were waiting on the async - // load. The shared_ptr captures keep `processor` alive; just don't - // touch a freed engine. The processor destructs cleanly when this - // scope exits. - // - // Gate on alreadyShutDown (atomic, properly synchronised) before the - // raw engine/vstHost pointer reads — once that flag is set, the - // dispatched reset of engine/vstHost is on its way and any use of - // the pointers from this worker thread is racy. The atomic check is - // the authoritative "should I still be touching engine?" signal. - if (slopsmith::addon::isShuttingDown()) - { - error_ = "engine torn down during load"; - return; - } - // Re-snapshot the engine — the original engineKeeper might have - // outlived a reset on the message thread, but the AudioEngine - // we're about to mutate must be the still-installed one. If the - // global has been reset, the local keeps the old engine alive but - // we shouldn't be adding slots to it any more. - auto liveEngine = snapshotEngine(); - if (!liveEngine || !snapshotVstHost()) - { - error_ = "engine torn down during load"; - return; - } - - auto name = processor->getName(); - slotId_ = liveEngine->getSignalChain().addProcessor( - std::move(processor), - ProcessorSlot::Type::VST, - name, - path); - } - - void OnOK() override - { - if (sandboxFailed_) - { - // Match the prior LoadVST throw-on-required-sandbox-failure - // behaviour so renderers' try/catch keeps working. - deferred_.Reject( - Napi::Error::New(Env(), error_.toStdString()).Value()); - return; - } - deferred_.Resolve(Napi::Number::New(Env(), slotId_)); - } - - void OnError(const Napi::Error& e) override { deferred_.Reject(e.Value()); } - -private: - Napi::Promise::Deferred deferred_; - std::string pluginPath_; - int slotId_ = -1; - bool sandboxFailed_ = false; - juce::String error_; -}; - -static Napi::Value LoadVST(const Napi::CallbackInfo& info) -{ - auto env = info.Env(); - auto deferred = Napi::Promise::Deferred::New(env); - - if (!snapshotEngine() || !snapshotVstHost() || info.Length() < 1) - { - deferred.Resolve(Napi::Number::New(env, -1)); - return deferred.Promise(); - } - - auto pluginPath = info[0].As().Utf8Value(); - - #if JUCE_MAC - // On macOS the JUCE MessageManager is bound to the Node/main thread. - // Running this as an AsyncWorker would call vstHost->loadPlugin on a - // libuv worker thread, which JUCE documents as unsupported for VST/AU - // instantiation. Do the load synchronously on the Node/main thread - // (same as the pre-PR LoadVST) and return a resolved Promise to match - // the new signature. Pays the foreground-block cost the AsyncWorker - // path was supposed to avoid, but that's the existing macOS reality — - // dispatchOnMessageThread already runs inline there. The async-load - // motivation (AmpliTube blocking the background JUCE message thread - // under Electron) is a Windows-only problem. - // Snapshot once for the whole load so the same AudioEngine is used for - // the sr/bs reads and the addProcessor mutation, even if shutdown - // resets the global mid-call. - auto liveEngine = snapshotEngine(); - if (! liveEngine) - { - deferred.Resolve(Napi::Number::New(env, -1)); - return deferred.Promise(); - } - juce::String error; - bool sandboxRequired = false; - auto processor = loadVstSandboxAware( - juce::String(pluginPath), - loadSafeSampleRate(*liveEngine), - loadSafeBlockSize(*liveEngine), - error, sandboxRequired); - - if (sandboxRequired && !processor) - { - fprintf(stderr, "[LoadVST] Failed: %s\n", error.toRawUTF8()); - deferred.Reject( - Napi::Error::New(env, error.toStdString()).Value()); - return deferred.Promise(); - } - - int slotId = -1; - if (processor) - { - auto name = processor->getName(); - slotId = liveEngine->getSignalChain().addProcessor( - std::move(processor), - ProcessorSlot::Type::VST, - name, - juce::String(pluginPath)); - } - else - { - fprintf(stderr, "[LoadVST] Failed: %s\n", error.toRawUTF8()); - } - deferred.Resolve(Napi::Number::New(env, slotId)); - return deferred.Promise(); - #else - auto* worker = new LoadVSTWorker(env, deferred, std::move(pluginPath)); - worker->Queue(); - return deferred.Promise(); - #endif -} - -class LoadNAMWorker : public Napi::AsyncWorker -{ -public: - LoadNAMWorker(Napi::Env env, Napi::Promise::Deferred deferred, std::string path) - : Napi::AsyncWorker(env), deferred_(deferred), modelPath_(std::move(path)) {} - - void Execute() override - { - // Serialize the FULL mutation (TLC deep-read 1): overlapping chain - // workers on the libuv pool must not interleave clear()/addProcessor(). - std::lock_guard chainLock(slopsmith::addon::chainMutationMutex()); - auto liveEngine = snapshotEngine(); - if (!liveEngine) { slotId_ = -1; return; } - - auto processor = std::make_unique(); - if (processor->loadModel(juce::File(juce::String(modelPath_)))) - { - auto name = processor->getModelName(); - slotId_ = liveEngine->getSignalChain().addProcessor( - std::move(processor), - ProcessorSlot::Type::NAM, - "NAM: " + name, - juce::String(modelPath_)); - } - } - - void OnOK() override { deferred_.Resolve(Napi::Number::New(Env(), slotId_)); } - void OnError(const Napi::Error& e) override { deferred_.Reject(e.Value()); } - -private: - Napi::Promise::Deferred deferred_; - std::string modelPath_; - int slotId_ = -1; -}; - -static Napi::Value LoadNAMModel(const Napi::CallbackInfo& info) -{ - auto env = info.Env(); - auto deferred = Napi::Promise::Deferred::New(env); - - if (!snapshotEngine() || info.Length() < 1) { - deferred.Resolve(Napi::Number::New(env, -1)); - return deferred.Promise(); - } - - auto modelPath = info[0].As().Utf8Value(); - auto worker = new LoadNAMWorker(env, deferred, modelPath); - worker->Queue(); - return deferred.Promise(); -} - -class LoadIRWorker : public Napi::AsyncWorker -{ -public: - LoadIRWorker(Napi::Env env, Napi::Promise::Deferred deferred, std::string path) - : Napi::AsyncWorker(env), deferred_(deferred), irPath_(std::move(path)) {} - - void Execute() override - { - // Serialize the FULL mutation (TLC deep-read 1): overlapping chain - // workers on the libuv pool must not interleave clear()/addProcessor(). - std::lock_guard chainLock(slopsmith::addon::chainMutationMutex()); - auto liveEngine = snapshotEngine(); - if (!liveEngine) { slotId_ = -1; return; } - - const auto sr = loadSafeSampleRate(*liveEngine); - const auto bs = loadSafeBlockSize(*liveEngine); - auto processor = std::make_unique(); - processor->setPlayConfigDetails(2, 2, sr, bs); - processor->prepareToPlay(sr, bs); - if (processor->loadIR(juce::File(juce::String(irPath_)))) - { - auto name = processor->getIRName(); - slotId_ = liveEngine->getSignalChain().addProcessor( - std::move(processor), - ProcessorSlot::Type::IR, - "IR: " + name, - juce::String(irPath_)); - } - } - - void OnOK() override { deferred_.Resolve(Napi::Number::New(Env(), slotId_)); } - void OnError(const Napi::Error& e) override { deferred_.Reject(e.Value()); } - -private: - Napi::Promise::Deferred deferred_; - std::string irPath_; - int slotId_ = -1; -}; - -static Napi::Value LoadIR(const Napi::CallbackInfo& info) -{ - auto env = info.Env(); - auto deferred = Napi::Promise::Deferred::New(env); - - if (!snapshotEngine() || info.Length() < 1) { - deferred.Resolve(Napi::Number::New(env, -1)); - return deferred.Promise(); - } - - auto irPath = info[0].As().Utf8Value(); - auto worker = new LoadIRWorker(env, deferred, irPath); - worker->Queue(); - return deferred.Promise(); -} - -// Replace the IR of an EXISTING convolution slot in place (cab swap / mic move), -// so the rest of the chain — the amp VST above all — is NOT torn down and rebuilt. -// Mirrors LoadIRWorker but calls SignalChain::replaceProcessor(slotId, …) instead -// of addProcessor. Optional `gain` (>=0) updates the slot's post-gain (the cab -// makeup); a negative gain leaves the existing post-gain untouched. -class ReplaceIRWorker : public Napi::AsyncWorker -{ -public: - ReplaceIRWorker(Napi::Env env, Napi::Promise::Deferred deferred, - int slotId, std::string path, float gain) - : Napi::AsyncWorker(env), deferred_(deferred), - slotId_(slotId), irPath_(std::move(path)), gain_(gain) {} - - void Execute() override - { - // Serialize the FULL mutation (TLC deep-read 1): overlapping chain - // workers on the libuv pool must not interleave clear()/addProcessor(). - std::lock_guard chainLock(slopsmith::addon::chainMutationMutex()); - auto liveEngine = snapshotEngine(); - if (!liveEngine) { ok_ = false; return; } - - const auto sr = loadSafeSampleRate(*liveEngine); - const auto bs = loadSafeBlockSize(*liveEngine); - auto processor = std::make_unique(); - processor->setPlayConfigDetails(2, 2, sr, bs); - processor->prepareToPlay(sr, bs); - if (! processor->loadIR(juce::File(juce::String(irPath_)))) { ok_ = false; return; } - - auto name = processor->getIRName(); - ok_ = liveEngine->getSignalChain().replaceProcessor( - slotId_, std::move(processor), - "IR: " + name, juce::String(irPath_)); - if (ok_ && gain_ >= 0.0f) - liveEngine->getSignalChain().setPostGain(slotId_, gain_); - } - - void OnOK() override { deferred_.Resolve(Napi::Boolean::New(Env(), ok_)); } - void OnError(const Napi::Error& e) override { deferred_.Reject(e.Value()); } - -private: - Napi::Promise::Deferred deferred_; - int slotId_; - std::string irPath_; - float gain_; - bool ok_ = false; -}; - -static Napi::Value ReplaceIR(const Napi::CallbackInfo& info) -{ - auto env = info.Env(); - auto deferred = Napi::Promise::Deferred::New(env); - - if (!snapshotEngine() || info.Length() < 2 - || !info[0].IsNumber() || !info[1].IsString()) { - deferred.Resolve(Napi::Boolean::New(env, false)); - return deferred.Promise(); - } - - const int slotId = info[0].As().Int32Value(); - const auto irPath = info[1].As().Utf8Value(); - const float gain = (info.Length() >= 3 && info[2].IsNumber()) - ? info[2].As().FloatValue() : -1.0f; - - auto worker = new ReplaceIRWorker(env, deferred, slotId, irPath, gain); - worker->Queue(); - return deferred.Promise(); -} - static Napi::Value RemoveProcessor(const Napi::CallbackInfo& info) { // Typed extractors (addon/NapiHelpers.h): NaN/Inf slot ids used to coerce @@ -2751,190 +2142,6 @@ static Napi::Value SavePreset(const Napi::CallbackInfo& info) return Napi::String::New(env, json.toStdString()); } -class LoadPresetWorker : public Napi::AsyncWorker -{ -public: - LoadPresetWorker(Napi::Env env, Napi::Promise::Deferred deferred, std::string json) - : Napi::AsyncWorker(env), deferred_(deferred), presetJson_(std::move(json)) {} - - void Execute() override - { - // Serialize the FULL mutation (TLC deep-read 1): overlapping chain - // workers on the libuv pool must not interleave clear()/addProcessor(). - std::lock_guard chainLock(slopsmith::addon::chainMutationMutex()); - auto liveEngine = snapshotEngine(); - if (!liveEngine) { success_ = false; error_ = "No engine"; return; } - - auto parsed = juce::JSON::parse(juce::String(presetJson_)); - if (!parsed.isObject()) { success_ = false; error_ = "Invalid JSON"; return; } - - auto* root = parsed.getDynamicObject(); - if (!root) { success_ = false; error_ = "Invalid preset"; return; } - - auto chainVar = root->getProperty("chain"); - auto* chainArray = chainVar.getArray(); - if (!chainArray) { success_ = false; error_ = "No chain array"; return; } - - // NB: any open in-process editor windows were already torn down on the - // message thread by LoadPreset() before this AsyncWorker was queued (see - // there) — so clearing the chain here can't leave an editor pointing at - // a freed processor (use-after-free; #56). We deliberately do NOT tear - // editors down from this worker thread: JUCE GUI objects must only be - // destroyed on the message thread, and macOS has no pump to marshal to - // from here. - // Clear existing chain - liveEngine->getSignalChain().clear(); - - double sr = loadSafeSampleRate(*liveEngine); - int bs = loadSafeBlockSize(*liveEngine); - - for (auto& slotVar : *chainArray) - { - auto* slotObj = slotVar.getDynamicObject(); - if (!slotObj) continue; - - int type = (int)slotObj->getProperty("type"); - auto name = slotObj->getProperty("name").toString(); - auto path = slotObj->getProperty("path").toString(); - bool bypassed = (bool)slotObj->getProperty("bypassed"); - auto stateB64 = slotObj->getProperty("state").toString(); - - std::unique_ptr processor; - - if (type == (int)ProcessorSlot::Type::VST && snapshotVstHost()) - { - // Sandbox-aware load: a crash-blocklisted plugin restored - // from a preset must still go out-of-process, otherwise the - // "one crash, then always sandbox" contract is defeated. - juce::String err; - bool sandboxRequired = false; - processor = loadVstSandboxAware(path, sr, bs, err, sandboxRequired); - if (!processor) - { - fprintf(stderr, "[LoadPreset] VST load failed: %s (%s)\n", - name.toRawUTF8(), err.toRawUTF8()); - continue; - } - } - else if (type == (int)ProcessorSlot::Type::NAM) - { - auto nam = std::make_unique(); - if (!nam->loadModel(juce::File(path))) - { - fprintf(stderr, "[LoadPreset] NAM load failed: %s\n", path.toRawUTF8()); - continue; - } - processor = std::move(nam); - } - else if (type == (int)ProcessorSlot::Type::IR) - { - auto ir = std::make_unique(); - ir->setPlayConfigDetails(2, 2, sr, bs); - ir->prepareToPlay(sr, bs); - if (!ir->loadIR(juce::File(path))) - { - fprintf(stderr, "[LoadPreset] IR load failed: %s\n", path.toRawUTF8()); - continue; - } - processor = std::move(ir); - } - else continue; - - int slotId = liveEngine->getSignalChain().addProcessor( - std::move(processor), - (ProcessorSlot::Type)type, - name, path); - - if (bypassed && slotId >= 0) - liveEngine->getSignalChain().setBypass(slotId, true); - - // Stereo routing (St-1). Absent keys read back as 0 (= default), so - // mono presets restore exactly as before. - if (slotId >= 0) - { - if (slotObj->hasProperty("pan")) - liveEngine->getSignalChain().setPan(slotId, (float)(double)slotObj->getProperty("pan")); - if (slotObj->hasProperty("branch")) - liveEngine->getSignalChain().setBranch(slotId, (int)slotObj->getProperty("branch")); - if (slotObj->hasProperty("postGain")) - liveEngine->getSignalChain().setPostGain(slotId, (float)(double)slotObj->getProperty("postGain")); - if (slotObj->hasProperty("branchSrc")) - liveEngine->getSignalChain().setBranchSrc(slotId, (int)slotObj->getProperty("branchSrc")); - } - - // Restore processor state (JUCE-format base64; IR/NAM slots also - // accept standard base64 — see decodeStateBlob: their plugin- - // emitted JSON states were silently dropped before, so IR stages - // never got their per-stage gain). - if (stateB64.isNotEmpty() && slotId >= 0) - { - const bool allowStandard = type == (int)ProcessorSlot::Type::IR - || type == (int)ProcessorSlot::Type::NAM; - juce::MemoryBlock state; - if (decodeStateBlob(stateB64, state, allowStandard)) - { - // Through the class's own synchronized API (deep-read 9) -- - // no more const_cast around setSlotState's locking. - liveEngine->getSignalChain().setSlotState(slotId, state); - } - } - - slotsLoaded_++; - } - - success_ = true; - generation_ = slopsmith::addon::bumpChainGeneration(); // still under chainLock - } - - void OnOK() override - { - auto obj = Napi::Object::New(Env()); - obj.Set("success", success_); - obj.Set("slotsLoaded", slotsLoaded_); - obj.Set("chainGeneration", (double) generation_); - if (!success_) obj.Set("error", error_); - deferred_.Resolve(obj); - } - void OnError(const Napi::Error& e) override { deferred_.Reject(e.Value()); } - -private: - Napi::Promise::Deferred deferred_; - std::string presetJson_; - uint64_t generation_ = 0; - bool success_ = false; - std::string error_; - int slotsLoaded_ = 0; -}; - -static Napi::Value LoadPreset(const Napi::CallbackInfo& info) -{ - auto env = info.Env(); - auto deferred = Napi::Promise::Deferred::New(env); - auto liveEngine = snapshotEngine(); - - if (!liveEngine || info.Length() < 1) { - auto obj = Napi::Object::New(env); - obj.Set("success", false); - obj.Set("error", "No engine or missing argument"); - deferred.Resolve(obj); - return deferred.Promise(); - } - - // Tear down any open in-process editor windows NOW, on the N-API/main - // thread, before the AsyncWorker frees the chain's processors on a libuv - // worker (#56). Doing it here — not inside LoadPresetWorker::Execute — keeps - // JUCE GUI teardown off the worker thread: on macOS this thread IS the - // message thread (inline teardown); on Linux/Windows closeAllPluginEditor- - // Windows() posts to the dedicated JUCE message thread and blocks. Either - // way editors are destroyed before Execute() clears the chain. - closeAllPluginEditorWindows(); - - auto json = info[0].As().Utf8Value(); - auto worker = new LoadPresetWorker(env, deferred, json); - worker->Queue(); - return deferred.Promise(); -} - static Napi::Value SetMultiBypass(const Napi::CallbackInfo& info) { auto env = info.Env(); diff --git a/src/audio/addon/ChainOps.cpp b/src/audio/addon/ChainOps.cpp index 3dbe091..82ab181 100644 --- a/src/audio/addon/ChainOps.cpp +++ b/src/audio/addon/ChainOps.cpp @@ -1,6 +1,28 @@ +// ChainOps implementation — the chain-mutating async workers, their N-API +// handlers, and loadVstSandboxAware, moved verbatim from NodeAddon.cpp (TLC +// plan phase 7b / 3.3). The serialization primitives (chainMutationMutex / +// chainGeneration) landed in phase 7a; every worker Execute() below holds +// the mutex for its full body. + #include "ChainOps.h" +#include "AddonContext.h" +#include "NapiHelpers.h" +#include "EditorWindows.h" +#include "../AudioEngine.h" +#include "../VSTHost.h" +#include "../VSTTrace.h" +#include "../NAMProcessor.h" +#include "../IRLoader.h" +#include "../Sandbox/SandboxedProcessor.h" + +#include + #include +#include +#include +#include +#include namespace slopsmith::addon { @@ -22,4 +44,817 @@ uint64_t currentChainGeneration() return chainGeneration.load(std::memory_order_acquire); } +// ── decodeStateBlob (moved verbatim) ──────────────────── + +// Decode a state blob that may be in EITHER base64 flavour. JUCE's +// MemoryBlock::fromBase64Encoding only understands JUCE's own proprietary +// format (".") and returns false for standard RFC-4648 +// base64 — which is what the Python-side plugins (rig_builder et al.) emit +// for per-slot state. That silent false meant setState() was never called +// for those slots: IR stages lost their per-stage `gain` (the cab loudness +// makeup and amp trims never reached the engine). Try the JUCE format first +// (engine-native saves), then fall back to standard b64. +// +// `allowStandard` is only set for IR/NAM slots: their processors take a JSON +// state ({"irPath","gain"} / model path), which is exactly what the plugins +// emit. VST slots keep the JUCE-only decode — their plugin-emitted blobs are +// metadata wrappers, not real setStateInformation() chunks, and feeding those +// to a VST3 for the first time would be an unasked-for behaviour change. +bool decodeStateBlob(const juce::String& s, juce::MemoryBlock& mb, + bool allowStandard) +{ + if (mb.fromBase64Encoding(s) && mb.getSize() > 0) + return true; + if (!allowStandard) + return false; + mb.reset(); + juce::MemoryOutputStream mo(mb, false); + return juce::Base64::convertFromBase64(mo, s) && mb.getSize() > 0; +} + +double loadSafeSampleRate(const AudioEngine& eng) +{ + const double sr = eng.getCurrentSampleRate(); + return (std::isfinite(sr) && sr > 0.0) ? sr : 48000.0; +} + +int loadSafeBlockSize(const AudioEngine& eng) +{ + const int bs = eng.getCurrentBlockSize(); + return bs > 0 ? bs : 256; +} + +// ── loadVstSandboxAware (moved verbatim from NodeAddon.cpp) ──────────────────── + +// Load a VST3, routing it through the out-of-process sandbox when +// shouldSandbox() says so (the filename pre-seed or the runtime crash +// blocklist), otherwise loading it in-process. The in-process load uses +// VSTHost::loadPluginAsync so the JUCE message thread keeps pumping during +// the plugin's init — critical for plugins like AmpliTube that post WM_USER +// / WM_TIMER messages to themselves while initialising. The sync +// createPluginInstance would block the pump, those self-messages would +// queue forever, and the plugin would end up half-wired (a pointer that +// only gets written by a queued message stays null, and the editor crashes +// on its first WindowProc dispatch — the AmpliTube failure signature). +// +// Threading: on !JUCE_MAC must be called from a libuv worker thread (NOT +// the JS main thread, NOT the JUCE message thread) — the done->wait below +// has to be on a thread that *isn't* the one running JUCE's pump or the +// load can't complete. On JUCE_MAC the inline sync fallback is used and +// the caller can be the Node/main thread (which is also JUCE's message +// thread there); LoadVST does exactly that, while LoadPresetWorker still +// hits this from a worker (a pre-existing macOS limitation). +// +// On a *required*-sandbox failure (the plugin matched shouldSandbox but the +// sandbox couldn't spawn) this returns nullptr with `error` set and +// `sandboxRequired` true, so the caller can choose how to surface it — +// LoadVSTWorker throws to JS, LoadPresetWorker just skips the slot. +std::unique_ptr loadVstSandboxAware( + const juce::String& pluginPath, double sr, int bs, + juce::String& error, bool& sandboxRequired) +{ + sandboxRequired = false; + + // A plugin persisted in a signal-chain preset can be uninstalled or + // deleted between runs. Instantiating a VST3 whose module is gone from + // disk faults deep inside the format loader (a stack-buffer-overrun / + // 0xC0000409 on Windows) and takes the whole app down on startup — before + // the crash blocklist or sandbox can ever intervene, because the preset is + // restored independently of those guards. A native access violation also + // can't be caught by the renderer's JS try/catch around loadPreset. So + // pre-flight a cheap existence check here, the single choke point shared by + // every load path (direct LoadVST and preset restore, in-process and + // sandboxed, all platforms), and fail soft when the file is missing. + // + // Only filesystem paths are judged: VST3/LV2 fileOrIdentifiers are absolute + // paths (File::exists covers both a .vst3 file and a bundle directory), + // whereas macOS AudioUnit identifiers ("AudioUnit:...") are not absolute + // paths and must not be rejected here. + if (juce::File::isAbsolutePath(pluginPath) && ! juce::File(pluginPath).exists()) + { + error = "Plugin file not found: " + pluginPath; + VST_TRACE("loadVstSandboxAware: missing plugin file '%s' — skipping load", + pluginPath.toRawUTF8()); + return nullptr; + } + + juce::PluginDescription probeDesc; + probeDesc.fileOrIdentifier = pluginPath; + probeDesc.name = juce::File(pluginPath).getFileNameWithoutExtension(); + + if (slopsmith::sandbox::shouldSandbox(probeDesc)) + { + sandboxRequired = true; + juce::String sandboxErr; + auto processor = slopsmith::sandbox::tryLoadSandboxed( + probeDesc, sr, bs, sandboxErr); + if (!processor) + { + error = "sandbox load failed: " + + (sandboxErr.isEmpty() ? juce::String("unknown error") + : sandboxErr); + VST_TRACE("loadVstSandboxAware: sandbox path declined/failed: %s", + sandboxErr.toRawUTF8()); + } + return processor; + } + + #if JUCE_MAC + // macOS has no separate JUCE message thread (see startJuceMessageThread / + // dispatchOnMessageThread): the JUCE MessageManager is bound to the + // Node/main thread, and dispatchOnMessageThread historically ran inline + // on the caller. A callAsync + done->wait pattern would queue a callback + // to a pump that may never run in this calling context. + // + // Fall back to the sync loadPlugin, executed on whichever thread called + // in — the Node/main thread for LoadVST's JUCE_MAC branch (correct: that + // *is* the MessageManager thread on macOS), or a libuv worker thread for + // LoadPresetWorker (the pre-existing macOS constraint). Caveat: the + // existing dispatchOnMessageThread block on macOS already documents + // that "VST/AU plugin instantiation (which genuinely requires a message + // thread on macOS) is the one capability we give up until a proper + // libuv-based pump lands." LoadPresetWorker has called loadVstSandbox- + // Aware on a worker thread for ages under exactly the same constraint; + // moving LoadVST to AsyncWorker brings direct loads under the same + // (pre-existing) limitation. The AmpliTube-class self-message problem + // this PR targets is Windows-specific (Electron owns the OS main + // thread, forcing JUCE's MessageManager onto a background thread that + // createPluginInstance then blocks); macOS doesn't have that mismatch. + auto host = snapshotVstHost(); + if (! host) { error = "vstHost not initialised"; return nullptr; } + juce::String err; + auto instance = host->loadPlugin(pluginPath, sr, bs, err); + if (! instance) error = err.isNotEmpty() ? err : juce::String("load failed"); + return instance; + #else + // In-process: kick off createPluginInstanceAsync on the message thread, + // block *this* (libuv worker) thread on a WaitableEvent until the load + // callback fires. The message thread keeps pumping during the wait so + // the plugin's self-posted init messages dispatch and its state finishes + // wiring up before the editor is ever opened. + // + // All state passed across the thread hop is held by shared_ptr so it + // outlives the lambda even on an unexpected destructor / scope exit. + auto instance = std::make_shared>(); + auto loadError = std::make_shared(); + auto done = std::make_shared(); + + // Register BEFORE scheduling so a shutdown that lands between callAsync + // and the wait below can't miss us — cancelAllPendingLoads would + // otherwise see an empty set and the worker would block forever. + registerPendingLoad(done); + + // Check alreadyShutDown after registering to catch the inverse race + // (shutdown ran before we registered): if it's already set, the + // shutdown won't see this event and we must bail ourselves. + if (slopsmith::addon::isShuttingDown()) + { + unregisterPendingLoad(done); + error = "shutdown in flight"; + return nullptr; + } + + // Snapshot a shared_ptr to vstHost so the async load and its inner + // continuation can keep VSTHost (and thus formatManager) alive even if + // shutdown resets the global mid-load. The inner callback captures the + // same hostKeeper, so JUCE retains it until createPluginInstanceAsync + // completes; once the callback destructs, the keeper drops, and if the + // global has been reset by then the VSTHost destructor runs safely + // (no work in flight). The snapshot itself goes through vstHostMutex + // so the shared_ptr copy can't race with shutdown's vstHost.reset(). + auto hostKeeper = snapshotVstHost(); + + const bool scheduled = juce::MessageManager::callAsync( + [hostKeeper, pluginPath, sr, bs, instance, loadError, done]() + { + // Shutdown may have fired between callAsync queueing this + // lambda and the message thread picking it up. Bail before + // kicking off another in-flight createPluginInstanceAsync + // that the shutdown would otherwise have to wait on. + if (slopsmith::addon::isShuttingDown()) + { + *loadError = "shutdown in flight"; + done->signal(); + return; + } + if (! hostKeeper) + { + *loadError = "vstHost not initialised"; + done->signal(); + return; + } + hostKeeper->loadPluginAsync( + pluginPath, sr, bs, + [hostKeeper, instance, loadError, done] + (std::unique_ptr inst, juce::String err) + { + *instance = std::move(inst); + *loadError = std::move(err); + done->signal(); + }); + }); + + if (! scheduled) + { + // The message queue is gone (typically: shutdown in flight). The + // lambda will never run, so done would never signal — surface the + // failure rather than hanging the worker forever. + unregisterPendingLoad(done); + error = "message manager unavailable (shutdown?)"; + return nullptr; + } + + // No timeout: createPluginInstanceAsync is genuinely async (the message + // thread keeps pumping), so a slow first-run plugin (e.g. one doing a + // license check that exceeds 15 s) is allowed to take however long it + // takes. The old 15-second timeout in dispatchOnMessageThread could + // return early while the lambda was still running, then the lambda + // would construct a fully-initialised plugin only for it to immediately + // destruct because no one held a reference — running VST teardown on + // the message thread while the user had already moved on. That race is + // gone with this design. + // + // Tradeoff: this call holds a libuv threadpool worker for the duration + // of the plugin's init. Multiple concurrent hung loads could in theory + // starve other AsyncWorkers (fs / crypto). In practice plugin loads are + // user-driven and serialised (LoadPresetWorker loads slots one at a + // time), and a truly stuck load is bounded by app shutdown via + // cancelAllPendingLoads. A proper "fire-and-forget with a TSFN + // completion callback" model would eliminate the block entirely but + // requires a bigger API restructure than this PR's scope. + done->wait(); + unregisterPendingLoad(done); + + // Distinguish "shutdown cancelled us before the callback fired" + // (instance null AND error empty) from a normal load failure (instance + // null with error set) and a normal success. + if (! *instance && loadError->isEmpty()) + { + error = "load cancelled (shutdown)"; + return nullptr; + } + error = *loadError; + return std::move(*instance); + #endif +} + +// AsyncWorker wrapper for LoadVST. Execute() runs on a libuv worker thread, +// so loadVstSandboxAware can block-wait on the async load without freezing +// the JS main thread or deadlocking the JUCE message thread. + +// ── VST/NAM/IR workers + handlers (moved verbatim from NodeAddon.cpp) ──────────────────── + +class LoadVSTWorker : public Napi::AsyncWorker +{ +public: + LoadVSTWorker(Napi::Env env, Napi::Promise::Deferred deferred, std::string path) + : Napi::AsyncWorker(env) + , deferred_(deferred) + , pluginPath_(std::move(path)) {} + + void Execute() override + { + // Serialize the FULL mutation (TLC deep-read 1): overlapping chain + // workers on the libuv pool must not interleave clear()/addProcessor(). + std::lock_guard chainLock(slopsmith::addon::chainMutationMutex()); + // Snapshot engine + vstHost through their mutex-protected helpers so + // shutdown's reset on the message thread can't race the worker's + // dereferences below. The shared_ptr locals keep both objects alive + // for the duration of this worker even if the globals get reset + // mid-load. The atomic alreadyShutDown gate is the early-out: once + // it's set, the dispatched reset is on its way and there's no point + // continuing. + if (slopsmith::addon::isShuttingDown()) + { + error_ = "shutdown in flight"; + return; + } + auto engineKeeper = snapshotEngine(); + auto hostSnap = snapshotVstHost(); + if (!engineKeeper || !hostSnap) + { + error_ = "engine not initialised"; + return; + } + + const auto sr = loadSafeSampleRate(*engineKeeper); + const auto bs = loadSafeBlockSize(*engineKeeper); + const auto path = juce::String(pluginPath_); + VST_TRACE("LoadVSTWorker: path='%s' sr=%.0f bs=%d", + pluginPath_.c_str(), sr, bs); + + bool sandboxRequired = false; + juce::String err; + auto processor = loadVstSandboxAware(path, sr, bs, err, sandboxRequired); + + if (sandboxRequired && !processor) + { + // The plugin's on the denylist and the sandbox couldn't spawn — + // falling back to in-process is what crashed the addon to begin + // with. Surface as a JS exception (handled in OnOK). + fprintf(stderr, "[LoadVST] Failed: %s\n", err.toRawUTF8()); + error_ = err; + sandboxFailed_ = true; + return; + } + + if (!processor) + { + fprintf(stderr, "[LoadVST] Failed: %s\n", err.toRawUTF8()); + error_ = err; + return; + } + + // Engine may have been torn down while we were waiting on the async + // load. The shared_ptr captures keep `processor` alive; just don't + // touch a freed engine. The processor destructs cleanly when this + // scope exits. + // + // Gate on alreadyShutDown (atomic, properly synchronised) before the + // raw engine/vstHost pointer reads — once that flag is set, the + // dispatched reset of engine/vstHost is on its way and any use of + // the pointers from this worker thread is racy. The atomic check is + // the authoritative "should I still be touching engine?" signal. + if (slopsmith::addon::isShuttingDown()) + { + error_ = "engine torn down during load"; + return; + } + // Re-snapshot the engine — the original engineKeeper might have + // outlived a reset on the message thread, but the AudioEngine + // we're about to mutate must be the still-installed one. If the + // global has been reset, the local keeps the old engine alive but + // we shouldn't be adding slots to it any more. + auto liveEngine = snapshotEngine(); + if (!liveEngine || !snapshotVstHost()) + { + error_ = "engine torn down during load"; + return; + } + + auto name = processor->getName(); + slotId_ = liveEngine->getSignalChain().addProcessor( + std::move(processor), + ProcessorSlot::Type::VST, + name, + path); + } + + void OnOK() override + { + if (sandboxFailed_) + { + // Match the prior LoadVST throw-on-required-sandbox-failure + // behaviour so renderers' try/catch keeps working. + deferred_.Reject( + Napi::Error::New(Env(), error_.toStdString()).Value()); + return; + } + deferred_.Resolve(Napi::Number::New(Env(), slotId_)); + } + + void OnError(const Napi::Error& e) override { deferred_.Reject(e.Value()); } + +private: + Napi::Promise::Deferred deferred_; + std::string pluginPath_; + int slotId_ = -1; + bool sandboxFailed_ = false; + juce::String error_; +}; + +Napi::Value LoadVST(const Napi::CallbackInfo& info) +{ + auto env = info.Env(); + auto deferred = Napi::Promise::Deferred::New(env); + + if (!snapshotEngine() || !snapshotVstHost() || info.Length() < 1) + { + deferred.Resolve(Napi::Number::New(env, -1)); + return deferred.Promise(); + } + + auto pluginPath = info[0].As().Utf8Value(); + + #if JUCE_MAC + // On macOS the JUCE MessageManager is bound to the Node/main thread. + // Running this as an AsyncWorker would call vstHost->loadPlugin on a + // libuv worker thread, which JUCE documents as unsupported for VST/AU + // instantiation. Do the load synchronously on the Node/main thread + // (same as the pre-PR LoadVST) and return a resolved Promise to match + // the new signature. Pays the foreground-block cost the AsyncWorker + // path was supposed to avoid, but that's the existing macOS reality — + // dispatchOnMessageThread already runs inline there. The async-load + // motivation (AmpliTube blocking the background JUCE message thread + // under Electron) is a Windows-only problem. + // Snapshot once for the whole load so the same AudioEngine is used for + // the sr/bs reads and the addProcessor mutation, even if shutdown + // resets the global mid-call. + auto liveEngine = snapshotEngine(); + if (! liveEngine) + { + deferred.Resolve(Napi::Number::New(env, -1)); + return deferred.Promise(); + } + juce::String error; + bool sandboxRequired = false; + auto processor = loadVstSandboxAware( + juce::String(pluginPath), + loadSafeSampleRate(*liveEngine), + loadSafeBlockSize(*liveEngine), + error, sandboxRequired); + + if (sandboxRequired && !processor) + { + fprintf(stderr, "[LoadVST] Failed: %s\n", error.toRawUTF8()); + deferred.Reject( + Napi::Error::New(env, error.toStdString()).Value()); + return deferred.Promise(); + } + + int slotId = -1; + if (processor) + { + auto name = processor->getName(); + slotId = liveEngine->getSignalChain().addProcessor( + std::move(processor), + ProcessorSlot::Type::VST, + name, + juce::String(pluginPath)); + } + else + { + fprintf(stderr, "[LoadVST] Failed: %s\n", error.toRawUTF8()); + } + deferred.Resolve(Napi::Number::New(env, slotId)); + return deferred.Promise(); + #else + auto* worker = new LoadVSTWorker(env, deferred, std::move(pluginPath)); + worker->Queue(); + return deferred.Promise(); + #endif +} + +class LoadNAMWorker : public Napi::AsyncWorker +{ +public: + LoadNAMWorker(Napi::Env env, Napi::Promise::Deferred deferred, std::string path) + : Napi::AsyncWorker(env), deferred_(deferred), modelPath_(std::move(path)) {} + + void Execute() override + { + // Serialize the FULL mutation (TLC deep-read 1): overlapping chain + // workers on the libuv pool must not interleave clear()/addProcessor(). + std::lock_guard chainLock(slopsmith::addon::chainMutationMutex()); + auto liveEngine = snapshotEngine(); + if (!liveEngine) { slotId_ = -1; return; } + + auto processor = std::make_unique(); + if (processor->loadModel(juce::File(juce::String(modelPath_)))) + { + auto name = processor->getModelName(); + slotId_ = liveEngine->getSignalChain().addProcessor( + std::move(processor), + ProcessorSlot::Type::NAM, + "NAM: " + name, + juce::String(modelPath_)); + } + } + + void OnOK() override { deferred_.Resolve(Napi::Number::New(Env(), slotId_)); } + void OnError(const Napi::Error& e) override { deferred_.Reject(e.Value()); } + +private: + Napi::Promise::Deferred deferred_; + std::string modelPath_; + int slotId_ = -1; +}; + +Napi::Value LoadNAMModel(const Napi::CallbackInfo& info) +{ + auto env = info.Env(); + auto deferred = Napi::Promise::Deferred::New(env); + + if (!snapshotEngine() || info.Length() < 1) { + deferred.Resolve(Napi::Number::New(env, -1)); + return deferred.Promise(); + } + + auto modelPath = info[0].As().Utf8Value(); + auto worker = new LoadNAMWorker(env, deferred, modelPath); + worker->Queue(); + return deferred.Promise(); +} + +class LoadIRWorker : public Napi::AsyncWorker +{ +public: + LoadIRWorker(Napi::Env env, Napi::Promise::Deferred deferred, std::string path) + : Napi::AsyncWorker(env), deferred_(deferred), irPath_(std::move(path)) {} + + void Execute() override + { + // Serialize the FULL mutation (TLC deep-read 1): overlapping chain + // workers on the libuv pool must not interleave clear()/addProcessor(). + std::lock_guard chainLock(slopsmith::addon::chainMutationMutex()); + auto liveEngine = snapshotEngine(); + if (!liveEngine) { slotId_ = -1; return; } + + const auto sr = loadSafeSampleRate(*liveEngine); + const auto bs = loadSafeBlockSize(*liveEngine); + auto processor = std::make_unique(); + processor->setPlayConfigDetails(2, 2, sr, bs); + processor->prepareToPlay(sr, bs); + if (processor->loadIR(juce::File(juce::String(irPath_)))) + { + auto name = processor->getIRName(); + slotId_ = liveEngine->getSignalChain().addProcessor( + std::move(processor), + ProcessorSlot::Type::IR, + "IR: " + name, + juce::String(irPath_)); + } + } + + void OnOK() override { deferred_.Resolve(Napi::Number::New(Env(), slotId_)); } + void OnError(const Napi::Error& e) override { deferred_.Reject(e.Value()); } + +private: + Napi::Promise::Deferred deferred_; + std::string irPath_; + int slotId_ = -1; +}; + +Napi::Value LoadIR(const Napi::CallbackInfo& info) +{ + auto env = info.Env(); + auto deferred = Napi::Promise::Deferred::New(env); + + if (!snapshotEngine() || info.Length() < 1) { + deferred.Resolve(Napi::Number::New(env, -1)); + return deferred.Promise(); + } + + auto irPath = info[0].As().Utf8Value(); + auto worker = new LoadIRWorker(env, deferred, irPath); + worker->Queue(); + return deferred.Promise(); +} + +// Replace the IR of an EXISTING convolution slot in place (cab swap / mic move), +// so the rest of the chain — the amp VST above all — is NOT torn down and rebuilt. +// Mirrors LoadIRWorker but calls SignalChain::replaceProcessor(slotId, …) instead +// of addProcessor. Optional `gain` (>=0) updates the slot's post-gain (the cab +// makeup); a negative gain leaves the existing post-gain untouched. +class ReplaceIRWorker : public Napi::AsyncWorker +{ +public: + ReplaceIRWorker(Napi::Env env, Napi::Promise::Deferred deferred, + int slotId, std::string path, float gain) + : Napi::AsyncWorker(env), deferred_(deferred), + slotId_(slotId), irPath_(std::move(path)), gain_(gain) {} + + void Execute() override + { + // Serialize the FULL mutation (TLC deep-read 1): overlapping chain + // workers on the libuv pool must not interleave clear()/addProcessor(). + std::lock_guard chainLock(slopsmith::addon::chainMutationMutex()); + auto liveEngine = snapshotEngine(); + if (!liveEngine) { ok_ = false; return; } + + const auto sr = loadSafeSampleRate(*liveEngine); + const auto bs = loadSafeBlockSize(*liveEngine); + auto processor = std::make_unique(); + processor->setPlayConfigDetails(2, 2, sr, bs); + processor->prepareToPlay(sr, bs); + if (! processor->loadIR(juce::File(juce::String(irPath_)))) { ok_ = false; return; } + + auto name = processor->getIRName(); + ok_ = liveEngine->getSignalChain().replaceProcessor( + slotId_, std::move(processor), + "IR: " + name, juce::String(irPath_)); + if (ok_ && gain_ >= 0.0f) + liveEngine->getSignalChain().setPostGain(slotId_, gain_); + } + + void OnOK() override { deferred_.Resolve(Napi::Boolean::New(Env(), ok_)); } + void OnError(const Napi::Error& e) override { deferred_.Reject(e.Value()); } + +private: + Napi::Promise::Deferred deferred_; + int slotId_; + std::string irPath_; + float gain_; + bool ok_ = false; +}; + +Napi::Value ReplaceIR(const Napi::CallbackInfo& info) +{ + auto env = info.Env(); + auto deferred = Napi::Promise::Deferred::New(env); + + if (!snapshotEngine() || info.Length() < 2 + || !info[0].IsNumber() || !info[1].IsString()) { + deferred.Resolve(Napi::Boolean::New(env, false)); + return deferred.Promise(); + } + + const int slotId = info[0].As().Int32Value(); + const auto irPath = info[1].As().Utf8Value(); + const float gain = (info.Length() >= 3 && info[2].IsNumber()) + ? info[2].As().FloatValue() : -1.0f; + + auto worker = new ReplaceIRWorker(env, deferred, slotId, irPath, gain); + worker->Queue(); + return deferred.Promise(); +} + + +// ── LoadPresetWorker + handler (moved verbatim from NodeAddon.cpp) ──────────────────── + +class LoadPresetWorker : public Napi::AsyncWorker +{ +public: + LoadPresetWorker(Napi::Env env, Napi::Promise::Deferred deferred, std::string json) + : Napi::AsyncWorker(env), deferred_(deferred), presetJson_(std::move(json)) {} + + void Execute() override + { + // Serialize the FULL mutation (TLC deep-read 1): overlapping chain + // workers on the libuv pool must not interleave clear()/addProcessor(). + std::lock_guard chainLock(slopsmith::addon::chainMutationMutex()); + auto liveEngine = snapshotEngine(); + if (!liveEngine) { success_ = false; error_ = "No engine"; return; } + + auto parsed = juce::JSON::parse(juce::String(presetJson_)); + if (!parsed.isObject()) { success_ = false; error_ = "Invalid JSON"; return; } + + auto* root = parsed.getDynamicObject(); + if (!root) { success_ = false; error_ = "Invalid preset"; return; } + + auto chainVar = root->getProperty("chain"); + auto* chainArray = chainVar.getArray(); + if (!chainArray) { success_ = false; error_ = "No chain array"; return; } + + // NB: any open in-process editor windows were already torn down on the + // message thread by LoadPreset() before this AsyncWorker was queued (see + // there) — so clearing the chain here can't leave an editor pointing at + // a freed processor (use-after-free; #56). We deliberately do NOT tear + // editors down from this worker thread: JUCE GUI objects must only be + // destroyed on the message thread, and macOS has no pump to marshal to + // from here. + // Clear existing chain + liveEngine->getSignalChain().clear(); + + double sr = loadSafeSampleRate(*liveEngine); + int bs = loadSafeBlockSize(*liveEngine); + + for (auto& slotVar : *chainArray) + { + auto* slotObj = slotVar.getDynamicObject(); + if (!slotObj) continue; + + int type = (int)slotObj->getProperty("type"); + auto name = slotObj->getProperty("name").toString(); + auto path = slotObj->getProperty("path").toString(); + bool bypassed = (bool)slotObj->getProperty("bypassed"); + auto stateB64 = slotObj->getProperty("state").toString(); + + std::unique_ptr processor; + + if (type == (int)ProcessorSlot::Type::VST && snapshotVstHost()) + { + // Sandbox-aware load: a crash-blocklisted plugin restored + // from a preset must still go out-of-process, otherwise the + // "one crash, then always sandbox" contract is defeated. + juce::String err; + bool sandboxRequired = false; + processor = loadVstSandboxAware(path, sr, bs, err, sandboxRequired); + if (!processor) + { + fprintf(stderr, "[LoadPreset] VST load failed: %s (%s)\n", + name.toRawUTF8(), err.toRawUTF8()); + continue; + } + } + else if (type == (int)ProcessorSlot::Type::NAM) + { + auto nam = std::make_unique(); + if (!nam->loadModel(juce::File(path))) + { + fprintf(stderr, "[LoadPreset] NAM load failed: %s\n", path.toRawUTF8()); + continue; + } + processor = std::move(nam); + } + else if (type == (int)ProcessorSlot::Type::IR) + { + auto ir = std::make_unique(); + ir->setPlayConfigDetails(2, 2, sr, bs); + ir->prepareToPlay(sr, bs); + if (!ir->loadIR(juce::File(path))) + { + fprintf(stderr, "[LoadPreset] IR load failed: %s\n", path.toRawUTF8()); + continue; + } + processor = std::move(ir); + } + else continue; + + int slotId = liveEngine->getSignalChain().addProcessor( + std::move(processor), + (ProcessorSlot::Type)type, + name, path); + + if (bypassed && slotId >= 0) + liveEngine->getSignalChain().setBypass(slotId, true); + + // Stereo routing (St-1). Absent keys read back as 0 (= default), so + // mono presets restore exactly as before. + if (slotId >= 0) + { + if (slotObj->hasProperty("pan")) + liveEngine->getSignalChain().setPan(slotId, (float)(double)slotObj->getProperty("pan")); + if (slotObj->hasProperty("branch")) + liveEngine->getSignalChain().setBranch(slotId, (int)slotObj->getProperty("branch")); + if (slotObj->hasProperty("postGain")) + liveEngine->getSignalChain().setPostGain(slotId, (float)(double)slotObj->getProperty("postGain")); + if (slotObj->hasProperty("branchSrc")) + liveEngine->getSignalChain().setBranchSrc(slotId, (int)slotObj->getProperty("branchSrc")); + } + + // Restore processor state (JUCE-format base64; IR/NAM slots also + // accept standard base64 — see decodeStateBlob: their plugin- + // emitted JSON states were silently dropped before, so IR stages + // never got their per-stage gain). + if (stateB64.isNotEmpty() && slotId >= 0) + { + const bool allowStandard = type == (int)ProcessorSlot::Type::IR + || type == (int)ProcessorSlot::Type::NAM; + juce::MemoryBlock state; + if (decodeStateBlob(stateB64, state, allowStandard)) + { + // Through the class's own synchronized API (deep-read 9) -- + // no more const_cast around setSlotState's locking. + liveEngine->getSignalChain().setSlotState(slotId, state); + } + } + + slotsLoaded_++; + } + + success_ = true; + generation_ = slopsmith::addon::bumpChainGeneration(); // still under chainLock + } + + void OnOK() override + { + auto obj = Napi::Object::New(Env()); + obj.Set("success", success_); + obj.Set("slotsLoaded", slotsLoaded_); + obj.Set("chainGeneration", (double) generation_); + if (!success_) obj.Set("error", error_); + deferred_.Resolve(obj); + } + void OnError(const Napi::Error& e) override { deferred_.Reject(e.Value()); } + +private: + Napi::Promise::Deferred deferred_; + std::string presetJson_; + uint64_t generation_ = 0; + bool success_ = false; + std::string error_; + int slotsLoaded_ = 0; +}; + +Napi::Value LoadPreset(const Napi::CallbackInfo& info) +{ + auto env = info.Env(); + auto deferred = Napi::Promise::Deferred::New(env); + auto liveEngine = snapshotEngine(); + + if (!liveEngine || info.Length() < 1) { + auto obj = Napi::Object::New(env); + obj.Set("success", false); + obj.Set("error", "No engine or missing argument"); + deferred.Resolve(obj); + return deferred.Promise(); + } + + // Tear down any open in-process editor windows NOW, on the N-API/main + // thread, before the AsyncWorker frees the chain's processors on a libuv + // worker (#56). Doing it here — not inside LoadPresetWorker::Execute — keeps + // JUCE GUI teardown off the worker thread: on macOS this thread IS the + // message thread (inline teardown); on Linux/Windows closeAllPluginEditor- + // Windows() posts to the dedicated JUCE message thread and blocks. Either + // way editors are destroyed before Execute() clears the chain. + closeAllPluginEditorWindows(); + + auto json = info[0].As().Utf8Value(); + auto worker = new LoadPresetWorker(env, deferred, json); + worker->Queue(); + return deferred.Promise(); +} + + } // namespace slopsmith::addon diff --git a/src/audio/addon/ChainOps.h b/src/audio/addon/ChainOps.h index 207636b..757f7e0 100644 --- a/src/audio/addon/ChainOps.h +++ b/src/audio/addon/ChainOps.h @@ -21,9 +21,17 @@ // The full worker bodies migrate into this unit with the phase-7 binding // split; the serializer lands first so the storm gate flips. +#include + +#include + #include +#include #include +class AudioEngine; +namespace juce { class AudioProcessor; } + namespace slopsmith::addon { // Held for the FULL clear+rebuild (or single-slot mutation). Control/worker @@ -41,4 +49,24 @@ uint64_t currentChainGeneration(); // const uint64_t gen = bumpChainGeneration(); // still under the lock // (return gen in the result object) +// ── Shared load helpers (used by the workers here and SetSlotState) ────── +// Decode a state blob in EITHER base64 flavour (JUCE-proprietary first, +// standard RFC-4648 fallback when `allowStandard` — IR/NAM slots only). +bool decodeStateBlob(const juce::String& s, juce::MemoryBlock& mb, bool allowStandard); +double loadSafeSampleRate(const AudioEngine& eng); +int loadSafeBlockSize(const AudioEngine& eng); +// Load a VST3 through the out-of-process sandbox when shouldSandbox() says +// so, else in-process via the async message-pumping path. See the .cpp for +// the threading contract. +std::unique_ptr loadVstSandboxAware( + const juce::String& pluginPath, double sr, int bs, + juce::String& error, bool& sandboxRequired); + +// ── N-API handlers (registered by NodeAddon's export table) ────────────── +Napi::Value LoadVST(const Napi::CallbackInfo& info); +Napi::Value LoadNAMModel(const Napi::CallbackInfo& info); +Napi::Value LoadIR(const Napi::CallbackInfo& info); +Napi::Value ReplaceIR(const Napi::CallbackInfo& info); +Napi::Value LoadPreset(const Napi::CallbackInfo& info); + } // namespace slopsmith::addon From 2906d2814b170bd221c67a20eade1911cc6138d5 Mon Sep 17 00:00:00 2001 From: OmikronApex Date: Tue, 14 Jul 2026 02:33:24 +0200 Subject: [PATCH 18/28] refactor(audio): split N-API bindings into grouped files (phase 7b complete) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Splits the remaining 88 handlers out of NodeAddon.cpp, grouped to match the preload API sections (plan §3.5): DeviceBindings (enumeration/selection/ audio-control/stream sink), ControlBindings (gain/metering/MIDI/debug logging), DetectionBindings (pitch/chart/verdict/source-indexed, owns the shared getValidatedSource), ChainBindings (slot/state/preset), and BackingBindings. Declarations live in addon/Bindings.h; NodeAddon.cpp keeps Init/Shutdown and the exports table — which now doubles as the API index the old 3699-line file lacked — at 459 lines. This completes the Part IV decomposition: AudioEngine.{h,cpp} 819+3223 → 509+1284 across seven engine/ units; NodeAddon.cpp 3699 → 459 across seven addon/ units. All gates green (contract-check, storm, arg-fuzz, full suite). Co-Authored-By: Claude Fable 5 --- src/audio/CMakeLists.txt | 5 + src/audio/NodeAddon.cpp | 2111 ++----------------------- src/audio/addon/BackingBindings.cpp | 91 ++ src/audio/addon/Bindings.h | 108 ++ src/audio/addon/ChainBindings.cpp | 279 ++++ src/audio/addon/ControlBindings.cpp | 336 ++++ src/audio/addon/DetectionBindings.cpp | 589 +++++++ src/audio/addon/DeviceBindings.cpp | 834 ++++++++++ 8 files changed, 2331 insertions(+), 2022 deletions(-) create mode 100644 src/audio/addon/BackingBindings.cpp create mode 100644 src/audio/addon/Bindings.h create mode 100644 src/audio/addon/ChainBindings.cpp create mode 100644 src/audio/addon/ControlBindings.cpp create mode 100644 src/audio/addon/DetectionBindings.cpp create mode 100644 src/audio/addon/DeviceBindings.cpp diff --git a/src/audio/CMakeLists.txt b/src/audio/CMakeLists.txt index 1284418..41489d9 100644 --- a/src/audio/CMakeLists.txt +++ b/src/audio/CMakeLists.txt @@ -14,6 +14,11 @@ set(AUDIO_SOURCES addon/AddonContext.cpp addon/ChainOps.cpp addon/EditorWindows.cpp + addon/DeviceBindings.cpp + addon/ControlBindings.cpp + addon/DetectionBindings.cpp + addon/ChainBindings.cpp + addon/BackingBindings.cpp SourceChain.cpp SignalChain.cpp VSTHost.cpp diff --git a/src/audio/NodeAddon.cpp b/src/audio/NodeAddon.cpp index 9532876..4fe7664 100644 --- a/src/audio/NodeAddon.cpp +++ b/src/audio/NodeAddon.cpp @@ -30,6 +30,7 @@ #include "addon/NapiHelpers.h" #include "addon/ChainOps.h" #include "addon/EditorWindows.h" +#include "addon/Bindings.h" using slopsmith::addon::closeAllPluginEditorWindows; using slopsmith::addon::destroyAllPluginEditorWindowsOnMessageThread; @@ -41,6 +42,94 @@ using slopsmith::addon::LoadNAMModel; using slopsmith::addon::LoadIR; using slopsmith::addon::ReplaceIR; using slopsmith::addon::LoadPreset; +using slopsmith::addon::AddSource; +using slopsmith::addon::BindInputDevice; +using slopsmith::addon::ClearChain; +using slopsmith::addon::ClearStreamOutput; +using slopsmith::addon::DetectNotes; +using slopsmith::addon::EnableFileLogging; +using slopsmith::addon::GetBackingDuration; +using slopsmith::addon::GetBackingLevel; +using slopsmith::addon::GetBackingPosition; +using slopsmith::addon::GetBufferSizes; +using slopsmith::addon::GetChainGeneration; +using slopsmith::addon::GetChainState; +using slopsmith::addon::GetCurrentDevice; +using slopsmith::addon::GetDeviceMetrics; +using slopsmith::addon::GetDeviceTypes; +using slopsmith::addon::GetLevels; +using slopsmith::addon::GetNoteVerdicts; +using slopsmith::addon::GetParameters; +using slopsmith::addon::GetPitchDetection; +using slopsmith::addon::GetRawAudioFrame; +using slopsmith::addon::GetRawPitchDetection; +using slopsmith::addon::GetRendererBusMetrics; +using slopsmith::addon::GetSampleRate; +using slopsmith::addon::GetSampleRates; +using slopsmith::addon::GetSourceLevels; +using slopsmith::addon::GetSourceNoteVerdicts; +using slopsmith::addon::GetSourcePitchDetection; +using slopsmith::addon::GetSourceRawAudioFrame; +using slopsmith::addon::GetSourceRawPitchDetection; +using slopsmith::addon::GetStreamOverflowCount; +using slopsmith::addon::GetStreamSinkLevel; +using slopsmith::addon::GetStreamUnderflowCount; +using slopsmith::addon::IsAudioRunning; +using slopsmith::addon::IsBackingPlaying; +using slopsmith::addon::IsMlNoteDetection; +using slopsmith::addon::IsMonitorMuted; +using slopsmith::addon::IsStreamOutputActive; +using slopsmith::addon::ListInputDevices; +using slopsmith::addon::ListSources; +using slopsmith::addon::LoadBackingTrack; +using slopsmith::addon::LoadNoteModel; +using slopsmith::addon::MoveProcessor; +using slopsmith::addon::ProbeDeviceOptions; +using slopsmith::addon::PushRendererAudio; +using slopsmith::addon::RemoveProcessor; +using slopsmith::addon::RemoveSource; +using slopsmith::addon::ResetPeaks; +using slopsmith::addon::SavePreset; +using slopsmith::addon::ScoreChord; +using slopsmith::addon::ScoreSourceChord; +using slopsmith::addon::SeekBacking; +using slopsmith::addon::SendMidiToSlot; +using slopsmith::addon::SetBackingSpeed; +using slopsmith::addon::SetBranch; +using slopsmith::addon::SetBranchSrc; +using slopsmith::addon::SetBypass; +using slopsmith::addon::SetChart; +using slopsmith::addon::SetDevice; +using slopsmith::addon::SetDeviceType; +using slopsmith::addon::SetGain; +using slopsmith::addon::SetInputChannel; +using slopsmith::addon::SetMonitorKill; +using slopsmith::addon::SetMonitorMute; +using slopsmith::addon::SetMonitorMuteSuppressed; +using slopsmith::addon::SetMultiBypass; +using slopsmith::addon::SetNoiseGate; +using slopsmith::addon::SetNoteDetectionEnabled; +using slopsmith::addon::SetOutputDeviceType; +using slopsmith::addon::SetPan; +using slopsmith::addon::SetParameter; +using slopsmith::addon::SetPostGain; +using slopsmith::addon::SetRendererBus; +using slopsmith::addon::SetSlotState; +using slopsmith::addon::SetSourceChart; +using slopsmith::addon::SetSourceInputChannel; +using slopsmith::addon::SetSourceMonitorMute; +using slopsmith::addon::SetSourceVerifierOffset; +using slopsmith::addon::SetStreamBus; +using slopsmith::addon::SetStreamBusGain; +using slopsmith::addon::SetStreamOutputDevice; +using slopsmith::addon::SetTonePolish; +using slopsmith::addon::StartAudio; +using slopsmith::addon::StartBacking; +using slopsmith::addon::StopAudio; +using slopsmith::addon::StopBacking; +using slopsmith::addon::UnbindInputDevice; +using slopsmith::addon::scoreChordCore; +using slopsmith::addon::setChartCore; // Lifetime/threading moved to addon/AddonContext (TLC phase 6); the usings // keep the 100+ existing binding bodies unchanged. @@ -53,21 +142,6 @@ using slopsmith::addon::cancelAllPendingLoads; using slopsmith::addon::doShutdown; -// Validate a JS source-id argument and return the live source, or nullptr if it is -// missing / not a Number / not a FINITE INTEGER / out of range. The TS bridge already -// validates, but the addon must fail soft on its own: Int32Value() silently coerces -// NaN/Infinity into a valid index (NaN -> 0), which would let a malformed id hit a -// real source (e.g. the default source 0). getSource() does the final -// [0, kMaxSources) + active check; the 4096 guard keeps the cast well-defined. -static SourceChain* getValidatedSource(AudioEngine* eng, const Napi::CallbackInfo& info, size_t argIndex) -{ - if (eng == nullptr || argIndex >= info.Length() || ! info[argIndex].IsNumber()) - return nullptr; - const double raw = info[argIndex].As().DoubleValue(); - if (! std::isfinite(raw) || raw != std::floor(raw) || raw < 0.0 || raw > 4096.0) - return nullptr; - return eng->getSource((int) raw); -} @@ -90,1573 +164,6 @@ static Napi::Value Shutdown(const Napi::CallbackInfo& info) return info.Env().Undefined(); } -// ── Device Enumeration ──────────────────────────────────────────────────────── - -static Napi::Value GetDeviceTypes(const Napi::CallbackInfo& info) -{ - auto env = info.Env(); - auto liveEngine = snapshotEngine(); - if (!liveEngine) return env.Null(); - - // Device types are already scanned during init — safe to read from any thread - auto types = liveEngine->getDeviceTypes(); - - auto result = Napi::Array::New(env, types.size()); - - for (int i = 0; i < types.size(); ++i) - { - auto obj = Napi::Object::New(env); - obj.Set("name", types[i].name.toStdString()); - - auto inputs = Napi::Array::New(env, types[i].inputDevices.size()); - for (int j = 0; j < types[i].inputDevices.size(); ++j) - inputs.Set((uint32_t)j, types[i].inputDevices[j].toStdString()); - obj.Set("inputs", inputs); - - auto outputs = Napi::Array::New(env, types[i].outputDevices.size()); - for (int j = 0; j < types[i].outputDevices.size(); ++j) - outputs.Set((uint32_t)j, types[i].outputDevices[j].toStdString()); - obj.Set("outputs", outputs); - - result.Set((uint32_t)i, obj); - } - - return result; -} - -static Napi::Value GetSampleRates(const Napi::CallbackInfo& info) -{ - auto env = info.Env(); - auto liveEngine = snapshotEngine(); - if (!liveEngine) return Napi::Array::New(env); - - auto rates = liveEngine->getSampleRates(); - auto result = Napi::Array::New(env, rates.size()); - for (int i = 0; i < rates.size(); ++i) - result.Set((uint32_t)i, rates[i]); - return result; -} - -static Napi::Value GetBufferSizes(const Napi::CallbackInfo& info) -{ - auto env = info.Env(); - auto liveEngine = snapshotEngine(); - if (!liveEngine) return Napi::Array::New(env); - - auto sizes = liveEngine->getBufferSizes(); - auto result = Napi::Array::New(env, sizes.size()); - for (int i = 0; i < sizes.size(); ++i) - result.Set((uint32_t)i, sizes[i]); - return result; -} - -static Napi::Value ProbeDeviceOptions(const Napi::CallbackInfo& info) -{ - auto env = info.Env(); - auto liveEngine = snapshotEngine(); - auto obj = Napi::Object::New(env); - // 3-arg legacy (type, input, output) or 4-arg dual (inputType, input, outputType, output). - auto arg0 = info.Length() > 0 && info[0].IsString() ? info[0].As().Utf8Value() : ""; - auto arg1 = info.Length() > 1 && info[1].IsString() ? info[1].As().Utf8Value() : ""; - auto arg2 = info.Length() > 2 && info[2].IsString() ? info[2].As().Utf8Value() : ""; - auto arg3 = info.Length() > 3 && info[3].IsString() ? info[3].As().Utf8Value() : ""; - - std::string inputType = arg0; - std::string inputName = arg1; - std::string outputType; - std::string outputName; - if (info.Length() >= 4) - { - outputType = arg2; - outputName = arg3; - } - else - { - outputType = arg0; - outputName = arg2; - } - - auto ratesArray = Napi::Array::New(env); - auto buffersArray = Napi::Array::New(env); - auto inputChannelsArray = Napi::Array::New(env); - auto outputChannelsArray = Napi::Array::New(env); - - obj.Set("type", inputType); - obj.Set("inputType", inputType); - obj.Set("outputType", outputType); - obj.Set("input", inputName); - obj.Set("output", outputName); - obj.Set("inputChannels", inputChannelsArray); - obj.Set("outputChannels", outputChannelsArray); - obj.Set("sampleRates", ratesArray); - obj.Set("bufferSizes", buffersArray); - obj.Set("compatible", true); - if (!liveEngine) - { - obj.Set("error", "Audio engine not initialized"); - obj.Set("compatible", false); - return obj; - } - - auto options = liveEngine->probeDeviceOptionsDual( - juce::String(inputType), juce::String(inputName), - juce::String(outputType), juce::String(outputName)); - obj.Set("type", options.inputType.toStdString()); // legacy alias - obj.Set("inputType", options.inputType.toStdString()); - obj.Set("outputType", options.outputType.toStdString()); - obj.Set("input", options.input.toStdString()); - obj.Set("output", options.output.toStdString()); - obj.Set("error", options.error.toStdString()); - obj.Set("compatible", options.compatible); - - inputChannelsArray = Napi::Array::New(env, options.inputChannels.size()); - for (int i = 0; i < options.inputChannels.size(); ++i) - inputChannelsArray.Set((uint32_t)i, options.inputChannels[i].toStdString()); - obj.Set("inputChannels", inputChannelsArray); - - outputChannelsArray = Napi::Array::New(env, options.outputChannels.size()); - for (int i = 0; i < options.outputChannels.size(); ++i) - outputChannelsArray.Set((uint32_t)i, options.outputChannels[i].toStdString()); - obj.Set("outputChannels", outputChannelsArray); - - ratesArray = Napi::Array::New(env, options.sampleRates.size()); - for (int i = 0; i < options.sampleRates.size(); ++i) - ratesArray.Set((uint32_t)i, options.sampleRates[i]); - obj.Set("sampleRates", ratesArray); - - buffersArray = Napi::Array::New(env, options.bufferSizes.size()); - for (int i = 0; i < options.bufferSizes.size(); ++i) - buffersArray.Set((uint32_t)i, options.bufferSizes[i]); - obj.Set("bufferSizes", buffersArray); - - return obj; -} - -static Napi::Value GetCurrentDevice(const Napi::CallbackInfo& info) -{ - auto env = info.Env(); - auto liveEngine = snapshotEngine(); - if (!liveEngine) return env.Null(); - - auto obj = Napi::Object::New(env); - const auto inputType = liveEngine->getCurrentInputDeviceType().toStdString(); - const auto outputType = liveEngine->getCurrentOutputDeviceType().toStdString(); - obj.Set("type", inputType); - obj.Set("inputType", inputType); - obj.Set("outputType", outputType); - obj.Set("input", liveEngine->getCurrentInputDevice().toStdString()); - obj.Set("output", liveEngine->getCurrentOutputDevice().toStdString()); - obj.Set("sampleRate", liveEngine->getCurrentSampleRate()); - obj.Set("blockSize", liveEngine->getCurrentBlockSize()); - obj.Set("inputBlockSize", liveEngine->getCurrentInputBlockSize()); - obj.Set("outputBlockSize", liveEngine->getCurrentOutputBlockSize()); - obj.Set("latencyMs", liveEngine->getLatencyMs()); - obj.Set("duplex", liveEngine->isDuplex()); - return obj; -} - -static Napi::Value GetDeviceMetrics(const Napi::CallbackInfo& info) -{ - auto env = info.Env(); - auto liveEngine = snapshotEngine(); - auto obj = Napi::Object::New(env); - if (!liveEngine) - { - obj.Set("duplex", true); - obj.Set("inputOverflowCount", 0.0); - obj.Set("outputUnderflowCount", 0.0); - obj.Set("outputRingFillFrames", 0); - obj.Set("outputRingCapacityFrames", 0); - return obj; - } - const auto m = liveEngine->getDeviceMetrics(); - obj.Set("duplex", m.duplex); - obj.Set("inputOverflowCount", static_cast(m.inputOverflowCount)); - obj.Set("outputUnderflowCount", static_cast(m.outputUnderflowCount)); - obj.Set("outputRingFillFrames", m.outputRingFillFrames); - obj.Set("outputRingCapacityFrames", m.outputRingCapacityFrames); - return obj; -} - -// ── Device Selection ────────────────────────────────────────────────────────── - -static Napi::Value SetDeviceType(const Napi::CallbackInfo& info) -{ - auto env = info.Env(); - auto liveEngine = snapshotEngine(); - if (!liveEngine || info.Length() < 1 || !info[0].IsString()) - return Napi::Boolean::New(env, false); - - auto typeName = info[0].As().Utf8Value(); - bool result = liveEngine->setDeviceType(juce::String(typeName)); - return Napi::Boolean::New(env, result); -} - -static Napi::Value SetOutputDeviceType(const Napi::CallbackInfo& info) -{ - auto env = info.Env(); - auto liveEngine = snapshotEngine(); - if (!liveEngine || info.Length() < 1 || !info[0].IsString()) - return Napi::Boolean::New(env, false); - auto typeName = info[0].As().Utf8Value(); - return Napi::Boolean::New(env, liveEngine->setOutputDeviceType(juce::String(typeName))); -} - -static Napi::Value SetDevice(const Napi::CallbackInfo& info) -{ - auto env = info.Env(); - auto liveEngine = snapshotEngine(); - auto result = Napi::Object::New(env); - result.Set("ok", false); - result.Set("duplex", true); - result.Set("sampleRate", 0.0); - result.Set("inputBlockSize", 0); - result.Set("outputBlockSize", 0); - result.Set("error", ""); - if (!liveEngine) - { - result.Set("error", "Audio engine not initialized"); - return result; - } - - // Object payload: setDevice({inputType, inputDevice, outputType, outputDevice, sampleRate, bufferSize}) - // Legacy positional: setDevice(input, output, sampleRate, bufferSize) - AudioEngine::DeviceConfig cfg; - if (info.Length() > 0 && info[0].IsObject() && !info[0].IsNull() && !info[0].IsArray()) - { - auto obj = info[0].As(); - auto readStr = [&](const char* key) -> std::string { - if (obj.Has(key) && obj.Get(key).IsString()) return obj.Get(key).As().Utf8Value(); - return {}; - }; - // Reject NaN/Infinity at the JS→C boundary so they can't poison - // downstream comparisons (NaN <= 0 is false, so the validation - // fallback in setAudioDevices() wouldn't catch them). Casting a - // non-finite double to int is also UB in C++. - auto readNum = [&](const char* key, double def) -> double { - if (obj.Has(key) && obj.Get(key).IsNumber()) - { - const double v = obj.Get(key).As().DoubleValue(); - if (std::isfinite(v)) return v; - } - return def; - }; - cfg.inputType = juce::String(readStr("inputType")); - cfg.inputDevice = juce::String(readStr("inputDevice")); - if (cfg.inputDevice.isEmpty()) cfg.inputDevice = juce::String(readStr("input")); - cfg.outputType = juce::String(readStr("outputType")); - cfg.outputDevice = juce::String(readStr("outputDevice")); - if (cfg.outputDevice.isEmpty()) cfg.outputDevice = juce::String(readStr("output")); - cfg.sampleRate = readNum("sampleRate", 48000.0); - // Clamp before the double→int cast: finite-but-out-of-range values - // (e.g. a JS-side bug passing 1e18) are UB to convert to int. readNum - // already filtered non-finite; we just need a range check here. - { - const double bsd = readNum("bufferSize", 256.0); - if (bsd >= 1.0 && bsd <= (double) (std::numeric_limits::max) ()) - cfg.bufferSize = (int) bsd; - else - cfg.bufferSize = 256; - } - } - else - { - auto input = info.Length() > 0 && info[0].IsString() ? info[0].As().Utf8Value() : ""; - auto output = info.Length() > 1 && info[1].IsString() ? info[1].As().Utf8Value() : ""; - double sr = info.Length() > 2 && info[2].IsNumber() ? info[2].As().DoubleValue() : 48000.0; - int bs = info.Length() > 3 && info[3].IsNumber() ? info[3].As().Int32Value() : 256; - cfg.inputDevice = juce::String(input); - cfg.outputDevice = juce::String(output); - cfg.sampleRate = sr; - cfg.bufferSize = bs; - } - - // Main thread only — JUCE's ALSA backend deadlocks if called from a worker. - const auto r = liveEngine->setAudioDevices(cfg); - result.Set("ok", r.ok); - result.Set("duplex", r.duplex); - result.Set("sampleRate", r.sampleRate); - result.Set("inputBlockSize", r.inputBlockSize); - result.Set("outputBlockSize", r.outputBlockSize); - result.Set("error", r.error.toStdString()); - return result; -} - -// ── Audio Control ───────────────────────────────────────────────────────────── - -static Napi::Value StartAudio(const Napi::CallbackInfo& info) -{ - if (auto liveEngine = snapshotEngine()) liveEngine->startAudio(); - return info.Env().Undefined(); -} - -static Napi::Value StopAudio(const Napi::CallbackInfo& info) -{ - if (auto liveEngine = snapshotEngine()) liveEngine->stopAudio(); - return info.Env().Undefined(); -} - -static Napi::Value IsAudioRunning(const Napi::CallbackInfo& info) -{ - auto liveEngine = snapshotEngine(); - return Napi::Boolean::New(info.Env(), liveEngine ? liveEngine->isAudioRunning() : false); -} - -// ── Gain ────────────────────────────────────────────────────────────────────── - -static Napi::Value SetGain(const Napi::CallbackInfo& info) -{ - auto env = info.Env(); - auto liveEngine = snapshotEngine(); - if (!liveEngine || info.Length() < 2) return env.Undefined(); - - if (!info[0].IsString()) return env.Undefined(); - auto which = info[0].As().Utf8Value(); - const auto valueOpt = slopsmith::addon::argFiniteFloat(info, 1); - if (!valueOpt) return env.Undefined(); // engine clamps range; NaN/Inf rejected here - const float value = *valueOpt; - - if (which == "input") liveEngine->setInputGain(value); - else if (which == "output") liveEngine->setOutputGain(value); - else if (which == "chain") liveEngine->setChainOutputGain(value); - else if (which == "backing") liveEngine->setBackingVolume(value); - - return env.Undefined(); -} - -static Napi::Value SetInputChannel(const Napi::CallbackInfo& info) -{ - auto liveEngine = snapshotEngine(); - if (liveEngine && info.Length() > 0) - liveEngine->setInputChannel(info[0].As().Int32Value()); - return info.Env().Undefined(); -} - -static Napi::Value SetMonitorMute(const Napi::CallbackInfo& info) -{ - auto liveEngine = snapshotEngine(); - if (liveEngine && info.Length() > 0) - liveEngine->setMonitorMute(info[0].As().Value()); - return info.Env().Undefined(); -} - -// setNoteDetectionEnabled(bool) -> undefined. Arms/suspends the polyphonic ML -// note-detection pipeline across all sources. The renderer (note_detect) calls -// this true only while a consumer actually reads ML notes (native-frame -// detection / non-verifier fallback) and false otherwise — the default -// harmonic-comb verifier path and the always-on home tuner leave ML suspended, -// so the engine runs no ONNX inference when nothing needs it. -static Napi::Value SetNoteDetectionEnabled(const Napi::CallbackInfo& info) -{ - auto liveEngine = snapshotEngine(); - if (liveEngine && info.Length() > 0) - liveEngine->setMlNoteDetectionEnabled(info[0].As().Value()); - return info.Env().Undefined(); -} - -static Napi::Value SetMonitorMuteSuppressed(const Napi::CallbackInfo& info) -{ - // IsBoolean()-guarded so a mismatched renderer build / manual caller - // passing a non-boolean is a clean no-op rather than a hard N-API failure - // (NAPI_DISABLE_CPP_EXCEPTIONS is enabled). Mirrors SetNoiseGate's style. - auto liveEngine = snapshotEngine(); - if (liveEngine && info.Length() > 0 && info[0].IsBoolean()) - liveEngine->setMonitorMuteSuppressed(info[0].As().Value()); - return info.Env().Undefined(); -} - -static Napi::Value SetMonitorKill(const Napi::CallbackInfo& info) -{ - // IsBoolean()-guarded (fail-soft no-op on a downlevel/mismatched caller), - // mirroring SetMonitorMuteSuppressed. - auto liveEngine = snapshotEngine(); - if (liveEngine && info.Length() > 0 && info[0].IsBoolean()) - liveEngine->setMonitorKill(info[0].As().Value()); - return info.Env().Undefined(); -} - -static Napi::Value SetNoiseGate(const Napi::CallbackInfo& info) -{ - auto env = info.Env(); - auto liveEngine = snapshotEngine(); - if (!liveEngine || info.Length() < 1 || !info[0].IsObject()) - return env.Undefined(); - - auto o = info[0].As(); - - bool enabled = false; - if (o.Has("enabled")) - { - auto v = o.Get("enabled"); - if (v.IsBoolean()) - enabled = v.As().Value(); - else if (v.IsNumber()) - enabled = v.As().DoubleValue() != 0.0; - } - - float thresholdDb = -60.0f; - if (o.Has("thresholdDb") && o.Get("thresholdDb").IsNumber()) - thresholdDb = (float)o.Get("thresholdDb").As().DoubleValue(); - - float releaseMs = 100.0f; - if (o.Has("releaseMs") && o.Get("releaseMs").IsNumber()) - releaseMs = (float)o.Get("releaseMs").As().DoubleValue(); - - float depthDb = -60.0f; - if (o.Has("depthDb") && o.Get("depthDb").IsNumber()) - depthDb = (float)o.Get("depthDb").As().DoubleValue(); - - liveEngine->setNoiseGate(enabled, thresholdDb, releaseMs, depthDb); - return env.Undefined(); -} - -static Napi::Value SetTonePolish(const Napi::CallbackInfo& info) -{ - // Tone Polish — { enabled: bool }. Mirrors SetNoiseGate's defensive - // shape so a mismatched renderer build / manual caller passing a - // non-object is a clean no-op rather than a hard N-API failure - // (NAPI_DISABLE_CPP_EXCEPTIONS). - auto env = info.Env(); - auto liveEngine = snapshotEngine(); - if (!liveEngine || info.Length() < 1 || !info[0].IsObject()) - return env.Undefined(); - - auto o = info[0].As(); - - bool enabled = true; - if (o.Has("enabled")) - { - auto v = o.Get("enabled"); - if (v.IsBoolean()) - enabled = v.As().Value(); - else if (v.IsNumber()) - enabled = v.As().DoubleValue() != 0.0; - } - - liveEngine->setTonePolishEnabled(enabled); - return env.Undefined(); -} - -static Napi::Value IsMonitorMuted(const Napi::CallbackInfo& info) -{ - auto liveEngine = snapshotEngine(); - return Napi::Boolean::New(info.Env(), liveEngine ? liveEngine->isMonitorMuted() : true); -} - -// ── Metering (polled — read atomics) ────────────────────────────────────────── - -static Napi::Value GetLevels(const Napi::CallbackInfo& info) -{ - auto env = info.Env(); - auto obj = Napi::Object::New(env); - auto liveEngine = snapshotEngine(); - - if (liveEngine) - { - obj.Set("inputLevel", liveEngine->getInputLevel()); - obj.Set("outputLevel", liveEngine->getOutputLevel()); - obj.Set("inputPeak", liveEngine->getInputPeak()); - obj.Set("outputPeak", liveEngine->getOutputPeak()); - } - else - { - obj.Set("inputLevel", 0.0); - obj.Set("outputLevel", 0.0); - obj.Set("inputPeak", 0.0); - obj.Set("outputPeak", 0.0); - } - - return obj; -} - -// getSourceLevels(sourceId) -> { inputLevel, inputPeak, outputLevel, outputPeak }. -// Per-source INPUT level so a bound detector's silence gate reads ITS OWN device's -// signal (not the global/primary level — which would force-fail every hit on an -// extra device the user is actually playing). Output fields mirror the master and -// are 0 (monitoring is post-mix / engine-global). Bad id -> all zeros. -static Napi::Value GetSourceLevels(const Napi::CallbackInfo& info) -{ - auto env = info.Env(); - auto obj = Napi::Object::New(env); - auto liveEngine = snapshotEngine(); - SourceChain* s = (liveEngine && info.Length() >= 1 && info[0].IsNumber()) - ? getValidatedSource(liveEngine.get(), info, 0) : nullptr; - obj.Set("inputLevel", s ? (double) s->getInputLevel() : 0.0); - obj.Set("inputPeak", s ? (double) s->getInputPeak() : 0.0); - obj.Set("outputLevel", 0.0); - obj.Set("outputPeak", 0.0); - return obj; -} - -static Napi::Value ResetPeaks(const Napi::CallbackInfo& info) -{ - if (auto liveEngine = snapshotEngine()) liveEngine->resetPeaks(); - return info.Env().Undefined(); -} - -// Backing-track mix bus RMS level — the engine's per-block running RMS after -// the backing volume fader but before the output-gain master. Returns 0.0 when -// the engine is unavailable or no backing track is loaded. Reads an atomic so -// it is safe to call from the JS thread without blocking the audio thread. -static Napi::Value GetBackingLevel(const Napi::CallbackInfo& info) -{ - auto liveEngine = snapshotEngine(); - return Napi::Number::New(info.Env(), liveEngine ? liveEngine->getBackingLevel() : 0.0f); -} - -// ── Pitch Detection (polled) ────────────────────────────────────────────────── - -// Load the Basic Pitch ONNX model for the polyphonic ML note detector. -// Called once at startup by audio-bridge.ts with the bundled model path. -// Never throws. Returns "is ML note detection available after this call" — -// a model is loaded with a valid contract. A missing/invalid file does NOT -// tear down an already-loaded model, so it can still return true; it returns -// false when the engine isn't ready or ONNX support isn't compiled in, and -// the engine then keeps using the YIN PitchDetector / ChordScorer -// (Constitution VII). -static Napi::Value LoadNoteModel(const Napi::CallbackInfo& info) -{ - auto env = info.Env(); - auto liveEngine = snapshotEngine(); - if (!liveEngine || info.Length() < 1 || !info[0].IsString()) - return Napi::Boolean::New(env, false); - - const auto path = info[0].As().Utf8Value(); - const bool ok = liveEngine->loadNoteModel(juce::File(juce::String(path))); - return Napi::Boolean::New(env, ok); -} - -// Whether the ML note detector is active (ONNX support compiled in AND a -// model loaded). Lets the renderer / tests tell the ML path from the YIN -// fallback without inferring it from behaviour. -static Napi::Value IsMlNoteDetection(const Napi::CallbackInfo& info) -{ - auto env = info.Env(); - // Report readiness, not just model-loaded: the engine only routes - // getPitchDetection()/scoreChord() to ML once the detector has published - // its first snapshot (isReady()). Reporting true during the cold-start - // window would tell the renderer "ML active" while it's still getting the - // YIN fallback. - auto liveEngine = snapshotEngine(); - return Napi::Boolean::New(env, - liveEngine && liveEngine->hasMlNoteDetector() - && liveEngine->getMlNoteDetector().isReady()); -} - -// Raw polyphonic transcription from the ML note detector — the full set of -// currently-active pitches, not just the dominant one. Returns -// `{ notes: [{ midi, confidence, onsetMs, onsetSeq }], sampleRate }`, or null when the ML -// detector isn't active (no model / ONNX support) so the renderer can feature- -// detect and fall back. Never throws. -static Napi::Value DetectNotes(const Napi::CallbackInfo& info) -{ - auto env = info.Env(); - // Gate on isReady(): the contract is that callers get null whenever the - // ML detector isn't actively producing notes. isReady() is false with no - // model, after a device stop, and during the cold-start window before the - // first inference publishes — so the renderer feature-detects correctly - // and falls back instead of consuming an empty ML stream. - auto liveEngine = snapshotEngine(); - if (!liveEngine || !liveEngine->getMlNoteDetector().isReady()) - return env.Null(); - - const auto active = liveEngine->getMlNoteDetector().getActiveNotes(); - auto notesArr = Napi::Array::New(env, active.size()); - for (size_t i = 0; i < active.size(); ++i) - { - auto entry = Napi::Object::New(env); - entry.Set("midi", active[i].midi); - entry.Set("confidence", active[i].confidence); - // Milliseconds since this pitch's onset — lets the renderer back-date - // a detection to the true onset instead of poll time. - entry.Set("onsetMs", active[i].onsetAgeMs); - // Monotonic per-pitch onset counter — a change means a new note was - // struck, so the renderer can consume onsets as discrete events. - entry.Set("onsetSeq", active[i].onsetSeq); - notesArr.Set((uint32_t) i, entry); - } - - auto obj = Napi::Object::New(env); - obj.Set("notes", notesArr); - // Normalise the sample rate: getCurrentSampleRate() is 0 when no audio - // device is active — hand the renderer a sane positive value so its - // Hz/time math can't divide by zero. - const double sr = liveEngine->getCurrentSampleRate(); - obj.Set("sampleRate", sr > 0.0 ? sr : 48000.0); - return obj; -} - -static Napi::Value GetPitchDetection(const Napi::CallbackInfo& info) -{ - auto env = info.Env(); - auto obj = Napi::Object::New(env); - auto liveEngine = snapshotEngine(); - - if (liveEngine) - { - // getActiveDetection() returns the polyphonic ML detector's dominant - // pitch when a Basic Pitch model is loaded, else the YIN detector's - // latest result — same shape either way, so the plugin is unchanged. - auto det = liveEngine->getActiveDetection(); - obj.Set("frequency", det.frequency); - obj.Set("confidence", det.confidence); - obj.Set("midiNote", det.midiNote); - obj.Set("cents", det.cents); - obj.Set("noteName", det.noteName.toStdString()); - } - else - { - obj.Set("frequency", -1.0); - obj.Set("confidence", 0.0); - obj.Set("midiNote", -1); - obj.Set("cents", 0.0); - obj.Set("noteName", ""); - } - - return obj; -} - -static Napi::Value GetRawPitchDetection(const Napi::CallbackInfo& info) -{ - auto env = info.Env(); - auto obj = Napi::Object::New(env); - auto liveEngine = snapshotEngine(); - - if (liveEngine) - { - // Always the raw YIN detection — bypasses the ML preference so frequency - // stays continuous (sub-Hz) and cents stays real even with a model loaded. - // Backs the tuner's audio:getRawPitch endpoint. - auto det = liveEngine->getRawPitchDetection(); - obj.Set("frequency", det.frequency); - obj.Set("confidence", det.confidence); - obj.Set("midiNote", det.midiNote); - obj.Set("cents", det.cents); - obj.Set("noteName", det.noteName.toStdString()); - } - else - { - obj.Set("frequency", -1.0); - obj.Set("confidence", 0.0); - obj.Set("midiNote", -1); - obj.Set("cents", 0.0); - obj.Set("noteName", ""); - } - - return obj; -} - -static Napi::Value GetRawAudioFrame(const Napi::CallbackInfo& info) -{ - auto env = info.Env(); - auto liveEngine = snapshotEngine(); - - // Optional sample count; defaults to AudioEngine::getRawAudioFrame's 4096. - // The engine clamps anything above its ring capacity. - int numSamples = 4096; - if (info.Length() > 0 && info[0].IsNumber()) - numSamples = info[0].As().Int32Value(); - - if (!liveEngine || numSamples <= 0) - return Napi::Float32Array::New(env, 0); - - // Post-gate mono snapshot for the tuner's own pitch pipeline. Returns a - // Float32Array of the most-recent N samples (left-zero-padded on cold start). - auto frame = liveEngine->getRawAudioFrame(numSamples); - auto out = Napi::Float32Array::New(env, frame.size()); - float* dst = out.Data(); - for (size_t i = 0; i < frame.size(); ++i) - dst[i] = frame[i]; - return out; -} - -// Score a polyphonic chord against the engine's most recent input -// samples. Renderer (notedetect plugin's matchNotes chord branch) -// supplies the chord context — chart notes plus tuning/arrangement -// metadata — and gets back a `{score, hitStrings, totalStrings, isHit, -// results[]}` object identical in shape to what the JS implementation -// produced. Audio never crosses the N-API boundary, which is the -// whole reason for moving the math here: constitution II says audio -// analysis lives in JUCE, and this is the missing piece. -// -// Request shape. Fields marked `required` must be present and -// internally consistent — the C++ scorer fails closed (all-miss -// result with one entry per requested note) when the validation -// invariants don't hold, rather than silently substituting defaults. -// { -// notes: [{ s, f, ho?, po?, b?, sl?, hm? }, ...], -// // required, each `s` must be in [0, stringCount) -// arrangement?: 'guitar'|'bass', // default 'guitar' — must be one of these two strings -// stringCount?: number, // default 6 — must match the (arrangement, stringCount) -// // table: bass{4,5} or guitar{6,7,8} -// offsets: number[], // required, length must equal stringCount. -// // Pass an array of zeros for standard tuning; -// // the default of `stringCount = 6` only works -// // if you supply 6 offsets. -// numSamples?: number, // analysis window (default 4096, capped at the -// // engine input-ring capacity, currently 8192) -// capo?: number, // default 0 -// pitchCheckCents?: number, // 0 = energy-only chord check (default 0) -// minHitRatio?: number, // default 0.6 -// bypassMl?: boolean, // force the DSP band-energy scorer even -// // when an ML model is loaded (default false) -// harmonicVerify?: boolean, // score each note by harmonic-comb energy -// // (f,2f..5f vs the floor between) instead -// // of band-energy/total (default false) -// harmonicSnr?: number, // min harmonic-to-floor ratio for a hit -// // when harmonicVerify is set (default 3.0) -// fundamentalRatio?: number, // fundamental-presence gate: reject when -// // f0 peak < ratio*strongest partial; lower -// // for bass, <=0 disables (default 0.20) -// } -// Shared core: parse `reqObj` into a ChordScorer::Request and score it against -// `target`'s input ring. `target` is sources[0] for the legacy scoreChord and -// getSource(id) for the source-indexed scoreSourceChord. -static Napi::Value scoreChordCore(Napi::Env env, Napi::Object reqObj, SourceChain* target) -{ - // Hard caps on caller-controlled array lengths. The scorer's - // (arrangement, stringCount) validation only accepts up to 8 - // strings; chord-notes have a natural ceiling at the same value - // (one per string). 32 is a generous headroom that still bounds - // worst-case allocations the renderer could trigger over IPC — - // without these limits, a malformed/malicious payload claiming a - // gigantic JS array length would force a multi-GB reserve before - // the scorer's own validation rejected the request. A request - // that exceeds either cap is treated as outright malformed and - // returns the "no chord requested" failure shape (totalStrings=0); - // every other validation failure goes through the all-miss path - // below so results[] stays in lockstep with notes[]. - static constexpr uint32_t kMaxOffsets = 32; - static constexpr uint32_t kMaxNotes = 32; - - auto noRequestFailure = [&env]() { - auto failure = Napi::Object::New(env); - failure.Set("score", 0.0); - failure.Set("hitStrings", 0); - failure.Set("totalStrings", 0); - failure.Set("isHit", false); - failure.Set("results", Napi::Array::New(env, 0)); - return failure; - }; - - // Capture the notes array up front so every downstream failure - // path can build a per-note all-miss result aligned 1:1 with the - // caller's notes[]. Pre-cap check happens before we even read the - // length into the helper to prevent a payload claiming an enormous - // length from forcing the helper to allocate a huge results array. - Napi::Value notesVal = reqObj.Has("notes") ? reqObj.Get("notes") : env.Null(); - if (!notesVal.IsArray()) return noRequestFailure(); - auto notesArr = notesVal.As(); - if (notesArr.Length() > kMaxNotes) return noRequestFailure(); - const uint32_t noteCount = notesArr.Length(); - - // All-miss result aligned with the caller's notes[]. Walks the - // original JS array so the per-note `s` / `f` echo back in the - // result even when the request fails validation (lets the renderer - // distinguish "this string missed" from "this string wasn't sent"). - // Used by every failure path below except the cap/no-notes case - // above, which doesn't have a coherent notes[] to mirror. - auto buildAllMiss = [&]() { - auto resultsArr = Napi::Array::New(env, noteCount); - for (uint32_t i = 0; i < noteCount; ++i) - { - int s = -1, f = -1; - auto v = notesArr.Get(i); - if (v.IsObject()) - { - auto o = v.As(); - if (o.Has("s") && o.Get("s").IsNumber()) - s = o.Get("s").As().Int32Value(); - if (o.Has("f") && o.Get("f").IsNumber()) - f = o.Get("f").As().Int32Value(); - } - auto entry = Napi::Object::New(env); - entry.Set("s", s); - entry.Set("f", f); - entry.Set("hit", false); - entry.Set("bandEnergy", 0.0); - entry.Set("centsDiff", env.Null()); - entry.Set("centsError", env.Null()); - resultsArr.Set(i, entry); - } - auto out = Napi::Object::New(env); - out.Set("score", 0.0); - out.Set("hitStrings", 0); - out.Set("totalStrings", (int) noteCount); - out.Set("isHit", false); - out.Set("results", resultsArr); - return out; - }; - - ChordScorer::Request req; - if (reqObj.Has("numSamples") && reqObj.Get("numSamples").IsNumber()) - req.numSamples = reqObj.Get("numSamples").As().Int32Value(); - if (reqObj.Has("arrangement") && reqObj.Get("arrangement").IsString()) - req.arrangement = reqObj.Get("arrangement").As().Utf8Value(); - if (reqObj.Has("stringCount") && reqObj.Get("stringCount").IsNumber()) - req.stringCount = reqObj.Get("stringCount").As().Int32Value(); - if (reqObj.Has("capo") && reqObj.Get("capo").IsNumber()) - req.capo = reqObj.Get("capo").As().Int32Value(); - if (reqObj.Has("pitchCheckCents") && reqObj.Get("pitchCheckCents").IsNumber()) - req.pitchCheckCents = reqObj.Get("pitchCheckCents").As().FloatValue(); - if (reqObj.Has("minHitRatio") && reqObj.Get("minHitRatio").IsNumber()) - req.minHitRatio = reqObj.Get("minHitRatio").As().FloatValue(); - if (reqObj.Has("bypassMl") && reqObj.Get("bypassMl").IsBoolean()) - req.bypassMl = reqObj.Get("bypassMl").As().Value(); - if (reqObj.Has("harmonicVerify") && reqObj.Get("harmonicVerify").IsBoolean()) - req.harmonicVerify = reqObj.Get("harmonicVerify").As().Value(); - if (reqObj.Has("harmonicSnr") && reqObj.Get("harmonicSnr").IsNumber()) - req.harmonicSnr = reqObj.Get("harmonicSnr").As().FloatValue(); - if (reqObj.Has("fundamentalRatio") && reqObj.Get("fundamentalRatio").IsNumber()) - { - // Drop NaN/Inf: a non-finite ratio poisons the fundamental-presence - // gate (fundMag >= NaN is always false -> every note false-rejected). - // Keep the safe 0.20 default instead. - const float v = reqObj.Get("fundamentalRatio").As().FloatValue(); - if (std::isfinite(v)) req.fundamentalRatio = v; - } - - if (reqObj.Has("offsets") && reqObj.Get("offsets").IsArray()) - { - auto arr = reqObj.Get("offsets").As(); - if (arr.Length() > kMaxOffsets) return noRequestFailure(); - req.tuningOffsets.reserve(arr.Length()); - for (uint32_t i = 0; i < arr.Length(); ++i) - { - auto v = arr.Get(i); - // Tuning offsets materially shift expected pitch — silently - // substituting 0 for a missing/non-numeric entry would - // produce confidently wrong scores. Fail closed with the - // per-note all-miss shape so the renderer sees the right - // results[] length even when the request is malformed. - if (!v.IsNumber()) return buildAllMiss(); - req.tuningOffsets.push_back(v.As().Int32Value()); - } - } - - req.notes.reserve(noteCount); - for (uint32_t i = 0; i < noteCount; ++i) - { - auto v = notesArr.Get(i); - // For malformed entries (non-object, or missing/non-numeric - // s/f) push a sentinel Note with string = -1. This keeps - // req.notes.size() in lockstep with the incoming notes[] - // length AND guarantees ChordScorer's range check - // (`n.string < 0 || n.string >= stringCount`) trips on the - // sentinel — yielding the same all-miss fail-closed result - // the shape contract advertises, never a false hit on the - // default low-string position. - ChordScorer::Note n{}; - n.string = -1; - n.fret = -1; - if (!v.IsObject()) - { - req.notes.push_back(n); - continue; - } - auto noteObj = v.As(); - const bool hasS = noteObj.Has("s") && noteObj.Get("s").IsNumber(); - const bool hasF = noteObj.Has("f") && noteObj.Get("f").IsNumber(); - if (!hasS || !hasF) - { - req.notes.push_back(n); - continue; - } - n.string = noteObj.Get("s").As().Int32Value(); - n.fret = noteObj.Get("f").As().Int32Value(); - // Technique flags are truthy/falsy in JS; coerce to bool - // here so an unset value cleanly becomes false. - auto truthy = [¬eObj](const char* key) { - if (!noteObj.Has(key)) return false; - auto val = noteObj.Get(key); - return val.ToBoolean().Value(); - }; - n.hammerOn = truthy("ho"); - n.pullOff = truthy("po"); - n.bend = truthy("b"); - n.slide = truthy("sl"); - n.harmonic = truthy("hm"); - req.notes.push_back(n); - } - - auto result = target->scoreChord(req); - - auto out = Napi::Object::New(env); - out.Set("score", result.score); - out.Set("hitStrings", result.hitStrings); - out.Set("totalStrings", result.totalStrings); - out.Set("isHit", result.isHit); - auto resultsArr = Napi::Array::New(env, result.results.size()); - for (size_t i = 0; i < result.results.size(); ++i) - { - const auto& r = result.results[i]; - auto entry = Napi::Object::New(env); - entry.Set("s", r.string); - entry.Set("f", r.fret); - entry.Set("hit", r.hit); - entry.Set("bandEnergy", r.bandEnergy); - // Mirror the JS result shape: when cents weren't measured the - // fields are present-but-null so the renderer can distinguish - // "no pitch check ran" (null) from "pitch check said 0" - // (numeric 0). - if (r.hasCents) - { - entry.Set("centsDiff", r.centsDiff); - entry.Set("centsError", r.centsError); - } - else - { - entry.Set("centsDiff", env.Null()); - entry.Set("centsError", env.Null()); - } - resultsArr.Set(i, entry); - } - out.Set("results", resultsArr); - return out; -} - -// Legacy: scoreChord(req) — targets sources[0]. Backward-compatible. -static Napi::Value ScoreChord(const Napi::CallbackInfo& info) -{ - auto env = info.Env(); - auto liveEngine = snapshotEngine(); - auto noRequestFailure = [&env]() { - auto failure = Napi::Object::New(env); - failure.Set("score", 0.0); - failure.Set("hitStrings", 0); - failure.Set("totalStrings", 0); - failure.Set("isHit", false); - failure.Set("results", Napi::Array::New(env, 0)); - return failure; - }; - if (!liveEngine || info.Length() < 1 || !info[0].IsObject()) - return noRequestFailure(); - return scoreChordCore(env, info[0].As(), liveEngine->getSource(0)); -} - -// Source-indexed: scoreSourceChord(sourceId, req). Bad id / payload -> the -// same "no chord requested" failure shape (totalStrings=0). -static Napi::Value ScoreSourceChord(const Napi::CallbackInfo& info) -{ - auto env = info.Env(); - auto liveEngine = snapshotEngine(); - auto noRequestFailure = [&env]() { - auto failure = Napi::Object::New(env); - failure.Set("score", 0.0); - failure.Set("hitStrings", 0); - failure.Set("totalStrings", 0); - failure.Set("isHit", false); - failure.Set("results", Napi::Array::New(env, 0)); - return failure; - }; - if (!liveEngine || info.Length() < 2 || !info[0].IsNumber() || !info[1].IsObject()) - return noRequestFailure(); - SourceChain* target = getValidatedSource(liveEngine.get(), info, 0); - if (!target) return noRequestFailure(); - return scoreChordCore(env, info[1].As(), target); -} - -// ── Multi-input source management bridge ───────────────────────────────────── -// A source is one independent input chain (own arrangement chart, detection, -// scoring, tone, monitor). sources[0] always exists. The renderer adds a source -// per extra player, binds it to an input channel, and drives its scoring via the -// *Source* methods below; the legacy un-suffixed methods keep targeting source 0. - -// addSource(inputChannel?) -> sourceId (number), or -1 if the pool is full. -static Napi::Value AddSource(const Napi::CallbackInfo& info) -{ - auto env = info.Env(); - auto liveEngine = snapshotEngine(); - if (!liveEngine) return Napi::Number::New(env, -1); - int channel = -1; // default: mono mix of the first pair - if (info.Length() > 0 && info[0].IsNumber()) - channel = info[0].As().Int32Value(); - int deviceKey = 0; // default: primary input device - if (info.Length() > 1 && info[1].IsNumber()) - { - const int k = info[1].As().Int32Value(); - if (k >= 0) deviceKey = k; // negatives ignored → primary - } - return Napi::Number::New(env, liveEngine->addSource(channel, deviceKey)); -} - -// removeSource(sourceId) -> boolean. sources[0] cannot be removed. -static Napi::Value RemoveSource(const Napi::CallbackInfo& info) -{ - auto env = info.Env(); - auto liveEngine = snapshotEngine(); - if (!liveEngine || info.Length() < 1 || !info[0].IsNumber()) - return Napi::Boolean::New(env, false); - return Napi::Boolean::New(env, liveEngine->removeSource(info[0].As().Int32Value())); -} - -// listSources() -> [{ id, inputChannel, active }]. Null on a missing engine. -static Napi::Value ListSources(const Napi::CallbackInfo& info) -{ - auto env = info.Env(); - auto liveEngine = snapshotEngine(); - if (!liveEngine) return env.Null(); - const auto sources = liveEngine->listSources(); - auto arr = Napi::Array::New(env, sources.size()); - for (size_t i = 0; i < sources.size(); ++i) - { - auto entry = Napi::Object::New(env); - entry.Set("id", sources[i].id); - entry.Set("inputChannel", sources[i].inputChannel); - entry.Set("deviceKey", sources[i].deviceKey); - entry.Set("active", sources[i].active); - arr.Set((uint32_t) i, entry); - } - return arr; -} - -// listInputDevices() -> [{ typeName, name }]. Every available capture device the -// renderer can bind to an additional engine input via bindInputDevice. Null on a -// missing engine. -static Napi::Value ListInputDevices(const Napi::CallbackInfo& info) -{ - auto env = info.Env(); - auto liveEngine = snapshotEngine(); - if (!liveEngine) return env.Null(); - const auto devices = liveEngine->getBindableInputDevices(); - auto arr = Napi::Array::New(env); - uint32_t n = 0; - for (const auto& d : devices) - { - auto entry = Napi::Object::New(env); - entry.Set("typeName", d.typeName.toStdString()); - entry.Set("name", d.name.toStdString()); - arr.Set(n++, entry); - } - return arr; -} - -// bindInputDevice(deviceKey, deviceName) -> "" on success, else an error string. -// Opens an ADDITIONAL physical input device (deviceKey 1..N) so sources created -// with addSource(channel, deviceKey) capture from it at its own clock. -static Napi::Value BindInputDevice(const Napi::CallbackInfo& info) -{ - auto env = info.Env(); - auto liveEngine = snapshotEngine(); - if (!liveEngine) return Napi::String::New(env, "no engine"); - if (info.Length() < 2 || !info[0].IsNumber() || !info[1].IsString()) - return Napi::String::New(env, "bindInputDevice(deviceKey:number, deviceName:string)"); - const int deviceKey = info[0].As().Int32Value(); - const std::string name = info[1].As().Utf8Value(); - return Napi::String::New(env, liveEngine->bindInputDevice(deviceKey, name).toStdString()); -} - -// unbindInputDevice(deviceKey) -> boolean. Stops + releases the extra device. -static Napi::Value UnbindInputDevice(const Napi::CallbackInfo& info) -{ - auto env = info.Env(); - auto liveEngine = snapshotEngine(); - if (!liveEngine || info.Length() < 1 || !info[0].IsNumber()) - return Napi::Boolean::New(env, false); - return Napi::Boolean::New(env, liveEngine->unbindInputDevice(info[0].As().Int32Value())); -} - -// ── Streamer mix output (PR1) ─────────────────────────────────────────────── -// setStreamOutputDevice(typeName, deviceName) -> "" on success, else an error. -static Napi::Value SetStreamOutputDevice(const Napi::CallbackInfo& info) -{ - auto env = info.Env(); - auto liveEngine = snapshotEngine(); - if (!liveEngine) return Napi::String::New(env, "no engine"); - if (info.Length() < 2 || !info[0].IsString() || !info[1].IsString()) - return Napi::String::New(env, "setStreamOutputDevice(typeName:string, deviceName:string)"); - const std::string typeName = info[0].As().Utf8Value(); - const std::string devName = info[1].As().Utf8Value(); - return Napi::String::New(env, - liveEngine->setStreamOutputDevice(juce::String(typeName), juce::String(devName)).toStdString()); -} - -// clearStreamOutput() -> undefined -static Napi::Value ClearStreamOutput(const Napi::CallbackInfo& info) -{ - auto liveEngine = snapshotEngine(); - if (liveEngine) liveEngine->clearStreamOutput(); - return info.Env().Undefined(); -} - -// setStreamBus(includeBacking:boolean, includeGuitar:boolean, gain:number) -static Napi::Value SetStreamBus(const Napi::CallbackInfo& info) -{ - auto liveEngine = snapshotEngine(); - if (liveEngine && info.Length() >= 3 && info[0].IsBoolean() && info[1].IsBoolean() && info[2].IsNumber()) - liveEngine->setStreamBus(info[0].As().Value(), - info[1].As().Value(), - (float) info[2].As().DoubleValue()); - return info.Env().Undefined(); -} - -// setStreamBusGain(gain:number) -static Napi::Value SetStreamBusGain(const Napi::CallbackInfo& info) -{ - auto liveEngine = snapshotEngine(); - if (liveEngine && info.Length() >= 1 && info[0].IsNumber()) - liveEngine->setStreamBusGain((float) info[0].As().DoubleValue()); - return info.Env().Undefined(); -} - -// setRendererBus(enabled:boolean, gain:number) -static Napi::Value SetRendererBus(const Napi::CallbackInfo& info) -{ - auto liveEngine = snapshotEngine(); - if (liveEngine && info.Length() >= 2 && info[0].IsBoolean() && info[1].IsNumber()) - liveEngine->setRendererBus(info[0].As().Value(), - (float) info[1].As().DoubleValue()); - return info.Env().Undefined(); -} - -// pushRendererAudio(interleavedLR:Float32Array, sourceRate:number) -> boolean -// Interleaved stereo (L0 R0 L1 R1 …); sourceRate is the renderer's -// AudioContext sample rate. Returns false when the bus is off / engine down / -// malformed args, so the renderer can stop pushing. -static Napi::Value PushRendererAudio(const Napi::CallbackInfo& info) -{ - auto env = info.Env(); - auto liveEngine = snapshotEngine(); - if (!liveEngine || info.Length() < 2 || !info[0].IsTypedArray() || !info[1].IsNumber()) - return Napi::Boolean::New(env, false); - auto ta = info[0].As(); - if (ta.TypedArrayType() != napi_float32_array) - return Napi::Boolean::New(env, false); - auto f32 = info[0].As(); - const size_t samples = f32.ElementLength(); - if (samples < 2) - return Napi::Boolean::New(env, false); - const int frames = (int) (samples / 2); - const bool ok = liveEngine->pushRendererAudio( - f32.Data(), frames, info[1].As().DoubleValue()); - return Napi::Boolean::New(env, ok); -} - -// getRendererBusMetrics() -> {enabled, fillFrames, capacityFrames, -// pushedFrames, consumedFrames, -// underflowCount, overflowCount} -static Napi::Value GetRendererBusMetrics(const Napi::CallbackInfo& info) -{ - auto env = info.Env(); - auto liveEngine = snapshotEngine(); - auto obj = Napi::Object::New(env); - if (!liveEngine) return obj; - const auto m = liveEngine->getRendererBusMetrics(); - obj.Set("enabled", m.enabled); - obj.Set("fillFrames", m.fillFrames); - obj.Set("capacityFrames", m.capacityFrames); - obj.Set("pushedFrames", (double) m.pushedFrames); - obj.Set("consumedFrames", (double) m.consumedFrames); - obj.Set("underflowCount", (double) m.underflowCount); - obj.Set("overflowCount", (double) m.overflowCount); - return obj; -} - -// getStreamSinkLevel() -> number (peak 0..1+) -static Napi::Value GetStreamSinkLevel(const Napi::CallbackInfo& info) -{ - auto liveEngine = snapshotEngine(); - return Napi::Number::New(info.Env(), liveEngine ? liveEngine->getStreamSinkLevel() : 0.0f); -} - -// isStreamOutputActive() -> boolean -static Napi::Value IsStreamOutputActive(const Napi::CallbackInfo& info) -{ - auto liveEngine = snapshotEngine(); - return Napi::Boolean::New(info.Env(), liveEngine ? liveEngine->isStreamOutputActive() : false); -} - -// getStreamUnderflowCount() -> number -static Napi::Value GetStreamUnderflowCount(const Napi::CallbackInfo& info) -{ - auto liveEngine = snapshotEngine(); - return Napi::Number::New(info.Env(), - (double) (liveEngine ? liveEngine->getStreamUnderflowCount() : 0ull)); -} - -// getStreamOverflowCount() -> number (consumer fell a full ring behind; frames dropped) -static Napi::Value GetStreamOverflowCount(const Napi::CallbackInfo& info) -{ - auto liveEngine = snapshotEngine(); - return Napi::Number::New(info.Env(), - (double) (liveEngine ? liveEngine->getStreamOverflowCount() : 0ull)); -} - -// setSourceInputChannel(sourceId, channel) -static Napi::Value SetSourceInputChannel(const Napi::CallbackInfo& info) -{ - auto liveEngine = snapshotEngine(); - if (liveEngine && info.Length() >= 2 && info[0].IsNumber() && info[1].IsNumber()) - if (SourceChain* s = getValidatedSource(liveEngine.get(), info, 0)) - s->setInputChannel(info[1].As().Int32Value()); - return info.Env().Undefined(); -} - -// setSourceVerifierOffset(sourceId, seconds) — per-source capture-latency -// correction the user dials in for an extra input device (the residual offset -// between that device's path and the primary's; not auto-measurable on JACK). -// Positive seconds DELAYS this source's scoring playhead, negative ADVANCES it. -static Napi::Value SetSourceVerifierOffset(const Napi::CallbackInfo& info) -{ - auto liveEngine = snapshotEngine(); - if (liveEngine && info.Length() >= 2 && info[0].IsNumber() && info[1].IsNumber()) - { - const double sec = info[1].As().DoubleValue(); - if (std::isfinite(sec)) - if (SourceChain* s = getValidatedSource(liveEngine.get(), info, 0)) - s->setVerifierUserOffset(sec); - } - return info.Env().Undefined(); -} - -// setSourceMonitorMute(sourceId, mute) -static Napi::Value SetSourceMonitorMute(const Napi::CallbackInfo& info) -{ - auto liveEngine = snapshotEngine(); - if (liveEngine && info.Length() >= 2 && info[0].IsNumber() && info[1].IsBoolean()) - if (SourceChain* s = getValidatedSource(liveEngine.get(), info, 0)) - s->setMonitorMute(info[1].As().Value()); - return info.Env().Undefined(); -} - -// getSourceRawAudioFrame(sourceId, numSamples?) -> Float32Array -static Napi::Value GetSourceRawAudioFrame(const Napi::CallbackInfo& info) -{ - auto env = info.Env(); - auto liveEngine = snapshotEngine(); - if (!liveEngine || info.Length() < 1 || !info[0].IsNumber()) - return Napi::Float32Array::New(env, 0); - SourceChain* s = getValidatedSource(liveEngine.get(), info, 0); - int numSamples = 4096; - if (info.Length() > 1 && info[1].IsNumber()) - numSamples = info[1].As().Int32Value(); - if (!s || numSamples <= 0) - return Napi::Float32Array::New(env, 0); - auto frame = s->getRawAudioFrame(numSamples); - auto out = Napi::Float32Array::New(env, frame.size()); - float* dst = out.Data(); - for (size_t i = 0; i < frame.size(); ++i) - dst[i] = frame[i]; - return out; -} - -// getSourcePitchDetection(sourceId) -> { frequency, confidence, midiNote, cents, -// noteName }. The no-detection shape when the id is bad/inactive. -static Napi::Value GetSourcePitchDetection(const Napi::CallbackInfo& info) -{ - auto env = info.Env(); - auto obj = Napi::Object::New(env); - auto liveEngine = snapshotEngine(); - SourceChain* s = (liveEngine && info.Length() >= 1 && info[0].IsNumber()) - ? getValidatedSource(liveEngine.get(), info, 0) : nullptr; - if (s) - { - auto det = s->getActiveDetection(); - obj.Set("frequency", det.frequency); - obj.Set("confidence", det.confidence); - obj.Set("midiNote", det.midiNote); - obj.Set("cents", det.cents); - obj.Set("noteName", det.noteName.toStdString()); - } - else - { - obj.Set("frequency", -1.0); - obj.Set("confidence", 0.0); - obj.Set("midiNote", -1); - obj.Set("cents", 0.0); - obj.Set("noteName", ""); - } - return obj; -} - -// getSourceRawPitchDetection(sourceId) -> raw YIN detection (bypasses ML), same -// shape as getSourcePitchDetection. Backs the per-source sustain glow / mono path. -static Napi::Value GetSourceRawPitchDetection(const Napi::CallbackInfo& info) -{ - auto env = info.Env(); - auto obj = Napi::Object::New(env); - auto liveEngine = snapshotEngine(); - SourceChain* s = (liveEngine && info.Length() >= 1 && info[0].IsNumber()) - ? getValidatedSource(liveEngine.get(), info, 0) : nullptr; - if (s) - { - auto det = s->getRawPitchDetection(); - obj.Set("frequency", det.frequency); - obj.Set("confidence", det.confidence); - obj.Set("midiNote", det.midiNote); - obj.Set("cents", det.cents); - obj.Set("noteName", det.noteName.toStdString()); - } - else - { - obj.Set("frequency", -1.0); - obj.Set("confidence", 0.0); - obj.Set("midiNote", -1); - obj.Set("cents", 0.0); - obj.Set("noteName", ""); - } - return obj; -} - -// getSourceNoteVerdicts(sourceId, songTime?, playing?) -> verdict array, or null -// on a missing engine / bad id. Folds in the per-source playhead push like the -// legacy getNoteVerdicts. -static Napi::Value GetSourceNoteVerdicts(const Napi::CallbackInfo& info) -{ - auto env = info.Env(); - auto liveEngine = snapshotEngine(); - if (!liveEngine || info.Length() < 1 || !info[0].IsNumber()) - return env.Null(); - SourceChain* s = getValidatedSource(liveEngine.get(), info, 0); - if (!s) return env.Null(); - - if (info.Length() >= 3 && info[1].IsNumber() && info[2].IsBoolean()) - { - const double songTime = info[1].As().DoubleValue(); - if (std::isfinite(songTime)) - s->setPlayhead(songTime, info[2].As().Value()); - } - - const auto verdicts = s->getNoteVerdicts(); - auto arr = Napi::Array::New(env, verdicts.size()); - for (size_t i = 0; i < verdicts.size(); ++i) - { - const auto& v = verdicts[i]; - auto entry = Napi::Object::New(env); - entry.Set("id", v.id); - entry.Set("detected", v.detected); - entry.Set("detectedSongTime", v.detectedSongTime); - entry.Set("centsError", v.centsError); - entry.Set("snr", v.snr); - arr.Set((uint32_t) i, entry); - } - return arr; -} - -// Push the song's note chart into the engine for continuous, background -// verification. The notedetect plugin calls this once per arrangement load; -// the engine's NoteVerifier thread then scores each note's timing window -// against the live playhead and input ring, so the renderer no longer runs a -// per-tick scoreChord IPC loop (which starved during dense passages). -// -// Expected payload: -// { -// arrangement?: 'guitar'|'bass', // default 'guitar' -// stringCount?: number, // default 6 -// tuningOffsets: number[], // length should equal stringCount -// capo?: number, // default 0 -// pitchCheckCents?: number, // default 0 (energy-only) -// harmonicSnr?: number, // default 3.0 -// fundamentalRatio?: number, // fundamental-presence gate, lower for -// // bass, <=0 disables (default 0.20) -// timingTolerance?: number, // seconds, default 0.1 -// notes: [{ id:string, t:number, s:number, f:number, sus:number, -// ho?,po?,b?,sl?,hm?:boolean }, ...] -// } -// Returns true when the chart was accepted, false on a malformed payload or -// when no engine exists. -// Shared core: parse `reqObj` into a ChartUpdate and push it to `target`'s -// verifier. `target` is sources[0] for the legacy setChart and getSource(id) for -// the source-indexed setSourceChart. A malformed payload clears the target's -// chart (so a failed reload can't leave a stale chart scoring) and returns false. -static Napi::Value setChartCore(Napi::Env env, Napi::Object reqObj, SourceChain* target) -{ - // Generous cap on the chart length — a full song's note list is well - // under this, but it bounds the worst-case allocation a malformed payload - // (claiming a gigantic JS array length) could force over IPC. - static constexpr uint32_t kMaxChartNotes = 8192; - - // Rejecting a malformed chart must also drop whatever chart the verifier - // currently holds — otherwise a failed (re)load leaves the previous - // song's chart active and getNoteVerdicts() keeps emitting stale verdicts. - auto reject = [&]() -> Napi::Value { - if (target) target->clearChart(); - return Napi::Boolean::New(env, false); - }; - - NoteVerifier::ChartUpdate chart; - if (reqObj.Has("arrangement") && reqObj.Get("arrangement").IsString()) - chart.arrangement = reqObj.Get("arrangement").As().Utf8Value(); - if (reqObj.Has("stringCount") && reqObj.Get("stringCount").IsNumber()) - chart.stringCount = reqObj.Get("stringCount").As().Int32Value(); - if (reqObj.Has("capo") && reqObj.Get("capo").IsNumber()) - chart.capo = reqObj.Get("capo").As().Int32Value(); - if (reqObj.Has("pitchCheckCents") && reqObj.Get("pitchCheckCents").IsNumber()) - chart.pitchCheckCents = reqObj.Get("pitchCheckCents").As().FloatValue(); - if (reqObj.Has("harmonicSnr") && reqObj.Get("harmonicSnr").IsNumber()) - chart.harmonicSnr = reqObj.Get("harmonicSnr").As().FloatValue(); - if (reqObj.Has("fundamentalRatio") && reqObj.Get("fundamentalRatio").IsNumber()) - { - // Drop NaN/Inf (see ScoreChord): a non-finite ratio poisons the - // fundamental-presence gate; keep the safe 0.20 default. - const float v = reqObj.Get("fundamentalRatio").As().FloatValue(); - if (std::isfinite(v)) chart.fundamentalRatio = v; - } - if (reqObj.Has("presenceRatio") && reqObj.Get("presenceRatio").IsNumber()) - { - // Temporal-persistence floor, clamped to [0,1]. Saturate rather than - // reject an out-of-range value: a stray >1 must NOT silently fall back to - // 0 (legacy ever-present), which would reintroduce the false-accept this - // guards against. Non-finite is ignored (keeps the 0 default). - const float v = reqObj.Get("presenceRatio").As().FloatValue(); - if (std::isfinite(v)) chart.presenceRatio = (v < 0.0f) ? 0.0f : (v > 1.0f ? 1.0f : v); - } - if (reqObj.Has("timingTolerance") && reqObj.Get("timingTolerance").IsNumber()) - chart.timingTolerance = reqObj.Get("timingTolerance").As().DoubleValue(); - - if (reqObj.Has("tuningOffsets") && reqObj.Get("tuningOffsets").IsArray()) - { - auto arr = reqObj.Get("tuningOffsets").As(); - if (arr.Length() > 32) return reject(); - chart.tuningOffsets.reserve(arr.Length()); - for (uint32_t i = 0; i < arr.Length(); ++i) - { - auto v = arr.Get(i); - if (!v.IsNumber()) return reject(); - chart.tuningOffsets.push_back(v.As().Int32Value()); - } - } - - // ChordScorer requires exactly one tuning offset per string and otherwise - // fails every note closed. Reject the chart here so a malformed payload - // surfaces as setChart() == false rather than a silently all-miss session - // the caller believes loaded fine. - if ((int) chart.tuningOffsets.size() != chart.stringCount) - return reject(); - - Napi::Value notesVal = reqObj.Has("notes") ? reqObj.Get("notes") : env.Null(); - if (!notesVal.IsArray()) return reject(); - auto notesArr = notesVal.As(); - if (notesArr.Length() > kMaxChartNotes) return reject(); - - chart.notes.reserve(notesArr.Length()); - for (uint32_t i = 0; i < notesArr.Length(); ++i) - { - auto v = notesArr.Get(i); - if (!v.IsObject()) return reject(); - auto noteObj = v.As(); - - // Every chart note must carry all five required fields with the right - // type. Filling defaults for a missing field would push a bogus - // time-0 note with an empty id — that breaks verdict-by-id alignment - // — so reject the whole chart instead. - const bool validNote = - noteObj.Has("id") && noteObj.Get("id").IsString() && - noteObj.Has("t") && noteObj.Get("t").IsNumber() && - noteObj.Has("s") && noteObj.Get("s").IsNumber() && - noteObj.Has("f") && noteObj.Get("f").IsNumber() && - noteObj.Has("sus") && noteObj.Get("sus").IsNumber(); - if (!validNote) return reject(); - - NoteVerifier::ChartNote n{}; - n.id = noteObj.Get("id").As().Utf8Value(); - n.t = noteObj.Get("t").As().DoubleValue(); - n.string = noteObj.Get("s").As().Int32Value(); - n.fret = noteObj.Get("f").As().Int32Value(); - n.sus = noteObj.Get("sus").As().DoubleValue(); - auto truthy = [¬eObj](const char* key) { - if (!noteObj.Has(key)) return false; - return noteObj.Get(key).ToBoolean().Value(); - }; - n.ho = truthy("ho"); - n.po = truthy("po"); - n.b = truthy("b"); - n.sl = truthy("sl"); - n.hm = truthy("hm"); - chart.notes.push_back(std::move(n)); - } - - target->setChart(chart); - return Napi::Boolean::New(env, true); -} - -// Legacy: setChart(chart) — targets sources[0]. Backward-compatible. -static Napi::Value SetChart(const Napi::CallbackInfo& info) -{ - auto env = info.Env(); - auto liveEngine = snapshotEngine(); - if (!liveEngine || info.Length() < 1 || !info[0].IsObject()) - { - if (liveEngine) liveEngine->clearChart(); - return Napi::Boolean::New(env, false); - } - return setChartCore(env, info[0].As(), liveEngine->getSource(0)); -} - -// Source-indexed: setSourceChart(sourceId, chart). Bad id / payload -> false. -static Napi::Value SetSourceChart(const Napi::CallbackInfo& info) -{ - auto env = info.Env(); - auto liveEngine = snapshotEngine(); - if (!liveEngine || info.Length() < 2 || !info[0].IsNumber() || !info[1].IsObject()) - return Napi::Boolean::New(env, false); - SourceChain* target = getValidatedSource(liveEngine.get(), info, 0); - if (!target) return Napi::Boolean::New(env, false); - return setChartCore(env, info[1].As(), target); -} - -// Drain the verdicts the NoteVerifier thread has finalized since the last -// call. Returns an array of { id, detected, detectedSongTime, centsError, snr }. -// -// Optionally also pushes the renderer's playhead: getNoteVerdicts(songTime, -// playing). The plugin calls this once per detect tick, so folding the push in -// here advances the verifier's clock without a second IPC round-trip. A -// downlevel caller passing no args still just drains. -static Napi::Value GetNoteVerdicts(const Napi::CallbackInfo& info) -{ - auto env = info.Env(); - // Null (not an empty array) on a missing engine — the bridge/preload - // contract treats null as "unsupported/unavailable" so the renderer - // feature-detects, matching detectNotes' no-engine path. - auto liveEngine = snapshotEngine(); - if (!liveEngine) return env.Null(); - - // Push the playhead before draining so this tick's verdicts reflect it. - // A JS NaN/Infinity passes IsNumber() — guard with isfinite so a bad - // value can't corrupt the verifier's interpolated timing. - if (info.Length() >= 2 && info[0].IsNumber() && info[1].IsBoolean()) - { - const double songTime = info[0].As().DoubleValue(); - if (std::isfinite(songTime)) - liveEngine->setPlayhead(songTime, info[1].As().Value()); - } - - const auto verdicts = liveEngine->getNoteVerdicts(); - auto arr = Napi::Array::New(env, verdicts.size()); - for (size_t i = 0; i < verdicts.size(); ++i) - { - const auto& v = verdicts[i]; - auto entry = Napi::Object::New(env); - entry.Set("id", v.id); - entry.Set("detected", v.detected); - entry.Set("detectedSongTime", v.detectedSongTime); - entry.Set("centsError", v.centsError); - entry.Set("snr", v.snr); - arr.Set((uint32_t) i, entry); - } - return arr; -} - -// Sample rate the audio device is running at. Notedetect's chord scorer -// needs this to map FFT bins to Hz; on the bridge path there's no -// AudioContext to read it from. Falls back to 48000 if the engine isn't -// ready (matches the historical fallback in screen.js) — and also if -// the engine is initialized but no device is currently active, which -// pins currentSampleRate to 0 internally and would otherwise propagate -// a divide-by-zero into the renderer's FFT-bin→Hz math. -static Napi::Value GetSampleRate(const Napi::CallbackInfo& info) -{ - auto env = info.Env(); - constexpr double kFallbackSampleRate = 48000.0; - auto liveEngine = snapshotEngine(); - if (!liveEngine) - return Napi::Number::New(env, kFallbackSampleRate); - const double sr = liveEngine->getCurrentSampleRate(); - if (!std::isfinite(sr) || sr <= 0.0) - return Napi::Number::New(env, kFallbackSampleRate); - return Napi::Number::New(env, sr); -} - // ── VST Plugin Scanning ────────────────────────────────────────────────────── class ScanPluginsWorker : public Napi::AsyncWorker @@ -1806,448 +313,8 @@ static Napi::Value SetVstCrashSentinelPath(const Napi::CallbackInfo& info) return env.Undefined(); } -// ── Signal Chain Management ────────────────────────────────────────────────── - -// Pending in-process loads: each LoadVSTWorker / LoadPresetWorker that's -// currently blocked on `done->wait()` registers its event here. doShutdown -// signals them all so the workers unblock and return a clean "cancelled" -// error instead of hanging forever when the JUCE message thread is about -// to be stopped (and any unfired callback would never arrive). -static 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 liveEngine = snapshotEngine(); - const auto slotId = slopsmith::addon::argSlotId(info, 0); - if (liveEngine && slotId) - { - std::lock_guard chainLock(slopsmith::addon::chainMutationMutex()); - liveEngine->getSignalChain().removeProcessor(*slotId); - slopsmith::addon::bumpChainGeneration(); - } - return info.Env().Undefined(); -} - -static Napi::Value MoveProcessor(const Napi::CallbackInfo& info) -{ - auto liveEngine = snapshotEngine(); - const auto from = slopsmith::addon::argSlotId(info, 0); - const auto to = slopsmith::addon::argSlotId(info, 1); - if (liveEngine && from && to) - { - std::lock_guard chainLock(slopsmith::addon::chainMutationMutex()); - liveEngine->getSignalChain().moveProcessor(*from, *to); - slopsmith::addon::bumpChainGeneration(); - } - return info.Env().Undefined(); -} - -static 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. - -static Napi::Value ClearChain(const Napi::CallbackInfo& info) -{ - // Tear editors down before their processors are freed just below (#56). - closeAllPluginEditorWindows(); - if (auto liveEngine = snapshotEngine()) - { - // Serialized with the async chain workers (deep-read 1). May block - // briefly behind an in-flight preset/VST load -- that wait IS the fix - // for the interleaved clear-vs-rebuild corruption. - std::lock_guard chainLock(slopsmith::addon::chainMutationMutex()); - liveEngine->getSignalChain().clear(); - slopsmith::addon::bumpChainGeneration(); - } - return info.Env().Undefined(); -} - -// Stereo routing (St-1). setPan(slotId, -1..+1); setBranch(slotId, 0=trunk/>=1). -static Napi::Value SetPan(const Napi::CallbackInfo& info) -{ - auto liveEngine = snapshotEngine(); - if (liveEngine && info.Length() >= 2) - { - 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(); -} - -static 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(); -} - -static 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. -static 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. -static Napi::Value GetChainGeneration(const Napi::CallbackInfo& info) -{ - return Napi::Number::New(info.Env(), (double) slopsmith::addon::currentChainGeneration()); -} - -static Napi::Value GetChainState(const Napi::CallbackInfo& info) -{ - auto env = info.Env(); - auto result = Napi::Array::New(env); - auto liveEngine = snapshotEngine(); - - if (liveEngine) - { - auto slots = liveEngine->getSignalChain().getAllSlots(); - for (int i = 0; i < slots.size(); ++i) - { - auto obj = Napi::Object::New(env); - obj.Set("id", slots[i]->id); - obj.Set("type", (int)slots[i]->type); - obj.Set("name", slots[i]->name.toStdString()); - obj.Set("path", slots[i]->path.toStdString()); - obj.Set("bypassed", slots[i]->bypassed); - obj.Set("pan", slots[i]->pan); - obj.Set("branch", slots[i]->branch); - obj.Set("branchSrc", slots[i]->branchSrc); - obj.Set("postGain", slots[i]->postGain); - obj.Set("hasEditor", slots[i]->processor && slots[i]->processor->hasEditor()); - result.Set((uint32_t)i, obj); - } - } - - return result; -} - // ── Plugin Editor Window ────────────────────────────────────────────────────── -// ── Parameters ──────────────────────────────────────────────────────────────── - -static Napi::Value GetParameters(const Napi::CallbackInfo& info) -{ - auto env = info.Env(); - auto liveEngine = snapshotEngine(); - if (!liveEngine || info.Length() < 1) return Napi::Array::New(env); - - int slotId = info[0].As().Int32Value(); - auto params = liveEngine->getSignalChain().getParameters(slotId); - auto result = Napi::Array::New(env, params.size()); - - for (int i = 0; i < params.size(); ++i) - { - auto obj = Napi::Object::New(env); - obj.Set("index", params[i].index); - obj.Set("name", params[i].name.toStdString()); - obj.Set("value", params[i].value); - obj.Set("label", params[i].label.toStdString()); - obj.Set("text", params[i].text.toStdString()); - result.Set((uint32_t)i, obj); - } - - return result; -} - -static Napi::Value SetParameter(const Napi::CallbackInfo& info) -{ - auto liveEngine = snapshotEngine(); - 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. -static Napi::Value SetSlotState(const Napi::CallbackInfo& info) -{ - // Type-guard both args (NAPI_DISABLE_CPP_EXCEPTIONS): a malformed IPC - // payload is a clean no-op rather than a hard addon failure. - auto liveEngine = snapshotEngine(); - if (liveEngine && info.Length() >= 2 && info[0].IsNumber() && info[1].IsString()) - { - int slotId = info[0].As().Int32Value(); - auto base64 = info[1].As().Utf8Value(); - const auto* slot = liveEngine->getSignalChain().getSlot(slotId); - const bool allowStandard = slot != nullptr - && (slot->type == ProcessorSlot::Type::IR - || slot->type == ProcessorSlot::Type::NAM); - juce::MemoryBlock mb; - if (decodeStateBlob(juce::String(base64), mb, allowStandard)) - liveEngine->getSignalChain().setSlotState(slotId, mb); - } - return info.Env().Undefined(); -} - -// ── MIDI ────────────────────────────────────────────────────────────────────── - -static Napi::Value SendMidiToSlot(const Napi::CallbackInfo& info) -{ - auto env = info.Env(); - auto liveEngine = snapshotEngine(); - if (!liveEngine || info.Length() < 4) - return Napi::Boolean::New(env, false); - - // 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); -} - -// ── Backing Track ───────────────────────────────────────────────────────────── - -static Napi::Value LoadBackingTrack(const Napi::CallbackInfo& info) -{ - auto env = info.Env(); - auto liveEngine = snapshotEngine(); - if (!liveEngine || info.Length() < 1) return Napi::Boolean::New(env, false); - - auto path = info[0].As().Utf8Value(); - bool result = liveEngine->loadBackingTrack(juce::File(juce::String(path))); - return Napi::Boolean::New(env, result); -} - -static Napi::Value StartBacking(const Napi::CallbackInfo& info) -{ - if (auto liveEngine = snapshotEngine()) liveEngine->startBacking(); - return info.Env().Undefined(); -} - -static Napi::Value StopBacking(const Napi::CallbackInfo& info) -{ - if (auto liveEngine = snapshotEngine()) liveEngine->stopBacking(); - return info.Env().Undefined(); -} - -static Napi::Value SeekBacking(const Napi::CallbackInfo& info) -{ - auto liveEngine = snapshotEngine(); - if (liveEngine && info.Length() > 0) - liveEngine->setBackingPosition(info[0].As().DoubleValue()); - return info.Env().Undefined(); -} - -static Napi::Value GetBackingPosition(const Napi::CallbackInfo& info) -{ - auto liveEngine = snapshotEngine(); - double pos = liveEngine ? liveEngine->getBackingPosition() : 0.0; - return Napi::Number::New(info.Env(), pos); -} - -static Napi::Value GetBackingDuration(const Napi::CallbackInfo& info) -{ - auto liveEngine = snapshotEngine(); - double dur = liveEngine ? liveEngine->getBackingDuration() : 0.0; - return Napi::Number::New(info.Env(), dur); -} - -static Napi::Value IsBackingPlaying(const Napi::CallbackInfo& info) -{ - auto liveEngine = snapshotEngine(); - bool playing = liveEngine ? liveEngine->isBackingPlaying() : false; - return Napi::Boolean::New(info.Env(), playing); -} - -static Napi::Value SetBackingSpeed(const Napi::CallbackInfo& info) -{ - auto env = info.Env(); - if (info.Length() < 1 || !info[0].IsNumber()) - { - Napi::TypeError::New(env, "setBackingSpeed(speed) requires a number") - .ThrowAsJavaScriptException(); - return env.Undefined(); - } - // (Was a bare `engine` dereference — the one binding that dodged the - // file's own snapshot rule; surfaced by the phase-6 move.) - if (auto liveEngine = snapshotEngine()) - liveEngine->setBackingSpeed(info[0].As().DoubleValue()); - return env.Undefined(); -} - -// ── Presets ─────────────────────────────────────────────────────────────────── - -static Napi::Value SavePreset(const Napi::CallbackInfo& info) -{ - auto env = info.Env(); - auto liveEngine = snapshotEngine(); - if (!liveEngine) return env.Null(); - auto json = liveEngine->getSignalChain().savePreset(); - return Napi::String::New(env, json.toStdString()); -} - -static Napi::Value SetMultiBypass(const Napi::CallbackInfo& info) -{ - auto env = info.Env(); - auto liveEngine = snapshotEngine(); - if (!liveEngine || info.Length() < 1 || !info[0].IsArray()) - return Napi::Boolean::New(env, false); - - auto arr = info[0].As(); - juce::Array> changes; - - for (uint32_t i = 0; i < arr.Length(); i++) - { - // Per-item type guards (deep-read §2): a malformed entry is skipped - // instead of coercing NaN to slot 0. - auto itemVal = arr.Get(i); - if (!itemVal.IsObject()) continue; - auto item = itemVal.As(); - auto slotVal = item.Get("slotId"); - auto bypVal = item.Get("bypassed"); - if (!slotVal.IsNumber() || !bypVal.IsBoolean()) continue; - const double raw = slotVal.As().DoubleValue(); - if (!std::isfinite(raw) || raw != std::floor(raw) || raw < 0.0 || raw > 4096.0) continue; - changes.add({ (int) raw, bypVal.As().Value() }); - } - - liveEngine->getSignalChain().setMultiBypass(changes); - return Napi::Boolean::New(env, true); -} - -// ── Debug file logging ──────────────────────────────────────────────────────── - -// Redirect the C runtime's stderr stream to a file so the native -// [AudioEngine] / [audio-native] diagnostics are captured for a bug report on -// machines with no console (packaged Windows builds). Only invoked when -// SLOPSMITH_DEBUG is set. Returns "" on success, or an error description the -// JS layer logs as an [audio] line. -// -// freopen (not dup2): a packaged GUI-subsystem app has no console, so stderr -// has no valid fd — dup2 onto fileno(stderr) fails. freopen reassigns the -// stream itself and works with or without a console. freopen would close -// stderr before trying the path, so a bad path is ruled out FIRST with a -// throwaway fopen probe (which never touches stderr); only once the path is -// known-writable do we freopen. Append mode so the JS layer's header -// survives; unbuffered so a crash leaves a complete tail. -static Napi::Value EnableFileLogging(const Napi::CallbackInfo& info) -{ - auto env = info.Env(); - if (info.Length() < 1 || !info[0].IsString()) - { - Napi::TypeError::New(env, "enableFileLogging(path) requires a string") - .ThrowAsJavaScriptException(); - return env.Undefined(); - } - -#if defined(_WIN32) - // Widen UTF-16 → wchar_t by value-converting each code unit (not a - // reinterpret_cast — char16_t and wchar_t are distinct types even though - // both are 16-bit on Windows). Wide path so a profile dir with non-ASCII - // characters isn't mangled by the ANSI codepage (cf. src/vst-host/main.cpp, - // which uses the GetEnvironmentVariableW / _wfopen wide path for the same - // reason). - const std::u16string u16 = info[0].As().Utf16Value(); - const std::wstring wpath(u16.begin(), u16.end()); - FILE* probe = _wfopen(wpath.c_str(), L"a"); -#else - const std::string path = info[0].As().Utf8Value(); - FILE* probe = std::fopen(path.c_str(), "a"); -#endif - if (probe == nullptr) - { - // Capture errno before Napi::String::New / std::to_string, which may - // call library code that clobbers it. - const int e = errno; - return Napi::String::New(env, std::string("fopen failed (errno=") - + std::to_string(e) + ")"); - } - std::fclose(probe); // path is writable; stderr never touched on this path - -#if defined(_WIN32) - FILE* fp = _wfreopen(wpath.c_str(), L"a", stderr); -#else - FILE* fp = std::freopen(path.c_str(), "a", stderr); -#endif - if (fp == nullptr) - { - const int e = errno; - // freopen closes stderr before trying the path; on failure it's left - // closed. The probe just verified the path, so this is near-impossible - // — but redirect stderr to the null device so it's a valid sink rather - // than a closed stream that could trip later fprintf(stderr) calls. -#if defined(_WIN32) - std::freopen("NUL", "w", stderr); -#else - std::freopen("/dev/null", "w", stderr); -#endif - return Napi::String::New(env, std::string("freopen failed (errno=") - + std::to_string(e) + ")"); - } - - // Unbuffered: each [AudioEngine] fprintf hits disk immediately, so a - // crash mid-reconfigure still leaves the diagnostic line that explains it. - std::setvbuf(stderr, nullptr, _IONBF, 0); - std::fprintf(stderr, "[audio-native] file logging enabled\n"); - return Napi::String::New(env, ""); // empty = success -} - // ── Module Registration ─────────────────────────────────────────────────────── static Napi::Object InitModule(Napi::Env env, Napi::Object exports) diff --git a/src/audio/addon/BackingBindings.cpp b/src/audio/addon/BackingBindings.cpp new file mode 100644 index 0000000..daa3197 --- /dev/null +++ b/src/audio/addon/BackingBindings.cpp @@ -0,0 +1,91 @@ +// Backing track bindings - moved verbatim from NodeAddon.cpp (TLC phase 7b +// binding split). Registered by NodeAddon's export table via Bindings.h. + +#include "Bindings.h" + +#include "AddonContext.h" +#include "NapiHelpers.h" +#include "ChainOps.h" +#include "../AudioEngine.h" +#include "../VSTHost.h" +#include "../VSTTrace.h" + +#include +#include +#include +#include + +namespace slopsmith::addon { + +// ── Backing Track ───────────────────────────────────────────────────────────── + +Napi::Value LoadBackingTrack(const Napi::CallbackInfo& info) +{ + auto env = info.Env(); + auto liveEngine = snapshotEngine(); + if (!liveEngine || info.Length() < 1) return Napi::Boolean::New(env, false); + + auto path = info[0].As().Utf8Value(); + bool result = liveEngine->loadBackingTrack(juce::File(juce::String(path))); + return Napi::Boolean::New(env, result); +} + +Napi::Value StartBacking(const Napi::CallbackInfo& info) +{ + if (auto liveEngine = snapshotEngine()) liveEngine->startBacking(); + return info.Env().Undefined(); +} + +Napi::Value StopBacking(const Napi::CallbackInfo& info) +{ + if (auto liveEngine = snapshotEngine()) liveEngine->stopBacking(); + return info.Env().Undefined(); +} + +Napi::Value SeekBacking(const Napi::CallbackInfo& info) +{ + auto liveEngine = snapshotEngine(); + if (liveEngine && info.Length() > 0) + liveEngine->setBackingPosition(info[0].As().DoubleValue()); + return info.Env().Undefined(); +} + +Napi::Value GetBackingPosition(const Napi::CallbackInfo& info) +{ + auto liveEngine = snapshotEngine(); + double pos = liveEngine ? liveEngine->getBackingPosition() : 0.0; + return Napi::Number::New(info.Env(), pos); +} + +Napi::Value GetBackingDuration(const Napi::CallbackInfo& info) +{ + auto liveEngine = snapshotEngine(); + double dur = liveEngine ? liveEngine->getBackingDuration() : 0.0; + return Napi::Number::New(info.Env(), dur); +} + +Napi::Value IsBackingPlaying(const Napi::CallbackInfo& info) +{ + auto liveEngine = snapshotEngine(); + bool playing = liveEngine ? liveEngine->isBackingPlaying() : false; + return Napi::Boolean::New(info.Env(), playing); +} + +Napi::Value SetBackingSpeed(const Napi::CallbackInfo& info) +{ + auto env = info.Env(); + if (info.Length() < 1 || !info[0].IsNumber()) + { + Napi::TypeError::New(env, "setBackingSpeed(speed) requires a number") + .ThrowAsJavaScriptException(); + return env.Undefined(); + } + // (Was a bare `engine` dereference — the one binding that dodged the + // file's own snapshot rule; surfaced by the phase-6 move.) + if (auto liveEngine = snapshotEngine()) + liveEngine->setBackingSpeed(info[0].As().DoubleValue()); + return env.Undefined(); +} + + +} // namespace slopsmith::addon diff --git a/src/audio/addon/Bindings.h b/src/audio/addon/Bindings.h new file mode 100644 index 0000000..acc79f4 --- /dev/null +++ b/src/audio/addon/Bindings.h @@ -0,0 +1,108 @@ +#pragma once + +// Binding declarations for the split N-API handler files (TLC phase 7b): +// DeviceBindings / ControlBindings / DetectionBindings / ChainBindings / +// BackingBindings. NodeAddon.cpp registers them in its export table. + +#include + +class AudioEngine; +class SourceChain; + +namespace slopsmith::addon { + +// 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 SetMonitorKill(const Napi::CallbackInfo& info); +Napi::Value SetMonitorMute(const Napi::CallbackInfo& info); +Napi::Value SetMonitorMuteSuppressed(const Napi::CallbackInfo& info); +Napi::Value SetMultiBypass(const Napi::CallbackInfo& info); +Napi::Value SetNoiseGate(const Napi::CallbackInfo& info); +Napi::Value SetNoteDetectionEnabled(const Napi::CallbackInfo& info); +Napi::Value SetOutputDeviceType(const Napi::CallbackInfo& info); +Napi::Value SetPan(const Napi::CallbackInfo& info); +Napi::Value SetParameter(const Napi::CallbackInfo& info); +Napi::Value SetPostGain(const Napi::CallbackInfo& info); +Napi::Value SetRendererBus(const Napi::CallbackInfo& info); +Napi::Value SetSlotState(const Napi::CallbackInfo& info); +Napi::Value SetSourceChart(const Napi::CallbackInfo& info); +Napi::Value SetSourceInputChannel(const Napi::CallbackInfo& info); +Napi::Value SetSourceMonitorMute(const Napi::CallbackInfo& info); +Napi::Value SetSourceVerifierOffset(const Napi::CallbackInfo& info); +Napi::Value SetStreamBus(const Napi::CallbackInfo& info); +Napi::Value SetStreamBusGain(const Napi::CallbackInfo& info); +Napi::Value SetStreamOutputDevice(const Napi::CallbackInfo& info); +Napi::Value SetTonePolish(const Napi::CallbackInfo& info); +Napi::Value StartAudio(const Napi::CallbackInfo& info); +Napi::Value StartBacking(const Napi::CallbackInfo& info); +Napi::Value StopAudio(const Napi::CallbackInfo& info); +Napi::Value StopBacking(const Napi::CallbackInfo& info); +Napi::Value UnbindInputDevice(const Napi::CallbackInfo& info); +Napi::Value scoreChordCore(const Napi::CallbackInfo& info); +Napi::Value setChartCore(const Napi::CallbackInfo& info); + +} // namespace slopsmith::addon diff --git a/src/audio/addon/ChainBindings.cpp b/src/audio/addon/ChainBindings.cpp new file mode 100644 index 0000000..07ec06b --- /dev/null +++ b/src/audio/addon/ChainBindings.cpp @@ -0,0 +1,279 @@ +// Signal-chain slot/state/preset bindings - moved verbatim from NodeAddon.cpp (TLC phase 7b +// binding split). Registered by NodeAddon's export table via Bindings.h. + +#include "Bindings.h" + +#include "AddonContext.h" +#include "NapiHelpers.h" +#include "ChainOps.h" +#include "EditorWindows.h" +#include "../AudioEngine.h" +#include "../VSTHost.h" +#include "../VSTTrace.h" + +#include +#include +#include +#include + +namespace slopsmith::addon { + +// ── Signal Chain Management ────────────────────────────────────────────────── + +// Pending in-process loads: each LoadVSTWorker / LoadPresetWorker that's +// currently blocked on `done->wait()` registers its event here. doShutdown +// signals them all so the workers unblock and return a clean "cancelled" +// error instead of hanging forever when the JUCE message thread is about +// to be stopped (and any unfired callback would never arrive). +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 liveEngine = snapshotEngine(); + const auto slotId = slopsmith::addon::argSlotId(info, 0); + if (liveEngine && slotId) + { + std::lock_guard chainLock(slopsmith::addon::chainMutationMutex()); + liveEngine->getSignalChain().removeProcessor(*slotId); + slopsmith::addon::bumpChainGeneration(); + } + return info.Env().Undefined(); +} + +Napi::Value MoveProcessor(const Napi::CallbackInfo& info) +{ + auto liveEngine = snapshotEngine(); + const auto from = slopsmith::addon::argSlotId(info, 0); + const auto to = slopsmith::addon::argSlotId(info, 1); + if (liveEngine && from && to) + { + std::lock_guard chainLock(slopsmith::addon::chainMutationMutex()); + liveEngine->getSignalChain().moveProcessor(*from, *to); + slopsmith::addon::bumpChainGeneration(); + } + return info.Env().Undefined(); +} + +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) +{ + // Tear editors down before their processors are freed just below (#56). + closeAllPluginEditorWindows(); + if (auto liveEngine = snapshotEngine()) + { + // Serialized with the async chain workers (deep-read 1). May block + // briefly behind an in-flight preset/VST load -- that wait IS the fix + // for the interleaved clear-vs-rebuild corruption. + std::lock_guard chainLock(slopsmith::addon::chainMutationMutex()); + liveEngine->getSignalChain().clear(); + slopsmith::addon::bumpChainGeneration(); + } + return info.Env().Undefined(); +} + +// 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) + { + auto slots = liveEngine->getSignalChain().getAllSlots(); + for (int i = 0; i < slots.size(); ++i) + { + auto obj = Napi::Object::New(env); + obj.Set("id", slots[i]->id); + obj.Set("type", (int)slots[i]->type); + obj.Set("name", slots[i]->name.toStdString()); + obj.Set("path", slots[i]->path.toStdString()); + obj.Set("bypassed", slots[i]->bypassed); + obj.Set("pan", slots[i]->pan); + obj.Set("branch", slots[i]->branch); + obj.Set("branchSrc", slots[i]->branchSrc); + obj.Set("postGain", slots[i]->postGain); + obj.Set("hasEditor", slots[i]->processor && slots[i]->processor->hasEditor()); + result.Set((uint32_t)i, obj); + } + } + + return result; +} + +// ── Parameters ──────────────────────────────────────────────────────────────── + +Napi::Value GetParameters(const Napi::CallbackInfo& info) +{ + auto env = info.Env(); + auto liveEngine = snapshotEngine(); + if (!liveEngine || info.Length() < 1) return Napi::Array::New(env); + + int slotId = info[0].As().Int32Value(); + auto params = liveEngine->getSignalChain().getParameters(slotId); + auto result = Napi::Array::New(env, params.size()); + + for (int i = 0; i < params.size(); ++i) + { + auto obj = Napi::Object::New(env); + obj.Set("index", params[i].index); + obj.Set("name", params[i].name.toStdString()); + obj.Set("value", params[i].value); + obj.Set("label", params[i].label.toStdString()); + obj.Set("text", params[i].text.toStdString()); + result.Set((uint32_t)i, obj); + } + + return result; +} + +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. + auto liveEngine = snapshotEngine(); + if (liveEngine && info.Length() >= 2 && info[0].IsNumber() && info[1].IsString()) + { + int slotId = info[0].As().Int32Value(); + auto base64 = info[1].As().Utf8Value(); + const auto* slot = liveEngine->getSignalChain().getSlot(slotId); + const bool allowStandard = slot != nullptr + && (slot->type == ProcessorSlot::Type::IR + || slot->type == ProcessorSlot::Type::NAM); + juce::MemoryBlock mb; + if (decodeStateBlob(juce::String(base64), mb, allowStandard)) + liveEngine->getSignalChain().setSlotState(slotId, mb); + } + return info.Env().Undefined(); +} + +// ── Presets ─────────────────────────────────────────────────────────────────── + +Napi::Value SavePreset(const Napi::CallbackInfo& info) +{ + auto env = info.Env(); + auto liveEngine = snapshotEngine(); + if (!liveEngine) return env.Null(); + auto json = liveEngine->getSignalChain().savePreset(); + return Napi::String::New(env, json.toStdString()); +} + +Napi::Value SetMultiBypass(const Napi::CallbackInfo& info) +{ + auto env = info.Env(); + auto liveEngine = snapshotEngine(); + if (!liveEngine || info.Length() < 1 || !info[0].IsArray()) + return Napi::Boolean::New(env, false); + + auto arr = info[0].As(); + juce::Array> changes; + + for (uint32_t i = 0; i < arr.Length(); i++) + { + // Per-item type guards (deep-read §2): a malformed entry is skipped + // instead of coercing NaN to slot 0. + auto itemVal = arr.Get(i); + if (!itemVal.IsObject()) continue; + auto item = itemVal.As(); + auto slotVal = item.Get("slotId"); + auto bypVal = item.Get("bypassed"); + if (!slotVal.IsNumber() || !bypVal.IsBoolean()) continue; + const double raw = slotVal.As().DoubleValue(); + if (!std::isfinite(raw) || raw != std::floor(raw) || raw < 0.0 || raw > 4096.0) continue; + changes.add({ (int) raw, bypVal.As().Value() }); + } + + liveEngine->getSignalChain().setMultiBypass(changes); + return Napi::Boolean::New(env, true); +} + + +} // namespace slopsmith::addon diff --git a/src/audio/addon/ControlBindings.cpp b/src/audio/addon/ControlBindings.cpp new file mode 100644 index 0000000..fde10a3 --- /dev/null +++ b/src/audio/addon/ControlBindings.cpp @@ -0,0 +1,336 @@ +// Gain/metering/MIDI/debug-logging bindings - moved verbatim from NodeAddon.cpp (TLC phase 7b +// binding split). Registered by NodeAddon's export table via Bindings.h. + +#include "Bindings.h" + +#include "AddonContext.h" +#include "NapiHelpers.h" +#include "ChainOps.h" +#include "../AudioEngine.h" +#include "../VSTHost.h" +#include "../VSTTrace.h" + +#include +#include +#include +#include + +namespace slopsmith::addon { + +// ── Gain ────────────────────────────────────────────────────────────────────── + +Napi::Value SetGain(const Napi::CallbackInfo& info) +{ + auto env = info.Env(); + auto liveEngine = snapshotEngine(); + if (!liveEngine || info.Length() < 2) return env.Undefined(); + + if (!info[0].IsString()) return env.Undefined(); + auto which = info[0].As().Utf8Value(); + const auto valueOpt = slopsmith::addon::argFiniteFloat(info, 1); + if (!valueOpt) return env.Undefined(); // engine clamps range; NaN/Inf rejected here + const float value = *valueOpt; + + if (which == "input") liveEngine->setInputGain(value); + else if (which == "output") liveEngine->setOutputGain(value); + else if (which == "chain") liveEngine->setChainOutputGain(value); + else if (which == "backing") liveEngine->setBackingVolume(value); + + return env.Undefined(); +} + +Napi::Value SetInputChannel(const Napi::CallbackInfo& info) +{ + auto liveEngine = snapshotEngine(); + if (liveEngine && info.Length() > 0) + liveEngine->setInputChannel(info[0].As().Int32Value()); + return info.Env().Undefined(); +} + +Napi::Value SetMonitorMute(const Napi::CallbackInfo& info) +{ + auto liveEngine = snapshotEngine(); + if (liveEngine && info.Length() > 0) + liveEngine->setMonitorMute(info[0].As().Value()); + return info.Env().Undefined(); +} + +// setNoteDetectionEnabled(bool) -> undefined. Arms/suspends the polyphonic ML +// note-detection pipeline across all sources. The renderer (note_detect) calls +// this true only while a consumer actually reads ML notes (native-frame +// detection / non-verifier fallback) and false otherwise — the default +// harmonic-comb verifier path and the always-on home tuner leave ML suspended, +// so the engine runs no ONNX inference when nothing needs it. +Napi::Value SetNoteDetectionEnabled(const Napi::CallbackInfo& info) +{ + auto liveEngine = snapshotEngine(); + if (liveEngine && info.Length() > 0) + liveEngine->setMlNoteDetectionEnabled(info[0].As().Value()); + return info.Env().Undefined(); +} + +Napi::Value SetMonitorMuteSuppressed(const Napi::CallbackInfo& info) +{ + // IsBoolean()-guarded so a mismatched renderer build / manual caller + // passing a non-boolean is a clean no-op rather than a hard N-API failure + // (NAPI_DISABLE_CPP_EXCEPTIONS is enabled). Mirrors SetNoiseGate's style. + auto liveEngine = snapshotEngine(); + if (liveEngine && info.Length() > 0 && info[0].IsBoolean()) + liveEngine->setMonitorMuteSuppressed(info[0].As().Value()); + return info.Env().Undefined(); +} + +Napi::Value SetMonitorKill(const Napi::CallbackInfo& info) +{ + // IsBoolean()-guarded (fail-soft no-op on a downlevel/mismatched caller), + // mirroring SetMonitorMuteSuppressed. + auto liveEngine = snapshotEngine(); + if (liveEngine && info.Length() > 0 && info[0].IsBoolean()) + liveEngine->setMonitorKill(info[0].As().Value()); + return info.Env().Undefined(); +} + +Napi::Value SetNoiseGate(const Napi::CallbackInfo& info) +{ + auto env = info.Env(); + auto liveEngine = snapshotEngine(); + if (!liveEngine || info.Length() < 1 || !info[0].IsObject()) + return env.Undefined(); + + auto o = info[0].As(); + + bool enabled = false; + if (o.Has("enabled")) + { + auto v = o.Get("enabled"); + if (v.IsBoolean()) + enabled = v.As().Value(); + else if (v.IsNumber()) + enabled = v.As().DoubleValue() != 0.0; + } + + float thresholdDb = -60.0f; + if (o.Has("thresholdDb") && o.Get("thresholdDb").IsNumber()) + thresholdDb = (float)o.Get("thresholdDb").As().DoubleValue(); + + float releaseMs = 100.0f; + if (o.Has("releaseMs") && o.Get("releaseMs").IsNumber()) + releaseMs = (float)o.Get("releaseMs").As().DoubleValue(); + + float depthDb = -60.0f; + if (o.Has("depthDb") && o.Get("depthDb").IsNumber()) + depthDb = (float)o.Get("depthDb").As().DoubleValue(); + + liveEngine->setNoiseGate(enabled, thresholdDb, releaseMs, depthDb); + return env.Undefined(); +} + +Napi::Value SetTonePolish(const Napi::CallbackInfo& info) +{ + // Tone Polish — { enabled: bool }. Mirrors SetNoiseGate's defensive + // shape so a mismatched renderer build / manual caller passing a + // non-object is a clean no-op rather than a hard N-API failure + // (NAPI_DISABLE_CPP_EXCEPTIONS). + auto env = info.Env(); + auto liveEngine = snapshotEngine(); + if (!liveEngine || info.Length() < 1 || !info[0].IsObject()) + return env.Undefined(); + + auto o = info[0].As(); + + bool enabled = true; + if (o.Has("enabled")) + { + auto v = o.Get("enabled"); + if (v.IsBoolean()) + enabled = v.As().Value(); + else if (v.IsNumber()) + enabled = v.As().DoubleValue() != 0.0; + } + + liveEngine->setTonePolishEnabled(enabled); + return env.Undefined(); +} + +Napi::Value IsMonitorMuted(const Napi::CallbackInfo& info) +{ + auto liveEngine = snapshotEngine(); + return Napi::Boolean::New(info.Env(), liveEngine ? liveEngine->isMonitorMuted() : true); +} + +// ── Metering (polled — read atomics) ────────────────────────────────────────── + +Napi::Value GetLevels(const Napi::CallbackInfo& info) +{ + auto env = info.Env(); + auto obj = Napi::Object::New(env); + auto liveEngine = snapshotEngine(); + + if (liveEngine) + { + obj.Set("inputLevel", liveEngine->getInputLevel()); + obj.Set("outputLevel", liveEngine->getOutputLevel()); + obj.Set("inputPeak", liveEngine->getInputPeak()); + obj.Set("outputPeak", liveEngine->getOutputPeak()); + } + else + { + obj.Set("inputLevel", 0.0); + obj.Set("outputLevel", 0.0); + obj.Set("inputPeak", 0.0); + obj.Set("outputPeak", 0.0); + } + + return obj; +} + +// getSourceLevels(sourceId) -> { inputLevel, inputPeak, outputLevel, outputPeak }. +// Per-source INPUT level so a bound detector's silence gate reads ITS OWN device's +// signal (not the global/primary level — which would force-fail every hit on an +// extra device the user is actually playing). Output fields mirror the master and +// are 0 (monitoring is post-mix / engine-global). Bad id -> all zeros. +Napi::Value GetSourceLevels(const Napi::CallbackInfo& info) +{ + auto env = info.Env(); + auto obj = Napi::Object::New(env); + auto liveEngine = snapshotEngine(); + SourceChain* s = (liveEngine && info.Length() >= 1 && info[0].IsNumber()) + ? getValidatedSource(liveEngine.get(), info, 0) : nullptr; + obj.Set("inputLevel", s ? (double) s->getInputLevel() : 0.0); + obj.Set("inputPeak", s ? (double) s->getInputPeak() : 0.0); + obj.Set("outputLevel", 0.0); + obj.Set("outputPeak", 0.0); + return obj; +} + +Napi::Value ResetPeaks(const Napi::CallbackInfo& info) +{ + if (auto liveEngine = snapshotEngine()) liveEngine->resetPeaks(); + return info.Env().Undefined(); +} + +// Backing-track mix bus RMS level — the engine's per-block running RMS after +// the backing volume fader but before the output-gain master. Returns 0.0 when +// the engine is unavailable or no backing track is loaded. Reads an atomic so +// it is safe to call from the JS thread without blocking the audio thread. +Napi::Value GetBackingLevel(const Napi::CallbackInfo& info) +{ + auto liveEngine = snapshotEngine(); + return Napi::Number::New(info.Env(), liveEngine ? liveEngine->getBackingLevel() : 0.0f); +} + +// ── MIDI ────────────────────────────────────────────────────────────────────── + +Napi::Value SendMidiToSlot(const Napi::CallbackInfo& info) +{ + auto env = info.Env(); + auto liveEngine = snapshotEngine(); + if (!liveEngine || info.Length() < 4) + return Napi::Boolean::New(env, false); + + // Typed + range-checked: unclamped channel/program used to trip JUCE + // assertions (deep-read §2). Out-of-range now returns false cleanly. + const auto slotId = slopsmith::addon::argSlotId(info, 0); + const auto msgType = slopsmith::addon::argInt(info, 1, 0, 1); + const auto channel = slopsmith::addon::argMidiChannel(info, 2); + if (!slotId || !msgType || !channel) + return Napi::Boolean::New(env, false); + + juce::MidiMessage midiMsg; + if (*msgType == 0) // Program Change + { + const auto program = slopsmith::addon::argMidiByte(info, 3); + if (!program) return Napi::Boolean::New(env, false); + midiMsg = juce::MidiMessage::programChange(*channel, *program); + } + else // Control Change + { + const auto controller = slopsmith::addon::argMidiByte(info, 3); + if (!controller) return Napi::Boolean::New(env, false); + const auto value = slopsmith::addon::argMidiByte(info, 4); + midiMsg = juce::MidiMessage::controllerEvent(*channel, *controller, value.value_or(0)); + } + + liveEngine->getSignalChain().queueMidiMessage(*slotId, midiMsg); + return Napi::Boolean::New(env, true); +} + +// ── Debug file logging ──────────────────────────────────────────────────────── + +// Redirect the C runtime's stderr stream to a file so the native +// [AudioEngine] / [audio-native] diagnostics are captured for a bug report on +// machines with no console (packaged Windows builds). Only invoked when +// SLOPSMITH_DEBUG is set. Returns "" on success, or an error description the +// JS layer logs as an [audio] line. +// +// freopen (not dup2): a packaged GUI-subsystem app has no console, so stderr +// has no valid fd — dup2 onto fileno(stderr) fails. freopen reassigns the +// stream itself and works with or without a console. freopen would close +// stderr before trying the path, so a bad path is ruled out FIRST with a +// throwaway fopen probe (which never touches stderr); only once the path is +// known-writable do we freopen. Append mode so the JS layer's header +// survives; unbuffered so a crash leaves a complete tail. +Napi::Value EnableFileLogging(const Napi::CallbackInfo& info) +{ + auto env = info.Env(); + if (info.Length() < 1 || !info[0].IsString()) + { + Napi::TypeError::New(env, "enableFileLogging(path) requires a string") + .ThrowAsJavaScriptException(); + return env.Undefined(); + } + +#if defined(_WIN32) + // Widen UTF-16 → wchar_t by value-converting each code unit (not a + // reinterpret_cast — char16_t and wchar_t are distinct types even though + // both are 16-bit on Windows). Wide path so a profile dir with non-ASCII + // characters isn't mangled by the ANSI codepage (cf. src/vst-host/main.cpp, + // which uses the GetEnvironmentVariableW / _wfopen wide path for the same + // reason). + const std::u16string u16 = info[0].As().Utf16Value(); + const std::wstring wpath(u16.begin(), u16.end()); + FILE* probe = _wfopen(wpath.c_str(), L"a"); +#else + const std::string path = info[0].As().Utf8Value(); + FILE* probe = std::fopen(path.c_str(), "a"); +#endif + if (probe == nullptr) + { + // Capture errno before Napi::String::New / std::to_string, which may + // call library code that clobbers it. + const int e = errno; + return Napi::String::New(env, std::string("fopen failed (errno=") + + std::to_string(e) + ")"); + } + std::fclose(probe); // path is writable; stderr never touched on this path + +#if defined(_WIN32) + FILE* fp = _wfreopen(wpath.c_str(), L"a", stderr); +#else + FILE* fp = std::freopen(path.c_str(), "a", stderr); +#endif + if (fp == nullptr) + { + const int e = errno; + // freopen closes stderr before trying the path; on failure it's left + // closed. The probe just verified the path, so this is near-impossible + // — but redirect stderr to the null device so it's a valid sink rather + // than a closed stream that could trip later fprintf(stderr) calls. +#if defined(_WIN32) + std::freopen("NUL", "w", stderr); +#else + std::freopen("/dev/null", "w", stderr); +#endif + return Napi::String::New(env, std::string("freopen failed (errno=") + + std::to_string(e) + ")"); + } + + // Unbuffered: each [AudioEngine] fprintf hits disk immediately, so a + // crash mid-reconfigure still leaves the diagnostic line that explains it. + std::setvbuf(stderr, nullptr, _IONBF, 0); + std::fprintf(stderr, "[audio-native] file logging enabled\n"); + return Napi::String::New(env, ""); // empty = success +} + + +} // namespace slopsmith::addon diff --git a/src/audio/addon/DetectionBindings.cpp b/src/audio/addon/DetectionBindings.cpp new file mode 100644 index 0000000..e82aeb8 --- /dev/null +++ b/src/audio/addon/DetectionBindings.cpp @@ -0,0 +1,589 @@ +// Pitch detection + source-indexed bindings - moved verbatim from NodeAddon.cpp (TLC phase 7b +// binding split). Registered by NodeAddon's export table via Bindings.h. + +#include "Bindings.h" + +#include "AddonContext.h" +#include "NapiHelpers.h" +#include "ChainOps.h" +#include "../AudioEngine.h" +#include "../VSTHost.h" +#include "../VSTTrace.h" + +#include +#include +#include +#include + +namespace slopsmith::addon { + +// Validate a JS source-id argument and return the live source, or nullptr if it is +// missing / not a Number / not a FINITE INTEGER / out of range. The TS bridge already +// validates, but the addon must fail soft on its own: Int32Value() silently coerces +// NaN/Infinity into a valid index (NaN -> 0), which would let a malformed id hit a +// real source (e.g. the default source 0). getSource() does the final +// [0, kMaxSources) + active check; the 4096 guard keeps the cast well-defined. +SourceChain* getValidatedSource(AudioEngine* eng, const Napi::CallbackInfo& info, size_t argIndex) +{ + if (eng == nullptr || argIndex >= info.Length() || ! info[argIndex].IsNumber()) + return nullptr; + const double raw = info[argIndex].As().DoubleValue(); + if (! std::isfinite(raw) || raw != std::floor(raw) || raw < 0.0 || raw > 4096.0) + return nullptr; + return eng->getSource((int) raw); +} + +// ── Pitch Detection (polled) ────────────────────────────────────────────────── + +// Load the Basic Pitch ONNX model for the polyphonic ML note detector. +// Called once at startup by audio-bridge.ts with the bundled model path. +// Never throws. Returns "is ML note detection available after this call" — +// a model is loaded with a valid contract. A missing/invalid file does NOT +// tear down an already-loaded model, so it can still return true; it returns +// false when the engine isn't ready or ONNX support isn't compiled in, and +// the engine then keeps using the YIN PitchDetector / ChordScorer +// (Constitution VII). +Napi::Value LoadNoteModel(const Napi::CallbackInfo& info) +{ + auto env = info.Env(); + auto liveEngine = snapshotEngine(); + if (!liveEngine || info.Length() < 1 || !info[0].IsString()) + return Napi::Boolean::New(env, false); + + const auto path = info[0].As().Utf8Value(); + const bool ok = liveEngine->loadNoteModel(juce::File(juce::String(path))); + return Napi::Boolean::New(env, ok); +} + +// Whether the ML note detector is active (ONNX support compiled in AND a +// model loaded). Lets the renderer / tests tell the ML path from the YIN +// fallback without inferring it from behaviour. +Napi::Value IsMlNoteDetection(const Napi::CallbackInfo& info) +{ + auto env = info.Env(); + // Report readiness, not just model-loaded: the engine only routes + // getPitchDetection()/scoreChord() to ML once the detector has published + // its first snapshot (isReady()). Reporting true during the cold-start + // window would tell the renderer "ML active" while it's still getting the + // YIN fallback. + auto liveEngine = snapshotEngine(); + return Napi::Boolean::New(env, + liveEngine && liveEngine->hasMlNoteDetector() + && liveEngine->getMlNoteDetector().isReady()); +} + +// Raw polyphonic transcription from the ML note detector — the full set of +// currently-active pitches, not just the dominant one. Returns +// `{ notes: [{ midi, confidence, onsetMs, onsetSeq }], sampleRate }`, or null when the ML +// detector isn't active (no model / ONNX support) so the renderer can feature- +// detect and fall back. Never throws. +Napi::Value DetectNotes(const Napi::CallbackInfo& info) +{ + auto env = info.Env(); + // Gate on isReady(): the contract is that callers get null whenever the + // ML detector isn't actively producing notes. isReady() is false with no + // model, after a device stop, and during the cold-start window before the + // first inference publishes — so the renderer feature-detects correctly + // and falls back instead of consuming an empty ML stream. + auto liveEngine = snapshotEngine(); + if (!liveEngine || !liveEngine->getMlNoteDetector().isReady()) + return env.Null(); + + const auto active = liveEngine->getMlNoteDetector().getActiveNotes(); + auto notesArr = Napi::Array::New(env, active.size()); + for (size_t i = 0; i < active.size(); ++i) + { + auto entry = Napi::Object::New(env); + entry.Set("midi", active[i].midi); + entry.Set("confidence", active[i].confidence); + // Milliseconds since this pitch's onset — lets the renderer back-date + // a detection to the true onset instead of poll time. + entry.Set("onsetMs", active[i].onsetAgeMs); + // Monotonic per-pitch onset counter — a change means a new note was + // struck, so the renderer can consume onsets as discrete events. + entry.Set("onsetSeq", active[i].onsetSeq); + notesArr.Set((uint32_t) i, entry); + } + + auto obj = Napi::Object::New(env); + obj.Set("notes", notesArr); + // Normalise the sample rate: getCurrentSampleRate() is 0 when no audio + // device is active — hand the renderer a sane positive value so its + // Hz/time math can't divide by zero. + const double sr = liveEngine->getCurrentSampleRate(); + obj.Set("sampleRate", sr > 0.0 ? sr : 48000.0); + return obj; +} + +Napi::Value GetPitchDetection(const Napi::CallbackInfo& info) +{ + auto env = info.Env(); + auto obj = Napi::Object::New(env); + auto liveEngine = snapshotEngine(); + + if (liveEngine) + { + // getActiveDetection() returns the polyphonic ML detector's dominant + // pitch when a Basic Pitch model is loaded, else the YIN detector's + // latest result — same shape either way, so the plugin is unchanged. + auto det = liveEngine->getActiveDetection(); + obj.Set("frequency", det.frequency); + obj.Set("confidence", det.confidence); + obj.Set("midiNote", det.midiNote); + obj.Set("cents", det.cents); + obj.Set("noteName", det.noteName.toStdString()); + } + else + { + obj.Set("frequency", -1.0); + obj.Set("confidence", 0.0); + obj.Set("midiNote", -1); + obj.Set("cents", 0.0); + obj.Set("noteName", ""); + } + + return obj; +} + +Napi::Value GetRawPitchDetection(const Napi::CallbackInfo& info) +{ + auto env = info.Env(); + auto obj = Napi::Object::New(env); + auto liveEngine = snapshotEngine(); + + if (liveEngine) + { + // Always the raw YIN detection — bypasses the ML preference so frequency + // stays continuous (sub-Hz) and cents stays real even with a model loaded. + // Backs the tuner's audio:getRawPitch endpoint. + auto det = liveEngine->getRawPitchDetection(); + obj.Set("frequency", det.frequency); + obj.Set("confidence", det.confidence); + obj.Set("midiNote", det.midiNote); + obj.Set("cents", det.cents); + obj.Set("noteName", det.noteName.toStdString()); + } + else + { + obj.Set("frequency", -1.0); + obj.Set("confidence", 0.0); + obj.Set("midiNote", -1); + obj.Set("cents", 0.0); + obj.Set("noteName", ""); + } + + return obj; +} + +Napi::Value GetRawAudioFrame(const Napi::CallbackInfo& info) +{ + auto env = info.Env(); + auto liveEngine = snapshotEngine(); + + // Optional sample count; defaults to AudioEngine::getRawAudioFrame's 4096. + // The engine clamps anything above its ring capacity. + int numSamples = 4096; + if (info.Length() > 0 && info[0].IsNumber()) + numSamples = info[0].As().Int32Value(); + + if (!liveEngine || numSamples <= 0) + return Napi::Float32Array::New(env, 0); + + // Post-gate mono snapshot for the tuner's own pitch pipeline. Returns a + // Float32Array of the most-recent N samples (left-zero-padded on cold start). + auto frame = liveEngine->getRawAudioFrame(numSamples); + auto out = Napi::Float32Array::New(env, frame.size()); + float* dst = out.Data(); + for (size_t i = 0; i < frame.size(); ++i) + dst[i] = frame[i]; + return out; +} + +// Score a polyphonic chord against the engine's most recent input +// samples. Renderer (notedetect plugin's matchNotes chord branch) +// supplies the chord context — chart notes plus tuning/arrangement +// metadata — and gets back a `{score, hitStrings, totalStrings, isHit, +// results[]}` object identical in shape to what the JS implementation +// produced. Audio never crosses the N-API boundary, which is the +// whole reason for moving the math here: constitution II says audio +// analysis lives in JUCE, and this is the missing piece. +// +// Request shape. Fields marked `required` must be present and +// internally consistent — the C++ scorer fails closed (all-miss +// result with one entry per requested note) when the validation +// invariants don't hold, rather than silently substituting defaults. +// { +// notes: [{ s, f, ho?, po?, b?, sl?, hm? }, ...], +// // required, each `s` must be in [0, stringCount) +// arrangement?: 'guitar'|'bass', // default 'guitar' — must be one of these two strings +// stringCount?: number, // default 6 — must match the (arrangement, stringCount) +// // table: bass{4,5} or guitar{6,7,8} +// offsets: number[], // required, length must equal stringCount. +// // Pass an array of zeros for standard tuning; +// // the default of `stringCount = 6` only works +// // if you supply 6 offsets. +// numSamples?: number, // analysis window (default 4096, capped at the +// // engine input-ring capacity, currently 8192) +// capo?: number, // default 0 +// pitchCheckCents?: number, // 0 = energy-only chord check (default 0) +// minHitRatio?: number, // default 0.6 +// bypassMl?: boolean, // force the DSP band-energy scorer even +// // when an ML model is loaded (default false) +// harmonicVerify?: boolean, // score each note by harmonic-comb energy +// // (f,2f..5f vs the floor between) instead +// // of band-energy/total (default false) +// harmonicSnr?: number, // min harmonic-to-floor ratio for a hit +// // when harmonicVerify is set (default 3.0) +// fundamentalRatio?: number, // fundamental-presence gate: reject when +// // f0 peak < ratio*strongest partial; lower +// // for bass, <=0 disables (default 0.20) +// } +// Shared core: parse `reqObj` into a ChordScorer::Request and score it against +// `target`'s input ring. `target` is sources[0] for the legacy scoreChord and +// getSource(id) for the source-indexed scoreSourceChord. +Napi::Value scoreChordCore(Napi::Env env, Napi::Object reqObj, SourceChain* target) +{ + // Hard caps on caller-controlled array lengths. The scorer's + // (arrangement, stringCount) validation only accepts up to 8 + // strings; chord-notes have a natural ceiling at the same value + // (one per string). 32 is a generous headroom that still bounds + // worst-case allocations the renderer could trigger over IPC — + // without these limits, a malformed/malicious payload claiming a + // gigantic JS array length would force a multi-GB reserve before + // the scorer's own validation rejected the request. A request + // that exceeds either cap is treated as outright malformed and + // returns the "no chord requested" failure shape (totalStrings=0); + // every other validation failure goes through the all-miss path + // below so results[] stays in lockstep with notes[]. + static constexpr uint32_t kMaxOffsets = 32; + static constexpr uint32_t kMaxNotes = 32; + + auto noRequestFailure = [&env]() { + auto failure = Napi::Object::New(env); + failure.Set("score", 0.0); + failure.Set("hitStrings", 0); + failure.Set("totalStrings", 0); + failure.Set("isHit", false); + failure.Set("results", Napi::Array::New(env, 0)); + return failure; + }; + + // Capture the notes array up front so every downstream failure + // path can build a per-note all-miss result aligned 1:1 with the + // caller's notes[]. Pre-cap check happens before we even read the + // length into the helper to prevent a payload claiming an enormous + // length from forcing the helper to allocate a huge results array. + Napi::Value notesVal = reqObj.Has("notes") ? reqObj.Get("notes") : env.Null(); + if (!notesVal.IsArray()) return noRequestFailure(); + auto notesArr = notesVal.As(); + if (notesArr.Length() > kMaxNotes) return noRequestFailure(); + const uint32_t noteCount = notesArr.Length(); + + // All-miss result aligned with the caller's notes[]. Walks the + // original JS array so the per-note `s` / `f` echo back in the + // result even when the request fails validation (lets the renderer + // distinguish "this string missed" from "this string wasn't sent"). + // Used by every failure path below except the cap/no-notes case + // above, which doesn't have a coherent notes[] to mirror. + auto buildAllMiss = [&]() { + auto resultsArr = Napi::Array::New(env, noteCount); + for (uint32_t i = 0; i < noteCount; ++i) + { + int s = -1, f = -1; + auto v = notesArr.Get(i); + if (v.IsObject()) + { + auto o = v.As(); + if (o.Has("s") && o.Get("s").IsNumber()) + s = o.Get("s").As().Int32Value(); + if (o.Has("f") && o.Get("f").IsNumber()) + f = o.Get("f").As().Int32Value(); + } + auto entry = Napi::Object::New(env); + entry.Set("s", s); + entry.Set("f", f); + entry.Set("hit", false); + entry.Set("bandEnergy", 0.0); + entry.Set("centsDiff", env.Null()); + entry.Set("centsError", env.Null()); + resultsArr.Set(i, entry); + } + auto out = Napi::Object::New(env); + out.Set("score", 0.0); + out.Set("hitStrings", 0); + out.Set("totalStrings", (int) noteCount); + out.Set("isHit", false); + out.Set("results", resultsArr); + return out; + }; + + ChordScorer::Request req; + if (reqObj.Has("numSamples") && reqObj.Get("numSamples").IsNumber()) + req.numSamples = reqObj.Get("numSamples").As().Int32Value(); + if (reqObj.Has("arrangement") && reqObj.Get("arrangement").IsString()) + req.arrangement = reqObj.Get("arrangement").As().Utf8Value(); + if (reqObj.Has("stringCount") && reqObj.Get("stringCount").IsNumber()) + req.stringCount = reqObj.Get("stringCount").As().Int32Value(); + if (reqObj.Has("capo") && reqObj.Get("capo").IsNumber()) + req.capo = reqObj.Get("capo").As().Int32Value(); + if (reqObj.Has("pitchCheckCents") && reqObj.Get("pitchCheckCents").IsNumber()) + req.pitchCheckCents = reqObj.Get("pitchCheckCents").As().FloatValue(); + if (reqObj.Has("minHitRatio") && reqObj.Get("minHitRatio").IsNumber()) + req.minHitRatio = reqObj.Get("minHitRatio").As().FloatValue(); + if (reqObj.Has("bypassMl") && reqObj.Get("bypassMl").IsBoolean()) + req.bypassMl = reqObj.Get("bypassMl").As().Value(); + if (reqObj.Has("harmonicVerify") && reqObj.Get("harmonicVerify").IsBoolean()) + req.harmonicVerify = reqObj.Get("harmonicVerify").As().Value(); + if (reqObj.Has("harmonicSnr") && reqObj.Get("harmonicSnr").IsNumber()) + req.harmonicSnr = reqObj.Get("harmonicSnr").As().FloatValue(); + if (reqObj.Has("fundamentalRatio") && reqObj.Get("fundamentalRatio").IsNumber()) + { + // Drop NaN/Inf: a non-finite ratio poisons the fundamental-presence + // gate (fundMag >= NaN is always false -> every note false-rejected). + // Keep the safe 0.20 default instead. + const float v = reqObj.Get("fundamentalRatio").As().FloatValue(); + if (std::isfinite(v)) req.fundamentalRatio = v; + } + + if (reqObj.Has("offsets") && reqObj.Get("offsets").IsArray()) + { + auto arr = reqObj.Get("offsets").As(); + if (arr.Length() > kMaxOffsets) return noRequestFailure(); + req.tuningOffsets.reserve(arr.Length()); + for (uint32_t i = 0; i < arr.Length(); ++i) + { + auto v = arr.Get(i); + // Tuning offsets materially shift expected pitch — silently + // substituting 0 for a missing/non-numeric entry would + // produce confidently wrong scores. Fail closed with the + // per-note all-miss shape so the renderer sees the right + // results[] length even when the request is malformed. + if (!v.IsNumber()) return buildAllMiss(); + req.tuningOffsets.push_back(v.As().Int32Value()); + } + } + + req.notes.reserve(noteCount); + for (uint32_t i = 0; i < noteCount; ++i) + { + auto v = notesArr.Get(i); + // For malformed entries (non-object, or missing/non-numeric + // s/f) push a sentinel Note with string = -1. This keeps + // req.notes.size() in lockstep with the incoming notes[] + // length AND guarantees ChordScorer's range check + // (`n.string < 0 || n.string >= stringCount`) trips on the + // sentinel — yielding the same all-miss fail-closed result + // the shape contract advertises, never a false hit on the + // default low-string position. + ChordScorer::Note n{}; + n.string = -1; + n.fret = -1; + if (!v.IsObject()) + { + req.notes.push_back(n); + continue; + } + auto noteObj = v.As(); + const bool hasS = noteObj.Has("s") && noteObj.Get("s").IsNumber(); + const bool hasF = noteObj.Has("f") && noteObj.Get("f").IsNumber(); + if (!hasS || !hasF) + { + req.notes.push_back(n); + continue; + } + n.string = noteObj.Get("s").As().Int32Value(); + n.fret = noteObj.Get("f").As().Int32Value(); + // Technique flags are truthy/falsy in JS; coerce to bool + // here so an unset value cleanly becomes false. + auto truthy = [¬eObj](const char* key) { + if (!noteObj.Has(key)) return false; + auto val = noteObj.Get(key); + return val.ToBoolean().Value(); + }; + n.hammerOn = truthy("ho"); + n.pullOff = truthy("po"); + n.bend = truthy("b"); + n.slide = truthy("sl"); + n.harmonic = truthy("hm"); + req.notes.push_back(n); + } + + auto result = target->scoreChord(req); + + auto out = Napi::Object::New(env); + out.Set("score", result.score); + out.Set("hitStrings", result.hitStrings); + out.Set("totalStrings", result.totalStrings); + out.Set("isHit", result.isHit); + auto resultsArr = Napi::Array::New(env, result.results.size()); + for (size_t i = 0; i < result.results.size(); ++i) + { + const auto& r = result.results[i]; + auto entry = Napi::Object::New(env); + entry.Set("s", r.string); + entry.Set("f", r.fret); + entry.Set("hit", r.hit); + entry.Set("bandEnergy", r.bandEnergy); + // Mirror the JS result shape: when cents weren't measured the + // fields are present-but-null so the renderer can distinguish + // "no pitch check ran" (null) from "pitch check said 0" + // (numeric 0). + if (r.hasCents) + { + entry.Set("centsDiff", r.centsDiff); + entry.Set("centsError", r.centsError); + } + else + { + entry.Set("centsDiff", env.Null()); + entry.Set("centsError", env.Null()); + } + resultsArr.Set(i, entry); + } + out.Set("results", resultsArr); + return out; +} + +// Legacy: scoreChord(req) — targets sources[0]. Backward-compatible. +Napi::Value ScoreChord(const Napi::CallbackInfo& info) +{ + auto env = info.Env(); + auto liveEngine = snapshotEngine(); + auto noRequestFailure = [&env]() { + auto failure = Napi::Object::New(env); + failure.Set("score", 0.0); + failure.Set("hitStrings", 0); + failure.Set("totalStrings", 0); + failure.Set("isHit", false); + failure.Set("results", Napi::Array::New(env, 0)); + return failure; + }; + if (!liveEngine || info.Length() < 1 || !info[0].IsObject()) + return noRequestFailure(); + return scoreChordCore(env, info[0].As(), liveEngine->getSource(0)); +} + +// Source-indexed: scoreSourceChord(sourceId, req). Bad id / payload -> the +// same "no chord requested" failure shape (totalStrings=0). +Napi::Value ScoreSourceChord(const Napi::CallbackInfo& info) +{ + auto env = info.Env(); + auto liveEngine = snapshotEngine(); + auto noRequestFailure = [&env]() { + auto failure = Napi::Object::New(env); + failure.Set("score", 0.0); + failure.Set("hitStrings", 0); + failure.Set("totalStrings", 0); + failure.Set("isHit", false); + failure.Set("results", Napi::Array::New(env, 0)); + return failure; + }; + if (!liveEngine || info.Length() < 2 || !info[0].IsNumber() || !info[1].IsObject()) + return noRequestFailure(); + SourceChain* target = getValidatedSource(liveEngine.get(), info, 0); + if (!target) return noRequestFailure(); + return scoreChordCore(env, info[1].As(), target); +} + +// ── Multi-input source management bridge ───────────────────────────────────── +// A source is one independent input chain (own arrangement chart, detection, +// scoring, tone, monitor). sources[0] always exists. The renderer adds a source +// per extra player, binds it to an input channel, and drives its scoring via the +// *Source* methods below; the legacy un-suffixed methods keep targeting source 0. + +// addSource(inputChannel?) -> sourceId (number), or -1 if the pool is full. +Napi::Value AddSource(const Napi::CallbackInfo& info) +{ + auto env = info.Env(); + auto liveEngine = snapshotEngine(); + if (!liveEngine) return Napi::Number::New(env, -1); + int channel = -1; // default: mono mix of the first pair + if (info.Length() > 0 && info[0].IsNumber()) + channel = info[0].As().Int32Value(); + int deviceKey = 0; // default: primary input device + if (info.Length() > 1 && info[1].IsNumber()) + { + const int k = info[1].As().Int32Value(); + if (k >= 0) deviceKey = k; // negatives ignored → primary + } + return Napi::Number::New(env, liveEngine->addSource(channel, deviceKey)); +} + +// removeSource(sourceId) -> boolean. sources[0] cannot be removed. +Napi::Value RemoveSource(const Napi::CallbackInfo& info) +{ + auto env = info.Env(); + auto liveEngine = snapshotEngine(); + if (!liveEngine || info.Length() < 1 || !info[0].IsNumber()) + return Napi::Boolean::New(env, false); + return Napi::Boolean::New(env, liveEngine->removeSource(info[0].As().Int32Value())); +} + +// listSources() -> [{ id, inputChannel, active }]. Null on a missing engine. +Napi::Value ListSources(const Napi::CallbackInfo& info) +{ + auto env = info.Env(); + auto liveEngine = snapshotEngine(); + if (!liveEngine) return env.Null(); + const auto sources = liveEngine->listSources(); + auto arr = Napi::Array::New(env, sources.size()); + for (size_t i = 0; i < sources.size(); ++i) + { + auto entry = Napi::Object::New(env); + entry.Set("id", sources[i].id); + entry.Set("inputChannel", sources[i].inputChannel); + entry.Set("deviceKey", sources[i].deviceKey); + entry.Set("active", sources[i].active); + arr.Set((uint32_t) i, entry); + } + return arr; +} + +// listInputDevices() -> [{ typeName, name }]. Every available capture device the +// renderer can bind to an additional engine input via bindInputDevice. Null on a +// missing engine. +Napi::Value ListInputDevices(const Napi::CallbackInfo& info) +{ + auto env = info.Env(); + auto liveEngine = snapshotEngine(); + if (!liveEngine) return env.Null(); + const auto devices = liveEngine->getBindableInputDevices(); + auto arr = Napi::Array::New(env); + uint32_t n = 0; + for (const auto& d : devices) + { + auto entry = Napi::Object::New(env); + entry.Set("typeName", d.typeName.toStdString()); + entry.Set("name", d.name.toStdString()); + arr.Set(n++, entry); + } + return arr; +} + +// bindInputDevice(deviceKey, deviceName) -> "" on success, else an error string. +// Opens an ADDITIONAL physical input device (deviceKey 1..N) so sources created +// with addSource(channel, deviceKey) capture from it at its own clock. +Napi::Value BindInputDevice(const Napi::CallbackInfo& info) +{ + auto env = info.Env(); + auto liveEngine = snapshotEngine(); + if (!liveEngine) return Napi::String::New(env, "no engine"); + if (info.Length() < 2 || !info[0].IsNumber() || !info[1].IsString()) + return Napi::String::New(env, "bindInputDevice(deviceKey:number, deviceName:string)"); + const int deviceKey = info[0].As().Int32Value(); + const std::string name = info[1].As().Utf8Value(); + return Napi::String::New(env, liveEngine->bindInputDevice(deviceKey, name).toStdString()); +} + +// unbindInputDevice(deviceKey) -> boolean. Stops + releases the extra device. +Napi::Value UnbindInputDevice(const Napi::CallbackInfo& info) +{ + auto env = info.Env(); + auto liveEngine = snapshotEngine(); + if (!liveEngine || info.Length() < 1 || !info[0].IsNumber()) + return Napi::Boolean::New(env, false); + return Napi::Boolean::New(env, liveEngine->unbindInputDevice(info[0].As().Int32Value())); +} + + +} // namespace slopsmith::addon diff --git a/src/audio/addon/DeviceBindings.cpp b/src/audio/addon/DeviceBindings.cpp new file mode 100644 index 0000000..0f2fb78 --- /dev/null +++ b/src/audio/addon/DeviceBindings.cpp @@ -0,0 +1,834 @@ +// Device enumeration/selection/control + stream sink bindings - moved verbatim from NodeAddon.cpp (TLC phase 7b +// binding split). Registered by NodeAddon's export table via Bindings.h. + +#include "Bindings.h" + +#include "AddonContext.h" +#include "NapiHelpers.h" +#include "ChainOps.h" +#include "../AudioEngine.h" +#include "../VSTHost.h" +#include "../VSTTrace.h" + +#include +#include +#include +#include + +namespace slopsmith::addon { + +// ── Device Enumeration ──────────────────────────────────────────────────────── + +Napi::Value GetDeviceTypes(const Napi::CallbackInfo& info) +{ + auto env = info.Env(); + auto liveEngine = snapshotEngine(); + if (!liveEngine) return env.Null(); + + // Device types are already scanned during init — safe to read from any thread + auto types = liveEngine->getDeviceTypes(); + + auto result = Napi::Array::New(env, types.size()); + + for (int i = 0; i < types.size(); ++i) + { + auto obj = Napi::Object::New(env); + obj.Set("name", types[i].name.toStdString()); + + auto inputs = Napi::Array::New(env, types[i].inputDevices.size()); + for (int j = 0; j < types[i].inputDevices.size(); ++j) + inputs.Set((uint32_t)j, types[i].inputDevices[j].toStdString()); + obj.Set("inputs", inputs); + + auto outputs = Napi::Array::New(env, types[i].outputDevices.size()); + for (int j = 0; j < types[i].outputDevices.size(); ++j) + outputs.Set((uint32_t)j, types[i].outputDevices[j].toStdString()); + obj.Set("outputs", outputs); + + result.Set((uint32_t)i, obj); + } + + return result; +} + +Napi::Value GetSampleRates(const Napi::CallbackInfo& info) +{ + auto env = info.Env(); + auto liveEngine = snapshotEngine(); + if (!liveEngine) return Napi::Array::New(env); + + auto rates = liveEngine->getSampleRates(); + auto result = Napi::Array::New(env, rates.size()); + for (int i = 0; i < rates.size(); ++i) + result.Set((uint32_t)i, rates[i]); + return result; +} + +Napi::Value GetBufferSizes(const Napi::CallbackInfo& info) +{ + auto env = info.Env(); + auto liveEngine = snapshotEngine(); + if (!liveEngine) return Napi::Array::New(env); + + auto sizes = liveEngine->getBufferSizes(); + auto result = Napi::Array::New(env, sizes.size()); + for (int i = 0; i < sizes.size(); ++i) + result.Set((uint32_t)i, sizes[i]); + return result; +} + +Napi::Value ProbeDeviceOptions(const Napi::CallbackInfo& info) +{ + auto env = info.Env(); + auto liveEngine = snapshotEngine(); + auto obj = Napi::Object::New(env); + // 3-arg legacy (type, input, output) or 4-arg dual (inputType, input, outputType, output). + auto arg0 = info.Length() > 0 && info[0].IsString() ? info[0].As().Utf8Value() : ""; + auto arg1 = info.Length() > 1 && info[1].IsString() ? info[1].As().Utf8Value() : ""; + auto arg2 = info.Length() > 2 && info[2].IsString() ? info[2].As().Utf8Value() : ""; + auto arg3 = info.Length() > 3 && info[3].IsString() ? info[3].As().Utf8Value() : ""; + + std::string inputType = arg0; + std::string inputName = arg1; + std::string outputType; + std::string outputName; + if (info.Length() >= 4) + { + outputType = arg2; + outputName = arg3; + } + else + { + outputType = arg0; + outputName = arg2; + } + + auto ratesArray = Napi::Array::New(env); + auto buffersArray = Napi::Array::New(env); + auto inputChannelsArray = Napi::Array::New(env); + auto outputChannelsArray = Napi::Array::New(env); + + obj.Set("type", inputType); + obj.Set("inputType", inputType); + obj.Set("outputType", outputType); + obj.Set("input", inputName); + obj.Set("output", outputName); + obj.Set("inputChannels", inputChannelsArray); + obj.Set("outputChannels", outputChannelsArray); + obj.Set("sampleRates", ratesArray); + obj.Set("bufferSizes", buffersArray); + obj.Set("compatible", true); + if (!liveEngine) + { + obj.Set("error", "Audio engine not initialized"); + obj.Set("compatible", false); + return obj; + } + + auto options = liveEngine->probeDeviceOptionsDual( + juce::String(inputType), juce::String(inputName), + juce::String(outputType), juce::String(outputName)); + obj.Set("type", options.inputType.toStdString()); // legacy alias + obj.Set("inputType", options.inputType.toStdString()); + obj.Set("outputType", options.outputType.toStdString()); + obj.Set("input", options.input.toStdString()); + obj.Set("output", options.output.toStdString()); + obj.Set("error", options.error.toStdString()); + obj.Set("compatible", options.compatible); + + inputChannelsArray = Napi::Array::New(env, options.inputChannels.size()); + for (int i = 0; i < options.inputChannels.size(); ++i) + inputChannelsArray.Set((uint32_t)i, options.inputChannels[i].toStdString()); + obj.Set("inputChannels", inputChannelsArray); + + outputChannelsArray = Napi::Array::New(env, options.outputChannels.size()); + for (int i = 0; i < options.outputChannels.size(); ++i) + outputChannelsArray.Set((uint32_t)i, options.outputChannels[i].toStdString()); + obj.Set("outputChannels", outputChannelsArray); + + ratesArray = Napi::Array::New(env, options.sampleRates.size()); + for (int i = 0; i < options.sampleRates.size(); ++i) + ratesArray.Set((uint32_t)i, options.sampleRates[i]); + obj.Set("sampleRates", ratesArray); + + buffersArray = Napi::Array::New(env, options.bufferSizes.size()); + for (int i = 0; i < options.bufferSizes.size(); ++i) + buffersArray.Set((uint32_t)i, options.bufferSizes[i]); + obj.Set("bufferSizes", buffersArray); + + return obj; +} + +Napi::Value GetCurrentDevice(const Napi::CallbackInfo& info) +{ + auto env = info.Env(); + auto liveEngine = snapshotEngine(); + if (!liveEngine) return env.Null(); + + auto obj = Napi::Object::New(env); + const auto inputType = liveEngine->getCurrentInputDeviceType().toStdString(); + const auto outputType = liveEngine->getCurrentOutputDeviceType().toStdString(); + obj.Set("type", inputType); + obj.Set("inputType", inputType); + obj.Set("outputType", outputType); + obj.Set("input", liveEngine->getCurrentInputDevice().toStdString()); + obj.Set("output", liveEngine->getCurrentOutputDevice().toStdString()); + obj.Set("sampleRate", liveEngine->getCurrentSampleRate()); + obj.Set("blockSize", liveEngine->getCurrentBlockSize()); + obj.Set("inputBlockSize", liveEngine->getCurrentInputBlockSize()); + obj.Set("outputBlockSize", liveEngine->getCurrentOutputBlockSize()); + obj.Set("latencyMs", liveEngine->getLatencyMs()); + obj.Set("duplex", liveEngine->isDuplex()); + return obj; +} + +Napi::Value GetDeviceMetrics(const Napi::CallbackInfo& info) +{ + auto env = info.Env(); + auto liveEngine = snapshotEngine(); + auto obj = Napi::Object::New(env); + if (!liveEngine) + { + obj.Set("duplex", true); + obj.Set("inputOverflowCount", 0.0); + obj.Set("outputUnderflowCount", 0.0); + obj.Set("outputRingFillFrames", 0); + obj.Set("outputRingCapacityFrames", 0); + return obj; + } + const auto m = liveEngine->getDeviceMetrics(); + obj.Set("duplex", m.duplex); + obj.Set("inputOverflowCount", static_cast(m.inputOverflowCount)); + obj.Set("outputUnderflowCount", static_cast(m.outputUnderflowCount)); + obj.Set("outputRingFillFrames", m.outputRingFillFrames); + obj.Set("outputRingCapacityFrames", m.outputRingCapacityFrames); + return obj; +} + +// ── Device Selection ────────────────────────────────────────────────────────── + +Napi::Value SetDeviceType(const Napi::CallbackInfo& info) +{ + auto env = info.Env(); + auto liveEngine = snapshotEngine(); + if (!liveEngine || info.Length() < 1 || !info[0].IsString()) + return Napi::Boolean::New(env, false); + + auto typeName = info[0].As().Utf8Value(); + bool result = liveEngine->setDeviceType(juce::String(typeName)); + return Napi::Boolean::New(env, result); +} + +Napi::Value SetOutputDeviceType(const Napi::CallbackInfo& info) +{ + auto env = info.Env(); + auto liveEngine = snapshotEngine(); + if (!liveEngine || info.Length() < 1 || !info[0].IsString()) + return Napi::Boolean::New(env, false); + auto typeName = info[0].As().Utf8Value(); + return Napi::Boolean::New(env, liveEngine->setOutputDeviceType(juce::String(typeName))); +} + +Napi::Value SetDevice(const Napi::CallbackInfo& info) +{ + auto env = info.Env(); + auto liveEngine = snapshotEngine(); + auto result = Napi::Object::New(env); + result.Set("ok", false); + result.Set("duplex", true); + result.Set("sampleRate", 0.0); + result.Set("inputBlockSize", 0); + result.Set("outputBlockSize", 0); + result.Set("error", ""); + if (!liveEngine) + { + result.Set("error", "Audio engine not initialized"); + return result; + } + + // Object payload: setDevice({inputType, inputDevice, outputType, outputDevice, sampleRate, bufferSize}) + // Legacy positional: setDevice(input, output, sampleRate, bufferSize) + AudioEngine::DeviceConfig cfg; + if (info.Length() > 0 && info[0].IsObject() && !info[0].IsNull() && !info[0].IsArray()) + { + auto obj = info[0].As(); + auto readStr = [&](const char* key) -> std::string { + if (obj.Has(key) && obj.Get(key).IsString()) return obj.Get(key).As().Utf8Value(); + return {}; + }; + // Reject NaN/Infinity at the JS→C boundary so they can't poison + // downstream comparisons (NaN <= 0 is false, so the validation + // fallback in setAudioDevices() wouldn't catch them). Casting a + // non-finite double to int is also UB in C++. + auto readNum = [&](const char* key, double def) -> double { + if (obj.Has(key) && obj.Get(key).IsNumber()) + { + const double v = obj.Get(key).As().DoubleValue(); + if (std::isfinite(v)) return v; + } + return def; + }; + cfg.inputType = juce::String(readStr("inputType")); + cfg.inputDevice = juce::String(readStr("inputDevice")); + if (cfg.inputDevice.isEmpty()) cfg.inputDevice = juce::String(readStr("input")); + cfg.outputType = juce::String(readStr("outputType")); + cfg.outputDevice = juce::String(readStr("outputDevice")); + if (cfg.outputDevice.isEmpty()) cfg.outputDevice = juce::String(readStr("output")); + cfg.sampleRate = readNum("sampleRate", 48000.0); + // Clamp before the double→int cast: finite-but-out-of-range values + // (e.g. a JS-side bug passing 1e18) are UB to convert to int. readNum + // already filtered non-finite; we just need a range check here. + { + const double bsd = readNum("bufferSize", 256.0); + if (bsd >= 1.0 && bsd <= (double) (std::numeric_limits::max) ()) + cfg.bufferSize = (int) bsd; + else + cfg.bufferSize = 256; + } + } + else + { + auto input = info.Length() > 0 && info[0].IsString() ? info[0].As().Utf8Value() : ""; + auto output = info.Length() > 1 && info[1].IsString() ? info[1].As().Utf8Value() : ""; + double sr = info.Length() > 2 && info[2].IsNumber() ? info[2].As().DoubleValue() : 48000.0; + int bs = info.Length() > 3 && info[3].IsNumber() ? info[3].As().Int32Value() : 256; + cfg.inputDevice = juce::String(input); + cfg.outputDevice = juce::String(output); + cfg.sampleRate = sr; + cfg.bufferSize = bs; + } + + // Main thread only — JUCE's ALSA backend deadlocks if called from a worker. + const auto r = liveEngine->setAudioDevices(cfg); + result.Set("ok", r.ok); + result.Set("duplex", r.duplex); + result.Set("sampleRate", r.sampleRate); + result.Set("inputBlockSize", r.inputBlockSize); + result.Set("outputBlockSize", r.outputBlockSize); + result.Set("error", r.error.toStdString()); + return result; +} + +// ── Audio Control ───────────────────────────────────────────────────────────── + +Napi::Value StartAudio(const Napi::CallbackInfo& info) +{ + if (auto liveEngine = snapshotEngine()) liveEngine->startAudio(); + return info.Env().Undefined(); +} + +Napi::Value StopAudio(const Napi::CallbackInfo& info) +{ + if (auto liveEngine = snapshotEngine()) liveEngine->stopAudio(); + return info.Env().Undefined(); +} + +Napi::Value IsAudioRunning(const Napi::CallbackInfo& info) +{ + auto liveEngine = snapshotEngine(); + return Napi::Boolean::New(info.Env(), liveEngine ? liveEngine->isAudioRunning() : false); +} + +// ── Streamer mix output (PR1) ─────────────────────────────────────────────── +// setStreamOutputDevice(typeName, deviceName) -> "" on success, else an error. +Napi::Value SetStreamOutputDevice(const Napi::CallbackInfo& info) +{ + auto env = info.Env(); + auto liveEngine = snapshotEngine(); + if (!liveEngine) return Napi::String::New(env, "no engine"); + if (info.Length() < 2 || !info[0].IsString() || !info[1].IsString()) + return Napi::String::New(env, "setStreamOutputDevice(typeName:string, deviceName:string)"); + const std::string typeName = info[0].As().Utf8Value(); + const std::string devName = info[1].As().Utf8Value(); + return Napi::String::New(env, + liveEngine->setStreamOutputDevice(juce::String(typeName), juce::String(devName)).toStdString()); +} + +// clearStreamOutput() -> undefined +Napi::Value ClearStreamOutput(const Napi::CallbackInfo& info) +{ + auto liveEngine = snapshotEngine(); + if (liveEngine) liveEngine->clearStreamOutput(); + return info.Env().Undefined(); +} + +// setStreamBus(includeBacking:boolean, includeGuitar:boolean, gain:number) +Napi::Value SetStreamBus(const Napi::CallbackInfo& info) +{ + auto liveEngine = snapshotEngine(); + if (liveEngine && info.Length() >= 3 && info[0].IsBoolean() && info[1].IsBoolean() && info[2].IsNumber()) + liveEngine->setStreamBus(info[0].As().Value(), + info[1].As().Value(), + (float) info[2].As().DoubleValue()); + return info.Env().Undefined(); +} + +// setStreamBusGain(gain:number) +Napi::Value SetStreamBusGain(const Napi::CallbackInfo& info) +{ + auto liveEngine = snapshotEngine(); + if (liveEngine && info.Length() >= 1 && info[0].IsNumber()) + liveEngine->setStreamBusGain((float) info[0].As().DoubleValue()); + return info.Env().Undefined(); +} + +// setRendererBus(enabled:boolean, gain:number) +Napi::Value SetRendererBus(const Napi::CallbackInfo& info) +{ + auto liveEngine = snapshotEngine(); + if (liveEngine && info.Length() >= 2 && info[0].IsBoolean() && info[1].IsNumber()) + liveEngine->setRendererBus(info[0].As().Value(), + (float) info[1].As().DoubleValue()); + return info.Env().Undefined(); +} + +// pushRendererAudio(interleavedLR:Float32Array, sourceRate:number) -> boolean +// Interleaved stereo (L0 R0 L1 R1 …); sourceRate is the renderer's +// AudioContext sample rate. Returns false when the bus is off / engine down / +// malformed args, so the renderer can stop pushing. +Napi::Value PushRendererAudio(const Napi::CallbackInfo& info) +{ + auto env = info.Env(); + auto liveEngine = snapshotEngine(); + if (!liveEngine || info.Length() < 2 || !info[0].IsTypedArray() || !info[1].IsNumber()) + return Napi::Boolean::New(env, false); + auto ta = info[0].As(); + if (ta.TypedArrayType() != napi_float32_array) + return Napi::Boolean::New(env, false); + auto f32 = info[0].As(); + const size_t samples = f32.ElementLength(); + if (samples < 2) + return Napi::Boolean::New(env, false); + const int frames = (int) (samples / 2); + const bool ok = liveEngine->pushRendererAudio( + f32.Data(), frames, info[1].As().DoubleValue()); + return Napi::Boolean::New(env, ok); +} + +// getRendererBusMetrics() -> {enabled, fillFrames, capacityFrames, +// pushedFrames, consumedFrames, +// underflowCount, overflowCount} +Napi::Value GetRendererBusMetrics(const Napi::CallbackInfo& info) +{ + auto env = info.Env(); + auto liveEngine = snapshotEngine(); + auto obj = Napi::Object::New(env); + if (!liveEngine) return obj; + const auto m = liveEngine->getRendererBusMetrics(); + obj.Set("enabled", m.enabled); + obj.Set("fillFrames", m.fillFrames); + obj.Set("capacityFrames", m.capacityFrames); + obj.Set("pushedFrames", (double) m.pushedFrames); + obj.Set("consumedFrames", (double) m.consumedFrames); + obj.Set("underflowCount", (double) m.underflowCount); + obj.Set("overflowCount", (double) m.overflowCount); + return obj; +} + +// getStreamSinkLevel() -> number (peak 0..1+) +Napi::Value GetStreamSinkLevel(const Napi::CallbackInfo& info) +{ + auto liveEngine = snapshotEngine(); + return Napi::Number::New(info.Env(), liveEngine ? liveEngine->getStreamSinkLevel() : 0.0f); +} + +// isStreamOutputActive() -> boolean +Napi::Value IsStreamOutputActive(const Napi::CallbackInfo& info) +{ + auto liveEngine = snapshotEngine(); + return Napi::Boolean::New(info.Env(), liveEngine ? liveEngine->isStreamOutputActive() : false); +} + +// getStreamUnderflowCount() -> number +Napi::Value GetStreamUnderflowCount(const Napi::CallbackInfo& info) +{ + auto liveEngine = snapshotEngine(); + return Napi::Number::New(info.Env(), + (double) (liveEngine ? liveEngine->getStreamUnderflowCount() : 0ull)); +} + +// getStreamOverflowCount() -> number (consumer fell a full ring behind; frames dropped) +Napi::Value GetStreamOverflowCount(const Napi::CallbackInfo& info) +{ + auto liveEngine = snapshotEngine(); + return Napi::Number::New(info.Env(), + (double) (liveEngine ? liveEngine->getStreamOverflowCount() : 0ull)); +} + +// setSourceInputChannel(sourceId, channel) +Napi::Value SetSourceInputChannel(const Napi::CallbackInfo& info) +{ + auto liveEngine = snapshotEngine(); + if (liveEngine && info.Length() >= 2 && info[0].IsNumber() && info[1].IsNumber()) + if (SourceChain* s = getValidatedSource(liveEngine.get(), info, 0)) + s->setInputChannel(info[1].As().Int32Value()); + return info.Env().Undefined(); +} + +// setSourceVerifierOffset(sourceId, seconds) — per-source capture-latency +// correction the user dials in for an extra input device (the residual offset +// between that device's path and the primary's; not auto-measurable on JACK). +// Positive seconds DELAYS this source's scoring playhead, negative ADVANCES it. +Napi::Value SetSourceVerifierOffset(const Napi::CallbackInfo& info) +{ + auto liveEngine = snapshotEngine(); + if (liveEngine && info.Length() >= 2 && info[0].IsNumber() && info[1].IsNumber()) + { + const double sec = info[1].As().DoubleValue(); + if (std::isfinite(sec)) + if (SourceChain* s = getValidatedSource(liveEngine.get(), info, 0)) + s->setVerifierUserOffset(sec); + } + return info.Env().Undefined(); +} + +// setSourceMonitorMute(sourceId, mute) +Napi::Value SetSourceMonitorMute(const Napi::CallbackInfo& info) +{ + auto liveEngine = snapshotEngine(); + if (liveEngine && info.Length() >= 2 && info[0].IsNumber() && info[1].IsBoolean()) + if (SourceChain* s = getValidatedSource(liveEngine.get(), info, 0)) + s->setMonitorMute(info[1].As().Value()); + return info.Env().Undefined(); +} + +// getSourceRawAudioFrame(sourceId, numSamples?) -> Float32Array +Napi::Value GetSourceRawAudioFrame(const Napi::CallbackInfo& info) +{ + auto env = info.Env(); + auto liveEngine = snapshotEngine(); + if (!liveEngine || info.Length() < 1 || !info[0].IsNumber()) + return Napi::Float32Array::New(env, 0); + SourceChain* s = getValidatedSource(liveEngine.get(), info, 0); + int numSamples = 4096; + if (info.Length() > 1 && info[1].IsNumber()) + numSamples = info[1].As().Int32Value(); + if (!s || numSamples <= 0) + return Napi::Float32Array::New(env, 0); + auto frame = s->getRawAudioFrame(numSamples); + auto out = Napi::Float32Array::New(env, frame.size()); + float* dst = out.Data(); + for (size_t i = 0; i < frame.size(); ++i) + dst[i] = frame[i]; + return out; +} + +// getSourcePitchDetection(sourceId) -> { frequency, confidence, midiNote, cents, +// noteName }. The no-detection shape when the id is bad/inactive. +Napi::Value GetSourcePitchDetection(const Napi::CallbackInfo& info) +{ + auto env = info.Env(); + auto obj = Napi::Object::New(env); + auto liveEngine = snapshotEngine(); + SourceChain* s = (liveEngine && info.Length() >= 1 && info[0].IsNumber()) + ? getValidatedSource(liveEngine.get(), info, 0) : nullptr; + if (s) + { + auto det = s->getActiveDetection(); + obj.Set("frequency", det.frequency); + obj.Set("confidence", det.confidence); + obj.Set("midiNote", det.midiNote); + obj.Set("cents", det.cents); + obj.Set("noteName", det.noteName.toStdString()); + } + else + { + obj.Set("frequency", -1.0); + obj.Set("confidence", 0.0); + obj.Set("midiNote", -1); + obj.Set("cents", 0.0); + obj.Set("noteName", ""); + } + return obj; +} + +// getSourceRawPitchDetection(sourceId) -> raw YIN detection (bypasses ML), same +// shape as getSourcePitchDetection. Backs the per-source sustain glow / mono path. +Napi::Value GetSourceRawPitchDetection(const Napi::CallbackInfo& info) +{ + auto env = info.Env(); + auto obj = Napi::Object::New(env); + auto liveEngine = snapshotEngine(); + SourceChain* s = (liveEngine && info.Length() >= 1 && info[0].IsNumber()) + ? getValidatedSource(liveEngine.get(), info, 0) : nullptr; + if (s) + { + auto det = s->getRawPitchDetection(); + obj.Set("frequency", det.frequency); + obj.Set("confidence", det.confidence); + obj.Set("midiNote", det.midiNote); + obj.Set("cents", det.cents); + obj.Set("noteName", det.noteName.toStdString()); + } + else + { + obj.Set("frequency", -1.0); + obj.Set("confidence", 0.0); + obj.Set("midiNote", -1); + obj.Set("cents", 0.0); + obj.Set("noteName", ""); + } + return obj; +} + +// getSourceNoteVerdicts(sourceId, songTime?, playing?) -> verdict array, or null +// on a missing engine / bad id. Folds in the per-source playhead push like the +// legacy getNoteVerdicts. +Napi::Value GetSourceNoteVerdicts(const Napi::CallbackInfo& info) +{ + auto env = info.Env(); + auto liveEngine = snapshotEngine(); + if (!liveEngine || info.Length() < 1 || !info[0].IsNumber()) + return env.Null(); + SourceChain* s = getValidatedSource(liveEngine.get(), info, 0); + if (!s) return env.Null(); + + if (info.Length() >= 3 && info[1].IsNumber() && info[2].IsBoolean()) + { + const double songTime = info[1].As().DoubleValue(); + if (std::isfinite(songTime)) + s->setPlayhead(songTime, info[2].As().Value()); + } + + const auto verdicts = s->getNoteVerdicts(); + auto arr = Napi::Array::New(env, verdicts.size()); + for (size_t i = 0; i < verdicts.size(); ++i) + { + const auto& v = verdicts[i]; + auto entry = Napi::Object::New(env); + entry.Set("id", v.id); + entry.Set("detected", v.detected); + entry.Set("detectedSongTime", v.detectedSongTime); + entry.Set("centsError", v.centsError); + entry.Set("snr", v.snr); + arr.Set((uint32_t) i, entry); + } + return arr; +} + +// Push the song's note chart into the engine for continuous, background +// verification. The notedetect plugin calls this once per arrangement load; +// the engine's NoteVerifier thread then scores each note's timing window +// against the live playhead and input ring, so the renderer no longer runs a +// per-tick scoreChord IPC loop (which starved during dense passages). +// +// Expected payload: +// { +// arrangement?: 'guitar'|'bass', // default 'guitar' +// stringCount?: number, // default 6 +// tuningOffsets: number[], // length should equal stringCount +// capo?: number, // default 0 +// pitchCheckCents?: number, // default 0 (energy-only) +// harmonicSnr?: number, // default 3.0 +// fundamentalRatio?: number, // fundamental-presence gate, lower for +// // bass, <=0 disables (default 0.20) +// timingTolerance?: number, // seconds, default 0.1 +// notes: [{ id:string, t:number, s:number, f:number, sus:number, +// ho?,po?,b?,sl?,hm?:boolean }, ...] +// } +// Returns true when the chart was accepted, false on a malformed payload or +// when no engine exists. +// Shared core: parse `reqObj` into a ChartUpdate and push it to `target`'s +// verifier. `target` is sources[0] for the legacy setChart and getSource(id) for +// the source-indexed setSourceChart. A malformed payload clears the target's +// chart (so a failed reload can't leave a stale chart scoring) and returns false. +Napi::Value setChartCore(Napi::Env env, Napi::Object reqObj, SourceChain* target) +{ + // Generous cap on the chart length — a full song's note list is well + // under this, but it bounds the worst-case allocation a malformed payload + // (claiming a gigantic JS array length) could force over IPC. + static constexpr uint32_t kMaxChartNotes = 8192; + + // Rejecting a malformed chart must also drop whatever chart the verifier + // currently holds — otherwise a failed (re)load leaves the previous + // song's chart active and getNoteVerdicts() keeps emitting stale verdicts. + auto reject = [&]() -> Napi::Value { + if (target) target->clearChart(); + return Napi::Boolean::New(env, false); + }; + + NoteVerifier::ChartUpdate chart; + if (reqObj.Has("arrangement") && reqObj.Get("arrangement").IsString()) + chart.arrangement = reqObj.Get("arrangement").As().Utf8Value(); + if (reqObj.Has("stringCount") && reqObj.Get("stringCount").IsNumber()) + chart.stringCount = reqObj.Get("stringCount").As().Int32Value(); + if (reqObj.Has("capo") && reqObj.Get("capo").IsNumber()) + chart.capo = reqObj.Get("capo").As().Int32Value(); + if (reqObj.Has("pitchCheckCents") && reqObj.Get("pitchCheckCents").IsNumber()) + chart.pitchCheckCents = reqObj.Get("pitchCheckCents").As().FloatValue(); + if (reqObj.Has("harmonicSnr") && reqObj.Get("harmonicSnr").IsNumber()) + chart.harmonicSnr = reqObj.Get("harmonicSnr").As().FloatValue(); + if (reqObj.Has("fundamentalRatio") && reqObj.Get("fundamentalRatio").IsNumber()) + { + // Drop NaN/Inf (see ScoreChord): a non-finite ratio poisons the + // fundamental-presence gate; keep the safe 0.20 default. + const float v = reqObj.Get("fundamentalRatio").As().FloatValue(); + if (std::isfinite(v)) chart.fundamentalRatio = v; + } + if (reqObj.Has("presenceRatio") && reqObj.Get("presenceRatio").IsNumber()) + { + // Temporal-persistence floor, clamped to [0,1]. Saturate rather than + // reject an out-of-range value: a stray >1 must NOT silently fall back to + // 0 (legacy ever-present), which would reintroduce the false-accept this + // guards against. Non-finite is ignored (keeps the 0 default). + const float v = reqObj.Get("presenceRatio").As().FloatValue(); + if (std::isfinite(v)) chart.presenceRatio = (v < 0.0f) ? 0.0f : (v > 1.0f ? 1.0f : v); + } + if (reqObj.Has("timingTolerance") && reqObj.Get("timingTolerance").IsNumber()) + chart.timingTolerance = reqObj.Get("timingTolerance").As().DoubleValue(); + + if (reqObj.Has("tuningOffsets") && reqObj.Get("tuningOffsets").IsArray()) + { + auto arr = reqObj.Get("tuningOffsets").As(); + if (arr.Length() > 32) return reject(); + chart.tuningOffsets.reserve(arr.Length()); + for (uint32_t i = 0; i < arr.Length(); ++i) + { + auto v = arr.Get(i); + if (!v.IsNumber()) return reject(); + chart.tuningOffsets.push_back(v.As().Int32Value()); + } + } + + // ChordScorer requires exactly one tuning offset per string and otherwise + // fails every note closed. Reject the chart here so a malformed payload + // surfaces as setChart() == false rather than a silently all-miss session + // the caller believes loaded fine. + if ((int) chart.tuningOffsets.size() != chart.stringCount) + return reject(); + + Napi::Value notesVal = reqObj.Has("notes") ? reqObj.Get("notes") : env.Null(); + if (!notesVal.IsArray()) return reject(); + auto notesArr = notesVal.As(); + if (notesArr.Length() > kMaxChartNotes) return reject(); + + chart.notes.reserve(notesArr.Length()); + for (uint32_t i = 0; i < notesArr.Length(); ++i) + { + auto v = notesArr.Get(i); + if (!v.IsObject()) return reject(); + auto noteObj = v.As(); + + // Every chart note must carry all five required fields with the right + // type. Filling defaults for a missing field would push a bogus + // time-0 note with an empty id — that breaks verdict-by-id alignment + // — so reject the whole chart instead. + const bool validNote = + noteObj.Has("id") && noteObj.Get("id").IsString() && + noteObj.Has("t") && noteObj.Get("t").IsNumber() && + noteObj.Has("s") && noteObj.Get("s").IsNumber() && + noteObj.Has("f") && noteObj.Get("f").IsNumber() && + noteObj.Has("sus") && noteObj.Get("sus").IsNumber(); + if (!validNote) return reject(); + + NoteVerifier::ChartNote n{}; + n.id = noteObj.Get("id").As().Utf8Value(); + n.t = noteObj.Get("t").As().DoubleValue(); + n.string = noteObj.Get("s").As().Int32Value(); + n.fret = noteObj.Get("f").As().Int32Value(); + n.sus = noteObj.Get("sus").As().DoubleValue(); + auto truthy = [¬eObj](const char* key) { + if (!noteObj.Has(key)) return false; + return noteObj.Get(key).ToBoolean().Value(); + }; + n.ho = truthy("ho"); + n.po = truthy("po"); + n.b = truthy("b"); + n.sl = truthy("sl"); + n.hm = truthy("hm"); + chart.notes.push_back(std::move(n)); + } + + target->setChart(chart); + return Napi::Boolean::New(env, true); +} + +// Legacy: setChart(chart) — targets sources[0]. Backward-compatible. +Napi::Value SetChart(const Napi::CallbackInfo& info) +{ + auto env = info.Env(); + auto liveEngine = snapshotEngine(); + if (!liveEngine || info.Length() < 1 || !info[0].IsObject()) + { + if (liveEngine) liveEngine->clearChart(); + return Napi::Boolean::New(env, false); + } + return setChartCore(env, info[0].As(), liveEngine->getSource(0)); +} + +// Source-indexed: setSourceChart(sourceId, chart). Bad id / payload -> false. +Napi::Value SetSourceChart(const Napi::CallbackInfo& info) +{ + auto env = info.Env(); + auto liveEngine = snapshotEngine(); + if (!liveEngine || info.Length() < 2 || !info[0].IsNumber() || !info[1].IsObject()) + return Napi::Boolean::New(env, false); + SourceChain* target = getValidatedSource(liveEngine.get(), info, 0); + if (!target) return Napi::Boolean::New(env, false); + return setChartCore(env, info[1].As(), target); +} + +// Drain the verdicts the NoteVerifier thread has finalized since the last +// call. Returns an array of { id, detected, detectedSongTime, centsError, snr }. +// +// Optionally also pushes the renderer's playhead: getNoteVerdicts(songTime, +// playing). The plugin calls this once per detect tick, so folding the push in +// here advances the verifier's clock without a second IPC round-trip. A +// downlevel caller passing no args still just drains. +Napi::Value GetNoteVerdicts(const Napi::CallbackInfo& info) +{ + auto env = info.Env(); + // Null (not an empty array) on a missing engine — the bridge/preload + // contract treats null as "unsupported/unavailable" so the renderer + // feature-detects, matching detectNotes' no-engine path. + auto liveEngine = snapshotEngine(); + if (!liveEngine) return env.Null(); + + // Push the playhead before draining so this tick's verdicts reflect it. + // A JS NaN/Infinity passes IsNumber() — guard with isfinite so a bad + // value can't corrupt the verifier's interpolated timing. + if (info.Length() >= 2 && info[0].IsNumber() && info[1].IsBoolean()) + { + const double songTime = info[0].As().DoubleValue(); + if (std::isfinite(songTime)) + liveEngine->setPlayhead(songTime, info[1].As().Value()); + } + + const auto verdicts = liveEngine->getNoteVerdicts(); + auto arr = Napi::Array::New(env, verdicts.size()); + for (size_t i = 0; i < verdicts.size(); ++i) + { + const auto& v = verdicts[i]; + auto entry = Napi::Object::New(env); + entry.Set("id", v.id); + entry.Set("detected", v.detected); + entry.Set("detectedSongTime", v.detectedSongTime); + entry.Set("centsError", v.centsError); + entry.Set("snr", v.snr); + arr.Set((uint32_t) i, entry); + } + return arr; +} + +// Sample rate the audio device is running at. Notedetect's chord scorer +// needs this to map FFT bins to Hz; on the bridge path there's no +// AudioContext to read it from. Falls back to 48000 if the engine isn't +// ready (matches the historical fallback in screen.js) — and also if +// the engine is initialized but no device is currently active, which +// pins currentSampleRate to 0 internally and would otherwise propagate +// a divide-by-zero into the renderer's FFT-bin→Hz math. +Napi::Value GetSampleRate(const Napi::CallbackInfo& info) +{ + auto env = info.Env(); + constexpr double kFallbackSampleRate = 48000.0; + auto liveEngine = snapshotEngine(); + if (!liveEngine) + return Napi::Number::New(env, kFallbackSampleRate); + const double sr = liveEngine->getCurrentSampleRate(); + if (!std::isfinite(sr) || sr <= 0.0) + return Napi::Number::New(env, kFallbackSampleRate); + return Napi::Number::New(env, sr); +} + + +} // namespace slopsmith::addon From 88f881dd1e5ba69325fe95e5d78ac6c9d2b42d11 Mon Sep 17 00:00:00 2001 From: OmikronApex Date: Tue, 14 Jul 2026 03:01:09 +0200 Subject: [PATCH 19/28] =?UTF-8?q?fix(audio):=20refcounted=20monitor-mute?= =?UTF-8?q?=20arbiter=20(TLC=20Part=20II=20=C2=A72)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The old single monitorMuted atomic had five writers fighting last-writer-wins: the settings checkbox, startup restore, the executor's preload read-force-restore, releaseRoute's unconditional setMonitorMute(true) (which clobbered the user's persisted preference), and the renderer's song-load suppression (un-refcounted — overlapping windows un-suppressed each other early). Native arbiter on SourceChain: userMonitorMute (the preference — checkbox + restore only), refcounted monitorMuteHolds (force-mute overrides), and refcounted suppressions (setMonitorMuteSuppressed keeps its bool surface; true=acquire, false=release, clamped at 0). Effective dry-mute = (holds || pref) && chain empty && no suppression — the suppressed-beats-muted precedence is unchanged. New exports: acquire/releaseMonitorMuteHold, getMonitorMuteState (diag); snapshots regenerated. Executor rewrite: acquires a suppression (dry-during-load, the default) or a hold, and releases exactly what it acquired via a single-fire closure that runs UNCONDITIONALLY (each load owns its acquisition — the stale-snapshot race against a mid-hold user toggle is structurally gone). releaseRoute no longer touches mute state at all. The ownership test now pins: preference API never called, acquire/release balanced. Renderer callers are unchanged: the checkbox writes the preference as before, and the song-load suppression sites now compose instead of racing. Co-Authored-By: Claude Fable 5 --- src/audio/AudioEngine.h | 5 ++ src/audio/NodeAddon.cpp | 6 +++ src/audio/SourceChain.cpp | 4 +- src/audio/SourceChain.h | 43 ++++++++++++--- src/audio/addon/Bindings.h | 3 ++ src/audio/addon/ControlBindings.cpp | 31 +++++++++++ src/main/audio-effects-executor.ts | 80 +++++++++++++++++----------- tests/audio-effects-executor.test.js | 18 +++++-- tests/contracts/addon-exports.json | 3 ++ 9 files changed, 151 insertions(+), 42 deletions(-) diff --git a/src/audio/AudioEngine.h b/src/audio/AudioEngine.h index c578afc..c0999bf 100644 --- a/src/audio/AudioEngine.h +++ b/src/audio/AudioEngine.h @@ -165,6 +165,11 @@ public: // so the brief empty-chain window doesn't silence the player's guitar. void setMonitorMuteSuppressed(bool suppressed) { source0().setMonitorMuteSuppressed(suppressed); } bool isMonitorMuteSuppressed() const { return source0().isMonitorMuteSuppressed(); } + // Refcounted force-mute overrides (see SourceChain's arbiter comment). + void acquireMonitorMuteHold() { source0().acquireMonitorMuteHold(); } + void releaseMonitorMuteHold() { source0().releaseMonitorMuteHold(); } + int getMonitorMuteHoldCount() const { return source0().getMonitorMuteHoldCount(); } + int getMonitorMuteSuppressCount() const { return source0().getMonitorMuteSuppressCount(); } // Full monitor kill — silences the guitar bus entirely (dry + processed), // for monitoring through an external rig. Unlike the per-source mute/gain diff --git a/src/audio/NodeAddon.cpp b/src/audio/NodeAddon.cpp index 4fe7664..f05ce03 100644 --- a/src/audio/NodeAddon.cpp +++ b/src/audio/NodeAddon.cpp @@ -106,6 +106,9 @@ using slopsmith::addon::SetInputChannel; using slopsmith::addon::SetMonitorKill; using slopsmith::addon::SetMonitorMute; using slopsmith::addon::SetMonitorMuteSuppressed; +using slopsmith::addon::AcquireMonitorMuteHold; +using slopsmith::addon::ReleaseMonitorMuteHold; +using slopsmith::addon::GetMonitorMuteState; using slopsmith::addon::SetMultiBypass; using slopsmith::addon::SetNoiseGate; using slopsmith::addon::SetNoteDetectionEnabled; @@ -346,6 +349,9 @@ static Napi::Object InitModule(Napi::Env env, Napi::Object exports) exports.Set("setInputChannel", Napi::Function::New(env, SetInputChannel)); exports.Set("setMonitorMute", Napi::Function::New(env, SetMonitorMute)); exports.Set("setMonitorMuteSuppressed", Napi::Function::New(env, SetMonitorMuteSuppressed)); + exports.Set("acquireMonitorMuteHold", Napi::Function::New(env, AcquireMonitorMuteHold)); + exports.Set("releaseMonitorMuteHold", Napi::Function::New(env, ReleaseMonitorMuteHold)); + exports.Set("getMonitorMuteState", Napi::Function::New(env, GetMonitorMuteState)); exports.Set("isMonitorMuted", Napi::Function::New(env, IsMonitorMuted)); exports.Set("setMonitorKill", Napi::Function::New(env, SetMonitorKill)); exports.Set("setNoiseGate", Napi::Function::New(env, SetNoiseGate)); diff --git a/src/audio/SourceChain.cpp b/src/audio/SourceChain.cpp index 93787ba..7ae7142 100644 --- a/src/audio/SourceChain.cpp +++ b/src/audio/SourceChain.cpp @@ -252,7 +252,9 @@ void SourceChain::processBlock(const float* const* inputData, int numInputChanne // chain yet. Backing track still plays through. Suppressed during a song-load // chain rebuild so the brief (or failed) empty-chain window doesn't silence // the guitar. - if (monitorMuted.load() && !hasProcessors && !monitorMuteSuppressed.load()) + if ((monitorMuteHolds.load(std::memory_order_acquire) > 0 || userMonitorMute.load()) + && !hasProcessors + && monitorMuteSuppress.load(std::memory_order_acquire) == 0) buffer.clear(); // Full monitor kill: silence the guitar bus unconditionally — dry AND the diff --git a/src/audio/SourceChain.h b/src/audio/SourceChain.h index 5f8fee6..7f44d5b 100644 --- a/src/audio/SourceChain.h +++ b/src/audio/SourceChain.h @@ -142,10 +142,40 @@ public: // then a channel index WITHIN the bound device. void setDeviceKey(int key) { deviceKey.store(key, std::memory_order_release); } int getDeviceKey() const { return deviceKey.load(std::memory_order_acquire); } - void setMonitorMute(bool mute) { monitorMuted.store(mute); } - bool isMonitorMuted() const { return monitorMuted.load(); } - void setMonitorMuteSuppressed(bool s) { monitorMuteSuppressed.store(s); } - bool isMonitorMuteSuppressed() const { return monitorMuteSuppressed.load(); } + // ── Monitor-mute arbiter (TLC Part II §2 fix) ───────────────────────────── + // The old single monitorMuted atomic had FIVE writers (settings checkbox, + // startup restore, executor preload-mute, executor releaseRoute, renderer + // song-load suppression) fighting last-writer-wins — releaseRoute clobbered + // the user's persisted preference and overlapping suppression windows + // un-suppressed each other. Now three composable inputs: + // userMonitorMute — the PREFERENCE (checkbox + startup restore). + // monitorMuteHolds — refcounted "force mute" overrides (executor + // preload-mute); released, never "restored". + // monitorMuteSuppress — refcounted "force unmute" windows (song-load + // chain rebuilds). Wins over pref + holds, + // preserving the old suppressed-beats-muted rule. + // effective dry-mute = (holds>0 || pref) && chain empty && suppress==0. + void setMonitorMute(bool mute) { userMonitorMute.store(mute); } + bool isMonitorMuted() const { return userMonitorMute.load(); } + void acquireMonitorMuteHold() { monitorMuteHolds.fetch_add(1, std::memory_order_acq_rel); } + void releaseMonitorMuteHold() + { + // Clamp at 0: an unpaired release (old callers, crashed holder) must + // not underflow into a permanently-forced state. + int cur = monitorMuteHolds.load(std::memory_order_acquire); + while (cur > 0 && !monitorMuteHolds.compare_exchange_weak(cur, cur - 1, std::memory_order_acq_rel)) {} + } + // Back-compat surface: true = acquire a suppression, false = release one. + // Overlapping windows now compose instead of last-clear-wins. + void setMonitorMuteSuppressed(bool s) + { + if (s) { monitorMuteSuppress.fetch_add(1, std::memory_order_acq_rel); return; } + int cur = monitorMuteSuppress.load(std::memory_order_acquire); + while (cur > 0 && !monitorMuteSuppress.compare_exchange_weak(cur, cur - 1, std::memory_order_acq_rel)) {} + } + bool isMonitorMuteSuppressed() const { return monitorMuteSuppress.load(std::memory_order_acquire) > 0; } + int getMonitorMuteHoldCount() const { return monitorMuteHolds.load(std::memory_order_acquire); } + int getMonitorMuteSuppressCount() const { return monitorMuteSuppress.load(std::memory_order_acquire); } // Full monitor kill — silences the guitar bus UNCONDITIONALLY (dry AND the // processed/amp-sim signal), unlike setMonitorMute which only mutes the dry // pass-through when no processors are loaded. For users who monitor through @@ -200,8 +230,9 @@ private: std::atomic deviceKey{0}; // 0 = primary input device std::atomic verifierAutoOffset{0.0}; // engine: device-latency delta std::atomic verifierUserOffset{0.0}; // renderer: manual fine-tune - std::atomic monitorMuted{true}; - std::atomic monitorMuteSuppressed{false}; + std::atomic userMonitorMute{true}; + std::atomic monitorMuteHolds{0}; + std::atomic monitorMuteSuppress{0}; std::atomic monitorKill{false}; std::atomic nonFiniteChainBlocks{0}; diff --git a/src/audio/addon/Bindings.h b/src/audio/addon/Bindings.h index acc79f4..641ca4c 100644 --- a/src/audio/addon/Bindings.h +++ b/src/audio/addon/Bindings.h @@ -77,6 +77,9 @@ 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); diff --git a/src/audio/addon/ControlBindings.cpp b/src/audio/addon/ControlBindings.cpp index fde10a3..470fa94 100644 --- a/src/audio/addon/ControlBindings.cpp +++ b/src/audio/addon/ControlBindings.cpp @@ -80,6 +80,37 @@ Napi::Value SetMonitorMuteSuppressed(const Napi::CallbackInfo& info) 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), diff --git a/src/main/audio-effects-executor.ts b/src/main/audio-effects-executor.ts index ac02a89..291b402 100644 --- a/src/main/audio-effects-executor.ts +++ b/src/main/audio-effects-executor.ts @@ -36,6 +36,8 @@ type AudioEffectsNativeAudio = { setGain?: (which: string, value: number) => Promise | unknown; setMonitorMute?: (muted: boolean) => Promise | unknown; setMonitorMuteSuppressed?: (suppressed: boolean) => Promise | unknown; + acquireMonitorMuteHold?: () => Promise | unknown; + releaseMonitorMuteHold?: () => Promise | unknown; isMonitorMuted?: () => Promise | unknown; startAudio?: () => Promise | unknown; }; @@ -413,23 +415,35 @@ async function restorePreset(nativeAudio: AudioEffectsNativeAudio, presetJson: u } } -async function readMonitorMuted(nativeAudio: AudioEffectsNativeAudio): Promise { - if (typeof nativeAudio.isMonitorMuted !== 'function') return null; - try { - return Boolean(await nativeAudio.isMonitorMuted()); - } catch (_) { - return null; +// Monitor-mute arbiter (TLC Part II §2): the executor no longer reads or +// writes the user's mute PREFERENCE. During a load it acquires a refcounted +// override on the native arbiter — a force-mute hold (default) or a +// suppression (dryDuringLoad: dry guitar stays audible) — and RELEASES it +// afterwards. Returns a single-fire release closure (safe to call from a +// timer even after newer loads: each load owns its own acquisition, so +// releasing can never clobber another writer's state, which is exactly the +// stale-snapshot race the old read-modify-restore had). +async function acquireMuteOverride(nativeAudio: AudioEffectsNativeAudio, dryDuringLoad: boolean): Promise<() => Promise> { + let released = false; + if (dryDuringLoad && typeof nativeAudio.setMonitorMuteSuppressed === 'function') { + try { await nativeAudio.setMonitorMuteSuppressed(true); } catch (_) { return async () => { /* never acquired */ }; } + return async () => { + if (released) return; + released = true; + try { await nativeAudio.setMonitorMuteSuppressed!(false); } catch (_) { /* best effort */ } + }; } -} - -async function trySetMonitorMute(nativeAudio: AudioEffectsNativeAudio, muted: boolean): Promise { - if (typeof nativeAudio.setMonitorMute !== 'function') return; - try { await nativeAudio.setMonitorMute(muted); } catch (_) { /* best effort */ } -} - -async function trySetMonitorMuteSuppressed(nativeAudio: AudioEffectsNativeAudio, suppressed: boolean): Promise { - if (typeof nativeAudio.setMonitorMuteSuppressed !== 'function') return; - try { await nativeAudio.setMonitorMuteSuppressed(suppressed); } catch (_) { /* best effort */ } + if (!dryDuringLoad && typeof nativeAudio.acquireMonitorMuteHold === 'function') { + try { await nativeAudio.acquireMonitorMuteHold(); } catch (_) { return async () => { /* never acquired */ }; } + return async () => { + if (released) return; + released = true; + try { await nativeAudio.releaseMonitorMuteHold?.(); } catch (_) { /* best effort */ } + }; + } + // Addon predates the arbiter — degrade to no mute forcing rather than + // reintroducing the preference-clobbering read/force/restore. + return async () => { /* nothing acquired */ }; } async function trySetGain(nativeAudio: AudioEffectsNativeAudio, which: string, value: number): Promise { @@ -449,10 +463,13 @@ async function applyGains(nativeAudio: AudioEffectsNativeAudio, gains: RouteGain return failed; } -function schedulePreloadRestore(nativeAudio: AudioEffectsNativeAudio, previousMonitorMute: boolean | null, targetGain: number, holdMs: number, shouldRestore?: () => boolean): void { +function schedulePreloadRestore(nativeAudio: AudioEffectsNativeAudio, releaseMuteOverride: (() => Promise) | null, targetGain: number, holdMs: number, shouldRestore?: () => boolean): void { const restore = async () => { + // The override release is UNCONDITIONAL: this load acquired it, this + // load must release it, even when a newer load superseded the gain + // ramp (refcounts compose — the newer load holds its own). + if (releaseMuteOverride) await releaseMuteOverride(); if (shouldRestore && !shouldRestore()) return; - if (previousMonitorMute !== null) await trySetMonitorMute(nativeAudio, previousMonitorMute); const restoreTarget = clampGain(targetGain, 1); const steps = [restoreTarget * 0.25, restoreTarget * 0.5, restoreTarget * 0.8, restoreTarget]; for (const value of steps) { @@ -489,25 +506,24 @@ export function createAudioEffectsExecutor(getAudio: NativeAudioGetter) { const started = Date.now(); const restoreVersion = ++preloadRestoreVersion; const rollbackPreset = typeof nativeAudio.savePreset === 'function' ? nativeAudio.savePreset() : null; - let previousMonitorMute: boolean | null = null; + let releaseMuteOverride: (() => Promise) | null = null; if (options.preloadMute?.enabled) { - previousMonitorMute = await readMonitorMuted(nativeAudio); await trySetGain(nativeAudio, 'chain', 0); - await trySetMonitorMute(nativeAudio, options.preloadMute.dryDuringLoad ? false : true); + releaseMuteOverride = await acquireMuteOverride(nativeAudio, options.preloadMute.dryDuringLoad === true); } let result: { success: boolean; slotsLoaded: number; error: string; chainGeneration: number }; try { result = normalizeLoadResult(await nativeAudio.loadPreset(validation.presetJson)); } catch (error) { const rollbackApplied = await restorePreset(nativeAudio, rollbackPreset); - if (options.preloadMute?.enabled) schedulePreloadRestore(nativeAudio, previousMonitorMute, options.gains.chain ?? options.preloadMute.targetGain, 0, () => restoreVersion === preloadRestoreVersion); + if (options.preloadMute?.enabled) schedulePreloadRestore(nativeAudio, releaseMuteOverride, options.gains.chain ?? options.preloadMute.targetGain, 0, () => restoreVersion === preloadRestoreVersion); return safeOutcome('failed', 'Native audio-effects plan load threw', { error: bounded(error instanceof Error ? error.message : String(error)), rollbackApplied }); } const nativeStages = validation.plan.stages.filter((stage) => stage.native); if (!result.success) { const rollbackApplied = await restorePreset(nativeAudio, rollbackPreset); - if (options.preloadMute?.enabled) schedulePreloadRestore(nativeAudio, previousMonitorMute, options.gains.chain ?? options.preloadMute.targetGain, 0, () => restoreVersion === preloadRestoreVersion); + if (options.preloadMute?.enabled) schedulePreloadRestore(nativeAudio, releaseMuteOverride, options.gains.chain ?? options.preloadMute.targetGain, 0, () => restoreVersion === preloadRestoreVersion); return safeOutcome('failed', 'Native audio-effects plan load failed', { routeKey: validation.plan.routeKey, providerId: validation.plan.providerId, @@ -522,7 +538,7 @@ export function createAudioEffectsExecutor(getAudio: NativeAudioGetter) { if (result.slotsLoaded < nativeStages.length) { const rollbackApplied = await restorePreset(nativeAudio, rollbackPreset); - if (options.preloadMute?.enabled) schedulePreloadRestore(nativeAudio, previousMonitorMute, options.gains.chain ?? options.preloadMute.targetGain, 0, () => restoreVersion === preloadRestoreVersion); + if (options.preloadMute?.enabled) schedulePreloadRestore(nativeAudio, releaseMuteOverride, options.gains.chain ?? options.preloadMute.targetGain, 0, () => restoreVersion === preloadRestoreVersion); return safeOutcome('degraded', 'Native audio-effects plan partially loaded and was rolled back', { routeKey: validation.plan.routeKey, providerId: validation.plan.providerId, @@ -539,7 +555,7 @@ export function createAudioEffectsExecutor(getAudio: NativeAudioGetter) { slots = chainSlots(nativeAudio); } catch (error) { const rollbackApplied = await restorePreset(nativeAudio, rollbackPreset); - if (options.preloadMute?.enabled) schedulePreloadRestore(nativeAudio, previousMonitorMute, options.gains.chain ?? options.preloadMute.targetGain, 0, () => restoreVersion === preloadRestoreVersion); + if (options.preloadMute?.enabled) schedulePreloadRestore(nativeAudio, releaseMuteOverride, options.gains.chain ?? options.preloadMute.targetGain, 0, () => restoreVersion === preloadRestoreVersion); return safeOutcome('failed', 'Native chain-state lookup threw', { routeKey: validation.plan.routeKey, providerId: validation.plan.providerId, @@ -559,7 +575,7 @@ export function createAudioEffectsExecutor(getAudio: NativeAudioGetter) { // as handled while later stage operations silently return no-target. Roll back instead. if (stageSlots.size !== nativeStages.length) { const rollbackApplied = await restorePreset(nativeAudio, rollbackPreset); - if (options.preloadMute?.enabled) schedulePreloadRestore(nativeAudio, previousMonitorMute, options.gains.chain ?? options.preloadMute.targetGain, 0, () => restoreVersion === preloadRestoreVersion); + if (options.preloadMute?.enabled) schedulePreloadRestore(nativeAudio, releaseMuteOverride, options.gains.chain ?? options.preloadMute.targetGain, 0, () => restoreVersion === preloadRestoreVersion); return safeOutcome('degraded', 'Native slot mapping was incomplete and was rolled back', { routeKey: validation.plan.routeKey, providerId: validation.plan.providerId, @@ -576,7 +592,7 @@ export function createAudioEffectsExecutor(getAudio: NativeAudioGetter) { 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, 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 chain was modified by another writer during plan load', { routeKey: validation.plan.routeKey, providerId: validation.plan.providerId, @@ -607,7 +623,7 @@ export function createAudioEffectsExecutor(getAudio: NativeAudioGetter) { try { await nativeAudio.startAudio(); } catch (_) { /* load succeeded; start is best-effort */ } } if (options.preloadMute?.enabled) { - schedulePreloadRestore(nativeAudio, previousMonitorMute, options.gains.chain ?? options.preloadMute.targetGain, options.preloadMute.holdMs, () => { + schedulePreloadRestore(nativeAudio, releaseMuteOverride, options.gains.chain ?? options.preloadMute.targetGain, options.preloadMute.holdMs, () => { const current = routes.get(validation.plan.routeKey); return restoreVersion === preloadRestoreVersion && current?.planId === validation.plan.planId; }); @@ -645,8 +661,12 @@ export function createAudioEffectsExecutor(getAudio: NativeAudioGetter) { } catch (error) { releaseFailure = safeOutcome('failed', 'Native route release threw', { routeKey, error: bounded(error instanceof Error ? error.message : String(error)) }); } - await trySetMonitorMute(nativeAudio, true); - await trySetMonitorMuteSuppressed(nativeAudio, false); + // Arbiter fix: releaseRoute used to FORCE monitorMute=true and clear + // suppression unconditionally — clobbering the user's persisted + // preference and any other writer's suppression window. The chain is + // cleared above, so the engine's own empty-chain dry-mute semantics + // apply; any preload override this executor still holds is released + // by its own scheduled closure. if (releaseFailure) return updateOutcome(route, releaseFailure); routes.delete(routeKey); return safeOutcome('handled', 'Audio-effects route released', { routeKey, providerId: route.providerId, planId: route.planId, cleanupFailures }); diff --git a/tests/audio-effects-executor.test.js b/tests/audio-effects-executor.test.js index e072a06..8104c15 100644 --- a/tests/audio-effects-executor.test.js +++ b/tests/audio-effects-executor.test.js @@ -200,18 +200,26 @@ test('audio-effects executor owns load mute, route gain, start, and release', as assert.equal(gained.outcome, 'handled'); assert.equal(released.outcome, 'handled'); assert.equal(inspected.outcome, 'no-target'); - assert.deepEqual(calls.slice(0, 7), [ - ['is-muted'], + // Monitor-mute arbiter (TLC Part II §2): the executor never reads or + // writes the user's mute preference — it acquires a suppression for the + // dry-during-load window (default) and releases exactly what it acquired. + assert.deepEqual(calls.slice(0, 6), [ ['gain', 'chain', 0], - ['monitor', false], + ['suppress', true], ['load', 2], ['gain', 'input', 8], ['start'], ['gain', 'chain', 2], ]); assert.equal(calls.some(call => call[0] === 'clear'), true); - assert.equal(calls.some(call => call[0] === 'monitor' && call[1] === true), true); - assert.equal(calls.some(call => call[0] === 'suppress' && call[1] === false), true); + // The preference API is untouched, in both directions — releaseRoute no + // longer forces monitorMute=true over the user's persisted choice. + assert.equal(calls.some(call => call[0] === 'is-muted'), false); + assert.equal(calls.some(call => call[0] === 'monitor'), false); + // The suppression is balanced: one acquire, one release — never an + // unpaired clear that would cancel another writer's window. + assert.equal(calls.filter(call => call[0] === 'suppress' && call[1] === true).length, 1); + assert.equal(calls.filter(call => call[0] === 'suppress' && call[1] === false).length, 1); assert.equal(calls.some(call => call[0] === 'gain' && call[1] === 'chain' && call[2] === 4), false); assert.equal(calls.some(call => call[0] === 'gain' && call[1] === 'chain' && call[2] === 0), true); }); diff --git a/tests/contracts/addon-exports.json b/tests/contracts/addon-exports.json index aac4fa5..dd85205 100644 --- a/tests/contracts/addon-exports.json +++ b/tests/contracts/addon-exports.json @@ -1,4 +1,5 @@ [ + "acquireMonitorMuteHold", "addSource", "bindInputDevice", "clearChain", @@ -17,6 +18,7 @@ "getDeviceTypes", "getKnownPlugins", "getLevels", + "getMonitorMuteState", "getNoteVerdicts", "getParameters", "getPitchDetection", @@ -52,6 +54,7 @@ "openPluginEditor", "probeDeviceOptions", "pushRendererAudio", + "releaseMonitorMuteHold", "removeProcessor", "removeSource", "replaceIR", From f0340ed425ba204e0a9d35832faa4e569e37ed5e Mon Sep 17 00:00:00 2001 From: OmikronApex Date: Tue, 14 Jul 2026 03:02:18 +0200 Subject: [PATCH 20/28] =?UTF-8?q?fix(audio-engine):=20single=20persistence?= =?UTF-8?q?=20store=20for=20device=20settings=20(TLC=20Part=20II=20=C2=A74?= =?UTF-8?q?)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The device config was persisted in TWO stores — the main process's file-backed settings AND localStorage['slopsmith-audio-device'] — merged on load by newest-savedAt. A main-side migration/reset left stale localStorage that could win the timestamp race and resurrect wiped settings, and a device re-save from either path re-persisted mute flags captured at that moment, interleaving with the (now-arbitrated) runtime mute writers. The file store is now the only write target. localStorage is treated as a one-time migration source: a strictly-newer browser copy is imported into the file store, then the key is deleted either way — after the first load the file is the single source of truth. Co-Authored-By: Claude Fable 5 --- src/renderer/screen.js | 27 +++++++++++++++++++++------ 1 file changed, 21 insertions(+), 6 deletions(-) diff --git a/src/renderer/screen.js b/src/renderer/screen.js index 8f449ee..c7e5dae 100644 --- a/src/renderer/screen.js +++ b/src/renderer/screen.js @@ -210,11 +210,14 @@ window.__feedBackDesktopAudioHooks = window.__feedBackDesktopAudioHooks || {}; } function saveDeviceSettings(settings = captureDeviceSettings()) { + // Single persistence store (TLC Part II §4): the file-backed settings + // are the only writer target. The old parallel localStorage copy meant + // a main-side migration/reset could lose the timestamp race against a + // stale browser copy and resurrect wiped settings. const snapshot = { ...cloneDeviceSettings(settings), savedAt: Date.now(), }; - try { localStorage.setItem('slopsmith-audio-device', JSON.stringify(snapshot)); } catch (_) {} pendingDeviceSave = pendingDeviceSave .catch(() => null) .then(() => { @@ -255,17 +258,29 @@ window.__feedBackDesktopAudioHooks = window.__feedBackDesktopAudioHooks || {}; } catch (e) { console.warn('[audio-engine] Failed to load file-backed device settings:', e); } + // Migration only (TLC Part II §4): 'slopsmith-audio-device' was a + // second store racing the file on savedAt. Import a strictly-newer + // browser copy into the file store ONCE, then delete the key either + // way — after this the file is the single source of truth. let browserSettings = null; try { const raw = localStorage.getItem('slopsmith-audio-device'); browserSettings = normalizeDeviceSettings(raw ? JSON.parse(raw) : null); } catch { browserSettings = null; } - if (fileSettings && browserSettings) { - return getDeviceSettingsSavedAt(browserSettings) > getDeviceSettingsSavedAt(fileSettings) - ? browserSettings - : fileSettings; + if (browserSettings !== null) { + const browserNewer = !fileSettings + || getDeviceSettingsSavedAt(browserSettings) > getDeviceSettingsSavedAt(fileSettings); + if (browserNewer) { + try { + if (typeof api.saveDeviceSettings === 'function') await api.saveDeviceSettings(browserSettings); + } catch (e) { + console.warn('[audio-engine] device-settings migration save failed:', e); + } + } + try { localStorage.removeItem('slopsmith-audio-device'); } catch (_) {} + if (browserNewer) return browserSettings; } - return fileSettings || browserSettings; + return fileSettings; } function hasSettingValue(value) { From 9d0963d6d53c77d73464e46df008f6430424c869 Mon Sep 17 00:00:00 2001 From: OmikronApex Date: Tue, 14 Jul 2026 03:05:19 +0200 Subject: [PATCH 21/28] =?UTF-8?q?feat(audio):=20getLatencyBreakdown=20?= =?UTF-8?q?=E2=80=94=20one=20owner=20for=20every=20latency=20term?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Deep-read §5: latency had three unreconciled truths — getLatencyMs' static half-capacity ring guess (42.7 ms), the verifier's input-delta-only offset, and the renderer bus adding prime+fill+resample that no figure surfaced. New engine API + export: per-term breakdown (deviceBufferMs, input/output driver latency, MEASURED split-ring residency, monitor total) plus the renderer-bus song-audio delay (measured bus fill) as its own term. On the user's split exclusive setup the measured ring sits at ~10 ms — the legacy figure overstated monitor latency by ~33 ms (102.7 reported vs ~70 real). getLatencyMs is unchanged for compatibility; UI adoption of the breakdown is renderer follow-up. Snapshots regenerated (104 exports). Co-Authored-By: Claude Fable 5 --- src/audio/AudioEngine.cpp | 48 ++++++++++++++++++++++++++++++ src/audio/AudioEngine.h | 19 ++++++++++++ src/audio/NodeAddon.cpp | 2 ++ src/audio/addon/Bindings.h | 2 ++ src/audio/addon/DeviceBindings.cpp | 18 +++++++++++ tests/contracts/addon-exports.json | 1 + 6 files changed, 90 insertions(+) diff --git a/src/audio/AudioEngine.cpp b/src/audio/AudioEngine.cpp index 84949a0..4f258ea 100644 --- a/src/audio/AudioEngine.cpp +++ b/src/audio/AudioEngine.cpp @@ -228,6 +228,54 @@ double AudioEngine::getLatencyMs() const return (totalSamples / sr) * 1000.0; } +AudioEngine::LatencyBreakdown AudioEngine::getLatencyBreakdown() const +{ + LatencyBreakdown b; + const double sr = currentSampleRate.load(std::memory_order_relaxed); + b.sampleRate = sr; + b.duplex = duplexMode.load(std::memory_order_relaxed); + if (sr <= 0.0) return b; + const auto ms = [sr](double samples) { return (samples / sr) * 1000.0; }; + + if (b.duplex) + { + if (auto* device = inputDeviceManager.getCurrentAudioDevice()) + { + b.deviceBufferMs = ms(device->getCurrentBufferSizeSamples()); + b.inputLatencyMs = ms(device->getInputLatencyInSamples()); + b.outputLatencyMs = ms(device->getOutputLatencyInSamples()); + } + } + else + { + if (auto* in = inputDeviceManager.getCurrentAudioDevice()) + { + b.deviceBufferMs += ms(in->getCurrentBufferSizeSamples()); + b.inputLatencyMs = ms(in->getInputLatencyInSamples()); + } + if (auto* out = outputDeviceManager.getCurrentAudioDevice()) + { + b.deviceBufferMs += ms(out->getCurrentBufferSizeSamples()); + b.outputLatencyMs = ms(out->getOutputLatencyInSamples()); + } + // MEASURED ring residency — getLatencyMs() still reports the static + // half-capacity estimate for compatibility; this is the live figure + // getDeviceMetrics already exposes, converted to time. + const uint64_t w = outputRing.writeIndex.load(std::memory_order_acquire); + const uint64_t r = outputRing.readIndex.load(std::memory_order_acquire); + const uint64_t fill = (w >= r) ? (w - r) : 0; + b.splitRingMs = ms((double) std::min(fill, (uint64_t) kOutputRingFrames)); + } + b.monitorTotalMs = b.deviceBufferMs + b.inputLatencyMs + b.outputLatencyMs + b.splitRingMs; + + // Song audio over the renderer bus is delayed by the measured bus fill — + // a term no previous latency figure surfaced (deep-read 5). + const auto busMetrics = rendererBus.metrics(); + if (busMetrics.enabled) + b.rendererBusMs = ms((double) busMetrics.fillFrames); + return b; +} + // ── Device Selection ────────────────────────────────────────────────────────── bool AudioEngine::setDeviceType(const juce::String& typeName) diff --git a/src/audio/AudioEngine.h b/src/audio/AudioEngine.h index c0999bf..a2db77b 100644 --- a/src/audio/AudioEngine.h +++ b/src/audio/AudioEngine.h @@ -280,6 +280,25 @@ public: // Latency double getLatencyMs() const; + // One owner for every latency term (TLC deep-read §5) — the previous + // three unreconciled truths were getLatencyMs' static half-capacity ring + // guess, the verifier's input-latency-delta-only offset, and the renderer + // bus adding prime+fill+resample that no figure surfaced. All ms. + struct LatencyBreakdown + { + double sampleRate = 0.0; + bool duplex = true; + double deviceBufferMs = 0.0; // input buffer (+ output buffer when split) + double inputLatencyMs = 0.0; // driver-reported capture latency + double outputLatencyMs = 0.0; // driver-reported playback latency + double splitRingMs = 0.0; // MEASURED primary-ring residency (0 in duplex) + double monitorTotalMs = 0.0; // guitar in→out: buffers + in/out + splitRing + // Renderer-bus song-audio delay: measured bus fill (includes the + // ~10.7 ms prime cushion once flowing). 0 when the bus is off. + double rendererBusMs = 0.0; + }; + LatencyBreakdown getLatencyBreakdown() const; + // Raw input frame snapshot for renderer-side polyphonic chord scoring in // notedetect. Backed by sources[0]'s pre-gate input ring; the rings (and the // power-of-two capacity constants) now live on SourceChain. Default snapshot diff --git a/src/audio/NodeAddon.cpp b/src/audio/NodeAddon.cpp index f05ce03..503b8e7 100644 --- a/src/audio/NodeAddon.cpp +++ b/src/audio/NodeAddon.cpp @@ -109,6 +109,7 @@ using slopsmith::addon::SetMonitorMuteSuppressed; using slopsmith::addon::AcquireMonitorMuteHold; using slopsmith::addon::ReleaseMonitorMuteHold; using slopsmith::addon::GetMonitorMuteState; +using slopsmith::addon::GetLatencyBreakdown; using slopsmith::addon::SetMultiBypass; using slopsmith::addon::SetNoiseGate; using slopsmith::addon::SetNoteDetectionEnabled; @@ -352,6 +353,7 @@ static Napi::Object InitModule(Napi::Env env, Napi::Object exports) exports.Set("acquireMonitorMuteHold", Napi::Function::New(env, AcquireMonitorMuteHold)); exports.Set("releaseMonitorMuteHold", Napi::Function::New(env, ReleaseMonitorMuteHold)); exports.Set("getMonitorMuteState", Napi::Function::New(env, GetMonitorMuteState)); + exports.Set("getLatencyBreakdown", Napi::Function::New(env, GetLatencyBreakdown)); exports.Set("isMonitorMuted", Napi::Function::New(env, IsMonitorMuted)); exports.Set("setMonitorKill", Napi::Function::New(env, SetMonitorKill)); exports.Set("setNoiseGate", Napi::Function::New(env, SetNoiseGate)); diff --git a/src/audio/addon/Bindings.h b/src/audio/addon/Bindings.h index 641ca4c..d53f754 100644 --- a/src/audio/addon/Bindings.h +++ b/src/audio/addon/Bindings.h @@ -11,6 +11,8 @@ 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. diff --git a/src/audio/addon/DeviceBindings.cpp b/src/audio/addon/DeviceBindings.cpp index 0f2fb78..f68d1d2 100644 --- a/src/audio/addon/DeviceBindings.cpp +++ b/src/audio/addon/DeviceBindings.cpp @@ -831,4 +831,22 @@ Napi::Value GetSampleRate(const Napi::CallbackInfo& info) } +Napi::Value GetLatencyBreakdown(const Napi::CallbackInfo& info) +{ + auto env = info.Env(); + auto obj = Napi::Object::New(env); + auto liveEngine = snapshotEngine(); + if (!liveEngine) return obj; + const auto b = liveEngine->getLatencyBreakdown(); + obj.Set("sampleRate", b.sampleRate); + obj.Set("duplex", b.duplex); + obj.Set("deviceBufferMs", b.deviceBufferMs); + obj.Set("inputLatencyMs", b.inputLatencyMs); + obj.Set("outputLatencyMs", b.outputLatencyMs); + obj.Set("splitRingMs", b.splitRingMs); + obj.Set("monitorTotalMs", b.monitorTotalMs); + obj.Set("rendererBusMs", b.rendererBusMs); + return obj; +} + } // namespace slopsmith::addon diff --git a/tests/contracts/addon-exports.json b/tests/contracts/addon-exports.json index dd85205..a4b7e17 100644 --- a/tests/contracts/addon-exports.json +++ b/tests/contracts/addon-exports.json @@ -17,6 +17,7 @@ "getDeviceMetrics", "getDeviceTypes", "getKnownPlugins", + "getLatencyBreakdown", "getLevels", "getMonitorMuteState", "getNoteVerdicts", From 48d7e68a917eac6e58bf351048e1b303ed073fd5 Mon Sep 17 00:00:00 2001 From: OmikronApex Date: Tue, 14 Jul 2026 03:18:33 +0200 Subject: [PATCH 22/28] test: fix two Windows-environment-dependent failures (suite now fully green) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Both tests pre-dated this branch and failed only on Windows checkouts — the product code was correct in both cases: - audio-effects-executor 'preload exposes the trusted surface': asserted a byte-exact two-line bridge snippet with \n, which never matches a core.autocrlf (CRLF) working tree. Line endings are now normalized before the includes checks. - config-paths 'SAFETY: ... ONLY in optInExtras': rebuilt the expected ML cache paths with host-native path.join, producing backslash paths that never equal the forward-slash simulated envs — failing the mlCaches equality and, worse, making the protected-root child checks vacuously pass on Windows (a silent coverage gap in the safety assertions). The test now uses the envs' resolved torchHome/hfHome fields, exactly what production returns, with '/' as the child separator. npm test: 78/78 passing (1 quarantined storm gate, green under CHAIN_STORM=1). Co-Authored-By: Claude Fable 5 --- tests/audio-effects-executor.test.js | 7 +++++-- tests/config-paths.test.js | 16 +++++++++++----- 2 files changed, 16 insertions(+), 7 deletions(-) diff --git a/tests/audio-effects-executor.test.js b/tests/audio-effects-executor.test.js index 8104c15..613a5a9 100644 --- a/tests/audio-effects-executor.test.js +++ b/tests/audio-effects-executor.test.js @@ -376,8 +376,11 @@ test('audio-effects executor rejects coerced parameter indices', async () => { }); test('preload exposes the trusted audio-effects executor surface', () => { - const preload = fs.readFileSync(path.join(ROOT, 'src', 'main', 'preload.ts'), 'utf8'); - const bridge = fs.readFileSync(path.join(ROOT, 'src', 'main', 'audio-bridge.ts'), 'utf8'); + // Normalize line endings: the multi-line snippet assertion below uses + // \n, but a Windows checkout with core.autocrlf reads these files as + // \r\n — the test must not depend on the developer's git config. + const preload = fs.readFileSync(path.join(ROOT, 'src', 'main', 'preload.ts'), 'utf8').replace(/\r\n/g, '\n'); + const bridge = fs.readFileSync(path.join(ROOT, 'src', 'main', 'audio-bridge.ts'), 'utf8').replace(/\r\n/g, '\n'); assert.equal(preload.includes('audioEffects: {'), true); for (const method of ['loadChainPlan', 'releaseRoute', 'inspectRoute', 'activateSegment', 'setStageBypass', 'setStageParameter', 'setRouteGain']) { diff --git a/tests/config-paths.test.js b/tests/config-paths.test.js index c194811..2e195d1 100644 --- a/tests/config-paths.test.js +++ b/tests/config-paths.test.js @@ -111,17 +111,23 @@ test('SAFETY: song library, installed plugins and ML caches are ONLY in optInExt ...cats.pluginStateAndPyDeps, ...cats.configDbsAndState, ]; - // None of the safe categories may equal or be a child of the protected dirs. + // None of the safe categories may equal or be a child of the protected + // dirs. Use the env's RESOLVED fields (exactly what production + // returns) rather than rebuilding them with host-native path.join — + // on Windows that produced backslash paths that never matched the + // forward-slash simulated envs, failing the mlCaches equality AND + // silently vacuous-passing these child checks. The simulated env + // paths are forward-slash on every platform, so '/' is the separator. const protectedRoots = [ env.dlcDir, env.pluginsDir, - path.join(env.cacheBase, 'torch'), - path.join(env.cacheBase, 'huggingface'), + env.torchHome, + env.hfHome, ]; for (const root of protectedRoots) { assert.ok(!safe.includes(root), `${name}: ${root} leaked into a safe category`); assert.ok( - !safe.some((p) => p === root || p.startsWith(root + path.sep)), + !safe.some((p) => p === root || p.startsWith(root + '/')), `${name}: a safe path lives under protected ${root}`, ); } @@ -130,7 +136,7 @@ test('SAFETY: song library, installed plugins and ML caches are ONLY in optInExt assert.deepEqual(cats.optInExtras.installedPlugins, [env.pluginsDir], `${name}: installedPlugins`); assert.deepEqual( cats.optInExtras.mlCaches, - [path.join(env.cacheBase, 'torch'), path.join(env.cacheBase, 'huggingface')], + [env.torchHome, env.hfHome], `${name}: mlCaches`, ); } From d887c68014996e7f8974d90c605b51420e0fa927 Mon Sep 17 00:00:00 2001 From: OmikronApex Date: Tue, 14 Jul 2026 03:24:46 +0200 Subject: [PATCH 23/28] docs: commit the audio-engine TLC findings + plan this branch implements Co-Authored-By: Claude Fable 5 --- docs/audio-engine-tlc.md | 940 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 940 insertions(+) create mode 100644 docs/audio-engine-tlc.md diff --git a/docs/audio-engine-tlc.md b/docs/audio-engine-tlc.md new file mode 100644 index 0000000..131c7bb --- /dev/null +++ b/docs/audio-engine-tlc.md @@ -0,0 +1,940 @@ +> **Execution status (2026-07-14, branch `refactor/audio-engine-tlc`):** implemented. +> Phases 0-8 of Part IV/V are complete on this branch, including the chain-mutation +> serializer + chainGeneration (native + executor), the monitor-mute arbiter, the +> single persistence store, and getLatencyBreakdown. Two sequencing changes vs. the +> roadmap below: the gain-sanitization fix shipped first (before phase 1), and the +> Part II ownership work that needs the rig_builder repo (single chain owner, +> legacy-path deletion, alias removal) is NOT on this branch. Drift note: the +> "21 clearChain call sites" count in Part II grew to 30 by execution time. + +# Audio Engine TLC — Consolidated Findings & Refactor Plan + +TLC pass on the feedBack-desktop audio engine (2026-07-12, branch `fix/loopback-capture-permission`). +Single consolidated document; supersedes the four separate docs +(overview / collisions / deep-read / refactor-plan). + +Contents: +- **Part I** — how the engine works and integrates with the app, effects (NAM/VST/IR), plugins. +- **Part II** — redundant code, settings with multiple writers, control collisions. +- **Part III** — line-level deep read of `AudioEngine.cpp` and `NodeAddon.cpp`. +- **Part IV** — decomposition plan for the monolithic files. +- **Part V** — merged priority roadmap. + +--- + +# Part I — Architecture Overview +Covers: engine architecture, desktop-app integration, input/output paths, effects (NAM / VST / IR), +detection pipeline, and which bundled plugins touch the engine and how. + +--- + +### 1. Layer map + +``` +Renderer plugins (rig_builder, note_detect, stems, …) + │ window.feedBackDesktop.audio.* / .audioEffects.* (aliased: slopsmithDesktop) + ▼ +preload.ts ── contextBridge, ~99 audio methods + audioEffects methods + │ ipcRenderer.invoke / send + ▼ +Main process + audio-bridge.ts 102 ipcMain.handle channels (audio:*, audio-effects:*) + audio-effects-executor.ts validates chain plans, drives native chain + vst-crash-guard.ts sentinel files → blocklist crashy VSTs across restarts + plugin-manager.ts git-based plugin install/update (server plugins) + │ require('slopsmith_audio.node') + ▼ +NodeAddon.cpp (N-API, ~160 KB) ── marshals JS ⇄ C++, async workers for VST loads + ▼ +AudioEngine (JUCE, C++) src/audio/ + ├── SourceChain ×8 (pooled) per-input capture + detect + tone chain + │ └── SignalChain ordered ProcessorSlots (VST | NAM | IR) + ├── Backing-track transport + signalsmith-stretch + ├── Stream sink (2nd output device for OBS/Discord) + └── Renderer bus (WebAudio master → engine output) + ▼ +JUCE AudioDeviceManager(s) → WASAPI / ASIO / DirectSound / CoreAudio / ALSA / JACK + +Out-of-process: slopsmith-vst-host.exe (src/vst-host/main.cpp) — VST3 sandbox child. +``` + +Key files: + +| Area | File | +|---|---| +| Engine core | `src/audio/AudioEngine.{h,cpp}` (47K header / 156K impl) | +| Per-input chain | `src/audio/SourceChain.{h,cpp}` | +| Effects chain | `src/audio/SignalChain.{h,cpp}` | +| NAM | `src/audio/NAMProcessor.{h,cpp}` (wraps NeuralAmpModelerCore, `src/audio/third_party/NAM`) | +| IR / cab | `src/audio/IRLoader.{h,cpp}` (juce::dsp::Convolution) | +| VST hosting | `src/audio/VSTHost.{h,cpp}` | +| VST sandbox | `src/audio/Sandbox/*` + `src/vst-host/main.cpp` | +| Detection | `PitchDetector` (YIN), `MlNoteDetector` (Basic Pitch ONNX), `ChordScorer`, `NoteVerifier`, `OnsetDetector` | +| Utility DSP | `NoiseGate`, `TonePolish`, `BackingLeveler`, `AudioSanitize` | +| JS bridge | `src/audio/NodeAddon.cpp`, `src/main/audio-bridge.ts`, `src/main/preload.ts` | + +--- + +### 2. AudioEngine core + +`AudioEngine` is a `juce::AudioIODeviceCallback` owning **two** `AudioDeviceManager`s: + +- **Duplex mode** (default): `inputDeviceManager` owns both directions; one callback + (`audioDeviceIOCallbackWithContext`) reads input, processes, writes output directly. +- **Split mode**: input-only on `inputDeviceManager`, output-only on `outputDeviceManager` + (separate device types possible, e.g. ASIO in + WASAPI out). Processed stereo crosses via + `outputPendingRing` — a lock-free SPSC ring of 4096 frames where each stereo frame is packed + into one `atomic` (bit_cast L|R) so reads are tear-free. ~85 ms of drift absorption. + Input and output block sizes may differ; the ring absorbs asymmetry. + +Device management surface: enumerate types/devices, dual-type probing +(`probeDeviceOptionsDual` — sample-rate intersection, `compatible` flag), `setAudioDevices`, +metrics (overflow/underflow counters, ring fill). Config persisted by the renderer via +`audio:saveDeviceSettings` / `loadDeviceSettings` (legacy single-`type` settings are mirrored to +input+output type). + +Threading model (recurring pattern throughout): audio thread never locks — atomics everywhere, +lock-free SPSC rings, `static_assert(is_always_lock_free)`, control-thread mutation via +pending-flag handoff (e.g. `backingPendingSpeed`), and drop-oldest on overflow with counters. + +--- + +### 3. Audio input + +#### Sources (multi-input) + +Per-input state lives on **SourceChain**, a fixed pool of `kMaxSources = 8` constructed up front +(pointers never reassigned → no race with audio thread; add/remove only flips an atomic `active` +flag). `sources[0]` is the permanent legacy default input; the engine facade forwards the +single-source API to it so NodeAddon/renderer needed no change. + +- `addSource(inputChannel, deviceKey)` — bind another channel of the current device + (multi-channel interfaces, e.g. Valeton GP-5) or of an **additional physical input device** + (`bindInputDevice(deviceKey, name)`, up to 3 extras, each at its own clock; forces split mode). +- Removal uses a per-deviceKey `callbacksInFlight` counter handshake; wedged callbacks defer the + release (`pendingRelease[]`) instead of blocking. +- Per-source: input gain, channel select (-1 = mono mix), monitor mute/kill, meters, verifier + offsets (auto device-latency delta + user fine-tune, summed). + +#### Per-source capture path (SourceChain::processBlock, audio thread) + +``` +device input → channel select / mono mix → inputGain + ├─→ MlNoteDetector feed + pre-gate inputFrameRing (8192, SPSC) [getInputFrame/getInputSince] + → NoiseGate (post-gain, pre-FX; pitch detector sees ungated signal) + ├─→ YIN PitchDetector feed + post-gate rawAudioRing (16384) [getRawAudioFrame → tuner] + → SignalChain (VST/NAM/IR tone chain) + → sanitize (non-finite/runaway scrub, counted — issue #403) + → monitor mute / monitor kill / chainOutputGain → TonePolish (fixed 3-band EQ, guitar bus only) + → summed into output mix (sourceMonitorScratch, pre-sized) +``` + +Monitor semantics: `monitorMute` mutes dry pass-through only when chain empty (suppressible +around song-load chain rebuilds); `monitorKill` silences the guitar bus unconditionally +(external-rig users), applied to every pooled source. + +--- + +### 4. Effects — SignalChain + +Ordered `ProcessorSlot`s, each `Type::{VST, NAM, IR, Empty}` holding a +`unique_ptr`. Features: + +- **Routing**: per-slot `pan` (constant-power), `branch` (0 = serial trunk, ≥1 = parallel branch, + branches read the pre-split signal, panned outputs summed at merge), `branchSrc` (branch reads + L / R / both), `postGain` (per-amp loudness trim). Pre-allocated scratch buffers; all-trunk path + pays nothing. +- **State**: per-slot base64 VST state; whole-chain JSON preset save/load (`savePreset` / + `loadPreset`); `replaceProcessor` swaps a slot in place (same id/position) — used for sandbox + promotion and `replaceIR` cab swaps; `setSlotState` for the tone-switcher's incremental rebuild. +- **MIDI**: lock-free SPSC queue (64 msgs), `queueMidiMessage(slotId, msg)` from the N-API thread + → drained on audio thread (`audio:sendMidiToSlot`). +- Oversized device blocks (WASAPI shared after device start) are sliced to the prepared block size. +- SEH/signal guards around plugin prepare/state calls; faults blocklist the plugin path. + +#### Processor types + +- **NAMProcessor** — mono in/out neural amp model (`.nam`), NeuralAmpModelerCore backend + (`SLOPSMITH_NAM_SUPPORT`). Async-safe model load: staged `pendingModel`, atomic swap. + Input/output level params. No editor. +- **IRLoader** — cab IR convolution (`.wav/.aif/.ir`) via `juce::dsp::Convolution`. +- **VST3** via **VSTHost**: background directory scanning (per-file subprocess probe through + `slopsmith-vst-host --scan-plugin` → XML merge, so crashy plugins can't kill the app), + known-plugin persistence, sync `loadPlugin` + async `loadPluginAsync` (message-thread pumping + required for AmpliTube-class plugins that post messages to themselves during init). + +#### VST sandbox (out-of-process) + +`SandboxedProcessor` (src/audio/Sandbox) is a `juce::AudioProcessor` façade that forwards +everything to a spawned `slopsmith-vst-host.exe` child over a control pipe + shared-memory audio +channel (`Protocol.h`; platform impls `_win` / `_posix` / `_shared`). Properties: + +- One child per plugin; child dies with the processor. Crash → `isAlive()` false → audio thread + inserts silence; `CrashCallback` + `CrashAttribution` report which plugin died. +- Child owns the plugin editor as its own top-level window (Reaper-style; cross-process HWND + reparenting broke D3D/GL plugins like Neural DSP Archetypes). +- **Promotion path**: an in-process VST3 is promoted to the sandbox when its editor is opened + (in-process editors are the Windows WndProc/Qt crash path). `captureVstStateForPromotion` + snapshots state under lock + SEH guard, then `replaceProcessor` swaps in the sandboxed twin. +- Child guarantees JUCE MessageManager on the OS main thread (impossible in the Node addon where + V8 owns it); audio runs on a dedicated ring-drain worker. +- Known v1 gaps (documented in header): no `AudioProcessorParameter` proxies; bus layout + hard-coded stereo↔stereo. +- **vst-crash-guard.ts** (main process): arms a sentinel file before risky load/editor operations; + a crash leaves the sentinel behind → next launch blocklists that plugin. + +--- + +### 5. Audio output + +Three output paths, mixed in the engine: + +1. **Monitor output** (primary device): sum of active sources' processed guitar buses + + backing-track mix (`backingVolume` fader, `BackingLeveler` per-song loudness normalizer) + + master `outputGain`. Duplex writes in-callback; split drains `outputPendingRing`. +2. **Stream sink** — an ADDITIONAL output device carrying an independent submix + (backing/game and/or guitar monitor, own gain, sanitized 0..8) for OBS/Discord capture. + `setStreamOutputDevice` / `setStreamBus`; underflow/overflow counters + level meter exposed. +3. **Renderer bus** (Phase 2, exclusive-mode support): the renderer's WebAudio master mix is + pushed over IPC (`audio:pushRendererAudio`, fire-and-forget `ipcRenderer.send`) into a large + SPSC ring (65536 frames ≈ 1.5 s; producer is the jittery IPC thread), linear-resampled + producer-side to device rate, mixed into engine output. Keeps song/stem audio audible when the + output device is exclusive-style (ASIO / WASAPI exclusive) and the OS mixer path is silent. + Fed renderer-side by a whole-app `getDisplayMedia({audio})` loopback capture — + `setDisplayMediaRequestHandler` in `main.ts` grants it (audio-only, own-app loopback; other + apps' audio not captured). Current branch (`fix/loopback-capture-permission`) fixes the media + permission handler to allow this getDisplayMedia request. + +**Backing track**: JUCE `AudioFormatReaderSource` → `AudioTransportSource` buffered by a +`TimeSliceThread` read-ahead → optional signalsmith-stretch phase vocoder for speed change +(1x bypass path; lock-free speed handoff via pending atomic so slider drags never block the RT +tryLock). Playhead = accumulated heard frames minus stretcher latency; non-blocking cached +position/duration getters. Used for local audio-file playback; sloppak/HTML5-routed songs play +through the renderer's WebAudio instead (engine playhead frozen; the verifier is fed the +renderer's corrected playhead via `setPlayhead`). + +--- + +### 6. Detection & scoring (engine-side) + +- **PitchDetector** — monophonic YIN, sub-Hz parabolic interpolation, reads post-gate signal + (silent when gate closed). Backs the always-on home tuner (`audio:getRawPitch`). +- **MlNoteDetector** — polyphonic Basic Pitch ONNX model (`loadNoteModel`); armed only while a + consumer actually reads ML notes (`setMlNoteDetectionEnabled`) so ONNX inference isn't paid + otherwise. `getActiveDetection()` prefers ML when loaded, else YIN — same shape either way. +- **ChordScorer** — scores a renderer-supplied chord context against the input ring + (`audio:scoreChord`); ML-backed variant when the ML detector is live. +- **NoteVerifier** — background thread per source; renderer pushes the chart once (`setChart`), + verifier scores each note's timing window against the live playhead + input ring, renderer + drains verdicts (`getNoteVerdicts`). Replaced the per-tick scoreChord IPC loop that starved on + dense passages. Playhead offset = auto device-latency delta + user fine-tune. +- **OnsetDetector** — consumes the input ring gaplessly via `getInputSince`. + +--- + +### 7. Main-process integration + +- **audio-bridge.ts** (65K): loads `slopsmith_audio.node`, registers all 102 `audio:*` / + `audio-effects:*` IPC handlers, normalizes/persists device settings, wires vst-crash-guard + sentinels around VST loads and editor opens, forwards renderer-bus audio. +- **audio-effects-executor.ts**: the capability-pipeline backend for the `audio-effects` + capability. Accepts validated **chain plans** (`feedBack.audio_effects.chain_plan.v1`, legacy + `slopsmith.…` schema accepted): up to 24 stages of kind `nam | ir | vst | utility | bypass` + with roles (pedal/amp/cab/…), route keys (default `desktop-main`), gain sets, authorization + gating (`user-action` / `restore-selection` / `playback-session`). Translates plans into native + calls (loadPreset/clearChain/setBypass/setParameter/setGain/…) and reports structured outcomes + (`handled | degraded | failed | unavailable | no-target | user-action-required`). +- **preload.ts**: exposes the whole surface as `window.feedBackDesktop` (alias + `window.slopsmithDesktop`) — `audio.*` (~99 methods) + `audioEffects.*`. +- **NodeAddon.cpp**: N-API glue; libuv async workers for plugin loads (message thread keeps + pumping); sandbox-aware VST loading (`loadVstSandboxAware`); shutdown cancels pending loads. + +--- + +### 8. Bundled plugins that touch audio + +Plugins are renderer-side (screen.js + plugin.json manifest, capability-pipelines.v1). The ones +interacting with the engine: + +| Plugin | Interaction | +|---|---| +| **audio_engine** (bundled in this repo, `src/renderer/`) | The engine's own UI: device setup, chain editor, meters. Declares provider capabilities `audio-input`, `audio-mix`, `audio-monitoring`; observes `playback` lifecycle to rebuild tone automation / tear down native chain state. | +| **rig_builder** | Biggest consumer. Builds amp/cab/pedal rigs; declares `audio-effects` capability and submits chain plans (NAM stages, IRs, VSTs, per-stage gain/bypass/params) through audio-effects-executor. Also privileged-capabilities, jobs, library. Own repo also contains a VST (`vst/`) and tone-curation tooling. | +| **nam_tone** | NAM tone library (server-side): manages `nam_models/`, `nam_irs/`, `nam_tone.db` on the Python backend; models/IRs are what `audio:loadNAMModel` / `loadIR` consume. | +| **note_detect** | Real-time detection/scoring: arms ML detection, pushes charts (`setChart`), drains verdicts, reads pitch/raw frames, drives per-source scoring APIs. | +| **stems** / **stem_mixer** | WebAudio-side stem mix (`audio-mix` capability, mute/volume commands). Their master mix reaches the engine only via the renderer bus on exclusive-mode outputs. | +| **midi_amp** | Sends MIDI Program Change to external amps/modelers on tone switches (external gear path; engine-side per-slot MIDI exists via `audio:sendMidiToSlot`). | +| **tuner (built-in home tuner)** | Always-on YIN readout via `getRawPitch` / `getRawAudioFrame` — deliberately never pays ONNX cost. | +| **virtuoso / practice / minigames** | Consume detection results (verdicts/pitch) rather than driving the chain. | + +Plugin lifecycle: `plugin-manager.ts` installs/updates plugins as git checkouts under the +plugins dir (https-only remotes, path-safe names); restart of the Python backend activates them. + +--- + +### 9. Observations for the TLC pass (starting points) + +- `AudioEngine.cpp` (156K) and `NodeAddon.cpp` (160K) are monoliths; SourceChain extraction + ("Phase 0/2" comments) is mid-flight — multi-source fan-out phases still landing. +- Duplicated facade surface: engine forwards ~40 single-source methods to `source0()` while a + parallel `getSource(id)`-indexed API grows alongside (`audio:*` vs `audio:setSource*`). +- Sandbox v1 gaps documented in `SandboxedProcessor.h` (no parameter proxies, fixed stereo buses). +- Three separate SPSC ring implementations (outputPendingRing, renderer bus, stream sink) share + the packed-LR pattern — candidate for one templated ring. +- Backing-track transport is legacy for sloppak songs (renderer WebAudio does playback); the + frozen-playhead special case leaks into NoteVerifier via `setPlayhead`. +- Naming drift: slopsmith → feedBack rebrand half-done (addon name `slopsmith_audio.node`, + `slopsmith-vst-host.exe`, legacy schema ids, `window.slopsmithDesktop` alias). + +--- + +# Part II — Redundancy & Control Collisions +Every finding below was +verified in source; file references point at the current tree. + +Severity legend: 🔴 active conflict (two writers fight at runtime) · 🟠 dual ownership +(same setting settable from two places, last-writer-wins, no arbitration) · 🟡 redundancy +(duplicate surface/code, no runtime conflict yet). + +--- + +### 1. 🔴 Signal chain has three independent writers + +The native `SignalChain` is a single global resource, but three parties load/clear it: + +1. **audio_engine bundle** (`src/renderer/screen.js`): direct `api.loadVST` / `loadNAMModel` + / `loadIR` / `loadPreset` / `clearChain` (21 `clearChain` call sites), plus its own tone + auto-switch/automation (`applyToneMappingsNow`, `applyToneAutomationFor`, + `_restorePresetBlob` → `clearChain` + `loadPreset`). +2. **rig_builder via capability pipeline**: `audioEffects.loadPlan` → main-process + `audio-effects-executor.ts` → `nativeAudio.loadPreset`. +3. **rig_builder legacy direct path**: `feedBackDesktop.audio.loadPreset` (tracked in its own + telemetry as `audio-effects.legacy-native-load`). + +Concrete evidence of the fight (rig_builder `screen.js`): + +> "PROACTIVE TRANSIENT KILL: the bundle calls loadPreset ~1ms after we return this response. +> We can't monkey-patch `feedBackDesktop.audio.loadPreset` … the object is frozen by +> contextBridge" + +rig_builder ships timing hacks (`_rbUnmuteTimer`, transient kill, fallback unmute) purely to +survive the bundle re-loading the chain right after it did. That is two plugins racing on the +same native chain with wall-clock heuristics as the arbiter. + +**Additional executor-state hazard**: the executor keeps a `routes` map with +`stageSlots` (stageId → native slotId). Any direct `loadPreset` / `clearChain` / +`removeProcessor` / `moveProcessor` from path 1 or 3 invalidates those slot ids silently — +subsequent `setStageBypass` / `setStageParameter` / `activateSegment` then flip +bypass/params on the **wrong slots** (slot ids are reused sequentially by `nextSlotId`) or +return `no-target`. Nothing detects the divergence. + +### 2. 🔴 Monitor mute: five writers, one atomic, persisted preference gets clobbered + +`SourceChain::monitorMuted` writers: + +| Writer | Where | When | +|---|---|---| +| audio_engine settings UI checkbox | `screen.js` (`ae-monitor-mute`) | user toggle; persisted in device settings | +| startup restore | `screen.js` ~901 | pushes saved value into engine on boot | +| executor preload-mute | `audio-effects-executor.ts:479-489` | saves `previousMonitorMute`, forces mute/unmute during chain load, restores on a `setTimeout` ramp | +| executor `releaseRoute` | `:616` | **unconditionally** `setMonitorMute(true)` + `setMonitorMuteSuppressed(false)` | +| renderer song-load suppression | `screen.js` (2 sites) + `audio:setMonitorMuteSuppressed` | temporary override around chain rebuild | + +Collisions: + +- `releaseRoute` forces mute=true regardless of the user's persisted `monitorMute:false` + preference — the checkbox UI and the engine now disagree until the next toggle/restart. +- The executor's read-modify-restore (`previousMonitorMute` + delayed `schedulePreloadRestore`) + races a user toggling the checkbox during the hold window: the restore overwrites the fresh + user choice with the stale snapshot. `preloadRestoreVersion` guards against *newer executor + loads*, not against other writers. +- `monitorMuteSuppressed` is set by both the renderer (song load) and executor flows with no + refcount — whoever clears last wins; overlapping windows un-suppress early. + +### 3. 🔴/🟠 Gain: four knobs, three surfaces, inconsistent clamping + +Native gains: per-source `inputGain`, per-source `chainOutputGain`, global `outputGain` +(master), `backingVolume` — all reachable through `audio:setGain(which, value)` +(`NodeAddon.cpp SetGain`), and `input`/`chain` also through +`audio-effects:setRouteGain` + chain-plan `options.gains` + `preloadMute.targetGain`. + +- **Dual ownership of `chain` gain**: audio_engine screen sets it (9 `setGain` sites); + the executor zeroes it (`trySetGain('chain', 0)` on load and on `releaseRoute`) and later + ramps it to `targetGain` (default **1**, or plan-supplied) on a timer. If the user (or tone + automation) set chain gain meanwhile, the ramp silently overwrites it. Same + stale-snapshot race as monitor mute. +- **Clamp inconsistency**: executor clamps to `0..32` (`clampGain`); `NodeAddon::SetGain` does + **no** validation — `NaN`/`Infinity` from any direct `audio:setGain` caller reaches + `outputGain.store()` / `inputGain.store()` raw. The engine sanitizes only the *stream* and + *renderer-bus* gains (`sanitizeStreamGain`, 0..8, explicitly "so a NaN/Inf from JS can never + reach the ring") — the exact same hazard is unguarded for master/input/chain/backing. + A NaN master gain silences output and poisons the peak meters. +- Per-slot `postGain` overlaps conceptually with `chainOutputGain` (both are "level after the + amp"): rig plans carry per-stage loudness trims while the screen's chain gain scales the + same signal — two normalization layers, no documented ownership. + +### 4. 🟠 Device settings: two persistence stores, newest-timestamp arbitration + +`screen.js loadDeviceSettings()` merges **file-backed** settings (main process, +`audio:saveDeviceSettings`) with **`localStorage['slopsmith-audio-device']`**, picking +whichever has the newer `savedAt`. Two stores for one setting means: + +- A main-side migration/reset (`config-reset.ts` territory) leaves stale localStorage that can + win the timestamp race and resurrect wiped settings. +- `monitorMute` / `monitorKill` ride inside the *device* settings blob, so a device re-save + from one path re-persists mute flags captured from checkbox state at that moment — + interleaving with §2's runtime writers. +- Renderer keeps 10 `slopsmith-*` localStorage keys total (`slopsmith-signal-chain`, + `slopsmith-chain-presets`, `slopsmith-tone-automation`, …) — the chain is *also* persisted + renderer-side while rig_builder persists rigs server-side (`routes.py` / DB): two saved + descriptions of the same chain that can disagree on restore. + +### 5. 🟡 Legacy alias surfaces (three layers deep) + +Same setting, multiple entry points kept for back-compat — each a place for behavior to drift: + +- **Engine facade**: `getDeviceManager()` ≡ `getInputDeviceManager()`; + `setInputDeviceType()` ≡ `setDeviceType()`; `DeviceOptions.type` ≡ `inputType`; + single-source methods (`setInputGain`, `setMonitorMute`, `setChart`, `scoreChord`, ~40 of + them) forward to `source0()` while a parallel indexed API (`getSource(id)` → + `audio:setSource*`) does the same thing for id 0. Two IPC routes mutate the same atomic + (`audio:setMonitorMute` vs `audio:setSourceMonitorMute(0, …)`). +- **Settings shape**: legacy `{type}` vs `{inputType, outputType}` normalized in **two + places** — `audio-bridge.ts normalizeDeviceSettings` *and* `screen.js + normalizeDeviceSettings` (duplicated logic, must stay in sync by hand). +- **Schema/branding**: `feedBack.audio_effects.chain_plan.v1` + accepted legacy + `slopsmith.…` id; `window.feedBackDesktop` + `window.slopsmithDesktop`; localStorage keys + still `slopsmith-*`. Each alias doubles the grep surface for every future change. + +### 6. 🟡 Duplicated implementation code + +- **Three packed-LR SPSC rings** in `AudioEngine.h` (split-mode `outputPendingRing`, renderer + bus, stream sink) — same pack/unpack, same power-of-two asserts, same drop-oldest logic, + three hand-maintained copies. One templated ring kills ~2/3 of the index math. +- **Two fail-soft wrappers per method** in the JS layer: audio-bridge's typeof-guarded + handlers and the executor's `trySetGain`/`trySetMonitorMute`/… re-wrap the same native + calls with slightly different error policy (bridge: silent no-op; executor: outcome + strings). A single native-call helper with one policy would remove a class of divergence. +- **`normalizeLoadResult` tolerance duplicated**: both rig_builder (`screen.js`: "Some JUCE + bridges return {success:false} or bare …") and the executor normalize loadPreset results + independently. +- **Chain-restore logic**: executor rollback (`rollbackPreset` + `restorePreset`) vs + screen.js `_restorePresetBlob` — two snapshot/rollback implementations for the same chain. + +### 7. 🟠 `startAudio` / route lifecycle from two sides + +`audio:startAudio` is invoked by the renderer UI **and** best-effort by the executor when a +chain plan carries `startAudio: true` (`:574`). Neither side knows the other's intent; there's +no matching stop ownership — `releaseRoute` clears the chain and mutes but leaves the device +running or not depending on who started it. + +--- + +### Recommended direction (for TLC scoping, not yet implemented) + +1. **Single chain owner**: make the audio-effects executor the *only* writer of the native + chain; migrate the audio_engine screen's direct loadVST/loadPreset/tone-switch calls onto + route-scoped executor operations; then delete rig_builder's transient-kill hacks and the + legacy direct `loadPreset` path. Executor should reject/re-sync when + `getChainState` disagrees with its `stageSlots` map (generation counter on the native chain). +2. **Arbitrated monitor state**: replace raw `setMonitorMute` writes with a small state owner + (user preference + N stackable suppressions/overrides, refcounted). `releaseRoute` releases + its override instead of forcing `true`. +3. **Sanitize all gains natively**: extend `sanitizeStreamGain`-style clamping to + input/chain/output/backing in `AudioEngine` setters (single choke point) and drop the + JS-side clamp divergence. +4. **One persistence store per setting**: file-backed settings as the single source; treat + localStorage as a migration source only, delete after import. Move mute flags out of the + device blob. +5. **Deprecation plan for aliases**: freeze `slopsmith*` surfaces, log-once on use, remove on + next major. + +--- + +# Part III — Deep Read: AudioEngine.cpp + NodeAddon.cpp + +Full read of `src/audio/AudioEngine.cpp` (3223 lines) and the load-bearing regions of +`src/audio/NodeAddon.cpp` (3699 lines). Line refs current as of +`fix/loopback-capture-permission`. + +**Overall verdict first**: the RT core is in much better shape than its size suggests — +disciplined lock-free SPSC rings, no allocation on the audio thread, denormal flushing on every +callback clock, a correct per-deviceKey quiescence handshake for source removal, and unusually +good comments that cite the bug each guard fixes. The problems live at the *edges*: the JS↔native +boundary, concurrency between async workers, and inconsistent input sanitization. + +--- + +### 1. 🔴 Chain-mutating async workers are not serialized (NodeAddon) + +`LoadPresetWorker`, `LoadVSTWorker`, `LoadNAMWorker`, `LoadIRWorker`, `ReplaceIRWorker` all queue +on the libuv threadpool (default 4 threads) with **no mutual exclusion between workers**. +`SignalChain` locks per-operation only, so the sequence `clear() → addProcessor() × N` +(`NodeAddon.cpp:3312-3408`) is not atomic. + +Two `loadPreset` calls in flight — which is precisely the documented rig_builder-vs-bundle +"~1ms later" race from the collisions doc — can interleave as: + +``` +worker A: clear() worker B: clear() +worker A: add(ampA) worker B: add(ampB) +worker A: add(irA) → final chain: [ampA, ampB, irA, irB] (merged garbage) +``` + +Both report `success:true` with wrong `slotsLoaded` semantics; the executor's stageId→slotId map +is then built against a chain that neither caller described. A `loadVST` concurrent with a +`loadPreset` similarly lands a slot into (or after) someone else's rebuild. + +**Fix shape**: one native "chain mutation" mutex (or a serial dispatch queue) around +clear+rebuild and single-slot adds; alternatively a chain generation counter returned to JS so +callers detect they lost the race. This is the single highest-value fix of the whole pass — +it converts the plugin-vs-plugin fight from corruption to last-writer-wins. + +### 2. 🔴 Argument sanitization is inconsistent across the N-API surface + +The addon knows the hazard — `getValidatedSource` (`NodeAddon.cpp:89-103`) documents that +`Int32Value()` coerces NaN→0, and `setAudioDevices` normalizes sampleRate against "NaN slipping +past N-API" (`AudioEngine.cpp:700-708`). But that rigor is only applied to the *newer* bindings: + +| Guarded (fail-soft) | Unguarded (blind `As<>()` coercion) | +|---|---| +| `getValidatedSource` (all `*Source*` methods) | `SetGain` — NaN/Inf reaches `outputGain.store()` raw | +| `SetSlotState` (IsNumber/IsString checks) | `SetParameter`, `SetBypass`, `RemoveProcessor`, `MoveProcessor` — NaN slotId → **slot 0** | +| `SetMonitorMuteSuppressed`, `SetMonitorKill` (bridge-side Boolean coercion) | `SetMultiBypass` (per-item `As` uncheck) | +| `setBackingSpeed` (isfinite + clamp, engine-side) | `SendMidiToSlot` (channel/program unclamped → JUCE assertions) | + +Consequences of the worst one: a NaN master gain via `audio:setGain('output', NaN)` multiplies +the entire device output to NaN (`buffer.applyGain(outputGain.load())`, +`AudioEngine.cpp:2407/3083`) — full silence plus poisoned peak meters, and nothing scrubs it +(the per-source NaN scrub runs *before* the master gain). Engine-side clamps at the four gain +setters (mirroring `sanitizeStreamGain`) fix every caller at once. + +### 3. 🔴 `wasRunning` race in `setAudioDevices` (AudioEngine.cpp:658) + +`audioDeviceStopped()` clears `audioRunning` on **transient** stops, and the code's own comment +says WASAPI exclusive opens "routinely fire one mid-start". `setAudioDevices` captures +`wasRunning = audioRunning.load()` and only calls `startAudio()` at the end when it was true. +The comment above it (`:651-657`) fixes the *detach* half of this race (stopAudio is now +unconditional) but the *restart* half still reads the racy flag: a reconfigure landing inside a +transient-stop window sees `wasRunning == false` and leaves the engine configured but stopped — +"no audio until user presses Start/Apply again". The intent flag it should read is "did the user +want audio running", which currently doesn't exist separately from device state (see §6). + +### 4. 🟠 `setRendererBus(false)` violates the ring's own SPSC discipline + +`AudioEngine.h` (`setRendererBus`) drops buffered audio on disable by writing +`rendererBusReadIndex` from the **control thread**, while `pullRendererBus` +(`AudioEngine.cpp:3143-3208`) is the designated single consumer-side writer of that index (the +file's comments elsewhere are explicit that "only the consumer ever moves readIndex"). A +concurrent output callback mid-`pullRendererBus` can overwrite the control thread's store with +`r + pull`, replaying a stale tail after re-enable — exactly what the drop was meant to prevent. +Low probability, audible-blip severity; fix by setting a "flush requested" atomic the consumer +honors instead of writing its index. + +### 5. 🟠 Latency accounting has three unreconciled truths + +- `getLatencyMs()` (`:469-496`): device latencies + (split only) a static `kOutputRingFrames/2` + ≈ 42.7 ms ring-residency guess. The actual ring fill is measurable (`getDeviceMetrics` reports + it) but not used. +- Verifier auto-offset (`extraInputAboutToStart`, `:2554-2568`): per-device *input-latency + delta* only, 0 on JACK/PipeWire (documented), user offset summed on top. +- Renderer bus: adds `kRendererBusPrimeFrames` (~10 ms) prime + fill drift + producer-side + resample, none of it surfaced in any latency figure; stems audio through the bus is delayed by + an amount the UI never reports and the verifier never compensates. + +For a TLC pass: one `getLatencyBreakdown()` that owns all terms would replace three ad-hoc sums. + +### 6. 🟠 `audioRunning` conflates user intent with device state + +Writers: `startAudio`/`stopAudio` (user intent), `audioDeviceAboutToStart` (device came up — +including JUCE auto-restarts the user never asked for, `:1802`), `audioDeviceStopped` (device +went down — including transient stops the user didn't ask for). Readers assume different +meanings: `setAudioDevices` reads it as intent (§3), detection guards read it as device state +(correct), the bridge's `isAudioRunning` surfaces it to the UI as intent. Two booleans +(`userWantsAudio`, `deviceRunning`) would kill the §3 race and make the auto-restart paths +self-explanatory. Related: `stopAudio()` does not stop the backing transport — `backingPlaying` +stays true and playback resumes on the next start, which is intentional for unplug-recovery but +surprising for an explicit user stop. + +### 7. 🟡 Probe/apply duplication — three copies of the rate-tolerance logic + +The `|r - r2| <= 0.5` sample-rate matching + round-to-nominal logic exists in +`probeDeviceOptionsDual` (`:316-350`), `applySplitSetup::rateSupportedBy` (`:961-982`), and the +post-open verify (`:1146`). The comments at each site narrate keeping the three in sync by hand +("<= 0.5 (not <) to match…", "Tolerance matches the probe-side rounding…") — i.e. they've +already been bitten. Same story for empty-name→first-enumerated resolution (probe, preflight, +apply must agree; three sites). One shared helper each. + +### 8. 🟡 Device identity is display-name only (documented limitation) + +`getBindableInputDevices` (`:120-161`) and `bindInputDevice`'s duplicate/primary checks compare +`juce::String` names. Two identical interfaces collapse to one entry; a device exposed under two +backends may bind the wrong one. The comment block is honest about it; flagging here because the +fix ((typeName, name) identity threaded through bind/reopen/persistence) also touches the +renderer's saved settings — a cross-layer change worth scheduling deliberately. + +### 9. 🟡 NodeAddon miscellany + +- **`LoadPresetWorker` state restore bypasses `setSlotState`**: it `const_cast`s the slot from + `getSlot()` and calls `slot->setState(state)` directly (`:3402-3404`) — outside whatever + synchronization `SignalChain::setSlotState` provides against a concurrently-processing audio + thread. During a preset load the chain was just rebuilt so the window is small, but it's the + only chain mutation in the file that dodges the class's own API. +- **Every preset load closes every editor window** (`LoadPreset:3452`, `ClearChain:2668` — + required by the #56 use-after-free). Combined with the tone auto-switch calling loadPreset on + song events, a user tweaking an amp editor mid-song has the window yanked. A single-slot + replace path (`replaceProcessor` exists) for tone switches would avoid the nuke. +- **macOS is a second-class citizen by design**: no JUCE dispatch loop (`startJuceMessageThread` + JUCE_MAC branch), `dispatchOnMessageThread` runs inline, VST/AU instantiation "given up until + a proper libuv-based pump lands". Every load path carries a divergent `#if JUCE_MAC` branch — + a large, mostly-untested platform fork woven through the file. +- **`loadVstSandboxAware` holds a libuv worker for the whole plugin init** (documented tradeoff, + `:2241-2248`); concurrent slow loads can starve fs/crypto AsyncWorkers. +- **Misnomer**: `inputOverflowCount` is incremented by the *output consumer's* catch-up on the + primary split ring (`:2941`) — it counts ring overruns, not input overflows; the metric name + leaks into `DeviceMetrics`/diagnostics. + +### 10. What is genuinely solid (don't "fix") + +- The per-deviceKey `callbacksInFlight` handshake + `pendingRelease` deferral for source removal + (`:1554-1618`) — correct, well-reasoned, and the 200 ms bounded wait is the right call. +- Ring discipline: packed-LR single-atomic frames, consumer-side drop-oldest, `w < r` resync + after index resets, consume-vs-pull split to avoid clock skew after clamps (`:2944-2974`). +- RT allocation hygiene: every scratch pre-sized in about-to-start, every hot path clamps to + capacity instead of resizing; stream scratches deliberately fixed at ring capacity so a + hotplug about-to-start can't realloc under a live producer (`:1830-1841`). +- `ScopedNoDenormals` on *both* callback clocks with the explanation of why (`:2268-2273,2898`). +- Backing speed hand-off (pending atomic + same-block stretcher reset, `:1670-1691`) and the + read-ahead thread rationale, including the honest note about `BufferingAudioSource`'s residual + lock (`:1334-1341`). +- `bindInputDevice`'s failure hygiene: every abort path closes the half-open device; validate- + eagerly-then-close when the engine is stopped (`:2774-2783`). + +--- + +### Priority order for the TLC pass + +1. **Serialize chain mutations** (§1) — prerequisite for any single-chain-owner work from the + collisions doc; without it the executor can't even trust its own load result. +2. **Sanitize gains + slot ids natively** (§2) — small, mechanical, kills a user-visible + silence-the-app bug class. +3. **Split `audioRunning` into intent + state** (§6, fixes §3) — unlocks correct + reconfigure-under-transient-stop and clarifies every auto-restart path. +4. **Renderer-bus flush flag** (§4) — one-line-ish, closes the last SPSC discipline hole. +5. **Latency breakdown API** (§5) and **probe/apply shared helpers** (§7) — quality-of-life, + schedule with the settings-ownership work. + +--- + +# Part IV — Decomposition / Refactor Plan +Targets the two monoliths: +`AudioEngine.{h,cpp}` (819 + 3223 lines) and `NodeAddon.cpp` (3699 lines). +Builds on the findings in Part II and Part III — +several fixes there (chain-mutation serialization, gain sanitization, intent/state split) +get a natural home in the new units instead of being bolted onto the monolith. + +**Precedent**: the SourceChain extraction already proved the working method on this codebase — +move a cohesive member cluster verbatim into a class, bind shared engine atomics by reference, +keep the facade byte-identical, land in phases. This plan repeats that recipe seven more times. + +**Prime directive**: no behavior change per phase. Every phase is a pure code move that +compiles + passes the existing tests (`tests/audio_sanitize`, `tests/sandbox/*`, e2e) before +the next starts. Bug fixes ride in separate commits on top of the phase that creates their home. + +--- + +### 1. Target layout + +``` +src/audio/ + engine/ + AudioEngine.{h,cpp} facade + callback orchestration only (~500 lines total) + DeviceSetup.{h,cpp} probe/apply/teardown for duplex + split (§2.1) + SourcePool.{h,cpp} source add/remove/reclaim + in-flight counts (§2.2) + ExtraInputs.{h,cpp} InputDeviceSlot registry, bind/unbind/reopen (§2.3) + BackingPlayer.{h,cpp} transport + stretch + leveler + playhead (§2.4) + StreamSink.{h,cpp} 2nd output device + submix compose (§2.5) + RendererBus.{h,cpp} WebAudio→engine ring, push/pull/metrics (§2.6) + PackedStereoRing.h the one SPSC ring template (§2.7) + EngineState.h shared atomics: rates, block sizes, run state(§2.8) + addon/ + NodeAddon.cpp module init + binding registration only + AddonContext.{h,cpp} engine/vstHost lifetime, message thread, shutdown latch + NapiHelpers.h arg validation (the getValidatedSource pattern, generalized) + ChainOps.{h,cpp} chain workers (LoadPreset/VST/NAM/IR) + mutation queue + DeviceBindings.cpp device enumeration/config/metrics bindings + ControlBindings.cpp gain/mute/gate/stream/renderer-bus bindings + DetectionBindings.cpp pitch/chord/chart/verdict/source-indexed bindings + BackingBindings.cpp backing-track bindings + EditorWindows.{h,cpp} PluginEditorWindow + open/close/promotion + (existing DSP files stay where they are) +``` + +CMake: append the new files to the existing source list in `src/audio/CMakeLists.txt`; no +target restructuring needed. + +--- + +### 2. AudioEngine decomposition (one phase per unit) + +Ordering is by extraction risk, lowest first. Each unit lists what moves, its boundary, and +which known bug lands in it afterwards. + +#### 2.1 `PackedStereoRing` — first, everything else builds on it + +Template over capacity; owns `array>`, write/read indices, the pack/unpack +helpers, and the three ritual moves currently copy-pasted at six sites: producer publish +(`packStereoIntoRing`), consumer `w < r` resync, lapped catch-up with overflow counter, and the +pull-vs-consume split. Replaces: `outputPendingRing`, each `InputDeviceSlot::ring`, +`streamSink.ring`, `rendererBusRing`. The static_asserts move inside the template. +**Bug fixed here after the move**: none — but §2.6's flush fix becomes a one-method addition +(`requestFlush()` honored by the consumer) instead of index surgery. + +#### 2.2 `SourcePool` + +Moves: `sources[]` array, `sourcesMutex`, `callbacksInFlight[]`, `pendingRelease[]`, +`addSource/removeSource/reclaimPendingReleases/listSources/getSource`, the fan-out helpers +(`setMlNoteDetectionEnabled`, `setMonitorKill` loop, `resetPeaks` loop), and +`mixSourcesForDevice`. Boundary: callbacks call `pool.enterCallback(deviceKey)` / +`pool.exitCallback(deviceKey)` (RAII guard) and `pool.mixForDevice(...)`. +This is the most delicate move (RT-shared state) but it is also the best-commented, most +self-contained cluster — the handshake logic doesn't touch any other member. + +#### 2.3 `ExtraInputs` + +Moves: `InputDeviceSlot` + `extraInputs[]`, `bindInputDevice/unbindInputDevice/ +closeExtraInputDevice/reopenDesiredExtraInputs/activeExtraInputCount`, the extra callback +trio (`extraInputCallback/AboutToStart/Stopped`). Depends on SourcePool (prepares/releases +sources by deviceKey) and PackedStereoRing. The (typeName, name) device-identity fix +(deep-read §8) lands here later without touching the engine again. + +#### 2.4 `BackingPlayer` + +Moves: transport + reader + read-ahead thread, signalsmith stretch state, `backingLock`, +speed hand-off atomics, `BackingLeveler`, `renderBackingBlockLocked`, all playhead caches, +load/start/stop/seek/speed. Boundary: `backing.renderInto(buffer, numSamples)` returning frames +(caller mixes + meters), `backing.prepare(sr, bs)` from the about-to-start hooks. The duplex and +split callbacks already share `renderBackingBlockLocked`, so the seam exists. +**Lands here later**: the "stop engine ≠ stop backing" intent decision (deep-read §6 note). + +#### 2.5 `StreamSink` + +Already 80% a struct — promote to a class owning its manager, callback, ring, scratches, +`composeAndPushStreamMix`, `set/clear/reopen/close`. Bus flags (`includeBacking/includeGuitar/ +gain`) move in. The engine's callbacks call `sink.publish(guitarMix, backing, renderer, n)`. + +#### 2.6 `RendererBus` + +Moves: ring + indices + resampler carry-state (`rendererBusSrcPos/PrevL/PrevR`), prime/fill +constants, `pushRendererAudio/pullRendererBus/getRendererBusMetrics/setRendererBus`. +**Bug fixed here after the move**: the control-thread readIndex write on disable (deep-read §4) +becomes an atomic `flushRequested` flag consumed in `pull()`. + +#### 2.7 `DeviceSetup` + +Moves: `probeDeviceOptions[Dual]`, `applyDuplexSetup`, `applySplitSetup`, `teardownSplitMode`, +type resolution/preference tables, and the three hand-synced helpers extracted once: +`rateIntersection()`, `bufferIntersection()`, `resolveDeviceName()` (deep-read §7). Stateless +apart from references to the two managers + EngineState; takes managers by reference so it owns +no lifetime. `setAudioDevices` stays on the facade as the orchestrator (stop → resolve → +duplex-or-split → restart) but shrinks to ~40 lines. + +#### 2.8 `EngineState` + +Tiny header: `currentSampleRate`, `inputBlockSize`, `outputBlockSize`, `duplexMode`, and — +**the deliberate fix from deep-read §3/§6** — `userWantsAudio` (intent, written only by +start/stopAudio) split from `deviceRunning` (state, written by the device callbacks). +SourceChain already binds engine atomics by reference; it re-binds to this struct unchanged. +Every unit above takes `EngineState&`, which is what keeps them unit-testable without JUCE +devices (hand them a state struct + a fake ring). + +#### What remains on `AudioEngine` + +The `AudioIODeviceCallback` implementations (now ~60 lines each: enter pool guard → mix → +backing → renderer bus → sink publish → master gain → meters), the facade forwarding to +`source0()` (unchanged for NodeAddon compatibility), device enumeration getters, and +construction/destruction ordering. Header drops from 819 lines to roughly 250. + +--- + +### 3. NodeAddon decomposition + +The file is 100+ bindings sharing four bits of infrastructure. Split infrastructure first, +then the bindings become mechanical moves. + +#### 3.1 `AddonContext` — lifetime + threading + +Moves: `engine/vstHost` globals + mutexes + `snapshotEngine/snapshotVstHost`, the JUCE message +thread (`startJuceMessageThread/stop/dispatchOnMessageThread` with the macOS fork in ONE place), +`alreadyShutDown`, `doShutdown`, `registerPendingLoad/cancelAllPendingLoads`. Everything else +receives `AddonContext&`. This quarantines the `#if JUCE_MAC` platform fork (deep-read §9) into +a single file instead of a branch inside every load path. + +#### 3.2 `NapiHelpers.h` — kill the validation inconsistency structurally + +Generalize the `getValidatedSource` pattern into typed extractors: + +```cpp +std::optional argSlotId(info, i); // finite integer, >= 0 +std::optional argGain(info, i); // finite, clamped 0..8 (one policy) +std::optional argParamValue(info, i); // finite, clamped 0..1 +std::optional argBool(info, i); +``` + +Then rewriting `SetGain/SetParameter/SetBypass/SendMidiToSlot/SetMultiBypass` onto them is the +deep-read §2 fix, done once, enforced by convention (new bindings have no raw `As<>()` path to +copy). Engine-side clamps in the gain setters stay as the second belt. + +#### 3.3 `ChainOps` — the serialization point + +Moves: all five chain workers + `loadVstSandboxAware` + `decodeStateBlob`. Adds the +**chain-mutation serializer** (deep-read §1): a single `std::mutex chainMutationMutex` acquired +for the full Execute() of every worker, plus a monotonic `chainGeneration` bumped on every +mutation and returned in load results — the executor and renderer can then detect a lost race +instead of trusting a corrupted merge. The `const_cast` slot-state bypass (deep-read §9) is +replaced with `setSlotState()` during this move. + +#### 3.4 `EditorWindows` + +Moves: `PluginEditorWindow`, the window map, open/close/destroy-on-message-thread, and the +sandbox-promotion flow inside `OpenPluginEditor`. Later improvement lands here: tone-switch +single-slot replace instead of close-all-editors (collisions/deep-read editor-nuke issue). + +#### 3.5 Binding files + +`DeviceBindings/ControlBindings/DetectionBindings/BackingBindings` — pure moves, grouped to +match the preload API sections, each ~400-600 lines. `NodeAddon.cpp` keeps only `Init/Shutdown` +and the `exports.Set(...)` table (which doubles as the API index the current file lacks). + +--- + +### 4. Phase 0 — Compatibility contract & test scaffolding + +Runs BEFORE any code moves. Purpose: turn "no public API change" from a review rule into a +failing CI check, and codify the compat decisions consumers (core screen, rig_builder, +note_detect, stems) depend on. Test infrastructure follows the repo's existing two-track +convention: native `tests//test.cpp` targets registered in `tests/CMakeLists.txt`, and +Node `tests/*.test.js` for the JS/addon boundary. + +**0.a Contract snapshots (`tests/contracts/`)** + +| Snapshot | Source | How | +|---|---|---| +| `addon-exports.json` | `slopsmith_audio.node` export table | Node script: `Object.keys(require(addon)).sort()` | +| `preload-audio-api.json` | `window.feedBackDesktop.audio.*` + `audioEffects.*` key lists | static extraction from `preload.ts` | +| `ipc-channels.json` | every `ipcMain.handle`/`ipcMain.on` name in `audio-bridge.ts` | static extraction | +| `result-shapes.json` | golden key/type shapes (not values) for `loadPreset`, `loadVST`, `loadNAM/IR`, `getChainState`, `savePreset`, `getDeviceMetrics`, `getRendererBusMetrics`, and every executor outcome (`loadChainPlan`/`releaseRoute`/`setRouteGain`/…) | run against the real addon (null audio device) + executor with a stubbed native | + +CI job `contract-check` regenerates all four and diffs against the committed snapshots. +Additive keys require a deliberate snapshot update in the same PR; removals/renames fail. + +**0.b Compat decisions codified as tests** + +- `isAudioRunning` reports **device state** (current semantics) — pinned by a test across a + simulated transient stop, so the phase-1 intent/state split can't silently change it. +- Native gain clamp bounds = **0..32** (matching the executor's `clampGain`), NaN/Inf rejected + universally; only stream/renderer-bus gains keep the tighter 0..8 `sanitizeStreamGain`. + Pinned by a table-driven test so phase 8's clamps can't under-shoot a legit rig gain. +- Concurrent `loadPreset` storm test written NOW (expected-fail / quarantined): two overlapping + loads must end with the chain equal to exactly one caller's preset. Documents today's + corruption, flips to expected-pass at phase 7, and doubles as the rig_builder timing smoke + (its transient-kill/unmute heuristics tolerate serialized latency — assert its fallback + unmute path still fires). + +**0.c Native unit-test harness** + +Add a `tests/engine_units/` CMake target (same pattern as `tests/audio_sanitize`) that links the +audio sources without a real device — the home for every per-unit test below. Add a tiny +`FakeClock`/`NullDevice` helper pair here once; all later phases reuse them. + +### 5. Bespoke tests per phase (unit + integration) + +Each extraction phase ships WITH its tests in the same PR — the unit tests pin the moved logic, +the integration gate proves the seam. "U" = `tests/engine_units` C++ test, "I" = Node/e2e. + +| Phase | Unit tests (new) | Integration tests | +|---|---|---| +| 1 `PackedStereoRing` | U: SPSC threaded stress (producer/consumer at different block sizes); wrap + drop-oldest lap; `w < r` resync after index reset; L/R tear check under lap (packed-atomic invariant); pull-vs-consume skew accounting; overflow/underflow counters | I: existing audio smoke (duplex, split, stream, renderer bus pass audio); contract-check green | +| 1 `EngineState` | U: intent/state transition table — user start/stop × device aboutToStart/stopped × transient stop; `isAudioRunning` compat pin from 0.b stays green | I: reconfigure-during-transient-stop scenario (documents deep-read §3; expected-fail until phase 8) | +| 2 `RendererBus` | U: resampler continuity across pushes (fractional pos + carried frame → no discontinuity at chunk seams); equal-rate degenerate path bit-exact; prime gate (no output until ~10 ms); underflow → silence + re-prime; fill clamp trims to prime target; flush-on-disable drops tail (expected-fail until phase 8 flag fix); metrics arithmetic | I: `getRendererBusMetrics` shape + push/consume accounting via addon against null device | +| 2 `StreamSink` | U: submix compose matrix (guitar/backing/renderer × include flags × gain); oversized-block skipped AND counted; scratch-not-sized skip is silent-safe | I: OBS-capture manual smoke; stream under/overflow counters via IPC | +| 3 `BackingPlayer` | U (synthetic reader source): speed change adopts rate + stretch reset in same block; EOF short-read playhead clamp; stretch-latency compensation vs 1× bypass; leveler re-prepare on SR change; tryLock-miss drops block without state damage | I: existing backing play/seek/speed e2e, duplex + split; `audio-chain-persistence.test.js` green | +| 4 `DeviceSetup` | U: `rateIntersection`/`bufferIntersection`/`resolveDeviceName` helpers — incl. the 0.5 Hz boundary cases the three duplicated sites hand-narrate today, midpoint-rounding fail-closed cases, empty-name resolution parity | I: manual device matrix (WASAPI shared/exclusive, ASIO, dual-type split); probe verdict == apply outcome assertion in a scripted run | +| 5 `SourcePool` | U: threaded add/remove storm under a fake callback loop — per-deviceKey quiescence handshake, deferred release + reclaim, no release while in-flight (TSAN job on this target); active-snapshot consistency in `mixForDevice` | I: `multi-source.test.js` + `tests/sandbox` e2e (GP-5 scenario), remove-under-load | +| 5 `ExtraInputs` | U: bind rejection matrix (duplicate name, primary device, duplex mode, out-of-range key); transient close keeps intent, permanent unbind clears + deactivates; reopen-failure ghost-source cleanup | I: second-interface e2e; meters zeroed while device gone | +| 6 `NapiHelpers` | I (Node, real addon): table-driven arg fuzz per extractor — NaN/Inf/negative/string/missing/object for slot ids, gains, params, midi bytes → no crash, documented no-op or clamp; pins the 0.b clamp decisions | I: addon init→shutdown→init cycle; pending-load cancellation on shutdown | +| 7 `ChainOps` | U: serializer — N threads × (loadPreset/loadVST/clearChain) → final chain equals exactly one caller's request, `chainGeneration` strictly monotonic, per-caller result reports the generation it produced | I: 0.b storm test flips to expected-pass; `audio-effects-executor.test.js` extended — executor detects stale generation; rig_builder legacy-path telemetry smoke; editor open/close + sandbox promotion e2e | +| 8 bug fixes | U: gain clamp tables (0..32 native, NaN reject); renderer flush flag; `wasRunning` intent read (phase-1 expected-fail flips to pass); latency breakdown terms sum | I: full regression: all snapshots + all suites green | + +Cross-cutting: + +- **TSAN/ASAN lane** for `tests/engine_units` in CI (the ring/pool tests are exactly what + sanitizers are for; the RT code has never had one). +- **Expected-fail discipline**: known bugs get their test at the phase that creates the home, + marked expected-fail with the deep-read § reference; the fix commit flips the mark. No fix + lands without its test having existed first. +- Existing suites (`audio_sanitize`, `chordscorer`, `mlnotedetector`, `sandbox/*`, + `*.test.js`) run on every phase — they are the behavior-freeze net. + +### 6. Phasing & verification + +| Phase | Content | Risk | Gate | +|---|---|---|---| +| **0** | **Contract snapshots + compat pins + `tests/engine_units` harness + storm test (expected-fail)** | **none (test-only)** | **`contract-check` job green; snapshots committed; harness builds on all 3 platforms** | +| 1 | `PackedStereoRing` + `EngineState` (incl. intent/state split behind a facade-compatible `isAudioRunning`) | low | phase-1 unit tests + audio smoke: duplex, split, stream sink, renderer bus each pass audio | +| 2 | `RendererBus`, `StreamSink` | low | phase-2 unit tests; renderer-bus metrics unchanged in diag build; OBS capture works | +| 3 | `BackingPlayer` | low-med | phase-3 unit tests; backing play/seek/speed e2e; split + duplex | +| 4 | `DeviceSetup` | med | helper unit tests; device matrix: WASAPI shared/exclusive, ASIO, dual-type split, probe==apply verdicts | +| 5 | `SourcePool` + `ExtraInputs` | med-high | TSAN-clean pool stress; multi-source + second-interface tests (`tests/sandbox` e2e, GP-5 scenario), remove-under-load | +| 6 | `AddonContext` + `NapiHelpers` | low | arg-fuzz suite; addon init/shutdown cycles, macOS build | +| 7 | `ChainOps` (with serializer) + `EditorWindows` + binding split | med | storm test flips to pass; serializer unit tests; editor open/close, sandbox promotion | +| 8 | Bug-fix commits now homed: gain clamps, renderer flush flag, `wasRunning` intent read, latency breakdown | — | each fix flips its pre-existing expected-fail test | + +Every phase additionally requires: `contract-check` green (public surface unchanged) and the +full pre-existing suite green. + +Rules that keep this safe: + +- **Move, don't edit**: each phase's diff should be reviewable as "same lines, new file" plus a + thin call seam. The excellent existing comments move with their code. +- **Reference-bind shared state** (the SourceChain trick) rather than adding getters — keeps the + RT paths free of indirection changes. +- **No public API change**: NodeAddon exports, IPC channel names, and preload surface stay + identical throughout; the collisions-doc ownership work (single chain owner, monitor-state + arbiter) is a separate track that starts after phase 7 gives it `chainGeneration`. +- Tester diag counters (`audiodiag`, `[asio-diag]`) must survive verbatim — they're how field + regressions get caught. + +### 7. Explicit non-goals + +- Rewriting the JS layer (`audio-bridge.ts` 65K / `preload.ts`) — separate track; its shape + already mirrors the binding groups this plan creates. +- Replacing JUCE transport/BufferingAudioSource, adaptive resampling for split mode, sandbox + parameter proxies — feature work, not decomposition. +- Renaming slopsmith→feedBack artifacts — orthogonal, and renaming during a move-refactor + destroys diff reviewability. + +--- + +# Part V — Merged Priority Roadmap + +One ordered list combining the collision remediation (Part II), the deep-read fixes (Part III), +and the decomposition phases (Part IV). Decomposition and fixes interleave: each fix lands as a +separate commit in the unit that becomes its home. + +1. **Phase 0** — contract snapshots, compat pins (gain bounds, `isAudioRunning` semantics), + unit-test harness, concurrency storm test (expected-fail). +2. **Refactor phases 1–2** (`PackedStereoRing`, `EngineState` with intent/state split, + `RendererBus`, `StreamSink`) — low risk, creates the homes. +3. **Fix: renderer-bus flush flag** (III §4) and **gain/slot-id sanitization** (III §2) — + small, kills the NaN-master-gain and stale-tail bug classes. +4. **Refactor phases 3–5** (`BackingPlayer`, `DeviceSetup`, `SourcePool` + `ExtraInputs`). +5. **Fix: `wasRunning` intent read in setAudioDevices** (III §3) — now trivial on the split + intent/state atomics. +6. **Refactor phases 6–7** (`AddonContext`, `NapiHelpers`, `ChainOps` + serializer + + `chainGeneration`, `EditorWindows`, binding split). +7. **Ownership work (Part II)** — single chain owner via executor (needs `chainGeneration`), + refcounted monitor-state arbiter, one persistence store per setting, tone-switch + single-slot replace instead of editor nuke. +8. **Long tail** — latency breakdown API, (typeName, name) device identity, + slopsmith→feedBack alias deprecation. From ea8c6a9ccd910286b05ceb2b3b718d8eb65e37b7 Mon Sep 17 00:00:00 2001 From: OmikronApex Date: Tue, 14 Jul 2026 11:56:08 +0200 Subject: [PATCH 24/28] =?UTF-8?q?fix(audio):=20address=20PR=20#107=20revie?= =?UTF-8?q?w=20=E2=80=94=20close=20serializer=20gaps,=20editor=20lifetime?= =?UTF-8?q?=20races,=20dispatch=20failures?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit All 8 CodeRabbit findings verified against the code and fixed: - ChainOps: macOS LoadVST routes its addProcessor through chainMutationMutex (macOS is a first-class platform; deadlock-safe — a worker holding the mutex never waits on the Node/main thread there). All four single-slot workers (LoadVST/NAM/IR/ReplaceIR) now bump chainGeneration so the executor's foreign-write detection sees direct loads, not just presets. - Rebuild barrier (beginChainRebuild/endChainRebuild): LoadPreset and ClearChain arm it before editor teardown; OpenPluginEditor refuses to open while a teardown+clear/rebuild is pending (#56 window between closeAllPluginEditorWindows returning and the worker taking the mutex). - EditorWindows: all slot/processor resolution in editor lambdas runs under a try_lock of chainMutationMutex (try_lock, never blocking — workers holding the mutex block-wait on the message thread). Sandbox promotion bumps chainGeneration. editorWindows map is now message-thread-only (duplicate-window check and close-erase moved into the queued lambdas). Null slot->processor recheck after a faulted promotion capture. - closeAllPluginEditorWindows returns false on refused post / 15s timeout; ClearChain skips the clear and LoadPreset resolves {success:false} instead of freeing processors under a live editor. - AddonContext: dispatchOnMessageThread reports refused-post/timeout; doShutdown leaves the message thread running when teardown didn't complete instead of unloading mid-destruction. - RendererBus::push rejects NaN/Inf/non-positive rates and a step that underflows to zero; new testRejectsUnusableRates unit case. Verified: addon builds clean, all 78 JS tests pass (storm, contracts, executor, N-API fuzz), all 5 engine_units native tests pass. Co-Authored-By: Claude Fable 5 --- src/audio/addon/AddonContext.cpp | 54 ++++++- src/audio/addon/AddonContext.h | 10 +- src/audio/addon/ChainBindings.cpp | 19 ++- src/audio/addon/ChainOps.cpp | 67 +++++++- src/audio/addon/ChainOps.h | 13 ++ src/audio/addon/EditorWindows.cpp | 190 ++++++++++++++--------- src/audio/addon/EditorWindows.h | 5 +- src/audio/engine/RendererBus.h | 9 +- tests/engine_units/renderer_bus_test.cpp | 27 ++++ 9 files changed, 308 insertions(+), 86 deletions(-) diff --git a/src/audio/addon/AddonContext.cpp b/src/audio/addon/AddonContext.cpp index b2050e5..79ee2a9 100644 --- a/src/audio/addon/AddonContext.cpp +++ b/src/audio/addon/AddonContext.cpp @@ -81,7 +81,7 @@ static void stopJuceMessageThread() #endif } -void dispatchOnMessageThreadImpl(std::function func) +bool dispatchOnMessageThreadImpl(std::function func) { #if JUCE_MAC // No background message thread on macOS — execute inline on caller thread. @@ -89,17 +89,38 @@ void dispatchOnMessageThreadImpl(std::function func) // 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::MessageManager::callAsync([func = std::move(func), done]() mutable { - func(); - done->signal(); - }); - done->wait(15000); + 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 } @@ -152,7 +173,7 @@ void initialize(std::function uiTeardownHook) #endif // Create engine on the JUCE message thread (or inline on macOS) - dispatchOnMessageThread([]() { + const bool initialized = dispatchOnMessageThread([]() { std::shared_ptr liveEngine; { std::lock_guard lock(engineMutex); @@ -172,6 +193,9 @@ void initialize(std::function uiTeardownHook) 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() @@ -201,7 +225,7 @@ void doShutdown() if (juceRunning.load() || snapshotEngine() || snapshotVstHost()) { - dispatchOnMessageThread([]() { + 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(); @@ -216,6 +240,20 @@ void doShutdown() 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(); diff --git a/src/audio/addon/AddonContext.h b/src/audio/addon/AddonContext.h index 879da2a..2c2a1b9 100644 --- a/src/audio/addon/AddonContext.h +++ b/src/audio/addon/AddonContext.h @@ -43,11 +43,15 @@ 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). -void dispatchOnMessageThreadImpl(std::function func); +// Returns false when the work did not complete: the post was refused +// (message queue gone — `func` will never run) or the wait timed out +// (`func` may still run later). Lifecycle callers must treat false as +// "teardown/init did not happen" rather than continuing. +bool dispatchOnMessageThreadImpl(std::function func); template -inline void dispatchOnMessageThread(Func&& func) +inline bool dispatchOnMessageThread(Func&& func) { - dispatchOnMessageThreadImpl(std::function(std::forward(func))); + return dispatchOnMessageThreadImpl(std::function(std::forward(func))); } // Pending-async-load registry: LoadVSTWorker / LoadPresetWorker block on a diff --git a/src/audio/addon/ChainBindings.cpp b/src/audio/addon/ChainBindings.cpp index 07ec06b..f98df93 100644 --- a/src/audio/addon/ChainBindings.cpp +++ b/src/audio/addon/ChainBindings.cpp @@ -74,8 +74,25 @@ Napi::Value SetBypass(const Napi::CallbackInfo& info) Napi::Value ClearChain(const Napi::CallbackInfo& info) { + // 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(); + struct BarrierRelease { + ~BarrierRelease() { slopsmith::addon::endChainRebuild(); } + } barrierRelease; + // Tear editors down before their processors are freed just below (#56). - closeAllPluginEditorWindows(); + 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. + fprintf(stderr, "[audio-native] clearChain: editor teardown did not complete; " + "chain left untouched\n"); + return info.Env().Undefined(); + } if (auto liveEngine = snapshotEngine()) { // Serialized with the async chain workers (deep-read 1). May block diff --git a/src/audio/addon/ChainOps.cpp b/src/audio/addon/ChainOps.cpp index 82ab181..36e8f1b 100644 --- a/src/audio/addon/ChainOps.cpp +++ b/src/audio/addon/ChainOps.cpp @@ -44,6 +44,25 @@ uint64_t currentChainGeneration() return chainGeneration.load(std::memory_order_acquire); } +// ── Rebuild barrier (see ChainOps.h) ──────────────────────────────────────── + +static std::atomic chainRebuildsPending{0}; + +void beginChainRebuild() +{ + chainRebuildsPending.fetch_add(1, std::memory_order_acq_rel); +} + +void endChainRebuild() +{ + chainRebuildsPending.fetch_sub(1, std::memory_order_acq_rel); +} + +bool isChainRebuildPending() +{ + return chainRebuildsPending.load(std::memory_order_acquire) > 0; +} + // ── decodeStateBlob (moved verbatim) ──────────────────── // Decode a state blob that may be in EITHER base64 flavour. JUCE's @@ -398,6 +417,8 @@ public: ProcessorSlot::Type::VST, name, path); + if (slotId_ >= 0) + slopsmith::addon::bumpChainGeneration(); // still under chainLock } void OnOK() override @@ -476,11 +497,22 @@ Napi::Value LoadVST(const Napi::CallbackInfo& info) if (processor) { auto name = processor->getName(); + // Serialize with the async chain workers (deep-read 1): an unguarded + // addProcessor here could land a slot inside a LoadPresetWorker's + // clear()+rebuild running on a libuv thread. Deadlock-safe on macOS: + // a worker holding this mutex never waits on THIS (Node/main) thread — + // loadVstSandboxAware's JUCE_MAC branch is a synchronous load on the + // worker itself, and dispatchOnMessageThread runs inline there. Only + // the mutation is guarded; the slow plugin load above stays outside + // the lock. + std::lock_guard chainLock(slopsmith::addon::chainMutationMutex()); slotId = liveEngine->getSignalChain().addProcessor( std::move(processor), ProcessorSlot::Type::VST, name, juce::String(pluginPath)); + if (slotId >= 0) + slopsmith::addon::bumpChainGeneration(); // still under chainLock } else { @@ -518,6 +550,8 @@ public: ProcessorSlot::Type::NAM, "NAM: " + name, juce::String(modelPath_)); + if (slotId_ >= 0) + slopsmith::addon::bumpChainGeneration(); // still under chainLock } } @@ -573,6 +607,8 @@ public: ProcessorSlot::Type::IR, "IR: " + name, juce::String(irPath_)); + if (slotId_ >= 0) + slopsmith::addon::bumpChainGeneration(); // still under chainLock } } @@ -635,6 +671,8 @@ public: "IR: " + name, juce::String(irPath_)); if (ok_ && gain_ >= 0.0f) liveEngine->getSignalChain().setPostGain(slotId_, gain_); + if (ok_) + slopsmith::addon::bumpChainGeneration(); // still under chainLock } void OnOK() override { deferred_.Resolve(Napi::Boolean::New(Env(), ok_)); } @@ -680,6 +718,13 @@ public: void Execute() override { + // Release the rebuild barrier LoadPreset() armed before editor + // teardown, on every exit path — editors may open again once the + // rebuild below has completed (or bailed). + struct BarrierRelease { + ~BarrierRelease() { slopsmith::addon::endChainRebuild(); } + } barrierRelease; + // Serialize the FULL mutation (TLC deep-read 1): overlapping chain // workers on the libuv pool must not interleave clear()/addProcessor(). std::lock_guard chainLock(slopsmith::addon::chainMutationMutex()); @@ -841,6 +886,14 @@ Napi::Value LoadPreset(const Napi::CallbackInfo& info) return deferred.Promise(); } + // Arm the rebuild barrier BEFORE editor teardown: between closeAll…() + // returning and the queued worker acquiring chainMutationMutex, nothing + // else stops OpenPluginEditor from opening a fresh editor whose processor + // the worker is about to free (#56). The barrier gates editor opens for + // the whole teardown+rebuild window; the worker releases it on every + // Execute() exit path. + slopsmith::addon::beginChainRebuild(); + // Tear down any open in-process editor windows NOW, on the N-API/main // thread, before the AsyncWorker frees the chain's processors on a libuv // worker (#56). Doing it here — not inside LoadPresetWorker::Execute — keeps @@ -848,7 +901,19 @@ Napi::Value LoadPreset(const Napi::CallbackInfo& info) // message thread (inline teardown); on Linux/Windows closeAllPluginEditor- // Windows() posts to the dedicated JUCE message thread and blocks. Either // way editors are destroyed before Execute() clears the chain. - closeAllPluginEditorWindows(); + if (!closeAllPluginEditorWindows()) + { + // Teardown refused or timed out: an editor may still be alive and + // bound to a chain processor. Clearing/rebuilding now would free that + // processor under the live editor — the documented UAF. Abort the + // load instead of proceeding. + slopsmith::addon::endChainRebuild(); + auto obj = Napi::Object::New(env); + obj.Set("success", false); + obj.Set("error", "editor teardown did not complete; preset load aborted"); + deferred.Resolve(obj); + return deferred.Promise(); + } auto json = info[0].As().Utf8Value(); auto worker = new LoadPresetWorker(env, deferred, json); diff --git a/src/audio/addon/ChainOps.h b/src/audio/addon/ChainOps.h index 757f7e0..4a7d240 100644 --- a/src/audio/addon/ChainOps.h +++ b/src/audio/addon/ChainOps.h @@ -49,6 +49,19 @@ uint64_t currentChainGeneration(); // const uint64_t gen = bumpChainGeneration(); // still under the lock // (return gen in the result object) +// ── 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). diff --git a/src/audio/addon/EditorWindows.cpp b/src/audio/addon/EditorWindows.cpp index d74ac59..1beb886 100644 --- a/src/audio/addon/EditorWindows.cpp +++ b/src/audio/addon/EditorWindows.cpp @@ -8,12 +8,14 @@ #include "AddonContext.h" #include "NapiHelpers.h" +#include "ChainOps.h" #include "../Sandbox/SandboxedProcessor.h" #include "../Sandbox/CrashAttribution.h" #include #include #include +#include namespace slopsmith::addon { @@ -82,13 +84,13 @@ void destroyAllPluginEditorWindowsOnMessageThread() // 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. -void closeAllPluginEditorWindows() +bool closeAllPluginEditorWindows() { auto* mm = juce::MessageManager::getInstanceWithoutCreating(); if (mm != nullptr && mm->isThisTheMessageThread()) { destroyAllPluginEditorWindowsOnMessageThread(); - return; + return true; } auto done = std::make_shared(); @@ -100,12 +102,19 @@ void closeAllPluginEditorWindows() if (!posted) { fprintf(stderr, "[audio-native] closeAllPluginEditorWindows: message queue refused the post; " - "editors may briefly outlive their processors\n"); - return; + "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; proceeding\n"); + "within 15s; caller must not free chain processors\n"); + return false; + } + return true; } Napi::Value OpenPluginEditor(const Napi::CallbackInfo& info) @@ -117,6 +126,22 @@ Napi::Value OpenPluginEditor(const Napi::CallbackInfo& info) return Napi::Boolean::New(env, false); const int slotId = *slotIdOpt; + // Rebuild barrier (ChainOps.h): a chain clear/rebuild is between its + // editor teardown and the mutation itself — the processor this editor + // would bind to is about to be freed (#56). Refuse to open. + if (slopsmith::addon::isChainRebuildPending()) + return Napi::Boolean::New(env, false); + + // Resolve the slot under the chain-mutation mutex: getSlot returns a raw + // pointer a concurrent worker's clear()/rebuild would free under us. + // try_lock, never a blocking lock — a preset load can hold the mutex for + // seconds (VST init) and this is V8's thread; if a mutation is in flight + // the slot we'd open is about to be replaced anyway. + std::unique_lock chainLock( + slopsmith::addon::chainMutationMutex(), std::try_to_lock); + if (!chainLock.owns_lock()) + return Napi::Boolean::New(env, false); + auto slot = liveEngine->getSignalChain().getSlot(slotId); if (!slot || !slot->processor || !slot->processor->hasEditor()) return Napi::Boolean::New(env, false); @@ -154,10 +179,22 @@ Napi::Value OpenPluginEditor(const Napi::CallbackInfo& info) // crash between then and now is possible — re-check here. if (!sb->isAlive()) return Napi::Boolean::New(env, false); + // Validation is done — release before queueing, so the lambda's own + // try_lock on the message thread can't collide with THIS thread still + // holding the mutex and drop the open as a false conflict. + chainLock.unlock(); const bool queued = juce::MessageManager::callAsync([slotId]() { auto liveEngine = snapshotEngine(); if (!liveEngine) return; + // try_lock, NEVER a blocking lock on the message thread: chain + // workers holding the mutex block-wait on this very thread + // (loadVstSandboxAware's callAsync+wait) — blocking here would + // deadlock. Contention means a mutation is rebuilding the slot; + // skip the open. + std::unique_lock chainLock( + slopsmith::addon::chainMutationMutex(), std::try_to_lock); + if (!chainLock.owns_lock()) return; if (auto* slot = liveEngine->getSignalChain().getSlot(slotId)) if (auto* sb = dynamic_cast(slot->processor.get())) sb->requestOpenEditor(); @@ -173,30 +210,46 @@ Napi::Value OpenPluginEditor(const Napi::CallbackInfo& info) } #endif - // In-process plugin — host-side PluginEditorWindow flow. If a window - // already exists for this slot, bring it to front rather than creating - // a duplicate. - auto it = editorWindows.find(slotId); - if (it != editorWindows.end() && it->second) - { - if (it->second->isVisible()) - { - it->second->toFront(true); - return Napi::Boolean::New(env, true); - } - // Window was hidden/closed, remove stale entry - editorWindows.erase(it); - } - - // Create editor on the message thread. Capture slotId only — re-resolve - // the slot via snapshotEngine() + getSlot(slotId) inside the lambda so a - // SignalChain::removeProcessor() between this call returning and the - // async firing can't leave us calling createEditorAndMakeActive() on a - // dangling juce::AudioProcessor*. Mirrors the sandbox branch's pattern. + // In-process plugin — host-side PluginEditorWindow flow. Everything — + // including the duplicate-window check — runs on the message thread: + // editorWindows is a plain std::map owned by that thread, and reading or + // erasing it from this (N-API) thread raced the message-thread inserts/ + // erases. + // + // Capture slotId only — re-resolve the slot via snapshotEngine() + + // getSlot(slotId) inside the lambda so a SignalChain::removeProcessor() + // between this call returning and the async firing can't leave us calling + // createEditorAndMakeActive() on a dangling juce::AudioProcessor*. + // + // Validation is done — release before queueing, so the lambda's own + // try_lock on the message thread can't collide with THIS thread still + // holding the mutex and drop the open as a false conflict. + chainLock.unlock(); const bool queued = juce::MessageManager::callAsync([slotId]() { + // If a window already exists for this slot, bring it to front rather + // than creating a duplicate. + auto it = editorWindows.find(slotId); + if (it != editorWindows.end() && it->second) + { + if (it->second->isVisible()) + { + it->second->toFront(true); + return; + } + // Window was hidden/closed, remove stale entry + editorWindows.erase(it); + } + auto liveEngine = snapshotEngine(); if (!liveEngine) return; + // try_lock, NEVER a blocking lock on the message thread: chain + // workers holding the mutex block-wait on this very thread + // (loadVstSandboxAware's callAsync+wait) — blocking here would + // deadlock. Contention means the slot is being rebuilt; skip. + std::unique_lock chainLock( + slopsmith::addon::chainMutationMutex(), std::try_to_lock); + if (!chainLock.owns_lock()) return; auto& chain = liveEngine->getSignalChain(); auto* slot = chain.getSlot(slotId); if (!slot || !slot->processor) return; @@ -259,6 +312,11 @@ Napi::Value OpenPluginEditor(const Napi::CallbackInfo& info) if (chain.replaceProcessor(slotId, std::move(sandboxed))) { promoted = true; + // A promotion swaps the slot's processor: bump the + // generation (we hold chainMutationMutex via the + // try_lock above) so JS-side chain owners re-sync + // instead of driving the replaced slot blind. + slopsmith::addon::bumpChainGeneration(); bool editorOpened = false; if (auto* slot2 = chain.getSlot(slotId)) if (auto* sb = dynamic_cast(slot2->processor.get())) @@ -296,7 +354,14 @@ Napi::Value OpenPluginEditor(const Napi::CallbackInfo& info) // 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 { @@ -324,53 +389,38 @@ Napi::Value ClosePluginEditor(const Napi::CallbackInfo& info) if (!slotIdOpt) return Napi::Boolean::New(env, false); const int slotId = *slotIdOpt; - // Sandboxed plugins: route the close to the sandbox child via IPC. - // No host-side PluginEditorWindow exists for these. - // - // Same shape as the open path: dispatch off the N-API thread and - // re-resolve the slot inside the lambda. requestCloseEditor() - // ultimately writes to the control pipe (writeFrame can block up - // to ~5s on a stalled reader), so running it synchronously here - // would freeze JS / the renderer UI on a slow sandbox; the - // re-resolve guards against slot-removal UAF between the napi call - // and the async firing. - // - // All desktop platforms: route the close to the sandbox child via IPC - // (SandboxedProcessor is compiled everywhere now). In-process plugins fall - // through to the host-side editor-window teardown below. -#if defined(SLOPSMITH_AUDIO_ADDON) - if (auto liveEngine = snapshotEngine()) + // 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]() { - if (auto* slot = liveEngine->getSignalChain().getSlot(slotId)) - { - if (slot->processor - && dynamic_cast(slot->processor.get())) - { - const bool queued = juce::MessageManager::callAsync([slotId]() - { - auto liveEngine = snapshotEngine(); - if (!liveEngine) return; - if (auto* slot = liveEngine->getSignalChain().getSlot(slotId)) - if (auto* sb = dynamic_cast(slot->processor.get())) - sb->requestCloseEditor(); - }); - return Napi::Boolean::New(env, queued); - } - } - } -#endif + // Host-side window (in-process plugins). Erasing a missing key is a + // no-op; sandbox slots never have an entry here. + editorWindows.erase(slotId); - // In-process plugin — tear down the host-side editor window. - auto it = editorWindows.find(slotId); - if (it != editorWindows.end()) - { - juce::MessageManager::callAsync([slotId]() - { - editorWindows.erase(slotId); - }); - return Napi::Boolean::New(env, true); - } - return Napi::Boolean::New(env, false); +#if defined(SLOPSMITH_AUDIO_ADDON) + auto liveEngine = snapshotEngine(); + if (!liveEngine) return; + // try_lock, NEVER a blocking lock on the message thread: chain + // workers holding the mutex block-wait on this very thread — + // blocking here would deadlock. Contention means the chain is being + // rebuilt, which tears editors down anyway. + std::unique_lock chainLock( + slopsmith::addon::chainMutationMutex(), std::try_to_lock); + if (!chainLock.owns_lock()) return; + if (auto* slot = liveEngine->getSignalChain().getSlot(slotId)) + if (auto* sb = dynamic_cast(slot->processor.get())) + sb->requestCloseEditor(); +#endif + }); + return Napi::Boolean::New(env, queued); } diff --git a/src/audio/addon/EditorWindows.h b/src/audio/addon/EditorWindows.h index 3afe80b..45304a7 100644 --- a/src/audio/addon/EditorWindows.h +++ b/src/audio/addon/EditorWindows.h @@ -19,7 +19,10 @@ void destroyAllPluginEditorWindowsOnMessageThread(); // 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. -void closeAllPluginEditorWindows(); +// 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); diff --git a/src/audio/engine/RendererBus.h b/src/audio/engine/RendererBus.h index 765b2e3..36f247e 100644 --- a/src/audio/engine/RendererBus.h +++ b/src/audio/engine/RendererBus.h @@ -62,8 +62,12 @@ public: { if (!busEnabled.load(std::memory_order_acquire)) return false; if (interleavedLR == nullptr || frames <= 0) return false; - if (deviceRate <= 0.0) return false; - if (!(sourceRate > 0.0)) sourceRate = deviceRate; + // 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(); @@ -73,6 +77,7 @@ public: // 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) diff --git a/tests/engine_units/renderer_bus_test.cpp b/tests/engine_units/renderer_bus_test.cpp index 6dcffe0..8e5fb8c 100644 --- a/tests/engine_units/renderer_bus_test.cpp +++ b/tests/engine_units/renderer_bus_test.cpp @@ -8,6 +8,7 @@ #include #include #include +#include #include using slopsmith::RendererBus; @@ -165,9 +166,35 @@ static void testFlushOnDisable() assert(dl[1] == 7.0f && "post-re-enable audio must be the fresh push"); } +// Rate validation (PR #107 review): non-finite rates cross the JS/IPC +// boundary; NaN passes a plain `<= 0` check, and a subnormal source rate can +// underflow step to 0 — both must be rejected before the resample loop. +// A bad sourceRate falls back to deviceRate (documented behaviour). +static void testRejectsUnusableRates() +{ + RendererBus bus; + bus.setEnabled(true, 1.0f); + const auto chunk = rampChunk(128, 1.0f, 0.0f); + const double nan = std::nan(""); + const double inf = std::numeric_limits::infinity(); + assert(!bus.push(chunk.data(), 128, 48000.0, nan)); + assert(!bus.push(chunk.data(), 128, 48000.0, inf)); + assert(!bus.push(chunk.data(), 128, 48000.0, -48000.0)); + assert(!bus.push(chunk.data(), 128, 48000.0, 0.0)); + // step underflow: denormal source over huge device rate → step == 0. + assert(!bus.push(chunk.data(), 128, 5e-324, 1e308)); + assert(bus.metrics().pushedFrames == 0 && "rejected pushes must stage nothing"); + // NaN/Inf/negative SOURCE rate falls back to deviceRate (step == 1). + assert(bus.push(chunk.data(), 128, nan, 48000.0)); + assert(bus.push(chunk.data(), 128, inf, 48000.0)); + assert(bus.push(chunk.data(), 128, -1.0, 48000.0)); + assert(bus.metrics().pushedFrames > 0); +} + int main() { testEqualRateBitExact(); + testRejectsUnusableRates(); testResampleContinuityAcrossPushes(); testPrimeGate(); testUnderflowReprimes(); From 1ba9b59e8a7a82944d368d1f76f1d7d0d2ac72f1 Mon Sep 17 00:00:00 2001 From: OmikronApex Date: Tue, 14 Jul 2026 12:16:22 +0200 Subject: [PATCH 25/28] fix(audio): validate LoadPreset arg before arming the rebuild barrier MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PR #107 round-2 review: beginChainRebuild() ran before info[0].As(), so a non-string argument threw between begin and the worker taking ownership — leaking the barrier and blocking editor opens permanently. Validate + read the argument first; the barrier is now armed only on a path where every exit releases it. Co-Authored-By: Claude Fable 5 --- src/audio/addon/ChainOps.cpp | 21 ++++++++++++++++++--- 1 file changed, 18 insertions(+), 3 deletions(-) diff --git a/src/audio/addon/ChainOps.cpp b/src/audio/addon/ChainOps.cpp index 36e8f1b..98ae709 100644 --- a/src/audio/addon/ChainOps.cpp +++ b/src/audio/addon/ChainOps.cpp @@ -886,12 +886,28 @@ Napi::Value LoadPreset(const Napi::CallbackInfo& info) return deferred.Promise(); } + // Validate + read the argument BEFORE arming the barrier: with a + // non-string arg, As() throws (JS TypeError / C++ + // exception), and anything thrown between begin and the worker taking + // ownership would leak the barrier and block editor opens forever. + if (!info[0].IsString()) + { + auto obj = Napi::Object::New(env); + obj.Set("success", false); + obj.Set("error", "preset must be a JSON string"); + deferred.Resolve(obj); + return deferred.Promise(); + } + auto json = info[0].As().Utf8Value(); + // Arm the rebuild barrier BEFORE editor teardown: between closeAll…() // returning and the queued worker acquiring chainMutationMutex, nothing // else stops OpenPluginEditor from opening a fresh editor whose processor // the worker is about to free (#56). The barrier gates editor opens for // the whole teardown+rebuild window; the worker releases it on every - // Execute() exit path. + // Execute() exit path. Nothing between here and Queue() can throw: the + // teardown-failure path below releases explicitly, and the worker's + // BarrierRelease guard covers every Execute() exit. slopsmith::addon::beginChainRebuild(); // Tear down any open in-process editor windows NOW, on the N-API/main @@ -915,8 +931,7 @@ Napi::Value LoadPreset(const Napi::CallbackInfo& info) return deferred.Promise(); } - auto json = info[0].As().Utf8Value(); - auto worker = new LoadPresetWorker(env, deferred, json); + auto worker = new LoadPresetWorker(env, deferred, std::move(json)); worker->Queue(); return deferred.Promise(); } From a332c35c9bd5968b4f55125765c9b72148cea1d7 Mon Sep 17 00:00:00 2001 From: byrongamatos Date: Tue, 14 Jul 2026 14:29:03 +0200 Subject: [PATCH 26/28] fix(audio): close the PR #107 review findings MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Seven fixes on top of the audio-engine TLC branch, each with the gate that catches its regression. Blocking: - Monitor-mute suppression leaked its refcount. setMonitorMuteSuppressed() became a refcounted acquire/release, but screen.js's callers are deliberately unpaired: resolveChainRebuildGuard() leaves the suppression on when a rebuild yields an empty chain, and returns early without releasing while a provider route is still resolving. Harmless against the old latched bool, a permanent +1 each against a refcount — after a failed tone rebuild the count never returned to zero and monitor mute was silently dead for the rest of the session. The renderer now holds at most one suppression. - Slot ids are monotonic HANDLES (nextSlotId, never reset by clear()), not bounded indices, so argSlotId's 4096 ceiling meant that once a session created its 4096th processor EVERY guarded binding — setBypass, setParameter, remove/moveProcessor, open/closePluginEditor — silently no-opped for the rest of the run. Ceiling removed (same for SetMultiBypass's hardcoded 4096); unknown ids are still rejected by SignalChain::findSlotIndex. - clearChain / removeProcessor / moveProcessor took chainMutationMutex with a blocking lock_guard on the N-API thread — Electron's main thread, and on macOS also the JUCE message thread. LoadPreset/LoadVST hold that mutex across an unbounded plugin init (done->wait() has no timeout by design), so a slow plugin froze the whole main process, every IPC channel with it. They now queue on a libuv worker via queueChainMutation() and resolve a promise; the bridge awaits them so callers still observe the mutation applied. Also: - getChainState() dereferenced raw ProcessorSlot* returned by getAllSlots() after the lock was dropped — a concurrent clear() frees them under the reader. Replaced with SignalChain::getSlotSummaries(), which copies under the lock. getAllSlots() is gone (it had one caller). - The device-settings migration removed the localStorage copy even when the file-store save failed or was unavailable, losing the user's settings. - SetSlotState and GetParameters kept the raw Int32Value() path: IsNumber() is true for NaN, so setSlotState(NaN) wrote onto slot 0 — the same coercion class the rest of the branch fixed. - RendererBus flushed to the LIVE writeIndex, so a disable→re-enable with no pull in between discarded the freshly pushed audio along with the stale tail. It now snapshots the flush target at disable time. - LoadPreset's rebuild barrier is now released by a scope guard, so a throw between arming it and Queue() can't block editor opens forever. Gates: new renderer-bus case (fails on the old flush), new slot-id-handle case (fails on the old ceiling). ctest 9/9, npm test 79 pass / 0 fail, chain-mutation storm green, addon export contract unchanged. --- src/audio/SignalChain.cpp | 24 ++++- src/audio/SignalChain.h | 22 +++- src/audio/addon/ChainBindings.cpp | 125 ++++++++++++----------- src/audio/addon/ChainOps.cpp | 75 +++++++++++++- src/audio/addon/ChainOps.h | 22 ++++ src/audio/addon/NapiHelpers.h | 15 ++- src/audio/engine/RendererBus.h | 27 ++++- src/main/audio-bridge.ts | 16 +-- src/renderer/screen.js | 29 +++++- tests/engine_units/renderer_bus_test.cpp | 30 ++++++ tests/napi-arg-fuzz.test.js | 57 ++++++++++- 11 files changed, 362 insertions(+), 80 deletions(-) diff --git a/src/audio/SignalChain.cpp b/src/audio/SignalChain.cpp index 8f85c8a..e718e2e 100644 --- a/src/audio/SignalChain.cpp +++ b/src/audio/SignalChain.cpp @@ -700,12 +700,30 @@ const ProcessorSlot* SignalChain::getSlot(int slotId) const return idx >= 0 ? slots[idx] : nullptr; } -juce::Array SignalChain::getAllSlots() const +std::vector SignalChain::getSlotSummaries() const { - juce::Array result; + std::vector result; const juce::ScopedLock sl(lock); + result.reserve((size_t) slots.size()); for (auto* slot : slots) - result.add(slot); + { + SlotSummary s; + s.id = slot->id; + s.type = (int) slot->type; + s.name = slot->name; + s.path = slot->path; + s.bypassed = slot->bypassed; + s.pan = slot->pan; + s.branch = slot->branch; + s.branchSrc = slot->branchSrc; + s.postGain = slot->postGain; + // Safe under `lock`: clear() detaches the slots under the same lock + // before destroying them, so a slot reachable here cannot be freed + // mid-call. The RT thread uses a ScopedTryLock, so holding it for this + // metadata copy never blocks the audio callback. + s.hasEditor = slot->processor != nullptr && slot->processor->hasEditor(); + result.push_back(std::move(s)); + } return result; } diff --git a/src/audio/SignalChain.h b/src/audio/SignalChain.h index 77e04a9..e04ee64 100644 --- a/src/audio/SignalChain.h +++ b/src/audio/SignalChain.h @@ -2,6 +2,7 @@ #include #include #include +#include // Represents a single processor slot in the signal chain. // Can hold a VST3/AU/LV2 plugin, NAM model, or IR loader. @@ -95,7 +96,26 @@ public: // Info int getNumSlots() const; const ProcessorSlot* getSlot(int slotId) const; - juce::Array getAllSlots() const; + // Metadata for every slot, copied UNDER the lock. Replaces getAllSlots(), + // which handed raw ProcessorSlot* back to the caller after dropping the + // lock: getChainState() then dereferenced them (down to + // processor->hasEditor()) while a concurrent clear()/loadPreset could free + // the slots underneath — a read-side use-after-free on the very rebuild + // window the chain-mutation serializer exists to police. + struct SlotSummary + { + int id = 0; + int type = 0; + juce::String name; + juce::String path; + bool bypassed = false; + float pan = 0.0f; + int branch = 0; + int branchSrc = 0; + float postGain = 1.0f; + bool hasEditor = false; + }; + std::vector getSlotSummaries() const; // Current prepared playback format — used to prepare a processor that is // swapped in mid-session (replaceProcessor) at the same rate as the chain. double getCurrentSampleRate() const { return currentSampleRate; } diff --git a/src/audio/addon/ChainBindings.cpp b/src/audio/addon/ChainBindings.cpp index f98df93..5102490 100644 --- a/src/audio/addon/ChainBindings.cpp +++ b/src/audio/addon/ChainBindings.cpp @@ -20,38 +20,40 @@ namespace slopsmith::addon { // ── Signal Chain Management ────────────────────────────────────────────────── -// Pending in-process loads: each LoadVSTWorker / LoadPresetWorker that's -// currently blocked on `done->wait()` registers its event here. doShutdown -// signals them all so the workers unblock and return a clean "cancelled" -// error instead of hanging forever when the JUCE message thread is about -// to be stopped (and any unfired callback would never arrive). +// 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 liveEngine = snapshotEngine(); + auto env = info.Env(); const auto slotId = slopsmith::addon::argSlotId(info, 0); - if (liveEngine && slotId) - { - std::lock_guard chainLock(slopsmith::addon::chainMutationMutex()); - liveEngine->getSignalChain().removeProcessor(*slotId); - slopsmith::addon::bumpChainGeneration(); - } - return info.Env().Undefined(); + 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 liveEngine = snapshotEngine(); + auto env = info.Env(); const auto from = slopsmith::addon::argSlotId(info, 0); const auto to = slopsmith::addon::argSlotId(info, 1); - if (liveEngine && from && to) - { - std::lock_guard chainLock(slopsmith::addon::chainMutationMutex()); - liveEngine->getSignalChain().moveProcessor(*from, *to); - slopsmith::addon::bumpChainGeneration(); - } - return info.Env().Undefined(); + 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) @@ -74,35 +76,34 @@ Napi::Value SetBypass(const Napi::CallbackInfo& info) 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(); - struct BarrierRelease { - ~BarrierRelease() { slopsmith::addon::endChainRebuild(); } - } barrierRelease; - // Tear editors down before their processors are freed just below (#56). + // 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 info.Env().Undefined(); + return resolvedBool(env, false); } - if (auto liveEngine = snapshotEngine()) - { - // Serialized with the async chain workers (deep-read 1). May block - // briefly behind an in-flight preset/VST load -- that wait IS the fix - // for the interleaved clear-vs-rebuild corruption. - std::lock_guard chainLock(slopsmith::addon::chainMutationMutex()); - liveEngine->getSignalChain().clear(); - slopsmith::addon::bumpChainGeneration(); - } - return info.Env().Undefined(); + + // 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). @@ -173,20 +174,23 @@ Napi::Value GetChainState(const Napi::CallbackInfo& info) if (liveEngine) { - auto slots = liveEngine->getSignalChain().getAllSlots(); - for (int i = 0; i < slots.size(); ++i) + // 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", (int)slots[i]->type); - obj.Set("name", slots[i]->name.toStdString()); - obj.Set("path", slots[i]->path.toStdString()); - obj.Set("bypassed", slots[i]->bypassed); - obj.Set("pan", slots[i]->pan); - obj.Set("branch", slots[i]->branch); - obj.Set("branchSrc", slots[i]->branchSrc); - obj.Set("postGain", slots[i]->postGain); - obj.Set("hasEditor", slots[i]->processor && slots[i]->processor->hasEditor()); + 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); } } @@ -200,10 +204,10 @@ Napi::Value GetParameters(const Napi::CallbackInfo& info) { auto env = info.Env(); auto liveEngine = snapshotEngine(); - if (!liveEngine || info.Length() < 1) return Napi::Array::New(env); + const auto slotId = slopsmith::addon::argSlotId(info, 0); + if (!liveEngine || !slotId) return Napi::Array::New(env); - int slotId = info[0].As().Int32Value(); - auto params = liveEngine->getSignalChain().getParameters(slotId); + auto params = liveEngine->getSignalChain().getParameters(*slotId); auto result = Napi::Array::New(env, params.size()); for (int i = 0; i < params.size(); ++i) @@ -235,19 +239,22 @@ Napi::Value SetParameter(const Napi::CallbackInfo& info) 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. + // 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(); - if (liveEngine && info.Length() >= 2 && info[0].IsNumber() && info[1].IsString()) + const auto slotId = slopsmith::addon::argSlotId(info, 0); + if (liveEngine && slotId && info.Length() >= 2 && info[1].IsString()) { - int slotId = info[0].As().Int32Value(); auto base64 = info[1].As().Utf8Value(); - const auto* slot = liveEngine->getSignalChain().getSlot(slotId); + 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); + liveEngine->getSignalChain().setSlotState(*slotId, mb); } return info.Env().Undefined(); } @@ -283,8 +290,12 @@ Napi::Value SetMultiBypass(const Napi::CallbackInfo& info) auto slotVal = item.Get("slotId"); auto bypVal = item.Get("bypassed"); if (!slotVal.IsNumber() || !bypVal.IsBoolean()) continue; + // Same rule as argSlotId: reject the NaN/Inf/fractional class, but do + // NOT impose an index ceiling — slot ids are monotonic handles, not + // indices (see NapiHelpers.h). const double raw = slotVal.As().DoubleValue(); - if (!std::isfinite(raw) || raw != std::floor(raw) || raw < 0.0 || raw > 4096.0) continue; + if (!std::isfinite(raw) || raw != std::floor(raw) + || raw < 0.0 || raw > (double) std::numeric_limits::max()) continue; changes.add({ (int) raw, bypVal.As().Value() }); } diff --git a/src/audio/addon/ChainOps.cpp b/src/audio/addon/ChainOps.cpp index 98ae709..c8ebfc2 100644 --- a/src/audio/addon/ChainOps.cpp +++ b/src/audio/addon/ChainOps.cpp @@ -63,6 +63,64 @@ bool isChainRebuildPending() return chainRebuildsPending.load(std::memory_order_acquire) > 0; } +// ── Async chain mutation (see ChainOps.h) ─────────────────────────────────── +// The synchronous mutators (clearChain / removeProcessor / moveProcessor) used +// to take chainMutationMutex with a blocking lock_guard on the N-API thread. +// That thread is Electron's main thread — and on macOS it is also the JUCE +// message thread — so waiting there behind a LoadPreset worker that holds the +// mutex across an unbounded plugin init froze the app (or deadlocked the pump +// the load needs). Queue the mutation instead and let the worker do the waiting. + +namespace { + +class ChainMutationWorker : public Napi::AsyncWorker +{ +public: + ChainMutationWorker(Napi::Env env, Napi::Promise::Deferred deferred, + std::function mutate, bool releasesBarrier) + : Napi::AsyncWorker(env) + , deferred_(deferred) + , mutate_(std::move(mutate)) + , releasesBarrier_(releasesBarrier) {} + + void Execute() override + { + struct BarrierRelease { + bool armed; + ~BarrierRelease() { if (armed) slopsmith::addon::endChainRebuild(); } + } barrierRelease{ releasesBarrier_ }; + + std::lock_guard chainLock(slopsmith::addon::chainMutationMutex()); + auto liveEngine = snapshotEngine(); + if (!liveEngine) return; // ok_ stays false + mutate_(*liveEngine); + slopsmith::addon::bumpChainGeneration(); // still under chainLock + ok_ = true; + } + + void OnOK() override { deferred_.Resolve(Napi::Boolean::New(Env(), ok_)); } + void OnError(const Napi::Error& e) override { deferred_.Reject(e.Value()); } + +private: + Napi::Promise::Deferred deferred_; + std::function mutate_; + bool releasesBarrier_ = false; + bool ok_ = false; +}; + +} // namespace + +Napi::Value queueChainMutation(Napi::Env env, + std::function mutate, + bool releasesRebuildBarrier) +{ + auto deferred = Napi::Promise::Deferred::New(env); + auto* worker = new ChainMutationWorker(env, deferred, std::move(mutate), + releasesRebuildBarrier); + worker->Queue(); + return deferred.Promise(); +} + // ── decodeStateBlob (moved verbatim) ──────────────────── // Decode a state blob that may be in EITHER base64 flavour. JUCE's @@ -904,11 +962,18 @@ Napi::Value LoadPreset(const Napi::CallbackInfo& info) // returning and the queued worker acquiring chainMutationMutex, nothing // else stops OpenPluginEditor from opening a fresh editor whose processor // the worker is about to free (#56). The barrier gates editor opens for - // the whole teardown+rebuild window; the worker releases it on every - // Execute() exit path. Nothing between here and Queue() can throw: the - // teardown-failure path below releases explicitly, and the worker's - // BarrierRelease guard covers every Execute() exit. + // the whole teardown+rebuild window. + // + // Ownership passes to the worker (whose BarrierRelease covers every + // Execute() exit) only once Queue() has actually taken it. Until then this + // guard holds it, so any early return — or a throwing allocation — releases + // instead of leaking a barrier that would block editor opens forever. slopsmith::addon::beginChainRebuild(); + bool barrierHandedOff = false; + struct BarrierGuard { + const bool& handedOff; + ~BarrierGuard() { if (!handedOff) slopsmith::addon::endChainRebuild(); } + } barrierGuard{ barrierHandedOff }; // Tear down any open in-process editor windows NOW, on the N-API/main // thread, before the AsyncWorker frees the chain's processors on a libuv @@ -923,7 +988,6 @@ Napi::Value LoadPreset(const Napi::CallbackInfo& info) // bound to a chain processor. Clearing/rebuilding now would free that // processor under the live editor — the documented UAF. Abort the // load instead of proceeding. - slopsmith::addon::endChainRebuild(); auto obj = Napi::Object::New(env); obj.Set("success", false); obj.Set("error", "editor teardown did not complete; preset load aborted"); @@ -933,6 +997,7 @@ Napi::Value LoadPreset(const Napi::CallbackInfo& info) auto worker = new LoadPresetWorker(env, deferred, std::move(json)); worker->Queue(); + barrierHandedOff = true; return deferred.Promise(); } diff --git a/src/audio/addon/ChainOps.h b/src/audio/addon/ChainOps.h index 4a7d240..cf1a28b 100644 --- a/src/audio/addon/ChainOps.h +++ b/src/audio/addon/ChainOps.h @@ -26,6 +26,7 @@ #include #include +#include #include #include @@ -48,6 +49,27 @@ uint64_t currentChainGeneration(); // ... clear/rebuild/add ... // const uint64_t gen = bumpChainGeneration(); // still under the lock // (return gen in the result object) +// +// ...but ONLY from a libuv worker thread. NOTHING may take this mutex with a +// blocking lock on the N-API/JS thread: LoadPresetWorker holds it across the +// full clear+rebuild, which includes an unbounded in-process plugin init +// (loadVstSandboxAware's done->wait() has no timeout — a slow first-run plugin +// is allowed to take as long as it needs). A blocking lock on the JS thread +// would therefore freeze the whole Electron main process — every IPC channel +// with it — for the duration of a plugin load, and on macOS (where the N-API +// thread IS the JUCE message thread — see AddonContext's startJuceMessageThread) +// it would deadlock the very pump that load is waiting on. The message-thread +// call sites in EditorWindows use try_to_lock for exactly this reason; the +// synchronous chain mutators go through queueChainMutation instead. +// +// Run `mutate` on a libuv worker under chainMutationMutex(), bump the chain +// generation, and resolve the returned promise with true (false if the engine +// went away). When `releasesRebuildBarrier`, the worker calls endChainRebuild() +// on every exit path — the caller must have armed it with beginChainRebuild() +// before tearing editors down. +Napi::Value queueChainMutation(Napi::Env env, + std::function mutate, + bool releasesRebuildBarrier = false); // ── Rebuild barrier (editor-open gate) ──────────────────────────────────── // A chain clear/rebuild is a two-step dance: editors are torn down on the diff --git a/src/audio/addon/NapiHelpers.h b/src/audio/addon/NapiHelpers.h index e52511d..7815b0c 100644 --- a/src/audio/addon/NapiHelpers.h +++ b/src/audio/addon/NapiHelpers.h @@ -13,12 +13,13 @@ #include #include +#include #include namespace slopsmith::addon { // Finite integer in [minV, maxV]. The 4096 default ceiling keeps the cast -// well-defined for id-shaped args (slot ids, source ids, indices). +// well-defined for index-shaped args (channel/branch indices and the like). inline std::optional argInt(const Napi::CallbackInfo& info, size_t i, int minV = 0, int maxV = 4096) { @@ -30,9 +31,19 @@ inline std::optional argInt(const Napi::CallbackInfo& info, size_t i, } // Slot / source / param-index ids: finite non-negative integers. +// +// NOT bounded by argInt's 4096 index ceiling. A slot id is a monotonic HANDLE +// from SignalChain::nextSlotId, which increments on every addProcessor and is +// never reset by clear() — a long session (each song load and mid-song tone +// switch rebuilds a chainful of slots) walks past 4096, and a ceiling here +// would then make every guarded binding — setBypass, setParameter, remove/ +// moveProcessor, open/closePluginEditor — silently no-op for the rest of the +// run. Ids that don't name a live slot are rejected by SignalChain's own +// findSlotIndex; the job here is only to keep the NaN/Inf/fractional class out +// (Int32Value() coerces NaN to 0, i.e. a real slot). inline std::optional argSlotId(const Napi::CallbackInfo& info, size_t i) { - return argInt(info, i); + return argInt(info, i, 0, std::numeric_limits::max()); } // Finite float (parameter values, gains, pans). Range clamping stays with diff --git a/src/audio/engine/RendererBus.h b/src/audio/engine/RendererBus.h index 36f247e..2141de2 100644 --- a/src/audio/engine/RendererBus.h +++ b/src/audio/engine/RendererBus.h @@ -48,6 +48,17 @@ public: // 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); } @@ -113,9 +124,18 @@ public: { // 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. + // 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)) - ring.commitRead(ring.writeIndex.load(std::memory_order_acquire)); + { + 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); @@ -209,7 +229,10 @@ private: std::atomic primed{false}; // Set by setEnabled(false) on the control thread, consumed (exchange) by // pull() — the drop-on-disable request, honored by the single consumer. + // flushTo is the writeIndex as of that disable: the exact end of the stale + // tail, so a re-enable's fresh frames survive the pending flush. std::atomic flushRequested{false}; + std::atomic flushTo{0}; // Producer-thread-only linear-resampler state (fractional read position // into the incoming chunk + the previous chunk's last frame for // interpolation continuity across pushes). diff --git a/src/main/audio-bridge.ts b/src/main/audio-bridge.ts index c2d1866..c37f230 100644 --- a/src/main/audio-bridge.ts +++ b/src/main/audio-bridge.ts @@ -1194,13 +1194,17 @@ export function initAudioBridge(): void { return await audio?.replaceIR(slotId, irPath, typeof gain === 'number' ? gain : -1) ?? false; }); - ipcMain.handle('audio:removeProcessor', (_event, slotId: number) => { - audio?.removeProcessor(slotId); + // The native chain mutators are async now (they take the chain-mutation + // mutex on a worker thread rather than freezing the main process behind an + // in-flight plugin load — see ChainOps.h). Await them so a renderer that + // awaits this IPC and then re-reads the chain sees the mutation applied. + ipcMain.handle('audio:removeProcessor', async (_event, slotId: number) => { + await audio?.removeProcessor(slotId); vstSlotPaths.delete(slotId); }); - ipcMain.handle('audio:moveProcessor', (_event, from: number, to: number) => { - audio?.moveProcessor(from, to); + ipcMain.handle('audio:moveProcessor', async (_event, from: number, to: number) => { + await audio?.moveProcessor(from, to); }); ipcMain.handle('audio:setBypass', (_event, slotId: number, bypassed: boolean) => { @@ -1221,8 +1225,8 @@ export function initAudioBridge(): void { audio?.setBranchSrc?.(slotId, src); }); - ipcMain.handle('audio:clearChain', () => { - audio?.clearChain(); + ipcMain.handle('audio:clearChain', async () => { + await audio?.clearChain(); vstSlotPaths.clear(); }); diff --git a/src/renderer/screen.js b/src/renderer/screen.js index c7e5dae..444fea9 100644 --- a/src/renderer/screen.js +++ b/src/renderer/screen.js @@ -270,14 +270,23 @@ window.__feedBackDesktopAudioHooks = window.__feedBackDesktopAudioHooks || {}; if (browserSettings !== null) { const browserNewer = !fileSettings || getDeviceSettingsSavedAt(browserSettings) > getDeviceSettingsSavedAt(fileSettings); + // Drop the browser copy ONLY once it is safely in the file store — + // deleting it after a failed (or unavailable) save would throw the + // user's device settings away for good. + let migrated = !browserNewer; if (browserNewer) { try { - if (typeof api.saveDeviceSettings === 'function') await api.saveDeviceSettings(browserSettings); + if (typeof api.saveDeviceSettings === 'function') { + await api.saveDeviceSettings(browserSettings); + migrated = true; + } } catch (e) { console.warn('[audio-engine] device-settings migration save failed:', e); } } - try { localStorage.removeItem('slopsmith-audio-device'); } catch (_) {} + if (migrated) { + try { localStorage.removeItem('slopsmith-audio-device'); } catch (_) {} + } if (browserNewer) return browserSettings; } return fileSettings; @@ -4367,14 +4376,28 @@ window.__feedBackDesktopAudioHooks = window.__feedBackDesktopAudioHooks || {}; // the preload below. While the chain is empty the native engine's monitor // mute would silence the dry guitar. Suppress the mute for the rebuild // window so the guitar keeps sounding; resolve it once the chain settles. + // The native side refcounts suppressions (SourceChain's monitor-mute + // arbiter): true = acquire, false = release. This latch keeps the renderer + // to AT MOST ONE outstanding suppression, because the guard below is + // deliberately unpaired — resolveChainRebuildGuard() leaves the suppression + // on when the rebuild produced an empty chain, and returns early without + // releasing while a provider route is still resolving. Under the old latched + // bool those were self-correcting (repeated trues were idempotent, any false + // reset it). Against a refcount each one would leak a permanent +1, and + // after a couple of song loads the count could never return to zero — monitor + // mute would be silently dead for the rest of the session. + let aeMonitorMuteSuppressionHeld = false; function aeSetMonitorMuteSuppressed(suppressed) { + const want = !!suppressed; + if (want === aeMonitorMuteSuppressionHeld) return; // idempotent, like the old bool + aeMonitorMuteSuppressionHeld = want; const api = window.feedBackDesktop?.audio; // Optional-chained: a downlevel native addon simply ignores this. // setMonitorMuteSuppressed is async (ipcRenderer.invoke) — the sync // try/catch only covers a missing method, so also swallow the // returned promise's rejection to avoid an unhandled rejection. try { - const r = api?.setMonitorMuteSuppressed?.(suppressed); + const r = api?.setMonitorMuteSuppressed?.(want); if (r && typeof r.catch === 'function') r.catch(() => {}); } catch (_) { /* downlevel */ } } diff --git a/tests/engine_units/renderer_bus_test.cpp b/tests/engine_units/renderer_bus_test.cpp index 8e5fb8c..b26f8f9 100644 --- a/tests/engine_units/renderer_bus_test.cpp +++ b/tests/engine_units/renderer_bus_test.cpp @@ -166,6 +166,35 @@ static void testFlushOnDisable() assert(dl[1] == 7.0f && "post-re-enable audio must be the fresh push"); } +// A pending flush must drop the STALE tail only. If no output callback runs +// between the disable and a re-enable (stopped device, device swap), the +// flush is still pending when fresh audio arrives — flushing to the live +// writeIndex at that point would discard the re-enabled bus's first frames +// too, silencing it until it re-primed. The flush target is snapshotted at +// disable time instead. +static void testFlushSparesPostReEnableAudio() +{ + RendererBus bus; + bus.setEnabled(true, 1.0f); + const auto stale = rampChunk(RendererBus::kPrimeFrames * 2, 5.0f, 0.0f); + bus.push(stale.data(), RendererBus::kPrimeFrames * 2, 48000.0, 48000.0); + + // Disable + re-enable with NO pull in between: the flush is still pending. + bus.setEnabled(false, 1.0f); + bus.setEnabled(true, 1.0f); + + // Fresh audio pushed while the flush is still pending must survive it. + const auto fresh = rampChunk(RendererBus::kPrimeFrames + 65, 7.0f, 0.0f); + bus.push(fresh.data(), RendererBus::kPrimeFrames + 65, 48000.0, 48000.0); + + std::vector dl(64), dr(64); + assert(bus.pull(dl.data(), dr.data(), 64) == 64 + && "fresh post-re-enable audio must not be flushed away with the stale tail"); + // Frame 0 is the resampler's one-frame interpolation carry (by design); + // everything after must be the fresh push, never the flushed 5.0 tail. + assert(dl[1] == 7.0f && "flush must drop only the pre-disable tail"); +} + // Rate validation (PR #107 review): non-finite rates cross the JS/IPC // boundary; NaN passes a plain `<= 0` check, and a subnormal source rate can // underflow step to 0 — both must be rejected before the resample loop. @@ -202,6 +231,7 @@ int main() testDisabledIsInert(); testGainApplied(); testFlushOnDisable(); + testFlushSparesPostReEnableAudio(); std::puts("renderer_bus: all cases passed"); return 0; } diff --git a/tests/napi-arg-fuzz.test.js b/tests/napi-arg-fuzz.test.js index 1beb593..3ac9f40 100644 --- a/tests/napi-arg-fuzz.test.js +++ b/tests/napi-arg-fuzz.test.js @@ -26,7 +26,12 @@ function writeImpulseWav(file) { fs.writeFileSync(file, buf); } -const GARBAGE = [NaN, Infinity, -Infinity, -1, 1.5, 4097, 'x', null, undefined, {}, []]; +// 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); @@ -73,3 +78,53 @@ test('chain-mutating bindings no-op on garbage args and never touch slot 0', { s 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 }); + } +}); From e29312f44659835b4807d5d085917497ebc916ed Mon Sep 17 00:00:00 2001 From: byrongamatos Date: Tue, 14 Jul 2026 14:52:34 +0200 Subject: [PATCH 27/28] fix(renderer): roll back the mute-suppression latch when the IPC fails MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CodeRabbit caught a real bug in the previous commit's fix. The latch mirrors the NATIVE refcount, but it was flipped before the invoke resolved: a rejected release left it reading "released" while the engine still held the suppression, so every later release short-circuited and monitor mute stayed suppressed for good — the same stuck-suppression bug the latch exists to prevent, just one level up. The latch now only stays flipped if the call actually landed, and rolls back otherwise (guarded so a newer call can't be clobbered by a stale rejection). A downlevel addon with no arbiter leaves the latch untouched instead of recording a hold it never acquired. Pins the whole contract with a vm-extracted unit test on the real screen.js function: unpaired acquires hold at most one native suppression, cycles stay balanced across 25 song loads, a rejected release retries, and both the downlevel and sync-throw paths are clean. Fails 3/5 against the original branch (the refcount leak) and 2/5 against the pre-rollback version. --- src/renderer/screen.js | 25 +++- tests/monitor-mute-suppression.test.js | 174 +++++++++++++++++++++++++ 2 files changed, 192 insertions(+), 7 deletions(-) create mode 100644 tests/monitor-mute-suppression.test.js diff --git a/src/renderer/screen.js b/src/renderer/screen.js index 444fea9..b28daab 100644 --- a/src/renderer/screen.js +++ b/src/renderer/screen.js @@ -4390,16 +4390,27 @@ window.__feedBackDesktopAudioHooks = window.__feedBackDesktopAudioHooks || {}; function aeSetMonitorMuteSuppressed(suppressed) { const want = !!suppressed; if (want === aeMonitorMuteSuppressionHeld) return; // idempotent, like the old bool - aeMonitorMuteSuppressionHeld = want; const api = window.feedBackDesktop?.audio; - // Optional-chained: a downlevel native addon simply ignores this. + // Downlevel addon (no arbiter): nothing is ever acquired, so leave the + // latch alone rather than recording a hold we don't have. + if (typeof api?.setMonitorMuteSuppressed !== 'function') return; + aeMonitorMuteSuppressionHeld = want; + // The latch mirrors the NATIVE refcount, so it may only stay flipped if + // the call actually landed. A rejected release that left the latch at + // "released" would short-circuit every later release while the native + // count stayed held — the same stuck-suppression bug, one level up. Roll + // back on failure so the next call retries (and only if no newer call + // has moved the latch on in the meantime). + const rollback = () => { + if (aeMonitorMuteSuppressionHeld === want) aeMonitorMuteSuppressionHeld = !want; + }; // setMonitorMuteSuppressed is async (ipcRenderer.invoke) — the sync - // try/catch only covers a missing method, so also swallow the - // returned promise's rejection to avoid an unhandled rejection. + // try/catch only covers a throwing call, so handle the returned + // promise's rejection too (which also avoids an unhandled rejection). try { - const r = api?.setMonitorMuteSuppressed?.(want); - if (r && typeof r.catch === 'function') r.catch(() => {}); - } catch (_) { /* downlevel */ } + const r = api.setMonitorMuteSuppressed(want); + if (r && typeof r.catch === 'function') r.catch(rollback); + } catch (_) { rollback(); } } // Called by clearChainForNewSong (IIFE 1) and the preload below. window._aeBeginChainRebuildGuard = function () { aeSetMonitorMuteSuppressed(true); }; diff --git a/tests/monitor-mute-suppression.test.js b/tests/monitor-mute-suppression.test.js new file mode 100644 index 0000000..a3799a4 --- /dev/null +++ b/tests/monitor-mute-suppression.test.js @@ -0,0 +1,174 @@ +// PR #107 review: the native monitor-mute arbiter REFCOUNTS suppressions +// (SourceChain::setMonitorMuteSuppressed — true = acquire, false = release), +// but it kept the old boolean signature. The renderer's rebuild guard is +// deliberately unpaired: resolveChainRebuildGuard() leaves the suppression on +// when a rebuild produced an empty chain, and returns early without releasing +// while a provider route is still resolving. Against the old LATCHED BOOL that +// was self-correcting (repeated trues were idempotent, any false reset it); +// against a refcount every unpaired call is a permanent +1, so after a couple +// of song loads the count can never return to zero and monitor mute is silently +// dead for the rest of the session. +// +// aeSetMonitorMuteSuppressed() therefore holds AT MOST ONE native suppression. +// These cases pin that, plus the rollback: the latch mirrors the native +// refcount, so it may only stay flipped if the IPC actually landed. + +'use strict'; + +const test = require('node:test'); +const assert = require('node:assert/strict'); +const path = require('node:path'); +const fs = require('node:fs'); +const vm = require('node:vm'); + +const ROOT = path.join(__dirname, '..'); +const SCREEN_JS = fs.readFileSync(path.join(ROOT, 'src', 'renderer', 'screen.js'), 'utf8'); + +function extractFunction(src, name) { + const sig = `function ${name}(`; + const start = src.indexOf(sig); + assert.ok(start !== -1, `function '${name}' not found`); + let i = src.indexOf('{', src.indexOf(')', start)); + let depth = 1; + i++; + while (i < src.length && depth > 0) { + if (src[i] === '{') depth++; + else if (src[i] === '}') depth--; + i++; + } + assert.ok(depth === 0, `unbalanced braces in '${name}'`); + return src.slice(start, i); +} + +// Build a sandbox with the real function plus a fake native api that records +// every acquire/release and can be made to fail. `calls` is the ground truth +// for what the native refcount would have done. +function makeHarness({ mode = 'ok' } = {}) { + const calls = []; + const setMonitorMuteSuppressed = (suppressed) => { + if (mode === 'throw') { calls.push({ suppressed, outcome: 'threw' }); throw new Error('sync boom'); } + if (mode === 'reject') { + calls.push({ suppressed, outcome: 'rejected' }); + return Promise.reject(new Error('ipc boom')); + } + calls.push({ suppressed, outcome: 'ok' }); + return Promise.resolve(); + }; + const audio = mode === 'downlevel' ? {} : { setMonitorMuteSuppressed }; + const ctx = { + window: { feedBackDesktop: { audio } }, + // The native refcount, simulated: clamped at 0 exactly like SourceChain's + // compare_exchange loop, so an unpaired release can't underflow. + nativeCount: 0, + }; + vm.createContext(ctx); + vm.runInContext( + 'let aeMonitorMuteSuppressionHeld = false;\n' + + extractFunction(SCREEN_JS, 'aeSetMonitorMuteSuppressed') + + '\nglobalThis.__call = aeSetMonitorMuteSuppressed;' + + '\nglobalThis.__held = () => aeMonitorMuteSuppressionHeld;', + ctx, + ); + return { + calls, + set: (v) => ctx.__call(v), + held: () => ctx.__held(), + // Replay the recorded calls through the native refcount semantics. + nativeCount: () => calls.reduce((n, c) => { + if (c.outcome !== 'ok') return n; // never reached the engine + return c.suppressed ? n + 1 : Math.max(0, n - 1); + }, 0), + }; +} + +const flush = () => new Promise((r) => setImmediate(r)); + +test('repeated unpaired acquires hold at most ONE native suppression', async () => { + const h = makeHarness(); + // Three song loads whose guard never releases (empty-chain / provider-pending + // branches). Under a raw refcount this would be +3 and never recoverable. + h.set(true); h.set(true); h.set(true); + await flush(); + assert.equal(h.calls.filter((c) => c.suppressed).length, 1, 'only one acquire may reach the engine'); + assert.equal(h.nativeCount(), 1); + + // ...and one release still returns the count to zero, so monitor mute works. + h.set(false); + await flush(); + assert.equal(h.nativeCount(), 0, 'a single release must fully un-suppress'); + assert.equal(h.held(), false); +}); + +test('acquire/release cycles stay balanced across many song loads', async () => { + const h = makeHarness(); + for (let i = 0; i < 25; i++) { + h.set(true); // clearChainForNewSong + preload both call the guard + h.set(true); + await flush(); + h.set(false); // resolveChainRebuildGuard + await flush(); + } + assert.equal(h.nativeCount(), 0, 'refcount must not drift across sessions'); + assert.equal(h.held(), false); +}); + +test('a rejected release rolls the latch back so the next release retries', async () => { + // The bug this guards: if the latch flipped to "released" on an IPC that + // never landed, every later release would short-circuit while the native + // count stayed held — stuck suppression, one level up from the C++ leak. + const calls = []; + let failNext = false; + const ctx = { + window: { + feedBackDesktop: { + audio: { + setMonitorMuteSuppressed: (s) => { + if (failNext) { calls.push({ suppressed: s, outcome: 'rejected' }); return Promise.reject(new Error('boom')); } + calls.push({ suppressed: s, outcome: 'ok' }); + return Promise.resolve(); + }, + }, + }, + }, + }; + vm.createContext(ctx); + vm.runInContext( + 'let aeMonitorMuteSuppressionHeld = false;\n' + + extractFunction(SCREEN_JS, 'aeSetMonitorMuteSuppressed') + + '\nglobalThis.__call = aeSetMonitorMuteSuppressed;' + + '\nglobalThis.__held = () => aeMonitorMuteSuppressionHeld;', + ctx, + ); + + ctx.__call(true); // acquire lands: native = 1 + await flush(); + assert.equal(ctx.__held(), true); + + failNext = true; + ctx.__call(false); // release REJECTS: native still 1 + await flush(); + assert.equal(ctx.__held(), true, 'a failed release must not leave the latch "released"'); + + failNext = false; + ctx.__call(false); // retry must actually be attempted + await flush(); + const releases = calls.filter((c) => !c.suppressed); + assert.equal(releases.length, 2, 'the retry must reach the engine, not short-circuit'); + assert.equal(releases.at(-1).outcome, 'ok'); + assert.equal(ctx.__held(), false); +}); + +test('a downlevel addon without the arbiter is a clean no-op', async () => { + const h = makeHarness({ mode: 'downlevel' }); + assert.doesNotThrow(() => { h.set(true); h.set(false); }); + await flush(); + assert.equal(h.calls.length, 0); + assert.equal(h.held(), false, 'nothing was acquired, so nothing may be recorded as held'); +}); + +test('a synchronously throwing bridge rolls the latch back', async () => { + const h = makeHarness({ mode: 'throw' }); + assert.doesNotThrow(() => h.set(true)); + await flush(); + assert.equal(h.held(), false, 'a throw means nothing was acquired'); +}); From 8354194722208533e828844fdea7dddd6aa6e0d9 Mon Sep 17 00:00:00 2001 From: byrongamatos Date: Tue, 14 Jul 2026 15:13:09 +0200 Subject: [PATCH 28/28] fix(audio): LoadVST's macOS branch deadlocked on the chain mutex MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The same finding as the clearChain/remove/moveProcessor fix, in a path I missed on the first pass. CodeRabbit pointed at this line for the wrong reason (it claimed the mutation was unguarded — it is guarded); the real defect is WHERE the guard is taken. LoadVST's macOS branch took a blocking lock_guard on chainMutationMutex on the Node/main thread. On macOS that thread is also JUCE's message thread, and it has no pump — its queue is drained by a libuv timer that only runs when the thread is idle. Meanwhile a LoadPresetWorker holding that mutex on a libuv thread calls JUCE's *synchronous* createPluginInstance, which — when called off the message thread, which is exactly where a worker calls it — posts an AsyncCreateMessage to the message thread and blocks until it runs (juce_AudioPluginFormat.cpp, createInstanceFromDescription). So: main thread blocks on the mutex → the message queue stops draining → the worker's load never completes → the mutex is never released → the app hangs permanently. Adding a VST while a preset load is in flight was enough to trigger it. The in-code comment asserted this was deadlock-safe because "loadVstSandboxAware's JUCE_MAC branch is a synchronous load on the worker itself" — but JUCE's sync load is not synchronous off the message thread, which is what makes the cycle. The plugin INSTANTIATION has to stay on the main thread (JUCE requires it for VST/AU on macOS), so only the mutation moves: it now goes through queueChainSlotMutation(), a slot-id-returning sibling of queueChainMutation(). While the worker waits for the mutex the main thread stays free to drain the queue, so the in-flight load completes and releases it. The branch is portable C++, so it was compile-checked on Linux by forcing the #if; the macOS addon lane is the real gate. --- src/audio/addon/ChainOps.cpp | 112 +++++++++++++++++++++++++---------- src/audio/addon/ChainOps.h | 7 +++ 2 files changed, 87 insertions(+), 32 deletions(-) diff --git a/src/audio/addon/ChainOps.cpp b/src/audio/addon/ChainOps.cpp index c8ebfc2..1ca92f3 100644 --- a/src/audio/addon/ChainOps.cpp +++ b/src/audio/addon/ChainOps.cpp @@ -73,6 +73,33 @@ bool isChainRebuildPending() namespace { +// As above, but the mutation yields a slot id (resolved as a Number). +class ChainSlotMutationWorker : public Napi::AsyncWorker +{ +public: + ChainSlotMutationWorker(Napi::Env env, Napi::Promise::Deferred deferred, + std::function mutate) + : Napi::AsyncWorker(env), deferred_(deferred), mutate_(std::move(mutate)) {} + + void Execute() override + { + std::lock_guard chainLock(slopsmith::addon::chainMutationMutex()); + auto liveEngine = snapshotEngine(); + if (!liveEngine) return; // slotId_ stays -1 + slotId_ = mutate_(*liveEngine); + if (slotId_ >= 0) + slopsmith::addon::bumpChainGeneration(); // still under chainLock + } + + void OnOK() override { deferred_.Resolve(Napi::Number::New(Env(), slotId_)); } + void OnError(const Napi::Error& e) override { deferred_.Reject(e.Value()); } + +private: + Napi::Promise::Deferred deferred_; + std::function mutate_; + int slotId_ = -1; +}; + class ChainMutationWorker : public Napi::AsyncWorker { public: @@ -121,6 +148,14 @@ Napi::Value queueChainMutation(Napi::Env env, return deferred.Promise(); } +Napi::Value queueChainSlotMutation(Napi::Env env, std::function mutate) +{ + auto deferred = Napi::Promise::Deferred::New(env); + auto* worker = new ChainSlotMutationWorker(env, deferred, std::move(mutate)); + worker->Queue(); + return deferred.Promise(); +} + // ── decodeStateBlob (moved verbatim) ──────────────────── // Decode a state blob that may be in EITHER base64 flavour. JUCE's @@ -517,15 +552,18 @@ Napi::Value LoadVST(const Napi::CallbackInfo& info) #if JUCE_MAC // On macOS the JUCE MessageManager is bound to the Node/main thread. - // Running this as an AsyncWorker would call vstHost->loadPlugin on a + // Running the LOAD as an AsyncWorker would call vstHost->loadPlugin on a // libuv worker thread, which JUCE documents as unsupported for VST/AU - // instantiation. Do the load synchronously on the Node/main thread - // (same as the pre-PR LoadVST) and return a resolved Promise to match - // the new signature. Pays the foreground-block cost the AsyncWorker - // path was supposed to avoid, but that's the existing macOS reality — - // dispatchOnMessageThread already runs inline there. The async-load - // motivation (AmpliTube blocking the background JUCE message thread - // under Electron) is a Windows-only problem. + // instantiation — so the instantiation stays here, on the Node/main + // thread, and pays the foreground-block cost (the existing macOS reality; + // dispatchOnMessageThread already runs inline there). The async-load + // motivation (AmpliTube blocking the background JUCE message thread under + // Electron) is a Windows-only problem. + // + // The chain MUTATION, however, moves to a worker — see the comment above + // queueChainSlotMutation below for the deadlock that a blocking lock here + // caused. + // // Snapshot once for the whole load so the same AudioEngine is used for // the sr/bs reads and the addProcessor mutation, even if shutdown // resets the global mid-call. @@ -551,33 +589,43 @@ Napi::Value LoadVST(const Napi::CallbackInfo& info) return deferred.Promise(); } - int slotId = -1; - if (processor) - { - auto name = processor->getName(); - // Serialize with the async chain workers (deep-read 1): an unguarded - // addProcessor here could land a slot inside a LoadPresetWorker's - // clear()+rebuild running on a libuv thread. Deadlock-safe on macOS: - // a worker holding this mutex never waits on THIS (Node/main) thread — - // loadVstSandboxAware's JUCE_MAC branch is a synchronous load on the - // worker itself, and dispatchOnMessageThread runs inline there. Only - // the mutation is guarded; the slow plugin load above stays outside - // the lock. - std::lock_guard chainLock(slopsmith::addon::chainMutationMutex()); - slotId = liveEngine->getSignalChain().addProcessor( - std::move(processor), - ProcessorSlot::Type::VST, - name, - juce::String(pluginPath)); - if (slotId >= 0) - slopsmith::addon::bumpChainGeneration(); // still under chainLock - } - else + if (! processor) { fprintf(stderr, "[LoadVST] Failed: %s\n", error.toRawUTF8()); + deferred.Resolve(Napi::Number::New(env, -1)); + return deferred.Promise(); } - deferred.Resolve(Napi::Number::New(env, slotId)); - return deferred.Promise(); + + // Serialize with the async chain workers (deep-read 1): an unguarded + // addProcessor here could land a slot inside a LoadPresetWorker's + // clear()+rebuild running on a libuv thread. + // + // But take the mutex on a WORKER, never on this (Node/main) thread. The + // previous blocking lock_guard here deadlocked macOS: a LoadPresetWorker + // holding the mutex calls JUCE's *synchronous* createPluginInstance, which — + // when called off the message thread, as it is on a libuv worker — posts an + // AsyncCreateMessage to the message thread and blocks on it + // (juce_AudioPluginFormat.cpp: createInstanceFromDescription). On macOS the + // message thread IS this Node/main thread, and it has no pump — its queue is + // drained by a libuv timer that only runs when this thread is idle. So + // blocking here stops the queue draining, the worker's load never completes, + // the mutex is never released, and the app hangs for good. + // + // The plugin INSTANTIATION above must stay on this thread (JUCE requires the + // message thread for VST/AU on macOS); only the mutation moves off it. While + // the worker waits for the mutex, this thread stays free to drain the queue, + // so the in-flight load can finish and release it. + // + // unique_ptr can't be captured by a std::function (copyable), so hand it over + // in a shared holder. + auto held = std::make_shared>(std::move(processor)); + auto name = (*held)->getName(); + return slopsmith::addon::queueChainSlotMutation( + env, [held, name, pluginPath](AudioEngine& eng) { + if (! *held) return -1; // a retry can't re-add an already-moved processor + return eng.getSignalChain().addProcessor( + std::move(*held), ProcessorSlot::Type::VST, name, juce::String(pluginPath)); + }); #else auto* worker = new LoadVSTWorker(env, deferred, std::move(pluginPath)); worker->Queue(); diff --git a/src/audio/addon/ChainOps.h b/src/audio/addon/ChainOps.h index cf1a28b..89fbf60 100644 --- a/src/audio/addon/ChainOps.h +++ b/src/audio/addon/ChainOps.h @@ -71,6 +71,13 @@ Napi::Value queueChainMutation(Napi::Env env, std::function mutate, bool releasesRebuildBarrier = false); +// Same, for a mutation that yields a slot id: resolves the returned Number +// (-1 when the engine went away). Used by LoadVST's macOS branch, where the +// plugin must be INSTANTIATED on the Node/main thread but its addProcessor +// must not be, because the mutex it needs can be held by a worker that is +// itself waiting on that thread. +Napi::Value queueChainSlotMutation(Napi::Env env, std::function mutate); + // ── Rebuild barrier (editor-open gate) ──────────────────────────────────── // A chain clear/rebuild is a two-step dance: editors are torn down on the // message thread FIRST, then the mutation runs (synchronously for ClearChain,