Clean release snapshot

This commit is contained in:
Byron Gamatos
2026-06-16 18:48:12 +02:00
commit bd603184d5
291 changed files with 47318 additions and 0 deletions
File diff suppressed because it is too large Load Diff
+598
View File
@@ -0,0 +1,598 @@
#pragma once
#include "SourceChain.h"
#include "signalsmith-stretch.h"
#include <juce_audio_devices/juce_audio_devices.h>
#include <juce_audio_formats/juce_audio_formats.h>
#include <array>
#include <atomic>
#include <bit>
#include <cmath>
#include <cstdint>
#include <memory>
#include <mutex>
#include <vector>
class AudioEngine : private juce::AudioIODeviceCallback
{
public:
AudioEngine();
~AudioEngine() override;
juce::AudioDeviceManager& getDeviceManager() { return inputDeviceManager; }
juce::AudioDeviceManager& getInputDeviceManager() { return inputDeviceManager; }
juce::AudioDeviceManager& getOutputDeviceManager() { return outputDeviceManager; }
// Per-input DSP now lives on a SourceChain; the engine owns sources[0] (the
// legacy default input) and forwards the single-source API to it. Multi-source
// fan-out (sources[1..N]) lands in a later phase; the public surface here is
// unchanged so NodeAddon and the renderer need no change.
SignalChain& getSignalChain() { return source0().getSignalChain(); }
PitchDetector& getPitchDetector() { return source0().getPitchDetector(); }
MlNoteDetector& getMlNoteDetector() { return source0().getMlNoteDetector(); }
// Load the Basic Pitch ONNX model for the polyphonic ML detector. When a
// model is loaded, getActiveDetection() / scoreChord() route through it;
// otherwise they fall back to the YIN PitchDetector / ChordScorer.
bool loadNoteModel(const juce::File& modelFile) { return source0().loadNoteModel(modelFile); }
bool hasMlNoteDetector() const { return source0().hasMlNoteDetector(); }
// Best current single-note detection: the ML detector's dominant pitch
// when a model is loaded, else the YIN detector's latest result. Shape is
// identical either way so the getPitchDetection bridge is detector-agnostic.
PitchDetector::Detection getActiveDetection() const { return source0().getActiveDetection(); }
// Raw monophonic YIN detection, always — bypasses the ML preference so the
// continuous frequency (sub-Hz, parabolically interpolated) and real cents
// survive even when a Basic Pitch model is loaded. Backs the tuner's
// getRawPitch bridge endpoint; the YIN detector reads the post-noise-gate
// signal, so this is silent (frequency -1) when the gate is closed.
PitchDetector::Detection getRawPitchDetection() const { return source0().getRawPitchDetection(); }
// Device enumeration
struct DeviceTypeInfo
{
juce::String name;
juce::StringArray inputDevices;
juce::StringArray outputDevices;
};
struct DeviceOptions
{
juce::String type; // legacy alias = inputType
juce::String inputType;
juce::String outputType;
juce::String input;
juce::String output;
juce::StringArray inputChannels;
juce::StringArray outputChannels;
juce::Array<double> sampleRates; // intersection when dual-type
juce::Array<int> bufferSizes;
bool compatible = true; // false when types share no usable sample rate
juce::String error;
};
struct DeviceConfig
{
juce::String inputType;
juce::String inputDevice;
juce::String outputType;
juce::String outputDevice;
double sampleRate = 48000.0;
int bufferSize = 256;
};
struct DeviceConfigResult
{
bool ok = false;
juce::String error;
double sampleRate = 0.0;
int inputBlockSize = 0;
int outputBlockSize = 0;
bool duplex = true;
};
struct DeviceMetrics
{
uint64_t inputOverflowCount = 0;
uint64_t outputUnderflowCount = 0;
// Counts are in audio frames (stereo pairs), not interleaved-float
// samples — the ring stores 2 floats per slot but the index math
// and consumer-facing health metric tick once per frame.
int outputRingFillFrames = 0;
int outputRingCapacityFrames = 0;
bool duplex = true;
};
juce::Array<DeviceTypeInfo> getDeviceTypes();
// Phase 2: input devices the user can bind as an ADDITIONAL engine input —
// restricted to the PRIMARY input's device type (so a JACK pick can't collide
// 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; };
std::vector<BindableInput> getBindableInputDevices();
juce::Array<double> getSampleRates();
juce::Array<int> getBufferSizes();
DeviceOptions probeDeviceOptions(const juce::String& typeName,
const juce::String& inputName,
const juce::String& outputName);
DeviceOptions probeDeviceOptionsDual(const juce::String& inputTypeName,
const juce::String& inputName,
const juce::String& outputTypeName,
const juce::String& outputName);
juce::String getCurrentDeviceType(); // = getCurrentInputDeviceType
juce::String getCurrentInputDeviceType();
juce::String getCurrentOutputDeviceType();
juce::String getCurrentInputDevice();
juce::String getCurrentOutputDevice();
bool isDuplex() const { return duplexMode.load(std::memory_order_relaxed); }
double getCurrentSampleRate() const { return currentSampleRate.load(std::memory_order_relaxed); }
int getCurrentBlockSize() const { return inputBlockSize.load(std::memory_order_relaxed); }
int getCurrentInputBlockSize() const { return inputBlockSize.load(std::memory_order_relaxed); }
int getCurrentOutputBlockSize() const { return outputBlockSize.load(std::memory_order_relaxed); }
DeviceMetrics getDeviceMetrics() const;
bool setDeviceType(const juce::String& typeName);
bool setInputDeviceType(const juce::String& typeName) { return setDeviceType(typeName); }
bool setOutputDeviceType(const juce::String& typeName);
bool setAudioDevice(const juce::String& inputName, const juce::String& outputName,
double sampleRate = 48000.0, int bufferSize = 256);
DeviceConfigResult setAudioDevices(const DeviceConfig& config);
// Audio start/stop
void startAudio();
void stopAudio();
bool isAudioRunning() const { return audioRunning.load(std::memory_order_relaxed); }
// 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); }
float getInputGain() const { return source0().getInputGain(); }
float getOutputGain() const { return outputGain.load(); }
// Chain output gain — the amp/tone's output level, applied to the guitar
// signal before the backing track is mixed. Distinct from outputGain (the
// post-mix master) so a tone-preset switch doesn't move the song volume.
void setChainOutputGain(float gain) { source0().setChainOutputGain(gain); }
float getChainOutputGain() const { return source0().getChainOutputGain(); }
// Input channel selection (for multi-channel interfaces like Valeton GP-5)
// 0=left (dry), 1=right (wet), -1=both (mono mix)
void setInputChannel(int channel) { source0().setInputChannel(channel); }
int getInputChannel() const { return source0().getInputChannel(); }
// Monitor mute — when true, input is still processed (pitch detection, metering)
// but output is silenced unless there are processors in the signal chain
void setMonitorMute(bool mute) { source0().setMonitorMute(mute); }
bool isMonitorMuted() const { return source0().isMonitorMuted(); }
// Monitor-mute suppression — when true, the monitor mute is temporarily
// overridden so the dry guitar stays audible even with an empty chain.
// The renderer sets this around a song-load chain rebuild (clear + reload),
// 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(); }
// Number of audio blocks whose signal-chain output had to be scrubbed for
// non-finite/runaway samples (issue #403). A nonzero value means the chain
// (NAM/IR/VST) emitted garbage that was contained before it reached the
// output. Exposed for diagnostics.
uint32_t getNonFiniteChainBlocks() const { return source0().getNonFiniteChainBlocks(); }
// Noise gate (post-input-gain, pre FX chain; pitch detector sees ungated signal)
void setNoiseGate(bool enabled, float thresholdDb, float releaseMs, float depthDb)
{
source0().setNoiseGate(enabled, thresholdDb, releaseMs, depthDb);
}
// Tone Polish — fixed 3-band mastering EQ (HPF 80 Hz, low shelf -3 dB
// @ 180 Hz, peak -0.5 dB @ 200 Hz Q=1). Applied on the guitar bus only,
// between chainOutputGain and the backing-track mix, so the backing
// track and master output gain stay bit-untouched. Defaults on;
// renderer exposes a per-preset toggle.
void setTonePolishEnabled(bool enabled) { source0().setTonePolishEnabled(enabled); }
// Backing track
void setBackingVolume(float vol) { backingVolume.store(vol); }
bool loadBackingTrack(const juce::File& file);
void setBackingPosition(double seconds);
void startBacking();
void stopBacking();
void setBackingSpeed(double speed);
// Non-blocking reads — do not acquire backingLock and never block the audio callback
bool isBackingPlaying() const { return backingPlaying.load(); }
double getBackingPosition() const { return cachedBackingPosition.load(); }
double getBackingDuration() const { return cachedBackingDuration.load(); }
// Metering (read from any thread — atomic). Input level/peak are per-source
// (sources[0]); output level/peak are the post-mix master, engine-global.
float getInputLevel() const { return source0().getInputLevel(); }
float getOutputLevel() const { return currentOutputLevel.load(); }
float getInputPeak() const { return source0().getInputPeak(); }
float getOutputPeak() const { return outputPeak.load(); }
// Running RMS of the backing-track mix bus after the volume fader, updated
// each audio block by the audio thread. Safe to call from any thread.
float getBackingLevel() const { return currentBackingLevel.load(); }
void resetPeaks();
// Latency
double getLatencyMs() 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
// size matches notedetect's _ND_MIN_YIN_SAMPLES (4096 samples).
std::vector<float> getInputFrame(int numSamples = 4096) const { return source0().getInputFrame(numSamples); }
// Gapless input-ring consumption for the onset detector — consecutive calls
// consume each sample exactly once. See SourceChain::getInputSince for the
// full gap/shortfall contract.
uint64_t getInputSince(uint64_t fromIndex, std::vector<float>& out) const { return source0().getInputSince(fromIndex, out); }
// Post-noise-gate raw mono audio snapshot for the external tuner plugin
// (distinct from getInputFrame's pre-gate ring). Backed by sources[0].
std::vector<float> getRawAudioFrame(int numSamples = 4096) const { return source0().getRawAudioFrame(numSamples); }
// Score a chord against the latest input-ring samples. The chord context
// (notes, arrangement, thresholds) comes from the renderer over IPC; audio
// data stays inside the engine. Same `{score, hitStrings, totalStrings,
// isHit, results[]}` shape as the JS implementation.
ChordScorer::Result scoreChord(const ChordScorer::Request& req) { return source0().scoreChord(req); }
// Continuous engine-side chart verification (notedetect). The renderer
// pushes the song's note chart once via setChart(); a background
// NoteVerifier thread scores each note's timing window against the live
// playhead and input ring, and the renderer drains finalized verdicts
// via getNoteVerdicts(). This replaces the renderer's per-tick
// scoreChord IPC loop, which starved during dense passages.
void setChart(const NoteVerifier::ChartUpdate& chart) { source0().setChart(chart); }
void clearChart() { source0().clearChart(); }
std::vector<NoteVerifier::Verdict> getNoteVerdicts() { return source0().getNoteVerdicts(); }
// Renderer's unified, already-corrected playhead — the verifier scores
// against this rather than getBackingPosition(), which is frozen for
// HTML5-routed (sloppak) songs. Pushed each detect tick via getNoteVerdicts.
void setPlayhead(double songTime, bool playing) { source0().setPlayhead(songTime, playing); }
// ── Multi-input source management ─────────────────────────────────────────
// A "source" is one independent input chain (its own arrangement chart, note
// detection, scoring, tone, and monitor). sources[0] always exists. Adding a
// source binds it to an input channel of the current device (multi-channel
// interface); separate-device binding lands in a later phase.
struct SourceInfo
{
int id = -1;
int inputChannel = -1; // -1 = mono mix of first pair
int deviceKey = 0; // 0 = primary input device
bool active = false;
};
// Activate a pooled chain bound to `inputChannel` of input device `deviceKey`
// (0 = primary device) and return its id, or -1 if the pool is full. Prepares
// the chain immediately when audio is running so it starts scoring without a
// device restart. Control-thread only.
int addSource(int inputChannel, int deviceKey = 0);
// Deactivate + release a source (id != 0; sources[0] is permanent). Stops its
// verifier/ML threads; the pooled object is reused by a later addSource.
bool removeSource(int id);
// Snapshot of every active source. Control-thread only.
std::vector<SourceInfo> listSources() const;
// Phase 2 (multi-device): open `deviceName` as an ADDITIONAL physical input
// device bound to `deviceKey` (1..kMaxExtraInputDevices) so sources created
// with addSource(channel, deviceKey) capture from it at its OWN clock. Forces
// split mode. Returns "" on success or an error string. unbind stops+releases
// it. activeExtraInputCount = # bound+running extras. Control-thread only.
juce::String bindInputDevice(int deviceKey, const juce::String& deviceName);
bool unbindInputDevice(int deviceKey);
int activeExtraInputCount() const;
// Per-source accessors for the NodeAddon source-indexed API. Return nullptr
// for an out-of-range or inactive id (sources[0] always valid).
SourceChain* getSource(int id);
private:
// sources[0] is the legacy default input chain; always present + active.
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.
void audioDeviceIOCallbackWithContext(const float* const* inputData,
int numInputChannels,
float* const* outputData,
int numOutputChannels,
int numSamples,
const juce::AudioIODeviceCallbackContext& context) override;
void audioDeviceAboutToStart(juce::AudioIODevice* device) override;
void audioDeviceStopped() override;
void stopBackingNoLock(); // caller holds backingLock
// Renders one block of the backing track into backingBuffer (1x bypass or
// phase-vocoder stretch), advances backingHeardPositionSec /
// cachedBackingPosition, and clears backingPlaying at EOF. Returns the
// number of output frames written (== jmin(numSamples, backingBuffer cap)).
// Shared by the duplex and split output callbacks so the two paths can't
// drift. Precondition: caller holds backingLock and has verified
// backingTransport && backingPlaying.
int renderBackingBlockLocked(int numSamples);
// Split-mode only: drains outputPendingRing, mixes backing, writes to device.
void audioOutputCallback(const float* const* inputData,
int numInputChannels,
float* const* outputData,
int numOutputChannels,
int numSamples);
void audioOutputAboutToStart(juce::AudioIODevice* device);
void audioOutputStopped();
class OutputCallback : public juce::AudioIODeviceCallback
{
public:
explicit OutputCallback(AudioEngine& e) : engine(e) {}
void audioDeviceIOCallbackWithContext(const float* const* inputData,
int numInputChannels,
float* const* outputData,
int numOutputChannels,
int numSamples,
const juce::AudioIODeviceCallbackContext&) override
{
engine.audioOutputCallback(inputData, numInputChannels, outputData, numOutputChannels, numSamples);
}
void audioDeviceAboutToStart(juce::AudioIODevice* device) override { engine.audioOutputAboutToStart(device); }
void audioDeviceStopped() override { engine.audioOutputStopped(); }
private:
AudioEngine& engine;
};
OutputCallback outputCallback{ *this };
juce::String applyDuplexSetup(const juce::String& inputName,
const juce::String& outputName,
double sampleRate,
int bufferSize);
DeviceConfigResult applySplitSetup(const DeviceConfig& config);
void teardownSplitMode();
// Duplex mode: inputDeviceManager owns both directions, outputDeviceManager idle.
// Split mode: input-only on inputDeviceManager, output-only on outputDeviceManager
// with an SPSC ring between them.
juce::AudioDeviceManager inputDeviceManager;
juce::AudioDeviceManager outputDeviceManager;
std::atomic<bool> duplexMode{true};
// Per-input capture+detect+monitor chains. A FIXED pool, all constructed up
// front, so adding/removing a source never reassigns a pointer the audio
// thread is reading — addSource/removeSource only flip an atomic `active`
// flag (and prepare/release the chain). sources[0] is the legacy default,
// active from construction and bound to the primary input device. The audio
// callback fans device channels out to each active source and fans their
// monitor signals into the output mix. SourceChain reads the engine's
// audioRunning / currentSampleRate atomics through references bound at
// construction.
static constexpr int kMaxSources = 8;
// Max ADDITIONAL input devices (beyond the primary). Declared here — ahead of the
// members that size arrays by it (e.g. callbacksInFlight) — though the extra-input
// slot registry that uses it lives further below.
static constexpr int kMaxExtraInputDevices = 3;
std::array<std::unique_ptr<SourceChain>, kMaxSources> sources;
// Serialises addSource/removeSource (control threads only — never the audio
// thread, which just reads each slot's atomic `active`).
std::mutex sourcesMutex;
// Audio-thread scratch for the multi-source mix: each active source renders
// its 2-channel monitor here in turn, then it is summed into the output.
// Pre-sized in audioDeviceAboutToStart so the hot loop never allocates.
juce::AudioBuffer<float> sourceMonitorScratch;
// Count of device callback bodies currently executing, PER deviceKey (index 0 =
// primary input, 1..kMaxExtraInputDevices = each extra-input slot). Each device
// callback increments its own key on entry and decrements at its real exit.
// removeSource() flips a source inactive (future callbacks snapshot active once
// and skip it), then waits to observe THIS SOURCE's deviceKey count == 0 — at
// that instant no callback that could touch this source is inside processBlock,
// so it is safe to release. Keying per-deviceKey (not a single global counter) is
// essential: with the primary + extra inputs on independent clocks they are
// rarely ALL idle at once, so a global check would strand removals during steady
// multi-device playback. A wedged callback past the bounded wait DEFERS the
// release via pendingRelease[], reclaimed later when that key's body is quiescent.
std::array<std::atomic<int>, kMaxExtraInputDevices + 1> callbacksInFlight{};
// Sources whose release was deferred (handshake timed out). Reclaimed under
// sourcesMutex by reclaimPendingReleases() at the next add/removeSource and on
// device stop, once it is safe (audio stopped or no callback in flight).
std::array<bool, kMaxSources> pendingRelease{};
// Release any deferred sources that are now safe to reclaim. Caller holds
// sourcesMutex (or is the device-stop path, where the callback is gone).
void reclaimPendingReleases();
juce::AudioFormatManager formatManager;
// Master output (post-mix) — engine-global, not per-source.
std::atomic<float> outputGain{1.0f};
std::atomic<float> backingVolume{0.7f};
std::atomic<float> currentOutputLevel{0.0f};
// Per-block RMS of the backing-track mix bus, written by the audio thread
// and read on the main/JS thread via getBackingLevel(). Computed after the
// backing volume fader but before the output-gain master so VU meters reflect
// the track level independently of the post-mix master volume.
std::atomic<float> currentBackingLevel{0.0f};
std::atomic<float> outputPeak{0.0f};
// Backing track
std::unique_ptr<juce::AudioFormatReaderSource> backingSource;
std::unique_ptr<juce::AudioTransportSource> backingTransport;
signalsmith::stretch::SignalsmithStretch<float> backingStretch;
juce::AudioBuffer<float> backingInputBuffer; // pulled from transport at device rate
juce::AudioBuffer<float> backingBuffer; // stretch output, mixed into device buffer
std::atomic<int> backingStretchLatencySamples{0};
std::atomic<bool> backingPlaying{false};
std::atomic<double> cachedBackingPosition{0.0};
std::atomic<double> cachedBackingDuration{0.0};
// Heard playhead: accumulates the source frames consumed each block, then
// clamped to backingTransport->getCurrentPosition() so a short read at EOF
// can't push it past the real source point. cachedBackingPosition is this
// value minus the stretcher output latency (zero on the 1x bypass path).
std::atomic<double> backingHeardPositionSec{0.0};
// Active playback rate. Mutated ONLY by the audio thread (in
// renderBackingBlockLocked), coupled with the stretcher reset, so a block
// is never processed at a new rate with stale stretch state.
std::atomic<double> backingSpeed{1.0};
// Lock-free speed hand-off: setBackingSpeed (control thread) publishes the
// requested rate here and raises backingSpeedChangePending; the audio
// thread adopts it on the next block. Avoids the control thread blocking on
// backingLock and starving the RT tryLock (which would drop a backing block
// mid-slider-drag).
std::atomic<double> backingPendingSpeed{1.0};
std::atomic<bool> backingSpeedChangePending{false};
juce::CriticalSection backingLock;
// Toggled from startAudio()/stopAudio() (main / device-management
// threads) and read from isAudioRunning() on the JS thread via the
// audio-bridge dispatch loop. Plain bool would be a data race;
// relaxed-atomic is well-defined and compiles to a plain MOV.
std::atomic<bool> audioRunning{false};
// Sample rate is written from the JUCE device callbacks (audio
// thread / device-management thread) and read from arbitrary
// callers including the JS thread via getCurrentSampleRate(),
// so a plain double would be a C++ data race. std::atomic<double>
// is well-defined and lock-free on the platforms we ship; the
// hot reads use relaxed since the consumer just wants the latest
// observable value, not a synchronization point.
std::atomic<double> currentSampleRate{48000.0};
// Split mode allows different input vs output block sizes; the ring absorbs
// the asymmetry. DSP prepares against input; backing resampler against output.
std::atomic<int> inputBlockSize{256};
std::atomic<int> outputBlockSize{256};
// The per-input lock-free SPSC rings (pre-gate getInputFrame ring + post-gate
// getRawAudioFrame ring), the YIN/ML detectors, and the zero-output capture
// scratch now live on SourceChain — one set per input source. See
// SourceChain.h for the full lock-free / power-of-two / cold-start rationale.
// Split-mode SPSC ring (unused in duplex). Each slot packs one stereo frame
// (L+R floats) into a single 64-bit atomic so the consumer reads both
// channels in one indivisible load — without packing, the producer's two
// separate atomic stores could interleave with the consumer's two loads
// during a drop-oldest wrap, surfacing as L_new+R_old (or vice versa)
// sample tears. ~85 ms @ 48 kHz — absorbs clock drift over typical sessions.
static constexpr int kOutputRingFrames = 4096;
std::array<std::atomic<uint64_t>, kOutputRingFrames> outputPendingRing{};
static_assert((kOutputRingFrames & (kOutputRingFrames - 1)) == 0,
"kOutputRingFrames must be a power of two for mask wraparound");
// RT-thread reads + writes touch these slots, so a lock-based fallback
// would risk priority inversion + audible dropouts. On the platforms we
// ship (x86_64 + arm64 across Linux/macOS/Windows) atomic<uint64_t> is
// always lock-free; this assert turns a regression into a build error
// instead of a silent latency degradation if a future platform port
// breaks the assumption.
static_assert(std::atomic<uint64_t>::is_always_lock_free,
"outputPendingRing requires lock-free atomic<uint64_t> for RT safety");
static_assert(sizeof(float) == 4,
"outputPendingRing pack/unpack assumes 32-bit float");
// Pack/unpack helpers — std::bit_cast (C++20) is constexpr + alias-safe.
static inline uint64_t packLR(float l, float r) noexcept
{
const uint32_t li = std::bit_cast<uint32_t>(l);
const uint32_t ri = std::bit_cast<uint32_t>(r);
return (static_cast<uint64_t>(ri) << 32) | static_cast<uint64_t>(li);
}
static inline void unpackLR(uint64_t v, float& l, float& r) noexcept
{
l = std::bit_cast<float>(static_cast<uint32_t>(v & 0xFFFFFFFFu));
r = std::bit_cast<float>(static_cast<uint32_t>(v >> 32));
}
std::atomic<uint64_t> outputRingWriteIndex{0};
std::atomic<uint64_t> outputRingReadIndex{0};
std::atomic<uint64_t> outputUnderflowCount{0};
std::atomic<uint64_t> inputOverflowCount{0};
// Pre-sized to outputBlockSize so the pull loop never allocates.
std::vector<float> outputPullScratchL;
std::vector<float> outputPullScratchR;
juce::AudioBuffer<float> outputBackingBuffer;
bool outputCallbackRegistered = 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;
std::array<std::atomic<uint64_t>, kOutputRingFrames> ring{};
std::atomic<uint64_t> writeIndex{0};
std::atomic<uint64_t> readIndex{0};
std::atomic<uint64_t> overflowCount{0};
std::atomic<bool> active{false}; // a device is bound + running
std::atomic<double> sampleRate{48000.0};
std::atomic<int> blockSize{256};
// (extra input latency primary input latency) in seconds — applied to
// this device's sources' verifiers so their capture aligns with the
// primary-corrected playhead. Computed when the device starts.
std::atomic<double> latencyDeltaSec{0.0};
// Audio-thread scratch — one set per slot since each slot's callback runs
// on its own thread (can't share the primary's sourceMonitorScratch).
juce::AudioBuffer<float> fanScratch; // the 2ch mix target
juce::AudioBuffer<float> monitorScratch; // per-source render in the N>1 path
int deviceKey = 0; // deviceKey this slot serves (slot+1)
// The device the user WANTS bound here — persistent INTENT, distinct from
// the transient `active` (currently open). Set by bindInputDevice, cleared
// only by a user unbind. stopAudio()/reconfigure close the device but keep
// this so startAudio() re-opens it; this is what survives a device change.
// Mutated + read on the control thread only.
juce::String desiredDeviceName;
// Whether the NEXT extraInputStopped() for this slot is a PERMANENT unbind
// (deactivate its sources) vs a transient close (keep them to resume). An
// atomic the control thread sets and the device thread reads, so the
// permanent-vs-transient decision never races on the juce::String above.
std::atomic<bool> permanentUnbind { false };
};
std::array<InputDeviceSlot, kMaxExtraInputDevices> extraInputs;
// Per-slot callback hooks (audio + device-management threads).
void extraInputCallback(int slot, const float* const* inputData, int numInputChannels, int numSamples);
void extraInputAboutToStart(int slot, juce::AudioIODevice* device);
void extraInputStopped(int slot);
// Close an extra device but KEEP its desiredDeviceName (transient close for
// stop/reconfigure); reopenDesiredExtraInputs() restores them after a (re)start.
bool closeExtraInputDevice(int slot);
void reopenDesiredExtraInputs();
// Shared fan-out used by both the primary and each extra device's callback:
// mix every active source bound to `deviceKey` into `mixBuf` (using the
// caller-owned `monitorScratch` for the N>1 render so concurrent device
// threads never share scratch). Returns the active source count for that key.
int mixSourcesForDevice(int deviceKey, const float* const* inputData, int numInputChannels,
juce::AudioBuffer<float>& mixBuf, juce::AudioBuffer<float>& monitorScratch,
int effectiveOutputChannels, int numSamples);
// Pack a stereo block into a packed-uint64 SPSC ring (producer side).
void packStereoIntoRing(const juce::AudioBuffer<float>& buf, int numSamples,
std::array<std::atomic<uint64_t>, kOutputRingFrames>& ring,
std::atomic<uint64_t>& writeIndex);
JUCE_DECLARE_NON_COPYABLE_WITH_LEAK_DETECTOR(AudioEngine)
};
+53
View File
@@ -0,0 +1,53 @@
#pragma once
#include <cmath>
// Containment for a divergent audio block (issue #403).
//
// The live signal chain (NAM / IR / VST) can emit non-finite samples (NaN/Inf)
// or a runaway level — most likely when the tone chain is rebuilt live on a
// song load. With nothing scrubbing it, that garbage reaches the speakers
// ("buzz, then extremely loud") and poisons persistent downstream state (the
// tonePolish IIR, the output ring), so the engine stays dead until the app is
// restarted. Running this over the guitar bus right after the chain — before
// any IIR/gain/mix — turns a catastrophic, permanent failure into a momentary
// glitch: feed-forward processors (WaveNet NAM, FIR IR) self-heal once they
// stop emitting garbage.
//
// JUCE-free and header-only on purpose so it unit-tests without the audio
// framework. Operates in place on one channel of `n` interleaved-or-planar
// float samples. Returns the number of samples it had to fix (for one-shot
// observability — the caller may count blocks, never log on the RT thread).
//
// `ceiling` is a hard magnitude clamp: real playing sits well under 1.0, so a
// generous ceiling (~+6 dBFS) only ever catches runaway garbage, never musical
// transients.
namespace slopsmith {
inline int sanitizeAudioBlock(float* data, int n, float ceiling = 2.0f) noexcept
{
int fixed = 0;
for (int i = 0; i < n; ++i)
{
const float s = data[i];
if (!std::isfinite(s))
{
data[i] = 0.0f;
++fixed;
}
else if (s > ceiling)
{
data[i] = ceiling;
++fixed;
}
else if (s < -ceiling)
{
data[i] = -ceiling;
++fixed;
}
}
return fixed;
}
} // namespace slopsmith
+285
View File
@@ -0,0 +1,285 @@
# Slopsmith Audio Engine — Node.js native addon built with JUCE
# Produces slopsmith_audio.node loadable via require()
set(AUDIO_SOURCES
NodeAddon.cpp
NoiseGate.cpp
TonePolish.cpp
AudioEngine.cpp
SourceChain.cpp
SignalChain.cpp
VSTHost.cpp
NAMProcessor.cpp
IRLoader.cpp
PitchDetector.cpp
ChordScorer.cpp
MlNoteDetector.cpp
OnsetDetector.cpp
NoteVerifier.cpp
Sandbox/Protocol.cpp
)
# Plugin sandbox — out-of-process VST3 host for plugins that don't survive
# in-process loading (notably Qt5-using plugins from Native Instruments;
# see docs/VST-SANDBOX-DIAG.md for the diagnosis).
#
# Cross-platform (constitution VIII): the IPC layer is a platform-neutral core
# (*_shared.cpp) plus per-OS backends (*_win.cpp / *_posix.cpp). All three
# desktop platforms now route VST3 plugins through the out-of-process sandbox.
# The slopsmith-vst-host child that the factory spawns is built by the same
# cmake-js invocation (src/vst-host) and lands next to slopsmith_audio.node, so
# resolveSandboxExe() finds it; if it is ever missing the factory returns
# nullptr and the caller falls back to in-process loading.
set(SANDBOX_SOURCES
Sandbox/SandboxedProcessor.cpp
Sandbox/ControlChannel_shared.cpp
Sandbox/AudioChannel_shared.cpp
Sandbox/SandboxFactory_shared.cpp
)
if(WIN32)
list(APPEND SANDBOX_SOURCES
Sandbox/ControlChannel_win.cpp
Sandbox/AudioChannel_win.cpp
Sandbox/SubprocessHandle_win.cpp
Sandbox/SandboxFactory_win.cpp
)
else()
list(APPEND SANDBOX_SOURCES
Sandbox/ControlChannel_posix.cpp
Sandbox/AudioChannel_posix.cpp
Sandbox/SubprocessHandle_posix.cpp
Sandbox/SandboxFactory_posix.cpp
)
endif()
list(APPEND AUDIO_SOURCES ${SANDBOX_SOURCES})
# Build as shared library (.node)
add_library(slopsmith_audio SHARED ${AUDIO_SOURCES} ${CMAKE_JS_SRC})
# Output as .node file
set_target_properties(slopsmith_audio PROPERTIES
PREFIX ""
SUFFIX ".node"
LIBRARY_OUTPUT_DIRECTORY "${CMAKE_BINARY_DIR}/Release"
)
# Node.js / N-API headers
if(DEFINED CMAKE_JS_INC)
target_include_directories(slopsmith_audio PRIVATE ${CMAKE_JS_INC})
endif()
# node-addon-api headers (installed via npm)
execute_process(
COMMAND node -p "require('node-addon-api').include"
WORKING_DIRECTORY "${CMAKE_SOURCE_DIR}"
OUTPUT_VARIABLE NAPI_INCLUDE_DIR
OUTPUT_STRIP_TRAILING_WHITESPACE
)
string(REPLACE "\"" "" NAPI_INCLUDE_DIR "${NAPI_INCLUDE_DIR}")
target_include_directories(slopsmith_audio PRIVATE ${NAPI_INCLUDE_DIR})
# N-API version
target_compile_definitions(slopsmith_audio PRIVATE NAPI_VERSION=9 NAPI_DISABLE_CPP_EXCEPTIONS)
# Marks this translation unit set as the audio addon (vs. slopsmith-vst-host,
# which also compiles VSTHost.cpp). Gates the out-of-process VST scan path so
# it's only built into the addon — the host exe never calls scanDirectories.
target_compile_definitions(slopsmith_audio PRIVATE SLOPSMITH_AUDIO_ADDON=1)
# JUCE modules — headless (no GUI)
target_link_libraries(slopsmith_audio PRIVATE
juce::juce_audio_basics
juce::juce_audio_devices
juce::juce_audio_formats
juce::juce_audio_processors
juce::juce_core
juce::juce_dsp
juce::juce_events
)
# JUCE compile definitions
target_compile_definitions(slopsmith_audio PRIVATE
JUCE_PLUGINHOST_VST3=1
JUCE_WEB_BROWSER=0
JUCE_USE_CURL=0
JUCE_DISPLAY_SPLASH_SCREEN=0
JUCE_MODAL_LOOPS_PERMITTED=1
JUCE_APPLICATION_NAME_STRING="SlopsmithAudio"
JUCE_STANDALONE_APPLICATION=0
JUCE_REPORT_APP_USAGE=0
JUCE_LOG_ASSERTIONS=0
JUCE_DISABLE_ASSERTIONS=1
JUCE_USE_OGGVORBIS=1
JUCE_USE_FLAC=1
# Backing tracks are commonly .mp3 (song backing-track audio). Without
# this, registerBasicFormats() omits MP3 and loadBackingTrack() rejects
# every mp3, forcing the song onto the HTML5 path. JUCE's built-in MP3
# decoder is patent-unencumbered and ships with the framework.
JUCE_USE_MP3AUDIOFORMAT=1
)
# Platform-specific
if(APPLE)
# NOTE: JUCE_PLUGINHOST_AU=1 requires linking CoreAudioKit/AppKit for
# AUGenericView, but doing so causes JUCE's MessageManager to crash
# when run from a Node.js native-addon thread (AppKit's NSRunLoop
# expects the main thread). Disabling AU hosting on macOS until the
# NodeAddon message-loop integration is redesigned. VST3 and NAM still
# work; users can load VST3 plugins instead of AU.
target_link_libraries(slopsmith_audio PRIVATE
"-framework CoreAudio"
"-framework CoreMIDI"
"-framework AudioUnit"
"-framework AudioToolbox"
"-framework CoreFoundation"
"-framework Accelerate"
)
elseif(WIN32)
target_compile_definitions(slopsmith_audio PRIVATE
JUCE_ASIO=1
)
# Enable /EHa so catch(...) also catches SEH exceptions (ASIO driver crashes)
if(MSVC)
target_compile_options(slopsmith_audio PRIVATE /EHa)
# Emit a PDB for Release builds so field crash dumps (WER minidumps
# from testers) can be symbolised — without one, our own frames in a
# dump are just raw addresses. /Zi adds debug info to the objects;
# /DEBUG:FULL makes the linker write a standalone, portable PDB but
# also turns /OPT:REF + /OPT:ICF off by default, so re-enable them —
# the .node stays a fully optimised release build that merely also
# ships a matching PDB. The PDB is not bundled into the app: the
# electron-builder `files` config picks up *.node / onnxruntime* by
# name, not *.pdb.
target_compile_options(slopsmith_audio PRIVATE $<$<CONFIG:Release>:/Zi>)
# One generator expression per flag: a single $<...:A B C> would be
# split on its internal spaces into separate — and malformed — CMake
# arguments.
target_link_options(slopsmith_audio PRIVATE
$<$<CONFIG:Release>:/DEBUG:FULL>
$<$<CONFIG:Release>:/OPT:REF>
$<$<CONFIG:Release>:/OPT:ICF>)
endif()
elseif(UNIX)
# LV2 is Linux-only — on Windows, lilv probes every .vst3 bundle for a
# manifest.ttl during scan and crashes the audio addon.
target_compile_definitions(slopsmith_audio PRIVATE
JUCE_PLUGINHOST_LV2=1
JUCE_JACK=1
JUCE_JACK_CLIENT_NAME="Slopsmith"
JUCE_ALSA=1
)
find_package(PkgConfig REQUIRED)
pkg_check_modules(ALSA REQUIRED alsa)
target_link_libraries(slopsmith_audio PRIVATE ${ALSA_LIBRARIES})
target_include_directories(slopsmith_audio PRIVATE ${ALSA_INCLUDE_DIRS})
# JACK is loaded dynamically by JUCE
find_library(JACK_LIB jack)
if(JACK_LIB)
target_link_libraries(slopsmith_audio PRIVATE ${JACK_LIB})
endif()
endif()
# cmake-js library
if(DEFINED CMAKE_JS_LIB)
target_link_libraries(slopsmith_audio PRIVATE ${CMAKE_JS_LIB})
endif()
# NAM support
if(NAM_AVAILABLE)
# Include NAM core sources directly. GLOB_RECURSE so the A2 architecture
# sources under NAM/wavenet/ (added in NeuralAmpModelerCore v0.5.x) are
# picked up, not just the flat NAM/*.cpp files.
file(GLOB_RECURSE NAM_SOURCES "${NAM_DIR}/NAM/*.cpp")
target_sources(slopsmith_audio PRIVATE ${NAM_SOURCES})
target_include_directories(slopsmith_audio PRIVATE
"${NAM_DIR}"
"${NAM_DIR}/Dependencies/eigen"
"${NAM_DIR}/Dependencies/nlohmann"
"${NAM_DIR}/Dependencies/AudioDSPTools"
)
# SLOPSMITH_NAM_SUPPORT gates our NAMProcessor; NAM_ENABLE_A2_FAST enables
# the hand-optimized A2 fast-path WaveNet (a2_fast.cpp / model.cpp are fully
# #if-guarded on it). Upstream defaults this ON; we compile NAM sources
# directly (no add_subdirectory) so we must set the define ourselves.
target_compile_definitions(slopsmith_audio PRIVATE
SLOPSMITH_NAM_SUPPORT=1
NAM_ENABLE_A2_FAST
)
if(RTNEURAL_AVAILABLE)
target_link_libraries(slopsmith_audio PRIVATE RTNeural)
endif()
else()
target_compile_definitions(slopsmith_audio PRIVATE SLOPSMITH_NAM_SUPPORT=0)
endif()
# ONNX Runtime — MlNoteDetector (Basic Pitch). When unavailable, the addon
# still builds and MlNoteDetector is compiled as an inert stub; the engine
# falls back to the YIN PitchDetector / ChordScorer (Constitution VII).
if(ONNXRUNTIME_AVAILABLE)
target_include_directories(slopsmith_audio PRIVATE "${ONNXRUNTIME_INCLUDE_DIR}")
target_link_libraries(slopsmith_audio PRIVATE "${ONNXRUNTIME_IMPORT_LIB}")
target_compile_definitions(slopsmith_audio PRIVATE SLOPSMITH_ONNX_SUPPORT=1)
# The ONNX Runtime shared lib must load next to slopsmith_audio.node.
# Linux: rpath $ORIGIN; macOS: @loader_path; Windows: DLL is searched in
# the module's own directory by default.
if(APPLE)
set_target_properties(slopsmith_audio PROPERTIES
BUILD_RPATH "@loader_path" INSTALL_RPATH "@loader_path")
elseif(UNIX)
set_target_properties(slopsmith_audio PROPERTIES
BUILD_RPATH "$ORIGIN" INSTALL_RPATH "$ORIGIN")
endif()
# Stage the runtime lib beside the .node so dev runs and electron-builder
# packaging (asarUnpack of build/Release/*) both find it.
add_custom_command(TARGET slopsmith_audio POST_BUILD
COMMAND ${CMAKE_COMMAND} -E copy_if_different
"${ONNXRUNTIME_RUNTIME_LIB}"
"$<TARGET_FILE_DIR:slopsmith_audio>/"
COMMENT "Staging ONNX Runtime shared library next to slopsmith_audio.node")
# Also stage the shared-provider stub that ONNX Runtime dlopen()s next to
# the main runtime. Conditional: it ships in the standard releases, but the
# cmake probe leaves ONNXRUNTIME_PROVIDERS_LIB empty if a layout lacks it.
if(ONNXRUNTIME_PROVIDERS_LIB)
add_custom_command(TARGET slopsmith_audio POST_BUILD
COMMAND ${CMAKE_COMMAND} -E copy_if_different
"${ONNXRUNTIME_PROVIDERS_LIB}"
"$<TARGET_FILE_DIR:slopsmith_audio>/"
COMMENT "Staging ONNX Runtime providers_shared library next to slopsmith_audio.node")
endif()
# macOS only: the prebuilt onnxruntime dylib bakes its absolute build-time
# extraction path into LC_ID_DYLIB, which the linker then copies into the
# addon as an absolute LC_LOAD_DYLIB. The @loader_path rpath set above is
# never consulted for an absolute load path, so the addon loads onnxruntime
# only on the build machine — everywhere else MlNoteDetector can't init and
# the engine silently falls back to YIN ("ML detection: OFF", slopsmith#818).
# Rewrite the install names to be @rpath-relative so the co-located runtime
# resolves on every Mac. Runs after staging so it rewrites the staged copy.
if(APPLE)
get_filename_component(_ort_runtime_name "${ONNXRUNTIME_RUNTIME_LIB}" NAME)
add_custom_command(TARGET slopsmith_audio POST_BUILD
COMMAND "${CMAKE_CURRENT_SOURCE_DIR}/../../scripts/fix-onnxruntime-install-names.sh"
"$<TARGET_FILE:slopsmith_audio>"
"${_ort_runtime_name}"
COMMENT "Normalising ONNX Runtime install names to @rpath (slopsmith#818)")
endif()
else()
target_compile_definitions(slopsmith_audio PRIVATE SLOPSMITH_ONNX_SUPPORT=0)
endif()
# Signalsmith Stretch: pitch-preserving time-stretch for the backing track.
# signalsmith-stretch.h includes "signalsmith-linear/stft.h",
# so the linear repo's parent must also be in the include path.
set(SS_STRETCH_DIR "${CMAKE_CURRENT_SOURCE_DIR}/third_party/signalsmith-stretch")
set(SS_LINEAR_PARENT "${CMAKE_CURRENT_SOURCE_DIR}/third_party")
target_include_directories(slopsmith_audio PRIVATE
"${SS_STRETCH_DIR}"
"${SS_LINEAR_PARENT}")
# Include directory for our headers
target_include_directories(slopsmith_audio PRIVATE "${CMAKE_CURRENT_SOURCE_DIR}")
+470
View File
@@ -0,0 +1,470 @@
#include "ChordScorer.h"
#include <algorithm>
#include <cmath>
#include <limits>
#include <utility>
namespace
{
// Standard-tuning MIDI base tables — verbatim from screen.js so
// open-string MIDI values stay identical between the browser path
// and the native port. Comments mirror the JS pitch labels for
// sanity at a glance.
const std::vector<int> kTuningBass4{ 28, 33, 38, 43 }; // E1 A1 D2 G2
const std::vector<int> kTuningBass5{ 23, 28, 33, 38, 43 }; // B0 E1 A1 D2 G2
const std::vector<int> kTuningGuitar6{ 40, 45, 50, 55, 59, 64 }; // E2 A2 D3 G3 B3 E4
const std::vector<int> kTuningGuitar7{ 35, 40, 45, 50, 55, 59, 64 }; // B1 E2 A2 D3 G3 B3 E4
const std::vector<int> kTuningGuitar8{ 30, 35, 40, 45, 50, 55, 59, 64 }; // F#1 B1 E2 A2 D3 G3 B3 E4
// Energy threshold default and the hammer-on / pull-off relaxation,
// both from `_ndScoreChord`. Pulled out as constants so the
// technique-adjustment block reads the same as the JS.
constexpr float kEnergyThresholdDefault = 0.03f;
constexpr float kEnergyThresholdSoftAttack = 0.015f;
// Bend / slide pitch window — pitch is in motion, so the JS widens
// the cents tolerance to at least 100. We mirror that floor exactly.
constexpr float kBendSlideCentsFloor = 100.0f;
// Target FFT bin width in Hz. Picks an fftSize that keeps the
// low-B fundamental (5-string bass) resolvable across any device
// sample rate. JS uses the same constant for the same reason.
constexpr double kTargetBinHz = 3.0;
int nextPow2(int n) noexcept
{
int p = 1;
while (p < n) p <<= 1;
return p;
}
// Same parabolic-peak refinement the JS uses. Clamps to ±1 so a
// near-zero denominator can't push the corrected peak into a
// neighbour bin.
float parabolicOffset(float yPrev, float yPeak, float yNext) noexcept
{
const float denom = yPrev - 2.0f * yPeak + yNext;
if (std::abs(denom) < 1e-12f) return 0.0f;
const float delta = 0.5f * (yPrev - yNext) / denom;
if (delta > 1.0f) return 1.0f;
if (delta < -1.0f) return -1.0f;
return delta;
}
// Octave-fold cents deviation into (-600, +600]. Used by the per-
// string pitch check so a detected octave-mismatched fundamental
// (very common on guitar — strong 2nd harmonic in DI tones) still
// counts as the right note. Mirrors `_ndFoldOctaveCents`.
//
// Range note: `std::round` ties away from zero, so an input of
// exactly +600 folds to -600 while an input of -600 stays at -600 +
// 1200 = +600. The asymmetry doesn't affect the hit/miss decision
// because the caller compares `std::abs(centsError) <= tolerance`,
// which collapses both endpoints to magnitude 600.
float foldOctaveCents(float cents) noexcept
{
if (! std::isfinite(cents)) return std::numeric_limits<float>::infinity();
return cents - (std::round(cents / 1200.0f) * 1200.0f);
}
int midiFromStringFret(int stringIdx, int fret, const std::vector<int>& base,
const std::vector<int>& offsets, int capo) noexcept
{
const int off = (stringIdx >= 0 && stringIdx < (int) offsets.size()) ? offsets[(size_t) stringIdx] : 0;
const int b = (stringIdx >= 0 && stringIdx < (int) base.size()) ? base[(size_t) stringIdx] : 0;
return b + off + capo + fret;
}
// Frequency band [loHz, hiHz] covering frets 0..24 for the given
// string at the supplied tuning, with ±10% headroom so non-standard
// tunings / capo / offsets still land inside the band. Same shape
// as `_ndStringBandHz`.
std::pair<double, double> stringBandHz(int stringIdx, const std::vector<int>& base,
const std::vector<int>& offsets, int capo) noexcept
{
const int openMidi = midiFromStringFret(stringIdx, 0, base, offsets, capo);
const int fret24Midi = openMidi + 24;
const double loHz = 440.0 * std::pow(2.0, (openMidi - 69) / 12.0) * 0.90;
const double hiHz = 440.0 * std::pow(2.0, (fret24Midi - 69) / 12.0) * 1.10;
return { loHz, hiHz };
}
}
const std::vector<int>* ChordScorer::standardMidiFor(const std::string& arrangement, int stringCount)
{
if (arrangement == "bass")
{
if (stringCount == 4) return &kTuningBass4;
if (stringCount == 5) return &kTuningBass5;
return nullptr;
}
if (arrangement == "guitar")
{
if (stringCount == 6) return &kTuningGuitar6;
if (stringCount == 7) return &kTuningGuitar7;
if (stringCount == 8) return &kTuningGuitar8;
return nullptr;
}
return nullptr;
}
void ChordScorer::ensureFft(int fftSize)
{
if (fftSize == currentFftSize) return;
int order = 0;
while ((1 << order) < fftSize) ++order;
fft = std::make_unique<juce::dsp::FFT>(order);
currentFftSize = fftSize;
currentFftOrder = order;
fftScratch.assign((size_t) fftSize, juce::dsp::Complex<float>{0.0f, 0.0f});
fftOutScratch.assign((size_t) fftSize, juce::dsp::Complex<float>{0.0f, 0.0f});
magnitudes.assign((size_t) ((fftSize >> 1) + 1), 0.0f);
}
void ChordScorer::computeMagnitudes(const float* buffer, int numSamples)
{
// Zero the scratch — the FFT reads all fftSize complex slots; the
// windowed input fills only the first `numSamples` of them.
std::fill(fftScratch.begin(), fftScratch.end(),
juce::dsp::Complex<float>{0.0f, 0.0f});
// Hann-window the real part, leave imag at zero. Identical to the
// JS implementation, including the `numSamples - 1` divisor (NOT
// `numSamples`) which is the closed-form Hann.
const float invDen = (numSamples > 1)
? static_cast<float>(2.0 * juce::MathConstants<double>::pi / (numSamples - 1))
: 0.0f;
for (int i = 0; i < numSamples; ++i)
{
const float w = 0.5f * (1.0f - std::cos(invDen * (float) i));
fftScratch[(size_t) i] = juce::dsp::Complex<float>{ buffer[i] * w, 0.0f };
}
// Forward FFT — out-of-place. JUCE's FFT::perform contract is that
// input and output must be distinct buffers ("Performs an out-of-place
// FFT" — juce_FFT.h). The Ooura fallback engine that Linux + Windows
// desktop builds default to recurses into a radix decomposition that
// reads input positions and writes output positions in overlapping
// iteration patterns; aliasing the two buffers produces cascading
// numerical corruption (observed: ~1e27-magnitude bins from sub-1.0
// input samples). The corrupted magnitudes propagate into the per-
// string band energy as Inf/NaN downstream, and every chord scores
// as all-miss. macOS desktop builds use vDSP (Apple Accelerate),
// which tolerates input==output aliasing in practice — which is
// why this bug only surfaces on the Linux/Windows desktop bridge.
fft->perform(fftScratch.data(), fftOutScratch.data(), false);
const int halfBins = (currentFftSize >> 1) + 1;
for (int k = 0; k < halfBins; ++k)
{
const auto& c = fftOutScratch[(size_t) k];
magnitudes[(size_t) k] = std::sqrt(c.real() * c.real() + c.imag() * c.imag());
}
}
ChordScorer::Result ChordScorer::scoreChord(const float* buffer, int numSamples,
double sampleRate, const Request& req)
{
Result out{};
out.totalStrings = (int) req.notes.size();
// Build the all-miss shape every validation-failure path returns.
// The caller's contract is one result entry per requested note
// (matches AudioEngine::scoreChord's audio-not-running fast path)
// — without this, an out-of-range or mismatched request would
// yield totalStrings > 0 with results.length == 0 and break
// renderers that iterate results[] one-to-one with the chord-note
// list.
auto fillMissResults = [&out, &req]() {
out.results.clear();
out.results.reserve(req.notes.size());
for (const auto& n : req.notes)
{
NoteResult r{};
r.string = n.string;
r.fret = n.fret;
out.results.push_back(r);
}
};
// Bail with the per-note all-miss shape when the audio inputs are
// unusable (zero/negative samples, zero sample rate, or a null
// buffer the caller forgot to populate). Setting totalStrings = 0
// here would diverge from the other failure paths; instead emit
// the same shape every other early-exit produces.
if (numSamples <= 0 || sampleRate <= 0.0 || buffer == nullptr)
{
fillMissResults();
return out;
}
if (out.totalStrings == 0) return out;
// Validate request shape. Unsupported (arrangement, stringCount)
// pairs and undersized/mismatched tuningOffsets used to silently
// fall back to bass-4 / guitar-6 with zero offsets, producing
// plausible-looking but wrong scores. Fail closed instead — emit
// an all-miss result set so the renderer sees score=0 / isHit=false
// with the expected per-note entries.
const auto* basePtr = standardMidiFor(req.arrangement, req.stringCount);
if (basePtr == nullptr) { fillMissResults(); return out; }
const auto& base = *basePtr;
if ((int) req.tuningOffsets.size() != req.stringCount)
{
fillMissResults();
return out;
}
for (const auto& n : req.notes)
{
if (n.string < 0 || n.string >= req.stringCount)
{
fillMissResults();
return out;
}
}
// Size the FFT exactly the way JS does: at least nextPow2(numSamples),
// but never finer than the bin-width floor derived from sampleRate so
// the low-B fundamental on 5-string bass remains resolvable across
// device rates. Clamp the final size to kMaxFftSize so a caller-
// controlled `numSamples` (or a pathological sampleRate) can't force
// an oversized FFT-plan/scratch allocation across the IPC boundary.
const int clampedSamples = std::min(numSamples, kMaxFftSize);
const int resolutionFloor = std::min(
nextPow2((int) std::ceil(sampleRate / kTargetBinHz)),
kMaxFftSize);
const int fftSize = std::max(nextPow2(clampedSamples), resolutionFloor);
ensureFft(fftSize);
computeMagnitudes(buffer, clampedSamples);
const double binHz = sampleRate / fftSize;
lastBinHz = binHz;
// Total spectrum energy — one full pass, shared across every per-
// string `bandEnergy` call below. Same optimisation `_ndScoreChord`
// does in JS.
double totalEnergy = 0.0;
for (float m : magnitudes)
totalEnergy += (double) m * m;
out.results.reserve(req.notes.size());
const int nBins = (int) magnitudes.size();
int hits = 0;
for (const auto& note : req.notes)
{
// Per-technique threshold adjustments, mirroring screen.js.
float energyThreshold = kEnergyThresholdDefault;
float cents = req.pitchCheckCents;
if (note.hammerOn || note.pullOff)
energyThreshold = kEnergyThresholdSoftAttack;
if (note.bend || note.slide)
cents = std::max(cents, kBendSlideCentsFloor);
if (note.harmonic)
cents = 0.0f; // energy-only
NoteResult nr{};
nr.string = note.string;
nr.fret = note.fret;
if (req.harmonicVerify)
{
// ── Harmonic-comb verification ──────────────────────────────
// Score the note by the energy at its expected harmonics
// (f, 2f .. 5f) relative to the off-harmonic spectral floor
// sampled between them. No whole-spectrum division, so a bright
// or broadband signal does not dilute the measurement.
const int expectedMidi =
midiFromStringFret(note.string, note.fret, base, req.tuningOffsets, req.capo);
const double f0 = 440.0 * std::pow(2.0, (expectedMidi - 69) / 12.0);
// Refined peak frequency + magnitude in a ±~half-semitone window
// around `targetHz`. The window doubles as the pitch tolerance:
// a note a semitone off (~6 %) falls outside it, so a
// neighbouring fret's comb will not score against this note.
auto peakNear = [&](double targetHz, float& outMag) -> double
{
const int lo = std::max(0, (int) std::floor(targetHz * 0.971 / binHz));
const int hi = std::min(nBins - 1, (int) std::ceil(targetHz * 1.030 / binHz));
int pkBin = lo;
float pk = 0.0f;
for (int k = lo; k <= hi; ++k)
{
if (magnitudes[(size_t) k] > pk)
{
pk = magnitudes[(size_t) k];
pkBin = k;
}
}
outMag = pk;
const float d = (pkBin > 0 && pkBin < nBins - 1)
? parabolicOffset(magnitudes[(size_t) (pkBin - 1)],
magnitudes[(size_t) pkBin],
magnitudes[(size_t) (pkBin + 1)])
: 0.0f;
return (pkBin + d) * binHz;
};
constexpr int kHarmonics = 5;
double harmEnergy = 0.0;
// Per-partial peak frequency + magnitude, captured so the pitch
// estimate below can blend the best-resolved low partials (bass)
// instead of being locked to the h=1 fundamental (guitar).
double harmFreq[kHarmonics + 1] = { 0.0 };
float harmMag[kHarmonics + 1] = { 0.0f };
// Pitch on guitar is read from the h=1 fundamental only. Taking the
// strongest partial ÷ its number reads systematically sharp: real
// strings are inharmonic, so the 2nd/3rd partials sit sharp of
// 2f0/3f0 — and on a DI tone those upper partials are often the
// strongest. At guitar fundamentals (>=82 Hz) the f0 bin is well
// resolved, so the fundamental is the bias-free pitch source.
double fundamentalFreq = f0;
float fundMag = 0.0f; // h=1 peak magnitude
float maxHarmMag = 0.0f; // strongest partial's magnitude
for (int h = 1; h <= kHarmonics; ++h)
{
float mag = 0.0f;
const double freq = peakNear(f0 * h, mag);
harmEnergy += (double) mag * mag;
harmFreq[h] = freq;
harmMag[h] = mag;
if (h == 1) { fundamentalFreq = freq; fundMag = mag; }
if (mag > maxHarmMag) maxHarmMag = mag;
}
// Off-harmonic floor: the midpoints 1.5f, 2.5f .. carry only
// local noise/decay, never this note's own partials.
double floorEnergy = 0.0;
for (int h = 1; h < kHarmonics; ++h)
{
float mag = 0.0f;
peakNear(f0 * (h + 0.5), mag);
floorEnergy += (double) mag * mag;
}
const double harmAvg = harmEnergy / kHarmonics;
const double floorAvg = floorEnergy / (kHarmonics - 1);
// Average per-bin energy is a scale-correct silence floor — in
// silence harmAvg, floorAvg and avgBin all collapse together so
// the ratio sits near 1 and nothing scores.
const double avgBin = totalEnergy / std::max(nBins, 1);
const double denom = std::max(std::max(floorAvg, avgBin), 1e-20);
const float snr = (float) (harmAvg / denom);
// Pitch source. Guitar: the h=1 fundamental (bias-free, well
// resolved at >=82 Hz). Bass: the fundamental of a low note spans
// barely one FFT bin (~157 cents/bin at the open low-B) and is
// often suppressed on a DI, so its lone cents reading is noise.
// Estimate f0 instead from a magnitude-weighted blend of the low
// partials' implied f0 (freq_h / h) — 2-3x better resolved, and the
// small inharmonic bias on h=2/3 is far below the low-bin error it
// replaces. Only partials clearly above the per-note floor
// contribute, so a spurious peak in an empty harmonic window cannot
// drag the estimate. (An octave-up impostor is still caught by the
// fundamental-presence gate below, which runs before this is used.)
double pitchFreq = fundamentalFreq;
if (req.arrangement == "bass" && maxHarmMag > 0.0f)
{
double wsum = 0.0, fsum = 0.0;
for (int h = 1; h <= 3 && h <= kHarmonics; ++h)
{
if (harmMag[h] < 0.25f * maxHarmMag) continue;
const double w = (double) harmMag[h];
wsum += w;
fsum += w * (harmFreq[h] / (double) h);
}
if (wsum > 0.0) pitchFreq = fsum / wsum;
}
const float centsError =
foldOctaveCents((float) (1200.0 * std::log2(pitchFreq / f0)));
// Fundamental-presence gate — specificity against octave / related
// wrong notes. A genuine note has real energy at f0. An octave-up
// impostor (or playing a power chord's root only, leaving the
// fifth's comb to feed on the root's 3rd partial) has its energy at
// f0's MULTIPLES, with f0 itself near the noise floor — so when the
// fundamental peak is tiny next to the strongest partial, reject.
// Skipped for harmonic-flagged notes, whose fundamental is meant to
// be weak. The ratio is req-tunable (ChordScorer.h): guitar keeps
// the 0.20 default; bass passes a lower value because its DI
// fundamental is legitimately weak, and `<= 0` disables the gate.
const bool fundamentalPresent =
note.harmonic
|| req.fundamentalRatio <= 0.0f
|| maxHarmMag <= 0.0f
|| fundMag >= req.fundamentalRatio * maxHarmMag;
// The `bandEnergy` field carries the SNR here so the renderer's
// diagnostics still have a number to surface.
nr.bandEnergy = snr;
nr.hasCents = true;
nr.centsDiff = std::abs(centsError);
nr.centsError = centsError;
nr.hit = (snr >= req.harmonicSnr)
&& fundamentalPresent
&& (cents <= 0.0f || std::abs(centsError) <= cents);
}
else
{
// ── Band-energy check (original path; chords still use it) ──
const auto [loHz, hiHz] = stringBandHz(note.string, base, req.tuningOffsets, req.capo);
const int loBin = std::max(0, (int) std::floor(loHz / binHz));
const int hiBin = std::min(nBins - 1, (int) std::ceil(hiHz / binHz));
double bandEnergy = 0.0;
if (hiBin >= loBin)
{
for (int k = loBin; k <= hiBin; ++k)
bandEnergy += (double) magnitudes[(size_t) k] * magnitudes[(size_t) k];
}
const float bandEnergyFraction = (totalEnergy < 1e-12)
? 0.0f
: (float) (bandEnergy / totalEnergy);
nr.bandEnergy = bandEnergyFraction;
if (bandEnergyFraction < energyThreshold)
{
nr.hit = false;
nr.hasCents = false;
}
else if (cents <= 0.0f)
{
// Energy-only path (harmonic flag, or caller asked for it).
nr.hit = true;
nr.hasCents = false;
}
else
{
int peakBin = loBin;
float peakVal = -std::numeric_limits<float>::infinity();
for (int k = loBin; k <= hiBin; ++k)
{
if (magnitudes[(size_t) k] > peakVal)
{
peakVal = magnitudes[(size_t) k];
peakBin = k;
}
}
const float delta = (peakBin > loBin && peakBin < hiBin)
? parabolicOffset(magnitudes[(size_t) (peakBin - 1)],
magnitudes[(size_t) peakBin],
magnitudes[(size_t) (peakBin + 1)])
: 0.0f;
const double detectedHz = (peakBin + delta) * binHz;
const int expectedMidi = midiFromStringFret(note.string, note.fret, base, req.tuningOffsets, req.capo);
const double expectedHz = 440.0 * std::pow(2.0, (expectedMidi - 69) / 12.0);
const float rawCentsError = (float) (1200.0 * std::log2(detectedHz / expectedHz));
const float centsError = foldOctaveCents(rawCentsError);
const float centsDiff = std::abs(centsError);
nr.hit = centsDiff <= cents;
nr.hasCents = true;
nr.centsDiff = centsDiff;
nr.centsError = centsError;
}
}
if (nr.hit) ++hits;
out.results.push_back(nr);
}
out.hitStrings = hits;
out.score = out.totalStrings > 0 ? (float) hits / (float) out.totalStrings : 0.0f;
out.isHit = out.score >= req.minHitRatio;
return out;
}
+170
View File
@@ -0,0 +1,170 @@
#pragma once
// ChordScorer — native port of the notedetect plugin's polyphonic
// chord-scoring math (slopsmith-plugin-notedetect/screen.js). Constitution
// II requires audio analysis to live in JUCE, not renderer JS; the
// renderer calls in over IPC (`audio:scoreChord`) and consumes the
// returned result object.
//
// The math is a direct C++ translation of the JS originals
// (`_ndFftMagnitude`, `_ndStringBandHz`, `_ndBandEnergy`, `_ndTotalEnergy`,
// `_ndConstraintCheckString`, `_ndScoreChord`). The custom radix-2
// Cooley-Tukey in JS is replaced with `juce::dsp::FFT`; the rest of the
// helpers are line-for-line translations. Behavioural parity with the
// browser path is the target — bin-for-bin floating-point identity is
// not, since the JS FFT and `juce::dsp::FFT` evaluate the butterflies
// in different orders and may also use vectorised intrinsics natively.
#include <juce_dsp/juce_dsp.h>
#include <memory>
#include <string>
#include <vector>
class ChordScorer
{
public:
// Standard-tuning MIDI base for the supported (arrangement, stringCount)
// pairs. Lifted from screen.js `_ND_TUNING_*` constants verbatim so the
// open-string MIDI values match between the native and JS paths.
// Returns `nullptr` for unsupported pairs (e.g. "guitar" + 5-string,
// or any unknown arrangement string) — caller is expected to fail
// the request rather than guess a fallback tuning.
static const std::vector<int>* standardMidiFor(const std::string& arrangement, int stringCount);
// Hard upper bound on the FFT size we will ever build. The 3 Hz
// bin-width floor in scoreChord() implies fftSize ≈ nextPow2(SR/3),
// which is 16384 at 48 kHz, 32768 at 96 kHz, 65536 at 192 kHz —
// already the largest realistic audio-interface rate. Bounding this
// here protects the addon against caller-controlled `numSamples`
// forcing pathological reallocations of the FFT plan and scratch
// buffers over IPC.
static constexpr int kMaxFftSize = 65536;
// One chord-note in the request payload. Mirrors the chart-note shape
// the JS chord scorer consumes from `matchNotes()`: `s` = string
// index, `f` = fret, plus optional technique flags that adjust
// per-string thresholds.
struct Note
{
int string = 0;
int fret = 0;
bool hammerOn = false; // ho — no pick attack, lower energy threshold
bool pullOff = false; // po — same
bool bend = false; // b — pitch moving, widen pitch window
bool slide = false; // sl — same
bool harmonic = false; // hm — energy-only check, skip pitch
};
// Per-note scoring result. Same field names as the JS shape so the
// N-API wrapper can map straight through and the renderer-side
// consumer is identical to the browser path.
struct NoteResult
{
int string = 0;
int fret = 0;
bool hit = false;
float bandEnergy = 0.0f;
// centsDiff is the absolute pitch deviation; centsError is signed
// (positive = sharp). Both are valid only when the band-energy
// threshold passed AND pitch-check was requested; otherwise
// hasCents is false and the renderer treats them as null.
bool hasCents = false;
float centsDiff = 0.0f;
float centsError = 0.0f;
};
struct Request
{
int numSamples = 4096; // window read out of the engine's input ring
std::string arrangement = "guitar"; // "guitar" | "bass"
int stringCount = 6;
std::vector<int> tuningOffsets; // size == stringCount, semitones per string
int capo = 0;
float pitchCheckCents = 0.0f; // 0 = energy-only chord check
float minHitRatio = 0.6f;
// Force the DSP band-energy scorer even when an ML model is
// loaded. The ML path is onset-driven and silently drops notes
// the detector never fires an onset for; the renderer sets this
// to verify a chart note purely from spectral energy at its
// expected fundamental (the harmonic-comb check).
bool bypassMl = false;
// Harmonic-comb verification. The default per-note check sums energy
// across a whole string's frequency band and divides by the total
// spectrum — a metric a bright or broadband signal dilutes to ~1-3%,
// below threshold, so correctly-played notes are rejected. With this
// set, each note is instead scored by the energy at its EXPECTED
// harmonics (f, 2f, 3f, 4f, 5f) relative to the off-harmonic spectral
// floor between them. That is the harmonic-comb targeted check:
// robust to brightness/distortion because distortion adds energy AT
// the harmonics, and free of whole-spectrum dilution.
bool harmonicVerify = false;
// Minimum harmonic-to-floor ratio for a note to count as present.
// Tunable over IPC so the renderer can calibrate without a rebuild.
float harmonicSnr = 3.0f;
// Fundamental-presence gate (harmonicVerify only): a note is rejected
// when its f0 peak is weaker than `fundamentalRatio × strongest
// partial`. This is the specificity guard against octave-up impostors
// (and power-chord-root-only feeding a fifth's comb), which have their
// energy at f0's MULTIPLES with f0 itself near the floor.
//
// The 0.20 default suits guitar, whose DI fundamental is healthy. Bass
// DI fundamentals are routinely WEAKER than the 2nd harmonic (amp-sim
// DIs, compressed/rolled-off-below-60 Hz tones — see `_ndHpsDetect`),
// so the renderer lowers this for bass to stop false rejects. <= 0
// disables the gate entirely. Tunable over IPC like harmonicSnr.
float fundamentalRatio = 0.20f;
std::vector<Note> notes;
};
struct Result
{
float score = 0.0f; // hitStrings / totalStrings
int hitStrings = 0;
int totalStrings = 0;
bool isHit = false;
std::vector<NoteResult> results;
};
ChordScorer() = default;
// Score a chord against `buffer` (numSamples mono floats). Audio is
// not stored; the caller (AudioEngine) snapshots its input ring and
// passes the pointer in. The FFT plan, the complex scratch buffer
// and the magnitude buffer are reused across calls, so the
// FFT/peak-pick path itself is allocation-free in steady state. The
// returned `Result` still allocates its `results` vector (one entry
// per requested note) — that's a tiny per-call cost the IPC layer
// pays anyway when serialising the response.
Result scoreChord(const float* buffer, int numSamples, double sampleRate, const Request& req);
private:
// Lazily-built FFT for whatever size the current sampleRate dictates.
// The JS version targets ~3 Hz bin width, so the size depends on
// sampleRate; we rebuild only when that derived size changes.
void ensureFft(int fftSize);
void computeMagnitudes(const float* buffer, int numSamples);
int currentFftSize = 0;
int currentFftOrder = 0;
std::unique_ptr<juce::dsp::FFT> fft;
// FFT scratch as a vector of complex bins, length fftSize. Storing
// it as std::complex<float> (which juce::dsp::Complex aliases) lets
// us pass &scratch[0] to fft->perform() without reinterpret_cast'ing
// a float buffer through a stricter aliasing boundary — the C++
// standard only guarantees that a std::complex<T> is layout-
// compatible with T[2] in one direction (complex → T[2]), not the
// other, so a float* → complex* cast is undefined.
std::vector<juce::dsp::Complex<float>> fftScratch;
// Output buffer for fft->perform(). JUCE's FFT::perform is documented
// out-of-place (juce_FFT.h: "Performs an out-of-place FFT"). Aliasing
// input and output silently corrupts the result on the Ooura fallback
// engine that ships on Linux/Windows builds — radix decomposition
// reads input positions and writes output positions in overlapping
// iteration patterns, so the same memory gets read after write and
// intermediate values cascade through butterflies into ~1e27-magnitude
// garbage bins. Keep a distinct output buffer of the same size.
std::vector<juce::dsp::Complex<float>> fftOutScratch;
// Magnitude spectrum, length fftSize/2 + 1 (Nyquist-inclusive).
std::vector<float> magnitudes;
double lastBinHz = 0.0;
};
+106
View File
@@ -0,0 +1,106 @@
#include "IRLoader.h"
IRLoader::IRLoader()
: AudioProcessor(BusesProperties()
.withInput("Input", juce::AudioChannelSet::stereo(), true)
.withOutput("Output", juce::AudioChannelSet::stereo(), true))
{
}
IRLoader::~IRLoader() {}
bool IRLoader::loadIR(const juce::File& irFile)
{
if (!irFile.existsAsFile()) return false;
try
{
// Use JUCE's file-based loading — it handles WAV reading internally
convolution.loadImpulseResponse(
irFile,
juce::dsp::Convolution::Stereo::yes,
juce::dsp::Convolution::Trim::yes,
0 // use full IR
);
currentIRName = irFile.getFileNameWithoutExtension();
currentIRPath = irFile.getFullPathName();
// Mark as ready immediately — JUCE's convolution handles
// the background loading internally and will start processing
// once the IR is ready.
irLoaded.store(true);
return true;
}
catch (const std::exception& e)
{
fprintf(stderr, "[IRLoader] Exception: %s\n", e.what());
return false;
}
catch (...)
{
fprintf(stderr, "[IRLoader] Unknown exception\n");
return false;
}
}
void IRLoader::prepareToPlay(double sampleRate, int samplesPerBlock)
{
currentSampleRate = sampleRate;
juce::dsp::ProcessSpec spec;
spec.sampleRate = sampleRate;
spec.maximumBlockSize = (juce::uint32)samplesPerBlock;
spec.numChannels = 2;
convolution.prepare(spec);
}
void IRLoader::releaseResources()
{
convolution.reset();
}
void IRLoader::processBlock(juce::AudioBuffer<float>& buffer, juce::MidiBuffer&)
{
if (!irLoaded.load()) return;
int numSamples = buffer.getNumSamples();
int numChannels = juce::jmin(buffer.getNumChannels(), 2);
// Ensure we only process up to 2 channels (what convolution was prepared for)
juce::dsp::AudioBlock<float> block(buffer.getArrayOfWritePointers(), (size_t)numChannels, (size_t)numSamples);
juce::dsp::ProcessContextReplacing<float> context(block);
convolution.process(context);
// Output gain
float gain = outputGain.load();
if (std::abs(gain - 1.0f) > 0.001f)
buffer.applyGain(gain);
}
void IRLoader::getStateInformation(juce::MemoryBlock& destData)
{
auto state = new juce::DynamicObject();
state->setProperty("irPath", currentIRPath);
state->setProperty("mix", (double)dryWetMix.load());
state->setProperty("gain", (double)outputGain.load());
auto json = juce::JSON::toString(juce::var(state));
destData.append(json.toRawUTF8(), json.getNumBytesAsUTF8());
}
void IRLoader::setStateInformation(const void* data, int sizeInBytes)
{
auto json = juce::String::fromUTF8((const char*)data, sizeInBytes);
auto parsed = juce::JSON::parse(json);
if (auto* obj = parsed.getDynamicObject())
{
auto path = obj->getProperty("irPath").toString();
if (path.isNotEmpty())
loadIR(juce::File(path));
if (obj->hasProperty("mix"))
dryWetMix.store((float)(double)obj->getProperty("mix"));
if (obj->hasProperty("gain"))
outputGain.store((float)(double)obj->getProperty("gain"));
}
}
+64
View File
@@ -0,0 +1,64 @@
#pragma once
#include <juce_audio_processors/juce_audio_processors.h>
#include <juce_audio_formats/juce_audio_formats.h>
#include <juce_dsp/juce_dsp.h>
// Cabinet impulse response loader using JUCE's convolution engine.
// Loads .wav/.ir files and applies them in real-time.
class IRLoader : public juce::AudioProcessor
{
public:
IRLoader();
~IRLoader() override;
// Load an impulse response file (.wav, .aif, .ir)
bool loadIR(const juce::File& irFile);
bool hasIR() const { return irLoaded.load(); }
juce::String getIRName() const { return currentIRName; }
juce::String getIRPath() const { return currentIRPath; }
// Dry/wet mix (0.0 = fully dry, 1.0 = fully wet)
void setMix(float mix) { dryWetMix.store(juce::jlimit(0.0f, 1.0f, mix)); }
float getMix() const { return dryWetMix.load(); }
// Output gain
void setGain(float gain) { outputGain.store(gain); }
float getGain() const { return outputGain.load(); }
// AudioProcessor interface
const juce::String getName() const override { return "IR Loader"; }
void prepareToPlay(double sampleRate, int samplesPerBlock) override;
void releaseResources() override;
void processBlock(juce::AudioBuffer<float>& buffer, juce::MidiBuffer& midi) override;
double getTailLengthSeconds() const override { return 0.5; }
bool acceptsMidi() const override { return false; }
bool producesMidi() const override { return false; }
juce::AudioProcessorEditor* createEditor() override { return nullptr; }
bool hasEditor() const override { return false; }
int getNumPrograms() override { return 1; }
int getCurrentProgram() override { return 0; }
void setCurrentProgram(int) override {}
const juce::String getProgramName(int) override { return {}; }
void changeProgramName(int, const juce::String&) override {}
void getStateInformation(juce::MemoryBlock& destData) override;
void setStateInformation(const void* data, int sizeInBytes) override;
private:
juce::dsp::Convolution convolution;
juce::AudioBuffer<float> dryBuffer; // for dry/wet mixing
std::atomic<bool> irLoaded{false};
std::atomic<float> dryWetMix{1.0f};
std::atomic<float> outputGain{1.0f};
juce::String currentIRName;
juce::String currentIRPath;
double currentSampleRate = 48000.0;
JUCE_DECLARE_NON_COPYABLE_WITH_LEAK_DETECTOR(IRLoader)
};
+29
View File
@@ -0,0 +1,29 @@
#pragma once
#include <cstdint>
#include <vector>
// InputRingReader — the narrow slice of a capture chain that NoteVerifier needs:
// the post-input lock-free sample ring plus the device sample rate. Factored out
// of AudioEngine so the verifier reads ITS OWN source's ring rather than the
// engine's single global one — the precondition for N independent capture chains
// (a SourceChain per audio input). AudioEngine used to expose these directly and
// NoteVerifier held an `AudioEngine&`; now SourceChain implements this interface
// and owns one verifier. See SourceChain.h.
class InputRingReader
{
public:
virtual ~InputRingReader() = default;
// Most-recent N samples from the pre-gate input ring, zero-padded on the
// left during cold start so the newest sample lands at out.back(). Off-
// audio-thread safe (acquire-loads the ring write index).
virtual std::vector<float> getInputFrame(int numSamples) const = 0;
// Gapless consumption: copies every sample written since monotonic index
// `fromIndex` into `out` and returns the current write index. See
// AudioEngine::getInputSince (the original) for the full contract.
virtual uint64_t getInputSince(uint64_t fromIndex, std::vector<float>& out) const = 0;
// The live device sample rate the ring samples were captured at.
virtual double getCurrentSampleRate() const = 0;
};
+571
View File
@@ -0,0 +1,571 @@
#include "MlNoteDetector.h"
#include <algorithm>
#include <cmath>
#if SLOPSMITH_ONNX_SUPPORT
#include <juce_audio_basics/juce_audio_basics.h> // juce::LagrangeInterpolator
#include <onnxruntime_cxx_api.h>
#include <array>
namespace
{
// --- Basic Pitch contract (basic_pitch/constants.py; verified by the Phase 0
// spike — see tests/spike/README.md) -------------------------------------
constexpr int kModelSampleRate = 22050;
constexpr int kAudioNSamples = 22050 * 2 - 256; // 43844, ~2 s window
constexpr int kFramesPerWindow = 172;
constexpr int kModelPitches = MlNoteDetector::kNumPitches; // 88
constexpr int kLowestMidi = MlNoteDetector::kLowestMidi; // 21 (A0)
// nmp.onnx tensor names (verified in Phase 0).
const char* kInputName = "serving_default_input_2:0";
const char* kNoteOutput = "StatefulPartitionedCall:1"; // frame/note posteriorgram
const char* kOnsetOutput = "StatefulPartitionedCall:2"; // onset posteriorgram
// Inference cadence. A 48 ms hop keeps detection latency low for live
// single-note scoring of fast material; inference itself is ~30 ms so the
// background thread stays under one core. (Onset *timing* no longer depends
// on the hop — it is back-dated from the posteriorgram frame, see below.)
constexpr int kHopMs = 48;
constexpr int kHopSamples = kModelSampleRate * kHopMs / 1000; // ~1058
// One posteriorgram frame in ms (FFT hop 256 @ 22050 Hz ≈ 11.6 ms). Used to
// back-date a detected onset from its frame index to a wall-clock time.
constexpr double kFrameMs = 256.0 * 1000.0 / kModelSampleRate;
// When reading "what is sounding now" from a fresh inference, look at the
// posteriorgram frames covering roughly the last hop plus a small margin.
constexpr int kFreshFrames = 12; // ~140 ms at 86 fps
constexpr float kActivityThreshold = 0.40f; // frame posteriorgram -> "active"
constexpr float kOnsetThreshold = 0.50f; // onset posteriorgram rising edge
// A pitch is reported active for this long after its onset even once its
// sustained level has decayed — so fast notes aren't missed between polls.
constexpr float kRecentOnsetMs = 200.0f;
// Two onset edges on the same pitch must be at least this far apart to count
// as distinct notes. Below the chug rate of fast palm muting (~10/s), above
// the per-inference jitter of re-detecting the same onset.
constexpr double kMinOnsetGapMs = 45.0;
const char* noteNameFor(int midi)
{
static const char* n[12] = {"C","C#","D","D#","E","F","F#","G","G#","A","A#","B"};
static thread_local char buf[8];
if (midi < 0 || midi > 127) return "?";
std::snprintf(buf, sizeof(buf), "%s%d", n[midi % 12], midi / 12 - 1);
return buf;
}
} // namespace
// ── Background inference thread ───────────────────────────────────────────────
class MlInferenceThread : public juce::Thread
{
public:
explicit MlInferenceThread(std::function<void()> callback)
: Thread("MlNoteDetector"), cb(std::move(callback)) {}
void run() override
{
while (! threadShouldExit())
{
cb();
sleep(8);
}
}
private:
std::function<void()> cb;
};
// ── Impl ─────────────────────────────────────────────────────────────────────
struct MlNoteDetector::Impl
{
// Audio thread -> inference thread.
juce::AbstractFifo fifo{ 16384 };
std::vector<float> fifoBuffer = std::vector<float>(16384, 0.0f);
// Resampling to 22050 Hz.
juce::LagrangeInterpolator resampler;
double resampleRatio = 48000.0 / kModelSampleRate;
std::vector<float> inQueue; // 48 kHz samples awaiting resampling
// Rolling ~2 s window at 22050 Hz, circular.
std::vector<float> circ = std::vector<float>(kAudioNSamples, 0.0f);
size_t circWrite = 0;
uint64_t totalResampled = 0;
int sinceInference = 0;
// ONNX Runtime session, guarded like NAMProcessor's model pointer.
Ort::Env env{ ORT_LOGGING_LEVEL_WARNING, "slopsmith-mlnd" };
Ort::MemoryInfo memInfo = Ort::MemoryInfo::CreateCpu(OrtArenaAllocator, OrtMemTypeDefault);
std::unique_ptr<Ort::Session> session;
juce::CriticalSection sessionLock;
// Active-pitch snapshot, written by the inference thread, read from the
// main (N-API) thread under snapshotLock. Neither writer nor reader is the
// audio thread, so a lock here is correct and cheap.
// Per-pitch onset tracking, owned by the inference thread (no lock — only
// runInferenceIfDue() touches these). onsetTimeMs is the monotonic ms of
// the last *distinct* onset; onsetSeq counts distinct onsets; onsetConf is
// the note posteriorgram at that onset.
std::array<double, kModelPitches> onsetTimeMs{};
std::array<int, kModelPitches> onsetSeq{};
std::array<float, kModelPitches> onsetConf{};
juce::CriticalSection snapshotLock;
std::array<float, kModelPitches> snapActivity{}; // sustained note level
std::array<double, kModelPitches> snapOnsetTimeMs{}; // monotonic ms of last onset; 0 = none
std::array<int, kModelPitches> snapOnsetSeq{}; // distinct-onset counter
std::array<float, kModelPitches> snapOnsetConf{}; // note posteriorgram at that onset
// Reusable inference scratch — avoids per-hop allocation.
std::vector<float> window = std::vector<float>(kAudioNSamples, 0.0f);
std::vector<float> resampleScratch;
std::unique_ptr<MlInferenceThread> thread;
// Set false by the inference thread when a loaded model's output shape
// violates the Basic Pitch contract, or after repeated Run() failures —
// such a model will never publish usable notes, so isAvailable() then
// reports false and callers fall back to the YIN detector instead of
// silently preferring a dead ML path.
std::atomic<bool> contractValid{ true };
// Consecutive inference exceptions; demote the model once it stays
// structurally broken. Atomic: incremented by the inference thread but
// also reset by loadModel() on the N-API thread.
std::atomic<int> consecutiveInferFailures{ 0 };
// False until the first inference publishes a snapshot. isReady() gates
// the engine's ML routing on this so the ~2 s cold-start window after
// every audio start/restart uses the YIN/ChordScorer fallback rather
// than an ML scorer that would return all-misses until the window fills.
std::atomic<bool> hasPublished{ false };
// Start / stop the background inference thread. prepare() and stop() use
// these so the thread is never alive while clearAudioState() mutates the
// FIFO / inQueue / circular buffer — otherwise a device stop→start cycle
// would race ingest() against the buffer reset.
void startThread()
{
thread = std::make_unique<MlInferenceThread>([this]()
{
ingest();
runInferenceIfDue();
});
thread->startThread(juce::Thread::Priority::normal);
}
void stopThread()
{
if (thread)
{
// juce::Thread::stopThread() signals, waits, and only force-kills
// on timeout — after it returns the thread is definitively not
// running, so resetting the unique_ptr can't destroy a live
// juce::Thread (which a bare waitForThreadToExit() + unconditional
// reset would, if the join timed out mid-inference).
thread->stopThread(2000);
thread.reset();
}
}
void clearAudioState()
{
resampler.reset();
inQueue.clear();
std::fill(circ.begin(), circ.end(), 0.0f);
circWrite = 0;
totalResampled = 0;
sinceInference = 0;
fifo.reset();
onsetTimeMs.fill(0.0);
onsetSeq.fill(0);
onsetConf.fill(0.0f);
// Also clear the published snapshot, under its lock: otherwise after a
// device restart detectNotes()/getActiveNotes()/getDominantNote() keep
// reporting the previous run's pitches until the next ~2 s inference
// window fills and publishes fresh data.
{
const juce::ScopedLock sl(snapshotLock);
snapActivity.fill(0.0f);
snapOnsetTimeMs.fill(0.0);
snapOnsetSeq.fill(0);
snapOnsetConf.fill(0.0f);
}
// No snapshot has been published for this (re)start yet — the engine
// routes to the YIN fallback via isReady() until the first inference.
hasPublished.store(false, std::memory_order_relaxed);
}
// Drain the FIFO, resample to 22050 Hz, append to the rolling window.
void ingest()
{
const int ready = fifo.getNumReady();
if (ready <= 0) return;
auto scope = fifo.read(ready);
const size_t base = inQueue.size();
inQueue.resize(base + (size_t) ready);
for (int i = 0; i < scope.blockSize1; ++i)
inQueue[base + (size_t) i] = fifoBuffer[(size_t) (scope.startIndex1 + i)];
for (int i = 0; i < scope.blockSize2; ++i)
inQueue[base + (size_t) scope.blockSize1 + (size_t) i]
= fifoBuffer[(size_t) (scope.startIndex2 + i)];
// Produce as many 22050 Hz samples as the queued input safely allows,
// leaving a small margin for the interpolator kernel.
const int avail = (int) inQueue.size();
const int numOut = (int) ((double) (avail - 8) / resampleRatio);
if (numOut <= 0) return;
resampleScratch.resize((size_t) numOut);
const int used = resampler.process(resampleRatio, inQueue.data(),
resampleScratch.data(), numOut);
inQueue.erase(inQueue.begin(), inQueue.begin() + juce::jmin(used, avail));
for (int i = 0; i < numOut; ++i)
{
circ[circWrite] = resampleScratch[(size_t) i];
circWrite = (circWrite + 1) % (size_t) kAudioNSamples;
}
totalResampled += (uint64_t) numOut;
sinceInference += numOut;
}
void runInferenceIfDue()
{
if (sinceInference < kHopSamples) return;
if (totalResampled < (uint64_t) kAudioNSamples) return; // window not full yet
sinceInference = 0;
// A demoted model (bad output contract or repeated Run() failures)
// will never publish — stop running inference for it. Callers have
// already fallen back to YIN via isAvailable(); no point burning CPU
// or throwing on every hop. loadModel() clears the flag on a reload.
if (! contractValid.load(std::memory_order_relaxed)) return;
// try-lock: if a model swap is in progress, skip this hop rather than
// stalling the detector (NAMProcessor's realtime-adjacent pattern).
const juce::ScopedTryLock sl(sessionLock);
if (! sl.isLocked() || session == nullptr) return;
// Linearise the circular window, oldest sample first. Capture the
// wall-clock time of the window's last sample ("now") so detected
// onsets can be back-dated from their posteriorgram frame index.
const double tWindowEnd = juce::Time::getMillisecondCounterHiRes();
for (int i = 0; i < kAudioNSamples; ++i)
window[(size_t) i] = circ[(circWrite + (size_t) i) % (size_t) kAudioNSamples];
try
{
const int64_t inShape[3] = { 1, kAudioNSamples, 1 };
Ort::Value inTensor = Ort::Value::CreateTensor<float>(
memInfo, window.data(), window.size(), inShape, 3);
const char* inNames[] = { kInputName };
const char* outNames[] = { kNoteOutput, kOnsetOutput };
auto out = session->Run(Ort::RunOptions{ nullptr },
inNames, &inTensor, 1, outNames, 2);
const float* note = out[0].GetTensorData<float>();
const float* onset = out[1].GetTensorData<float>();
// Validate BOTH output tensors against the Basic Pitch contract
// ([1, frames, 88]) before any indexing — a fallback to expected
// dims would let an unexpected-shape model slip through and the
// flat indexing below would read past a tensor. On a violation the
// model can never publish usable notes, so demote (contractValid
// = false): isAvailable() then reports false and callers fall back
// to YIN instead of silently preferring a dead ML path.
const auto noteShape = out[0].GetTensorTypeAndShapeInfo().GetShape();
const auto onsetShape = out[1].GetTensorTypeAndShapeInfo().GetShape();
const bool contractOk =
noteShape.size() == 3 && onsetShape.size() == 3
&& noteShape[0] == 1 && onsetShape[0] == 1
&& noteShape[1] > 0
&& (int) noteShape[2] == kModelPitches
&& onsetShape[1] == noteShape[1]
&& onsetShape[2] == noteShape[2];
if (! contractOk)
{
contractValid.store(false, std::memory_order_relaxed);
return;
}
const int frames = (int) noteShape[1];
const int pitches = (int) noteShape[2];
const int firstFrame = juce::jmax(0, frames - kFreshFrames);
std::array<float, kModelPitches> act{};
for (int p = 0; p < kModelPitches; ++p)
{
// Sustained level: peak note posteriorgram over the fresh frames.
float a = 0.0f;
for (int f = firstFrame; f < frames; ++f)
a = juce::jmax(a, note[f * pitches + p]);
act[(size_t) p] = a;
// Most recent onset: latest rising edge of the onset
// posteriorgram anywhere in the window, back-dated to a
// wall-clock time from its frame offset to the window end.
for (int f = frames - 1; f >= 1; --f)
{
// Gate the rising edge on the note posteriorgram too — an
// isolated onset spike with a near-zero note posterior is
// noise; without this gate it would still advance onsetSeq
// and getActiveNotes()/scoreChordWithMl() would treat the
// pitch as a recent onset for ~200 ms. (Mirrors the
// verified Basic Pitch onset/frame post-processing.)
if (onset[f * pitches + p] >= kOnsetThreshold
&& onset[(f - 1) * pitches + p] < kOnsetThreshold
&& note[f * pitches + p] >= kActivityThreshold)
{
const double t = tWindowEnd - (double) (frames - 1 - f) * kFrameMs;
// Advance the onset counter only for a genuinely newer
// onset — the same physical onset re-detected on the
// next inference computes ~the same time and must not
// be counted twice.
if (t > onsetTimeMs[(size_t) p] + kMinOnsetGapMs)
{
onsetTimeMs[(size_t) p] = t;
onsetSeq[(size_t) p] += 1;
onsetConf[(size_t) p] = note[f * pitches + p];
}
break;
}
}
}
{
const juce::ScopedLock snap(snapshotLock);
snapActivity = act;
snapOnsetTimeMs = onsetTimeMs;
snapOnsetSeq = onsetSeq;
snapOnsetConf = onsetConf;
}
consecutiveInferFailures = 0;
hasPublished.store(true, std::memory_order_relaxed);
}
catch (...)
{
// Inference failed — leave the previous snapshot in place; the
// engine still has the YIN fallback. A model that keeps throwing
// (e.g. missing the expected Basic Pitch output names) is
// structurally broken: demote it after a few consecutive
// failures so isAvailable() reports false and callers stop
// preferring a permanently dead ML path.
if (++consecutiveInferFailures >= 3)
contractValid.store(false, std::memory_order_relaxed);
}
}
};
// ── MlNoteDetector ───────────────────────────────────────────────────────────
MlNoteDetector::MlNoteDetector() : impl(std::make_unique<Impl>()) {}
MlNoteDetector::~MlNoteDetector() { stop(); }
bool MlNoteDetector::isAvailable() const
{
return modelLoaded.load(std::memory_order_relaxed)
&& impl->contractValid.load(std::memory_order_relaxed);
}
bool MlNoteDetector::isReady() const
{
// Available AND has published at least one inference snapshot — the
// engine gates ML routing on this so the cold-start window after an
// audio start/restart uses the YIN/ChordScorer fallback.
return isAvailable()
&& impl->hasPublished.load(std::memory_order_relaxed);
}
bool MlNoteDetector::loadModel(const juce::File& modelFile)
{
// Return value is "is the ML detector available after this call" — not
// "did this particular attempt succeed". A failed load never tears down a
// model that was already working, so a bad reload over a good model still
// reports true, keeping the result consistent with isAvailable().
if (! modelFile.existsAsFile())
return isAvailable();
try
{
Ort::SessionOptions opts;
opts.SetIntraOpNumThreads(2);
opts.SetGraphOptimizationLevel(GraphOptimizationLevel::ORT_ENABLE_ALL);
#ifdef _WIN32
auto newSession = std::make_unique<Ort::Session>(
impl->env, modelFile.getFullPathName().toWideCharPointer(), opts);
#else
auto newSession = std::make_unique<Ort::Session>(
impl->env, modelFile.getFullPathName().toRawUTF8(), opts);
#endif
{
const juce::ScopedLock sl(impl->sessionLock);
impl->session = std::move(newSession);
}
// Fresh session — clear any prior contract-violation demotion so a
// good model loaded after a bad one is usable again, and reset the
// published flag so isReady() waits for this model's first inference.
impl->contractValid.store(true, std::memory_order_relaxed);
impl->consecutiveInferFailures = 0;
impl->hasPublished.store(false, std::memory_order_relaxed);
modelLoaded.store(true, std::memory_order_relaxed);
// Bring the inference thread up as soon as a model is loaded — not
// only on the first audio-device prepare(). This keeps isAvailable()
// and the engine state honest: once a model is loaded the detector is
// genuinely live (it idles on an empty FIFO until audio starts).
// loadModel() runs once at startup, before any audio device begins,
// so it never races prepare()'s stop/clear/start.
if (! impl->thread)
impl->startThread();
return true;
}
catch (...)
{
// Build failed — the previous session (if any) is untouched, so
// report whatever availability state still holds.
return isAvailable();
}
}
void MlNoteDetector::prepare(double sampleRate, int /*blockSize*/)
{
// Stop the inference thread before mutating the shared buffers so a device
// stop→start cycle can't race clearAudioState() against ingest() /
// runInferenceIfDue() on the FIFO, inQueue and circular window.
impl->stopThread();
impl->resampleRatio = (sampleRate > 0.0 ? sampleRate : 48000.0) / kModelSampleRate;
impl->clearAudioState();
// Only run the inference thread when there's a model to feed it —
// otherwise it would resample the input for nothing. loadModel() starts
// the thread itself if a model arrives after prepare().
if (modelLoaded.load(std::memory_order_relaxed))
impl->startThread();
}
void MlNoteDetector::stop()
{
impl->stopThread();
// Thread is joined — no race. Clear buffers and the published snapshot so
// a stopped detector reports nothing stale.
impl->clearAudioState();
}
void MlNoteDetector::pushSamples(const float* data, int numSamples)
{
if (numSamples <= 0) return;
// Lock-free write; if the FIFO is full (inference stalled) the oldest
// unread samples are simply not overwritten — we drop the newest instead,
// which never blocks or allocates on the audio thread.
const int free = impl->fifo.getFreeSpace();
const int n = juce::jmin(numSamples, free);
if (n <= 0) return;
auto scope = impl->fifo.write(n);
for (int i = 0; i < scope.blockSize1; ++i)
impl->fifoBuffer[(size_t) (scope.startIndex1 + i)] = data[i];
for (int i = 0; i < scope.blockSize2; ++i)
impl->fifoBuffer[(size_t) (scope.startIndex2 + i)] = data[scope.blockSize1 + i];
}
std::vector<MlNoteDetector::ActiveNote> MlNoteDetector::getActiveNotes() const
{
std::vector<ActiveNote> notes;
const double now = juce::Time::getMillisecondCounterHiRes();
{
const juce::ScopedLock sl(impl->snapshotLock);
for (int p = 0; p < kNumPitches; ++p)
{
const float level = impl->snapActivity[(size_t) p];
const double ot = impl->snapOnsetTimeMs[(size_t) p];
const float ageMs = (ot > 0.0) ? (float) (now - ot) : 1.0e9f;
const bool recentOnset = ageMs >= 0.0f && ageMs <= kRecentOnsetMs;
// Report a pitch that is either sustained above the level
// threshold or freshly onset — the latter keeps fast notes,
// whose sustained level decays between polls, from being missed.
if (level >= kActivityThreshold || recentOnset)
{
ActiveNote n;
n.midi = kLowestMidi + p;
n.confidence = juce::jmax(level,
recentOnset ? impl->snapOnsetConf[(size_t) p] : 0.0f);
n.onsetAgeMs = ageMs;
n.onsetSeq = impl->snapOnsetSeq[(size_t) p];
notes.push_back(n);
}
}
}
std::sort(notes.begin(), notes.end(),
[](const ActiveNote& a, const ActiveNote& b)
{ return a.confidence > b.confidence; });
return notes;
}
MlNoteDetector::ActiveNote MlNoteDetector::getDominantNote() const
{
ActiveNote best;
const double now = juce::Time::getMillisecondCounterHiRes();
const juce::ScopedLock sl(impl->snapshotLock);
for (int p = 0; p < kNumPitches; ++p)
{
// Use the same "active" definition as getActiveNotes()/isPitchActive()
// — sustained level OR a fresh onset — so the ML-backed
// getPitchDetection path doesn't miss fast/decaying notes the rest of
// the ML API still reports.
const float level = impl->snapActivity[(size_t) p];
const double ot = impl->snapOnsetTimeMs[(size_t) p];
const float ageMs = (ot > 0.0) ? (float) (now - ot) : 1.0e9f;
const bool recentOnset = ageMs >= 0.0f && ageMs <= kRecentOnsetMs;
if (level < kActivityThreshold && ! recentOnset) continue;
const float conf = juce::jmax(level,
recentOnset ? impl->snapOnsetConf[(size_t) p] : 0.0f);
if (conf > best.confidence)
{
best.midi = kLowestMidi + p;
best.confidence = conf;
best.onsetAgeMs = ageMs;
}
}
return best;
}
bool MlNoteDetector::isPitchActive(int midi, float* confidenceOut) const
{
const int p = midi - kLowestMidi;
if (p < 0 || p >= kNumPitches) return false;
const double now = juce::Time::getMillisecondCounterHiRes();
const juce::ScopedLock sl(impl->snapshotLock);
// Match getActiveNotes()'s definition of "active" exactly: sustained
// level OR a fresh onset. Checking the level alone would miss a
// freshly-struck fast/decaying note that getActiveNotes() — and hence
// detectNotes() — still reports, leaving the chord-scoring path
// (scoreChordWithMl) inconsistent with the detectNotes path.
const float level = impl->snapActivity[(size_t) p];
const double ot = impl->snapOnsetTimeMs[(size_t) p];
const float ageMs = (ot > 0.0) ? (float) (now - ot) : 1.0e9f;
const bool recentOnset = ageMs >= 0.0f && ageMs <= kRecentOnsetMs;
if (confidenceOut != nullptr)
*confidenceOut = juce::jmax(level,
recentOnset ? impl->snapOnsetConf[(size_t) p] : 0.0f);
return level >= kActivityThreshold || recentOnset;
}
#else // !SLOPSMITH_ONNX_SUPPORT — inert stub, YIN fallback covers detection.
struct MlNoteDetector::Impl {};
MlNoteDetector::MlNoteDetector() = default;
MlNoteDetector::~MlNoteDetector() = default;
bool MlNoteDetector::isAvailable() const { return false; }
bool MlNoteDetector::isReady() const { return false; }
bool MlNoteDetector::loadModel(const juce::File&) { return false; }
void MlNoteDetector::prepare(double, int) {}
void MlNoteDetector::stop() {}
void MlNoteDetector::pushSamples(const float*, int) {}
std::vector<MlNoteDetector::ActiveNote> MlNoteDetector::getActiveNotes() const { return {}; }
MlNoteDetector::ActiveNote MlNoteDetector::getDominantNote() const { return {}; }
bool MlNoteDetector::isPitchActive(int, float*) const { return false; }
#endif
+88
View File
@@ -0,0 +1,88 @@
#pragma once
#include <juce_core/juce_core.h>
#include <atomic>
#include <memory>
#include <vector>
// Polyphonic ML note detector — Spotify Basic Pitch run via ONNX Runtime.
//
// Mirrors PitchDetector's threading contract: the audio thread pushes samples
// through a lock-free FIFO; a background thread resamples to 22050 Hz, runs
// inference on a rolling ~2 s window, and publishes an active-pitch snapshot
// that any non-audio thread can poll.
//
// When ONNX Runtime is not available at build time (SLOPSMITH_ONNX_SUPPORT=0)
// every method is an inert no-op and isAvailable() returns false — callers
// fall back to the YIN PitchDetector / ChordScorer (Constitution VII).
//
// The ONNX Runtime headers are confined to MlNoteDetector.cpp via a PIMPL, so
// including this header does not pull <onnxruntime_cxx_api.h> into the engine.
class MlNoteDetector
{
public:
MlNoteDetector();
~MlNoteDetector();
static constexpr int kNumPitches = 88; // Basic Pitch: MIDI 21..108
static constexpr int kLowestMidi = 21; // A0
// Load the Basic Pitch ONNX model. Thread-safe; the live model pointer is
// swapped under a lock once the new session is built (NAMProcessor pattern).
// Returns "is the ML detector available after this call" — NOT whether
// this particular load succeeded. A missing/invalid file never tears down
// an already-loaded model, so it can still return true; returns false when
// no model is loaded or ONNX support is compiled out.
bool loadModel(const juce::File& modelFile);
bool hasModel() const { return modelLoaded.load(std::memory_order_relaxed); }
// True only when ONNX support is compiled in AND a model is loaded
// (and its output contract has not been demoted).
bool isAvailable() const;
// isAvailable() AND the detector has published at least one inference
// snapshot — gate engine-side ML routing on this so the cold-start
// window after an audio start/restart uses the YIN fallback.
bool isReady() const;
void prepare(double sampleRate, int blockSize);
void stop();
// Audio thread — lock-free, no allocation.
void pushSamples(const float* data, int numSamples);
// A currently-sounding pitch from the latest inference.
struct ActiveNote
{
int midi = -1;
float confidence = 0.0f; // note posteriorgram, 0..1
// Milliseconds since this pitch's most recent detected onset, measured
// when getActiveNotes()/getDominantNote() is called. Lets the caller
// back-date a detection to the true onset rather than poll time.
// >= 1e6 means no recent onset (sustained / decaying tail).
float onsetAgeMs = 1.0e9f;
// Monotonic per-pitch onset counter — increments once per distinct
// detected onset. A change since the last poll means a NEW note was
// struck on this pitch, letting the caller treat onsets as discrete
// events instead of polling "is this pitch active".
int onsetSeq = 0;
};
// Pitches that are sounding now — either sustained above the activity
// threshold or freshly onset within the last ~200 ms. Polled from the
// main (N-API) thread.
std::vector<ActiveNote> getActiveNotes() const;
// Highest-confidence active pitch; midi < 0 when nothing is sounding.
ActiveNote getDominantNote() const;
// Whether a specific MIDI pitch is currently active. Optionally returns the
// pitch's confidence. Used by the ML-backed scoreChord path.
bool isPitchActive(int midi, float* confidenceOut = nullptr) const;
private:
struct Impl;
std::unique_ptr<Impl> impl;
std::atomic<bool> modelLoaded{false};
JUCE_DECLARE_NON_COPYABLE_WITH_LEAK_DETECTOR(MlNoteDetector)
};
+142
View File
@@ -0,0 +1,142 @@
#include "NAMProcessor.h"
NAMProcessor::NAMProcessor()
: AudioProcessor(BusesProperties()
.withInput("Input", juce::AudioChannelSet::stereo(), true)
.withOutput("Output", juce::AudioChannelSet::stereo(), true))
{
}
NAMProcessor::~NAMProcessor()
{
const juce::ScopedLock sl(modelLock);
#if SLOPSMITH_NAM_SUPPORT
model.reset();
pendingModel.reset();
#endif
}
bool NAMProcessor::loadModel(const juce::File& namFile)
{
#if SLOPSMITH_NAM_SUPPORT
if (!namFile.existsAsFile()) return false;
try
{
std::filesystem::path namPath(namFile.getFullPathName().toStdString());
auto newModel = nam::get_dsp(namPath);
if (!newModel) return false;
// Prepare the new model at current sample rate
newModel->Reset(currentSampleRate, currentBlockSize);
// Swap atomically
{
const juce::ScopedLock sl(modelLock);
model = std::move(newModel);
}
currentModelName = namFile.getFileNameWithoutExtension();
currentModelPath = namFile.getFullPathName();
modelLoaded.store(true);
return true;
}
catch (const std::exception& e)
{
DBG("NAM load error: " + juce::String(e.what()));
return false;
}
#else
juce::ignoreUnused(namFile);
return false;
#endif
}
void NAMProcessor::prepareToPlay(double sampleRate, int samplesPerBlock)
{
currentSampleRate = sampleRate;
currentBlockSize = samplesPerBlock;
monoBuffer.resize((size_t)samplesPerBlock);
#if SLOPSMITH_NAM_SUPPORT
const juce::ScopedLock sl(modelLock);
if (model)
model->Reset(sampleRate, samplesPerBlock);
#endif
}
void NAMProcessor::releaseResources()
{
monoBuffer.clear();
}
void NAMProcessor::processBlock(juce::AudioBuffer<float>& buffer, juce::MidiBuffer&)
{
#if SLOPSMITH_NAM_SUPPORT
const juce::ScopedLock sl(modelLock);
if (!model)
return;
int numSamples = buffer.getNumSamples();
int numChannels = buffer.getNumChannels();
// Pre-allocated buffers (avoid heap allocation in audio callback)
thread_local std::vector<double> inputBuf;
thread_local std::vector<double> outputBuf;
inputBuf.resize((size_t)numSamples);
outputBuf.resize((size_t)numSamples);
// Mix input to mono with input level
float inLevel = inputLevel.load();
for (int i = 0; i < numSamples; ++i)
{
float sum = 0.0f;
for (int ch = 0; ch < numChannels; ++ch)
sum += buffer.getSample(ch, i);
inputBuf[(size_t)i] = (double)((sum / (float)numChannels) * inLevel);
}
// Process through NAM model (double** in, double** out)
double* inPtr = inputBuf.data();
double* outPtr = outputBuf.data();
double** inPtrs = &inPtr;
double** outPtrs = &outPtr;
model->process(inPtrs, outPtrs, numSamples);
// Copy mono result to all output channels with output level
float outLevel = outputLevel.load();
for (int ch = 0; ch < numChannels; ++ch)
for (int i = 0; i < numSamples; ++i)
buffer.setSample(ch, i, (float)outputBuf[(size_t)i] * outLevel);
#else
juce::ignoreUnused(buffer);
#endif
}
void NAMProcessor::getStateInformation(juce::MemoryBlock& destData)
{
auto state = new juce::DynamicObject();
state->setProperty("modelPath", currentModelPath);
state->setProperty("inputLevel", (double)inputLevel.load());
state->setProperty("outputLevel", (double)outputLevel.load());
auto json = juce::JSON::toString(juce::var(state));
destData.append(json.toRawUTF8(), json.getNumBytesAsUTF8());
}
void NAMProcessor::setStateInformation(const void* data, int sizeInBytes)
{
auto json = juce::String::fromUTF8((const char*)data, sizeInBytes);
auto parsed = juce::JSON::parse(json);
if (auto* obj = parsed.getDynamicObject())
{
auto path = obj->getProperty("modelPath").toString();
if (path.isNotEmpty())
loadModel(juce::File(path));
if (obj->hasProperty("inputLevel"))
inputLevel.store((float)(double)obj->getProperty("inputLevel"));
if (obj->hasProperty("outputLevel"))
outputLevel.store((float)(double)obj->getProperty("outputLevel"));
}
}
+71
View File
@@ -0,0 +1,71 @@
#pragma once
#include <juce_audio_processors/juce_audio_processors.h>
#if SLOPSMITH_NAM_SUPPORT
#include "NAM/get_dsp.h"
#endif
// Neural Amp Modeler processor — wraps a .nam model file
// for real-time guitar amp simulation.
class NAMProcessor : public juce::AudioProcessor
{
public:
NAMProcessor();
~NAMProcessor() override;
// Load a .nam model file (async-safe: prepares new model, then swaps atomically)
bool loadModel(const juce::File& namFile);
bool hasModel() const { return modelLoaded.load(); }
juce::String getModelName() const { return currentModelName; }
juce::String getModelPath() const { return currentModelPath; }
// AudioProcessor interface
const juce::String getName() const override { return "NAM"; }
void prepareToPlay(double sampleRate, int samplesPerBlock) override;
void releaseResources() override;
void processBlock(juce::AudioBuffer<float>& buffer, juce::MidiBuffer& midi) override;
double getTailLengthSeconds() const override { return 0.0; }
bool acceptsMidi() const override { return false; }
bool producesMidi() const override { return false; }
juce::AudioProcessorEditor* createEditor() override { return nullptr; }
bool hasEditor() const override { return false; }
int getNumPrograms() override { return 1; }
int getCurrentProgram() override { return 0; }
void setCurrentProgram(int) override {}
const juce::String getProgramName(int) override { return {}; }
void changeProgramName(int, const juce::String&) override {}
void getStateInformation(juce::MemoryBlock& destData) override;
void setStateInformation(const void* data, int sizeInBytes) override;
// Parameters
float getInputLevel() const { return inputLevel.load(); }
void setInputLevel(float v) { inputLevel.store(v); }
float getOutputLevel() const { return outputLevel.load(); }
void setOutputLevel(float v) { outputLevel.store(v); }
private:
#if SLOPSMITH_NAM_SUPPORT
std::unique_ptr<nam::DSP> model;
std::unique_ptr<nam::DSP> pendingModel; // staged for swap
#endif
std::atomic<bool> modelLoaded{false};
std::atomic<float> inputLevel{1.0f};
std::atomic<float> outputLevel{1.0f};
juce::String currentModelName;
juce::String currentModelPath;
double currentSampleRate = 48000.0;
int currentBlockSize = 256;
// Mono processing buffer (NAM is mono in/mono out)
std::vector<float> monoBuffer;
juce::CriticalSection modelLock;
JUCE_DECLARE_NON_COPYABLE_WITH_LEAK_DETECTOR(NAMProcessor)
};
File diff suppressed because it is too large Load Diff
+97
View File
@@ -0,0 +1,97 @@
#include "NoiseGate.h"
#include <cmath>
float NoiseGate::timeMsToAlpha(double sr, float timeMs)
{
using namespace NoiseGateSpecs;
if (sr <= 0.0 || timeMs <= 0.0f)
return 1.0f;
const float tauSec = timeMs * 0.001f;
return 1.0f - std::exp(-1.0f / (tauSec * (float)sr));
}
void NoiseGate::prepare(double sr, int /*samplesPerBlock*/)
{
sampleRate = sr > 0.0 ? sr : 48000.0;
coeffAttack = timeMsToAlpha(sampleRate, NoiseGateSpecs::kAttackMs);
reset();
}
void NoiseGate::reset()
{
currentGain = 1.0f;
}
void NoiseGate::setParameters(bool enabled, float thresholdDb, float releaseMs, float depthDb)
{
using namespace NoiseGateSpecs;
// Publish derived params first with relaxed ordering, then release-store
// paramEnabled. The audio thread acquire-loads paramEnabled in processBlock,
// so when it sees enabled=true the threshold/release/depth values stored
// here are guaranteed visible. (Updates while already enabled can still
// briefly mix old/new params across two fields — acceptable for slow-moving
// UI changes; a SeqLock or versioned struct would be needed to fully avoid.)
const float tDb = juce::jlimit(kThresholdDbMin, kThresholdDbMax, thresholdDb);
paramThresholdDb.store(tDb, std::memory_order_relaxed);
const float tLin = std::pow(10.0f, tDb / 20.0f);
paramThresholdLinear.store(juce::jmax(kAmpFloor, tLin), std::memory_order_relaxed);
const float rMs = juce::jlimit(kReleaseMsMin, kReleaseMsMax, releaseMs);
paramReleaseMs.store(rMs, std::memory_order_relaxed);
const float dDb = juce::jlimit(kDepthDbMin, kDepthDbMax, depthDb);
paramDepthDb.store(dDb, std::memory_order_relaxed);
const float dLin = std::pow(10.0f, dDb / 20.0f);
paramDepthLinear.store(juce::jlimit(kAmpFloor, 1.0f, dLin), std::memory_order_relaxed);
paramEnabled.store(enabled, std::memory_order_release);
}
void NoiseGate::processBlock(juce::AudioBuffer<float>& buffer)
{
// Acquire-load pairs with the release-store in setParameters so that when
// we see enabled=true the threshold/release/depth values are fully visible.
if (!paramEnabled.load(std::memory_order_acquire))
{
// Park currentGain at unity while disabled so re-enabling the gate
// doesn't inherit a previously-closed envelope (which would otherwise
// produce an unintended fade-in until the detector opens). Audio-thread
// only writes this; UI thread never touches it.
currentGain = 1.0f;
return;
}
const int numChannels = buffer.getNumChannels();
const int numSamples = buffer.getNumSamples();
if (numChannels <= 0 || numSamples <= 0)
return;
const float thresh = paramThresholdLinear.load(std::memory_order_relaxed);
const float depthLin = paramDepthLinear.load(std::memory_order_relaxed);
const float releaseMs = paramReleaseMs.load(std::memory_order_relaxed);
const float coeffRelease = timeMsToAlpha(sampleRate, releaseMs);
// juce::AudioBuffer maintains its own per-channel pointer table internally;
// reuse it (no allocation, no arbitrary cap) instead of building our own.
float* const* channelData = buffer.getArrayOfWritePointers();
for (int i = 0; i < numSamples; ++i)
{
float det = 0.0f;
for (int ch = 0; ch < numChannels; ++ch)
det = juce::jmax(det, std::abs(channelData[ch][i]));
if (det > thresh)
currentGain += coeffAttack * (1.0f - currentGain);
else
currentGain += coeffRelease * (depthLin - currentGain);
const float g = currentGain;
for (int ch = 0; ch < numChannels; ++ch)
channelData[ch][i] *= g;
}
}
+59
View File
@@ -0,0 +1,59 @@
#pragma once
#include <atomic>
#include <juce_audio_basics/juce_audio_basics.h>
// AmpliTube-style noise gate: threshold (dBFS), user release time, depth floor (dB attenuation
// when closed). Fixed ~1 ms attack. Sample-accurate envelope; stereo-linked detector.
namespace NoiseGateSpecs
{
/** Fast attack kept internal so picking transients pass (typical commercial gate UX). */
inline constexpr float kAttackMs = 1.0f;
inline constexpr float kReleaseMsMin = 5.0f;
inline constexpr float kReleaseMsMax = 2000.0f;
inline constexpr float kThresholdDbMin = -96.0f;
inline constexpr float kThresholdDbMax = 0.0f;
inline constexpr float kDepthDbMin = -100.0f;
inline constexpr float kDepthDbMax = 0.0f;
inline constexpr float kAmpFloor = 1.0e-9f;
}
class NoiseGate
{
public:
NoiseGate() = default;
void prepare(double sampleRate, int samplesPerBlock);
void reset();
// UI / IPC thread — updates atomics only (audio thread reads with relaxed ordering).
void setParameters(bool enabled, float thresholdDb, float releaseMs, float depthDb);
void processBlock(juce::AudioBuffer<float>& buffer);
private:
std::atomic<bool> paramEnabled{false};
std::atomic<float> paramThresholdDb{-60.0f};
std::atomic<float> paramReleaseMs{100.0f};
std::atomic<float> paramDepthDb{-60.0f};
/** Derived from thresholdDb each setParameters() — amp comparison without log10 per sample. */
std::atomic<float> paramThresholdLinear{1.0e-3f};
/** Derived from depthDb — minimum multiplier when gate fully closed (not necessarily silence). */
std::atomic<float> paramDepthLinear{1.0e-3f};
float currentGain = 1.0f;
float coeffAttack = 1.0f;
double sampleRate = 48000.0;
static float timeMsToAlpha(double sr, float timeMs);
JUCE_DECLARE_NON_COPYABLE_WITH_LEAK_DETECTOR(NoiseGate)
};
+416
View File
@@ -0,0 +1,416 @@
#include "NoteVerifier.h"
#include "InputRingReader.h"
#include <algorithm>
#include <cmath>
namespace
{
// The plugin's MAX_SUS_LATE_GRACE — a sustained note's scoring window may run
// past its onset by up to its sustain length, capped at 1 s, so a held note is
// still verified while it rings.
double susGraceFor(double sus)
{
return std::min(std::max(sus, 0.0), 1.0);
}
// A push older than this means the renderer's detect tick has stopped (plugin
// disabled, song unloaded, renderer wedged) — freeze the playhead rather than
// let interpolation run a clock away from reality. ~20 missed 50 ms ticks.
constexpr double kPlayheadStaleMs = 1000.0;
// A playhead that drops by more than this is a drill A-B loop wrap or a manual
// seek-back, not push-to-push interpolation jitter (bounded by the ~50 ms
// tick). Notes at/after the new position are re-opened for re-scoring.
constexpr double kBackwardJumpSeconds = 0.25;
} // namespace
// ── Background scoring thread ────────────────────────────────────────────────
struct NoteVerifier::Worker : public juce::Thread
{
explicit Worker(NoteVerifier& ownerVerifier)
: Thread("NoteVerifier"), owner(ownerVerifier) {}
void run() override
{
while (! threadShouldExit())
{
owner.run();
sleep(10);
}
}
NoteVerifier& owner;
};
// ── NoteVerifier ─────────────────────────────────────────────────────────────
NoteVerifier::NoteVerifier(InputRingReader& ringReader)
: engine(ringReader) {}
NoteVerifier::~NoteVerifier() { stop(); }
void NoteVerifier::setChart(const ChartUpdate& update)
{
const juce::ScopedLock sl(lock);
++chartEpoch;
chart = update;
// Fresh per-note state — nothing is finalized yet for the new chart.
state.assign(chart.notes.size(), NoteState{});
pending.clear();
// A new chart means a new song/arrangement: drop the old playhead so the
// worker waits for the renderer's first push rather than scoring against a
// stale position. lastPlayhead is worker-only but guarded by `lock` here.
havePushedPlayhead.store(false);
pushedPlaying = false; // plain bool, guarded by `lock` (held here)
lastPlayhead = 0.0;
// Onset profile follows the arrangement; publish it BEFORE the reset flag
// so the worker tick that observes onsetResetPending (and resets the
// detector) is guaranteed to also see the new desired profile for that same
// tick, rather than re-applying the previous arrangement's profile.
wantBassProfile.store(update.arrangement == "bass");
// The song-time-tagged onset log is stale for the new chart; run() drops
// it on its next tick (the log itself is worker-thread only).
onsetResetPending.store(true);
}
void NoteVerifier::clearChart()
{
const juce::ScopedLock sl(lock);
++chartEpoch;
chart.notes.clear();
state.clear();
pending.clear();
// Reset the playhead state too — same as setChart(). Otherwise a
// clearChart() between songs leaves a stale lastPlayhead/havePushedPlayhead
// for the worker to score the next setPlayhead() against.
havePushedPlayhead.store(false);
pushedPlaying = false;
lastPlayhead = 0.0;
onsetResetPending.store(true);
}
std::vector<NoteVerifier::Verdict> NoteVerifier::drainVerdicts()
{
const juce::ScopedLock sl(lock);
std::vector<Verdict> out;
out.swap(pending);
return out;
}
void NoteVerifier::setPlayhead(double songTime, bool playing)
{
{
// Publish the timing trio as one snapshot under `lock` so
// currentPlayhead() can never read a half-updated set.
const juce::ScopedLock sl(lock);
pushedReceiptMs = juce::Time::getMillisecondCounterHiRes();
// Shift by this source's capture-latency correction (0 on the primary).
pushedSongTime = songTime - playheadOffsetSec.load(std::memory_order_relaxed);
pushedPlaying = playing;
}
havePushedPlayhead.store(true);
}
double NoteVerifier::currentPlayhead() const
{
double base, receiptMs;
bool playing;
{
const juce::ScopedLock sl(lock);
base = pushedSongTime;
receiptMs = pushedReceiptMs;
playing = pushedPlaying;
}
const double ageMs = juce::Time::getMillisecondCounterHiRes() - receiptMs;
if (ageMs > kPlayheadStaleMs) return base; // tick stopped — freeze
if (! playing) return base; // paused — hold
return base + ageMs / 1000.0; // playing — interpolate forward
// Note: after a stale freeze the playhead jumps forward on the first
// fresh push; any note whose whole window was traversed during the freeze
// finalizes as a miss next tick — correct, detection genuinely was down.
}
void NoteVerifier::prepare(double sampleRate, int /*blockSize*/)
{
// Match MlNoteDetector: a stop→start cycle tears the thread down before
// restarting so a device restart can't race the worker against state.
stop();
// The worker is not running here — configure the onset state directly.
onsetDetector.prepare(sampleRate);
readCursor = 0;
onsetLog.clear();
worker = std::make_unique<Worker>(*this);
worker->startThread(juce::Thread::Priority::normal);
}
void NoteVerifier::stop()
{
if (worker)
{
// stopThread() signals, waits, and only force-kills on timeout — after
// it returns the thread is definitively not running, so resetting the
// unique_ptr can't destroy a live juce::Thread.
worker->stopThread(2000);
worker.reset();
}
}
void NoteVerifier::run()
{
// Score nothing until the renderer has pushed a playhead at least once —
// otherwise the loop below would finalize the whole chart against
// playhead 0 the instant a chart is set.
if (! havePushedPlayhead.load()) return;
// A fresh chart (new song/arrangement) invalidates the song-time-tagged
// onset log. The flux detector itself keeps running — the input stream is
// continuous regardless of song position.
if (onsetResetPending.exchange(false))
onsetLog.clear();
// Apply the arrangement's onset profile before this tick's onset pass.
// setProfile() is a no-op when unchanged; on a real switch it reconfigures
// the flux band/threshold and resets the detector's history. Both this and
// the onsetDetector are worker-thread-only, so the call is race-free.
const bool wantBass = wantBassProfile.load();
if (wantBass != appliedBassProfile)
{
onsetDetector.setProfile(wantBass);
appliedBassProfile = wantBass;
}
// The playhead for this whole pass — interpolated from the last push so
// every note here is judged against the same chart position.
const double playhead = currentPlayhead();
double sr = engine.getCurrentSampleRate();
if (! std::isfinite(sr) || sr <= 0.0) sr = 48000.0;
// ── Onset detection ─────────────────────────────────────────────────────
// Feed every input sample captured since the last tick into the flux
// detector, and log each detected pick attack in song time. Input-ring
// index `w` ("now") corresponds to `playhead`; an earlier sample is
// proportionally earlier.
{
std::vector<float> fresh;
const uint64_t w = engine.getInputSince(readCursor, fresh);
readCursor = w;
if (! fresh.empty())
{
const uint64_t firstIdx = w - (uint64_t) fresh.size();
std::vector<OnsetDetector::Onset> onsets;
onsetDetector.process(fresh.data(), fresh.size(), firstIdx, onsets);
for (const auto& o : onsets)
{
const double songT = playhead - (double) (w - o.sampleIndex) / sr;
onsetLog.push_back({ songT, false });
}
}
}
// Drop onsets far behind the playhead — no note window still reaches them.
while (! onsetLog.empty() && onsetLog.front().songTime < playhead - 4.0)
onsetLog.pop_front();
// ── Snapshot the chart + collect this tick's open / passed notes ────────
struct Candidate { size_t index; ChordScorer::Note note; };
std::vector<Candidate> batch; // notes whose window is open — score now
std::vector<size_t> passedIdx; // notes whose window has fully passed
bool backwardJump = false;
ChartUpdate ctx;
uint64_t snapshotEpoch = 0;
{
const juce::ScopedLock sl(lock);
snapshotEpoch = chartEpoch;
// Copy only the scalar scoring context — NOT chart.notes. The notes
// vector is large and is never read off-lock (the open-window batch is
// assembled under this lock below), so deep-copying it on every ~10 ms
// worker tick would be wasted allocation under a contended lock.
ctx.arrangement = chart.arrangement;
ctx.stringCount = chart.stringCount;
ctx.tuningOffsets = chart.tuningOffsets;
ctx.capo = chart.capo;
ctx.pitchCheckCents = chart.pitchCheckCents;
ctx.harmonicSnr = chart.harmonicSnr;
ctx.fundamentalRatio = chart.fundamentalRatio;
ctx.presenceRatio = chart.presenceRatio;
ctx.timingTolerance = chart.timingTolerance;
// A drill A-B loop wrap (or a manual seek-back) jumps the playhead
// backward. Re-open every note at/after the new position so the next
// pass re-scores it; notes before the loop point keep their verdict.
if (playhead < lastPlayhead - kBackwardJumpSeconds)
{
backwardJump = true;
for (size_t i = 0; i < chart.notes.size() && i < state.size(); ++i)
if (chart.notes[i].t >= playhead)
state[i] = NoteState{};
pending.erase(std::remove_if(pending.begin(), pending.end(),
[&](const Verdict& v)
{
for (size_t i = 0; i < chart.notes.size() && i < state.size(); ++i)
if (chart.notes[i].id == v.id) return ! state[i].finalized;
return false;
}), pending.end());
}
lastPlayhead = playhead;
const double tol = chart.timingTolerance;
for (size_t i = 0; i < chart.notes.size(); ++i)
{
if (i >= state.size() || state[i].finalized) continue;
const auto& cn = chart.notes[i];
const double grace = susGraceFor(cn.sus);
if (playhead > cn.t + tol + grace)
passedIdx.push_back(i);
// Sustain grace extends only the LATE edge — a note rings *after*
// its onset, never before. The early edge is the plain timing
// tolerance; widening it by `grace` would open a long sustain up
// to a second early and let a same-pitch note still ringing from
// an earlier strike set everPresent → a false hit.
else if (playhead >= cn.t - tol)
{
ChordScorer::Note n{};
n.string = cn.string;
n.fret = cn.fret;
n.hammerOn = cn.ho;
n.pullOff = cn.po;
n.bend = cn.b;
n.slide = cn.sl;
n.harmonic = cn.hm;
batch.push_back({ i, n });
}
}
}
// A backward jump stranded the logged onsets in the old song-time range.
if (backwardJump)
onsetLog.clear();
// ── Harmonic-comb presence pass ─────────────────────────────────────────
// The comb only answers "is this note's pitch present?" — timing comes
// from the onset log. Score every open-window note against the latest
// input frame.
struct ScoredNote { size_t index; bool present; float centsError; float snr; };
std::vector<ScoredNote> scored;
if (! batch.empty())
{
ChordScorer::Request req;
req.numSamples = 4096;
req.arrangement = ctx.arrangement;
req.stringCount = ctx.stringCount;
req.tuningOffsets = ctx.tuningOffsets;
req.capo = ctx.capo;
req.pitchCheckCents = ctx.pitchCheckCents;
req.harmonicVerify = true; // harmonic-comb targeted check
req.harmonicSnr = ctx.harmonicSnr;
req.fundamentalRatio = ctx.fundamentalRatio;
req.notes.reserve(batch.size());
for (const auto& c : batch)
req.notes.push_back(c.note);
const auto frame = engine.getInputFrame(4096);
const auto result = chordScorer.scoreChord(frame.data(), (int) frame.size(),
sr, req);
const size_t n = std::min(result.results.size(), batch.size());
for (size_t i = 0; i < n; ++i)
{
ScoredNote s;
s.index = batch[i].index;
s.present = result.results[i].hit;
s.centsError = result.results[i].centsError;
s.snr = result.results[i].bandEnergy; // harmonicVerify puts SNR here
scored.push_back(s);
}
}
// ── Update presence, then finalize passed notes ─────────────────────────
{
const juce::ScopedLock sl(lock);
// A setChart()/clearChart() since the snapshot — even one that kept the
// same note count — invalidates `state`/`passedIdx`; drop this pass.
if (chartEpoch != snapshotEpoch) return;
for (const auto& s : scored)
{
if (s.index >= state.size()) continue;
NoteState& st = state[s.index];
if (st.finalized) continue;
++st.scoredFrames; // count every frame the note was scored against
if (! s.present) continue;
++st.presentFrames;
st.everPresent = true;
if (s.snr > st.bestSnr) { st.bestSnr = s.snr; st.bestCents = s.centsError; }
}
const double tol = ctx.timingTolerance;
for (size_t idx : passedIdx)
{
if (idx >= state.size() || state[idx].finalized) continue;
NoteState& st = state[idx];
const auto& cn = chart.notes[idx];
st.finalized = true;
// Persistence decision. With presenceRatio <= 0 this is the legacy
// ever-present rule (guitar: byte-identical). With a floor set (bass),
// require the comb to have confirmed the pitch in at least that
// fraction of the note's scored frames — rejecting wrong-position
// notes that only flicker present on a few stray frames while keeping
// correctly-fretted notes, which ring through most of their window.
const bool hit = (ctx.presenceRatio <= 0.0f)
? st.everPresent
: (st.presentFrames > 0
&& (double) st.presentFrames
>= std::ceil((double) ctx.presenceRatio * (double) st.scoredFrames));
if (hit)
{
// The comb confirmed this note's pitch in its window — a hit.
// Timing: a picked note claims the nearest unclaimed pick
// attack from the onset log; legato (hammer-on / pull-off) has
// no attack, so it is reported on-time.
st.detected = true;
st.centsError = st.bestCents;
st.snr = st.bestSnr;
double when = cn.t;
if (! cn.ho && ! cn.po)
{
int best = -1;
double bestDist = tol;
for (size_t k = 0; k < onsetLog.size(); ++k)
{
if (onsetLog[k].claimed) continue;
const double d = std::abs(onsetLog[k].songTime - cn.t);
if (d <= bestDist) { bestDist = d; best = (int) k; }
}
if (best >= 0)
{
onsetLog[(size_t) best].claimed = true;
when = onsetLog[(size_t) best].songTime;
}
}
st.detectedSongTime = when;
Verdict v;
v.id = cn.id;
v.detected = true;
v.detectedSongTime = when;
v.centsError = st.bestCents;
v.snr = st.bestSnr;
pending.push_back(v);
}
else
{
st.detected = false;
Verdict v;
v.id = cn.id;
v.detected = false;
pending.push_back(v);
}
}
}
}
+205
View File
@@ -0,0 +1,205 @@
#pragma once
// NoteVerifier — continuous, engine-side scoring of a chart's guitar notes.
//
// The notedetect plugin used to run a renderer `setInterval` loop that called
// `audio.scoreChord` over IPC on every tick. During dense passages the renderer
// event loop is starved and the loop blacks out for 1-3 s — whole runs of notes
// are never scored. NoteVerifier moves that work into the engine: a background
// `juce::Thread` walks the pushed chart against the live playhead and scores
// each note's timing window exactly once, publishing a verdict the renderer
// just drains.
//
// Threading contract mirrors MlNoteDetector: a background thread does the work
// and publishes a snapshot guarded by a juce::CriticalSection; any non-audio
// thread (the N-API thread) polls it via drainVerdicts(). The thread reads the
// engine's input ring through AudioEngine::getInputFrame() (already designed
// for off-audio-thread reads). It never touches the audio thread directly.
//
// Playhead: the worker does NOT use AudioEngine::getBackingPosition() — that
// only advances for JUCE-routed songs, and stem-based (sloppak) songs always
// plays on the renderer's HTML5 <audio>, leaving the backing transport frozen.
// Instead the renderer pushes its own unified, already-corrected playhead via
// setPlayhead() each detect tick; the worker interpolates between pushes.
//
// Scoring: a note is a HIT when the harmonic-comb confirms its pitch present
// anywhere in its timing window (presence). Timing is taken from a separate
// spectral-flux OnsetDetector — for a picked note the nearest detected pick
// attack gives a precise strike time; legato (hammer-on / pull-off) notes have
// no attack, so they are reported on-time. The comb's 85 ms window is far too
// coarse to time an onset, hence the dedicated detector.
#include <juce_core/juce_core.h>
#include "ChordScorer.h"
#include "OnsetDetector.h"
#include <atomic>
#include <cstdint>
#include <deque>
#include <memory>
#include <string>
#include <vector>
class InputRingReader; // resolved in NoteVerifier.cpp — avoids a circular include
class NoteVerifier
{
public:
explicit NoteVerifier(InputRingReader& ringReader);
~NoteVerifier();
// One chart note plus the technique flags ChordScorer consumes.
struct ChartNote
{
std::string id;
double t = 0.0; // chart time (seconds) of the note onset
int string = 0;
int fret = 0;
double sus = 0.0; // sustain length (seconds)
bool ho = false, po = false, b = false, sl = false, hm = false;
};
// Chart context — the per-song scoring parameters. Mirrors the fields the
// plugin previously passed into `audio.scoreChord` per tick.
struct ChartUpdate
{
std::string arrangement = "guitar";
int stringCount = 6;
std::vector<int> tuningOffsets;
int capo = 0;
float pitchCheckCents = 0.0f;
float harmonicSnr = 3.0f;
float fundamentalRatio = 0.20f; // ChordScorer fundamental-presence gate
// Temporal-persistence floor: a note counts as hit only when the comb
// confirms its pitch in at least this FRACTION of the frames scored in
// its window — not just one ("ever present"). A correctly-fretted note
// rings through ~70-100% of its frames; a wrong-position note only trips
// the comb on a handful of stray frames, so a persistence floor rejects
// it. 0 keeps the legacy ever-present rule (guitar default, byte-
// identical); bass passes ~0.3. Tunable over IPC like harmonicSnr.
float presenceRatio = 0.0f;
double timingTolerance = 0.1; // seconds — half-width of the scoring window
std::vector<ChartNote> notes;
};
// A finalized per-note verdict, drained by the renderer.
struct Verdict
{
std::string id;
bool detected = false;
double detectedSongTime = 0.0; // playhead at which the note was scored
float centsError = 0.0f;
float snr = 0.0f;
};
// Replace the chart + context, resetting all finalized state. Thread-safe.
void setChart(const ChartUpdate& update);
// Empty the chart — no notes are scored until the next setChart().
void clearChart();
// Verdicts finalized since the last drain; clears the pending buffer.
std::vector<Verdict> drainVerdicts();
// Receive the renderer's unified playhead. `songTime` is already
// avOffset/latency-corrected — the same clock the plugin correlates chart
// note times against. Safe from the N-API thread; the timing trio is
// published under `lock` so the worker reads a coherent snapshot.
void setPlayhead(double songTime, bool playing);
// Extra per-source playhead correction (seconds), subtracted from every pushed
// playhead. The renderer's avOffset correction aligns the PRIMARY device; a
// source on an ADDITIONAL input device has a different capture latency, so its
// audio sits at a different song-time than the unified playhead assumes. The
// engine sets this to (extraInputLatency - primaryInputLatency) so the worker
// matches that device's just-captured audio against the right chart notes. 0
// for the primary device (the single-device path is unchanged).
void setPlayheadOffset(double seconds) { playheadOffsetSec.store(seconds, std::memory_order_relaxed); }
// Start / stop the background thread — matches MlNoteDetector's lifecycle.
void prepare(double sampleRate, int blockSize);
void stop();
private:
void run();
// The playhead the worker should score against right now: the last pushed
// song time, advanced by wall-clock elapsed since the push while playing.
// Freezes on a stale push (renderer tick stopped) or when paused.
double currentPlayhead() const;
// Per-note finalized state, parallel to `chart`. The harmonic-comb scores
// every open-window note each tick; a note that is ever confirmed present
// is a hit (timing then comes from the OnsetDetector, or the chart time
// for legato). A note never present is a miss.
struct NoteState
{
bool finalized = false;
bool detected = false;
double detectedSongTime = 0.0;
float centsError = 0.0f;
float snr = 0.0f;
bool everPresent = false; // comb confirmed pitch present sometime in-window
int presentFrames = 0; // frames in-window where the comb confirmed the pitch
int scoredFrames = 0; // frames in-window the note was scored against
float bestSnr = 0.0f; // strongest SNR among present ticks
float bestCents = 0.0f; // cents error at the strongest present tick
};
// The capture chain whose input ring this verifier scores against (the owning
// SourceChain). Bound at construction; reads getInputFrame/getInputSince/
// getCurrentSampleRate only — see InputRingReader.
InputRingReader& engine;
ChordScorer chordScorer;
// Background worker. Defined in the .cpp so this header stays free of the
// juce::Thread subclass.
struct Worker;
std::unique_ptr<Worker> worker;
// Spectral-flux onset detection — worker-thread only. `readCursor` is the
// monotonic input-ring index consumed so far; `onsetLog` holds recent pick
// attacks tagged in song time, claimed one-per-note at finalization.
OnsetDetector onsetDetector;
uint64_t readCursor = 0;
struct LoggedOnset { double songTime = 0.0; bool claimed = false; };
std::deque<LoggedOnset> onsetLog;
// setChart() (N-API thread) sets this; run() clears the stale onset log on
// the next tick. The log itself stays worker-thread only.
std::atomic<bool> onsetResetPending { false };
// Desired onset profile, set from the chart arrangement by setChart()
// (N-API thread); run() applies it to the worker-owned onsetDetector and
// tracks what is currently applied in `appliedBassProfile` (worker-only).
std::atomic<bool> wantBassProfile { false };
bool appliedBassProfile = false;
// Chart + context + per-note state, all guarded by `lock`. The worker
// thread mutates `state` and `pending`; setChart()/clearChart()/drain()
// run on the N-API thread. Neither side is the audio thread.
juce::CriticalSection lock;
ChartUpdate chart;
// Bumped on every setChart()/clearChart(). run() snapshots it with the
// chart and re-checks it before applying verdicts — a note-count match is
// not enough, a same-size chart swap must still invalidate the snapshot.
uint64_t chartEpoch = 0;
std::vector<NoteState> state;
std::vector<Verdict> pending; // verdicts finalized since the last drain
// Renderer-pushed playhead. The timing trio is written by setPlayhead()
// (N-API thread) and read by currentPlayhead() (worker thread) — both
// under `lock`, as one coherent snapshot, so a fresh songTime can never
// be paired with a stale receiptMs/playing from an earlier push.
// Per-source capture-latency correction subtracted from each pushed playhead
// (0 on the primary device). See setPlayheadOffset.
std::atomic<double> playheadOffsetSec { 0.0 };
double pushedSongTime = 0.0;
double pushedReceiptMs = 0.0; // getMillisecondCounterHiRes() at push
bool pushedPlaying = false;
// One-way latch (false→true on first push, reset by setChart). Read
// lock-free at the top of run(); a slightly stale read is harmless.
std::atomic<bool> havePushedPlayhead { false };
// Worker-thread only — last playhead seen, for backward-jump detection.
double lastPlayhead = 0.0;
JUCE_DECLARE_NON_COPYABLE_WITH_LEAK_DETECTOR(NoteVerifier)
};
+226
View File
@@ -0,0 +1,226 @@
#include "OnsetDetector.h"
#include <algorithm>
#include <cmath>
namespace
{
// Absolute floor so a near-silent passage (tiny mean) can't let noise through.
constexpr float kThresholdFloor = 1.0e-3f;
// Trailing flux frames feeding the adaptive threshold (~170 ms at 256-hop).
constexpr int kFluxHistFrames = 32;
// ── Guitar profile (default) ────────────────────────────────────────────────
// Adaptive-threshold multiplier: a flux frame must exceed the trailing mean by
// this factor to count as an onset.
constexpr float kGuitarThresholdK = 1.8f;
// Minimum gap between onsets — a guitarist cannot pick faster than this.
constexpr double kGuitarDebounceSeconds = 0.040;
// Net onset-timing calibration: the reported attack sits this far before the
// flux-peak frame's end. Absorbs the flux-peak-vs-attack lag (the transient
// takes time to fill the analysis window) plus residual chain latency. Tuned
// from the live timing-error median (~50 ms centres it). Stored in seconds and
// converted to samples per device rate so it holds at 44.1 kHz, 96 kHz, etc.
constexpr double kGuitarBackdateSeconds = 0.050;
// Flux band: 70 Hz (below the lowest guitar fundamental's skirt) to 3 kHz —
// picks carry strong broadband energy here; rumble/hiss outside only add noise.
constexpr double kGuitarBandLoHz = 70.0;
constexpr double kGuitarBandHiHz = 3000.0;
// ── Bass profile ──────────────────────────────────────────────────────────
// A bass pluck — especially fingerstyle — rises slower and softer than a guitar
// pick. Relax the threshold multiplier so a gentle attack still clears it, drop
// the band floor toward the bass attack energy, and cap the band lower (a bass
// attack click is concentrated well below 3 kHz; the high end is mostly other-
// instrument bleed that only adds flux noise). The longer backdate is a
// starting point — the slower rise puts the flux peak later relative to the
// true attack — and should be calibrated against the bass bench.
constexpr float kBassThresholdK = 1.4f;
constexpr double kBassDebounceSeconds = 0.050;
constexpr double kBassBackdateSeconds = 0.060;
constexpr double kBassBandLoHz = 40.0;
constexpr double kBassBandHiHz = 2000.0;
} // namespace
OnsetDetector::OnsetDetector() { prepare(48000.0); }
void OnsetDetector::prepare(double sr)
{
sampleRate = (sr > 0.0) ? sr : 48000.0;
fft = std::make_unique<juce::dsp::FFT>(kFftOrder);
fftIn.assign((size_t) kWindow, juce::dsp::Complex<float>{ 0.0f, 0.0f });
fftOut.assign((size_t) kWindow, juce::dsp::Complex<float>{ 0.0f, 0.0f });
mag.assign((size_t) (kWindow / 2 + 1), 0.0f);
prevMag.assign((size_t) (kWindow / 2 + 1), 0.0f);
hist.assign((size_t) kWindow, 0.0f);
hann.assign((size_t) kWindow, 0.0f);
for (int i = 0; i < kWindow; ++i)
hann[(size_t) i] = 0.5f * (1.0f - std::cos(
2.0 * juce::MathConstants<double>::pi * i / (kWindow - 1)));
// Load the active profile's tunables and derive the flux band + backdate
// for this sample rate. bassProfile is preserved across a device restart,
// so a re-prepare() keeps whatever profile setProfile() last selected.
applyProfile();
reset();
}
void OnsetDetector::applyProfile()
{
if (bassProfile)
{
thresholdK = kBassThresholdK;
debounceSeconds = kBassDebounceSeconds;
backdateSeconds = kBassBackdateSeconds;
bandLoHz = kBassBandLoHz;
bandHiHz = kBassBandHiHz;
}
else
{
thresholdK = kGuitarThresholdK;
debounceSeconds = kGuitarDebounceSeconds;
backdateSeconds = kGuitarBackdateSeconds;
bandLoHz = kGuitarBandLoHz;
bandHiHz = kGuitarBandHiHz;
}
// Spectral-flux band, derived for this device's sample rate.
const double binHz = sampleRate / kWindow;
loBin = std::max(1, (int) std::floor(bandLoHz / binHz));
hiBin = std::min(kWindow / 2, (int) std::ceil(bandHiHz / binHz));
// Onset backdate in samples, so the timing calibration is rate-independent.
onsetBackdateSamples = (int) std::lround(backdateSeconds * sampleRate);
}
void OnsetDetector::setProfile(bool bass)
{
if (bass == bassProfile) return;
bassProfile = bass;
applyProfile();
// The trailing flux history was gathered under the old band, so it is not
// comparable to frames measured under the new one — start clean.
reset();
}
void OnsetDetector::reset()
{
std::fill(hist.begin(), hist.end(), 0.0f);
std::fill(prevMag.begin(), prevMag.end(), 0.0f);
histPos = 0;
sinceHop = 0;
primingCount = 0;
framesSeen = 0;
fluxA = fluxB = 0.0f;
frameEndA = frameEndB = 0;
fluxHist.clear();
lastOnsetIndex = 0;
haveOnset = false;
// nextIndex / indexInit are intentionally left alone — the caller's
// monotonic index space is unaffected by a history flush.
}
void OnsetDetector::process(const float* samples, size_t n, uint64_t firstSampleIndex,
std::vector<OnsetDetector::Onset>& out)
{
if (samples == nullptr || n == 0) return;
if (! indexInit)
{
nextIndex = firstSampleIndex;
indexInit = true;
}
else if (firstSampleIndex != nextIndex)
{
// A gap (samples lost, or a seek): flux across the discontinuity is
// meaningless — drop history and resync.
reset();
nextIndex = firstSampleIndex;
indexInit = true;
}
for (size_t i = 0; i < n; ++i)
{
hist[histPos] = samples[i];
histPos = (histPos + 1) % (size_t) kWindow;
if (primingCount < kWindow) ++primingCount;
++sinceHop;
const uint64_t idx = nextIndex; // monotonic index of this sample
++nextIndex;
if (sinceHop >= kHop && primingCount >= kWindow)
{
sinceHop = 0;
processFrame(idx, out);
}
}
}
void OnsetDetector::processFrame(uint64_t frameEndIndex,
std::vector<OnsetDetector::Onset>& out)
{
// Assemble the windowed frame — `histPos` points at the oldest sample.
for (int j = 0; j < kWindow; ++j)
{
const float s = hist[(histPos + (size_t) j) % (size_t) kWindow];
fftIn[(size_t) j] = juce::dsp::Complex<float>{ s * hann[(size_t) j], 0.0f };
}
fft->perform(fftIn.data(), fftOut.data(), false);
const int halfBins = kWindow / 2 + 1;
for (int k = 0; k < halfBins; ++k)
{
const auto& c = fftOut[(size_t) k];
mag[(size_t) k] = std::sqrt(c.real() * c.real() + c.imag() * c.imag());
}
// Spectral flux: sum of positive magnitude change across the band. The
// very first frame has no predecessor — seed prevMag and report flux 0
// so the empty-prevMag spike never enters the history or peak-picker.
float flux = 0.0f;
if (framesSeen > 0)
{
for (int k = loBin; k <= hiBin; ++k)
{
const float d = mag[(size_t) k] - prevMag[(size_t) k];
if (d > 0.0f) flux += d;
}
}
prevMag = mag;
// Adaptive threshold from the trailing flux mean.
float threshold = kThresholdFloor;
if (! fluxHist.empty())
{
float sum = 0.0f;
for (float f : fluxHist) sum += f;
threshold = std::max(kThresholdFloor,
(sum / (float) fluxHist.size()) * thresholdK);
}
// Confirm the previous frame (B) as a local maximum: B > A and B >= C.
if (framesSeen >= 2 && fluxB > fluxA && fluxB >= flux && fluxB >= threshold)
{
const auto debounce = (uint64_t) (debounceSeconds * sampleRate);
if (! haveOnset || frameEndB > lastOnsetIndex + debounce)
{
Onset o;
o.sampleIndex = (frameEndB > (uint64_t) onsetBackdateSamples)
? frameEndB - (uint64_t) onsetBackdateSamples : 0;
o.strength = fluxB;
out.push_back(o);
lastOnsetIndex = frameEndB;
haveOnset = true;
}
}
// Roll the flux history and the A/B window forward.
fluxHist.push_back(flux);
if ((int) fluxHist.size() > kFluxHistFrames) fluxHist.pop_front();
fluxA = fluxB; frameEndA = frameEndB;
fluxB = flux; frameEndB = frameEndIndex;
++framesSeen;
}
+105
View File
@@ -0,0 +1,105 @@
#pragma once
// OnsetDetector — spectral-flux pick-attack detection.
//
// Consumes a contiguous mono input stream and emits an Onset for each detected
// pick attack, tagged with the monotonic input-ring sample index where the
// attack occurred. NoteVerifier uses it to time picked notes precisely: the
// harmonic-comb's 85 ms analysis window is far too coarse to place an onset,
// so timing comes from this short-hop (~5 ms) flux detector instead, and the
// comb is left to do only what it is good at — pitch.
//
// Algorithm: a 1024-sample STFT on a 256-sample hop; spectral flux is the sum
// of positive magnitude changes between consecutive frames over a guitar band.
// A flux frame that is a local maximum above an adaptive threshold (trailing
// mean × k) and far enough from the previous onset is reported as an attack.
#include <juce_dsp/juce_dsp.h>
#include <cstdint>
#include <deque>
#include <memory>
#include <vector>
class OnsetDetector
{
public:
struct Onset
{
uint64_t sampleIndex = 0; // monotonic input-ring index of the attack
float strength = 0.0f; // spectral-flux peak value (diagnostics)
};
OnsetDetector();
// Configure for a sample rate. Safe to call again on a device restart.
void prepare(double sampleRate);
// Drop all history — call on chart change / seek so stale flux state can't
// fabricate an onset. Does not change the configured sample rate.
void reset();
// Switch attack-detection tuning between guitar (default) and bass. A bass
// pluck — especially fingerstyle — rises slower and softer than a guitar
// pick, so the bass profile lowers the flux-band floor, relaxes the
// adaptive-threshold multiplier, and lengthens the onset backdate.
// Recomputes the rate-dependent state from the last prepare() sample rate.
// No-op if the profile is unchanged. Call from the same thread that drives
// process() (the NoteVerifier worker thread) — not concurrently with it.
void setProfile(bool bass);
// Feed contiguous samples whose first element sits at monotonic index
// `firstSampleIndex`. Detected onsets are appended to `out`. A gap in the
// index sequence (samples lost) resyncs and clears flux history.
void process(const float* samples, size_t n, uint64_t firstSampleIndex,
std::vector<Onset>& out);
private:
void processFrame(uint64_t frameEndIndex, std::vector<Onset>& out);
static constexpr int kWindow = 1024; // STFT window
static constexpr int kHop = 256; // hop — onset time resolution
static constexpr int kFftOrder = 10; // 2^10 == kWindow
double sampleRate = 48000.0;
std::unique_ptr<juce::dsp::FFT> fft;
std::vector<juce::dsp::Complex<float>> fftIn, fftOut;
std::vector<float> hann; // precomputed window
std::vector<float> mag; // current-frame magnitudes
std::vector<float> prevMag; // previous-frame magnitudes
// Sample-by-sample sliding history; a frame is taken every kHop samples.
std::vector<float> hist; // kWindow-long ring
size_t histPos = 0; // next write slot in `hist`
int sinceHop = 0; // samples since the last frame
int primingCount = 0; // samples seen, capped at kWindow
// Monotonic index bookkeeping (the caller's input-ring index space).
uint64_t nextIndex = 0;
bool indexInit = false;
// Flux peak-picking — keeps the last three frames' flux (A,B,C) so the
// middle (B) can be confirmed a local maximum.
int framesSeen = 0;
float fluxA = 0.0f, fluxB = 0.0f;
uint64_t frameEndA = 0, frameEndB = 0;
std::deque<float> fluxHist; // trailing flux for the adaptive threshold
uint64_t lastOnsetIndex = 0;
bool haveOnset = false;
int loBin = 1, hiBin = 64; // flux band, set from the sample rate
int onsetBackdateSamples = 2400; // onset calibration in samples, per rate
// Profile-dependent tunables. prepare()/setProfile() write these from the
// active profile, then derive loBin/hiBin/onsetBackdateSamples for the
// current sample rate. Defaults are the guitar values.
bool bassProfile = false;
float thresholdK = 1.8f; // adaptive-threshold multiplier
double debounceSeconds = 0.040; // min gap between onsets
double backdateSeconds = 0.050; // flux-peak-vs-attack + chain latency
double bandLoHz = 70.0; // flux band floor
double bandHiHz = 3000.0; // flux band ceiling
// Load the profile tunables then recompute the rate-dependent state.
void applyProfile();
};
+316
View File
@@ -0,0 +1,316 @@
#include "PitchDetector.h"
#include <cmath>
// Background thread for YIN detection
class PitchDetectionThread : public juce::Thread
{
public:
PitchDetectionThread(std::function<void()> callback)
: Thread("PitchDetector"), cb(std::move(callback)) {}
void run() override
{
while (!threadShouldExit())
{
cb();
sleep(10); // ~100Hz detection rate
}
}
private:
std::function<void()> cb;
};
PitchDetector::PitchDetector()
{
fifoBuffer.resize(4096, 0.0f);
analysisBuffer.resize(analysisSize, 0.0f);
}
PitchDetector::~PitchDetector()
{
stop();
}
// Designs one 2nd-order RBJ-cookbook low-pass biquad. The cascade in
// prepare() is Butterworth only because of the section Q values it passes.
void PitchDetector::designLowpass(Biquad& bq, double cutoffHz, double sampleRate, double q)
{
const double w0 = juce::MathConstants<double>::twoPi * cutoffHz / sampleRate;
const double cosw0 = std::cos(w0);
const double alpha = std::sin(w0) / (2.0 * q);
const double a0 = 1.0 + alpha;
bq.b0 = ((1.0 - cosw0) * 0.5) / a0;
bq.b1 = (1.0 - cosw0) / a0;
bq.b2 = ((1.0 - cosw0) * 0.5) / a0;
bq.a1 = (-2.0 * cosw0) / a0;
bq.a2 = (1.0 - alpha) / a0;
bq.reset();
}
void PitchDetector::prepare(double sampleRate, int /*blockSize*/)
{
// prepare() runs on the audio-device setup path (audioDeviceAboutToStart),
// i.e. while the audio callback is stopped — pushSamples() is not running
// concurrently, so the FIFO and analysis buffer can be reconfigured safely.
// stop() additionally joins the detection thread before any shared state is
// touched below; it must not be called while the audio callback is live.
stop();
// Decimate the device stream down to <= ~8 kHz for detection so YIN's
// O(N^2) difference function stays bounded regardless of device rate.
// ceil() keeps internalRate at or below targetInternalRate (floor() could
// leave it higher, e.g. ~8.8 kHz at 44.1 kHz, inflating the YIN cost).
decimationFactor = std::max(1, (int)std::ceil(sampleRate / targetInternalRate));
internalRate = sampleRate / decimationFactor;
decimPhase = 0;
// Window spans >2 periods of the lowest note of interest (25 Hz) at the
// internal rate, so YIN can resolve B0 / drop tunings (~640 samples).
analysisSize = 2 * ((int)std::ceil(internalRate / 25.0) + 1);
analysisBuffer.assign((size_t)analysisSize, 0.0f);
analysisWritePos = 0;
analysisSamplesPrimed = 0;
// Pre-size the detection-thread scratch buffers so the detection loop
// never allocates. yinBuffer needs analysisSize/2 entries (halfLen),
// the upper bound on tauMax + 1.
windowBuffer.assign((size_t)analysisSize, 0.0f);
yinBuffer.assign((size_t)(analysisSize / 2), 0.0f);
// Anti-aliasing low-pass below the post-decimation Nyquist (internalRate/2),
// run at the device rate before decimation. Two cascaded RBJ low-pass
// biquads given the standard Butterworth section Q values, which makes the
// overall 4th-order response Butterworth. The cutoff sits near the top
// of the detectable range (~2 kHz) when the internal rate allows; otherwise
// it is capped at internalRate*0.45 to stay below the Nyquist margin.
const double cutoff = std::min(2200.0, internalRate * 0.45);
designLowpass(aaFilter[0], cutoff, sampleRate, 0.54119610);
designLowpass(aaFilter[1], cutoff, sampleRate, 1.30656296);
// Drop any samples queued before this (re)configure so the restarted
// detector never analyses stale audio from the previous run.
fifo.reset();
// Clear the last published detection so getLatestDetection() never reports
// a stale note from the previous run before the first new frame arrives.
detectedFreq.store(-1.0f);
detectedConfidence.store(0.0f);
detectedMidi.store(-1);
detectedCents.store(0.0f);
thread = std::make_unique<PitchDetectionThread>([this]() { detectionThread(); });
thread->startThread(juce::Thread::Priority::normal);
}
void PitchDetector::stop()
{
if (thread)
{
// Wait unconditionally for the thread to exit before destroying it.
// The detection loop sleeps 10 ms between passes, so this typically
// returns within ~10 ms plus one bounded YIN pass; waiting without a
// timeout avoids racing into a use-after-free on a still-running thread.
thread->stopThread(-1);
thread.reset();
}
}
void PitchDetector::pushSamples(const float* data, int numSamples)
{
auto scope = fifo.write(numSamples);
for (int i = 0; i < scope.blockSize1; ++i)
fifoBuffer[(size_t)(scope.startIndex1 + i)] = data[i];
for (int i = 0; i < scope.blockSize2; ++i)
fifoBuffer[(size_t)(scope.startIndex2 + i)] = data[scope.blockSize1 + i];
}
PitchDetector::Detection PitchDetector::getLatestDetection() const
{
Detection d;
d.frequency = detectedFreq.load();
d.confidence = detectedConfidence.load();
d.midiNote = detectedMidi.load();
d.cents = detectedCents.load();
if (d.midiNote >= 0)
d.noteName = midiToNoteName(d.midiNote);
return d;
}
void PitchDetector::detectionThread()
{
// Read all available device-rate samples from the FIFO.
auto scope = fifo.read(fifo.getNumReady());
if (scope.blockSize1 + scope.blockSize2 == 0)
return; // no new data
// Anti-alias filter at the device rate, then decimate into the
// internal-rate analysis buffer (keep every decimationFactor-th sample).
int decimatedWritten = 0;
auto consume = [this, &decimatedWritten](float raw)
{
const float filtered = aaFilter[1].process(aaFilter[0].process(raw));
if (++decimPhase >= decimationFactor)
{
decimPhase = 0;
analysisBuffer[(size_t)analysisWritePos] = filtered;
analysisWritePos = (analysisWritePos + 1) % analysisSize;
if (analysisSamplesPrimed < analysisSize)
++analysisSamplesPrimed;
++decimatedWritten;
}
};
for (int i = 0; i < scope.blockSize1; ++i)
consume(fifoBuffer[(size_t)(scope.startIndex1 + i)]);
for (int i = 0; i < scope.blockSize2; ++i)
consume(fifoBuffer[(size_t)(scope.startIndex2 + i)]);
// Skip the YIN pass when the window is not worth analysing:
// - decimatedWritten == 0: this tick produced no decimated sample, so the
// window is unchanged — avoid re-publishing an identical result.
// - not yet primed: fewer than analysisSize decimated samples have been
// written since prepare(), so the window still holds startup silence,
// which could otherwise yield spurious notes.
if (decimatedWritten == 0 || analysisSamplesPrimed < analysisSize)
return;
// Rearrange the ring buffer into the contiguous window scratch buffer
// (oldest sample first) and run YIN on it.
for (int i = 0; i < analysisSize; ++i)
windowBuffer[(size_t)i] = analysisBuffer[(size_t)((analysisWritePos + i) % analysisSize)];
float freq = yinDetect(windowBuffer.data(), analysisSize, (float)internalRate);
if (freq > 0.0f)
{
float ref = tuningRef.load();
int midi = frequencyToMidi(freq, ref);
float nearestFreq = midiToFrequency(midi, ref);
float cents = 1200.0f * std::log2(freq / nearestFreq);
detectedFreq.store(freq);
detectedConfidence.store(1.0f); // YIN confidence is implicit from threshold
detectedMidi.store(midi);
detectedCents.store(cents);
}
else
{
detectedFreq.store(-1.0f);
detectedConfidence.store(0.0f);
detectedMidi.store(-1);
detectedCents.store(0.0f);
}
}
// ── YIN Algorithm ─────────────────────────────────────────────────────────────
// Ported from Slopsmith's note_detect plugin JavaScript implementation.
float PitchDetector::yinDetect(const float* buffer, int length, float sampleRate)
{
const float threshold = 0.15f;
const int halfLen = length / 2;
// Restrict tau to the frequency range we care about. This avoids scanning
// taus that correspond to undetectable or out-of-range pitches and keeps
// computation proportional to the useful search window.
// tauMin → smallest lag scanned (highest pitch of interest, 2000 Hz);
// floor() so the integer lag just below sampleRate/2000 is
// still considered, keeping pitches up to ~2000 Hz in range.
// tauMax → lowest pitch of interest (25 Hz covers B0/drop tunings)
// capped at halfLen-1, which is the true YIN limit.
const int tauMin = std::max(2, (int)std::floor(sampleRate / 2000.0f));
const int tauMax = std::min(halfLen - 1, (int)std::ceil(sampleRate / 25.0f));
// yinBuffer is the pre-sized scratch member (>= halfLen entries, so it
// always covers indices [0, tauMax]); only [0, tauMax] are written/read.
// Difference function — compute only up to tauMax
float runningSum = 0.0f;
yinBuffer[0] = 1.0f;
for (int tau = 1; tau <= tauMax; ++tau)
{
float sum = 0.0f;
for (int i = 0; i < halfLen; ++i)
{
float delta = buffer[i] - buffer[i + tau];
sum += delta * delta;
}
yinBuffer[(size_t)tau] = sum;
runningSum += sum;
// Cumulative mean normalized difference
if (runningSum > 0.0f)
yinBuffer[(size_t)tau] *= (float)tau / runningSum;
}
// Silent or DC-constant frame: all difference values are zero, every
// CMNDF entry stays 0, and the threshold test (0 < 0.15) would accept the
// very first tau — producing a spurious pitch. Bail out early.
if (runningSum < 1e-10f) return -1.0f;
// Absolute threshold — search only within the detectable pitch range
int tau = tauMin;
while (tau <= tauMax)
{
if (yinBuffer[(size_t)tau] < threshold)
{
while (tau + 1 <= tauMax && yinBuffer[(size_t)(tau + 1)] < yinBuffer[(size_t)tau])
++tau;
break;
}
++tau;
}
if (tau > tauMax) return -1.0f; // no pitch detected
// Parabolic interpolation for sub-sample accuracy
float s0 = tau > 0 ? yinBuffer[(size_t)(tau - 1)] : yinBuffer[(size_t)tau];
float s1 = yinBuffer[(size_t)tau];
float s2 = (tau + 1 <= tauMax) ? yinBuffer[(size_t)(tau + 1)] : yinBuffer[(size_t)tau];
float denom = 2.0f * (s0 - 2.0f * s1 + s2);
float betterTau = (std::abs(denom) > 1e-9f)
? (float)tau + (s0 - s2) / denom
: (float)tau;
float freq = sampleRate / betterTau;
// Sanity check: guitar range is ~80 Hz (E2) to ~1320 Hz (E6);
// bass is ~41 Hz (E1) to ~330 Hz (E4), with some extended-range basses
// reaching ~31 Hz (B0). Detection runs on a decimated ~8 kHz internal
// stream whose window spans >2 periods of 25 Hz, so a 25 Hz floor is
// resolvable; it covers extended-range bass tunings (B0 ~31 Hz, drop-A
// ~27.5 Hz) and rejects sub-bass artefacts.
if (freq < 25.0f || freq > 2000.0f) return -1.0f;
return freq;
}
// ── Helpers ───────────────────────────────────────────────────────────────────
int PitchDetector::frequencyToMidi(float freq, float tuningRef)
{
if (freq <= 0.0f) return -1;
return (int)std::round(69.0f + 12.0f * std::log2(freq / tuningRef));
}
float PitchDetector::midiToFrequency(int midi, float tuningRef)
{
return tuningRef * std::pow(2.0f, (float)(midi - 69) / 12.0f);
}
juce::String PitchDetector::midiToNoteName(int midi)
{
static const char* noteNames[] = {"C", "C#", "D", "D#", "E", "F", "F#", "G", "G#", "A", "A#", "B"};
if (midi < 0 || midi > 127) return "?";
int note = midi % 12;
int octave = (midi / 12) - 1;
return juce::String(noteNames[note]) + juce::String(octave);
}
+121
View File
@@ -0,0 +1,121 @@
#pragma once
#include <juce_core/juce_core.h>
#include <atomic>
#include <vector>
// Real-time pitch detector using the YIN algorithm.
// Audio samples are pushed from the audio thread via a lock-free FIFO.
// A background thread runs YIN detection and updates atomic results
// that can be polled from any thread.
class PitchDetector
{
public:
PitchDetector();
~PitchDetector();
void prepare(double sampleRate, int blockSize);
void stop();
// Called from audio thread — must be lock-free
void pushSamples(const float* data, int numSamples);
// Detection result — read from any thread
struct Detection
{
float frequency = -1.0f; // Hz, -1 if no pitch detected
float confidence = 0.0f; // 0-1
int midiNote = -1; // nearest MIDI note, -1 if none
float cents = 0.0f; // deviation from nearest MIDI note in cents
juce::String noteName; // e.g. "A4", "E2"
};
Detection getLatestDetection() const;
// Tuning reference (default 440 Hz)
void setTuningReference(float hz) { tuningRef.store(hz); }
float getTuningReference() const { return tuningRef.load(); }
private:
void detectionThread();
// YIN algorithm — non-const: reuses the yinBuffer scratch member.
float yinDetect(const float* buffer, int length, float sampleRate);
// MIDI note helpers
static int frequencyToMidi(float freq, float tuningRef);
static float midiToFrequency(int midi, float tuningRef);
static juce::String midiToNoteName(int midi);
// ── Anti-aliasing decimation ────────────────────────────────────────────
// Detection runs at a bounded internal rate (~8 kHz) so YIN's O(N^2) cost
// stays constant regardless of the device sample rate. Incoming audio is
// low-pass filtered and decimated before analysis.
struct Biquad
{
double b0 = 1.0, b1 = 0.0, b2 = 0.0, a1 = 0.0, a2 = 0.0;
double z1 = 0.0, z2 = 0.0; // Direct Form II transposed state
inline float process(float x) noexcept
{
const double in = (double)x;
const double y = b0 * in + z1;
z1 = b1 * in - a1 * y + z2;
z2 = b2 * in - a2 * y;
return (float)y;
}
void reset() noexcept { z1 = z2 = 0.0; }
};
// Designs one 2nd-order RBJ-cookbook low-pass biquad into 'bq'. The
// cascade is Butterworth only by virtue of the section Q values passed.
static void designLowpass(Biquad& bq, double cutoffHz, double sampleRate, double q);
// Lock-free FIFO for audio thread -> detection thread
juce::AbstractFifo fifo{4096};
std::vector<float> fifoBuffer;
// Detection runs on decimated audio at ~8 kHz so YIN's cost is bounded.
// decimationFactor and internalRate are computed in prepare(); aaFilter is
// a 4th-order Butterworth-response low-pass (two cascaded biquads) at the
// device rate before decimation. prepare() writes these while the
// detection thread is joined (stopped), and the thread only reads them
// while running — so no concurrent access occurs.
static constexpr double targetInternalRate = 8000.0;
int decimationFactor = 1;
double internalRate = 48000.0;
Biquad aaFilter[2];
int decimPhase = 0;
// Analysis buffer — holds the most recent decimated (internal-rate)
// samples, spanning >2 periods of the lowest note of interest (25 Hz).
// Sized at prepare() time as 2 * (ceil(internalRate / 25) + 1); ~640
// samples regardless of device rate. The initialiser is just a
// placeholder — detection only runs after prepare() has resized it.
int analysisSize = 1024;
std::vector<float> analysisBuffer;
int analysisWritePos = 0;
// Count of decimated samples written since prepare(), capped at
// analysisSize. YIN is gated on this so it never analyses a window that
// still contains startup silence (which could publish spurious notes).
int analysisSamplesPrimed = 0;
// Detection-thread scratch buffers, sized in prepare() so the detection
// loop stays allocation-free. Touched only by the detection thread.
std::vector<float> windowBuffer; // rearranged contiguous analysis window
std::vector<float> yinBuffer; // YIN CMNDF working buffer
// Results (atomic struct via padding)
std::atomic<float> detectedFreq{-1.0f};
std::atomic<float> detectedConfidence{0.0f};
std::atomic<int> detectedMidi{-1};
std::atomic<float> detectedCents{0.0f};
std::atomic<float> tuningRef{440.0f};
// Background thread
std::unique_ptr<juce::Thread> thread;
JUCE_DECLARE_NON_COPYABLE_WITH_LEAK_DETECTOR(PitchDetector)
};
+126
View File
@@ -0,0 +1,126 @@
// AudioChannel — lock-free audio shared-memory ring between host and sandbox.
//
// Layout described in Protocol.h (AudioShmHeader). One mapping per sandbox;
// the host creates it before spawning the subprocess and passes the mapping
// name on the command line.
//
// Threading: the host's audio thread calls `pushInputBlock()` (publishes a
// block of audio + the per-block MIDI queue together) and `popBlock(true,…)`
// (drains the matching processed-output block). The sandbox's audio thread
// runs the mirror: `popInputBlock()` → plugin->processBlock → `pushBlock(true,
// …)`. Both sides block on the partner's OS event with a short timeout, so
// dropouts are detectable. `signalSandboxWake()` lets the host break the
// sandbox out of its popInputBlock wait without publishing a real block —
// used by the audio-thread pause/drain protocol around non-realtime control
// ops (kPrepare / kSetBlockSize / kGetState / kSetState).
#pragma once
#include <juce_audio_basics/juce_audio_basics.h>
#include <atomic>
#include <memory>
#include "Protocol.h"
namespace slopsmith::sandbox {
class AudioChannel
{
public:
// Handoff from the host side to the sandbox side. On Windows these are
// kernel-object names the sandbox re-opens by name. On POSIX the audio
// path is fd-passed instead (no named auto-reset event exists on macOS
// that is also crash-safe — see AudioChannel_posix.cpp), so the string
// names are unused and the integer fds below carry the handoff.
struct Names
{
juce::String shm; // file-mapping object name (Windows)
juce::String evtToHost; // sandbox→host (output ready) (Windows)
juce::String evtToSandbox; // host→sandbox (input ready) (Windows)
#if ! JUCE_WINDOWS
// POSIX handoff. `shmFd` is a dup of the anonymous shared-memory fd;
// `sandboxAudioFd` is the sandbox's end of the bidirectional doorbell
// socketpair. createHostSide fills both with fds the *sandbox* side
// consumes: for an in-process loopback the sandbox AudioChannel takes
// them directly via openSandboxSide; for a real spawn the host
// dup2()s them into the child (SubprocessHandle) and then closes its
// copies. openSandboxSide takes ownership and closes them in close().
int shmFd = -1;
int sandboxAudioFd = -1;
#endif
};
AudioChannel();
~AudioChannel();
// Host side: create the shm + both events, return the names for passing to
// the subprocess.
bool createHostSide(const AudioDimensions& dims, Names& namesOut,
juce::String& errorOut);
// Sandbox side: open existing shm + events by name.
bool openSandboxSide(const Names& names, juce::String& errorOut);
// Whichever side we are: copy a block of audio in (host: input → sandbox;
// sandbox: processed output → host). Returns false if the ring is full.
//
// For the INPUT direction, callers MUST use pushInputBlock() — pushBlock
// does not touch the slot's MidiQueue, so a direct pushBlock(false, ...)
// would leave whatever MIDI count was in the slot from a prior
// pushInputBlock and the next popInputBlock would replay those stale
// events against fresh audio. Today the only input producer is
// SandboxedProcessor::processBlock and it always goes through
// pushInputBlock; this overload exists for the OUTPUT direction
// (sandbox → host audio, no MIDI carried back).
bool pushBlock(bool isOutputRing, const juce::AudioBuffer<float>& src,
int numSamples);
// Mirror of pushBlock: drain one block out. Returns false on timeout.
bool popBlock(bool isOutputRing, juce::AudioBuffer<float>& dst,
int numSamples, int timeoutMs);
// Host-side input push that bundles per-block MIDI into the upcoming
// slot's MidiQueue. Events past kMidiEventsPerSlot (or larger than
// kMidiEventMaxBytes, e.g. SysEx) bump the queue's overflow counter and
// are dropped. The audio thread never blocks; lossy MIDI is the
// documented v2 policy.
bool pushInputBlock(const juce::AudioBuffer<float>& src,
const juce::MidiBuffer& midi,
int numSamples);
// Sandbox-side input pop that drains the matching MidiQueue into `midi`.
// The MIDI queue is read before the read-index is advanced so the slot
// stays owned by the sandbox until both audio and MIDI are consumed.
bool popInputBlock(juce::AudioBuffer<float>& dst,
juce::MidiBuffer& midi,
int numSamples, int timeoutMs);
// Wake the sandbox audio thread out of its popInputBlock wait without
// pushing a real block. Used by the host-side audio-thread pause/drain
// protocol so non-realtime control ops don't have to wait the full
// popInputBlock timeout for the audio worker to notice the pause flag.
// Sandbox-side: also called on shutdown to break the loop's WaitFor.
void signalSandboxWake();
const AudioDimensions& dims() const noexcept { return cachedDims; }
// Test/diagnostic readers for the shared header's cumulative counters.
// Safe to call from either side (both map the same object); return 0 when
// the channel isn't mapped. Used by the loopback test in place of
// re-opening the shm by name (which POSIX anonymous shm can't do).
uint64_t diagMidiOverflows() const noexcept;
uint64_t diagXruns() const noexcept;
uint64_t diagDropouts() const noexcept;
void close();
private:
struct Impl;
std::unique_ptr<Impl> impl;
AudioDimensions cachedDims;
JUCE_DECLARE_NON_COPYABLE_WITH_LEAK_DETECTOR(AudioChannel)
};
} // namespace slopsmith::sandbox
+80
View File
@@ -0,0 +1,80 @@
// AudioChannel::Impl — private OS-handle wrapper, shared between the
// platform-neutral ring logic (AudioChannel_shared.cpp) and the per-platform
// create/open/close + doorbell signalling (AudioChannel_{win,posix}.cpp).
//
// This header is internal to the AudioChannel translation units; it is NOT
// part of the public AudioChannel.h surface (it would otherwise drag
// <windows.h> / POSIX fd semantics into every includer of AudioChannel.h).
//
// The ring methods touch only the neutral mapped-region pointers plus the two
// platform doorbell primitives (`signalEvent` / `waitEvent`); everything
// genuinely OS-specific lives behind those two calls + the create/open/close
// trio so the lock-free ring algorithm is written once.
#pragma once
#include "AudioChannel.h"
#if JUCE_WINDOWS
#include <windows.h>
#endif
#include <cstddef>
namespace slopsmith::sandbox {
struct AudioChannel::Impl
{
// Neutral mapped-region pointers, set by createHostSide / openSandboxSide
// and cleared by close(). The ring algorithm in AudioChannel_shared.cpp
// uses only these.
void* view = nullptr;
AudioShmHeader* header = nullptr;
float* inputRing = nullptr; // host writes, sandbox reads
float* outputRing = nullptr; // sandbox writes, host reads
MidiQueue* midiQueues = nullptr; // [maxBlocks], one per input slot
#if JUCE_WINDOWS
HANDLE mapping = nullptr;
HANDLE evtToHost = nullptr; // sandbox→host (output ready)
HANDLE evtToSandbox = nullptr; // host→sandbox (input ready)
#else
// Anonymous shm object: shm_open()'d, ftruncate()'d, then immediately
// shm_unlink()'d so it has no lingering name to leak on a crash; the fd
// keeps the object alive and is dup2()'d into the sandbox child.
int shmFd = -1;
size_t mappedBytes = 0; // for munmap on close()
// Our end of the bidirectional doorbell socketpair. Because a socketpair
// delivers each side only what the *other* side wrote, one fd per process
// multiplexes both cross-process directions: writing wakes the peer's
// waitEvent, reading drains the peer's wakes. Non-blocking; SO_NOSIGPIPE /
// MSG_NOSIGNAL keep a dead peer from killing us with SIGPIPE.
int evtFd = -1;
// Same-process self-wake pipe. signalSandboxWake() must break OUR OWN
// waiter (the audio worker's popInputBlock) without data — but a write to
// `evtFd` goes to the *peer*, not back to us, so it can't self-wake (and
// worse, would spuriously wake the peer). The self-pipe is the POSIX analog
// of the Win32 auto-reset event's self-signal: waitEvent polls evtFd AND
// selfWake[0]; wakeSelf() writes selfWake[1].
int selfWake[2] = { -1, -1 };
#endif
// Doorbell. signalEvent wakes the consumer of `isOutputRing`; waitEvent
// blocks the consumer until the producer signals (or timeoutMs elapses,
// returning false). On POSIX `isOutputRing` is irrelevant — the single
// socketpair fd carries both directions — but the parameter keeps the
// Windows two-event mapping expressible. Both are no-ops / immediate
// failures once close() has run.
void signalEvent(bool isOutputRing);
bool waitEvent(bool isOutputRing, int timeoutMs);
// Break OUR OWN waitEvent (the audio worker's popInputBlock) without
// publishing a block — used by the sandbox-side pause/drain + shutdown.
// Windows: SetEvent(evtToSandbox) (auto-reset events self-signal fine).
// POSIX: write the self-pipe (a socketpair self-write would hit the peer).
void wakeSelf();
};
} // namespace slopsmith::sandbox
+471
View File
@@ -0,0 +1,471 @@
// AudioChannel — POSIX backend (macOS + Linux).
//
// Shared memory: an *anonymous* object — shm_open(O_CREAT|O_EXCL), ftruncate,
// then shm_unlink immediately. The fd keeps the object alive (like a deleted-
// but-open file), so there is no lingering /dev/shm name to leak if the
// sandbox crashes, and the macOS 31-char shm-name limit is irrelevant past
// creation. The fd is handed to the sandbox by inheritance (SubprocessHandle
// dup2()s it across posix_spawn) rather than re-opened by name.
//
// Doorbell: a single bidirectional socketpair per side. A socketpair delivers
// each end only what the *other* end wrote, so one fd multiplexes both
// directions — signalEvent writes one byte to wake the peer's waitEvent;
// waitEvent poll()s then drains. This is the only cross-process auto-reset-
// style primitive that is (a) implemented on macOS (unnamed POSIX semaphores
// are not — sem_init returns ENOSYS), (b) crash-safe (a process-shared pthread
// mutex/condvar has no robust-mutex support on macOS, so a producer crash
// holding the lock would deadlock the consumer; a dead socketpair peer instead
// surfaces as POLLHUP), and (c) tolerant of coalesced signals (the ring
// consumers re-read the atomic index on wake, exactly as on the Win32 auto-
// reset path).
#include "AudioChannelImpl.h"
#include "../VSTTrace.h"
#if JUCE_WINDOWS
#error "AudioChannel_posix.cpp is POSIX-only; Windows builds use AudioChannel_win.cpp."
#endif
#include <atomic>
#include <cerrno>
#include <cstring>
#include <fcntl.h>
#include <poll.h>
#include <sys/mman.h>
#include <sys/socket.h>
#include <sys/stat.h>
#include <unistd.h>
namespace slopsmith::sandbox {
namespace {
// shm_open names must be globally unique and — on macOS — at most 31 chars
// (PSHMNAMLEN), including the leading '/'. We unlink immediately after
// creation, so the name only has to survive the open() call; pid + a
// per-process counter is unique enough. "/slsv-<pid>-<n>" stays well under 31.
juce::String makeShortShmName()
{
static std::atomic<unsigned> counter{0};
const unsigned n = counter.fetch_add(1, std::memory_order_relaxed);
return "/slsv-" + juce::String((int)getpid()) + "-" + juce::String((int)n);
}
void setNonBlocking(int fd)
{
const int flags = fcntl(fd, F_GETFL, 0);
if (flags >= 0) fcntl(fd, F_SETFL, flags | O_NONBLOCK);
}
void setCloExec(int fd)
{
const int flags = fcntl(fd, F_GETFD, 0);
if (flags >= 0) fcntl(fd, F_SETFD, flags | FD_CLOEXEC);
}
// Suppress SIGPIPE on this socket. macOS has the per-socket SO_NOSIGPIPE
// option; Linux has no such option (it uses MSG_NOSIGNAL on send() — see
// writeDoorbell). A dead doorbell peer must surface as a write error /
// POLLHUP, never as a process-killing signal.
void setNoSigPipe([[maybe_unused]] int fd)
{
#ifdef SO_NOSIGPIPE
const int one = 1;
setsockopt(fd, SOL_SOCKET, SO_NOSIGPIPE, &one, sizeof(one));
#endif
}
void prepDoorbellFd(int fd)
{
setNonBlocking(fd);
setCloExec(fd);
setNoSigPipe(fd);
}
// Create this side's same-process self-wake channel. A socketpair (not a bare
// pipe) so wakeSelf's write can be SIGPIPE-suppressed (SO_NOSIGPIPE /
// MSG_NOSIGNAL) like the cross-process doorbell — a write after the read end
// is closed must never raise SIGPIPE. Read end non-blocking so the drain loop
// terminates; both ends CLOEXEC (each process makes its own, never inherited).
bool makeSelfWakePipe(int fds[2], juce::String& errorOut)
{
if (socketpair(AF_UNIX, SOCK_STREAM, 0, fds) != 0)
{
errorOut = "self-wake socketpair() failed: " + juce::String(strerror(errno));
return false;
}
setNonBlocking(fds[0]);
setCloExec(fds[0]);
setNoSigPipe(fds[0]);
setCloExec(fds[1]);
setNoSigPipe(fds[1]);
return true;
}
} // namespace
bool AudioChannel::createHostSide(const AudioDimensions& dims, Names& namesOut,
juce::String& errorOut)
{
// Windows event names are unused on POSIX; clear them so a stray reader
// can't mistake them for live handles.
namesOut.shm.clear();
namesOut.evtToHost.clear();
namesOut.evtToSandbox.clear();
const uint64_t totalBytes = dims.totalShmBytes();
// Create the anonymous shm object: open exclusively, then unlink so only
// the fd keeps it alive. Retry on the (vanishingly unlikely) name clash.
int fd = -1;
for (int attempt = 0; attempt < 8; ++attempt)
{
const juce::String name = makeShortShmName();
fd = shm_open(name.toRawUTF8(), O_RDWR | O_CREAT | O_EXCL, 0600);
if (fd >= 0)
{
shm_unlink(name.toRawUTF8()); // anonymous from here on
break;
}
if (errno != EEXIST)
{
errorOut = "shm_open failed: " + juce::String(strerror(errno));
close();
return false;
}
}
if (fd < 0)
{
errorOut = "shm_open failed: name collisions exhausted";
close();
return false;
}
impl->shmFd = fd;
setCloExec(impl->shmFd);
if (ftruncate(impl->shmFd, (off_t)totalBytes) != 0)
{
errorOut = "ftruncate failed: " + juce::String(strerror(errno));
close();
return false;
}
void* m = mmap(nullptr, (size_t)totalBytes, PROT_READ | PROT_WRITE,
MAP_SHARED, impl->shmFd, 0);
if (m == MAP_FAILED)
{
errorOut = "mmap failed: " + juce::String(strerror(errno));
impl->view = nullptr; // MAP_FAILED is not a valid pointer to munmap
close();
return false;
}
impl->view = m;
impl->mappedBytes = (size_t)totalBytes;
impl->header = reinterpret_cast<AudioShmHeader*>(impl->view);
// Initialise the header (identical layout to the Windows backend).
impl->header->magic = kAudioShmMagic;
impl->header->protocolVersion = kProtocolVersion;
impl->header->maxBlocks = dims.maxBlocks;
impl->header->maxBlockSamples = dims.maxBlockSamples;
impl->header->maxChannels = dims.maxChannels;
impl->header->sampleRate = dims.sampleRate;
impl->header->inWriteIdx = 0;
impl->header->inReadIdx = 0;
impl->header->outWriteIdx = 0;
impl->header->outReadIdx = 0;
impl->header->xruns = 0;
impl->header->dropouts = 0;
impl->header->midiOverflows = 0;
impl->header->ringBytesPerSlot = dims.bytesPerSlot();
impl->header->inputRingOffset = sizeof(AudioShmHeader);
impl->header->outputRingOffset = impl->header->inputRingOffset
+ uint64_t(dims.maxBlocks) * dims.bytesPerSlot();
impl->header->midiQueueOffset = impl->header->outputRingOffset
+ uint64_t(dims.maxBlocks) * dims.bytesPerSlot();
// Release fence so the header writes are visible before the sandbox
// observes the mapping. The spawn (posix_spawn) is the real publish point
// and is a full barrier, but the fence documents the producer side of the
// spawn-order invariant explicitly rather than relying on it.
std::atomic_thread_fence(std::memory_order_release);
auto* base = reinterpret_cast<char*>(impl->view);
impl->inputRing = reinterpret_cast<float*>(base + impl->header->inputRingOffset);
impl->outputRing = reinterpret_cast<float*>(base + impl->header->outputRingOffset);
impl->midiQueues = reinterpret_cast<MidiQueue*>(base + impl->header->midiQueueOffset);
std::memset(impl->midiQueues, 0,
sizeof(MidiQueue) * (size_t)dims.maxBlocks);
// Doorbell socketpair: sp[0] stays here (host end), sp[1] goes to the
// sandbox (handed off via Names, then dup2()'d into the child or consumed
// directly by an in-process openSandboxSide).
int sp[2] = { -1, -1 };
if (socketpair(AF_UNIX, SOCK_STREAM, 0, sp) != 0)
{
errorOut = "socketpair failed: " + juce::String(strerror(errno));
close();
return false;
}
impl->evtFd = sp[0];
prepDoorbellFd(impl->evtFd);
prepDoorbellFd(sp[1]);
if (!makeSelfWakePipe(impl->selfWake, errorOut))
{
::close(sp[1]);
close();
return false;
}
// Hand off a dup of the shm fd + the sandbox's doorbell end. dup() so the
// host keeps independent ownership of impl->shmFd: an in-process
// openSandboxSide closes these in its own close(), and a real spawn dup2()s
// them into the child and closes its copies, neither of which must disturb
// the host's fds.
namesOut.shmFd = dup(impl->shmFd);
if (namesOut.shmFd < 0)
{
errorOut = "dup(shmFd) failed: " + juce::String(strerror(errno));
::close(sp[1]);
close();
return false;
}
setCloExec(namesOut.shmFd);
namesOut.sandboxAudioFd = sp[1];
cachedDims = dims;
return true;
}
bool AudioChannel::openSandboxSide(const Names& names, juce::String& errorOut)
{
// Take ownership of the handed-off fds. From here, close() releases them.
impl->shmFd = names.shmFd;
impl->evtFd = names.sandboxAudioFd;
if (impl->shmFd < 0 || impl->evtFd < 0)
{
errorOut = "openSandboxSide: invalid handoff fds (shmFd="
+ juce::String(impl->shmFd) + " audioFd="
+ juce::String(impl->evtFd) + ")";
close();
return false;
}
setCloExec(impl->shmFd);
prepDoorbellFd(impl->evtFd);
if (!makeSelfWakePipe(impl->selfWake, errorOut))
{
close();
return false;
}
// fstat reports the true object size (set by the host's ftruncate) — the
// POSIX analog of the Windows VirtualQuery RegionSize checks, and more
// precise (object size, not mapped-region size). Reject anything smaller
// than the header before mapping + dereferencing it.
struct stat st{};
if (fstat(impl->shmFd, &st) != 0)
{
errorOut = "fstat(shmFd) failed: " + juce::String(strerror(errno));
close();
return false;
}
const uint64_t objectBytes = (uint64_t)st.st_size;
if (objectBytes < sizeof(AudioShmHeader))
{
errorOut = "audio shm object too small for header ("
+ juce::String((int64_t)objectBytes) + " < "
+ juce::String((int64_t)sizeof(AudioShmHeader)) + ")";
close();
return false;
}
void* m = mmap(nullptr, (size_t)objectBytes, PROT_READ | PROT_WRITE,
MAP_SHARED, impl->shmFd, 0);
if (m == MAP_FAILED)
{
errorOut = "mmap (sandbox) failed: " + juce::String(strerror(errno));
impl->view = nullptr;
close();
return false;
}
impl->view = m;
impl->mappedBytes = (size_t)objectBytes;
impl->header = reinterpret_cast<AudioShmHeader*>(impl->view);
if (impl->header->magic != kAudioShmMagic)
{
errorOut = "audio shm magic mismatch";
close();
return false;
}
if (impl->header->protocolVersion != kProtocolVersion)
{
errorOut = "audio shm protocol mismatch: expected "
+ juce::String((int)kProtocolVersion) + ", got "
+ juce::String((int)impl->header->protocolVersion);
close();
return false;
}
// Validate dims against compile-time caps BEFORE computing bytesPerSlot()
// / expectedTotal, to keep pathological header values from overflowing the
// uint64_t arithmetic in the bounds check below.
if (impl->header->maxBlocks == 0 || impl->header->maxBlocks > kAudioMaxBlocks
|| impl->header->maxBlockSamples == 0
|| impl->header->maxBlockSamples > kAudioMaxBlockSamples
|| impl->header->maxChannels == 0
|| impl->header->maxChannels > kAudioMaxChannels)
{
errorOut = "audio shm dims exceed protocol caps: blocks="
+ juce::String((int64_t)impl->header->maxBlocks)
+ " blockSamples=" + juce::String((int64_t)impl->header->maxBlockSamples)
+ " channels=" + juce::String((int64_t)impl->header->maxChannels);
close();
return false;
}
cachedDims.maxBlocks = impl->header->maxBlocks;
cachedDims.maxBlockSamples = impl->header->maxBlockSamples;
cachedDims.maxChannels = impl->header->maxChannels;
cachedDims.sampleRate = impl->header->sampleRate;
const uint64_t expectedSlotBytes = cachedDims.bytesPerSlot();
if (impl->header->ringBytesPerSlot != expectedSlotBytes)
{
errorOut = "audio shm ringBytesPerSlot mismatch: expected "
+ juce::String((int64_t)expectedSlotBytes) + ", got "
+ juce::String((int64_t)impl->header->ringBytesPerSlot);
close();
return false;
}
const uint64_t expectedTotal = sizeof(AudioShmHeader)
+ 2 * uint64_t(impl->header->maxBlocks) * impl->header->ringBytesPerSlot
+ uint64_t(impl->header->maxBlocks) * sizeof(MidiQueue);
const uint64_t inEnd = impl->header->inputRingOffset
+ uint64_t(impl->header->maxBlocks) * impl->header->ringBytesPerSlot;
const uint64_t outEnd = impl->header->outputRingOffset
+ uint64_t(impl->header->maxBlocks) * impl->header->ringBytesPerSlot;
const uint64_t midiEnd = impl->header->midiQueueOffset
+ uint64_t(impl->header->maxBlocks) * sizeof(MidiQueue);
// Bounds + ordering check: each region must fit within the object and not
// overlap. Canonical layout: [header][input][output][midi].
if (inEnd > expectedTotal
|| outEnd > expectedTotal
|| midiEnd > expectedTotal
|| impl->header->inputRingOffset < sizeof(AudioShmHeader)
|| impl->header->outputRingOffset < inEnd
|| impl->header->midiQueueOffset < outEnd)
{
errorOut = "audio shm ring/MIDI offsets out of bounds or overlapping";
close();
return false;
}
// And the real object must be at least expectedTotal — a stale/foreign fd
// could pass magic+version+caps but be backed by a smaller object.
if (objectBytes < expectedTotal)
{
errorOut = "audio shm object too small for ring layout: object="
+ juce::String((int64_t)objectBytes) + " expected>="
+ juce::String((int64_t)expectedTotal);
close();
return false;
}
auto* base = reinterpret_cast<char*>(impl->view);
impl->inputRing = reinterpret_cast<float*>(base + impl->header->inputRingOffset);
impl->outputRing = reinterpret_cast<float*>(base + impl->header->outputRingOffset);
impl->midiQueues = reinterpret_cast<MidiQueue*>(base + impl->header->midiQueueOffset);
return true;
}
void AudioChannel::close()
{
if (impl->view) { munmap(impl->view, impl->mappedBytes); impl->view = nullptr; }
impl->mappedBytes = 0;
if (impl->shmFd >= 0) { ::close(impl->shmFd); impl->shmFd = -1; }
if (impl->evtFd >= 0) { ::close(impl->evtFd); impl->evtFd = -1; }
if (impl->selfWake[0] >= 0) { ::close(impl->selfWake[0]); impl->selfWake[0] = -1; }
if (impl->selfWake[1] >= 0) { ::close(impl->selfWake[1]); impl->selfWake[1] = -1; }
impl->header = nullptr;
impl->inputRing = nullptr;
impl->outputRing = nullptr;
impl->midiQueues = nullptr;
}
void AudioChannel::Impl::signalEvent(bool /*isOutputRing*/)
{
// One socketpair carries both directions: a write here wakes whichever
// side is poll()ing the *other* end. `isOutputRing` is therefore
// irrelevant on POSIX. Non-blocking + SIGPIPE-suppressed: a full buffer
// (EAGAIN) means the peer already has pending wakes it hasn't drained, so
// dropping this one is harmless (the consumer re-reads the index); a dead
// peer (EPIPE) is handled by the pop-timeout / disconnect paths.
if (evtFd < 0) return;
const unsigned char byte = 1;
#ifdef MSG_NOSIGNAL
(void)::send(evtFd, &byte, 1, MSG_NOSIGNAL);
#else
(void)::send(evtFd, &byte, 1, 0); // SO_NOSIGPIPE was set on the fd
#endif
}
bool AudioChannel::Impl::waitEvent(bool /*isOutputRing*/, int timeoutMs)
{
if (evtFd < 0) return false;
// Poll the cross-process doorbell (peer signals) AND our own self-wake pipe
// (same-process signalSandboxWake). Either readable is a wake.
struct pollfd pfds[2]{};
pfds[0].fd = evtFd; pfds[0].events = POLLIN;
pfds[1].fd = selfWake[0]; pfds[1].events = POLLIN;
const nfds_t nfds = (selfWake[0] >= 0) ? 2 : 1;
for (;;)
{
const int rc = ::poll(pfds, nfds, timeoutMs);
if (rc < 0)
{
if (errno == EINTR) continue; // restart the wait on a signal
return false;
}
if (rc == 0) return false; // timeout — caller decides if it's a dropout
break;
}
// Drain both fds: coalesced wake bytes mustn't cause spurious wakes on
// later waits — the caller re-reads the ring index for the real state. A
// peer hangup (POLLHUP) reads as EOF (0) and falls through as a non-data
// wake; the caller's index recheck then returns no data.
auto drain = [](int fd)
{
if (fd < 0) return;
unsigned char scratch[64];
for (;;)
{
const ssize_t n = ::read(fd, scratch, sizeof(scratch));
if (n > 0) continue;
if (n < 0 && errno == EINTR) continue;
break; // 0 (EOF/HUP) or EAGAIN/EWOULDBLOCK — drained
}
};
drain(evtFd);
drain(selfWake[0]);
return true;
}
void AudioChannel::Impl::wakeSelf()
{
// Break OUR OWN waitEvent (the worker's popInputBlock) without a block.
// Writes the self-pipe, NOT evtFd: a socketpair self-write would land on
// the peer (host), spuriously waking it and never waking us.
if (selfWake[1] < 0) return;
const unsigned char byte = 1;
// SIGPIPE-safe like signalEvent: the self-wake is a socketpair so a write
// after the read end closed surfaces as EPIPE, not a process-killing signal.
#ifdef MSG_NOSIGNAL
(void)::send(selfWake[1], &byte, 1, MSG_NOSIGNAL);
#else
(void)::send(selfWake[1], &byte, 1, 0); // SO_NOSIGPIPE set on the fd instead
#endif
}
} // namespace slopsmith::sandbox
+503
View File
@@ -0,0 +1,503 @@
// AudioChannel — platform-neutral lock-free ring logic.
//
// The shared-memory layout (Protocol.h / AudioShmHeader), the producer/
// consumer index discipline, and the inline-MIDI packing are identical on
// every OS. Only the shared-memory create/open/close and the doorbell
// signal/wait differ, and those live behind AudioChannel::Impl::signalEvent /
// waitEvent + the create/open/close trio in AudioChannel_{win,posix}.cpp.
//
// Threading: see AudioChannel.h. The release-store on a write index pairs with
// the consumer's acquire-load of the same index, which is what makes the plain
// memcpy/memset slot writes visible to the consumer — release/acquire on the
// shared atomic establishes happens-before regardless of architecture
// (x86/x64 and arm64 alike), and across the process boundary because both
// sides map the same physical memory.
#include "AudioChannelImpl.h"
#include "../VSTTrace.h"
#include <atomic>
#include <cstring>
namespace slopsmith::sandbox {
AudioChannel::AudioChannel() : impl(std::make_unique<Impl>()) {}
AudioChannel::~AudioChannel() { close(); }
// Atomic access to the plain uint64_t/uint32_t shm header indices (written by
// one side, read by the other across the process boundary). We avoid UB from
// reinterpret_cast'ing the storage to std::atomic<T>* (not layout-guaranteed)
// in one of two ways:
//
// * std::atomic_ref<T> (C++20, P0019) where the library provides it — MSVC,
// libstdc++ 11+, libc++ 19+ (Apple clang / Xcode 16+).
// * the gcc/clang __atomic builtins otherwise — notably older Apple libc++
// (pre-Xcode-16 macOS runners) which lacks std::atomic_ref. Same lock-free
// acquire/release semantics on the caller-aligned object. This branch uses
// gcc/clang-only builtins; it is never compiled on MSVC, which always has
// std::atomic_ref. (SLOPSMITH_FORCE_ATOMIC_BUILTINS forces it for testing.)
#if defined(__cpp_lib_atomic_ref) && ! defined(SLOPSMITH_FORCE_ATOMIC_BUILTINS)
// `atomic_ref<T>::required_alignment` may exceed `alignof(T)`; header fields
// are `alignas(8)`. Fail at compile time if a platform needs more, rather than
// constructing an atomic_ref on an under-aligned object (UB).
static_assert(std::atomic_ref<uint64_t>::required_alignment <= 8,
"AudioShmHeader uint64_t fields are alignas(8); bump the "
"alignas to std::atomic_ref<uint64_t>::required_alignment");
template <typename T>
static std::atomic_ref<T> atomicRefOf(T& slot) { return std::atomic_ref<T>(slot); }
#else
// std::memory_order's enumerator values match the __ATOMIC_* constants on
// gcc/clang, so the cast is exact.
template <typename T>
struct BuiltinAtomicRef
{
T* p;
explicit BuiltinAtomicRef(T& r) : p(&r) {}
T load(std::memory_order o) const { return __atomic_load_n(p, static_cast<int>(o)); }
void store(T v, std::memory_order o) { __atomic_store_n(p, v, static_cast<int>(o)); }
T fetch_add(T v, std::memory_order o) { return __atomic_fetch_add(p, v, static_cast<int>(o)); }
};
template <typename T>
static BuiltinAtomicRef<T> atomicRefOf(T& slot) { return BuiltinAtomicRef<T>(slot); }
#endif
static auto atomicAt(uint64_t& slot) { return atomicRefOf(slot); }
static auto atomicAt32(uint32_t& slot) { return atomicRefOf(slot); }
namespace
{
// Pick the right (write, read) index pair for a direction. Input ring
// (host → sandbox) is produced by host / consumed by sandbox; output
// ring (sandbox → host) is the inverse.
struct RingIndices { uint64_t& write; uint64_t& read; };
RingIndices indicesFor(AudioShmHeader& h, bool isOutputRing)
{
return isOutputRing
? RingIndices{ h.outWriteIdx, h.outReadIdx }
: RingIndices{ h.inWriteIdx, h.inReadIdx };
}
}
bool AudioChannel::pushBlock(bool isOutputRing, const juce::AudioBuffer<float>& src,
int numSamples)
{
// Input-direction pushes MUST go through pushInputBlock (which publishes
// the slot's MidiQueue alongside the audio). Calling pushBlock(false,…)
// directly would leave whatever MIDI count was in the slot from a prior
// pushInputBlock and the next popInputBlock would replay those stale
// events against fresh audio.
//
// jassert in debug + return false in release: a release-build regression
// would otherwise silently corrupt MIDI delivery rather than failing
// loudly. Today the only input producer is
// SandboxedProcessor::processBlock and it always calls pushInputBlock.
jassert(isOutputRing);
if (!isOutputRing) return false;
if (!impl->header) return false;
auto idx = indicesFor(*impl->header, isOutputRing);
auto writeIdx = atomicAt(idx.write);
auto readIdx = atomicAt(idx.read);
uint64_t w = writeIdx.load(std::memory_order_relaxed);
uint64_t r = readIdx.load(std::memory_order_acquire);
if (w - r >= impl->header->maxBlocks)
{
atomicAt(impl->header->xruns).fetch_add(1, std::memory_order_relaxed);
return false;
}
auto slot = w % impl->header->maxBlocks;
auto bytesPerSlot = impl->header->ringBytesPerSlot;
auto* dst = (isOutputRing ? impl->outputRing : impl->inputRing)
+ slot * (bytesPerSlot / sizeof(float));
const int maxCh = (int)impl->header->maxChannels;
const int maxSamples = (int)impl->header->maxBlockSamples;
const int channels = juce::jmin(maxCh, src.getNumChannels());
const int samples = juce::jmin(maxSamples, numSamples);
for (int ch = 0; ch < channels; ++ch)
{
auto* slotCh = dst + ch * maxSamples;
std::memcpy(slotCh, src.getReadPointer(ch),
sizeof(float) * (size_t)samples);
// Wipe tail samples so a shorter block doesn't leave audio from a
// previous slot-overwrite hanging around for the consumer.
if (samples < maxSamples)
std::memset(slotCh + samples, 0,
sizeof(float) * (size_t)(maxSamples - samples));
}
// Wipe channels the producer didn't write at all — same rationale.
for (int ch = channels; ch < maxCh; ++ch)
std::memset(dst + ch * maxSamples, 0,
sizeof(float) * (size_t)maxSamples);
// The release store on writeIdx pairs with the consumer's acquire load in
// popBlock, so the slot memcpy/memset above are happens-before any read of
// writeIdx >= w+1 — on every supported architecture (the pairing, not the
// hardware, is what guarantees it). signalEvent is only a wakeup; the
// actual data handoff is the index + shm.
writeIdx.store(w + 1, std::memory_order_release);
impl->signalEvent(isOutputRing);
return true;
}
bool AudioChannel::popBlock(bool isOutputRing, juce::AudioBuffer<float>& dst,
int numSamples, int timeoutMs)
{
if (!impl->header) return false;
auto idx = indicesFor(*impl->header, isOutputRing);
auto writeIdx = atomicAt(idx.write);
auto readIdx = atomicAt(idx.read);
// Check indices BEFORE waiting. The doorbell is not counting: if the
// producer signals twice in a row (queue 2 blocks), the wakes collapse
// (Win32 auto-reset event) or are drained together (POSIX socketpair).
// Without this fast path, the consumer would block on the second pop even
// though w > r already.
uint64_t r = readIdx.load(std::memory_order_relaxed);
uint64_t w = writeIdx.load(std::memory_order_acquire);
if (w == r)
{
if (!impl->waitEvent(isOutputRing, timeoutMs))
{
atomicAt(impl->header->dropouts).fetch_add(1, std::memory_order_relaxed);
return false;
}
// Re-read; the wake might have been from teardown, an
// AudioPauseGuard's signalSandboxWake (every kPrepare /
// kSetBlockSize / kGetState / kSetState), or a kShutdown /
// disconnect callback. NONE of those are dropouts — they're
// intentional non-data wakes. Don't bump `dropouts` here or the
// counter pollutes every pause-guarded control op. Real
// dropouts are still counted on the waitEvent timeout path above
// and at the SandboxedProcessor pop-timeout call site.
r = readIdx.load(std::memory_order_relaxed);
w = writeIdx.load(std::memory_order_acquire);
if (w == r) return false;
}
auto slot = r % impl->header->maxBlocks;
auto bytesPerSlot = impl->header->ringBytesPerSlot;
auto* src = (isOutputRing ? impl->outputRing : impl->inputRing)
+ slot * (bytesPerSlot / sizeof(float));
const int maxSamples = (int)impl->header->maxBlockSamples;
const int dstCh = dst.getNumChannels();
const int channels = juce::jmin((int)impl->header->maxChannels, dstCh);
const int samples = juce::jmin(maxSamples, numSamples);
if (numSamples > maxSamples)
{
// One-shot warn: caller passed more samples than the spawn-time cap
// allows, so we'll truncate to maxSamples and zero-fill the tail.
// Producer-side push paths apply the same clamp, so this fires only
// if a misconfigured consumer asks for too much (kPrepare /
// kSetBlockSize spawn-cap validation should have prevented it).
static std::atomic<bool> warned{false};
bool expected = false;
if (warned.compare_exchange_strong(expected, true,
std::memory_order_acq_rel))
{
VST_TRACE("[audio-shm] popBlock: caller numSamples=%d > spawn cap "
"maxBlockSamples=%d — truncating, tail zeroed",
numSamples, maxSamples);
}
}
for (int ch = 0; ch < channels; ++ch)
{
std::memcpy(dst.getWritePointer(ch),
src + ch * maxSamples,
sizeof(float) * (size_t)samples);
// Zero any portion of dst beyond what we copied (the caller's buffer
// may be longer than the producer's payload).
if (samples < numSamples)
std::memset(dst.getWritePointer(ch) + samples, 0,
sizeof(float) * (size_t)(numSamples - samples));
}
// Zero channels the producer didn't fill so dst doesn't carry stale audio.
for (int ch = channels; ch < dstCh; ++ch)
dst.clear(ch, 0, numSamples);
readIdx.store(r + 1, std::memory_order_release);
return true;
}
bool AudioChannel::pushInputBlock(const juce::AudioBuffer<float>& src,
const juce::MidiBuffer& midi,
int numSamples)
{
// Inlined audio + MIDI publish so the slot's MidiQueue is published
// alongside the audio under the same inWriteIdx release. Earlier this
// method delegated to pushBlock(false, ...) for the audio half, but
// pushBlock used to clobber `count` to 0 between our MIDI publish and
// the inWriteIdx bump — every MIDI event was being dropped.
if (!impl->header || !impl->midiQueues) return false;
// Reject up front when the caller exceeds the spawn-time cap rather
// than silently truncating audio + dropping MIDI in [maxSamples,
// numSamples) into midiOverflows. Spawn-cap validation in kPrepare /
// kSetBlockSize should prevent this; if it ever fires the caller
// gets a `false` return — that's the diagnostic. Don't bump dropouts
// (it means "real audio dropout / missed deadline") or xruns (means
// "destination ring was full"); caller misuse is its own class and
// conflating them muddles operator-facing metrics.
//
// jassert in debug + return false in release — same fail-fast pattern
// as pushBlock(isOutputRing). Today the only producer
// (SandboxedProcessor::processBlock) bounds numSamples to JUCE's
// negotiated block size, so this branch is unreachable in practice;
// the assert flags any future caller that introduces a path where it
// becomes reachable.
jassert(numSamples <= (int)impl->header->maxBlockSamples);
if (numSamples > (int)impl->header->maxBlockSamples)
return false;
auto writeIdx = atomicAt(impl->header->inWriteIdx);
auto readIdx = atomicAt(impl->header->inReadIdx);
uint64_t w = writeIdx.load(std::memory_order_relaxed);
uint64_t r = readIdx.load(std::memory_order_acquire);
if (w - r >= impl->header->maxBlocks)
{
atomicAt(impl->header->xruns).fetch_add(1, std::memory_order_relaxed);
return false;
}
const auto slot = w % impl->header->maxBlocks;
auto& queue = impl->midiQueues[slot];
// Compute the truncated sample count up front so the MIDI loop below
// can clamp event frames against the SAME bound the audio copy uses.
// If the caller passed numSamples > maxSamples, both halves truncate
// to maxSamples consistently — otherwise the sandbox would receive
// MIDI frames pointing past the end of the audio it actually got.
const int maxCh = (int)impl->header->maxChannels;
const int maxSamples = (int)impl->header->maxBlockSamples;
const int channels = juce::jmin(maxCh, src.getNumChannels());
const int samples = juce::jmin(maxSamples, numSamples);
// 1. Pack MIDI into the slot's queue. The slot is owned by the host
// until we publish the new inWriteIdx below, so writes here are
// private — no need for the relaxed-clear-then-release-store on
// `count`, the release on inWriteIdx publishes both `count` and
// `events[]` together. Using atomic_ref for the count store anyway
// so the layout stays consistent for the sandbox-side acquire load.
uint32_t written = 0;
auto bumpMidiOverflow = [&](uint64_t n = 1)
{
// Global cumulative counter — per-slot was confusing because slots
// round-robin (the per-slot value would mix counts from many
// different blocks rather than answering "did THIS block
// overflow?"). Per-event accuracy past the cap is not a documented
// contract — the bulk-bump on cap-overflow keeps the audio thread
// from iterating arbitrarily many events on a real-time path.
atomicAt(impl->header->midiOverflows).fetch_add(n, std::memory_order_relaxed);
};
int scanned = 0;
const int totalEvents = midi.getNumEvents();
// Hard cap on iterations regardless of accept/reject ratio. The
// cap-overflow break below bounds the loop when events are valid-and-
// fit (written climbs to kMidiEventsPerSlot quickly), but a flood of
// pure SysEx would never increment `written` and could otherwise
// iterate the entire buffer one event at a time on the RT thread.
// 2× kMidiEventsPerSlot leaves headroom for normal mixed-in
// SysEx-among-CCs blocks while still bounding the worst case.
constexpr int kMaxScanIterations = 2 * (int)kMidiEventsPerSlot;
for (const auto meta : midi)
{
if (scanned >= kMaxScanIterations)
{
// Hit the per-block scan cap. Bulk-bump remaining and break;
// per-event accuracy past the cap is not a documented
// contract, the bound matters more on the audio thread.
bumpMidiOverflow((uint64_t)(totalEvents - scanned));
break;
}
++scanned;
const auto& msg = meta.getMessage();
const int rawSize = msg.getRawDataSize();
if (rawSize <= 0 || rawSize > (int)kMidiEventMaxBytes)
{
// Doesn't fit (SysEx etc.). Audio thread never blocks; the
// lossy policy is documented in PR #2.
bumpMidiOverflow();
continue;
}
if (written >= kMidiEventsPerSlot)
{
// Bulk-bump for THIS event + every remaining event the
// iterator would visit, then break. Together with the
// scan-cap above, total audio-thread MIDI work is bounded
// at 2× kMidiEventsPerSlot iterations regardless of how
// bloated or pathological the inbound buffer is.
bumpMidiOverflow((uint64_t)(totalEvents - scanned + 1));
break;
}
// Reject events whose frame is past the truncated audio (samples
// ≤ numSamples — see the comment on the maxSamples computation
// above). Clamping would silently re-time the event into the
// audible portion, which is a worse failure mode than dropping it.
// samplePosition < 0 is an invalid input; treat it as out-of-range
// and drop too.
if (meta.samplePosition < 0 || meta.samplePosition >= samples)
{
bumpMidiOverflow();
continue;
}
auto& ev = queue.events[written];
ev.frame = (uint32_t)meta.samplePosition;
ev.size = (uint32_t)rawSize;
std::memcpy(ev.bytes, msg.getRawData(), (size_t)rawSize);
++written;
}
// Relaxed: the inWriteIdx release-store below synchronises this write
// with the sandbox's acquire-load of inWriteIdx in popInputBlock, so
// when the consumer observes the new write index it also observes
// count + events[].
atomicAt32(queue.count).store(written, std::memory_order_relaxed);
// 2. Copy audio into the same slot of the input ring.
auto bytesPerSlot = impl->header->ringBytesPerSlot;
auto* dst = impl->inputRing + slot * (bytesPerSlot / sizeof(float));
for (int ch = 0; ch < channels; ++ch)
{
auto* slotCh = dst + ch * maxSamples;
std::memcpy(slotCh, src.getReadPointer(ch),
sizeof(float) * (size_t)samples);
if (samples < maxSamples)
std::memset(slotCh + samples, 0,
sizeof(float) * (size_t)(maxSamples - samples));
}
for (int ch = channels; ch < maxCh; ++ch)
std::memset(dst + ch * maxSamples, 0,
sizeof(float) * (size_t)maxSamples);
// 3. Publish the slot — release-synchronises with the consumer's acquire
// on inWriteIdx in popInputBlock, which makes both the audio bytes
// and the MIDI queue visible together.
writeIdx.store(w + 1, std::memory_order_release);
impl->signalEvent(/*isOutputRing*/ false);
return true;
}
bool AudioChannel::popInputBlock(juce::AudioBuffer<float>& dst,
juce::MidiBuffer& midi,
int numSamples, int timeoutMs)
{
// Inlined audio + MIDI drain so we hold the slot until both are read.
// Earlier this method delegated to popBlock(false, ...), which advanced
// inReadIdx before the MIDI was drained — the host could then immediately
// reuse the slot and overwrite the queue we were still reading.
if (!impl->header || !impl->midiQueues) return false;
// Symmetric with pushInputBlock: reject up front when the caller
// exceeds the spawn-time cap, so a misconfigured consumer learns
// about the misuse via the false return rather than getting silently
// truncated audio. Don't advance inReadIdx — the producer's slot
// stays full until the consumer corrects its numSamples (or the host
// tears down). Don't bump dropouts — caller misuse is its own class;
// see the matching comment in pushInputBlock.
//
// jassert + return false: today's only consumer (runAudioThread
// calls with currentBlockSize = jlimit(1, bufferCap, ...)) makes
// this branch unreachable; the assert flags any future caller that
// changes that.
jassert(numSamples <= (int)impl->header->maxBlockSamples);
if (numSamples > (int)impl->header->maxBlockSamples)
return false;
auto writeIdx = atomicAt(impl->header->inWriteIdx);
auto readIdx = atomicAt(impl->header->inReadIdx);
// Same fast-path / wait / recheck pattern as popBlock: the doorbell
// collapses/coalesces signals, so if pushInputBlock fires twice in a row
// we'd otherwise block on the second pop even though w > r.
uint64_t r = readIdx.load(std::memory_order_relaxed);
uint64_t w = writeIdx.load(std::memory_order_acquire);
if (w == r)
{
if (!impl->waitEvent(/*isOutputRing*/ false, timeoutMs))
{
atomicAt(impl->header->dropouts).fetch_add(1, std::memory_order_relaxed);
return false;
}
r = readIdx.load(std::memory_order_relaxed);
w = writeIdx.load(std::memory_order_acquire);
// Intentional non-data wake (AudioPauseGuard signalSandboxWake on
// every pause-guarded control op, kShutdown, disconnect). Same
// rationale as popBlock: don't count these as dropouts. Real
// missed-deadline events are caught by the timeout branch above
// and by SandboxedProcessor's pop-timeout call site.
if (w == r) return false;
}
const auto slot = r % impl->header->maxBlocks;
// 1. Drain MIDI from the slot. Relaxed-load on `count` is sufficient:
// the synchronisation that publishes count + events[] is the
// acquire-load on inWriteIdx above (paired with the producer's
// release-store on inWriteIdx in pushInputBlock), and the producer
// writes count itself with relaxed semantics. The acquire here
// would be redundant overhead and slightly misleading about the
// actual sync model.
auto& queue = impl->midiQueues[slot];
const uint32_t count = atomicAt32(queue.count)
.load(std::memory_order_relaxed);
const uint32_t safeCount = juce::jmin(count, kMidiEventsPerSlot);
for (uint32_t i = 0; i < safeCount; ++i)
{
const auto& ev = queue.events[i];
const uint32_t size = juce::jmin(ev.size, kMidiEventMaxBytes);
if (size == 0) continue;
midi.addEvent(juce::MidiMessage(ev.bytes, (int)size),
(int)ev.frame);
}
// 2. Copy audio out of the slot.
auto bytesPerSlot = impl->header->ringBytesPerSlot;
auto* src = impl->inputRing + slot * (bytesPerSlot / sizeof(float));
// numSamples ≤ maxSamples here (cap enforced by the early-return guard
// at the top of this function), so samples == numSamples and no
// tail-zero / one-shot warn is needed — both belonged to the old
// truncate-and-continue path.
const int maxSamples = (int)impl->header->maxBlockSamples;
const int dstCh = dst.getNumChannels();
const int channels = juce::jmin((int)impl->header->maxChannels, dstCh);
for (int ch = 0; ch < channels; ++ch)
std::memcpy(dst.getWritePointer(ch),
src + ch * maxSamples,
sizeof(float) * (size_t)numSamples);
for (int ch = channels; ch < dstCh; ++ch)
dst.clear(ch, 0, numSamples);
// 3. Release the slot — the host can now reuse it; we've finished both
// audio and MIDI reads.
readIdx.store(r + 1, std::memory_order_release);
return true;
}
uint64_t AudioChannel::diagMidiOverflows() const noexcept
{
if (!impl->header) return 0;
return atomicAt(impl->header->midiOverflows).load(std::memory_order_relaxed);
}
uint64_t AudioChannel::diagXruns() const noexcept
{
if (!impl->header) return 0;
return atomicAt(impl->header->xruns).load(std::memory_order_relaxed);
}
uint64_t AudioChannel::diagDropouts() const noexcept
{
if (!impl->header) return 0;
return atomicAt(impl->header->dropouts).load(std::memory_order_relaxed);
}
void AudioChannel::signalSandboxWake()
{
// Wake the sandbox audio worker out of its popInputBlock wait without
// publishing a real block — the input-ring doorbell. Used by the
// sandbox-side AudioPauseGuard around non-realtime control ops + shutdown.
// This must wake OUR OWN worker (same process), so it goes through wakeSelf
// (POSIX self-pipe / Win32 evtToSandbox) — NOT signalEvent, whose POSIX
// socketpair write would hit the peer instead and spuriously wake it.
impl->wakeSelf();
}
} // namespace slopsmith::sandbox
+294
View File
@@ -0,0 +1,294 @@
// AudioChannel — Windows backend: named file-mapping shm + a pair of named
// auto-reset events. The ring algorithm lives in AudioChannel_shared.cpp; this
// file implements only create/open/close and the doorbell (signalEvent /
// waitEvent map to SetEvent / WaitForSingleObject).
#include "AudioChannelImpl.h"
#include "../VSTTrace.h"
#if ! JUCE_WINDOWS
#error "AudioChannel_win.cpp is Windows-only; POSIX builds use AudioChannel_posix.cpp."
#endif
#include <cstring>
namespace slopsmith::sandbox {
static juce::String makeUniqueName(const char* suffix)
{
return "Local\\slopsmith-vst-" + juce::Uuid().toDashedString() + "-" + suffix;
}
bool AudioChannel::createHostSide(const AudioDimensions& dims, Names& namesOut,
juce::String& errorOut)
{
namesOut.shm = makeUniqueName(kShmNameSuffix);
namesOut.evtToHost = makeUniqueName(kEvtToHostSuffix);
namesOut.evtToSandbox = makeUniqueName(kEvtToSandboxSuffix);
auto totalBytes = dims.totalShmBytes();
impl->mapping = CreateFileMappingW(
INVALID_HANDLE_VALUE, nullptr, PAGE_READWRITE,
(DWORD)(totalBytes >> 32), (DWORD)(totalBytes & 0xFFFFFFFFu),
namesOut.shm.toWideCharPointer());
if (impl->mapping == nullptr)
{
errorOut = "CreateFileMapping failed: " + juce::String((int)GetLastError());
close();
return false;
}
impl->view = MapViewOfFile(impl->mapping, FILE_MAP_ALL_ACCESS, 0, 0, totalBytes);
if (!impl->view)
{
errorOut = "MapViewOfFile failed";
close();
return false;
}
impl->header = reinterpret_cast<AudioShmHeader*>(impl->view);
// Initialise the header on the host side.
impl->header->magic = kAudioShmMagic;
impl->header->protocolVersion = kProtocolVersion;
impl->header->maxBlocks = dims.maxBlocks;
impl->header->maxBlockSamples = dims.maxBlockSamples;
impl->header->maxChannels = dims.maxChannels;
impl->header->sampleRate = dims.sampleRate;
impl->header->inWriteIdx = 0;
impl->header->inReadIdx = 0;
impl->header->outWriteIdx = 0;
impl->header->outReadIdx = 0;
impl->header->xruns = 0;
impl->header->dropouts = 0;
impl->header->midiOverflows = 0;
impl->header->ringBytesPerSlot = dims.bytesPerSlot();
impl->header->inputRingOffset = sizeof(AudioShmHeader);
impl->header->outputRingOffset = impl->header->inputRingOffset
+ uint64_t(dims.maxBlocks) * dims.bytesPerSlot();
impl->header->midiQueueOffset = impl->header->outputRingOffset
+ uint64_t(dims.maxBlocks) * dims.bytesPerSlot();
// Release fence so all the header writes above are visible before the
// sandbox observes the mapping. CreateProcessW (the publish point on
// the host side) is a strong synchronisation primitive on Windows, so
// in practice the writes are already flushed before the child starts —
// but the fence makes the spawn-order invariant documented in
// openSandboxSide explicit at the producer rather than relying on the
// implicit semantics of the spawn call.
std::atomic_thread_fence(std::memory_order_release);
auto* base = reinterpret_cast<char*>(impl->view);
impl->inputRing = reinterpret_cast<float*>(base + impl->header->inputRingOffset);
impl->outputRing = reinterpret_cast<float*>(base + impl->header->outputRingOffset);
impl->midiQueues = reinterpret_cast<MidiQueue*>(base + impl->header->midiQueueOffset);
// Zero-initialise the per-slot MidiQueues so a producer's first publish
// doesn't have to clear count/overflow bookkeeping.
std::memset(impl->midiQueues, 0,
sizeof(MidiQueue) * (size_t)dims.maxBlocks);
impl->evtToHost = CreateEventW(
nullptr, /*manualReset*/FALSE, /*initial*/FALSE,
namesOut.evtToHost.toWideCharPointer());
impl->evtToSandbox = CreateEventW(
nullptr, FALSE, FALSE,
namesOut.evtToSandbox.toWideCharPointer());
if (!impl->evtToHost || !impl->evtToSandbox)
{
errorOut = "CreateEvent failed";
close();
return false;
}
cachedDims = dims;
return true;
}
bool AudioChannel::openSandboxSide(const Names& names, juce::String& errorOut)
{
impl->mapping = OpenFileMappingW(FILE_MAP_ALL_ACCESS, FALSE,
names.shm.toWideCharPointer());
if (!impl->mapping)
{
errorOut = "OpenFileMapping failed: " + juce::String((int)GetLastError());
close();
return false;
}
impl->view = MapViewOfFile(impl->mapping, FILE_MAP_ALL_ACCESS, 0, 0, 0);
if (!impl->view)
{
errorOut = "MapViewOfFile (sandbox) failed";
close();
return false;
}
// Before the magic check (which reads header->magic), verify the mapping
// is at least sizeof(AudioShmHeader). `MapViewOfFile(...,0)` maps the
// whole object, but if a corrupted/malicious named-mapping pointed at a
// smaller object the magic check itself would be an OOB read. The
// expectedTotal bounds check below uses header-derived fields and so
// cannot detect an undersized real mapping — this is the only place we
// can close that gap.
{
MEMORY_BASIC_INFORMATION mbi{};
if (VirtualQuery(impl->view, &mbi, sizeof(mbi)) == 0
|| mbi.RegionSize < sizeof(AudioShmHeader))
{
errorOut = "audio shm mapping too small for header ("
+ juce::String((int64_t)mbi.RegionSize) + " < "
+ juce::String((int64_t)sizeof(AudioShmHeader)) + ")";
close();
return false;
}
}
impl->header = reinterpret_cast<AudioShmHeader*>(impl->view);
if (impl->header->magic != kAudioShmMagic)
{
errorOut = "audio shm magic mismatch";
close();
return false;
}
if (impl->header->protocolVersion != kProtocolVersion)
{
errorOut = "audio shm protocol mismatch: expected "
+ juce::String((int)kProtocolVersion) + ", got "
+ juce::String((int)impl->header->protocolVersion);
close();
return false;
}
// Validate dims against compile-time caps BEFORE computing bytesPerSlot()
// and expectedTotal. Without this, pathological header values (corrupted
// or malicious mapping that passed magic+protocolVersion) can overflow
// uint64_t in `maxBlockSamples * maxChannels * 4` or
// `2 * maxBlocks * ringBytesPerSlot`, defeating the inEnd/outEnd bounds
// check below and pointing inputRing/outputRing past the actual mapping.
if (impl->header->maxBlocks == 0 || impl->header->maxBlocks > kAudioMaxBlocks
|| impl->header->maxBlockSamples == 0
|| impl->header->maxBlockSamples > kAudioMaxBlockSamples
|| impl->header->maxChannels == 0
|| impl->header->maxChannels > kAudioMaxChannels)
{
errorOut = "audio shm dims exceed protocol caps: blocks="
+ juce::String((int64_t)impl->header->maxBlocks)
+ " blockSamples=" + juce::String((int64_t)impl->header->maxBlockSamples)
+ " channels=" + juce::String((int64_t)impl->header->maxChannels);
close();
return false;
}
cachedDims.maxBlocks = impl->header->maxBlocks;
cachedDims.maxBlockSamples = impl->header->maxBlockSamples;
cachedDims.maxChannels = impl->header->maxChannels;
cachedDims.sampleRate = impl->header->sampleRate;
// Cross-check ringBytesPerSlot against the dims the host published. A
// mismatch here would silently produce misaligned ring access — the
// protocol version check upstream already guarantees host/sandbox
// agree on the layout, this just makes the contract local.
const uint64_t expectedSlotBytes = cachedDims.bytesPerSlot();
if (impl->header->ringBytesPerSlot != expectedSlotBytes)
{
errorOut = "audio shm ringBytesPerSlot mismatch: expected "
+ juce::String((int64_t)expectedSlotBytes) + ", got "
+ juce::String((int64_t)impl->header->ringBytesPerSlot);
close();
return false;
}
// Spawn-order invariant: the host fully initialises the shared header
// BEFORE calling CreateProcessW, so by the time the sandbox observes the
// mapping the header is fully populated.
const uint64_t expectedTotal = sizeof(AudioShmHeader)
+ 2 * uint64_t(impl->header->maxBlocks) * impl->header->ringBytesPerSlot
+ uint64_t(impl->header->maxBlocks) * sizeof(MidiQueue);
const uint64_t inEnd = impl->header->inputRingOffset
+ uint64_t(impl->header->maxBlocks) * impl->header->ringBytesPerSlot;
const uint64_t outEnd = impl->header->outputRingOffset
+ uint64_t(impl->header->maxBlocks) * impl->header->ringBytesPerSlot;
const uint64_t midiEnd = impl->header->midiQueueOffset
+ uint64_t(impl->header->maxBlocks) * sizeof(MidiQueue);
// Bounds + ordering check: each region must fit within the mapping and
// not overlap. The canonical layout is: [header][input][output][midi].
if (inEnd > expectedTotal
|| outEnd > expectedTotal
|| midiEnd > expectedTotal
|| impl->header->inputRingOffset < sizeof(AudioShmHeader)
|| impl->header->outputRingOffset < inEnd
|| impl->header->midiQueueOffset < outEnd)
{
errorOut = "audio shm ring/MIDI offsets out of bounds or overlapping";
close();
return false;
}
// Verify the actual mapped region is at least expectedTotal. The earlier
// VirtualQuery covered just sizeof(AudioShmHeader).
{
MEMORY_BASIC_INFORMATION mbi2{};
if (VirtualQuery(impl->view, &mbi2, sizeof(mbi2)) == 0
|| mbi2.RegionSize < expectedTotal)
{
errorOut = "audio shm mapping too small for ring layout: region="
+ juce::String((int64_t)mbi2.RegionSize) + " expected>="
+ juce::String((int64_t)expectedTotal);
close();
return false;
}
}
auto* base = reinterpret_cast<char*>(impl->view);
impl->inputRing = reinterpret_cast<float*>(base + impl->header->inputRingOffset);
impl->outputRing = reinterpret_cast<float*>(base + impl->header->outputRingOffset);
impl->midiQueues = reinterpret_cast<MidiQueue*>(base + impl->header->midiQueueOffset);
impl->evtToHost = OpenEventW(EVENT_ALL_ACCESS, FALSE,
names.evtToHost.toWideCharPointer());
impl->evtToSandbox = OpenEventW(EVENT_ALL_ACCESS, FALSE,
names.evtToSandbox.toWideCharPointer());
if (!impl->evtToHost || !impl->evtToSandbox)
{
errorOut = "OpenEvent failed: " + juce::String((int)GetLastError());
close();
return false;
}
return true;
}
void AudioChannel::close()
{
if (impl->evtToHost) { CloseHandle(impl->evtToHost); impl->evtToHost = nullptr; }
if (impl->evtToSandbox) { CloseHandle(impl->evtToSandbox); impl->evtToSandbox = nullptr; }
if (impl->view) { UnmapViewOfFile(impl->view); impl->view = nullptr; }
if (impl->mapping) { CloseHandle(impl->mapping); impl->mapping = nullptr; }
impl->header = nullptr;
impl->inputRing = nullptr;
impl->outputRing = nullptr;
impl->midiQueues = nullptr;
}
void AudioChannel::Impl::signalEvent(bool isOutputRing)
{
// The `if (handle)` guard is non-atomic, so a concurrent close() racing
// this call could in principle observe a freed handle. Today's call paths
// (the audio producer push + AudioPauseGuard ctor's signalSandboxWake +
// dispatchRequest's kShutdown / disconnect callback) are serialised
// against close() by audioThread.join() + control.stop() on the WinMain
// thread before close() runs. A future caller outside those teardown
// invariants needs an atomic<HANDLE> swapped to nullptr by close().
HANDLE evt = isOutputRing ? evtToHost : evtToSandbox;
if (evt) SetEvent(evt);
}
bool AudioChannel::Impl::waitEvent(bool isOutputRing, int timeoutMs)
{
HANDLE evt = isOutputRing ? evtToHost : evtToSandbox;
if (!evt) return false;
return WaitForSingleObject(evt, (DWORD)timeoutMs) == WAIT_OBJECT_0;
}
void AudioChannel::Impl::wakeSelf()
{
// The sandbox worker waits on evtToSandbox (the input doorbell). A Win32
// auto-reset event self-signals fine — SetEvent from this process's
// control thread wakes this process's worker, and the host (which waits on
// the separate evtToHost) is unaffected. (POSIX needs a dedicated self-pipe
// instead; see AudioChannel_posix.cpp.)
if (evtToSandbox) SetEvent(evtToSandbox);
}
} // namespace slopsmith::sandbox
+163
View File
@@ -0,0 +1,163 @@
// ControlChannel — request/response + event messaging between the host
// (Slopsmith Desktop) and a sandbox subprocess.
//
// Transport: Windows named pipe in byte mode (PIPE_TYPE_BYTE |
// PIPE_READMODE_BYTE) with an explicit `[u32 length-LE][body]` framing
// layer. PIPE_TYPE_MESSAGE was tried first but dropped because the
// sandbox's `ready` frame wasn't being delivered to the host I/O thread
// reliably — see commit 2cb9ae9. Posix transport TBD when the macOS /
// Linux sandbox PRs land.
//
// Threading model: one I/O thread inside the channel reads frames and dispatches
// them to the event callback or to the matching pending request future. Callers
// invoke `request()` from arbitrary threads.
#pragma once
#include <juce_core/juce_core.h>
#include <atomic>
#include <chrono>
#include <functional>
#include <future>
#include <memory>
#include <mutex>
#include <thread>
#include <unordered_map>
#include "Protocol.h"
namespace slopsmith::sandbox {
class ControlChannel
{
public:
// Async callback fired for each sandbox-originated event. Invoked from the
// channel's internal I/O thread; the callback must not block.
using EventCallback = std::function<void(const juce::String& event,
const juce::var& data)>;
// Sentinel reason strings passed to the disconnect callback.
static const juce::String kReasonPeerClosed;
static const juce::String kReasonReadError;
static const juce::String kReasonProtocolError;
ControlChannel();
~ControlChannel();
// Host-side: create a uniquely-named pipe in CONNECT (server) mode and
// return the pipe name. The sandbox subprocess will connect to it shortly
// after spawn.
bool createServerSide(juce::String& pipeNameOut, juce::String& errorOut);
// Sandbox-side: connect to a pipe created by the host. Windows only —
// the named pipe is re-opened by name. POSIX uses connectClientSideFd
// (the control transport is a fd-passed socketpair, not a named object).
bool connectClientSide(const juce::String& pipeName, juce::String& errorOut);
#if ! JUCE_WINDOWS
// POSIX sandbox-side connect: adopt the inherited socketpair end (the fd
// the spawner dup2()'d into us, or — in an in-process loopback —
// server.sandboxFd()). Takes ownership of `fd`. The Windows
// connectClientSide(name) overload is unavailable on POSIX.
bool connectClientSideFd(int fd, juce::String& errorOut);
// POSIX host-side: the sandbox's end of the socketpair created by
// createServerSide. The caller owns it — dup2() it into the child (then
// close the copy) or hand it to an in-process connectClientSideFd.
// Returns -1 before createServerSide or on a non-server channel.
int sandboxFd() const noexcept;
// POSIX host-side: close our copy of the sandbox's socketpair end after a
// real spawn (the spawner has dup2()'d it into the child). REQUIRED — if
// the host keeps this open, the host end never observes EOF when the child
// dies, so crash detection never fires. No-op for the in-process loopback
// (connectClientSideFd already adopted the fd).
void closeSandboxFd() noexcept;
#endif
// Start the background I/O thread. Must be called after either
// createServerSide() or connectClientSide(). Returns false on error;
// callers can read getLastStartError() for a diagnostic string.
bool start(EventCallback onEvent,
std::function<void(const juce::String& reason)> onDisconnect);
// Diagnostic reason for the most recent start() failure (re-start
// attempted, no pipe, CreateEventW failure). Empty if start succeeded
// or has not been called.
juce::String getLastStartError() const { return lastStartError; }
void stop();
bool isAlive() const noexcept { return alive.load(std::memory_order_acquire); }
// Synchronous request/response. Returns the parsed result `juce::var`, or
// an undefined `var` on timeout/error (with the reason in `errorOut`).
juce::var request(const char* op, const juce::var& args,
int timeoutMs, juce::String* errorOut = nullptr);
// Fire-and-forget: no reply expected. Used for high-frequency messages
// like MIDI events and parameter automation.
bool postNoReply(const char* op, const juce::var& args);
// Sandbox-side helpers: send a reply to the host's request, or originate
// an event.
bool sendReply(int requestId, bool ok, const juce::var& result,
const juce::String& errorMessage = {});
bool sendEvent(const char* eventName, const juce::var& data);
// Sandbox-side: when the channel parses an inbound request, the consumer
// installs a request handler. MUST be called BEFORE start() — the I/O
// thread reads `requestHandler` on every inbound request, and the
// member is intentionally not synchronised. Installing after start()
// races the read.
using RequestHandler =
std::function<void(int requestId, const juce::String& op,
const juce::var& args)>;
void setRequestHandler(RequestHandler handler);
private:
struct Pending
{
std::promise<juce::var> promise;
};
bool writeFrame(const juce::MemoryBlock& body);
bool readFrame(juce::MemoryBlock& out);
// Server side: block until the sandbox connects (Windows: ConnectNamedPipe;
// POSIX: socketpair is already connected, so this just races the stop
// signal). Returns false if stop() fired first; `failReason` is set for
// failWith. Platform-defined.
bool waitForPeer(juce::String& failReason);
void ioLoop();
void failWith(const juce::String& reason);
// Set by readFrame() before it returns false so ioLoop can classify the
// disconnect. `lastReadError` is the raw OS error (Win32 GetLastError /
// POSIX errno) for diagnostics; `lastReadPeerClosed` is the
// platform-agnostic verdict ioLoop acts on (a clean peer-side close /
// EOF → kReasonPeerClosed, anything else → kReasonReadError) so the shared
// dispatch loop never has to know per-OS error codes. Only the I/O thread
// writes or reads these fields.
unsigned long lastReadError = 0;
bool lastReadPeerClosed = false;
struct Impl;
std::unique_ptr<Impl> impl; // OS-specific handle wrapper
std::atomic<bool> alive{false};
std::atomic<int> nextRequestId{1};
EventCallback onEvent;
std::function<void(const juce::String& reason)> onDisconnect;
RequestHandler requestHandler;
std::mutex pendingMutex;
std::unordered_map<int, std::shared_ptr<Pending>> pending;
std::mutex writeMutex; // serialises outbound writes
std::thread ioThread;
juce::String lastStartError;
JUCE_DECLARE_NON_COPYABLE_WITH_LEAK_DETECTOR(ControlChannel)
};
} // namespace slopsmith::sandbox
+51
View File
@@ -0,0 +1,51 @@
// ControlChannel::Impl — private OS-handle wrapper shared between the
// platform-neutral request/reply/dispatch logic (ControlChannel_shared.cpp)
// and the per-platform transport (ControlChannel_{win,posix}.cpp).
//
// Internal to the ControlChannel translation units; NOT part of the public
// ControlChannel.h surface (keeps <windows.h> / POSIX fd semantics out of
// every includer).
#pragma once
#include "ControlChannel.h"
#if JUCE_WINDOWS
#include <windows.h>
#endif
namespace slopsmith::sandbox {
struct ControlChannel::Impl
{
bool isServer = false;
#if JUCE_WINDOWS
HANDLE pipe = INVALID_HANDLE_VALUE;
// Set by stop() before the join. The I/O thread's ConnectNamedPipe wait
// observes it via WaitForMultipleObjects so a stop() that races the start
// of ioLoop still tears down promptly — CancelIoEx alone is a no-op
// against I/O that hasn't been issued yet.
HANDLE stopEvent = nullptr;
#else
// Connected stream socket (one end of a socketpair). Bidirectional, like
// the Windows duplex named pipe; the [u32 length-LE][body] framing layer
// in ControlChannel_shared.cpp sits on top unchanged.
int fd = -1;
// Self-pipe used to break the I/O thread out of poll() on stop(). stop()
// writes one byte and never drains it (manual-reset semantics: every
// subsequent poll sees POLLIN and returns immediately), mirroring the
// Windows manual-reset stopEvent.
int stopPipe[2] = { -1, -1 };
// Server side only: the sandbox's end of the socketpair, produced by
// createServerSide and consumed (owned) by whoever connects — an
// in-process peer via connectClientSideFd, or the spawner which dup2()s
// it into the child. Exposed via ControlChannel::sandboxFd(). NOT closed
// by stop(): ownership has transferred to the consumer by then.
int handoffFd = -1;
#endif
};
} // namespace slopsmith::sandbox
+400
View File
@@ -0,0 +1,400 @@
// ControlChannel — POSIX transport (macOS + Linux).
//
// Transport is a connected AF_UNIX SOCK_STREAM socket (one end of a
// socketpair). It is bidirectional like the Windows duplex named pipe, has the
// same partial-read/partial-write stream semantics (so the [u32 length-LE]
// [body] framing in ControlChannel_shared.cpp carries over unchanged), and is
// passed to the sandbox by fd inheritance rather than re-opened by name —
// dodging the macOS sun_path length limit and leaving no socket file to leak.
//
// The I/O thread multiplexes the socket and a self-pipe with poll(): stop()
// writes one (never-drained) byte to the self-pipe, which is the POSIX analog
// of the Windows manual-reset stopEvent + CancelIoEx — every subsequent poll
// returns immediately so the thread unwinds promptly.
//
// SIGPIPE: writing to a socket whose peer has closed would raise SIGPIPE and
// kill the process by default (Windows just returns an error). Every send()
// uses MSG_NOSIGNAL where available and the fd carries SO_NOSIGPIPE on macOS,
// so a dead peer surfaces as EPIPE — never a signal.
#include "ControlChannelImpl.h"
#if JUCE_WINDOWS
#error "ControlChannel_posix.cpp is POSIX-only; Windows builds use ControlChannel_win.cpp."
#endif
#include <cerrno>
#include <fcntl.h>
#include <poll.h>
#include <sys/socket.h>
#include <thread>
#include <unistd.h>
#include "../VSTTrace.h"
#define CTL_TRACE(...) VST_TRACE("[ctrl] " __VA_ARGS__)
namespace slopsmith::sandbox {
namespace {
void setNonBlocking(int fd)
{
const int flags = fcntl(fd, F_GETFL, 0);
if (flags >= 0) fcntl(fd, F_SETFL, flags | O_NONBLOCK);
}
void setCloExec(int fd)
{
const int flags = fcntl(fd, F_GETFD, 0);
if (flags >= 0) fcntl(fd, F_SETFD, flags | FD_CLOEXEC);
}
void setNoSigPipe([[maybe_unused]] int fd)
{
#ifdef SO_NOSIGPIPE
const int one = 1;
setsockopt(fd, SOL_SOCKET, SO_NOSIGPIPE, &one, sizeof(one));
#endif
}
ssize_t sendNoSignal(int fd, const void* buf, size_t n)
{
#ifdef MSG_NOSIGNAL
return ::send(fd, buf, n, MSG_NOSIGNAL);
#else
return ::send(fd, buf, n, 0); // SO_NOSIGPIPE set on the fd instead
#endif
}
} // namespace
bool ControlChannel::createServerSide(juce::String& pipeNameOut,
juce::String& errorOut)
{
int sp[2] = { -1, -1 };
if (socketpair(AF_UNIX, SOCK_STREAM, 0, sp) != 0)
{
errorOut = "socketpair failed: " + juce::String(strerror(errno));
return false;
}
impl->fd = sp[0]; // host end (stays here)
impl->handoffFd = sp[1]; // sandbox end (owned by whoever connects)
impl->isServer = true;
setNonBlocking(impl->fd);
setCloExec(impl->fd);
setNoSigPipe(impl->fd);
setCloExec(impl->handoffFd);
setNoSigPipe(impl->handoffFd);
pipeNameOut.clear(); // no named object on POSIX
return true;
}
int ControlChannel::sandboxFd() const noexcept
{
return impl ? impl->handoffFd : -1;
}
void ControlChannel::closeSandboxFd() noexcept
{
if (impl && impl->handoffFd >= 0)
{
::close(impl->handoffFd);
impl->handoffFd = -1;
}
}
bool ControlChannel::connectClientSide(const juce::String& /*pipeName*/,
juce::String& errorOut)
{
// POSIX has no named control object — the sandbox adopts the inherited fd
// via connectClientSideFd instead.
errorOut = "connectClientSide(name) is Windows-only; use connectClientSideFd on POSIX";
return false;
}
bool ControlChannel::connectClientSideFd(int fd, juce::String& errorOut)
{
if (fd < 0)
{
errorOut = "connectClientSideFd: invalid fd";
return false;
}
impl->fd = fd; // take ownership
impl->isServer = false;
setNonBlocking(impl->fd);
setCloExec(impl->fd);
setNoSigPipe(impl->fd);
return true;
}
bool ControlChannel::start(EventCallback evCb,
std::function<void(const juce::String&)> disconnectCb)
{
lastStartError.clear();
if (ioThread.joinable() || alive.load(std::memory_order_acquire))
{
lastStartError = "channel already started";
return false;
}
if (!impl || impl->fd < 0)
{
lastStartError = "no socket (createServerSide/connectClientSideFd not called or failed)";
return false;
}
// Self-pipe used as the stop signal. Read end stays readable forever once
// stop() writes a byte (never drained) — manual-reset semantics matching
// the Windows stopEvent.
if (::pipe(impl->stopPipe) != 0)
{
lastStartError = "pipe(stopPipe) failed: " + juce::String(strerror(errno));
return false;
}
setNonBlocking(impl->stopPipe[0]);
setCloExec(impl->stopPipe[0]);
setCloExec(impl->stopPipe[1]);
onEvent = std::move(evCb);
onDisconnect = std::move(disconnectCb);
alive.store(true, std::memory_order_release);
ioThread = std::thread([this] { ioLoop(); });
return true;
}
void ControlChannel::stop()
{
// See the Windows backend for the full callback-lifetime invariant: by the
// time stop() returns the I/O thread has been joined (or detached on the
// self-stop path) and onDisconnect has fired for the last time.
alive.store(false, std::memory_order_release);
// Signal stop FIRST (self-pipe byte, never drained), then shutdown() the
// socket so a blocked recv()/poll() on it also wakes. Ordering mirrors the
// Windows SetEvent-before-CancelIoEx: the self-pipe covers the window
// before the I/O thread has even reached its first poll().
if (impl && impl->stopPipe[1] >= 0)
{
const unsigned char b = 1;
ssize_t r = ::write(impl->stopPipe[1], &b, 1);
(void)r; // best-effort wake; a full self-pipe already means "stopping"
}
if (impl && impl->fd >= 0)
::shutdown(impl->fd, SHUT_RDWR);
if (ioThread.joinable())
{
if (std::this_thread::get_id() == ioThread.get_id())
ioThread.detach(); // self-stop: self-join would deadlock
else
ioThread.join();
}
if (impl)
{
if (impl->fd >= 0) { ::close(impl->fd); impl->fd = -1; }
if (impl->stopPipe[0] >= 0) { ::close(impl->stopPipe[0]); impl->stopPipe[0] = -1; }
if (impl->stopPipe[1] >= 0) { ::close(impl->stopPipe[1]); impl->stopPipe[1] = -1; }
// handoffFd ownership transferred to the consumer at connect/spawn
// time; do NOT close it here.
}
std::lock_guard<std::mutex> lk(pendingMutex);
for (auto& [id, p] : pending)
{
try { p->promise.set_value({}); }
catch (const std::future_error&) {}
}
pending.clear();
}
namespace {
// Transfer exactly `n` bytes to/from the socket, blocking via poll() up to
// `timeoutMs` (or indefinitely if INFINITE-style negative), while also waking
// on the stop self-pipe. Returns 0 on success, or a negative code:
// -1 timeout, -2 stop requested, -3 peer closed (EOF/EPIPE/ECONNRESET),
// -4 other I/O error.
constexpr int kXferOk = 0;
constexpr int kXferTimeout = -1;
constexpr int kXferStopped = -2;
constexpr int kXferPeerClose = -3;
constexpr int kXferError = -4;
int transferN(int fd, int stopFd, bool isWrite, void* buf, size_t n,
int timeoutMs)
{
auto* p = static_cast<char*>(buf);
size_t remaining = n;
const bool bounded = (timeoutMs >= 0);
// Track elapsed (now - start) rather than an absolute now+timeout deadline:
// juce::Time::getMillisecondCounter() is a 32-bit counter that wraps every
// ~49.7 days, and an absolute deadline that straddles the wrap reads as
// already-past (spurious immediate timeout). Unsigned (now - start) stays
// correct across a single wrap for any timeout shorter than the wrap period.
const uint32_t startMs = juce::Time::getMillisecondCounter();
while (remaining > 0)
{
int waitMs = -1;
if (bounded)
{
const uint32_t elapsed = juce::Time::getMillisecondCounter() - startMs;
if (elapsed >= (uint32_t)timeoutMs) { errno = ETIMEDOUT; return kXferTimeout; }
waitMs = (int)((uint32_t)timeoutMs - elapsed);
}
struct pollfd pfds[2]{};
pfds[0].fd = fd;
pfds[0].events = isWrite ? POLLOUT : POLLIN;
pfds[1].fd = stopFd;
pfds[1].events = POLLIN;
const int nfds = (stopFd >= 0) ? 2 : 1;
const int rc = ::poll(pfds, (nfds_t)nfds, waitMs);
if (rc < 0)
{
if (errno == EINTR) continue;
return kXferError;
}
// Timeout carries no syscall errno; set ETIMEDOUT so readFrame records a
// deterministic lastReadError rather than a stale unrelated value.
if (rc == 0) { errno = ETIMEDOUT; return kXferTimeout; }
if (nfds == 2 && (pfds[1].revents & POLLIN)) return kXferStopped;
// Attempt the transfer even on POLLHUP/POLLERR: a reader should still
// drain buffered bytes before observing EOF.
const ssize_t got = isWrite ? sendNoSignal(fd, p, remaining)
: ::recv(fd, p, remaining, 0);
if (got > 0)
{
p += got;
remaining -= (size_t)got;
continue;
}
if (got == 0)
{
// Clean EOF carries no errno; clear it so readFrame reports a
// deterministic lastReadError of 0 for a graceful peer close rather
// than whatever stale value errno happened to hold.
errno = 0;
return kXferPeerClose; // EOF (read side); peer closed cleanly
}
// got < 0
if (errno == EINTR || errno == EAGAIN || errno == EWOULDBLOCK)
continue; // re-poll
if (errno == EPIPE || errno == ECONNRESET)
return kXferPeerClose;
return kXferError;
}
return kXferOk;
}
} // namespace
bool ControlChannel::writeFrame(const juce::MemoryBlock& body)
{
std::lock_guard<std::mutex> lk(writeMutex);
if (impl->fd < 0) return false;
if (body.getSize() > kMaxControlMessageBytes) return false;
// Don't watch the stop pipe on writes: a half-written frame would corrupt
// the stream for the peer. 5 s bounds a stalled reader without pinning the
// caller until the higher-level request timeout.
constexpr int kWriteTimeoutMs = 5000;
uint32_t lenLE = (uint32_t)body.getSize();
if (transferN(impl->fd, -1, /*isWrite*/true, &lenLE, sizeof(lenLE),
kWriteTimeoutMs) != kXferOk)
return false;
if (body.getSize() > 0)
{
if (transferN(impl->fd, -1, true,
const_cast<void*>(body.getData()),
body.getSize(), kWriteTimeoutMs) != kXferOk)
return false;
}
return true;
}
bool ControlChannel::readFrame(juce::MemoryBlock& out)
{
lastReadError = 0;
lastReadPeerClosed = false;
if (impl->fd < 0)
{
CTL_TRACE("readFrame: fd is closed");
return false;
}
auto classify = [this](int code)
{
// Classify the failure for ioLoop's clean-vs-fault decision. A peer
// EOF/reset (kXferPeerClose) is a clean close; a stop() via the
// self-pipe (kXferStopped) is ALSO a clean, expected teardown — not an
// I/O fault — so don't report it as a read-error (matches the Windows
// CancelIoEx path's intent, and avoids false crash/restart handling if
// a future change ever delivers this reason). Only a genuine fault
// (kXferError) maps to read-error. lastReadError is informational.
lastReadError = (code == kXferStopped) ? 0ul : (unsigned long)errno;
lastReadPeerClosed = (code == kXferPeerClose || code == kXferStopped);
};
// Length prefix: no read timeout (the I/O thread blocks here between
// frames), but the stop pipe still breaks the wait.
uint32_t lenLE = 0;
int rc = transferN(impl->fd, impl->stopPipe[0], /*isWrite*/false,
&lenLE, sizeof(lenLE), /*timeoutMs*/ -1);
if (rc != kXferOk)
{
classify(rc);
CTL_TRACE("readFrame: len read failed rc=%d errno=%lu", rc, lastReadError);
return false;
}
if (lenLE > kMaxControlMessageBytes)
{
lastReadError = (unsigned long)EMSGSIZE;
CTL_TRACE("readFrame: oversized frame len=%lu", (unsigned long)lenLE);
return false;
}
out.setSize(lenLE, false);
if (lenLE == 0) return true;
// Finite body timeout once the prefix has arrived (a stalled peer must not
// wedge the I/O thread). 30 s matches the Windows backend: generous for a
// multi-MB state-restore frame while bounding the DoS window.
constexpr int kBodyReadTimeoutMs = 30000;
rc = transferN(impl->fd, impl->stopPipe[0], false,
out.getData(), lenLE, kBodyReadTimeoutMs);
if (rc != kXferOk)
{
classify(rc);
CTL_TRACE("readFrame: body read failed rc=%d len=%lu errno=%lu",
rc, (unsigned long)lenLE, lastReadError);
return false;
}
return true;
}
bool ControlChannel::waitForPeer(juce::String& failReason)
{
// A socketpair is already connected, so there is nothing to wait for — but
// honour a stop() that fired between start() and here (mirrors the Windows
// backend racing the stop event during the connect).
if (impl->stopPipe[0] >= 0)
{
struct pollfd pfd{};
pfd.fd = impl->stopPipe[0];
pfd.events = POLLIN;
if (::poll(&pfd, 1, 0) > 0 && (pfd.revents & POLLIN))
{
failReason = kReasonReadError + " (stopped)";
return false;
}
}
if (!alive.load(std::memory_order_acquire))
{
failReason = kReasonReadError + " (stopped)";
return false;
}
return true;
}
} // namespace slopsmith::sandbox
+248
View File
@@ -0,0 +1,248 @@
// ControlChannel — platform-neutral request/reply/event dispatch.
//
// The wire format ([u32 length-LE][utf8 JSON], envelopes in Protocol.cpp), the
// pending-request promise map, and the inbound-frame routing (event vs request
// vs reply) are identical on every OS. Only the transport (named pipe +
// overlapped I/O on Windows, socketpair + poll on POSIX) differs, and that
// lives in ControlChannel_{win,posix}.cpp behind createServerSide /
// connectClientSide / start / stop / waitForPeer / readFrame / writeFrame.
#include "ControlChannelImpl.h"
#include <thread>
// Reuse the existing VST trace logger for diagnostics. Cheap and survives
// crashes thanks to its synchronous flush.
#include "../VSTTrace.h"
#define CTL_TRACE(...) VST_TRACE("[ctrl] " __VA_ARGS__)
namespace slopsmith::sandbox {
const juce::String ControlChannel::kReasonPeerClosed = "peer-closed";
const juce::String ControlChannel::kReasonReadError = "read-error";
const juce::String ControlChannel::kReasonProtocolError = "protocol-error";
ControlChannel::ControlChannel() : impl(std::make_unique<Impl>()) {}
ControlChannel::~ControlChannel()
{
stop();
}
void ControlChannel::ioLoop()
{
CTL_TRACE("ioLoop entered (isServer=%d)", (int)impl->isServer);
// Server side: wait for the sandbox to connect (or for stop()). The
// platform decides what "connect" means; on a clean stop it returns false
// with a reason already suited to failWith.
if (impl->isServer)
{
juce::String failReason;
if (!waitForPeer(failReason))
{
failWith(failReason);
return;
}
}
juce::MemoryBlock frame;
while (alive.load(std::memory_order_acquire))
{
if (!readFrame(frame))
{
CTL_TRACE("readFrame failed; exiting loop (peerClosed=%d err=%lu)",
(int)lastReadPeerClosed, lastReadError);
// Distinguish a clean peer-side shutdown from an actual I/O fault
// so the disconnect callback's caller can decide between
// "expected" and "should restart". readFrame set
// lastReadPeerClosed per-OS so this stays platform-agnostic.
failWith(lastReadPeerClosed ? kReasonPeerClosed : kReasonReadError);
return;
}
CTL_TRACE("readFrame got %d bytes", (int)frame.getSize());
juce::String parseError;
auto msg = wire::decode(frame.getData(), frame.getSize(), &parseError);
if (!msg.isObject())
{
// %.*s with an explicit length — the frame buffer is not
// NUL-terminated and can be 0 bytes (no body), so %.32s would
// read past the end (or dereference null).
const int previewLen = juce::jmin<int>(32, (int)frame.getSize());
CTL_TRACE("decode failed: %s; first %d bytes: %.*s",
parseError.toRawUTF8(), previewLen,
previewLen, (const char*)frame.getData());
failWith(kReasonProtocolError + ": " + parseError);
return;
}
// Reject frames missing or mismatching the protocol version — better
// to fail fast on host/sandbox skew than to keep going and misparse a
// payload that doesn't match the schema we expect.
const int incomingVersion = (int)msg.getProperty("v", -1);
if (incomingVersion != (int)kProtocolVersion)
{
CTL_TRACE("protocol version mismatch: got=%d expected=%d",
incomingVersion, (int)kProtocolVersion);
failWith(kReasonProtocolError + ": version mismatch (got "
+ juce::String(incomingVersion) + ", expected "
+ juce::String((int)kProtocolVersion) + ")");
return;
}
// Reply ({id, ok, result/error}) vs event ({event, data}) vs request
// ({id, op, args}). Dispatch by structure.
if (msg.hasProperty("event"))
{
CTL_TRACE("event: %s", msg["event"].toString().toRawUTF8());
if (onEvent)
onEvent(msg["event"].toString(), msg["data"]);
continue;
}
if (msg.hasProperty("op"))
{
const int id = (int)msg.getProperty("id", -1);
if (requestHandler)
{
requestHandler(id, msg["op"].toString(), msg["args"]);
}
else if (id >= 0)
{
// No handler installed (host side never accepts inbound
// requests). Reply with an explicit error so a misbehaving
// or forged peer can't pin our request() with a 10 s wait.
sendReply(id, false, {}, "no request handler installed");
}
continue;
}
// Reply path
int id = (int)msg.getProperty("id", -1);
std::shared_ptr<Pending> pendingEntry;
{
std::lock_guard<std::mutex> lk(pendingMutex);
auto it = pending.find(id);
if (it != pending.end())
{
pendingEntry = it->second;
pending.erase(it);
}
}
if (pendingEntry)
{
bool ok = (bool)msg.getProperty("ok", false);
juce::DynamicObject::Ptr replyObj(new juce::DynamicObject());
replyObj->setProperty("ok", ok);
replyObj->setProperty("result", msg["result"]);
replyObj->setProperty("error", msg["error"]);
try { pendingEntry->promise.set_value(juce::var(replyObj.get())); }
catch (const std::future_error&) {}
}
}
}
void ControlChannel::failWith(const juce::String& reason)
{
if (!alive.exchange(false, std::memory_order_acq_rel)) return;
// Drain internal state BEFORE invoking the callback. The disconnect
// handler is allowed to tear down higher-level owners that destroy this
// ControlChannel (typical teardown chain: SandboxedProcessor::teardown
// → ControlChannel::stop → ~ControlChannel), so any member access after
// the callback returns would be use-after-free.
{
std::lock_guard<std::mutex> lk(pendingMutex);
for (auto& [id, p] : pending)
{
try { p->promise.set_value({}); } catch (...) {}
}
pending.clear();
}
auto cb = std::move(onDisconnect);
if (cb) cb(reason);
}
juce::var ControlChannel::request(const char* op, const juce::var& args,
int timeoutMs, juce::String* errorOut)
{
if (!alive.load(std::memory_order_acquire))
{
if (errorOut) *errorOut = "channel not alive";
return {};
}
int id = nextRequestId.fetch_add(1, std::memory_order_relaxed);
auto entry = std::make_shared<Pending>();
auto fut = entry->promise.get_future();
{
std::lock_guard<std::mutex> lk(pendingMutex);
pending[id] = entry;
}
auto frame = wire::encode(wire::makeRequest(id, op, args));
if (!writeFrame(frame))
{
std::lock_guard<std::mutex> lk(pendingMutex);
pending.erase(id);
if (errorOut) *errorOut = "write failed";
return {};
}
if (fut.wait_for(std::chrono::milliseconds(timeoutMs))
== std::future_status::timeout)
{
std::lock_guard<std::mutex> lk(pendingMutex);
pending.erase(id);
if (errorOut) *errorOut = "timeout";
return {};
}
auto reply = fut.get();
// stop() / failWith() resolve in-flight requests with an undefined `var`
// so callers don't hang. Detect that case explicitly — otherwise
// getProperty(...) returns defaults and the caller sees an empty
// errorOut even though the real cause was disconnect/cancellation.
if (!reply.isObject())
{
if (errorOut) *errorOut = "control channel disconnected";
return {};
}
if (!(bool)reply.getProperty("ok", false))
{
if (errorOut) *errorOut = reply.getProperty("error", "").toString();
return {};
}
return reply["result"];
}
bool ControlChannel::postNoReply(const char* op, const juce::var& args)
{
if (!alive.load(std::memory_order_acquire)) return false;
auto frame = wire::encode(wire::makeRequest(-1, op, args));
return writeFrame(frame);
}
bool ControlChannel::sendReply(int requestId, bool ok, const juce::var& result,
const juce::String& errorMessage)
{
auto frame = wire::encode(wire::makeReply(requestId, ok, result, errorMessage));
return writeFrame(frame);
}
bool ControlChannel::sendEvent(const char* eventName, const juce::var& data)
{
auto frame = wire::encode(wire::makeEvent(eventName, data));
return writeFrame(frame);
}
void ControlChannel::setRequestHandler(RequestHandler handler)
{
// The I/O thread reads requestHandler unsynchronized — assignments after
// start() would race. Header documents "MUST be called BEFORE start()";
// assert it so a future regression (e.g. wiring a handler from a ready
// callback) fails loudly in debug builds rather than silently racing.
jassert(!ioThread.joinable() && !alive.load(std::memory_order_acquire));
requestHandler = std::move(handler);
}
} // namespace slopsmith::sandbox
+373
View File
@@ -0,0 +1,373 @@
// ControlChannel — Windows transport: a duplex named pipe in byte mode with
// overlapped I/O. The neutral request/reply/dispatch logic lives in
// ControlChannel_shared.cpp; this file implements createServerSide /
// connectClientSide / start / stop / waitForPeer / readFrame / writeFrame.
//
// Byte mode (PIPE_TYPE_BYTE | PIPE_READMODE_BYTE) + the explicit length-prefix
// framing was chosen over PIPE_TYPE_MESSAGE because the sandbox's `ready`
// frame wasn't being delivered reliably in message mode — see commit 2cb9ae9.
#include "ControlChannelImpl.h"
#if ! JUCE_WINDOWS
#error "ControlChannel_win.cpp is Windows-only; POSIX builds use ControlChannel_posix.cpp."
#endif
#include <thread>
#include "../VSTTrace.h"
#define CTL_TRACE(...) VST_TRACE("[ctrl] " __VA_ARGS__)
namespace slopsmith::sandbox {
bool ControlChannel::createServerSide(juce::String& pipeNameOut,
juce::String& errorOut)
{
juce::Uuid uuid;
juce::String pipeName = "\\\\.\\pipe\\slopsmith-vst-" + uuid.toDashedString();
// PIPE_REJECT_REMOTE_CLIENTS (Vista+) refuses connections from machines
// other than the local one. The pipe name is random per-spawn, but
// rejecting remote clients narrows the attack surface regardless.
HANDLE h = CreateNamedPipeW(
pipeName.toWideCharPointer(),
PIPE_ACCESS_DUPLEX | FILE_FLAG_OVERLAPPED,
PIPE_TYPE_BYTE | PIPE_READMODE_BYTE | PIPE_WAIT
| PIPE_REJECT_REMOTE_CLIENTS,
/*maxInstances*/ 1,
kControlPipeBufferBytes,
kControlPipeBufferBytes,
/*default timeout*/ 0,
nullptr);
if (h == INVALID_HANDLE_VALUE)
{
errorOut = "CreateNamedPipeW failed: " + juce::String((int)GetLastError());
return false;
}
impl->pipe = h;
impl->isServer = true;
pipeNameOut = pipeName;
return true;
}
bool ControlChannel::connectClientSide(const juce::String& pipeName,
juce::String& errorOut)
{
HANDLE h = CreateFileW(
pipeName.toWideCharPointer(),
GENERIC_READ | GENERIC_WRITE,
0, nullptr, OPEN_EXISTING,
FILE_FLAG_OVERLAPPED,
nullptr);
if (h == INVALID_HANDLE_VALUE)
{
errorOut = "CreateFileW (client) failed: " + juce::String((int)GetLastError());
return false;
}
// Pipe was opened in BYTE mode (default) which matches our framing —
// no SetNamedPipeHandleState call needed since CreateNamedPipeW on the
// server side is now also PIPE_TYPE_BYTE | PIPE_READMODE_BYTE.
impl->pipe = h;
impl->isServer = false;
return true;
}
bool ControlChannel::start(EventCallback evCb,
std::function<void(const juce::String&)> disconnectCb)
{
lastStartError.clear();
// Reassigning a joinable std::thread aborts via std::terminate, so refuse
// a second start. Callers should stop() then re-create the channel.
if (ioThread.joinable() || alive.load(std::memory_order_acquire))
{
lastStartError = "channel already started";
return false;
}
if (!impl || impl->pipe == INVALID_HANDLE_VALUE)
{
lastStartError = "no pipe handle (createServerSide/connectClientSide not called or failed)";
return false;
}
// Manual-reset so once stop() signals it, every subsequent wait inside
// ioLoop returns immediately.
impl->stopEvent = CreateEventW(nullptr, /*manualReset*/TRUE, FALSE, nullptr);
if (impl->stopEvent == nullptr)
{
lastStartError = "CreateEventW(stopEvent) failed: GetLastError="
+ juce::String((int)GetLastError());
return false;
}
onEvent = std::move(evCb);
onDisconnect = std::move(disconnectCb);
alive.store(true, std::memory_order_release);
// ConnectNamedPipe is performed inside the I/O thread (waitForPeer) so the
// caller never blocks. If the sandbox subprocess dies before connecting,
// the caller's watchdog can call stop(), which CancelIoEx's the pending
// connect and unwinds cleanly.
ioThread = std::thread([this] { ioLoop(); });
return true;
}
void ControlChannel::stop()
{
// Callback lifetime invariant: by the time stop() returns, both
// `onEvent` and `onDisconnect` have been observed for the last time —
// the I/O thread is either joined (non-self path) or has already
// returned from its last dispatch (self-detach path; see below).
// Owners therefore MUST call stop() before destroying any state
// captured by-reference into onEvent/onDisconnect.
alive.store(false, std::memory_order_release);
// Signal stop BEFORE CancelIoEx. The race we're guarding against is
// stop() running between ioThread spawn and the I/O thread issuing
// ConnectNamedPipe — CancelIoEx would be a no-op there. With the
// stop event signalled, the I/O thread's WaitForMultipleObjects exits
// promptly regardless of whether the connect was ever started.
if (impl && impl->stopEvent != nullptr)
SetEvent(impl->stopEvent);
// CancelIoEx unblocks the I/O thread's pending read so it can exit. The
// handle must stay valid until the thread has returned — closing it
// first is a TOCTOU on the in-flight read.
if (impl && impl->pipe != INVALID_HANDLE_VALUE)
CancelIoEx(impl->pipe, nullptr);
if (ioThread.joinable())
{
if (std::this_thread::get_id() == ioThread.get_id())
{
// Self-stop: the I/O thread is unwinding through ioLoop /
// failWith / disconnect-callback / our caller into here.
// Detaching is the only choice (self-join deadlocks); the
// CancelIoEx + SetEvent above already shoved the I/O thread past
// any blocking syscall on the handles, so the window between
// detach and CloseHandle is just stack unwinding.
ioThread.detach();
}
else
ioThread.join();
}
if (impl && impl->pipe != INVALID_HANDLE_VALUE)
{
CloseHandle(impl->pipe);
impl->pipe = INVALID_HANDLE_VALUE;
}
if (impl && impl->stopEvent != nullptr)
{
CloseHandle(impl->stopEvent);
impl->stopEvent = nullptr;
}
// Fail any in-flight requests so callers don't hang.
std::lock_guard<std::mutex> lk(pendingMutex);
for (auto& [id, p] : pending)
{
try { p->promise.set_value({}); }
catch (const std::future_error&) {}
}
pending.clear();
}
// One-shot overlapped issue/wait/result, returning the actual bytes
// transferred (or 0 on failure with GetLastError set). Used by
// overlappedTransfer below in a loop, because byte-mode pipes can satisfy
// a single ReadFile/WriteFile with fewer bytes than requested.
static DWORD overlappedChunk(HANDLE pipe, bool isWrite, void* buf,
DWORD bytes, DWORD timeoutMs)
{
OVERLAPPED ov{};
ov.hEvent = CreateEventW(nullptr, TRUE, FALSE, nullptr);
if (ov.hEvent == nullptr)
return 0;
const BOOL started = isWrite
? WriteFile(pipe, buf, bytes, nullptr, &ov)
: ReadFile (pipe, buf, bytes, nullptr, &ov);
const DWORD startErr = started ? 0 : GetLastError();
if (!started && startErr != ERROR_IO_PENDING)
{
CloseHandle(ov.hEvent);
SetLastError(startErr);
return 0;
}
if (timeoutMs != INFINITE)
{
if (WaitForSingleObject(ov.hEvent, timeoutMs) != WAIT_OBJECT_0)
{
CancelIoEx(pipe, &ov);
DWORD drained = 0;
GetOverlappedResult(pipe, &ov, &drained, TRUE);
CloseHandle(ov.hEvent);
SetLastError(ERROR_TIMEOUT);
return 0;
}
}
DWORD transferred = 0;
if (!GetOverlappedResult(pipe, &ov, &transferred, TRUE))
{
const DWORD err = GetLastError();
CloseHandle(ov.hEvent);
SetLastError(err);
return 0;
}
CloseHandle(ov.hEvent);
return transferred;
}
// Loop over overlappedChunk until `bytesPerOp` have been transferred. Needed
// because the pipe is in byte mode (PIPE_TYPE_BYTE), so a single ReadFile or
// WriteFile can return fewer bytes than requested even when the rest is
// still on the wire.
static bool overlappedTransfer(HANDLE pipe, bool isWrite, void* buf,
DWORD bytesPerOp, DWORD timeoutMs = INFINITE)
{
auto* p = static_cast<char*>(buf);
DWORD remaining = bytesPerOp;
while (remaining > 0)
{
const DWORD got = overlappedChunk(pipe, isWrite, p, remaining,
timeoutMs);
if (got == 0)
return false; // GetLastError() preserved from overlappedChunk
p += got;
remaining -= got;
}
return true;
}
bool ControlChannel::writeFrame(const juce::MemoryBlock& body)
{
std::lock_guard<std::mutex> lk(writeMutex);
if (impl->pipe == INVALID_HANDLE_VALUE) return false;
if (body.getSize() > kMaxControlMessageBytes) return false;
constexpr DWORD kWriteTimeoutMs = 5000;
uint32_t lenLE = (uint32_t)body.getSize();
if (!overlappedTransfer(impl->pipe, true, &lenLE, sizeof(lenLE),
kWriteTimeoutMs))
return false;
if (body.getSize() > 0)
{
if (!overlappedTransfer(impl->pipe, true,
const_cast<void*>(body.getData()),
(DWORD)body.getSize(), kWriteTimeoutMs))
return false;
}
return true;
}
// Classify a readFrame failure error code as a clean peer-side close vs a
// genuine I/O fault, so ControlChannel_shared.cpp's ioLoop stays free of Win32
// error codes.
static bool isPeerClosedError(unsigned long err)
{
return err == ERROR_BROKEN_PIPE
|| err == ERROR_PIPE_NOT_CONNECTED
|| err == ERROR_NO_DATA;
}
bool ControlChannel::readFrame(juce::MemoryBlock& out)
{
lastReadError = 0;
lastReadPeerClosed = false;
if (impl->pipe == INVALID_HANDLE_VALUE)
{
CTL_TRACE("readFrame: pipe is INVALID_HANDLE_VALUE");
return false;
}
uint32_t lenLE = 0;
if (!overlappedTransfer(impl->pipe, false, &lenLE, sizeof(lenLE)))
{
lastReadError = GetLastError();
lastReadPeerClosed = isPeerClosedError(lastReadError);
CTL_TRACE("readFrame: ReadFile(len) failed err=%lu", lastReadError);
return false;
}
if (lenLE > kMaxControlMessageBytes)
{
lastReadError = ERROR_INVALID_DATA;
CTL_TRACE("readFrame: oversized frame len=%lu", (unsigned long)lenLE);
return false;
}
out.setSize(lenLE, false);
if (lenLE == 0) return true;
// Finite body timeout: once the 4-byte length prefix arrives the peer is
// committed to sending lenLE more bytes; INFINITE here would wedge the I/O
// thread if a buggy/malicious peer wrote the prefix and stalled. 30 s is
// generous for the largest legitimate frame (state-restore can be a few
// MB through a slow link) while bounding the DoS window.
constexpr DWORD kBodyReadTimeoutMs = 30000;
if (!overlappedTransfer(impl->pipe, false, out.getData(),
(DWORD)lenLE, kBodyReadTimeoutMs))
{
lastReadError = GetLastError();
lastReadPeerClosed = isPeerClosedError(lastReadError);
CTL_TRACE("readFrame: ReadFile(body len=%lu) failed err=%lu",
(unsigned long)lenLE, lastReadError);
return false;
}
return true;
}
bool ControlChannel::waitForPeer(juce::String& failReason)
{
// Server side: wait for the sandbox to connect. Overlapped because the
// pipe was opened with FILE_FLAG_OVERLAPPED; synchronous wait via
// GetOverlappedResult, racing the stop event.
if (impl->pipe == INVALID_HANDLE_VALUE)
{
failReason = kReasonReadError + " (no pipe)";
return false;
}
OVERLAPPED ov{};
ov.hEvent = CreateEventW(nullptr, TRUE, FALSE, nullptr);
if (ov.hEvent == nullptr)
{
CTL_TRACE("ConnectNamedPipe: CreateEventW failed err=%lu",
(unsigned long)GetLastError());
failReason = kReasonReadError + " (event)";
return false;
}
BOOL ok = ConnectNamedPipe(impl->pipe, &ov);
DWORD err = ok ? 0 : GetLastError();
if (!ok && err == ERROR_IO_PENDING)
{
// Wait on the connect event AND the stop event so a stop() during the
// window between ioThread spawn and this call still unwinds (CancelIoEx
// alone is a no-op against not-yet-issued I/O).
HANDLE waits[2] = { ov.hEvent, impl->stopEvent };
const DWORD which = WaitForMultipleObjects(2, waits, FALSE, INFINITE);
if (which == WAIT_OBJECT_0 + 1)
{
CancelIoEx(impl->pipe, &ov);
DWORD drained = 0;
GetOverlappedResult(impl->pipe, &ov, &drained, TRUE);
CloseHandle(ov.hEvent);
CTL_TRACE("ConnectNamedPipe cancelled by stop()");
failReason = kReasonReadError + " (stopped)";
return false;
}
DWORD t = 0;
ok = GetOverlappedResult(impl->pipe, &ov, &t, TRUE);
if (!ok) err = GetLastError();
}
else if (!ok && err == ERROR_PIPE_CONNECTED)
{
ok = TRUE;
}
CloseHandle(ov.hEvent);
if (!ok)
{
CTL_TRACE("ConnectNamedPipe failed err=%lu", (unsigned long)err);
failReason = kReasonReadError + " (connect)";
return false;
}
CTL_TRACE("ConnectNamedPipe returned (client connected)");
return true;
}
} // namespace slopsmith::sandbox
+84
View File
@@ -0,0 +1,84 @@
#include "Protocol.h"
namespace slopsmith::sandbox::wire {
juce::var makeRequest(int requestId, const char* op, const juce::var& args)
{
juce::DynamicObject::Ptr obj(new juce::DynamicObject());
obj->setProperty("v", static_cast<int>(kProtocolVersion));
obj->setProperty("id", requestId);
obj->setProperty("op", juce::String(op));
obj->setProperty("args", args);
return juce::var(obj.get());
}
juce::var makeEvent(const char* eventName, const juce::var& data)
{
juce::DynamicObject::Ptr obj(new juce::DynamicObject());
obj->setProperty("v", static_cast<int>(kProtocolVersion));
obj->setProperty("id", juce::var());
obj->setProperty("event", juce::String(eventName));
obj->setProperty("data", data);
return juce::var(obj.get());
}
juce::var makeReply(int requestId, bool ok, const juce::var& result,
const juce::String& errorMessage)
{
juce::DynamicObject::Ptr obj(new juce::DynamicObject());
obj->setProperty("v", static_cast<int>(kProtocolVersion));
obj->setProperty("id", requestId);
obj->setProperty("ok", ok);
if (ok)
obj->setProperty("result", result);
else
obj->setProperty("error", errorMessage);
return juce::var(obj.get());
}
juce::MemoryBlock encode(const juce::var& v)
{
const auto json = juce::JSON::toString(v, /*allOnOneLine*/ true);
// MemoryBlock copies, so the temporary's storage doesn't need to
// outlive this expression. getNumBytesAsUTF8() excludes the trailing
// NUL, which we don't want on the wire.
return juce::MemoryBlock(json.toRawUTF8(), json.getNumBytesAsUTF8());
}
juce::var decode(const void* data, size_t bytes, juce::String* errorOut)
{
if (data == nullptr || bytes == 0)
{
if (errorOut) *errorOut = "empty message";
return {};
}
// Use the (begin, end) range constructor — the (CharPointer_UTF8, size_t)
// overload treats the size_t as a maximum *character* count and may scan
// past the buffer counting characters when the body contains multi-byte
// UTF-8 sequences (e.g. a plugin name with non-ASCII chars). The buffer
// is not NUL-terminated, so bounding strictly by byte length matters.
auto* begin = static_cast<const char*>(data);
// Validate well-formedness before constructing the String. Today we
// only consume frames from a subprocess we spawned, so this is mostly
// defensive — but downstream `toString()` / `toRawUTF8()` callers
// (e.g. dispatchRequest kOpenEditor) treat the String as valid UTF-8
// without re-checking. Catch malformed input here so a misbehaving
// peer can't corrupt the host-side state.
if (! juce::CharPointer_UTF8::isValidString(begin, (int)bytes))
{
if (errorOut) *errorOut = "malformed utf-8 in message body";
return {};
}
juce::String text(juce::CharPointer_UTF8(begin),
juce::CharPointer_UTF8(begin + bytes));
juce::var parsed;
auto result = juce::JSON::parse(text, parsed);
if (result.failed())
{
if (errorOut) *errorOut = result.getErrorMessage();
return {};
}
return parsed;
}
} // namespace slopsmith::sandbox::wire
+285
View File
@@ -0,0 +1,285 @@
// Slopsmith plugin-sandbox IPC protocol.
//
// One pair of files defines the wire format used by both the host (Slopsmith
// Desktop, inside the slopsmith_audio.node addon) and the sandbox subprocess
// (slopsmith-vst-host.exe).
//
// Two channels:
// * Control: a bidirectional named pipe carrying length-prefixed JSON
// messages. Used for everything that isn't per-block audio.
// * Audio: shared-memory ring buffers plus a pair of auto-reset OS events.
// Used for the audio fast-path; sized for one block at the configured
// sample rate.
//
// See SANDBOX-DESIGN.md (in the PR description) for the rationale.
#pragma once
#include <cstdint>
#include <juce_core/juce_core.h>
namespace slopsmith::sandbox {
// Bumped whenever the wire format changes incompatibly. Host and sandbox MUST
// agree on this number during the `ready` handshake; mismatched versions abort
// the spawn and fall back to in-process loading.
//
// v2 changes vs v1:
// * AudioShmHeader splits writeIdx/readIdx into per-direction
// in*/out* pairs so input and output rings no longer share state.
// * MIDI is bundled into the audio shm per input slot (MidiQueue) instead
// of riding the control pipe per event; op::kMidiEvent is removed (the
// sandbox keeps a warn-and-drop handler as a paranoid v1 fallback).
// * setBlockSize is no longer "planned" — it's a real op gated by the
// audio-thread pause/drain/resume protocol described in vst-host/main.cpp.
//
// v3 (this PR, mid-review): SHM ABI cleanup — overflow accounting moved from
// a per-slot field on MidiQueue to a single AudioShmHeader.midiOverflows
// counter (per-slot was confusing under round-robin slot reuse). Bumped
// because mixed v2/v3 binaries would interpret different offsets for the
// MIDI queue + trailing header counters and silently corrupt ring data.
// v2 was never released externally; this is the first version that ships.
inline constexpr uint32_t kProtocolVersion = 3;
// Magic number stamped at the head of the audio shared memory so a stale
// mapping from a crashed sandbox can be detected.
inline constexpr uint32_t kAudioShmMagic = 0x534C5341u; // 'SLSA'
// Frame format on the control pipe:
// [u32 length-LE][utf8 json body of `length` bytes]
inline constexpr uint32_t kMaxControlMessageBytes = 8 * 1024 * 1024; // 8 MiB
inline constexpr uint32_t kControlPipeBufferBytes = 64 * 1024;
// Tightest reasonable budget: 4 blocks at 1024 samples / 8 channels / float32.
inline constexpr uint32_t kAudioMaxBlocks = 4;
inline constexpr uint32_t kAudioMaxBlockSamples = 1024;
inline constexpr uint32_t kAudioMaxChannels = 8;
// Wall-clock cap on host-side waits for sandbox replies. Plugins doing slow
// state-restore can legitimately take a while, so this is generous.
inline constexpr int kDefaultReplyTimeoutMs = 10000;
// Named-object suffixes used by the audio shm + event pair. Names are
// generated host-side and passed to the sandbox via command-line args, so
// only the host's AudioChannel::createHostSide actually uses these — but
// centralising them prevents future drift if the sandbox ever needs to
// reconstruct a name (e.g. for diagnostic logging that surfaces the names
// in stable form rather than via argv echo).
inline constexpr const char* kShmNameSuffix = "audio";
inline constexpr const char* kEvtToHostSuffix = "evt-out";
inline constexpr const char* kEvtToSandboxSuffix = "evt-in";
// Editor size default applied when the plugin reports an invalid (< 16 in
// either axis) editor size. Used by both the sandbox host (kOpenEditor
// reply) and the host-side SandboxedEditor fallback so the two sides
// don't drift.
inline constexpr int kDefaultEditorWidth = 1000;
inline constexpr int kDefaultEditorHeight = 600;
// Watchdog: a sandbox that sends neither `ready` nor a `loading` heartbeat
// within this window is presumed broken. The host measures the deadline
// against the last signal of either kind (see event::kLoading), so a plugin
// that keeps heart-beating through a long first-run init is not fast-failed —
// only a genuinely silent (hung or crashed) sandbox trips it. Still generous
// on its own because some plugins (NI Guitar Rig 6 in particular) spin up an
// embedded Qt5/QML engine on first load, which can take 8-12 seconds on a
// cold cache.
inline constexpr int kReadyTimeoutMs = 30000;
// Absolute upper bound on the ready handshake, independent of heartbeats.
// event::kLoading heartbeats push the kReadyTimeoutMs deadline forward, but
// the heartbeat is a fixed timer — not a real plugin-progress signal — so a
// plugin whose load hangs while the sandbox's message loop stays responsive
// would otherwise heartbeat forever and never trip the watchdog. This cap
// fails the spawn regardless of heartbeats. Bumped to 20x kReadyTimeoutMs
// (10 min) after a tester reproduced Archetype Gojira X tripping the
// previous 5-min cap with ~58 kLoading heartbeats from a healthy sandbox
// — Neural DSP's large neural-net models can genuinely take that long on
// a cold cache + slow disk. The heartbeat-silence watchdog (kReadyTimeoutMs,
// 30 s) is the real hang detector; this absolute cap is just a backstop
// against a sandbox that keeps heart-beating but never finishes.
inline constexpr int kReadyAbsoluteTimeoutMs = 600000;
// Control channel — operation names.
//
// Kept as string constants (rather than an enum) because they appear verbatim
// in JSON on the wire and in log output; matching tools downstream don't have
// to know the enum.
namespace op {
// Host → sandbox requests
inline constexpr const char* kPrepare = "prepare";
inline constexpr const char* kSetBlockSize = "setBlockSize";
inline constexpr const char* kSetParameter = "setParameter";
inline constexpr const char* kListParameters = "listParameters";
inline constexpr const char* kGetState = "getState";
inline constexpr const char* kSetState = "setState";
// Removed as of protocol v2: MIDI now flows inline in the audio shm
// (see MidiQueue below). The version handshake rejects v1 hosts before
// they can reach this op, but the sandbox keeps a warn-log no-op
// handler as a paranoid fallback. Drop the handler when v1 host
// binaries are no longer in circulation.
inline constexpr const char* kMidiEvent = "midiEvent";
inline constexpr const char* kOpenEditor = "openEditor";
inline constexpr const char* kResizeEditor = "resizeEditor";
inline constexpr const char* kCloseEditor = "closeEditor";
inline constexpr const char* kShutdown = "shutdown";
}
// Control channel — sandbox-originated event names (requestId is null).
namespace event {
inline constexpr const char* kReady = "ready";
// Heartbeat emitted by the sandbox while a slow plugin is still loading,
// so the host's kReadyTimeoutMs handshake watchdog measures its deadline
// against the last heartbeat rather than fast-failing a legitimately slow
// first-run load. Purely additive: pre-kLoading hosts ignore it as an
// unknown event, and pre-kLoading sandboxes simply never send it, so no
// protocol-version bump is needed.
inline constexpr const char* kLoading = "loading";
inline constexpr const char* kParameterChanged = "parameterChanged";
inline constexpr const char* kEditorClosed = "editorClosed";
inline constexpr const char* kLog = "log";
inline constexpr const char* kError = "error";
inline constexpr const char* kGoodbye = "goodbye";
}
// Per-block MIDI bundling.
//
// In v1 the host posted each MIDI event over the control pipe from the audio
// thread, which is a classic priority-inversion footgun (mutex + overlapped
// pipe I/O on the audio callback). v2 inlines MIDI in the audio shared memory
// so the audio thread does no IPC beyond two atomic stores and a SetEvent.
//
// Layout: one `MidiQueue` per *input* slot (host → sandbox direction). The
// host fills the upcoming slot's queue immediately before pushing the audio
// block; the sandbox drains it immediately after popping the same slot.
//
// Caps are deliberately tight. SysEx > 4 bytes does not fit and is silently
// dropped (overflow counter); a separate v3 op can carry it if a real
// workload ever hits the case.
inline constexpr uint32_t kMidiEventMaxBytes = 4;
inline constexpr uint32_t kMidiEventsPerSlot = 64;
struct MidiEvent
{
uint32_t frame = 0; // sample offset within the block
uint32_t size = 0; // 1..kMidiEventMaxBytes
uint8_t bytes[kMidiEventMaxBytes] = {};
};
struct MidiQueue
{
// `count` is published with release semantics by the host after the
// matching `events[]` entries are written; sandbox loads with acquire
// semantics. Accessed via std::atomic_ref<uint32_t> from .cpp so the
// shm layout stays trivially copyable.
alignas(8) uint32_t count = 0;
MidiEvent events[kMidiEventsPerSlot] = {};
// Per-slot overflow was here in the first v2 cut, but slots are reused
// round-robin and the value would accumulate across all uses of that
// slot — confusing for any "did this block overflow?" reader, redundant
// for any "lifetime total" reader. Cumulative count lives in
// AudioShmHeader.midiOverflows.
};
// Shared-memory layout for the audio fast path.
//
// All offsets are bytes from the start of the mapping. Sized at spawn time
// from the prepared sample rate / block size / channel count; the values
// below are hard caps a sandbox will refuse to exceed.
//
// Atomic indices are stored as plain uint64_t for shm layout portability
// and accessed via std::atomic_ref<uint64_t> at the call site (C++20). Don't
// reintroduce reinterpret_cast to std::atomic<uint64_t>* — that's not
// layout-guaranteed. Modulo-`maxBlocks` of an index gives the block slot.
//
// Per-direction split (v2): input ring (host → sandbox) and output ring
// (sandbox → host) each get their own writer/reader index pair so the two
// directions can advance independently without sharing state.
struct AudioShmHeader
{
uint32_t magic = 0; // kAudioShmMagic
uint32_t protocolVersion = 0; // kProtocolVersion
uint32_t maxBlocks = 0;
uint32_t maxBlockSamples = 0;
uint32_t maxChannels = 0;
uint32_t sampleRate = 0;
alignas(8) uint64_t inWriteIdx = 0; // host produces ring A (input audio + MIDI)
alignas(8) uint64_t inReadIdx = 0; // sandbox consumes ring A
alignas(8) uint64_t outWriteIdx = 0; // sandbox produces ring B (output audio)
alignas(8) uint64_t outReadIdx = 0; // host consumes ring B
// Direction-agnostic diagnostic counters. xruns covers any pushBlock
// where the destination ring was full; dropouts covers any popBlock that
// timed out waiting for the partner; midiOverflows covers any MIDI event
// dropped by pushInputBlock for being SysEx-sized or because the slot's
// MidiQueue already held kMidiEventsPerSlot entries.
alignas(8) uint64_t xruns = 0;
alignas(8) uint64_t dropouts = 0;
alignas(8) uint64_t midiOverflows = 0;
// Byte offsets of the two rings + per-slot MIDI region inside the mapping.
// Convenient for tools and asserts; computed at spawn time.
uint64_t inputRingOffset = 0;
uint64_t outputRingOffset = 0;
uint64_t midiQueueOffset = 0; // base of MidiQueue[maxBlocks]
uint64_t ringBytesPerSlot = 0;
};
// Convenience: a fully-validated set of audio dimensions, agreed at handshake.
struct AudioDimensions
{
uint32_t maxBlocks = kAudioMaxBlocks;
uint32_t maxBlockSamples = kAudioMaxBlockSamples;
uint32_t maxChannels = 2;
uint32_t sampleRate = 48000;
constexpr bool operator==(const AudioDimensions& o) const
{
return maxBlocks == o.maxBlocks && maxBlockSamples == o.maxBlockSamples
&& maxChannels == o.maxChannels && sampleRate == o.sampleRate;
}
constexpr uint64_t bytesPerSlot() const
{
// float32, planar across channels.
return uint64_t(maxBlockSamples) * uint64_t(maxChannels) * sizeof(float);
}
constexpr uint64_t totalShmBytes() const
{
// Header + two audio rings + per-input-slot MidiQueue.
return sizeof(AudioShmHeader)
+ 2 * uint64_t(maxBlocks) * bytesPerSlot()
+ uint64_t(maxBlocks) * sizeof(MidiQueue);
}
};
// JSON helpers — thin wrappers around juce::JSON to keep call sites tidy.
// All sandbox messages flow through these so the framing/encoding is
// consistent (and any future migration to a binary codec is one-spot).
namespace wire {
// Build the standard envelope:
// { "v": 1, "id": <int|null>, "op": "<name>", "args": { ... } }
juce::var makeRequest(int requestId, const char* op, const juce::var& args);
// Sandbox-originated:
// { "v": 1, "event": "<name>", "data": { ... } }
juce::var makeEvent(const char* eventName, const juce::var& data);
// Reply:
// { "v": 1, "id": <int>, "ok": true/false, "result": ..., "error": ... }
juce::var makeReply(int requestId, bool ok, const juce::var& result,
const juce::String& errorMessage = {});
// Serialise to a UTF-8 buffer (no length prefix — that's the channel's job).
juce::MemoryBlock encode(const juce::var& v);
// Parse a UTF-8 buffer back into a var. Returns juce::var::undefined() on parse
// error (and stashes the reason in `errorOut` if provided).
juce::var decode(const void* data, size_t bytes, juce::String* errorOut = nullptr);
} // namespace wire
} // namespace slopsmith::sandbox
@@ -0,0 +1,48 @@
// Sandbox factory — POSIX (macOS + Linux) resolveSandboxExe(). The routing
// policy lives in SandboxFactory_shared.cpp.
//
// Replaces the old SandboxFactory_stub.cpp: macOS/Linux now route VST3 plugins
// through the out-of-process sandbox (slopsmith-vst-host) like Windows.
#include "SandboxedProcessor.h"
#include "../VSTTrace.h"
#include <juce_core/juce_core.h>
#include <cstdlib> // getenv
#include <dlfcn.h> // dladdr
namespace slopsmith::sandbox {
juce::File resolveSandboxExe()
{
// Locate the directory of the addon shared object via dladdr on this
// function's address — juce::File::currentExecutableFile returns the host
// process (Electron / node), not the .node, when we're loaded as an addon.
// (The Windows backend uses GetModuleHandleEx for the same reason.)
juce::File addonDir;
Dl_info info{};
if (dladdr(reinterpret_cast<const void*>(&resolveSandboxExe), &info) != 0
&& info.dli_fname != nullptr)
{
addonDir = juce::File(juce::String::fromUTF8(info.dli_fname))
.getParentDirectory();
}
if (addonDir.exists())
{
auto candidate = addonDir.getChildFile("slopsmith-vst-host");
if (candidate.existsAsFile()) return candidate;
}
// Explicit dev override — opt-in via SLOPSMITH_DEV_SANDBOX_PATH (matches the
// Windows backend; fail closed in production rather than probing the CWD).
if (const char* env = ::getenv("SLOPSMITH_DEV_SANDBOX_PATH"))
{
const juce::File explicitPath{ juce::String::fromUTF8(env) };
if (explicitPath.existsAsFile()) return explicitPath;
}
return {};
}
} // namespace slopsmith::sandbox
+146
View File
@@ -0,0 +1,146 @@
// Sandbox factory — platform-neutral routing policy.
//
// Decides whether a plugin loads through the out-of-process sandbox and, if so,
// constructs a SandboxedProcessor. Only resolveSandboxExe() (locating the
// slopsmith-vst-host binary next to the addon) is platform-specific — it lives
// in SandboxFactory_{win,posix}.cpp.
#include "SandboxedProcessor.h"
#include "../VSTTrace.h"
#include <juce_core/juce_core.h>
#include <cmath> // std::isfinite, std::lround
#include <limits> // std::numeric_limits
#include <mutex> // guards the runtime crash blocklist
namespace slopsmith::sandbox {
namespace {
// Historical pre-seed of plugins known to fail in-process. With the
// sandbox-by-default policy in shouldSandbox() below, every VST3 routes to the
// sandbox regardless of this list, so it no longer determines routing on its
// own. It survives as (a) documentation of *why* each plugin originally needed
// the sandbox, (b) diagnostic tagging in shouldSandbox's VST_TRACE output, and
// (c) forward-looking infrastructure for a future per-plugin opt-out.
const juce::StringArray kDefaultNeedsSandboxFilenames = {
"Guitar Rig",
"Graphene",
"TONEX",
"AmpliTube",
};
// Runtime crash blocklist: full plugin paths that crashed the app on a previous
// run, supplied by the renderer's VST crash guard via setCrashedPlugins().
std::mutex g_crashedPluginsMutex;
juce::StringArray g_crashedPlugins;
} // anonymous
// Routing policy: every VST3 plugin loads via the out-of-process sandbox.
// Non-VST3 processors (NAM, IR) stay in-process. (See the long rationale in the
// git history / docs: plugins assume the host's message thread is the OS main
// thread with STA COM — which the sandbox child provides and Electron's
// background JUCE thread does not.)
bool shouldSandbox(const juce::PluginDescription& desc)
{
const auto path = juce::File(desc.fileOrIdentifier);
// VST3 only: non-VST3 processors (NAM models, IRs) keep loading in-process.
if (!path.getFileName().endsWithIgnoreCase(".vst3"))
return false;
// Runtime crash blocklist — diagnostic tagging only under sandbox-by-default.
{
const std::lock_guard<std::mutex> lock(g_crashedPluginsMutex);
const auto canonical = path.getFullPathName();
if (g_crashedPlugins.contains(canonical, /*ignoreCase*/ true))
{
VST_TRACE("shouldSandbox: %s — on the runtime crash blocklist",
desc.fileOrIdentifier.toRawUTF8());
return true;
}
}
// Pre-seed filename match — diagnostic tagging only.
const auto basename = path.getFileNameWithoutExtension();
for (auto& needle : kDefaultNeedsSandboxFilenames)
{
if (basename.startsWithIgnoreCase(needle))
{
VST_TRACE("shouldSandbox: %s — filename starts with '%s'",
desc.fileOrIdentifier.toRawUTF8(), needle.toRawUTF8());
return true;
}
}
VST_TRACE("shouldSandbox: %s — default policy (every VST3 sandboxes)",
desc.fileOrIdentifier.toRawUTF8());
return true;
}
std::unique_ptr<juce::AudioProcessor> tryLoadSandboxed(
const juce::PluginDescription& desc,
double sampleRate, int blockSize,
juce::String& errorOut)
{
if (!shouldSandbox(desc))
return nullptr;
auto exe = resolveSandboxExe();
if (!exe.existsAsFile())
{
errorOut = "slopsmith-vst-host not found";
return nullptr;
}
// Validate sampleRate before narrowing to uint32_t — `(uint32_t)NaN` is UB
// and silently accepting 0 / negative / overflow makes a bad caller surface
// as a late sandbox-spawn failure instead of a clear errorOut here.
if (! std::isfinite(sampleRate) || sampleRate <= 0.0
|| sampleRate > (double)(std::numeric_limits<uint32_t>::max)())
{
errorOut = "invalid sampleRate: " + juce::String(sampleRate);
return nullptr;
}
SandboxedProcessor::SpawnConfig cfg;
cfg.pluginPath = desc.fileOrIdentifier;
cfg.pluginName = desc.name.isNotEmpty() ? desc.name : "plugin";
cfg.sandboxExePath = exe.getFullPathName();
cfg.audio.sampleRate = (uint32_t)std::lround(sampleRate);
// Clamp to the protocol cap: vst-host's kPrepare rejects blockSize
// > kAudioMaxBlockSamples, so spawning a larger shm layout would later fail
// the prepare round-trip rather than silently misbehave.
cfg.audio.maxBlockSamples = (uint32_t)juce::jlimit(
64, (int)kAudioMaxBlockSamples, blockSize);
cfg.audio.maxChannels = 2;
cfg.audio.maxBlocks = kAudioMaxBlocks;
return SandboxedProcessor::spawn(cfg, errorOut);
}
void addCrashedPlugin(const juce::String& pluginPath)
{
if (pluginPath.isEmpty()) return;
const auto canonical = juce::File(pluginPath).getFullPathName();
const std::lock_guard<std::mutex> lock(g_crashedPluginsMutex);
if (! g_crashedPlugins.contains(canonical, /*ignoreCase*/ true))
{
g_crashedPlugins.add(canonical);
VST_TRACE("addCrashedPlugin: %s appended to runtime crash blocklist",
canonical.toRawUTF8());
}
}
void setCrashedPlugins(const juce::StringArray& pluginPaths)
{
const std::lock_guard<std::mutex> lock(g_crashedPluginsMutex);
g_crashedPlugins.clearQuick();
for (const auto& p : pluginPaths)
g_crashedPlugins.add(p.isNotEmpty() ? juce::File(p).getFullPathName() : p);
VST_TRACE("setCrashedPlugins: %d plugin(s) on the runtime crash blocklist",
g_crashedPlugins.size());
}
} // namespace slopsmith::sandbox
+77
View File
@@ -0,0 +1,77 @@
// Sandbox factory — Windows resolveSandboxExe(). The routing policy
// (shouldSandbox / tryLoadSandboxed / the crash blocklist) is platform-neutral
// and lives in SandboxFactory_shared.cpp.
#include "SandboxedProcessor.h"
#include "../VSTTrace.h"
#include <juce_core/juce_core.h>
#include <vector> // dynamic buffer for the module-path + env-var lookups
#include <windows.h> // GetModuleHandleExW / GetModuleFileNameW
namespace slopsmith::sandbox {
juce::File resolveSandboxExe()
{
// Locate the directory of the .node DLL via GetModuleHandleEx with this
// function's address — juce::File::currentExecutableFile returns the host's
// exe (node.exe / electron.exe) when we're loaded as an addon, which points
// at the wrong directory entirely.
juce::File addonDir;
HMODULE selfModule = nullptr;
if (GetModuleHandleExW(GET_MODULE_HANDLE_EX_FLAG_FROM_ADDRESS
| GET_MODULE_HANDLE_EX_FLAG_UNCHANGED_REFCOUNT,
reinterpret_cast<LPCWSTR>(&resolveSandboxExe),
&selfModule)
&& selfModule != nullptr)
{
// Grow the buffer until GetModuleFileNameW returns < capacity. MAX_PATH
// is the floor for back-compat; long-paths-enabled installs / deep dev
// trees can blow past it. Cap at 32K (Windows long-path ceiling).
std::vector<wchar_t> buf(MAX_PATH + 1);
for (;;)
{
const DWORD n = GetModuleFileNameW(selfModule, buf.data(),
(DWORD)buf.size());
if (n > 0 && n < buf.size())
{
addonDir = juce::File(juce::String(buf.data())).getParentDirectory();
break;
}
if (buf.size() >= 32768) break;
buf.resize(buf.size() * 2);
}
}
if (addonDir.exists())
{
auto candidate = addonDir.getChildFile("slopsmith-vst-host.exe");
if (candidate.existsAsFile()) return candidate;
}
// Explicit dev override — opt-in via SLOPSMITH_DEV_SANDBOX_PATH. The
// previous implicit CWD probe was a search-path attack vector; fail closed
// in production, require the env var for dev workflows.
const wchar_t* kVar = L"SLOPSMITH_DEV_SANDBOX_PATH";
const DWORD probe = GetEnvironmentVariableW(kVar, nullptr, 0);
if (probe > 0)
{
std::vector<wchar_t> buf(probe);
const DWORD got = GetEnvironmentVariableW(kVar, buf.data(), probe);
if (got > 0 && got < probe)
{
const juce::File explicitPath{ juce::String(buf.data()) };
if (explicitPath.existsAsFile()) return explicitPath;
}
else
{
VST_TRACE("SandboxFactory: SLOPSMITH_DEV_SANDBOX_PATH read race "
"(probe=%lu, got=%lu)",
(unsigned long)probe, (unsigned long)got);
}
}
return {};
}
} // namespace slopsmith::sandbox
+582
View File
@@ -0,0 +1,582 @@
#include "SandboxedProcessor.h"
#include "ControlChannel.h"
#include "AudioChannel.h"
#include "SubprocessHandle.h"
#include "../VSTTrace.h"
#if ! JUCE_WINDOWS
#include <fcntl.h> // F_DUPFD_CLOEXEC
#include <unistd.h> // close
#include <vector>
#endif
namespace slopsmith::sandbox {
SandboxedProcessor::SandboxedProcessor(SpawnConfig cfg)
: juce::AudioProcessor(BusesProperties()
.withInput("Input", juce::AudioChannelSet::stereo(), true)
.withOutput("Output", juce::AudioChannelSet::stereo(), true))
, spawnConfig(std::move(cfg))
, spawnName(spawnConfig.pluginName)
{
}
SandboxedProcessor::~SandboxedProcessor()
{
// Destruction is a deliberate teardown, not a crash. Drop the onCrash
// callback before teardown so it doesn't fire — consumers reasonably
// assume onCrash means "the sandbox died unexpectedly".
setOnCrash(nullptr);
teardown("destructor");
}
void SandboxedProcessor::setOnCrash(CrashCallback cb)
{
std::lock_guard<std::mutex> lock(onCrashMutex);
onCrash = std::move(cb);
}
std::unique_ptr<SandboxedProcessor> SandboxedProcessor::spawn(const SpawnConfig& cfg,
juce::String& errorOut)
{
// Validate caller-supplied dims against the protocol caps before any
// shm/pipe allocation — both sides assume slots fit inside the cap-
// derived layout; an oversize maxBlocks/maxChannels would let
// createHostSide allocate beyond what openSandboxSide validates
// (which today only checks magic + protocolVersion).
if (cfg.audio.maxBlocks == 0 || cfg.audio.maxBlocks > kAudioMaxBlocks)
{
errorOut = "invalid audio.maxBlocks: " + juce::String((int)cfg.audio.maxBlocks)
+ " (cap=" + juce::String((int)kAudioMaxBlocks) + ")";
return nullptr;
}
if (cfg.audio.maxChannels == 0 || cfg.audio.maxChannels > kAudioMaxChannels)
{
errorOut = "invalid audio.maxChannels: " + juce::String((int)cfg.audio.maxChannels)
+ " (cap=" + juce::String((int)kAudioMaxChannels) + ")";
return nullptr;
}
std::unique_ptr<SandboxedProcessor> p(new SandboxedProcessor(cfg));
if (!p->initialise(errorOut))
return nullptr;
return p;
}
bool SandboxedProcessor::initialise(juce::String& errorOut)
{
control = std::make_unique<ControlChannel>();
audio = std::make_unique<AudioChannel>();
juce::String pipeName, err;
if (!control->createServerSide(pipeName, err))
{
errorOut = "control pipe: " + err;
return false;
}
AudioChannel::Names audioNames;
if (!audio->createHostSide(spawnConfig.audio, audioNames, err))
{
errorOut = "audio shm: " + err;
return false;
}
subprocess = std::make_unique<SubprocessHandle>();
juce::StringArray args;
args.add("--plugin-path"); args.add(spawnConfig.pluginPath);
args.add("--sample-rate"); args.add(juce::String((int)spawnConfig.audio.sampleRate));
args.add("--max-block"); args.add(juce::String((int)spawnConfig.audio.maxBlockSamples));
args.add("--channels"); args.add(juce::String((int)spawnConfig.audio.maxChannels));
#if JUCE_WINDOWS
// Windows: the child re-opens the IPC objects by name.
args.add("--control-pipe"); args.add(pipeName);
args.add("--audio-shm"); args.add(audioNames.shm);
args.add("--audio-event-out"); args.add(audioNames.evtToHost);
args.add("--audio-event-in"); args.add(audioNames.evtToSandbox);
#else
// POSIX: the IPC objects are fd-passed (no named objects). The child
// reads its fd numbers from argv; SubprocessHandle::startPosix dup2()s the
// host-side fds onto these fixed numbers in the child. Targets sit past
// stdin/stdout/stderr.
static constexpr int kChildControlFd = 3;
static constexpr int kChildAudioEvtFd = 4;
static constexpr int kChildAudioShmFd = 5;
args.add("--control-fd"); args.add(juce::String(kChildControlFd));
args.add("--audio-evt-fd"); args.add(juce::String(kChildAudioEvtFd));
args.add("--audio-shm-fd"); args.add(juce::String(kChildAudioShmFd));
#endif
// ControlChannel keeps the event callback past initialise()'s return, so
// the ready-handshake state has to outlive this stack frame. Wrap it in a
// shared_ptr the lambda copies — the future's shared state lives via the
// promise inside.
struct ReadyState
{
std::promise<bool> readyP;
std::atomic<bool> readySet{false};
// Millisecond-counter timestamp of the last `loading` heartbeat from
// the sandbox. The ready-wait below measures its timeout against this
// rather than against spawn time, so a slow-but-alive plugin that
// keeps heart-beating is never fast-failed.
std::atomic<juce::uint32> lastProgressMs{0};
};
auto readyState = std::make_shared<ReadyState>();
readyState->lastProgressMs.store(juce::Time::getMillisecondCounter(),
std::memory_order_relaxed);
auto readyF = readyState->readyP.get_future();
// Ordering invariant for the cached fields below:
// 1. The event callback (control I/O thread) writes the cached fields.
// 2. The same callback then publishes via `alive.store(release)`.
// Reads MUST observe `alive` via `isAlive()` (which does `load(acquire)`)
// before touching the cached fields. The getters in SandboxedProcessor.h
// already gate this way; new getters must follow the same pattern, or
// the cached field has to become std::atomic.
auto eventCb = [this, readyState](const juce::String& evname,
const juce::var& data)
{
if (evname == event::kLoading)
{
// Slow-load heartbeat: advance the ready-wait deadline, nothing
// else. Not forwarded to onControlEvent — it carries no payload.
readyState->lastProgressMs.store(juce::Time::getMillisecondCounter(),
std::memory_order_relaxed);
return;
}
if (evname == event::kReady)
{
bool expected = false;
if (readyState->readySet.compare_exchange_strong(expected, true,
std::memory_order_acq_rel))
{
// Validate the sandbox-advertised protocol version against
// the host build. The per-frame `v` check in
// ControlChannel::ioLoop catches mismatched messages too,
// but doing it here makes the failure a clean handshake
// error instead of "first message in/out the channel
// suddenly tears down". 0 means the sandbox didn't emit
// the field (pre-protocolVersion-in-ready build); treat
// as legacy = current protocol so old test stubs still
// work.
const int wireVer = (int)data.getProperty("protocolVersion", 0);
if (wireVer != 0 && wireVer != (int)kProtocolVersion)
{
VST_TRACE("[sandbox] protocol version mismatch at handshake: "
"host=%d sandbox=%d",
(int)kProtocolVersion, wireVer);
try { readyState->readyP.set_value(false); } catch (...) {}
return;
}
descriptionCached.name = data.getProperty("pluginName", "").toString();
descriptionCached.manufacturerName =
data.getProperty("manufacturer", "").toString();
// Prefer the plugin's own reported fileOrIdentifier /
// pluginFormatName (from desc.* on the sandbox side) when
// the ready event carries them — some VST3s normalise the
// path differently than the caller passed in. Fall back
// to the spawn-time hardcodes when the wire field is
// empty so the description always has _something_ usable
// for a SignalChain round-trip.
{
juce::String wireFOI = data.getProperty("fileOrIdentifier", "").toString();
juce::String wireFmt = data.getProperty("pluginFormatName", "").toString();
descriptionCached.fileOrIdentifier =
wireFOI.isNotEmpty() ? wireFOI : spawnConfig.pluginPath;
descriptionCached.pluginFormatName =
wireFmt.isNotEmpty() ? wireFmt : juce::String("VST3");
// uniqueId + deprecatedUid are critical for SignalChain
// persistence: a saved session re-locates plugins by
// identity, not by file path. Without these, sandboxed
// plugins wouldn't survive a session save/load round-
// trip across host machines (where file paths differ).
descriptionCached.uniqueId =
(int)data.getProperty("uniqueId", 0);
descriptionCached.deprecatedUid =
(int)data.getProperty("deprecatedUid", 0);
}
hasEditorCached = (bool)data.getProperty("hasEditor", false);
acceptsMidiCached = (bool)data.getProperty("acceptsMidi", false);
producesMidiCached = (bool)data.getProperty("producesMidi", false);
numInputsCached = (int)data.getProperty("numInputs",
(int)spawnConfig.audio.maxChannels);
numOutputsCached = (int)data.getProperty("numOutputs",
(int)spawnConfig.audio.maxChannels);
alive.store(true, std::memory_order_release);
try { readyState->readyP.set_value(true); } catch (...) {}
}
}
onControlEvent(evname, data);
};
auto failHandshake = [readyState]()
{
bool expected = false;
if (readyState->readySet.compare_exchange_strong(expected, true,
std::memory_order_acq_rel))
{
try { readyState->readyP.set_value(false); } catch (...) {}
}
};
// Lifetime invariant for the callbacks below: both capture `this` and
// call teardown(). teardown() calls control->stop() and subprocess->
// shutdown() which JOIN the threads invoking these callbacks. So the
// callbacks always complete before destruction proceeds — but only
// because stop()/shutdown() happen at the top of teardown(), before
// member-destruction. Keep that ordering when editing teardown(); if
// a future refactor moves member destruction before the joins, the
// watcher thread could re-enter teardown on a partially-destroyed
// `this`. A weak_ptr-based state block would make this self-evident.
auto disconnectCb = [this, failHandshake](const juce::String& reason)
{
failHandshake();
teardown(reason);
};
if (!control->start(eventCb, disconnectCb))
{
errorOut = "control->start failed: " + control->getLastStartError();
return false;
}
auto onExitCb = [this, failHandshake](int code)
{
failHandshake();
teardown("sandbox exit code " + juce::String(code));
};
#if JUCE_WINDOWS
const bool spawnOk = subprocess->start(spawnConfig.sandboxExePath, args,
onExitCb, err);
#else
// Dup each host-side handoff fd to a high number (>= 10) before spawning,
// so none collides with a child target fd (3/4/5). posix_spawn applies the
// dup2 file-actions in order in the child; without this, a source fd that
// happens to equal a *later* action's target would be clobbered first.
// F_DUPFD_CLOEXEC keeps the temporaries out of the child (only the
// explicit dup2 targets survive under POSIX_SPAWN_CLOEXEC_DEFAULT).
auto dupHigh = [](int fd) { return fd < 0 ? -1 : ::fcntl(fd, F_DUPFD_CLOEXEC, 10); };
const int ctlSrc = dupHigh(control->sandboxFd());
const int evtSrc = dupHigh(audioNames.sandboxAudioFd);
const int shmSrc = dupHigh(audioNames.shmFd);
bool spawnOk = false;
if (ctlSrc < 0 || evtSrc < 0 || shmSrc < 0)
{
err = "failed to dup sandbox handoff fds";
}
else
{
std::vector<SubprocessHandle::InheritedFd> inherited{
{ kChildControlFd, ctlSrc },
{ kChildAudioEvtFd, evtSrc },
{ kChildAudioShmFd, shmSrc },
};
spawnOk = subprocess->startPosix(spawnConfig.sandboxExePath, args,
inherited, onExitCb, err);
}
// Close the high-dup temporaries (posix_spawn captured them at call time)
// and our copies of the handoff fds, now dup2()'d into the child. The host
// keeps its own channel ends in the channels' Impl; dropping these lets the
// host observe EOF/POLLHUP when the child dies.
if (ctlSrc >= 0) ::close(ctlSrc);
if (evtSrc >= 0) ::close(evtSrc);
if (shmSrc >= 0) ::close(shmSrc);
control->closeSandboxFd();
if (audioNames.shmFd >= 0) ::close(audioNames.shmFd);
if (audioNames.sandboxAudioFd >= 0) ::close(audioNames.sandboxAudioFd);
#endif
if (!spawnOk)
{
errorOut = "subprocess: " + err;
// Symmetry with the success path: control->start() already armed the
// I/O thread inside ConnectNamedPipe; stopping it explicitly here
// shortens the time we hold the pipe + the watchdog grace period
// (otherwise it'd sit blocked until the destructor's teardown picks
// it up at unique_ptr drop).
//
// Lifetime invariant for the disconnect callback fired in this
// window: control->start() succeeded before subprocess->start(), so
// the disconnectCb (captures `this`, calls teardown) is reachable
// for the brief gap between the two starts. teardown() calls
// subprocess->shutdown() on a SubprocessHandle whose start() never
// ran — safe because `running` defaults false and shutdown() bails
// immediately, leaving the (empty) PROCESS_INFORMATION handles
// as nullptr for CloseHandle to no-op.
control->stop();
return false;
}
// Wait for the `ready` handshake against two independent bounds:
// * per-heartbeat — fail if no `loading` heartbeat (or `ready`) arrives
// within spawnTimeoutMs. Catches a dead or frozen sandbox.
// * absolute — fail after kReadyAbsoluteTimeoutMs regardless of
// heartbeats. The heartbeat is a fixed timer, not a real progress
// signal, so a sandbox that stays alive and keeps heart-beating but
// whose plugin load never completes would defeat the per-heartbeat
// bound forever; the absolute cap is the hard backstop.
const juce::uint32 waitStartMs = juce::Time::getMillisecondCounter();
for (;;)
{
if (readyF.wait_for(std::chrono::milliseconds(250))
== std::future_status::ready)
break;
const juce::uint32 nowMs = juce::Time::getMillisecondCounter();
const juce::uint32 sinceProgress =
nowMs - readyState->lastProgressMs.load(std::memory_order_relaxed);
const juce::uint32 sinceStart = nowMs - waitStartMs;
const bool absoluteExceeded =
sinceStart > (juce::uint32) kReadyAbsoluteTimeoutMs;
if (sinceProgress > (juce::uint32) spawnConfig.spawnTimeoutMs
|| absoluteExceeded)
{
errorOut = absoluteExceeded
? "sandbox did not become ready within absolute timeout"
: "sandbox did not become ready within timeout";
// Explicit teardown so all the resource-release wiring lives in
// one place. The destructor would otherwise pick this up when the
// outer unique_ptr drops, but it's clearer to tear down on the
// failure edge and not rely on destruction order. Distinct reason
// per bound so crash/teardown reporting can tell a silent sandbox
// apart from a plugin that kept heart-beating but never loaded.
teardown(absoluteExceeded ? "ready absolute timeout" : "ready timeout");
return false;
}
}
if (!readyF.get())
{
// The promise was resolved with false by failHandshake (subprocess
// exit, control disconnect, etc.). errorOut was likely empty until
// now — surface a concrete reason so callers don't see "unknown".
if (errorOut.isEmpty())
errorOut = "sandbox handshake failed before ready (subprocess "
"exit or control-pipe disconnect)";
return false;
}
return true;
}
void SandboxedProcessor::teardown(const juce::String& reason)
{
alive.exchange(false, std::memory_order_acq_rel);
// Clear the editor-open bit too: if the sandbox dies while the editor
// was open, isEditorOpen() would otherwise stay stuck true forever and
// renderer UI showing "editor open" would never reset.
editorOpen.store(false, std::memory_order_release);
// Copy under the mutex so a concurrent setOnCrash() can't race with
// std::function's internals. Invoking happens later (outside the lock)
// so a callback that re-enters setOnCrash doesn't deadlock.
//
// Invoke regardless of `wasAlive`: pre-ready failures (subprocess
// exits before handshake, plugin DLL fails to load → exit code 5)
// are exactly the case where an async caller most needs to know
// the sandbox died. Today initialise() also surfaces such failures
// via errorOut, but a future async-spawn caller that registers
// setOnCrash and then waits asynchronously needs the callback.
CrashCallback cb;
{
std::lock_guard<std::mutex> lock(onCrashMutex);
cb = onCrash;
}
// The closers themselves are individually idempotent, but running them
// concurrently from the destructor and the subprocess-exit watcher races
// on CloseHandle. Gate the whole block on a single-fire latch.
bool expected = false;
if (resourcesReleased.compare_exchange_strong(expected, true,
std::memory_order_acq_rel))
{
if (control) control->stop();
if (subprocess) subprocess->shutdown(1500);
if (audio) audio->close();
}
if (cb) cb(reason);
}
void SandboxedProcessor::requestCloseEditor()
{
// Always clear the cached editorOpen bit — even if the sandbox is no
// longer reachable. If the sandbox crashed or was torn down while the
// editor was open, isEditorOpen() would otherwise remain stuck true
// forever and renderer UI showing "editor open" would never reset.
editorOpen.store(false, std::memory_order_release);
// Send the IPC unconditionally when the channel is up — the host's
// editorOpen bit is best-effort tracking under the top-level-window
// model (a kOpenEditor whose reply was lost on the wire leaves the
// child holding a visible editor window the host doesn't know about),
// and the child's kCloseEditor handler is idempotent so a redundant
// send is harmless.
if (!control || !isAlive()) return;
control->postNoReply(op::kCloseEditor, {});
}
bool SandboxedProcessor::isAlive() const noexcept
{
return alive.load(std::memory_order_acquire);
}
void SandboxedProcessor::onControlEvent(const juce::String& evname, const juce::var& data)
{
if (evname == event::kEditorClosed)
{
editorOpen.store(false, std::memory_order_release);
}
// Other events (parameterChanged, log, error) are handled by upper layers
// in follow-up sessions.
(void)data;
}
void SandboxedProcessor::prepareToPlay(double sampleRate, int samplesPerBlock)
{
if (!control || !isAlive()) return;
juce::DynamicObject::Ptr args(new juce::DynamicObject());
args->setProperty("sampleRate", sampleRate);
args->setProperty("blockSize", samplesPerBlock);
juce::String err;
control->request(op::kPrepare, juce::var(args.get()),
kDefaultReplyTimeoutMs, &err);
if (err.isNotEmpty())
VST_TRACE("[sandbox] prepareToPlay sr=%.0f bs=%d failed: %s",
sampleRate, samplesPerBlock, err.toRawUTF8());
}
void SandboxedProcessor::releaseResources()
{
// Nothing to release here — block-size changes are signalled via prepareToPlay.
}
void SandboxedProcessor::processBlock(juce::AudioBuffer<float>& buffer,
juce::MidiBuffer& midiMessages)
{
if (!isAlive() || !audio)
{
// Sandbox is gone — pass silence through so the chain keeps
// flowing. Leave midiMessages untouched: downstream processors
// in a SignalChain expect to see MIDI we can't deliver to our
// own sandbox, but they may still consume it themselves.
buffer.clear();
return;
}
const int n = buffer.getNumSamples();
// v2: MIDI rides inline in the input slot's MidiQueue. Zero control-pipe
// I/O on the audio thread (was the deferred Copilot finding from PR #63).
//
// We deliberately do NOT clear `midiMessages` after pushInputBlock — the
// host has only published a *snapshot* into the slot's queue, the
// original buffer still belongs to the SignalChain. Downstream
// processors (e.g. a synth after a sandboxed effect) need to see the
// same MIDI events the chain delivered to us. Mirrors the early-return
// branch above which leaves midiMessages untouched for the same reason.
if (!audio->pushInputBlock(buffer, midiMessages, n))
{
// Input ring full — sandbox isn't keeping up. Don't wait the full
// pop timeout (which would extend the dropout); zero output and
// exit. xruns was incremented inside pushInputBlock.
VST_TRACE("[sandbox] processBlock: input ring full, dropping (xruns++)");
buffer.clear();
return;
}
// Pop timeout = 4× the block period, floored at 2 ms so very high
// sample rates / small blocks don't end up with sub-millisecond budgets.
constexpr int kPopTimeoutBlockMultiplier = 4;
// Upstream invariant: SandboxFactory_win::tryLoadSandboxed caps
// sampleRate at uint32_t::max via `(uint32_t)std::lround(sr)`, so
// the `(int)spawnConfig.audio.sampleRate` cast below cannot wrap
// negative today. If that cap ever loosens, this divisor could
// become a wrapped-negative int and `jmax(1, negative)` would
// yield 1 → enormous timeout. The jmax(1, ...) is the in-place
// guard; keep it even though the upstream cap currently makes
// it unreachable.
const int popTimeoutMs = (int)juce::jmax(2.0,
1000.0 * n / juce::jmax(1, (int)spawnConfig.audio.sampleRate)
* kPopTimeoutBlockMultiplier);
if (!audio->popBlock(/*isOutputRing=*/true, buffer, n, popTimeoutMs))
{
// Missed deadline — sandbox is too slow or hung. AudioChannel
// already bumped `dropouts`; trace so the missed-deadline path is
// diagnosable without a debugger.
VST_TRACE("[sandbox] processBlock: pop timeout (%d ms), inserting silence",
popTimeoutMs);
buffer.clear();
}
}
bool SandboxedProcessor::requestOpenEditor()
{
if (!isAlive() || !hasEditor() || !control) return false;
// No "already-open short-circuit": the child's kOpenEditor handler
// brings the existing editor window to front when one already exists,
// which is the correct behaviour for a second "Edit" click. A redundant
// round-trip is cheap (one IPC, no plugin recreation) and replaces the
// previous design's complexity around tracking host-side editor state.
juce::String err;
auto result = control->request(op::kOpenEditor, {}, kDefaultReplyTimeoutMs, &err);
if (!result.isObject())
{
// Don't auto-send op::kCloseEditor here. With the top-level-window
// model the child may already have a visible editor window the
// user can interact with (the open could have succeeded but the
// reply got lost on the wire); auto-closing would dismiss a valid
// editor against user intent. If no window is open, the next Edit
// click sends a fresh kOpenEditor, which the child handles either
// by creating one or by toFront-ing the existing one.
return false;
}
// The HWND in the reply payload is left over from the previous embed
// design; the child now owns its own top-level window so the host
// doesn't need the handle. Tracking just the open/closed bit here is
// enough — the kEditorClosed event flips it back when the user clicks
// the window's close button on the child side.
editorOpen.store(true, std::memory_order_release);
return true;
}
void SandboxedProcessor::getStateInformation(juce::MemoryBlock& destData)
{
if (!isAlive()) return;
juce::String err;
auto reply = control->request(op::kGetState, {}, kDefaultReplyTimeoutMs, &err);
if (err.isNotEmpty())
{
// Otherwise a failed round-trip would silently emit an empty blob —
// JUCE writes that to the host's preset, which then round-trips to
// setStateInformation later and resets the plugin to defaults.
// Until the state-cache work lands (PR-body checklist), at least
// make the failure visible.
VST_TRACE("[sandbox] getStateInformation request failed: %s",
err.toRawUTF8());
return;
}
auto b64 = reply.getProperty("stateBase64", "").toString();
juce::MemoryOutputStream mo(destData, false);
if (! juce::Base64::convertFromBase64(mo, b64))
{
// Malformed base64 in the wire payload — leave destData empty
// (mo writes nothing on failure) so the host sees a "no state"
// outcome rather than a partial blob. Surface the failure so
// IPC corruption is diagnosable instead of silently masquerading
// as a plugin with no state.
VST_TRACE("[sandbox] getStateInformation: invalid base64 in reply "
"(len=%d)", (int)b64.length());
}
}
void SandboxedProcessor::setStateInformation(const void* data, int sizeInBytes)
{
if (!isAlive() || data == nullptr || sizeInBytes <= 0) return;
juce::DynamicObject::Ptr args(new juce::DynamicObject());
args->setProperty("stateBase64", juce::Base64::toBase64(data, (size_t)sizeInBytes));
juce::String err;
control->request(op::kSetState, juce::var(args.get()),
kDefaultReplyTimeoutMs, &err);
if (err.isNotEmpty())
VST_TRACE("[sandbox] setStateInformation request failed (%d bytes): %s",
sizeInBytes, err.toRawUTF8());
}
} // namespace slopsmith::sandbox
+241
View File
@@ -0,0 +1,241 @@
// SandboxedProcessor — a juce::AudioProcessor that forwards every call to a
// separate slopsmith-vst-host.exe subprocess via the IPC protocol defined in
// Protocol.h.
//
// SignalChain stores plugins as `std::unique_ptr<juce::AudioProcessor>`. This
// class makes a sandboxed plugin *mostly* indistinguishable from an in-process
// one from SignalChain's point of view: SignalChain calls processBlock() and
// state methods normally; we marshal everything across the IPC boundary.
//
// Known v1 gaps (tracked as follow-up PRs, see PR-body checklist):
// * getParameters() returns no juce::AudioProcessorParameter proxies, so
// parameter automation / UI / preset save round-trip via JUCE's parameter
// API doesn't reach the sandboxed plugin. The control protocol carries
// kSetParameter/kListParameters; the proxy layer that maps them onto
// juce::AudioProcessorParameter is a dedicated follow-up PR.
// * BusesProperties is hard-coded stereo↔stereo at construction (the
// numInputs/numOutputs from the ready event are cached but not yet
// applied — JUCE wants the bus layout at construction time, so dynamic
// reconfiguration lands with the audio-thread-sync follow-up).
//
// One SandboxedProcessor owns exactly one sandbox subprocess. The subprocess
// dies when the SandboxedProcessor is destroyed.
#pragma once
#include <juce_audio_processors/juce_audio_processors.h>
#include <memory>
#include <atomic>
#include <functional>
#include <mutex>
#include "Protocol.h"
namespace slopsmith::sandbox {
class ControlChannel;
class AudioChannel;
class SubprocessHandle;
class SandboxedProcessor final : public juce::AudioProcessor
{
public:
// Parameters captured at spawn time. The factory fills these from the
// PluginDescription before construction so we can pass them to the
// subprocess on its command line.
struct SpawnConfig
{
juce::String pluginPath; // VST3 file path
juce::String pluginName; // for logging
juce::String sandboxExePath; // resolved slopsmith-vst-host.exe path
AudioDimensions audio; // initial dimensions; can grow via setBlockSize
int spawnTimeoutMs = kReadyTimeoutMs;
};
// Construct + spawn. Returns nullptr on any failure (subprocess start
// error, control-pipe disconnect, no `ready` within timeout) and writes
// a descriptive reason into `errorOut`. No exceptions are thrown.
static std::unique_ptr<SandboxedProcessor> spawn(const SpawnConfig& cfg,
juce::String& errorOut);
~SandboxedProcessor() override;
// True after `ready` was received and the subprocess accepted the protocol
// version. False once the subprocess crashes — the audio thread observes
// this and inserts silence rather than blocking.
bool isAlive() const noexcept;
// Callback fired when the subprocess unexpectedly exits or its control
// pipe breaks. Always invoked from a background thread; mutex-guarded
// so concurrent assignment from the owner thread + read from the I/O
// thread don't race on std::function's internal state.
using CrashCallback = std::function<void(const juce::String& reason)>;
void setOnCrash(CrashCallback cb);
// juce::AudioProcessor overrides ────────────────────────────────────────
const juce::String getName() const override { return spawnName; }
void prepareToPlay(double sampleRate, int samplesPerBlock) override;
void releaseResources() override;
void processBlock(juce::AudioBuffer<float>& buffer,
juce::MidiBuffer& midiMessages) override;
double getTailLengthSeconds() const override { return 0.0; }
// Cached-field getters: gate on isAlive() with acquire-semantics so a
// caller that opportunistically queries before the `ready` handshake
// completes can't observe uninitialised state. The event-callback
// populates the cache *before* alive.store(release), so the matching
// alive.load(acquire) in isAlive() pairs as a synchronizes-with edge.
bool acceptsMidi() const override { return isAlive() && acceptsMidiCached; }
bool producesMidi() const override { return isAlive() && producesMidiCached; }
bool isMidiEffect() const override { return false; }
// No host-side editor object. The sandbox child owns the plugin's
// editor as its own top-level window — the previous cross-process
// SetParent path produced a blank rendered surface for D3D / OpenGL
// plugins (Neural DSP Archetypes etc.) because their render context
// lives in the child process and doesn't survive HWND reparenting
// across processes. Reaper's undocked-plugin-window model: the
// window lives in the same process as its paint surface. Open and
// close are driven via requestOpenEditor / requestCloseEditor below,
// and the renderer-side flow in NodeAddon::OpenPluginEditor /
// ClosePluginEditor branches on dynamic_cast<SandboxedProcessor*>
// to skip the host-side PluginEditorWindow creation entirely.
juce::AudioProcessorEditor* createEditor() override { return nullptr; }
// The sandbox child owns a floating top-level editor window in its own
// process — Windows (HWND), macOS (NSWindow), and Linux (X11) alike. On
// Linux this rides JUCE 8's VST3 editor hosting (Steinberg::Linux::IRunLoop
// integrated with the child's MessageManager X11 event loop); the host
// never reparents the window, so a plugin's GL/Qt render context stays in
// the process that created it. Audio + state hosting are platform-neutral.
bool hasEditor() const override { return isAlive() && hasEditorCached; }
// Show the sandbox plugin's editor in a top-level window owned by the
// sandbox child. Idempotent: a second call while the editor is already
// open brings the existing window to front rather than re-creating it
// (the child's kOpenEditor handler does the toFront).
bool requestOpenEditor();
// Close the editor window if one is open. Idempotent — safe to call
// when no editor is open.
void requestCloseEditor();
// Current editor-open state. Set true on a successful requestOpenEditor,
// false on requestCloseEditor and on the child's event::kEditorClosed
// (sent when the user clicks the editor window's close button).
bool isEditorOpen() const noexcept
{
return editorOpen.load(std::memory_order_acquire);
}
int getNumPrograms() override { return 1; }
int getCurrentProgram() override { return 0; }
void setCurrentProgram(int) override {}
const juce::String getProgramName(int) override { return {}; }
void changeProgramName(int, const juce::String&) override {}
void getStateInformation(juce::MemoryBlock& destData) override;
void setStateInformation(const void* data, int sizeInBytes) override;
// Plugin description we synthesised from the `ready` event. Returned by
// getPluginDescription() so SignalChain can present it like any other
// plugin.
juce::PluginDescription getDescription() const
{
// Empty description before the ready handshake — better than a
// torn read of the cached fields.
return isAlive() ? descriptionCached : juce::PluginDescription{};
}
private:
SandboxedProcessor(SpawnConfig cfg);
bool initialise(juce::String& errorOut);
void onControlEvent(const juce::String& event, const juce::var& data);
void teardown(const juce::String& reason);
SpawnConfig spawnConfig;
juce::String spawnName;
// Publication discipline for the cached-from-`ready`-event fields below:
// the control I/O thread writes them, then publishes via
// alive.store(release). Readers MUST go through isAlive() (which does
// load(acquire)) before touching any of these — non-atomic members are
// torn-readable otherwise. Every getter here gates this way; if you add
// a new accessor or want to read these for logging, gate on isAlive()
// or load alive directly with memory_order_acquire first. (Don't
// shortcut with a getDescriptionUnchecked() — it would expose a torn
// PluginDescription on a future caller's first read.)
juce::PluginDescription descriptionCached;
bool hasEditorCached = false;
bool acceptsMidiCached = false;
bool producesMidiCached = false;
// Cached from the `ready` event so the deferred BusesProperties refactor
// can use them without an extra round-trip. Currently informational only
// (the constructor hard-codes stereo I/O — see PR-body follow-up list).
int numInputsCached = 2;
int numOutputsCached = 2;
std::atomic<bool> alive{false};
std::atomic<bool> editorOpen{false};
// Single-fire latch so resource closers only run on the first teardown
// path that reaches them, even if destructor + watcher onExit fire
// concurrently from different threads.
std::atomic<bool> resourcesReleased{false};
std::unique_ptr<SubprocessHandle> subprocess;
std::unique_ptr<ControlChannel> control;
std::unique_ptr<AudioChannel> audio;
std::mutex onCrashMutex;
CrashCallback onCrash;
JUCE_DECLARE_NON_COPYABLE_WITH_LEAK_DETECTOR(SandboxedProcessor)
};
// Factory entry point used by NodeAddon / VSTHost::loadPlugin.
//
// Returns the wrapped sandboxed processor when sandboxing is appropriate for
// the given plugin, or nullptr otherwise. The caller should fall back to the
// existing in-process loader on nullptr.
//
// On non-Windows builds this is a no-op that always returns nullptr; the
// existing in-process loader handles all plugins.
std::unique_ptr<juce::AudioProcessor> tryLoadSandboxed(
const juce::PluginDescription& desc,
double sampleRate, int blockSize,
juce::String& errorOut);
// Decide whether a plugin should be loaded via the out-of-process sandbox
// (slopsmith-vst-host.exe) rather than in-process. Under the current
// sandbox-by-default policy every VST3 plugin routes through the sandbox;
// non-VST3 processors (NAM, IR) stay in-process. The pre-seed filename list
// and the runtime crash blocklist still drive the VST_TRACE diagnostic
// tagging and remain as forward-looking infrastructure for a future
// per-plugin opt-out (letting specific plugins back into in-process), but
// they no longer determine routing on their own.
//
// Exposed for tests and for the UI to surface "this plugin is sandboxed"
// status.
bool shouldSandbox(const juce::PluginDescription& desc);
// Register the set of plugin paths known to have crashed the app on a
// previous run (persisted by the renderer's VST crash guard). A registered
// plugin is routed through the sandbox by shouldSandbox() even if it doesn't
// match the built-in filename heuristic — that's how a never-before-seen
// offender is made safe after a single crash. Paths are matched
// case-insensitively. Each call replaces the previous set.
void setCrashedPlugins(const juce::StringArray& pluginPaths);
// Idempotently append one plugin path to the runtime crash blocklist —
// distinct from setCrashedPlugins() in that it does not clear the existing
// set. Designed to be called from the audio-thread SEH catch in
// SignalChain when a plugin faults during processBlock / prepareToPlay /
// releaseResources, so future LoadVST calls in this session route the
// offending plugin to the out-of-process sandbox.
void addCrashedPlugin(const juce::String& pluginPath);
// Resolve the path to slopsmith-vst-host.exe (sits next to the audio addon
// .node). Returns a non-existent File if it can't be located. Exposed so the
// out-of-process VST scan path can spawn the same host binary as the sandbox.
juce::File resolveSandboxExe();
} // namespace slopsmith::sandbox
+75
View File
@@ -0,0 +1,75 @@
// SubprocessHandle — owns a slopsmith-vst-host.exe subprocess and observes its
// lifetime. Spawn via `start()`; the destructor performs a graceful close
// (terminate-after-timeout) if the process is still running.
#pragma once
#include <juce_core/juce_core.h>
#include <atomic>
#include <functional>
#include <memory>
#include <thread>
#include <vector>
namespace slopsmith::sandbox {
class SubprocessHandle
{
public:
SubprocessHandle();
~SubprocessHandle();
// Spawn the subprocess. `args` are the command-line arguments (the exe
// path is implicit; callers pass it in `exePath`). The exit watcher
// thread reports unexpected exits via `onExit`. Windows: inherits nothing
// (the sandbox connects to all IPC objects by name). On POSIX use
// startPosix — the IPC objects are fd-passed, not named.
bool start(const juce::String& exePath,
const juce::StringArray& args,
std::function<void(int exitCode)> onExit,
juce::String& errorOut);
#if ! JUCE_WINDOWS
// One fd to hand to the child: dup2(hostFd → childFd) in the spawned
// process, with childFd left non-close-on-exec so it survives the exec.
// The child learns its fd numbers from argv. hostFd stays owned by the
// caller (posix_spawn dup2()s a copy; close yours afterwards).
struct InheritedFd { int childFd; int hostFd; };
// POSIX spawn with explicit fd inheritance. On macOS POSIX_SPAWN_CLOEXEC_
// DEFAULT makes every fd close-on-exec by default so ONLY the dup2()'d fds
// below reach the child (the analog of Windows bInheritHandles=FALSE); on
// Linux that flag is absent, so the caller must keep its other fds
// CLOEXEC (the channels do). Uses posix_spawn — never a bare fork —
// because the host has touched CoreAudio/Obj-C and fork-without-exec is
// unsafe there.
bool startPosix(const juce::String& exePath,
const juce::StringArray& args,
const std::vector<InheritedFd>& inherited,
std::function<void(int exitCode)> onExit,
juce::String& errorOut);
#endif
// Escalating graceful close. Windows: post WM_QUIT, then TerminateProcess
// after `timeoutMs`. POSIX: SIGTERM, then SIGKILL after `timeoutMs`.
// (Callers should send the `shutdown` control op first for a clean exit;
// this is the backstop.)
void shutdown(int timeoutMs);
bool isRunning() const noexcept { return running.load(std::memory_order_acquire); }
// Windows DWORD is unsigned 32-bit; storing as int would silently
// narrow a high PID into a negative value when surfaced via pid().
uint32_t pid() const noexcept { return cachedPid; }
private:
struct Impl;
std::unique_ptr<Impl> impl;
std::atomic<bool> running{false};
uint32_t cachedPid = 0;
std::function<void(int)> onExitCb;
std::thread watcher;
JUCE_DECLARE_NON_COPYABLE_WITH_LEAK_DETECTOR(SubprocessHandle)
};
} // namespace slopsmith::sandbox
+26
View File
@@ -0,0 +1,26 @@
// SubprocessHandle::Impl — private OS process-handle wrapper, shared between
// SubprocessHandle_{win,posix}.cpp. Internal to those TUs; not part of the
// public header.
#pragma once
#include "SubprocessHandle.h"
#if JUCE_WINDOWS
#include <windows.h>
#else
#include <sys/types.h> // pid_t
#endif
namespace slopsmith::sandbox {
struct SubprocessHandle::Impl
{
#if JUCE_WINDOWS
PROCESS_INFORMATION pi{};
#else
pid_t pid = -1;
#endif
};
} // namespace slopsmith::sandbox
@@ -0,0 +1,229 @@
// SubprocessHandle — POSIX backend (macOS + Linux).
//
// Spawn: posix_spawn, never a bare fork. The host process has already touched
// CoreAudio / the Obj-C runtime (it links them and runs the in-process VST
// path), and fork-without-immediate-exec is unsafe there — CoreFoundation
// aborts on many post-fork calls. posix_spawn does the fork+exec atomically.
//
// fd inheritance: posix_spawn_file_actions_adddup2 places each requested host
// fd onto a fixed child fd number (left non-close-on-exec by dup2). On macOS
// POSIX_SPAWN_CLOEXEC_DEFAULT forces every other fd close-on-exec, so ONLY the
// dup2()'d fds reach the child (the analog of Windows bInheritHandles=FALSE).
// Linux lacks that flag; the caller keeps its other fds CLOEXEC instead.
//
// Exit watcher: a blocking waitpid in a dedicated thread (mirrors the Windows
// WaitForSingleObject watcher). We deliberately do NOT install a global
// SIGCHLD handler — inside Electron that would fight libuv's own child reaping.
//
// Shutdown: SIGTERM, then SIGKILL after the timeout (the caller sends the
// `shutdown` control op first for a clean exit; this is the backstop).
#include "SubprocessHandleImpl.h"
#include "../VSTTrace.h"
#if JUCE_WINDOWS
#error "SubprocessHandle_posix.cpp is POSIX-only; Windows builds use SubprocessHandle_win.cpp."
#endif
#include <cerrno>
#include <chrono>
#include <csignal>
#include <cstring>
#include <spawn.h>
#include <string>
#include <sys/wait.h>
#include <unistd.h>
#include <vector>
#if defined(__APPLE__)
#include <crt_externs.h>
#define SLOPSMITH_ENVIRON (*_NSGetEnviron())
#else
extern char** environ;
#define SLOPSMITH_ENVIRON environ
#endif
namespace slopsmith::sandbox {
SubprocessHandle::SubprocessHandle() : impl(std::make_unique<Impl>()) {}
SubprocessHandle::~SubprocessHandle()
{
shutdown(2000);
}
bool SubprocessHandle::start(const juce::String& exePath,
const juce::StringArray& args,
std::function<void(int)> onExit,
juce::String& errorOut)
{
// The Windows start() inherits nothing and connects by name; on POSIX the
// IPC objects are fd-passed, so callers must use startPosix with the fd
// list. Forward to it with no inherited fds for API symmetry / a plain
// "just run this exe" spawn.
return startPosix(exePath, args, {}, std::move(onExit), errorOut);
}
bool SubprocessHandle::startPosix(const juce::String& exePath,
const juce::StringArray& args,
const std::vector<InheritedFd>& inherited,
std::function<void(int)> onExit,
juce::String& errorOut)
{
// Refuse to re-spawn over a still-running process — reassigning a joinable
// std::thread calls std::terminate, and we'd leak the prior pid.
if (running.load(std::memory_order_acquire) || watcher.joinable())
{
errorOut = "subprocess already running — call shutdown() first";
return false;
}
// Build argv: posix_spawn takes a plain char* const[] — no shell, no
// quoting (the Windows CommandLineToArgvW quoting dance is gone). argv[0]
// is the exe path by convention.
std::vector<std::string> storage;
storage.reserve((size_t)args.size() + 1);
storage.push_back(exePath.toStdString());
for (const auto& a : args)
storage.push_back(a.toStdString());
std::vector<char*> argv;
argv.reserve(storage.size() + 1);
for (auto& s : storage)
argv.push_back(s.data());
argv.push_back(nullptr);
// Validate every file-action / attr setup call: a failure (bad fd,
// resource pressure) must surface here, not as an opaque handshake timeout
// after posix_spawn runs a child missing its dup2()'d fds.
posix_spawn_file_actions_t actions;
int rc = posix_spawn_file_actions_init(&actions);
if (rc != 0)
{
// init failed → `actions` is uninitialized; destroying it is undefined.
errorOut = "posix_spawn_file_actions_init failed: "
+ juce::String(strerror(rc));
return false;
}
for (const auto& f : inherited)
if (rc == 0)
rc = posix_spawn_file_actions_adddup2(&actions, f.hostFd, f.childFd);
if (rc != 0)
{
errorOut = "posix_spawn_file_actions setup failed: "
+ juce::String(strerror(rc));
posix_spawn_file_actions_destroy(&actions); // init succeeded → safe
return false;
}
posix_spawnattr_t attr;
int arc = posix_spawnattr_init(&attr);
if (arc != 0)
{
// init failed → don't destroy `attr`; `actions` was inited, so free it.
errorOut = "posix_spawnattr_init failed: " + juce::String(strerror(arc));
posix_spawn_file_actions_destroy(&actions);
return false;
}
#ifdef POSIX_SPAWN_CLOEXEC_DEFAULT
// macOS: only the dup2()'d fds above survive into the child. On Linux this
// flag doesn't exist; CLOEXEC hygiene on the host fds covers it instead.
arc = posix_spawnattr_setflags(&attr, POSIX_SPAWN_CLOEXEC_DEFAULT);
#endif
if (arc != 0)
{
errorOut = "posix_spawnattr setup failed: " + juce::String(strerror(arc));
posix_spawn_file_actions_destroy(&actions);
posix_spawnattr_destroy(&attr); // init succeeded → safe
return false;
}
pid_t pid = -1;
VST_TRACE("SubprocessHandle.startPosix: posix_spawn '%s' (%d inherited fds)",
exePath.toRawUTF8(), (int)inherited.size());
rc = posix_spawn(&pid, exePath.toRawUTF8(), &actions, &attr,
argv.data(), SLOPSMITH_ENVIRON);
posix_spawn_file_actions_destroy(&actions);
posix_spawnattr_destroy(&attr);
if (rc != 0)
{
errorOut = "posix_spawn failed: " + juce::String(strerror(rc));
VST_TRACE("SubprocessHandle.startPosix: posix_spawn FAILED rc=%d (%s)",
rc, strerror(rc));
return false;
}
VST_TRACE("SubprocessHandle.startPosix: spawned pid=%d", (int)pid);
impl->pid = pid;
cachedPid = (uint32_t)pid;
running.store(true, std::memory_order_release);
// onExitCb is single-writer (this path) and read only by the watcher
// thread spawned just below — same invariant the Windows backend relies on.
onExitCb = std::move(onExit);
watcher = std::thread([this, pid]
{
int status = 0;
int code = -1;
for (;;)
{
const pid_t w = ::waitpid(pid, &status, 0);
if (w == pid)
{
if (WIFEXITED(status)) code = WEXITSTATUS(status);
else if (WIFSIGNALED(status)) code = 128 + WTERMSIG(status);
break;
}
if (w < 0)
{
if (errno == EINTR) continue;
// ECHILD: someone else reaped it (e.g. libuv's SIGCHLD handler
// inside Electron). Treat as exited with unknown status rather
// than spinning — see the kqueue/EVFILT_PROC fallback note in
// the design plan if this proves common.
break;
}
}
running.store(false, std::memory_order_release);
if (onExitCb) onExitCb(code);
});
return true;
}
void SubprocessHandle::shutdown(int timeoutMs)
{
if (running.load(std::memory_order_acquire) && impl->pid > 0)
{
// Escalating close: SIGTERM, wait for the watcher to observe the exit
// (it owns waitpid — we must NOT waitpid here too or we race the reap),
// then SIGKILL. Poll `running` rather than waitpid.
::kill(impl->pid, SIGTERM);
const auto deadline = std::chrono::steady_clock::now()
+ std::chrono::milliseconds(timeoutMs);
while (running.load(std::memory_order_acquire)
&& std::chrono::steady_clock::now() < deadline)
std::this_thread::sleep_for(std::chrono::milliseconds(2));
if (running.load(std::memory_order_acquire))
::kill(impl->pid, SIGKILL);
}
if (watcher.joinable())
{
if (std::this_thread::get_id() == watcher.get_id())
{
// Self-join would deadlock. Detaching is safe for the same reasons
// documented in the Windows backend: SandboxedProcessor::teardown
// drops the onCrash callback before calling shutdown(), and the
// watcher touches no member state after onExitCb beyond the atomic
// `running` store.
watcher.detach();
}
else
watcher.join();
}
impl->pid = -1;
}
} // namespace slopsmith::sandbox
+178
View File
@@ -0,0 +1,178 @@
// SubprocessHandle — Windows backend (CreateProcessW + WM_QUIT/TerminateProcess
// shutdown + WaitForSingleObject exit watcher). POSIX lives in
// SubprocessHandle_posix.cpp.
#include "SubprocessHandleImpl.h"
#include "../VSTTrace.h"
#if ! JUCE_WINDOWS
#error "SubprocessHandle_win.cpp is Windows-only; POSIX builds use SubprocessHandle_posix.cpp."
#endif
namespace slopsmith::sandbox {
SubprocessHandle::SubprocessHandle() : impl(std::make_unique<Impl>()) {}
SubprocessHandle::~SubprocessHandle()
{
shutdown(2000);
}
bool SubprocessHandle::start(const juce::String& exePath,
const juce::StringArray& args,
std::function<void(int)> onExit,
juce::String& errorOut)
{
// Refuse to re-spawn over a still-running process — overwriting impl->pi
// would leak the existing process/thread handles, and reassigning a
// joinable std::thread calls std::terminate.
if (running.load(std::memory_order_acquire) || watcher.joinable())
{
errorOut = "subprocess already running — call shutdown() first";
return false;
}
// Win32 CommandLineToArgvW quoting rules per Microsoft docs:
// - 2N backslashes followed by `"` → N backslashes + end of quoted region
// - 2N+1 backslashes followed by `"` → N backslashes + literal `"`
// - backslashes NOT followed by `"` are kept literal
// So embedded `"` needs all preceding backslashes doubled AND the `"`
// backslash-escaped, and a trailing backslash inside a quoted arg also
// needs to be doubled (otherwise it escapes the closing quote).
auto quoteWin32 = [](const juce::String& in) -> juce::String
{
juce::String out;
out << '"';
int backslashes = 0;
for (juce::juce_wchar c : in)
{
if (c == '\\')
{
++backslashes;
}
else if (c == '"')
{
// Double all the pending backslashes, then escape the quote.
out += juce::String::repeatedString("\\\\", backslashes);
out += "\\\"";
backslashes = 0;
}
else
{
// Pending backslashes are literal (not followed by a quote).
out += juce::String::repeatedString("\\", backslashes);
backslashes = 0;
out += juce::String::charToString(c);
}
}
// Trailing backslashes inside a quoted arg get doubled, otherwise
// the closing quote turns into an escaped literal `"`.
out += juce::String::repeatedString("\\\\", backslashes);
out << '"';
return out;
};
juce::String cmd;
cmd << quoteWin32(exePath);
for (auto& a : args)
cmd << ' ' << quoteWin32(a);
STARTUPINFOW si{};
si.cb = sizeof(si);
si.dwFlags = STARTF_USESHOWWINDOW;
si.wShowWindow = SW_HIDE; // detach console; the sandbox is GUI-only
std::wstring wcmd = cmd.toWideCharPointer();
VST_TRACE("SubprocessHandle.start: CreateProcessW cmd='%s'", cmd.toRawUTF8());
if (!CreateProcessW(
nullptr, wcmd.data(),
nullptr, nullptr, FALSE,
CREATE_UNICODE_ENVIRONMENT,
nullptr, nullptr,
&si, &impl->pi))
{
DWORD err = GetLastError();
errorOut = "CreateProcessW failed: " + juce::String((int)err);
VST_TRACE("SubprocessHandle.start: CreateProcessW FAILED err=%lu", (unsigned long)err);
return false;
}
VST_TRACE("SubprocessHandle.start: spawned pid=%lu",
(unsigned long)impl->pi.dwProcessId);
running.store(true, std::memory_order_release);
cachedPid = (uint32_t)impl->pi.dwProcessId;
// `onExitCb` is single-writer: only this start() path assigns to it,
// and the watcher thread (the only reader) is spawned a few lines
// below, after the assignment. The early-return guard at the top of
// this function rejects re-starts while a previous run is still
// alive, which keeps that invariant intact. If a future refactor
// ever permits an in-flight re-start, this assignment vs. the
// watcher's read becomes a data race — make onExitCb atomic or
// serialise via a mutex at that point.
onExitCb = std::move(onExit);
HANDLE procHandle = impl->pi.hProcess;
watcher = std::thread([this, procHandle]
{
WaitForSingleObject(procHandle, INFINITE);
DWORD code = 0;
GetExitCodeProcess(procHandle, &code);
running.store(false, std::memory_order_release);
if (onExitCb) onExitCb((int)code);
});
return true;
}
void SubprocessHandle::shutdown(int timeoutMs)
{
if (running.load(std::memory_order_acquire))
{
// Try a clean shutdown: post WM_QUIT to the subprocess's initial
// thread (`pi.dwThreadId` from PROCESS_INFORMATION). PostThreadMessageW
// is per-TID, not per-process — this works because vst-host's WinMain
// runs the JUCE message loop on the initial thread (the audio worker
// is a child thread that doesn't pump messages), so WM_QUIT lands on
// the right pump. If a future refactor moves the message loop off the
// initial thread, this needs the new TID or to switch to a
// process-wide signalling mechanism (named event, etc.). If
// dwThreadId is zero (start() failed mid-way) we skip and let the
// wait+TerminateProcess below clean up.
if (impl->pi.dwThreadId != 0)
PostThreadMessageW(impl->pi.dwThreadId, WM_QUIT, 0, 0);
DWORD wait = WaitForSingleObject(impl->pi.hProcess, (DWORD)timeoutMs);
if (wait != WAIT_OBJECT_0)
TerminateProcess(impl->pi.hProcess, 1);
}
if (watcher.joinable())
{
if (std::this_thread::get_id() == watcher.get_id())
{
// Self-join would deadlock. Detaching leaves the watcher
// thread alive briefly past this destructor's return, which
// would normally be a UAF on captured `this`. Two things
// make it safe here:
// 1. SandboxedProcessor::teardown drops the onCrash
// callback BEFORE invoking subprocess->shutdown(), so
// the watcher's onExitCb fires into a no-op when this
// path is reached.
// 2. The watcher's remaining work after onExitCb is just
// `running.store(false)` and falling off the lambda —
// no member-state access beyond the atomic.
// If a future refactor adds member access in the watcher
// after onExitCb, revisit this — a `resourcesReleased`
// latch + shared_ptr captured into the lambda is the
// standard fix.
watcher.detach();
}
else
watcher.join();
}
// Always close handles — when the watcher detected a crash, `running` is
// already false here, but the kernel handles are still ours to release.
if (impl->pi.hThread) { CloseHandle(impl->pi.hThread); impl->pi.hThread = nullptr; }
if (impl->pi.hProcess) { CloseHandle(impl->pi.hProcess); impl->pi.hProcess = nullptr; }
}
} // namespace slopsmith::sandbox
+325
View File
@@ -0,0 +1,325 @@
#include "SignalChain.h"
#include "Sandbox/SandboxedProcessor.h"
namespace {
// Catch a plugin fault — access violation, heap corruption, C++ exception —
// rather than let it kill the host process. /EHa on this TU makes catch(...)
// catch SEH on Windows too; on other platforms it covers C++ exceptions only.
//
// On fault: route future loads of the offending plugin through the
// out-of-process sandbox (via the runtime crash blocklist), and *leak* the
// AudioPluginInstance — calling its destructor on a now-corrupted heap is
// its own crash hazard. A one-time leak per kill in exchange for a live app.
// The next iteration of any slot loop sees slot->processor == nullptr and
// skips the slot.
template <typename Fn>
inline void invokePlugin(ProcessorSlot& slot, Fn&& fn) noexcept
{
if (! slot.processor) return;
try
{
fn(*slot.processor);
}
catch (...)
{
// Best-effort blocklist update — addCrashedPlugin allocates (juce path
// canonicalisation, StringArray.add) and locks a mutex, both of which
// can throw under OOM or corruption. Swallow any exception here so the
// outer noexcept boundary stays honest; release() is itself noexcept
// and never escapes the catch.
try { slopsmith::sandbox::addCrashedPlugin(slot.path); }
catch (...) { /* nothing useful to do on the noexcept boundary */ }
(void) slot.processor.release();
}
}
} // namespace
// ── ProcessorSlot ─────────────────────────────────────────────────────────────
juce::MemoryBlock ProcessorSlot::getState() const
{
juce::MemoryBlock state;
if (processor)
processor->getStateInformation(state);
return state;
}
void ProcessorSlot::setState(const juce::MemoryBlock& state)
{
if (processor && state.getSize() > 0)
processor->setStateInformation(state.getData(), (int)state.getSize());
}
// ── SignalChain ───────────────────────────────────────────────────────────────
SignalChain::SignalChain() {}
SignalChain::~SignalChain()
{
const juce::ScopedLock sl(lock);
slots.clear();
}
void SignalChain::prepare(double sampleRate, int blockSize)
{
currentSampleRate = sampleRate;
currentBlockSize = blockSize;
const juce::ScopedLock sl(lock);
for (auto* slot : slots)
{
invokePlugin(*slot, [&](juce::AudioProcessor& p)
{
p.releaseResources();
p.setPlayConfigDetails(2, 2, sampleRate, blockSize);
p.prepareToPlay(sampleRate, blockSize);
});
}
}
void SignalChain::releaseResources()
{
const juce::ScopedLock sl(lock);
for (auto* slot : slots)
{
invokePlugin(*slot, [](juce::AudioProcessor& p)
{
p.releaseResources();
});
}
}
void SignalChain::process(juce::AudioBuffer<float>& buffer, juce::MidiBuffer& midi)
{
const juce::ScopedTryLock sl(lock);
if (!sl.isLocked()) return;
// Drain pending MIDI messages from the lock-free queue
struct DrainedMsg { int slotId; juce::MidiMessage msg; };
DrainedMsg drained[kMidiQueueSize];
int numDrained = 0;
const auto scope = midiQueueFifo.read(midiQueueFifo.getNumReady());
for (int i = 0; i < scope.blockSize1 && numDrained < kMidiQueueSize; ++i)
drained[numDrained++] = { midiRingBuffer[(size_t)scope.startIndex1 + i].targetSlotId,
midiRingBuffer[(size_t)scope.startIndex1 + i].msg };
for (int i = 0; i < scope.blockSize2 && numDrained < kMidiQueueSize; ++i)
drained[numDrained++] = { midiRingBuffer[(size_t)scope.startIndex2 + i].targetSlotId,
midiRingBuffer[(size_t)scope.startIndex2 + i].msg };
for (auto* slot : slots)
{
if (slot->processor && !slot->bypassed)
{
// Build per-slot MIDI buffer from drained messages
juce::MidiBuffer slotMidi(midi); // start with pass-through MIDI
for (int i = 0; i < numDrained; ++i)
{
if (drained[i].slotId == slot->id || drained[i].slotId == -1)
slotMidi.addEvent(drained[i].msg, 0);
}
invokePlugin(*slot, [&](juce::AudioProcessor& p)
{
p.processBlock(buffer, slotMidi);
});
}
}
}
void SignalChain::queueMidiMessage(int targetSlotId, const juce::MidiMessage& msg)
{
const auto scope = midiQueueFifo.write(1);
if (scope.blockSize1 > 0)
midiRingBuffer[(size_t)scope.startIndex1] = { targetSlotId, msg };
else if (scope.blockSize2 > 0)
midiRingBuffer[(size_t)scope.startIndex2] = { targetSlotId, msg };
// If queue full, message silently dropped (acceptable for PC messages)
}
int SignalChain::addProcessor(std::unique_ptr<juce::AudioProcessor> processor,
ProcessorSlot::Type type,
const juce::String& name,
const juce::String& path)
{
if (!processor) return -1;
auto slot = std::make_unique<ProcessorSlot>();
slot->type = type;
slot->processor = std::move(processor);
slot->name = name;
slot->path = path;
slot->id = nextSlotId++;
// Prepare under the SEH-catching helper so a plugin that faults during
// prepareToPlay is blocklisted (next load routes to the sandbox) and the
// slot is dropped, rather than taking the app down.
invokePlugin(*slot, [&](juce::AudioProcessor& p)
{
p.setPlayConfigDetails(2, 2, currentSampleRate, currentBlockSize);
p.prepareToPlay(currentSampleRate, currentBlockSize);
});
if (! slot->processor) return -1;
int id = slot->id;
const juce::ScopedLock sl(lock);
slots.add(slot.release());
return id;
}
void SignalChain::removeProcessor(int slotId)
{
const juce::ScopedLock sl(lock);
int idx = findSlotIndex(slotId);
if (idx >= 0) slots.remove(idx);
}
void SignalChain::moveProcessor(int fromIndex, int toIndex)
{
const juce::ScopedLock sl(lock);
if (fromIndex >= 0 && fromIndex < slots.size() &&
toIndex >= 0 && toIndex < slots.size() && fromIndex != toIndex)
{
slots.move(fromIndex, toIndex);
}
}
void SignalChain::setBypass(int slotId, bool bypassed)
{
const juce::ScopedLock sl(lock);
int idx = findSlotIndex(slotId);
if (idx >= 0) slots[idx]->bypassed = bypassed;
}
void SignalChain::setMultiBypass(const juce::Array<std::pair<int, bool>>& changes)
{
const juce::ScopedLock sl(lock);
for (auto& [slotId, bypassed] : changes)
{
int idx = findSlotIndex(slotId);
if (idx >= 0) slots[idx]->bypassed = bypassed;
}
}
void SignalChain::clear()
{
const juce::ScopedLock sl(lock);
slots.clear();
}
int SignalChain::getNumSlots() const
{
const juce::ScopedLock sl(lock);
return slots.size();
}
const ProcessorSlot* SignalChain::getSlot(int slotId) const
{
const juce::ScopedLock sl(lock);
int idx = findSlotIndex(slotId);
return idx >= 0 ? slots[idx] : nullptr;
}
juce::Array<const ProcessorSlot*> SignalChain::getAllSlots() const
{
juce::Array<const ProcessorSlot*> result;
const juce::ScopedLock sl(lock);
for (auto* slot : slots)
result.add(slot);
return result;
}
juce::Array<SignalChain::ParamInfo> SignalChain::getParameters(int slotId) const
{
juce::Array<ParamInfo> result;
const juce::ScopedLock sl(lock);
int idx = findSlotIndex(slotId);
if (idx < 0) return result;
auto* proc = slots[idx]->processor.get();
if (!proc) return result;
auto& params = proc->getParameters();
for (int i = 0; i < params.size(); ++i)
{
ParamInfo info;
info.index = i;
info.name = params[i]->getName(128);
info.value = params[i]->getValue();
info.label = params[i]->getLabel();
info.text = params[i]->getCurrentValueAsText();
result.add(info);
}
return result;
}
void SignalChain::setParameter(int slotId, int paramIndex, float value)
{
const juce::ScopedLock sl(lock);
int idx = findSlotIndex(slotId);
if (idx < 0) return;
auto* proc = slots[idx]->processor.get();
if (!proc) return;
auto& params = proc->getParameters();
if (paramIndex >= 0 && paramIndex < params.size())
params[paramIndex]->setValue(value);
}
void SignalChain::setSlotState(int slotId, const juce::MemoryBlock& state)
{
const juce::ScopedLock sl(lock);
int idx = findSlotIndex(slotId);
if (idx >= 0)
slots[idx]->setState(state); // ProcessorSlot::setState() is null/empty-safe
}
// ── Presets ───────────────────────────────────────────────────────────────────
juce::String SignalChain::savePreset() const
{
auto root = new juce::DynamicObject();
root->setProperty("version", 1);
juce::Array<juce::var> chainArray;
const juce::ScopedLock sl(lock);
for (auto* slot : slots)
{
auto slotObj = new juce::DynamicObject();
slotObj->setProperty("id", slot->id);
slotObj->setProperty("type", (int)slot->type);
slotObj->setProperty("name", slot->name);
slotObj->setProperty("path", slot->path);
slotObj->setProperty("bypassed", slot->bypassed);
// Save processor state as base64
auto state = slot->getState();
if (state.getSize() > 0)
slotObj->setProperty("state", state.toBase64Encoding());
chainArray.add(juce::var(slotObj));
}
root->setProperty("chain", juce::var(chainArray));
return juce::JSON::toString(juce::var(root));
}
void SignalChain::loadPreset(const juce::String& json)
{
// Preset loading is handled at a higher level (NodeAddon) because
// it needs to re-instantiate processors (VSTs, NAMs, IRs) which
// requires the VSTHost and other components. The chain just needs
// to be rebuilt via addProcessor() calls followed by setState().
}
// ── Private ───────────────────────────────────────────────────────────────────
int SignalChain::findSlotIndex(int slotId) const
{
for (int i = 0; i < slots.size(); ++i)
if (slots[i]->id == slotId) return i;
return -1;
}
+91
View File
@@ -0,0 +1,91 @@
#pragma once
#include <juce_audio_processors/juce_audio_processors.h>
#include <juce_dsp/juce_dsp.h>
#include <array>
// Represents a single processor slot in the signal chain.
// Can hold a VST3/AU/LV2 plugin, NAM model, or IR loader.
struct ProcessorSlot
{
enum class Type { VST, NAM, IR, Empty };
Type type = Type::Empty;
std::unique_ptr<juce::AudioProcessor> processor;
juce::String name;
juce::String path; // plugin file path, NAM model path, or IR file path
bool bypassed = false;
int id = 0;
// For VST plugins — their state as base64 for preset save/load
juce::MemoryBlock getState() const;
void setState(const juce::MemoryBlock& state);
};
class SignalChain
{
public:
SignalChain();
~SignalChain();
void prepare(double sampleRate, int blockSize);
void releaseResources();
void process(juce::AudioBuffer<float>& buffer, juce::MidiBuffer& midi);
// Chain management
int addProcessor(std::unique_ptr<juce::AudioProcessor> processor,
ProcessorSlot::Type type,
const juce::String& name,
const juce::String& path);
void removeProcessor(int slotId);
void moveProcessor(int fromIndex, int toIndex);
void setBypass(int slotId, bool bypassed);
void setMultiBypass(const juce::Array<std::pair<int, bool>>& changes);
void clear();
// Info
int getNumSlots() const;
const ProcessorSlot* getSlot(int slotId) const;
juce::Array<const ProcessorSlot*> getAllSlots() const;
// Parameters for a specific slot
struct ParamInfo
{
int index;
juce::String name;
float value;
juce::String label;
juce::String text;
};
juce::Array<ParamInfo> getParameters(int slotId) const;
void setParameter(int slotId, int paramIndex, float value);
// Restore a processor's full state (a getStateInformation() blob) by slot
// id. Used to re-apply per-slot VST state when the tone-switcher rebuilds
// a chain processor-by-processor rather than via a whole-chain loadPreset.
void setSlotState(int slotId, const juce::MemoryBlock& state);
// Preset serialization
juce::String savePreset() const;
void loadPreset(const juce::String& json);
// MIDI message injection (lock-free, called from N-API thread)
void queueMidiMessage(int targetSlotId, const juce::MidiMessage& msg);
private:
int findSlotIndex(int slotId) const;
juce::OwnedArray<ProcessorSlot> slots;
juce::CriticalSection lock;
int nextSlotId = 1;
double currentSampleRate = 48000.0;
int currentBlockSize = 256;
// Lock-free SPSC MIDI queue (N-API thread writes, audio thread reads)
struct PendingMidiMessage { int targetSlotId = -1; juce::MidiMessage msg; };
static constexpr int kMidiQueueSize = 64;
std::array<PendingMidiMessage, kMidiQueueSize> midiRingBuffer;
juce::AbstractFifo midiQueueFifo { kMidiQueueSize };
JUCE_DECLARE_NON_COPYABLE_WITH_LEAK_DETECTOR(SignalChain)
};
+544
View File
@@ -0,0 +1,544 @@
#include "SourceChain.h"
#include "AudioSanitize.h"
#include <cmath>
// SourceChain — implementation. Method bodies are moved verbatim from
// AudioEngine (Phase 0 is a pure extraction; no behavioural change), with the
// global `inputFrameRing` / `rawAudioRing` / detectors now this source's own
// members and the engine's `audioRunning` / `currentSampleRate` read through the
// references bound at construction.
// ── Lifecycle ───────────────────────────────────────────────────────────────
void SourceChain::prepare(double sr, int blockSize)
{
// Reset the input rings so a stop→start cycle delivers a clean zero-padded
// cold-start frame instead of mixing in stale samples from the previous run.
// The audio thread isn't running yet (device-start hook), so relaxed is fine.
inputFrameRingWriteIndex.store(0, std::memory_order_relaxed);
for (auto& slot : inputFrameRing)
slot.store(0.0f, std::memory_order_relaxed);
rawAudioRingWriteIndex.store(0, std::memory_order_relaxed);
for (auto& slot : rawAudioRing)
slot.store(0.0f, std::memory_order_relaxed);
// Pre-size the zero-output capture scratch to this device's block size so the
// audio thread doesn't allocate when we hit that path. For the common
// output > 0 case this storage stays unused.
if ((int) inputCaptureScratch.size() < blockSize)
inputCaptureScratch.assign((size_t) blockSize, 0.0f);
signalChain.prepare(sr, blockSize);
pitchDetector.prepare(sr, blockSize);
mlNoteDetector.prepare(sr, blockSize);
noteVerifier.prepare(sr, blockSize);
noiseGate.prepare(sr, blockSize);
tonePolish.prepare(sr);
}
void SourceChain::releaseResources()
{
signalChain.releaseResources();
mlNoteDetector.stop();
noteVerifier.stop();
inputFrameRingWriteIndex.store(0, std::memory_order_relaxed);
rawAudioRingWriteIndex.store(0, std::memory_order_relaxed);
}
// ── Per-block processing (audio thread) ──────────────────────────────────────
void SourceChain::processBlock(const float* const* inputData, int numInputChannels,
juce::AudioBuffer<float>& buffer, int effectiveOutputChannels,
int numSamples) noexcept
{
const float inGain = inputGain.load();
const int selectedCh = selectedInputChannel.load();
// Copy input with gain, handling channel selection. Track how many output
// channels we've filled so the "zero extras" pass below can clip to the right
// range — the broadcast branches fill all of them, the pass-through branch
// only fills the overlap.
int filledOutputChannels = 0;
if (numInputChannels >= 2 && selectedCh >= 0 && selectedCh < numInputChannels)
{
// Single-channel mode (e.g. dry from Valeton GP-5 left channel).
// Broadcast the selected input across all output channels.
for (int outCh = 0; outCh < effectiveOutputChannels; ++outCh)
for (int i = 0; i < numSamples; ++i)
buffer.setSample(outCh, i, inputData[selectedCh][i] * inGain);
filledOutputChannels = effectiveOutputChannels;
}
else if (selectedCh < 0 && numInputChannels > 1)
{
// Default pair mono mix: average the first two input channels and
// broadcast to every output channel, so the signal chain, pitch detector,
// input-frame ring, and the user's monitoring all see the same mono
// signal. We open all advertised hardware inputs so explicit higher
// channel picks work, but the default keeps the old first-pair semantics
// instead of attenuating the signal by averaging every input.
const int mixChannels = juce::jmin(numInputChannels, 2);
const float invCh = 1.0f / (float) mixChannels;
for (int i = 0; i < numSamples; ++i)
{
float mix = 0.0f;
for (int ch = 0; ch < mixChannels; ++ch)
mix += inputData[ch][i];
const float gained = mix * invCh * inGain;
for (int outCh = 0; outCh < effectiveOutputChannels; ++outCh)
buffer.setSample(outCh, i, gained);
}
filledOutputChannels = effectiveOutputChannels;
}
else
{
// Pass-through: single-input device, or stereo in/out with no explicit
// channel selection and no need to mix.
const int passThroughChannels = juce::jmin(numInputChannels, effectiveOutputChannels);
for (int ch = 0; ch < passThroughChannels; ++ch)
for (int i = 0; i < numSamples; ++i)
buffer.setSample(ch, i, inputData[ch][i] * inGain);
filledOutputChannels = passThroughChannels;
}
// Zero anything we didn't fill.
for (int ch = filledOutputChannels; ch < effectiveOutputChannels; ++ch)
buffer.clear(ch, 0, numSamples);
// Metering: input level (pre-processing)
{
float peak = 0.0f;
for (int ch = 0; ch < effectiveOutputChannels; ++ch)
peak = juce::jmax(peak, buffer.getMagnitude(ch, 0, numSamples));
currentInputLevel.store(peak);
float prevPeak = inputPeak.load();
if (peak > prevPeak) inputPeak.store(peak);
}
// Build the mono guitar source. The ML detector and the getInputFrame() ring
// are fed here, pre-gate (note-detection scoring expects the raw dry signal);
// the YIN pitch detector is fed lower down, AFTER the noise gate (both the
// monitored path and the zero-output fallback), so the tuner reads silence as
// "no pitch" instead of chasing gated noise. Buffer ch 0 holds the post-gain
// mono signal in both duplex and split paths. Zero-output duplex setups
// (input-only ASIO/JACK) need the scratch fallback.
const float* monoSource = nullptr;
if (effectiveOutputChannels > 0)
{
monoSource = buffer.getReadPointer(0);
}
else if (numInputChannels > 0 && (int) inputCaptureScratch.size() >= numSamples)
{
// Build the mono source mirroring the channel-copy semantics:
// explicit channel select picks one input; -1 with multi-input averages
// the first pair; otherwise input channel 0.
if (selectedCh >= 0 && selectedCh < numInputChannels)
{
for (int i = 0; i < numSamples; ++i)
inputCaptureScratch[(size_t) i] = inputData[selectedCh][i] * inGain;
}
else if (selectedCh < 0 && numInputChannels > 1)
{
const int mixChannels = juce::jmin(numInputChannels, 2);
const float invCh = 1.0f / (float) mixChannels;
for (int i = 0; i < numSamples; ++i)
{
float mix = 0.0f;
for (int ch = 0; ch < mixChannels; ++ch)
mix += inputData[ch][i];
inputCaptureScratch[(size_t) i] = mix * invCh * inGain;
}
}
else
{
for (int i = 0; i < numSamples; ++i)
inputCaptureScratch[(size_t) i] = inputData[0][i] * inGain;
}
monoSource = inputCaptureScratch.data();
}
if (monoSource != nullptr)
{
// Feed the polyphonic ML detector the dry mono signal (pre-gate).
// Lock-free and a no-op when ONNX support isn't compiled in.
mlNoteDetector.pushSamples(monoSource, numSamples);
// Mirror the same signal into the lock-free ring buffer that backs
// getInputFrame(). The release-store on the write index pairs with the
// main-thread reader's acquire load so every sample written below is
// visible before the index update. Per-slot stores are atomic-relaxed so
// the concurrent read by getInputFrame() isn't a data race (UB) when the
// writer laps mid-snapshot.
const uint64_t w = inputFrameRingWriteIndex.load(std::memory_order_relaxed);
constexpr int kMask = kInputFrameRingCapacity - 1;
for (int i = 0; i < numSamples; ++i)
inputFrameRing[(w + (uint64_t) i) & (uint64_t) kMask]
.store(monoSource[i], std::memory_order_relaxed);
inputFrameRingWriteIndex.store(w + (uint64_t) numSamples, std::memory_order_release);
}
noiseGate.processBlock(buffer);
// Feed the YIN pitch detector from the POST-gate signal so the tuner reports
// silence as "no pitch" instead of chasing gated noise.
if (monoSource != nullptr)
{
if (effectiveOutputChannels > 0)
{
// monoSource aliases buffer ch0, which the gate just processed in place.
pitchDetector.pushSamples(monoSource, numSamples);
// Same post-gate samples feed the tuner's raw-audio ring.
pushRawAudioFrame(monoSource, numSamples);
}
else
{
// Zero-output (input-only ASIO/JACK) fallback: buffer has no channel,
// so the processBlock above was a no-op (NoiseGate early-returns and
// leaves its envelope untouched when numChannels <= 0). Run the gate
// once on the scratch mono here — a single real gate pass — so the
// tuner is gated in this path too. ML / input-frame ring already
// copied the pre-gate samples above, so gating in place is safe.
float* scratchPtr = inputCaptureScratch.data();
juce::AudioBuffer<float> scratchGate(&scratchPtr, 1, numSamples);
noiseGate.processBlock(scratchGate);
pitchDetector.pushSamples(scratchPtr, numSamples);
pushRawAudioFrame(scratchPtr, numSamples);
}
}
// Process through signal chain (VSTs, NAM, IR)
const bool hasProcessors = signalChain.getNumSlots() > 0;
juce::MidiBuffer midi;
signalChain.process(buffer, midi);
// Contain a divergent chain block before it reaches the IIR/gain/mix and the
// output: a NAM/IR/VST can emit NaN/Inf or a runaway level (esp. on a live
// chain rebuild during song load), which otherwise blasts the output and
// poisons persistent downstream state until an app restart (#403). Scrub here
// so feed-forward processors self-heal and the failure is a glitch, not a dead
// engine. Count flagged blocks (relaxed; RT-safe — no logging on the audio
// thread) for later observability.
if (hasProcessors)
{
int fixed = 0;
for (int ch = 0; ch < buffer.getNumChannels(); ++ch)
fixed += slopsmith::sanitizeAudioBlock(buffer.getWritePointer(ch), numSamples);
if (fixed > 0)
nonFiniteChainBlocks.fetch_add(1, std::memory_order_relaxed);
}
// Monitor mute: silence the guitar pass-through when no processors are loaded.
// This prevents hearing raw/amp-processed input when the user hasn't set up a
// 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())
buffer.clear();
// Chain output gain — the amp/tone's output level. Applied to the guitar
// signal ONLY, before the backing track is mixed in, so switching tone presets
// changes the guitar level without touching the song volume.
buffer.applyGain(chainOutputGain.load());
// Tone Polish — fixed 3-band mastering EQ. Sits on the guitar bus only,
// between chain output gain and the backing-track mix, so the backing track
// and master output gain stay bit-untouched. Bypassed at a single atomic load
// when disabled.
tonePolish.processBlock(buffer);
}
// ── Ring readers (main thread) ───────────────────────────────────────────────
std::vector<float> SourceChain::getInputFrame(int numSamples) const
{
if (numSamples <= 0) return {};
if (numSamples > kInputFrameRingCapacity)
numSamples = kInputFrameRingCapacity;
// Acquire pairs with the audio thread's release store of the write index:
// every sample written into the ring before that index is visible to us here.
const uint64_t w = inputFrameRingWriteIndex.load(std::memory_order_acquire);
std::vector<float> out((size_t) numSamples, 0.0f);
// Cold-start: audio thread hasn't filled `numSamples` yet. Return what we
// have, zero-padded on the *left* so the most-recent samples land at the end
// of the buffer (the YIN/HPS algorithms expect time-aligned data).
if (w < (uint64_t) numSamples)
{
const size_t available = (size_t) w;
for (size_t i = 0; i < available; ++i)
out[(size_t) numSamples - available + i]
= inputFrameRing[i].load(std::memory_order_relaxed);
return out;
}
constexpr uint64_t kMask = (uint64_t) kInputFrameRingCapacity - 1;
const uint64_t start = w - (uint64_t) numSamples;
for (int i = 0; i < numSamples; ++i)
out[(size_t) i]
= inputFrameRing[(start + (uint64_t) i) & kMask].load(std::memory_order_relaxed);
return out;
}
uint64_t SourceChain::getInputSince(uint64_t fromIndex, std::vector<float>& out) const
{
out.clear();
// Acquire pairs with the audio thread's release store — every sample written
// before `w` is visible here.
const uint64_t w = inputFrameRingWriteIndex.load(std::memory_order_acquire);
if (fromIndex >= w) return w; // nothing new
constexpr uint64_t kCap = (uint64_t) kInputFrameRingCapacity;
constexpr uint64_t kMask = kCap - 1;
// If the caller fell more than a ring behind, the oldest samples were
// overwritten — start at the oldest still-live sample.
uint64_t start = fromIndex;
if (w - start > kCap) start = w - kCap;
const size_t n = (size_t) (w - start);
out.resize(n);
for (size_t i = 0; i < n; ++i)
out[i] = inputFrameRing[(start + (uint64_t) i) & kMask].load(std::memory_order_relaxed);
return w;
}
void SourceChain::pushRawAudioFrame(const float* data, int numSamples) noexcept
{
// Audio thread only. Relaxed per-slot stores (a concurrent reader that laps
// mid-snapshot would otherwise be a data race); the release store on the
// write index publishes the samples to getRawAudioFrame()'s acquire load.
const uint64_t w = rawAudioRingWriteIndex.load(std::memory_order_relaxed);
constexpr uint64_t kMask = (uint64_t) kRawAudioRingCapacity - 1;
for (int i = 0; i < numSamples; ++i)
rawAudioRing[(w + (uint64_t) i) & kMask].store(data[i], std::memory_order_relaxed);
rawAudioRingWriteIndex.store(w + (uint64_t) numSamples, std::memory_order_release);
}
std::vector<float> SourceChain::getRawAudioFrame(int numSamples) const
{
if (numSamples <= 0) return {};
if (numSamples > kRawAudioRingCapacity)
numSamples = kRawAudioRingCapacity;
// Acquire pairs with the audio thread's release store of the write index:
// every sample written into the ring before that index is visible here.
const uint64_t w = rawAudioRingWriteIndex.load(std::memory_order_acquire);
std::vector<float> out((size_t) numSamples, 0.0f);
// Cold-start: audio thread hasn't filled `numSamples` yet. Return what we
// have, zero-padded on the left so the most-recent samples land at the end (a
// tuner's pitch algorithm expects time-aligned data).
if (w < (uint64_t) numSamples)
{
const size_t available = (size_t) w;
for (size_t i = 0; i < available; ++i)
out[(size_t) numSamples - available + i]
= rawAudioRing[i].load(std::memory_order_relaxed);
return out;
}
constexpr uint64_t kMask = (uint64_t) kRawAudioRingCapacity - 1;
const uint64_t start = w - (uint64_t) numSamples;
for (int i = 0; i < numSamples; ++i)
out[(size_t) i]
= rawAudioRing[(start + (uint64_t) i) & kMask].load(std::memory_order_relaxed);
return out;
}
// ── Detection / scoring (main thread) ────────────────────────────────────────
ChordScorer::Result SourceChain::scoreChord(const ChordScorer::Request& req)
{
// Fast-path when the device isn't running — the input ring is zeroed in
// releaseResources() (and stays at zero between init and the first device
// start), so any FFT here would just produce an all-miss score against a
// silence buffer. Skip the ring snapshot + FFT and synthesize the same shape.
if (! audioRunning.load(std::memory_order_relaxed))
{
ChordScorer::Result out{};
out.totalStrings = (int) req.notes.size();
out.results.reserve(req.notes.size());
for (const auto& n : req.notes)
{
ChordScorer::NoteResult r{};
r.string = n.string;
r.fret = n.fret;
out.results.push_back(r);
}
return out;
}
// When a Basic Pitch model is loaded, judge the chord against the ML
// detector's active-pitch set — genuine polyphonic transcription rather than
// the per-string energy/constraint check. `req.bypassMl` overrides this so the
// renderer can force the DSP band-energy scorer.
if (! req.bypassMl && mlNoteDetector.isReady())
return scoreChordWithMl(req);
// Snapshot the input ring at the requested window size and forward to the
// scorer. The renderer never sees audio data — only the result object.
const int numSamples = (req.numSamples > 0) ? req.numSamples : 4096;
auto frame = getInputFrame(numSamples);
// sampleRate is 0 between init() and the first audioDeviceAboutToStart, and
// can also drop to 0 after a device teardown; floor to 48 kHz so the scorer
// can still run the FFT against whatever stale audio is in the ring. Mirrors
// NodeAddon::GetSampleRate's 48 kHz floor.
double sr = sampleRate.load(std::memory_order_relaxed);
if (! std::isfinite(sr) || sr <= 0.0) sr = 48000.0;
return chordScorer.scoreChord(frame.data(), (int) frame.size(), sr, req);
}
namespace
{
juce::String midiNoteName(int midi)
{
static const char* names[] = {"C","C#","D","D#","E","F","F#","G","G#","A","A#","B"};
if (midi < 0 || midi > 127) return "?";
return juce::String(names[midi % 12]) + juce::String(midi / 12 - 1);
}
} // namespace
PitchDetector::Detection SourceChain::getActiveDetection() const
{
// Audio stopped — neither detector has live data. Return no detection rather
// than letting the YIN fallback surface its last stale note for the whole
// stopped / cold-start window.
if (! audioRunning.load(std::memory_order_relaxed))
return {};
// Prefer the polyphonic ML detector's dominant pitch when a model is loaded;
// otherwise fall back to the YIN detector. The shape is identical so
// getPitchDetection's consumers can't tell which detector answered.
if (mlNoteDetector.isReady())
{
const auto note = mlNoteDetector.getDominantNote();
PitchDetector::Detection d;
if (note.midi >= 0)
{
d.midiNote = note.midi;
d.confidence = note.confidence;
d.frequency = 440.0f * std::pow(2.0f, (float) (note.midi - 69) / 12.0f);
d.cents = 0.0f; // ML detection is discrete-pitch — no cents estimate
d.noteName = midiNoteName(note.midi);
}
return d;
}
return pitchDetector.getLatestDetection();
}
PitchDetector::Detection SourceChain::getRawPitchDetection() const
{
// Same stopped/cold-start guard as getActiveDetection() so a halted engine
// returns no detection rather than a stale note.
if (! audioRunning.load(std::memory_order_relaxed))
return {};
// Always the raw YIN result — never the ML override.
return pitchDetector.getLatestDetection();
}
ChordScorer::Result SourceChain::scoreChordWithMl(const ChordScorer::Request& req) const
{
ChordScorer::Result out{};
out.totalStrings = (int) req.notes.size();
out.results.reserve(req.notes.size());
// Standard-tuning MIDI base for this (arrangement, stringCount). nullptr for
// unsupported pairs — every note then fails closed, mirroring the constraint
// scorer's fail-closed contract.
const std::vector<int>* base = ChordScorer::standardMidiFor(req.arrangement, req.stringCount);
// Mirror ChordScorer's request-shape validation: a tuningOffsets vector whose
// length doesn't match stringCount is malformed — fail closed.
const bool validRequest = base != nullptr
&& (int) req.tuningOffsets.size() == req.stringCount;
// Mirror ChordScorer exactly: a malformed request, or any single out-of-range
// note, fails the WHOLE chord closed (all-miss).
bool allValid = validRequest;
if (allValid)
for (const auto& n : req.notes)
{
if (n.string < 0 || n.string >= req.stringCount || n.fret < 0)
{
allValid = false;
break;
}
const int off = req.tuningOffsets[(size_t) n.string];
// Sum in 64-bit: base/off/capo/fret arrive from IPC as 32-bit ints,
// so an int sum could overflow before the range check.
const long long expectedMidi =
(long long) (*base)[(size_t) n.string] + off + req.capo + n.fret;
if (expectedMidi < 0 || expectedMidi > 127)
{
allValid = false;
break;
}
}
if (! allValid)
{
for (const auto& n : req.notes)
{
ChordScorer::NoteResult r{};
r.string = n.string;
r.fret = n.fret;
r.hasCents = false;
out.results.push_back(r); // r.hit defaults to false
}
out.hitStrings = 0;
out.score = 0.0f;
out.isHit = false;
return out;
}
int hits = 0;
for (const auto& n : req.notes)
{
ChordScorer::NoteResult r{};
r.string = n.string;
r.fret = n.fret;
r.hasCents = false; // ML judges by pitch-class membership, not cents
if (validRequest && n.string >= 0
&& n.string < (int) base->size() && n.fret >= 0)
{
// Expected MIDI exactly as ChordScorer computes it:
// base + per-string tuning offset + capo + fret.
const int off = (n.string < (int) req.tuningOffsets.size())
? req.tuningOffsets[(size_t) n.string] : 0;
const int expectedMidi = (int) (
(long long) (*base)[(size_t) n.string] + off + req.capo + n.fret);
float conf = 0.0f;
bool active = mlNoteDetector.isPitchActive(expectedMidi, &conf);
// Bend / slide: the sounding pitch is moving — accept a ±2 semitone
// window around the expected note.
if (! active && (n.bend || n.slide))
{
for (int d = -2; d <= 2 && ! active; ++d)
if (d != 0)
active = mlNoteDetector.isPitchActive(expectedMidi + d, &conf);
}
// Harmonic: the fretted fundamental is suppressed and an overtone
// sounds — accept the octave or octave+fifth above.
if (! active && n.harmonic)
active = mlNoteDetector.isPitchActive(expectedMidi + 12, &conf)
|| mlNoteDetector.isPitchActive(expectedMidi + 19, &conf);
r.hit = active;
r.bandEnergy = conf; // posteriorgram confidence, 0..1 (energy proxy)
}
if (r.hit) ++hits;
out.results.push_back(r);
}
out.hitStrings = hits;
out.score = out.totalStrings > 0 ? (float) hits / (float) out.totalStrings : 0.0f;
out.isHit = out.totalStrings > 0 && out.score >= req.minHitRatio;
return out;
}
+215
View File
@@ -0,0 +1,215 @@
#pragma once
#include "InputRingReader.h"
#include "NoiseGate.h"
#include "TonePolish.h"
#include "SignalChain.h"
#include "PitchDetector.h"
#include "ChordScorer.h"
#include "MlNoteDetector.h"
#include "NoteVerifier.h"
#include <juce_audio_basics/juce_audio_basics.h>
#include <array>
#include <atomic>
#include <cstdint>
#include <vector>
// SourceChain — one independent capture+detect+monitor chain for a single audio
// input. Owns everything that used to be a singleton on AudioEngine from the mono
// guitar signal downward: the lock-free input rings, the noise gate, the YIN +
// ML pitch detectors, the per-input tone chain (VST/NAM/IR), tone polish, and the
// background NoteVerifier with its own chart + verdict stream. AudioEngine holds a
// vector of these (sources[0] is the legacy default); the audio callback fans the
// device's channels out to each source's processBlock and fans the monitor signals
// back into one output mix.
//
// Phase 0 (this commit) wires exactly one source so behaviour is byte-identical to
// the old single-pipeline engine; multi-source fan-out lands in a later phase.
//
// Thread model is inherited verbatim from AudioEngine: processBlock/prepare/
// releaseResources run on the audio + device-management threads; the ring readers,
// scoreChord, and detection getters run on the N-API/main thread; NoteVerifier owns
// its own background worker. The rings are lock-free SPSC (audio writer / main
// reader). `engineAudioRunning` and `engineSampleRate` are references to the
// owning engine's atomics so the detection guards and sample-rate fallback match
// the engine exactly.
class SourceChain : public InputRingReader
{
public:
SourceChain(int id,
const std::atomic<bool>& engineAudioRunning,
const std::atomic<double>& engineSampleRate)
: sourceId(id), audioRunning(engineAudioRunning), sampleRate(engineSampleRate),
noteVerifier(*this) {}
// Stable id (== pool slot index); handed to JS as the source handle.
int getId() const { return sourceId; }
// Whether this chain is a live player. The audio callback skips inactive
// chains; the engine flips this on addSource/removeSource. Released chains
// keep their object (pooled) so there is no pointer-reassignment race with
// the audio thread. sources[0] is active from construction.
bool isActive() const { return active.load(std::memory_order_acquire); }
void setActive(bool a) { active.store(a, std::memory_order_release); }
// ── Lifecycle (audio/device-management thread) ────────────────────────────
// Reset rings to a clean cold start, size the zero-output scratch, and prepare
// every DSP unit. Mirrors the per-source half of audioDeviceAboutToStart.
void prepare(double sr, int blockSize);
// Release the chain, stop the ML detector + verifier, zero the ring indices.
// Mirrors the per-source half of audioDeviceStopped.
void releaseResources();
// Prepare only the monitor DSP (tone chain + gate + polish) at a sample rate
// — used by the device-setup paths (applyDuplexSetup / applySplitSetup),
// which historically prepared just these three before the full prepare() runs
// from audioDeviceAboutToStart. Kept narrow so behaviour is byte-identical.
void prepareMonitorChain(double sr, int blockSize)
{
signalChain.prepare(sr, blockSize);
noiseGate.prepare(sr, blockSize);
tonePolish.prepare(sr);
}
void releaseMonitorChain() { signalChain.releaseResources(); }
// ── Per-block processing (audio thread, RT-safe) ──────────────────────────
// Build this source's mono signal from `inputData` (channel select / mono
// mix per selectedInputChannel + inputGain), feed the ML detector + input
// ring, gate, feed the YIN detector + raw ring, run the tone chain, sanitize,
// apply monitor mute + chain gain + tone polish — leaving the processed
// monitor signal in `buffer` (the same in-place semantics as before). For
// Phase 0 `buffer` is the device output buffer and `effectiveOutputChannels`
// its channel count.
void processBlock(const float* const* inputData, int numInputChannels,
juce::AudioBuffer<float>& buffer, int effectiveOutputChannels,
int numSamples) noexcept;
// ── Accessors for the AudioEngine facade / NodeAddon ──────────────────────
SignalChain& getSignalChain() { return signalChain; }
PitchDetector& getPitchDetector() { return pitchDetector; }
MlNoteDetector& getMlNoteDetector() { return mlNoteDetector; }
bool loadNoteModel(const juce::File& modelFile) { return mlNoteDetector.loadModel(modelFile); }
bool hasMlNoteDetector() const { return mlNoteDetector.isAvailable(); }
PitchDetector::Detection getActiveDetection() const;
PitchDetector::Detection getRawPitchDetection() const;
ChordScorer::Result scoreChord(const ChordScorer::Request& req);
// Post-gate raw mono snapshot for the external tuner plugin (distinct from
// the pre-gate getInputFrame ring). Not part of InputRingReader.
std::vector<float> getRawAudioFrame(int numSamples = 4096) const;
void setChart(const NoteVerifier::ChartUpdate& chart) { noteVerifier.setChart(chart); }
void clearChart() { noteVerifier.clearChart(); }
std::vector<NoteVerifier::Verdict> getNoteVerdicts() { return noteVerifier.drainVerdicts(); }
void setPlayhead(double songTime, bool playing) { noteVerifier.setPlayhead(songTime, playing); }
// Per-source capture-latency correction (seconds), applied to the verifier
// playhead. Two INDEPENDENT components that SUM: the AUTO part is the engine's
// measured (extra-primary) device input-latency delta; the USER part is the
// renderer's manual fine-tune. Keeping them separate means a user nudge refines
// — rather than discards — the hardware compensation on platforms that report
// latency. 0 on the primary device.
void setVerifierAutoOffset(double seconds)
{
verifierAutoOffset.store(seconds, std::memory_order_relaxed);
noteVerifier.setPlayheadOffset(seconds + verifierUserOffset.load(std::memory_order_relaxed));
}
void setVerifierUserOffset(double seconds)
{
verifierUserOffset.store(seconds, std::memory_order_relaxed);
noteVerifier.setPlayheadOffset(verifierAutoOffset.load(std::memory_order_relaxed) + seconds);
}
void setNoiseGate(bool enabled, float thresholdDb, float releaseMs, float depthDb)
{
noiseGate.setParameters(enabled, thresholdDb, releaseMs, depthDb);
}
void setTonePolishEnabled(bool enabled) { tonePolish.setEnabled(enabled); }
void setInputGain(float gain) { inputGain.store(gain); }
float getInputGain() const { return inputGain.load(); }
void setChainOutputGain(float gain) { chainOutputGain.store(gain); }
float getChainOutputGain() const { return chainOutputGain.load(); }
void setInputChannel(int channel) { selectedInputChannel.store(channel); }
int getInputChannel() const { return selectedInputChannel.load(); }
// Which physical input device this source captures from. 0 = the primary
// input device (the legacy single-device path; every source today). Phase 2
// lets a source bind an ADDITIONAL device's slot so two separate interfaces
// (e.g. two USB cables) each feed their own sources at their own clock —
// only that device's callback processes this source. selectedInputChannel is
// 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(); }
float getInputLevel() const { return currentInputLevel.load(); }
float getInputPeak() const { return inputPeak.load(); }
void resetInputPeak() { inputPeak.store(0.0f); }
// Clear instantaneous level + latched peak — used when a pooled chain is reused
// for a new source so it doesn't report the previous player's meters until fresh
// audio arrives (getSourceLevels() exposes these per source).
void resetInputMeters() { currentInputLevel.store(0.0f); inputPeak.store(0.0f); }
uint32_t getNonFiniteChainBlocks() const { return nonFiniteChainBlocks.load(std::memory_order_relaxed); }
// ── InputRingReader ───────────────────────────────────────────────────────
std::vector<float> getInputFrame(int numSamples = 4096) const override;
uint64_t getInputSince(uint64_t fromIndex, std::vector<float>& out) const override;
double getCurrentSampleRate() const override { return sampleRate.load(std::memory_order_relaxed); }
private:
// ML-backed chord scoring against the MlNoteDetector's active-pitch set.
ChordScorer::Result scoreChordWithMl(const ChordScorer::Request& req) const;
// Append post-gate mono samples to rawAudioRing (audio-thread only, RT-safe).
void pushRawAudioFrame(const float* data, int numSamples) noexcept;
const int sourceId;
std::atomic<bool> active{false};
// Engine-owned shared state (read-only here): the run-state guard and the
// device sample rate, exactly as the original AudioEngine methods consulted.
const std::atomic<bool>& audioRunning;
const std::atomic<double>& sampleRate;
// ── DSP units (moved verbatim from AudioEngine) ───────────────────────────
SignalChain signalChain;
PitchDetector pitchDetector;
MlNoteDetector mlNoteDetector;
NoiseGate noiseGate;
TonePolish tonePolish;
ChordScorer chordScorer;
NoteVerifier noteVerifier; // constructed with *this as the InputRingReader
// ── Per-source controls / metering ────────────────────────────────────────
std::atomic<float> inputGain{1.0f};
std::atomic<float> chainOutputGain{1.0f};
std::atomic<float> currentInputLevel{0.0f};
std::atomic<float> inputPeak{0.0f};
std::atomic<int> selectedInputChannel{-1}; // -1 = mono mix
std::atomic<int> deviceKey{0}; // 0 = primary input device
std::atomic<double> verifierAutoOffset{0.0}; // engine: device-latency delta
std::atomic<double> verifierUserOffset{0.0}; // renderer: manual fine-tune
std::atomic<bool> monitorMuted{true};
std::atomic<bool> monitorMuteSuppressed{false};
std::atomic<uint32_t> nonFiniteChainBlocks{0};
// ── Lock-free SPSC input rings (see AudioEngine.h for the full rationale) ──
static constexpr int kInputFrameRingCapacity = 8192;
static_assert((kInputFrameRingCapacity & (kInputFrameRingCapacity - 1)) == 0,
"kInputFrameRingCapacity must be a power of two");
std::array<std::atomic<float>, kInputFrameRingCapacity> inputFrameRing{};
std::atomic<uint64_t> inputFrameRingWriteIndex{0};
static constexpr int kRawAudioRingCapacity = 16384;
static_assert((kRawAudioRingCapacity & (kRawAudioRingCapacity - 1)) == 0,
"kRawAudioRingCapacity must be a power of two");
std::array<std::atomic<float>, kRawAudioRingCapacity> rawAudioRing{};
std::atomic<uint64_t> rawAudioRingWriteIndex{0};
// Zero-output capture scratch, pre-sized in prepare() so the hot loop never
// allocates (input-only ASIO/JACK configs).
std::vector<float> inputCaptureScratch;
JUCE_DECLARE_NON_COPYABLE_WITH_LEAK_DETECTOR(SourceChain)
};
+116
View File
@@ -0,0 +1,116 @@
#include "TonePolish.h"
#include <cmath>
void TonePolish::prepare(double sr)
{
sampleRate = sr > 0.0 ? sr : 48000.0;
// juce::dsp::IIR::Filter is non-copy-assignable (its HeapBlock member is
// non-copyable), so vector::assign(count, value) won't compile. Clear and
// resize default-constructs each slot in place instead.
filters.clear();
filters.resize((size_t) kMaxChannels);
updateCoefficients();
reset();
// Signal that DSP state is fully initialised. Release-store so the audio
// thread's acquire-load in processBlock() sees all preceding writes.
paramPrepared.store(true, std::memory_order_release);
}
void TonePolish::reset()
{
for (auto& trio : filters)
for (auto& f : trio)
f.reset();
}
void TonePolish::setEnabled(bool enabled)
{
// When re-enabling, mark that filter state needs to be cleared before the
// next block so stale IIR delay lines don't produce a click at the bypass
// boundary.
//
// Ordering: paramNeedsReset is set *before* paramEnabled using seq_cst
// stores, and both reads in processBlock() use seq_cst loads. This gives
// a total store order visible to the audio thread, preventing the race
// where paramEnabled is seen as true while paramNeedsReset is still false
// (which would skip the reset and allow a click on re-enable).
if (enabled)
paramNeedsReset.store(true, std::memory_order_seq_cst);
paramEnabled.store(enabled, std::memory_order_seq_cst);
}
void TonePolish::updateCoefficients()
{
using Coeffs = juce::dsp::IIR::Coefficients<float>;
const auto sr = sampleRate;
// Reference-counted Coefficients objects shared across the per-channel
// Filter instances — one allocation per filter type, not per channel.
const auto hp = Coeffs::makeHighPass(sr, TonePolishSpecs::kHighPassHz);
// makeLowShelf signature: (sampleRate, cutoffFrequency, Q, gain).
// gain is a linear amplitude factor: 10^(dB/20).
const auto shelfGain = std::pow(10.0f, TonePolishSpecs::kLowShelfDb / 20.0f);
const auto ls = Coeffs::makeLowShelf(sr, TonePolishSpecs::kLowShelfHz,
1.0f / juce::MathConstants<float>::sqrt2, // S = 1 → Q ≈ 0.707
shelfGain);
const auto peakGain = std::pow(10.0f, TonePolishSpecs::kPeakDb / 20.0f);
const auto pk = Coeffs::makePeakFilter(sr, TonePolishSpecs::kPeakHz,
TonePolishSpecs::kPeakQ, peakGain);
for (auto& trio : filters)
{
trio[0].coefficients = hp;
trio[1].coefficients = ls;
trio[2].coefficients = pk;
}
}
void TonePolish::processBlock(juce::AudioBuffer<float>& buffer)
{
// Guard against calls before prepare() has populated filter coefficients.
if (! paramPrepared.load(std::memory_order_acquire))
return;
// seq_cst load pairs with the seq_cst store in setEnabled() so the
// paramNeedsReset flag written before paramEnabled is always visible
// before we observe paramEnabled == true.
if (! paramEnabled.load(std::memory_order_seq_cst))
return;
// Clear stale IIR delay-line state on re-enable so the first active block
// starts clean and does not produce a click at the bypass boundary.
if (paramNeedsReset.exchange(false, std::memory_order_seq_cst))
reset();
const int numChannels = buffer.getNumChannels();
const int numSamples = buffer.getNumSamples();
if (numChannels <= 0 || numSamples <= 0)
return;
const int available = (int) filters.size();
const int chans = juce::jmin(numChannels, available);
if (chans <= 0)
return;
float* const* channelData = buffer.getArrayOfWritePointers();
for (int ch = 0; ch < chans; ++ch)
{
auto* data = channelData[ch];
auto& trio = filters[(size_t) ch];
for (auto& f : trio)
{
juce::dsp::AudioBlock<float> block(&data, 1, (size_t) numSamples);
juce::dsp::ProcessContextReplacing<float> ctx(block);
f.process(ctx);
}
}
}
+73
View File
@@ -0,0 +1,73 @@
#pragma once
#include <array>
#include <atomic>
#include <vector>
#include <juce_audio_basics/juce_audio_basics.h>
#include <juce_dsp/juce_dsp.h>
// Tone Polish — fixed 3-band mastering EQ on the live guitar bus:
// • High-pass @ 80 Hz
// • Low shelf 3 dB @ 180 Hz
// • Peak/bell 0.5 dB @ 200 Hz, Q = 1
//
// Three cascaded IIR biquads per channel. Zero added latency. Insertion
// point in AudioEngine: between chainOutputGain and the backing-track
// mix, so this only colours the guitar — the backing track and master
// gain stage stay bit-untouched. Always enabled by default; renderer
// exposes a per-preset toggle that can disable it.
namespace TonePolishSpecs
{
inline constexpr float kHighPassHz = 80.0f;
inline constexpr float kLowShelfHz = 180.0f;
inline constexpr float kLowShelfDb = -3.0f;
inline constexpr float kPeakHz = 200.0f;
inline constexpr float kPeakQ = 1.0f;
inline constexpr float kPeakDb = -0.5f;
}
class TonePolish
{
public:
TonePolish() = default;
// Maximum channel count we pre-allocate filter state for. Any realistic
// audio device on Windows/macOS/Linux is well under this; we cap rather
// than grow on the audio thread so processBlock() never allocates.
static constexpr int kMaxChannels = 8;
void prepare(double sampleRate);
void reset();
// UI / IPC thread — flips the atomic only. Audio thread reads on each
// block. When disabled, processBlock() is a single load + early-return.
void setEnabled(bool enabled);
bool isEnabled() const { return paramEnabled.load(std::memory_order_seq_cst); }
void processBlock(juce::AudioBuffer<float>& buffer);
private:
void updateCoefficients();
std::atomic<bool> paramEnabled{true};
// Set to true at the end of prepare() and cleared in the constructor so
// processBlock() is a no-op until DSP state is fully initialised.
std::atomic<bool> paramPrepared{false};
// Set by setEnabled(true) on the UI thread so processBlock() clears stale
// IIR delay-line state before the first enabled block, preventing clicks
// at the bypass re-enable boundary.
std::atomic<bool> paramNeedsReset{false};
double sampleRate = 48000.0;
// Per channel: HPF, low-shelf, peak. Filters are stateful (one Direct
// Form II Transposed delay line per instance) so each channel needs
// its own trio. Coefficients are shared across channels via the JUCE
// reference-counted Coefficients<float> object so updateCoefficients()
// touches one allocation, not numChannels.
std::vector<std::array<juce::dsp::IIR::Filter<float>, 3>> filters;
JUCE_DECLARE_NON_COPYABLE_WITH_LEAK_DETECTOR(TonePolish)
};
+609
View File
@@ -0,0 +1,609 @@
#include "VSTHost.h"
#include "VSTTrace.h"
// The out-of-process scan path is compiled only into the audio addon
// (SLOPSMITH_AUDIO_ADDON, set in src/audio/CMakeLists.txt). slopsmith-vst-host
// also links VSTHost.cpp but must NOT pull in SandboxFactory — and it never
// calls scanDirectories anyway (it runs the --scan-plugin one-shot instead).
#if JUCE_WINDOWS && defined(SLOPSMITH_AUDIO_ADDON)
#include "Sandbox/SandboxedProcessor.h"
#endif
#if defined(SLOPSMITH_AUDIO_ADDON) && (JUCE_WINDOWS || JUCE_MAC)
namespace {
#if JUCE_MAC
#include <dlfcn.h>
// Anchor in this TU so dladdr resolves slopsmith_audio.node, not Electron.
static int macAudioAddonDlAddrAnchor() { return 0; }
// Directory containing slopsmith_audio.node (not Electron's executable).
static juce::File resolveMacAddonDirectory()
{
Dl_info info{};
if (dladdr(reinterpret_cast<const void*>(&macAudioAddonDlAddrAnchor),
&info) != 0
&& info.dli_fname != nullptr
&& info.dli_fname[0] != '\0')
{
const juce::File addonFile(info.dli_fname);
if (addonFile.existsAsFile())
return addonFile.getParentDirectory();
}
return {};
}
// macOS: slopsmith-vst-scan (built by src/vst-host/CMakeLists.txt).
static juce::File resolveMacScanHostExecutable()
{
if (const char* env = std::getenv("SLOPSMITH_VST_SCAN_HOST"))
{
const juce::File fromEnv(env);
if (fromEnv.existsAsFile())
return fromEnv;
}
juce::Array<juce::File> candidates;
// Packaged + dev: helper sits next to slopsmith_audio.node in
// build/Release/ or app.asar.unpacked/build/Release/.
const auto addonDir = resolveMacAddonDirectory();
if (addonDir.isDirectory())
candidates.add(addonDir.getChildFile("slopsmith-vst-scan"));
// npm run dev when cwd is slopsmith-desktop/
candidates.add(juce::File::getCurrentWorkingDirectory()
.getChildFile("build/Release/slopsmith-vst-scan"));
// Source-tree anchor when cwd differs
candidates.add(juce::File(__FILE__).getParentDirectory()
.getParentDirectory()
.getParentDirectory()
.getChildFile("build/Release/slopsmith-vst-scan"));
for (const auto& c : candidates)
if (c.existsAsFile())
return c;
return {};
}
#endif
// Probe one plugin file in a child scan host (slopsmith-vst-host.exe /
// slopsmith-vst-scan) so a plugin that
// crashes / aborts / hangs during init can't take down the host process.
// Returns the descriptor XML on success; sets `reason` and returns empty on
// failure (spawn failure, timeout, non-zero exit, or no output).
juce::String scanPluginOutOfProcess(const juce::File& hostExe,
const juce::String& pluginPath,
int timeoutMs,
juce::String& reason)
{
const juce::File outFile = juce::File::createTempFile(".scan.xml");
juce::ChildProcess proc;
const juce::StringArray args {
hostExe.getFullPathName(),
"--scan-plugin", pluginPath,
"--scan-out", outFile.getFullPathName(),
};
if (! proc.start(args, 0))
{
reason = "failed to spawn scan host";
outFile.deleteFile();
return {};
}
if (! proc.waitForProcessToFinish(timeoutMs))
{
// A plugin that hangs during init (license-wait deadlock, modal
// dialog) never returns — kill the child and move on.
proc.kill();
reason = "scan timed out after " + juce::String(timeoutMs) + " ms";
outFile.deleteFile();
return {};
}
const auto exitCode = proc.getExitCode();
if (exitCode != 0)
{
reason = "scan host exited with code " + juce::String((int) exitCode);
outFile.deleteFile();
return {};
}
const juce::String xml = outFile.loadFileAsString();
outFile.deleteFile();
if (xml.isEmpty())
{
reason = "scan host produced no output";
return {};
}
return xml;
}
static juce::File resolveOutOfProcessScanHost()
{
#if JUCE_WINDOWS
return slopsmith::sandbox::resolveSandboxExe();
#elif JUCE_MAC
return resolveMacScanHostExecutable();
#else
return {};
#endif
}
} // anonymous
#endif
VSTHost::VSTHost()
{
formatManager.addFormat(std::make_unique<juce::VST3PluginFormat>());
#if JUCE_PLUGINHOST_AU
formatManager.addFormat(std::make_unique<juce::AudioUnitPluginFormat>());
#endif
#if JUCE_PLUGINHOST_LV2
formatManager.addFormat(std::make_unique<juce::LV2PluginFormat>());
#endif
}
VSTHost::~VSTHost()
{
cancelScan();
}
// ── Scanning ──────────────────────────────────────────────────────────────────
juce::StringArray VSTHost::getDefaultScanDirectories()
{
juce::StringArray dirs;
#if JUCE_LINUX
dirs.add(juce::File::getSpecialLocation(juce::File::userHomeDirectory)
.getChildFile(".vst3").getFullPathName());
dirs.add("/usr/lib/vst3");
dirs.add("/usr/local/lib/vst3");
// LV2
dirs.add(juce::File::getSpecialLocation(juce::File::userHomeDirectory)
.getChildFile(".lv2").getFullPathName());
#if JUCE_64BIT
if (juce::File ("/usr/lib64/lv2").exists())
{
dirs.add("/usr/local/lib64/lv2");
dirs.add("/usr/lib64/lv2");
}
else
#endif
{
dirs.add("/usr/lib/lv2");
dirs.add("/usr/local/lib/lv2");
}
#elif JUCE_MAC
dirs.add(juce::File::getSpecialLocation(juce::File::userHomeDirectory)
.getChildFile("Library/Audio/Plug-Ins/VST3").getFullPathName());
dirs.add("/Library/Audio/Plug-Ins/VST3");
#if JUCE_PLUGINHOST_AU
dirs.add(juce::File::getSpecialLocation(juce::File::userHomeDirectory)
.getChildFile("Library/Audio/Plug-Ins/Components").getFullPathName());
dirs.add("/Library/Audio/Plug-Ins/Components");
#endif
#elif JUCE_WINDOWS
dirs.add("C:\\Program Files\\Common Files\\VST3");
dirs.add("C:\\Program Files (x86)\\Common Files\\VST3");
auto localAppData = juce::File::getSpecialLocation(juce::File::userApplicationDataDirectory);
dirs.add(localAppData.getChildFile("VST3").getFullPathName());
#endif
return dirs;
}
void VSTHost::scanDefaultDirectories(ScanProgressCallback callback)
{
scanDirectories(getDefaultScanDirectories(), std::move(callback));
}
namespace {
bool isFormatSupported(const juce::AudioPluginFormatManager& fm,
const juce::PluginDescription& desc)
{
for (auto* format : fm.getFormats())
if (format->getName() == desc.pluginFormatName)
return true;
return false;
}
} // namespace
void VSTHost::scanDirectories(const juce::StringArray& directories, ScanProgressCallback callback)
{
if (scanning.load()) return;
scanning.store(true);
scanCancelled.store(false);
// Collect all plugin files first
juce::StringArray filesToScan;
for (auto& dir : directories)
{
juce::File d(dir);
if (!d.isDirectory()) continue;
// VST3
for (auto& f : d.findChildFiles(juce::File::findFilesAndDirectories, true, "*.vst3"))
filesToScan.addIfNotAlreadyThere(f.getFullPathName());
// AU (.component) — only when this binary can actually load AudioUnits.
// The Electron addon deliberately omits JUCE_PLUGINHOST_AU (see
// src/audio/CMakeLists.txt); scanning Components would list duplicates
// that fail at load with "No compatible plug-in format exists".
#if JUCE_MAC && JUCE_PLUGINHOST_AU
for (auto& f : d.findChildFiles(juce::File::findFilesAndDirectories, true, "*.component"))
filesToScan.addIfNotAlreadyThere(f.getFullPathName());
#endif
// LV2
#if JUCE_PLUGINHOST_LV2
for (auto& f : d.findChildFiles(juce::File::findDirectories, true, "*.lv2"))
filesToScan.addIfNotAlreadyThere(f.getFullPathName());
#endif
}
const int totalFiles = filesToScan.size();
int scannedCount = 0;
#if defined(SLOPSMITH_AUDIO_ADDON) && (JUCE_WINDOWS || JUCE_MAC)
// Out-of-process scan: one child per plugin file. In-process scanAndAddFile
// inside Electron can SIGTRAP/abort on certain plugins.
{
// Skip helper resolution entirely when there's nothing to probe — a
// missing helper shouldn't block an "everything was uninstalled"
// rescan from clearing the catalog.
juce::File hostExe;
if (! filesToScan.isEmpty())
{
hostExe = resolveOutOfProcessScanHost();
if (! hostExe.existsAsFile())
{
juce::Logger::writeToLog("VST scan: out-of-process scan host not found —"
" aborting rescan (plugin list unchanged)");
scanning.store(false);
return;
}
}
// Stage in a temp list and swap into knownPlugins only after a clean
// pass. Cancel-mid-scan or every-probe-fails (broken helper env, etc.)
// leaves the live catalog untouched instead of wiping it to empty.
juce::KnownPluginList staged;
bool completed = true;
constexpr int kScanTimeoutMs = 20000;
for (auto& file : filesToScan)
{
if (scanCancelled.load()) { completed = false; break; }
juce::String reason;
const juce::String xml = scanPluginOutOfProcess(
hostExe, file, kScanTimeoutMs, reason);
if (xml.isEmpty() || ! mergePluginsFromXmlInto(xml, staged))
{
if (reason.isEmpty())
reason = "scan host produced unparseable output";
juce::Logger::writeToLog("VST scan: skipped " + file
+ "" + reason);
}
++scannedCount;
const float progress = totalFiles > 0
? (float) scannedCount / (float) totalFiles : 1.0f;
if (callback)
callback(progress,
juce::File(file).getFileNameWithoutExtension());
}
// Swap only if the pass completed AND it actually produced results
// (or there were no plugins to scan — then an empty catalog is correct).
if (completed && (totalFiles == 0 || staged.getNumTypes() > 0))
{
const juce::ScopedLock sl(listLock);
knownPlugins.clear();
for (auto& desc : staged.getTypes())
knownPlugins.addType(desc);
}
scanning.store(false);
return;
}
#endif
#if defined(SLOPSMITH_AUDIO_ADDON) && ! (JUCE_WINDOWS || JUCE_MAC)
// Linux: no out-of-process scan host. Stage into a temp list and swap on
// success so a cancelled scan doesn't wipe stale-but-still-loadable rows.
juce::KnownPluginList linuxStaged;
bool linuxRescanCompleted = true;
#endif
// In-process scan (Linux addon, or non-addon builds).
for (auto& file : filesToScan)
{
if (scanCancelled.load())
{
#if defined(SLOPSMITH_AUDIO_ADDON) && ! (JUCE_WINDOWS || JUCE_MAC)
linuxRescanCompleted = false;
#endif
break;
}
juce::String pluginName = juce::File(file).getFileNameWithoutExtension();
for (auto* format : formatManager.getFormats())
{
if (scanCancelled.load())
{
#if defined(SLOPSMITH_AUDIO_ADDON) && ! (JUCE_WINDOWS || JUCE_MAC)
linuxRescanCompleted = false;
#endif
break;
}
juce::OwnedArray<juce::PluginDescription> found;
#if defined(SLOPSMITH_AUDIO_ADDON) && ! (JUCE_WINDOWS || JUCE_MAC)
// Local list — no lock needed; swapped into knownPlugins below.
linuxStaged.scanAndAddFile(file, true, found, *format);
#else
{
const juce::ScopedLock sl(listLock);
knownPlugins.scanAndAddFile(file, true, found, *format);
}
#endif
for (auto* desc : found)
pluginName = desc->name;
}
scannedCount++;
float progress = totalFiles > 0 ? (float)scannedCount / (float)totalFiles : 1.0f;
if (callback) callback(progress, pluginName);
}
#if defined(SLOPSMITH_AUDIO_ADDON) && ! (JUCE_WINDOWS || JUCE_MAC)
if (linuxRescanCompleted
&& (totalFiles == 0 || linuxStaged.getNumTypes() > 0))
{
const juce::ScopedLock sl(listLock);
knownPlugins.clear();
for (auto& desc : linuxStaged.getTypes())
knownPlugins.addType(desc);
}
#endif
scanning.store(false);
}
juce::String VSTHost::scanPluginFileToXml(const juce::String& path)
{
juce::XmlElement root("PLUGINS");
for (auto* format : formatManager.getFormats())
{
juce::OwnedArray<juce::PluginDescription> found;
{
const juce::ScopedLock sl(listLock);
knownPlugins.scanAndAddFile(path, true, found, *format);
}
for (auto* desc : found)
root.addChildElement(desc->createXml().release());
}
// Always a parseable document — <PLUGINS/> when the file yields nothing,
// so the parent treats "scanned, empty" as success rather than failure.
return root.toString();
}
bool VSTHost::mergePluginsFromXmlInto(const juce::String& xml,
juce::KnownPluginList& target) const
{
const auto parsed = juce::parseXML(xml);
if (parsed == nullptr || ! parsed->hasTagName("PLUGINS"))
return false;
for (auto* child : parsed->getChildIterator())
{
juce::PluginDescription desc;
if (! desc.loadFromXml(*child))
continue;
#if defined(SLOPSMITH_AUDIO_ADDON)
// Scan helper may probe formats the addon cannot host (e.g. AU in
// slopsmith-vst-scan). Skip them so the UI does not list unloadable dupes.
if (! isFormatSupported(formatManager, desc))
continue;
#endif
target.addType(desc);
}
return true;
}
bool VSTHost::addPluginsFromXml(const juce::String& xml)
{
const juce::ScopedLock sl(listLock);
return mergePluginsFromXmlInto(xml, knownPlugins);
}
// ── Plugin Access ─────────────────────────────────────────────────────────────
juce::Array<VSTHost::PluginInfo> VSTHost::getKnownPlugins() const
{
juce::Array<PluginInfo> result;
const juce::ScopedLock sl(listLock);
for (auto& desc : knownPlugins.getTypes())
{
PluginInfo info;
info.name = desc.name;
info.manufacturer = desc.manufacturerName;
info.category = desc.category;
info.formatName = desc.pluginFormatName;
info.fileOrIdentifier = desc.fileOrIdentifier;
info.uid = desc.createIdentifierString();
info.isInstrument = desc.isInstrument;
result.add(info);
}
return result;
}
std::unique_ptr<juce::AudioPluginInstance> VSTHost::loadPlugin(
const juce::String& fileOrIdentifier,
double sampleRate, int blockSize,
juce::String& errorMessage)
{
// Find matching description
juce::PluginDescription matchedDesc;
bool found = false;
{
const juce::ScopedLock sl(listLock);
for (auto& desc : knownPlugins.getTypes())
{
if (desc.fileOrIdentifier == fileOrIdentifier ||
desc.createIdentifierString() == fileOrIdentifier)
{
matchedDesc = desc;
found = true;
break;
}
}
}
if (!found)
{
// Try scanning the file directly if not in known list
juce::OwnedArray<juce::PluginDescription> descs;
for (auto* format : formatManager.getFormats())
{
const juce::ScopedLock sl(listLock);
knownPlugins.scanAndAddFile(fileOrIdentifier, true, descs, *format);
}
if (descs.isEmpty())
{
errorMessage = "Plugin not found: " + fileOrIdentifier;
return nullptr;
}
matchedDesc = *descs[0];
}
// Create instance synchronously
juce::String error;
VST_TRACE("VSTHost.loadPlugin: createPluginInstance BEGIN name='%s' format='%s' file='%s' sr=%.0f bs=%d",
matchedDesc.name.toRawUTF8(),
matchedDesc.pluginFormatName.toRawUTF8(),
matchedDesc.fileOrIdentifier.toRawUTF8(),
sampleRate, blockSize);
auto instance = formatManager.createPluginInstance(
matchedDesc, sampleRate, blockSize, error);
VST_TRACE("VSTHost.loadPlugin: createPluginInstance END instance=%s error='%s'",
instance ? "OK" : "null",
error.toRawUTF8());
if (!instance)
{
errorMessage = error.isNotEmpty() ? error : "Failed to create plugin instance";
return nullptr;
}
return instance;
}
void VSTHost::loadPluginAsync(
const juce::String& fileOrIdentifier,
double sampleRate, int blockSize,
std::function<void(std::unique_ptr<juce::AudioPluginInstance>, juce::String)> callback)
{
// Same matchedDesc lookup as the sync loadPlugin above. Kept inline
// rather than factored out so the two paths can be read independently.
juce::PluginDescription matchedDesc;
bool found = false;
{
const juce::ScopedLock sl(listLock);
for (auto& desc : knownPlugins.getTypes())
{
if (desc.fileOrIdentifier == fileOrIdentifier
|| desc.createIdentifierString() == fileOrIdentifier)
{
matchedDesc = desc;
found = true;
break;
}
}
}
if (!found)
{
juce::OwnedArray<juce::PluginDescription> descs;
for (auto* format : formatManager.getFormats())
{
const juce::ScopedLock sl(listLock);
knownPlugins.scanAndAddFile(fileOrIdentifier, true, descs, *format);
}
if (descs.isEmpty())
{
callback(nullptr, "Plugin not found: " + fileOrIdentifier);
return;
}
matchedDesc = *descs[0];
}
VST_TRACE("VSTHost.loadPluginAsync: createPluginInstanceAsync BEGIN "
"name='%s' format='%s' file='%s' sr=%.0f bs=%d",
matchedDesc.name.toRawUTF8(),
matchedDesc.pluginFormatName.toRawUTF8(),
matchedDesc.fileOrIdentifier.toRawUTF8(),
sampleRate, blockSize);
// createPluginInstanceAsync pumps the message thread while the plugin
// initialises. The callback fires on the message thread when the load
// completes (or fails). Move the user's callback in so a single shared
// copy threads through both lambda hops.
formatManager.createPluginInstanceAsync(
matchedDesc, sampleRate, blockSize,
[cb = std::move(callback), name = matchedDesc.name]
(std::unique_ptr<juce::AudioPluginInstance> instance, const juce::String& error)
{
VST_TRACE("VSTHost.loadPluginAsync: createPluginInstanceAsync END "
"name='%s' instance=%s error='%s'",
name.toRawUTF8(),
instance ? "OK" : "null",
error.toRawUTF8());
if (!instance)
{
cb(nullptr,
error.isNotEmpty() ? error
: juce::String("Failed to create plugin instance"));
return;
}
cb(std::move(instance), {});
});
}
// ── Persistence ───────────────────────────────────────────────────────────────
void VSTHost::savePluginList(const juce::File& xmlFile)
{
const juce::ScopedLock sl(listLock);
if (auto xml = knownPlugins.createXml())
xml->writeTo(xmlFile);
}
void VSTHost::loadPluginList(const juce::File& xmlFile)
{
if (!xmlFile.existsAsFile()) return;
if (auto xml = juce::XmlDocument::parse(xmlFile))
{
const juce::ScopedLock sl(listLock);
knownPlugins.recreateFromXml(*xml);
}
}
+91
View File
@@ -0,0 +1,91 @@
#pragma once
#include <juce_audio_processors/juce_audio_processors.h>
#include <functional>
class VSTHost
{
public:
VSTHost();
~VSTHost();
// Plugin scanning — runs on background thread, reports progress
using ScanProgressCallback = std::function<void(float progress, const juce::String& pluginName)>;
void scanDirectories(const juce::StringArray& directories, ScanProgressCallback callback);
void scanDefaultDirectories(ScanProgressCallback callback);
bool isScanning() const { return scanning.load(); }
void cancelScan() { scanCancelled.store(true); }
// Access scan results
struct PluginInfo
{
juce::String name;
juce::String manufacturer;
juce::String category;
juce::String formatName; // VST3, AU, LV2
juce::String fileOrIdentifier;
juce::String uid;
bool isInstrument = false;
};
juce::Array<PluginInfo> getKnownPlugins() const;
// Scan a single plugin file in-process and return its PluginDescriptions
// serialised as XML (root <PLUGINS>, one child element per description;
// <PLUGINS/> when the file yields nothing). Used by slopsmith-vst-host's
// --scan-plugin subprocess mode so a crashy plugin can't take the app down.
juce::String scanPluginFileToXml(const juce::String& path);
// Merge PluginDescriptions from XML produced by scanPluginFileToXml (in a
// child process) into the known-plugins list. Returns false if the XML
// could not be parsed as a <PLUGINS> document (a child that exited 0 but
// emitted garbage) — the caller treats that as a failed probe rather than
// silently counting the plugin as scanned. A valid but empty <PLUGINS/>
// returns true (the file genuinely yielded no descriptors).
bool addPluginsFromXml(const juce::String& xml);
// Load a plugin instance
std::unique_ptr<juce::AudioPluginInstance> loadPlugin(
const juce::String& fileOrIdentifier,
double sampleRate, int blockSize,
juce::String& errorMessage);
// Async variant: uses JUCE's createPluginInstanceAsync so the message
// thread is free to pump during plugin initialisation. Required for
// plugins that post WM_USER / WM_TIMER messages to themselves during
// init (AmpliTube, and a class of other DAW-targeted VST3s) — the sync
// createPluginInstance would block the pump and the plugin's init never
// finishes wiring up internal state, producing a half-wired editor that
// crashes on its first WindowProc dispatch.
//
// Must be called from the JUCE message thread; the callback fires there
// too. Callers waiting on the result must do so from a *different*
// thread (e.g. a libuv worker) so the message thread can keep pumping.
void loadPluginAsync(
const juce::String& fileOrIdentifier,
double sampleRate, int blockSize,
std::function<void(std::unique_ptr<juce::AudioPluginInstance>, juce::String)> callback);
// Persistence
void savePluginList(const juce::File& xmlFile);
void loadPluginList(const juce::File& xmlFile);
// Default scan paths per platform
static juce::StringArray getDefaultScanDirectories();
private:
// Parse an XML document produced by scanPluginFileToXml and merge each
// contained PluginDescription into `target`. Returns false if the XML is
// not a valid <PLUGINS> document. Does not take listLock — the caller is
// responsible for synchronising access to `target` (or staging results in
// a thread-local list before swapping into knownPlugins).
bool mergePluginsFromXmlInto(const juce::String& xml,
juce::KnownPluginList& target) const;
juce::AudioPluginFormatManager formatManager;
juce::KnownPluginList knownPlugins;
juce::CriticalSection listLock;
std::atomic<bool> scanning{false};
std::atomic<bool> scanCancelled{false};
JUCE_DECLARE_NON_COPYABLE_WITH_LEAK_DETECTOR(VSTHost)
};
+163
View File
@@ -0,0 +1,163 @@
// Diagnostic logger for VST3 host ↔ plugin handshake.
//
// Defined in a header so it's reachable from both the addon
// (NodeAddon.cpp, VSTHost.cpp) and the vendored JUCE VST3 host context
// (juce_VST3PluginFormatImpl.h). Writes every call to a file + stderr
// with immediate flush so a process abort (e.g. `__fastfail` from a
// crashy plugin) doesn't lose the last few lines.
//
// Compiled into all builds, but no-op at runtime unless
// SLOPSMITH_SANDBOX_DEBUG is set to any non-empty value other than "0"
// when the addon loads. The first call caches the env var, so flipping
// it mid-process has no effect.
#pragma once
#include <cstdio>
#include <cstdarg>
#include <cstdlib>
#include <cstring>
#include <ctime>
#include <mutex>
#if defined(_WIN32)
#include <windows.h>
#else
#include <unistd.h>
#endif
namespace slopsmith_vst_trace {
// Gate on SLOPSMITH_SANDBOX_DEBUG=1 so release builds don't litter %TEMP%
// with the trace file or burn stderr cycles on every host callback.
inline bool isEnabled()
{
static const bool v = [] {
const char* s = std::getenv("SLOPSMITH_SANDBOX_DEBUG");
return s && *s && std::strcmp(s, "0") != 0;
}();
return v;
}
inline std::FILE* logFile()
{
static std::FILE* f = []() -> std::FILE* {
if (!isEnabled()) return nullptr;
char path[1024] = {0};
#if defined(_WIN32)
// Prefer %TEMP%; fall back to %USERPROFILE% (per-PID log path in
// src/vst-host/main.cpp does the same — non-elevated users can't
// write to C:\ root, and "trace gated on env var but silently
// creates no file" is the worst-of-both-worlds failure mode).
DWORD n = GetEnvironmentVariableA("TEMP", path, sizeof(path));
if (n == 0 || n >= sizeof(path)) {
n = GetEnvironmentVariableA("USERPROFILE", path, sizeof(path));
}
if (n > 0 && n < sizeof(path)) {
// Per-PID suffix: this header is compiled into both the addon
// and the sandbox host, so concurrent runs would otherwise
// interleave traces in one file and make sandbox debugging
// (host A spawning host B) hard to correlate. Mirrors the
// per-PID naming the sandbox-host log already uses.
char suffix[64]{};
const int suffixLen = std::snprintf(
suffix, sizeof(suffix), "\\slopsmith-vst-trace-%lu.log",
(unsigned long)GetCurrentProcessId());
if (suffixLen > 0
&& (size_t)n + (size_t)suffixLen < sizeof(path))
{
std::strncat(path, suffix, sizeof(path) - n - 1);
}
}
// If both env vars failed, leave `path` empty — writing to the
// drive root requires admin on a default Windows install and the
// sandbox host's per-PID log made the same decision. fopen(nullptr-
// equivalent path) returns NULL, which the rest of the code handles
// cleanly.
#else
// Per-PID suffix mirrors the Windows branch: same rationale (header
// compiled into addon + sandbox host → concurrent runs interleave
// the same file) plus /tmp is world-writable on POSIX, which makes
// a stable filename a symlink-attack / log-poisoning vector. The
// per-PID name closes both.
std::snprintf(path, sizeof(path), "/tmp/slopsmith-vst-trace-%ld.log",
(long)getpid());
#endif
// Truncate ("w") rather than append ("a"): per-PID naming guarantees
// single-writer-per-file, but Windows reuses PIDs across reboots
// (and Linux can too under PID-namespace cycling), so an old trace
// from a previous run on the same PID would otherwise accumulate
// confusing context. Matches the sandbox-host log policy in
// src/vst-host/main.cpp.
std::FILE* fp = path[0] ? std::fopen(path, "w") : nullptr;
if (fp) {
std::fprintf(fp, "\n========== slopsmith-vst-trace opened (pid=%lu) ==========\n",
(unsigned long)
#if defined(_WIN32)
GetCurrentProcessId()
#else
(unsigned long) getpid()
#endif
);
std::fflush(fp);
}
return fp;
}();
return f;
}
inline std::mutex& logMutex()
{
static std::mutex m;
return m;
}
inline void writef(const char* fmt, ...)
{
if (!isEnabled()) return;
std::lock_guard<std::mutex> lock(logMutex());
char buf[2048];
va_list ap;
va_start(ap, fmt);
std::vsnprintf(buf, sizeof(buf), fmt, ap);
va_end(ap);
auto* fp = logFile();
if (fp) {
std::fputs(buf, fp);
std::fputc('\n', fp);
std::fflush(fp);
}
std::fputs("[vst-trace] ", stderr);
std::fputs(buf, stderr);
std::fputc('\n', stderr);
std::fflush(stderr);
}
// Format 16 hex bytes of a TUID. The Steinberg TUID type is `char[16]`.
// Rotates through a 4-slot ring of thread-local buffers so multiple tuidHex
// arguments in a single VST_TRACE call don't clobber each other before the
// formatter consumes them.
inline const char* tuidHex(const void* tuid)
{
static thread_local char ring[4][40];
static thread_local unsigned idx = 0;
char* out = ring[idx++ & 3u];
const unsigned char* b = static_cast<const unsigned char*>(tuid);
std::snprintf(out, sizeof(ring[0]),
"%02x%02x%02x%02x-%02x%02x%02x%02x-%02x%02x%02x%02x-%02x%02x%02x%02x",
b[0], b[1], b[2], b[3], b[4], b[5], b[6], b[7],
b[8], b[9], b[10], b[11], b[12], b[13], b[14], b[15]);
return out;
}
} // namespace slopsmith_vst_trace
// Check the enabled flag at the call site so disabled builds don't pay for
// formatting arg evaluation (e.g. `tuidHex(...)`, `cmd.toRawUTF8()`).
#define VST_TRACE(...) \
do { \
if (::slopsmith_vst_trace::isEnabled()) \
::slopsmith_vst_trace::writef(__VA_ARGS__); \
} while (0)
View File
Vendored Submodule
+1
File diff suppressed because it is too large Load Diff
+713
View File
@@ -0,0 +1,713 @@
import * as fs from 'fs';
import * as path from 'path';
const PLAN_SCHEMA = 'slopsmith.audio_effects.chain_plan.v1';
const DEFAULT_ROUTE_KEY = 'desktop-main';
const MAX_STAGES = 24;
const MAX_SEGMENTS = 80;
const MAX_PARAM_INDEX = 4095;
const MAX_SEQUENTIAL_NAM = 8;
const VALID_KINDS = new Set(['nam', 'ir', 'vst', 'utility', 'bypass']);
const VALID_ROLES = new Set(['input', 'pre-pedal', 'pedal', 'amp', 'post-pedal', 'rack', 'cab', 'master-pre', 'master-post', 'utility', 'unknown']);
const AUTHORIZATIONS = new Set(['user-action', 'restore-selection', 'playback-session']);
const NATIVE_TYPES: Record<string, number> = {
vst: 0,
nam: 1,
ir: 2,
};
type Dict = Record<string, unknown>;
type AudioEffectsNativeAudio = {
loadPreset?: (presetJson: string) => Promise<unknown> | unknown;
savePreset?: () => unknown;
clearChain?: () => Promise<unknown> | unknown;
getChainState?: () => unknown;
setBypass?: (slotId: number, bypassed: boolean) => unknown;
setMultiBypass?: (changes: Array<{ slotId: number; bypassed: boolean }>) => unknown;
setParameter?: (slotId: number, paramIndex: number, value: number) => unknown;
setGain?: (which: string, value: number) => Promise<unknown> | unknown;
setMonitorMute?: (muted: boolean) => Promise<unknown> | unknown;
setMonitorMuteSuppressed?: (suppressed: boolean) => Promise<unknown> | unknown;
isMonitorMuted?: () => Promise<unknown> | unknown;
startAudio?: () => Promise<unknown> | unknown;
};
type NativeAudioGetter = () => AudioEffectsNativeAudio | null;
type ValidStage = {
stageId: string;
kind: string;
role: string;
assetRef: string;
stateRef: string;
bypassed: boolean;
gainDb: number;
native: boolean;
};
type ValidSegment = {
segmentId: string;
stageIds: string[];
stageBypass: Record<string, boolean>;
};
type ValidPlan = {
planId: string;
routeKey: string;
providerId: string;
stages: ValidStage[];
segments: ValidSegment[];
};
type RouteGains = {
input?: number;
chain?: number;
};
type LoadOptions = {
preloadMute: {
enabled: boolean;
dryDuringLoad: boolean;
targetGain: number;
holdMs: number;
} | null;
gains: RouteGains;
startAudio: boolean;
};
type RouteState = {
routeKey: string;
providerId: string;
planId: string;
state: string;
activeSegmentId: string;
stageSlots: Map<string, number>;
stageKinds: Map<string, string>;
segments: ValidSegment[];
loadedAt: string;
updatedAt: string;
lastOutcome: SafeOutcome | null;
};
type AudioEffectsOutcome = 'handled' | 'degraded' | 'failed' | 'unavailable' | 'no-target' | 'user-action-required';
type SafeOutcome = {
outcome: AudioEffectsOutcome;
status: string;
reason: string;
payload?: Dict;
};
function asRecord(value: unknown): Dict | null {
return value && typeof value === 'object' && !Array.isArray(value) ? value as Dict : null;
}
function asArray(value: unknown): unknown[] {
return Array.isArray(value) ? value : [];
}
function now(): string {
return new Date().toISOString();
}
function bounded(value: unknown, max = 200): string {
return String(value ?? '')
.replace(/(?:\/Users\/|\/home\/|\/root\b\/?)[^\r\n\t"'`,;(){}\[\]<>|]*/g, '[path]')
.replace(/[A-Za-z]:\\[^\r\n\t"'`,;(){}\[\]<>|]*/g, '[path]')
.replace(/https?:\/\/[^\s?#]+[^\s]*/gi, '[url]')
.replace(/file:\/\/[^\s]+/gi, '[path]')
.replace(/\b(token|secret|password|api[_-]?key|key)=([^\s&]+)/gi, '$1=[redacted]')
.replace(/\b[^\s]+\.(psarc|sloppak|wem|ogg|mp3|wav|flac|nam|vst3|component|dll|json|db)\b/gi, '[file]')
.replace(/\s+/g, ' ')
.trim()
.slice(0, max);
}
function safeId(value: unknown, fallback: string): string {
const text = String(value ?? '').trim() || fallback;
return text.replace(/[^A-Za-z0-9_.:-]+/g, '-').replace(/^-+|-+$/g, '').slice(0, 96) || fallback;
}
function safeNumber(value: unknown, fallback = 0): number {
const numberValue = Number(value);
return Number.isFinite(numberValue) ? numberValue : fallback;
}
function clampGain(value: unknown, fallback = Number.NaN): number {
const numberValue = safeNumber(value, fallback);
return Number.isFinite(numberValue) ? Math.max(0, Math.min(32, numberValue)) : Number.NaN;
}
function parseGains(value: unknown): RouteGains {
const input = asRecord(value) || {};
const gains: RouteGains = {};
const inputGain = clampGain(input.input);
const chainGain = clampGain(input.chain);
if (Number.isFinite(inputGain)) gains.input = inputGain;
if (Number.isFinite(chainGain)) gains.chain = chainGain;
return gains;
}
function parseLoadOptions(value: unknown): LoadOptions {
const input = asRecord(value) || {};
const rawPreload = asRecord(input.preloadMute) || asRecord(input.preLoadMute) || asRecord(input.loadMute) || asRecord(input.preload) || {};
const hasPreload = input.preloadMute === true || input.loadMute === true || Object.keys(rawPreload).length > 0;
const targetGain = clampGain(rawPreload.targetGain ?? rawPreload.restoreGain ?? rawPreload.chainGain, 1);
return {
preloadMute: hasPreload ? {
enabled: rawPreload.enabled !== false && input.preloadMute !== false && input.loadMute !== false,
dryDuringLoad: rawPreload.dryDuringLoad !== false,
targetGain: Number.isFinite(targetGain) ? targetGain : 1,
holdMs: Math.max(0, Math.min(5000, Math.round(safeNumber(rawPreload.holdMs, 0)))),
} : null,
gains: parseGains(input.gains ?? input.gain),
startAudio: input.startAudio === true,
};
}
function parseStrictNumber(value: unknown): number {
if (typeof value === 'number') return Number.isFinite(value) ? value : Number.NaN;
if (typeof value !== 'string') return Number.NaN;
const trimmed = value.trim();
if (!trimmed || !/^[+-]?(?:\d+(?:\.\d*)?|\.\d+)$/.test(trimmed)) return Number.NaN;
const parsed = Number(trimmed);
return Number.isFinite(parsed) ? parsed : Number.NaN;
}
function parseParamIndex(input: Dict): number {
const paramIndex = parseStrictNumber(input.paramIndex);
if (Number.isFinite(paramIndex)) return paramIndex;
return parseStrictNumber(input.parameterId);
}
function nativeFailure(result: unknown): boolean {
return result === false || asRecord(result)?.success === false;
}
function safeBool(value: unknown, fallback = false): boolean {
return value === true || value === false ? value : fallback;
}
function safeOutcome(outcome: AudioEffectsOutcome, reason: unknown, payload?: Dict, status?: string): SafeOutcome {
return {
outcome,
status: status || outcome,
reason: bounded(reason),
...(payload ? { payload } : {}),
};
}
function safeRoute(route: RouteState): Dict {
return {
routeKey: route.routeKey,
providerId: route.providerId,
planId: route.planId,
state: route.state,
activeSegmentId: route.activeSegmentId,
nativeStageCount: route.stageSlots.size,
stageKinds: Array.from(route.stageKinds.values()),
segmentCount: route.segments.length,
loadedAt: route.loadedAt,
updatedAt: route.updatedAt,
lastOutcome: route.lastOutcome ? {
outcome: route.lastOutcome.outcome,
status: route.lastOutcome.status,
reason: route.lastOutcome.reason,
} : null,
};
}
function normalizeAssetMap(value: unknown): Map<string, Dict> {
const map = new Map<string, Dict>();
const record = asRecord(value);
if (!record) return map;
for (const [key, entry] of Object.entries(record)) {
const asset = asRecord(entry);
if (asset) map.set(key, asset);
}
return map;
}
function validateAssetPath(filePath: unknown, kind: string, errors: string[], stageId: string): string {
const candidate = String(filePath ?? '').trim();
if (!candidate) {
errors.push(`Stage ${stageId} has no trusted asset path`);
return '';
}
if (/^(?:https?:|file:)/i.test(candidate) || !path.isAbsolute(candidate)) {
errors.push(`Stage ${stageId} asset path must be an absolute local path inside the trusted executor call`);
return '';
}
const ext = path.extname(candidate).toLowerCase();
const validExt = kind === 'nam'
? ext === '.nam'
: kind === 'ir'
? ['.wav', '.flac', '.aiff', '.aif'].includes(ext)
: kind === 'vst'
? ['.vst3', '.component', '.dll'].includes(ext)
: true;
if (!validExt) errors.push(`Stage ${stageId} asset extension is not valid for ${kind}`);
if (!fs.existsSync(candidate)) errors.push(`Stage ${stageId} trusted asset is missing`);
return candidate;
}
function validatePlan(request: unknown): { ok: true; plan: ValidPlan; presetJson: string } | { ok: false; errors: string[] } {
const input = asRecord(request) || {};
const planInput = asRecord(input.plan) || asRecord(input.chainPlan) || null;
const errors: string[] = [];
if (!planInput) return { ok: false, errors: ['Missing audio-effects chain plan'] };
const authorization = String(input.authorization ?? '').trim();
if (!AUTHORIZATIONS.has(authorization)) {
errors.push('Audio-effects plan loading requires user-action, restore-selection, or playback-session authorization');
}
const schema = String(planInput.schema ?? '').trim();
if (schema !== PLAN_SCHEMA) errors.push('Unsupported audio-effects chain plan schema');
const routeKey = safeId(planInput.routeKey ?? planInput.route ?? DEFAULT_ROUTE_KEY, DEFAULT_ROUTE_KEY);
const providerId = safeId(planInput.providerId, 'provider');
const planId = safeId(planInput.planId ?? planInput.chainId, 'plan');
const rawStages = asArray(planInput.stages);
if (rawStages.length < 1) errors.push('Audio-effects chain plan must include at least one stage');
if (rawStages.length > MAX_STAGES) errors.push(`Audio-effects chain plan exceeds maximum stage count ${MAX_STAGES}`);
const assets = normalizeAssetMap(input.assets ?? input.trustedAssets);
const states = normalizeAssetMap(input.states ?? input.trustedStates);
const stages: ValidStage[] = [];
const nativePresetChain: Dict[] = [];
const seenStageIds = new Set<string>();
let sequentialNam = 0;
rawStages.slice(0, MAX_STAGES).forEach((entry, index) => {
const stageInput = asRecord(entry) || {};
const kind = safeId(stageInput.kind, 'utility');
const roleCandidate = safeId(stageInput.role ?? stageInput.slot, 'unknown');
const role = VALID_ROLES.has(roleCandidate) ? roleCandidate : 'unknown';
const stageId = safeId(stageInput.stageId ?? stageInput.id ?? `${kind}-${index}`, `stage-${index}`);
if (seenStageIds.has(stageId)) {
errors.push(`Duplicate stageId ${stageId}`);
return;
}
seenStageIds.add(stageId);
const assetRef = String(stageInput.assetRef ?? stageInput.ref ?? '').trim();
const stateRef = String(stageInput.stateRef ?? '').trim();
const native = kind === 'nam' || kind === 'ir' || kind === 'vst';
if (!VALID_KINDS.has(kind)) errors.push(`Stage ${stageId} has unsupported kind`);
if (native && !assetRef) errors.push(`Stage ${stageId} requires an opaque assetRef`);
if (kind === 'nam') sequentialNam += 1;
else sequentialNam = 0;
if (sequentialNam > MAX_SEQUENTIAL_NAM) errors.push(`Plan exceeds maximum sequential NAM count ${MAX_SEQUENTIAL_NAM}`);
const stage: ValidStage = {
stageId,
kind: VALID_KINDS.has(kind) ? kind : 'utility',
role,
assetRef,
stateRef,
bypassed: safeBool(stageInput.bypassed, false),
gainDb: safeNumber(stageInput.gainDb, 0),
native,
};
stages.push(stage);
if (!native) return;
const asset = assets.get(assetRef);
if (!asset) {
errors.push(`Stage ${stageId} has no trusted asset for its assetRef`);
return;
}
const assetKind = safeId(asset.kind, kind);
if (assetKind !== kind) errors.push(`Stage ${stageId} trusted asset kind does not match plan kind`);
const assetPath = validateAssetPath(asset.path, kind, errors, stageId);
const state = stateRef ? states.get(stateRef) : null;
const stateBase64 = String(asset.stateBase64 ?? state?.stateBase64 ?? state?.base64 ?? '').trim();
const nativeStage: Dict = {
type: NATIVE_TYPES[kind],
name: bounded(asset.safeName ?? asset.label ?? `${role}-${kind}`, 96) || `${role}-${kind}`,
path: assetPath,
bypassed: stage.bypassed,
};
if (stateBase64) nativeStage.state = stateBase64;
nativePresetChain.push(nativeStage);
});
const nativeStageIds = stages.filter((stage) => stage.native).map((stage) => stage.stageId);
const seenSegmentIds = new Set<string>();
const segments: ValidSegment[] = asArray(planInput.segments).slice(0, MAX_SEGMENTS).map((segment, index) => {
const item = asRecord(segment) || {};
// segmentId is the public lookup key; activateSegment() resolves it with Array.find,
// so a duplicate would make the later segment unreachable. Reject the plan instead.
const segmentId = safeId(item.segmentId ?? item.toneKey ?? item.id ?? `segment-${index}`, `segment-${index}`);
if (seenSegmentIds.has(segmentId)) errors.push(`Audio-effects chain plan has a duplicate segmentId: ${segmentId}`);
seenSegmentIds.add(segmentId);
const rawStageBypass = asRecord(item.stageBypass) || asRecord(item.stageBypasses) || asRecord(item.bypassByStage) || {};
const stageBypass: Record<string, boolean> = {};
for (const [stageId, bypassed] of Object.entries(rawStageBypass)) {
const safeStageId = safeId(stageId, '');
if (nativeStageIds.includes(safeStageId)) stageBypass[safeStageId] = safeBool(bypassed, false);
}
return {
segmentId,
stageIds: asArray(item.stageIds ?? item.stages).map((value) => safeId(value, '')).filter((value) => nativeStageIds.includes(value)),
stageBypass,
};
});
if (nativePresetChain.length < 1) errors.push('Audio-effects chain plan has no loadable native stages');
if (errors.length) return { ok: false, errors };
return {
ok: true,
plan: { planId, routeKey, providerId, stages, segments },
presetJson: JSON.stringify({ chain: nativePresetChain }),
};
}
function normalizeLoadResult(value: unknown): { success: boolean; slotsLoaded: number; error: string } {
const record = asRecord(value);
if (!record) return { success: false, slotsLoaded: 0, error: 'Native load returned an unsupported result' };
return {
success: record.success === true,
slotsLoaded: safeNumber(record.slotsLoaded, 0),
error: bounded(record.error ?? ''),
};
}
function chainSlots(nativeAudio: AudioEffectsNativeAudio | null): Dict[] {
if (!nativeAudio || typeof nativeAudio.getChainState !== 'function') return [];
const state = nativeAudio.getChainState();
return asArray(state).map((entry) => asRecord(entry)).filter((entry): entry is Dict => !!entry);
}
async function restorePreset(nativeAudio: AudioEffectsNativeAudio, presetJson: unknown): Promise<boolean> {
if (typeof presetJson !== 'string' || !presetJson.trim() || typeof nativeAudio.loadPreset !== 'function') return false;
try {
await nativeAudio.loadPreset(presetJson);
return true;
} catch (_) {
return false;
}
}
async function readMonitorMuted(nativeAudio: AudioEffectsNativeAudio): Promise<boolean | null> {
if (typeof nativeAudio.isMonitorMuted !== 'function') return null;
try {
return Boolean(await nativeAudio.isMonitorMuted());
} catch (_) {
return null;
}
}
async function trySetMonitorMute(nativeAudio: AudioEffectsNativeAudio, muted: boolean): Promise<void> {
if (typeof nativeAudio.setMonitorMute !== 'function') return;
try { await nativeAudio.setMonitorMute(muted); } catch (_) { /* best effort */ }
}
async function trySetMonitorMuteSuppressed(nativeAudio: AudioEffectsNativeAudio, suppressed: boolean): Promise<void> {
if (typeof nativeAudio.setMonitorMuteSuppressed !== 'function') return;
try { await nativeAudio.setMonitorMuteSuppressed(suppressed); } catch (_) { /* best effort */ }
}
async function trySetGain(nativeAudio: AudioEffectsNativeAudio, which: string, value: number): Promise<boolean> {
if (typeof nativeAudio.setGain !== 'function' || !Number.isFinite(value)) return false;
try {
const result = await nativeAudio.setGain(which, value);
return !nativeFailure(result);
} catch (_) {
return false;
}
}
async function applyGains(nativeAudio: AudioEffectsNativeAudio, gains: RouteGains, skipChain = false): Promise<string[]> {
const failed: string[] = [];
if (gains.input != null && !(await trySetGain(nativeAudio, 'input', gains.input))) failed.push('input');
if (!skipChain && gains.chain != null && !(await trySetGain(nativeAudio, 'chain', gains.chain))) failed.push('chain');
return failed;
}
function schedulePreloadRestore(nativeAudio: AudioEffectsNativeAudio, previousMonitorMute: boolean | null, targetGain: number, holdMs: number, shouldRestore?: () => boolean): void {
const restore = async () => {
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) {
if (shouldRestore && !shouldRestore()) return;
await trySetGain(nativeAudio, 'chain', value);
await new Promise((resolve) => setTimeout(resolve, 6));
}
};
setTimeout(() => { void restore(); }, Math.max(0, holdMs));
}
export function createAudioEffectsExecutor(getAudio: NativeAudioGetter) {
const routes = new Map<string, RouteState>();
let preloadRestoreVersion = 0;
function updateOutcome(route: RouteState, outcome: SafeOutcome): SafeOutcome {
route.lastOutcome = outcome;
route.updatedAt = now();
return outcome;
}
async function loadChainPlan(request: unknown): Promise<SafeOutcome> {
const nativeAudio = getAudio();
if (!nativeAudio || typeof nativeAudio.loadPreset !== 'function') {
return safeOutcome('unavailable', 'Native audio engine is unavailable');
}
const input = asRecord(request) || {};
const options = parseLoadOptions(input.options ?? input.executorOptions ?? input.loadOptions);
const validation = validatePlan(request);
if (!validation.ok) {
return safeOutcome('failed', 'Audio-effects chain plan validation failed', { errors: validation.errors.map((error) => bounded(error)) });
}
const started = Date.now();
const restoreVersion = ++preloadRestoreVersion;
const rollbackPreset = typeof nativeAudio.savePreset === 'function' ? nativeAudio.savePreset() : null;
let previousMonitorMute: boolean | null = null;
if (options.preloadMute?.enabled) {
previousMonitorMute = await readMonitorMuted(nativeAudio);
await trySetGain(nativeAudio, 'chain', 0);
await trySetMonitorMute(nativeAudio, options.preloadMute.dryDuringLoad ? false : true);
}
let result: { success: boolean; slotsLoaded: number; error: string };
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);
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);
return safeOutcome('failed', 'Native audio-effects plan load failed', {
routeKey: validation.plan.routeKey,
providerId: validation.plan.providerId,
planId: validation.plan.planId,
stageCount: nativeStages.length,
slotsLoaded: result.slotsLoaded,
loadMs: Date.now() - started,
error: result.error,
rollbackApplied,
});
}
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);
return safeOutcome('degraded', 'Native audio-effects plan partially loaded and was rolled back', {
routeKey: validation.plan.routeKey,
providerId: validation.plan.providerId,
planId: validation.plan.planId,
stageCount: nativeStages.length,
slotsLoaded: result.slotsLoaded,
loadMs: Date.now() - started,
rollbackApplied,
});
}
let slots: Dict[];
try {
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);
return safeOutcome('failed', 'Native chain-state lookup threw', {
routeKey: validation.plan.routeKey,
providerId: validation.plan.providerId,
planId: validation.plan.planId,
error: bounded(error instanceof Error ? error.message : String(error)),
rollbackApplied,
});
}
const stageSlots = new Map<string, number>();
const stageKinds = new Map<string, string>();
nativeStages.forEach((stage, index) => {
const slotId = safeNumber(slots[index]?.id, -1);
if (slotId >= 0) stageSlots.set(stage.stageId, slotId);
stageKinds.set(stage.stageId, stage.kind);
});
// Every native stage must map to a real slot; an incomplete mapping would report the load
// 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);
return safeOutcome('degraded', 'Native slot mapping was incomplete and was rolled back', {
routeKey: validation.plan.routeKey,
providerId: validation.plan.providerId,
planId: validation.plan.planId,
stageCount: nativeStages.length,
slotsMapped: stageSlots.size,
rollbackApplied,
});
}
const route: RouteState = {
routeKey: validation.plan.routeKey,
providerId: validation.plan.providerId,
planId: validation.plan.planId,
state: result.slotsLoaded >= nativeStages.length ? 'loaded' : 'degraded',
activeSegmentId: '',
stageSlots,
stageKinds,
segments: validation.plan.segments,
loadedAt: now(),
updatedAt: now(),
lastOutcome: null,
};
routes.set(route.routeKey, route);
const gainFailures = await applyGains(nativeAudio, options.gains, options.preloadMute?.enabled === true);
if (options.startAudio && typeof nativeAudio.startAudio === 'function') {
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, () => {
const current = routes.get(validation.plan.routeKey);
return restoreVersion === preloadRestoreVersion && current?.planId === validation.plan.planId;
});
}
return updateOutcome(route, safeOutcome('handled', 'Audio-effects chain plan loaded', {
route: safeRoute(route),
stageCount: nativeStages.length,
slotsLoaded: result.slotsLoaded,
loadMs: Date.now() - started,
gainFailures,
}));
}
function inspectRoute(routeKeyInput?: unknown): SafeOutcome {
const routeKey = safeId(routeKeyInput ?? DEFAULT_ROUTE_KEY, DEFAULT_ROUTE_KEY);
const route = routes.get(routeKey);
if (!route) return safeOutcome('no-target', 'No audio-effects route has been loaded', { routeKey });
return safeOutcome('handled', 'Audio-effects route inspected', { route: safeRoute(route) });
}
async function releaseRoute(request: unknown): Promise<SafeOutcome> {
const input = asRecord(request) || {};
const routeKey = safeId(input.routeKey ?? DEFAULT_ROUTE_KEY, DEFAULT_ROUTE_KEY);
const route = routes.get(routeKey);
if (!route) return safeOutcome('no-target', 'No audio-effects route has been loaded', { routeKey });
const nativeAudio = getAudio();
if (!nativeAudio || typeof nativeAudio.clearChain !== 'function') return updateOutcome(route, safeOutcome('unavailable', 'Native route release is unavailable', { routeKey }));
preloadRestoreVersion += 1;
const cleanupFailures: string[] = [];
if (!(await trySetGain(nativeAudio, 'chain', 0))) cleanupFailures.push('chain-gain');
let releaseFailure: SafeOutcome | null = null;
try {
const result = await nativeAudio.clearChain();
if (nativeFailure(result)) releaseFailure = safeOutcome('failed', 'Native route release returned failure', { routeKey });
} 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);
if (releaseFailure) return updateOutcome(route, releaseFailure);
routes.delete(routeKey);
return safeOutcome('handled', 'Audio-effects route released', { routeKey, providerId: route.providerId, planId: route.planId, cleanupFailures });
}
async function setRouteGain(request: unknown): Promise<SafeOutcome> {
const input = asRecord(request) || {};
const routeKey = safeId(input.routeKey ?? DEFAULT_ROUTE_KEY, DEFAULT_ROUTE_KEY);
const route = routes.get(routeKey);
if (!route) return safeOutcome('no-target', 'No audio-effects route has been loaded', { routeKey });
const gains = parseGains(input.gains ?? (input.which ? { [String(input.which)]: input.value } : {}));
if (gains.input == null && gains.chain == null) return updateOutcome(route, safeOutcome('failed', 'Audio-effects route gain request is invalid', { routeKey }));
const nativeAudio = getAudio();
if (!nativeAudio || typeof nativeAudio.setGain !== 'function') return updateOutcome(route, safeOutcome('unavailable', 'Native route gain is unavailable', { routeKey }));
const failed = await applyGains(nativeAudio, gains);
if (failed.length) return updateOutcome(route, safeOutcome('failed', 'Native route gain returned failure', { routeKey, failed }));
return updateOutcome(route, safeOutcome('handled', 'Audio-effects route gain applied', { route: safeRoute(route), gains }));
}
async function setStageBypass(request: unknown): Promise<SafeOutcome> {
const input = asRecord(request) || {};
const routeKey = safeId(input.routeKey ?? DEFAULT_ROUTE_KEY, DEFAULT_ROUTE_KEY);
const stageId = safeId(input.stageId, '');
const route = routes.get(routeKey);
if (!route) return safeOutcome('no-target', 'No audio-effects route has been loaded', { routeKey });
const slotId = route.stageSlots.get(stageId);
if (slotId == null) return updateOutcome(route, safeOutcome('no-target', 'Audio-effects stage is not mapped to a native slot', { routeKey, stageId }));
const nativeAudio = getAudio();
if (!nativeAudio || typeof nativeAudio.setBypass !== 'function') return updateOutcome(route, safeOutcome('unavailable', 'Native stage bypass is unavailable', { routeKey, stageId }));
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 }));
} catch (error) {
return updateOutcome(route, safeOutcome('failed', 'Native stage bypass threw', { routeKey, stageId, error: bounded(error instanceof Error ? error.message : String(error)) }));
}
return updateOutcome(route, safeOutcome('handled', 'Audio-effects stage bypass applied', { route: safeRoute(route), stageId }));
}
async function setStageParameter(request: unknown): Promise<SafeOutcome> {
const input = asRecord(request) || {};
const routeKey = safeId(input.routeKey ?? DEFAULT_ROUTE_KEY, DEFAULT_ROUTE_KEY);
const stageId = safeId(input.stageId, '');
const route = routes.get(routeKey);
if (!route) return safeOutcome('no-target', 'No audio-effects route has been loaded', { routeKey });
const slotId = route.stageSlots.get(stageId);
if (slotId == null) return updateOutcome(route, safeOutcome('no-target', 'Audio-effects stage is not mapped to a native slot', { routeKey, stageId }));
const paramIndex = parseParamIndex(input);
const value = parseStrictNumber(input.value);
if (!Number.isInteger(paramIndex) || paramIndex < 0 || paramIndex > MAX_PARAM_INDEX || !Number.isFinite(value)) {
return updateOutcome(route, safeOutcome('failed', 'Audio-effects stage parameter request is invalid', { routeKey, stageId }));
}
const nativeAudio = getAudio();
if (!nativeAudio || typeof nativeAudio.setParameter !== 'function') return updateOutcome(route, safeOutcome('unavailable', 'Native stage parameter control is unavailable', { routeKey, stageId }));
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 }));
} catch (error) {
return updateOutcome(route, safeOutcome('failed', 'Native stage parameter threw', { routeKey, stageId, paramIndex, error: bounded(error instanceof Error ? error.message : String(error)) }));
}
return updateOutcome(route, safeOutcome('handled', 'Audio-effects stage parameter applied', { route: safeRoute(route), stageId, paramIndex }));
}
async function activateSegment(request: unknown): Promise<SafeOutcome> {
const input = asRecord(request) || {};
const routeKey = safeId(input.routeKey ?? DEFAULT_ROUTE_KEY, DEFAULT_ROUTE_KEY);
const segmentId = safeId(input.segmentId ?? input.toneKey, '');
const route = routes.get(routeKey);
if (!route) return safeOutcome('no-target', 'No audio-effects route has been loaded', { routeKey });
const segment = route.segments.find((item) => item.segmentId === segmentId);
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 active = new Set(segment.stageIds);
const changes = Array.from(route.stageSlots.entries()).map(([stageId, slotId]) => ({
slotId,
bypassed: active.has(stageId)
? (Object.prototype.hasOwnProperty.call(segment.stageBypass, stageId) ? segment.stageBypass[stageId] : false)
: true,
}));
try {
const result = await nativeAudio.setMultiBypass(changes);
if (nativeFailure(result)) return updateOutcome(route, safeOutcome('failed', 'Native multi-bypass returned failure', { routeKey, segmentId, changedCount: changes.length }));
} catch (error) {
return updateOutcome(route, safeOutcome('failed', 'Native multi-bypass threw', { routeKey, segmentId, changedCount: changes.length, error: bounded(error instanceof Error ? error.message : String(error)) }));
}
route.activeSegmentId = segment.segmentId;
return updateOutcome(route, safeOutcome('handled', 'Audio-effects segment activated', { route: safeRoute(route), segmentId, changedCount: changes.length }));
}
return {
loadChainPlan,
releaseRoute,
inspectRoute,
activateSegment,
setStageBypass,
setStageParameter,
setRouteGain,
};
}
export type AudioEffectsExecutor = ReturnType<typeof createAudioEffectsExecutor>;
+75
View File
@@ -0,0 +1,75 @@
// Debug logging — opt-in diagnostic capture for bug reports.
//
// Enabled by the SLOPSMITH_DEBUG env var or a --verbose / --debug CLI flag.
// When on, console.* output is routed to <logs>/slopsmith-debug.log, and the
// native addon redirects its stderr into the same file (see audio-bridge.ts /
// NodeAddon enableFileLogging), so one file captures the Electron main
// process, the native [AudioEngine] diagnostics, and (forwarded as [python]
// lines) the Python subprocess.
import { app } from 'electron';
import * as fs from 'fs';
import * as path from 'path';
import * as util from 'util';
let debugEnabled: boolean | null = null;
let logFilePath: string | null = null;
export function isDebugEnabled(): boolean {
if (debugEnabled !== null) return debugEnabled;
const env = (process.env.SLOPSMITH_DEBUG || '').trim().toLowerCase();
const envOn = env !== '' && env !== '0' && env !== 'false';
const argOn = process.argv.includes('--verbose') || process.argv.includes('--debug');
debugEnabled = envOn || argOn;
return debugEnabled;
}
// <logs>/slopsmith-debug.log — Windows: %APPDATA%\<app>\logs, macOS:
// ~/Library/Logs/<app>, Linux: ~/.config/<app>/logs.
export function getDebugLogPath(): string {
if (logFilePath) return logFilePath;
logFilePath = path.join(app.getPath('logs'), 'slopsmith-debug.log');
return logFilePath;
}
// Truncate the log with a fresh header and route every console.* call into it
// via fs.appendFileSync. Writing directly to the file (rather than relying on
// the native stderr redirect) captures the JS-side logs from the moment this
// runs — the startup banner and early Python output, before the addon is even
// loaded. The addon separately redirects native stderr into the same file
// (enableFileLogging) for the [AudioEngine] diagnostics.
// Returns the log path when debug mode is on, otherwise null.
export function initDebugLogging(): string | null {
if (!isDebugEnabled()) return null;
const file = getDebugLogPath();
try {
fs.mkdirSync(path.dirname(file), { recursive: true });
fs.writeFileSync(file, `=== Slopsmith debug log — ${new Date().toISOString()} ===\n`);
} catch {
// Can't open the log file — stay console-only rather than crash.
return null;
}
// Debug mode → console.* is routed to the log file only, NOT also tee'd to
// the original console. Deliberate: enableFileLogging freopen's the native
// stderr stream onto this same file, so letting console.error/warn also
// reach their original stderr could write those lines into the file twice.
// A packaged build has no console anyway; a dev who wants live output can
// tail the file. A transient write failure is swallowed so logging can't
// take the app down.
const writeLine = (...args: unknown[]) => {
try {
fs.appendFileSync(file, util.format(...args) + '\n');
} catch {
/* ignore log-write failures */
}
};
console.log = writeLine;
console.info = writeLine;
console.debug = writeLine;
console.warn = writeLine;
console.error = writeLine;
return file;
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 4.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.1 KiB

Some files were not shown because too many files have changed in this diff Show More