#pragma once #include "SourceChain.h" #include "GainSanitize.h" #include "engine/PackedStereoRing.h" #include "engine/EngineState.h" #include "BackingLeveler.h" #include "signalsmith-stretch.h" #include #include #include #include #include #include #include #include #include #include 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(); } // Arm/suspend the ML note-detection pipeline across every source's detector. // Defaults off; the renderer (note_detect) calls this true only while a // consumer actually reads ML notes (native-frame detection / non-verifier // fallback) and false otherwise, so the default harmonic-comb verifier path // — and the always-on home tuner — never pay for ONNX inference. Main thread. void setMlNoteDetectionEnabled(bool e); // 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 sampleRates; // intersection when dual-type juce::Array bufferSizes; bool compatible = true; // false when types share no usable sample rate juce::String error; }; struct DeviceConfig { juce::String inputType; juce::String inputDevice; juce::String outputType; juce::String outputDevice; double sampleRate = 48000.0; int bufferSize = 256; }; struct DeviceConfigResult { bool ok = false; juce::String error; double sampleRate = 0.0; int inputBlockSize = 0; int outputBlockSize = 0; bool duplex = true; }; 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 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 getBindableInputDevices(); juce::Array getSampleRates(); juce::Array 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); } // Sanitized (see GainSanitize.h): a NaN/Inf master gain from JS would // multiply the whole device output to NaN downstream of the per-source // scrub — clamp at the store so every caller is covered. void setOutputGain(float gain) { outputGain.store(slopsmith::sanitizeMasterGain(gain)); } float getInputGain() const { return source0().getInputGain(); } float getOutputGain() const { return outputGain.load(); } // 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(); } // Full monitor kill — silences the guitar bus entirely (dry + processed), // for monitoring through an external rig. Unlike the per-source mute/gain // controls (which delegate to source0()), this is a GLOBAL "play through my // own rig" preference, so it's applied to EVERY pooled source — active or // not — so additional inputs are silenced too and a later addSource() // inherits it (addSource never resets the flag). The fixed pool's pointers // are never reassigned, and these are plain atomic stores, so iterating it // off the control thread is race-free. Default off; see SourceChain. void setMonitorKill(bool kill) { for (auto& s : sources) if (s) s->setMonitorKill(kill); } bool isMonitorKilled() const { return source0().isMonitorKilled(); } // 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(slopsmith::sanitizeMasterGain(vol)); } bool loadBackingTrack(const juce::File& file); void setBackingPosition(double seconds); void startBacking(); void stopBacking(); void setBackingSpeed(double speed); // Non-blocking reads — do not acquire backingLock and never block the audio callback bool isBackingPlaying() const { return backingPlaying.load(); } double getBackingPosition() const { return cachedBackingPosition.load(); } double getBackingDuration() const { return cachedBackingDuration.load(); } // 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(); // ── Streamer mix output (PR1: one stream bus → one extra output device) ─── // An ADDITIONAL output device carrying an independent submix (game/backing + // the guitar monitor mix) for OBS/Discord capture, separate from the local // monitor output. Default off → zero behaviour change. Control-thread only. // setStreamOutputDevice returns "" on success or an error string. juce::String setStreamOutputDevice(const juce::String& typeName, const juce::String& deviceName); void clearStreamOutput(); bool isStreamOutputActive() const { return streamSink.active.load(std::memory_order_acquire); } juce::String getStreamOutputDeviceName() const { return streamSink.desiredDeviceName; } // Bus content: include the backing/game, include the guitar monitor mix, and a // linear output gain. All atomic — safe to set live. Gain is sanitised // (finite, clamped 0..8) so a NaN/Inf from JS can never reach the stream ring. void setStreamBus(bool includeBacking, bool includeGuitar, float gain) { streamBusIncludeBacking.store(includeBacking, std::memory_order_relaxed); streamBusIncludeGuitar.store(includeGuitar, std::memory_order_relaxed); streamBusGain.store(sanitizeStreamGain(gain), std::memory_order_relaxed); } void setStreamBusGain(float gain) { streamBusGain.store(sanitizeStreamGain(gain), std::memory_order_relaxed); } // ── Renderer-audio bus (Phase 2: WebAudio master → engine output) ───────── // The renderer pushes its WebAudio master mix here (via IPC) so song/stem // audio stays audible when the output device is exclusive-style and the OS // mixer path is silenced. SPSC: producer is the main-process IPC thread, // consumer is whichever output callback is live (duplex or split). Default // off → zero behaviour change. void setRendererBus(bool enabled, float gain) { rendererBusGain.store(sanitizeStreamGain(gain), std::memory_order_relaxed); const bool was = rendererBusEnabled.exchange(enabled, std::memory_order_acq_rel); if (was && !enabled) { // Drop buffered audio on disable so a later re-enable starts fresh // instead of playing a stale tail. Consumer tolerates the jump. rendererBusRing.readIndex.store( rendererBusRing.writeIndex.load(std::memory_order_acquire), std::memory_order_release); rendererBusPrimed.store(false, std::memory_order_relaxed); } } // Interleaved stereo frames at `sourceRate`; linear-resampled to the device // rate on the producer thread (fractional position + previous frame carried // across calls). Returns false when the bus is disabled or the engine is // not running. Drop-oldest on overflow, counted. bool pushRendererAudio(const float* interleavedLR, int frames, double sourceRate); struct RendererBusMetrics { uint64_t pushedFrames = 0, consumedFrames = 0, underflowCount = 0, overflowCount = 0; int fillFrames = 0, capacityFrames = 0; bool enabled = false; }; RendererBusMetrics getRendererBusMetrics() const; float getStreamSinkLevel() const { return streamSinkLevel.load(std::memory_order_relaxed); } uint64_t getStreamUnderflowCount() const { return streamSink.underflowCount.load(std::memory_order_relaxed); } // Producer overflow (drop-oldest): the consumer fell a full ring behind and // frames were skipped. Exposed alongside underflow for stream drift diagnosis. uint64_t getStreamOverflowCount() const { return streamSink.overflowCount.load(std::memory_order_relaxed); } // 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 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& 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 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 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 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 outputRing 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 outputRing, 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; // Shared run-state atomics (TLC phase 1) — the members below are // reference aliases under their historical names so call sites are // untouched; extracted units take `state` (EngineState&) directly. slopsmith::EngineState state; std::atomic& duplexMode = state.duplexMode; // Per-input capture+detect+monitor chains. A FIXED pool, all constructed up // front, so adding/removing a source never reassigns a pointer the audio // thread is reading — addSource/removeSource only flip an atomic `active` // flag (and prepare/release the chain). sources[0] is the legacy default, // active from construction and bound to the primary input device. The audio // callback fans device channels out to each active source and fans their // monitor signals into the output mix. SourceChain reads the engine's // audioRunning / currentSampleRate atomics through references bound at // construction. static constexpr int kMaxSources = 8; // Max ADDITIONAL input devices (beyond the primary). Declared here — ahead of the // members that size arrays by it (e.g. callbacksInFlight) — though the extra-input // slot registry that uses it lives further below. static constexpr int kMaxExtraInputDevices = 3; std::array, kMaxSources> sources; // Serialises addSource/removeSource (control threads only — never the audio // thread, which just reads each slot's atomic `active`). std::mutex sourcesMutex; // Audio-thread scratch for the multi-source mix: each active source renders // its 2-channel monitor here in turn, then it is summed into the output. // Pre-sized in audioDeviceAboutToStart so the hot loop never allocates. juce::AudioBuffer sourceMonitorScratch; // Count of device callback bodies currently executing, PER deviceKey (index 0 = // primary input, 1..kMaxExtraInputDevices = each extra-input slot). Each device // callback increments its own key on entry and decrements at its real exit. // removeSource() flips a source inactive (future callbacks snapshot active once // and skip it), then waits to observe THIS SOURCE's deviceKey count == 0 — at // that instant no callback that could touch this source is inside processBlock, // so it is safe to release. Keying per-deviceKey (not a single global counter) is // essential: with the primary + extra inputs on independent clocks they are // rarely ALL idle at once, so a global check would strand removals during steady // multi-device playback. A wedged callback past the bounded wait DEFERS the // release via pendingRelease[], reclaimed later when that key's body is quiescent. std::array, kMaxExtraInputDevices + 1> callbacksInFlight{}; // Sources whose release was deferred (handshake timed out). Reclaimed under // sourcesMutex by reclaimPendingReleases() at the next add/removeSource and on // device stop, once it is safe (audio stopped or no callback in flight). std::array pendingRelease{}; // Release any deferred sources that are now safe to reclaim. Caller holds // sourcesMutex (or is the device-stop path, where the callback is gone). void reclaimPendingReleases(); juce::AudioFormatManager formatManager; // Master output (post-mix) — engine-global, not per-source. std::atomic outputGain{1.0f}; std::atomic backingVolume{0.8f}; // Per-song loudness normalizer for the backing track (applied in // renderBackingBlockLocked, pre-fader). Owned + driven by the audio thread. BackingLeveler backingLeveler; double backingLevelerSr = 0.0; std::atomic currentOutputLevel{0.0f}; // Per-block RMS of the backing-track mix bus, written by the audio thread // and read on the main/JS thread via getBackingLevel(). Computed after the // 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 currentBackingLevel{0.0f}; std::atomic outputPeak{0.0f}; // Backing track // Read-ahead worker that fills the transport's buffer off the audio thread // (see loadBackingTrack). Declared BEFORE backingTransport so it is destroyed // AFTER it — the transport's BufferingAudioSource holds a pointer to this // thread and must be torn down before the thread goes away. juce::TimeSliceThread backingReadThread { "BackingReadAhead" }; std::unique_ptr backingSource; std::unique_ptr backingTransport; signalsmith::stretch::SignalsmithStretch backingStretch; juce::AudioBuffer backingInputBuffer; // pulled from transport at device rate juce::AudioBuffer backingBuffer; // stretch output, mixed into device buffer std::atomic backingStretchLatencySamples{0}; std::atomic backingPlaying{false}; std::atomic cachedBackingPosition{0.0}; std::atomic cachedBackingDuration{0.0}; // Heard playhead: accumulates the source frames consumed each block, then // clamped to backingTransport->getCurrentPosition() so a short read at EOF // can't push it past the real source point. cachedBackingPosition is this // value minus the stretcher output latency (zero on the 1x bypass path). std::atomic backingHeardPositionSec{0.0}; // Active playback rate. Mutated ONLY by the audio thread (in // renderBackingBlockLocked), coupled with the stretcher reset, so a block // is never processed at a new rate with stale stretch state. std::atomic backingSpeed{1.0}; // Lock-free speed hand-off: setBackingSpeed (control thread) publishes the // requested rate here and raises backingSpeedChangePending; the audio // thread adopts it on the next block. Avoids the control thread blocking on // backingLock and starving the RT tryLock (which would drop a backing block // mid-slider-drag). std::atomic backingPendingSpeed{1.0}; std::atomic backingSpeedChangePending{false}; juce::CriticalSection backingLock; // audioRunning keeps its historical DEVICE-STATE semantics (isAudioRunning // compat pin); the intent half is state.userWantsAudio — see EngineState.h. std::atomic& audioRunning = state.deviceRunning; std::atomic& currentSampleRate = state.currentSampleRate; std::atomic& inputBlockSize = state.inputBlockSize; std::atomic& outputBlockSize = state.outputBlockSize; // The per-input lock-free SPSC rings (pre-gate getInputFrame ring + post-gate // getRawAudioFrame ring), the YIN/ML detectors, and the zero-output capture // 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). Packed-LR single-atomic frames // — see engine/PackedStereoRing.h for the tear/lock-free rationale (moved // there in TLC phase 1). ~85 ms @ 48 kHz — absorbs clock drift over // typical sessions. static constexpr int kOutputRingFrames = 4096; slopsmith::PackedStereoRing outputRing; // ── Renderer-audio bus ring (see setRendererBus/pushRendererAudio) ─────── // Same packed-LR SPSC design as outputRing. Sized generously // (~1.5 s @ 48 kHz — vs outputRing's 85 ms) because the producer is // an IPC thread with scheduling jitter, not another audio callback; the // consumer trims steady-state fill via the drift clamp in the mix step. static constexpr int kRendererBusFrames = 65536; static_assert((kRendererBusFrames & (kRendererBusFrames - 1)) == 0, "kRendererBusFrames must be a power of two for mask wraparound"); // Prefill gate: consume nothing until the producer has built this cushion // (~10.7 ms @ 48 kHz); re-armed after every underflow so stall recovery is // one clean gap. Fill clamp: fill beyond this (~85 ms) means a renderer // stall dumped a backlog — trim to the prime target, don't play the tail. static constexpr int kRendererBusPrimeFrames = 512; static constexpr int kRendererBusMaxFillFrames = 4096; slopsmith::PackedStereoRing rendererBusRing; std::atomic rendererBusPushedFrames{0}; std::atomic rendererBusConsumedFrames{0}; std::atomic rendererBusUnderflowCount{0}; std::atomic rendererBusOverflowCount{0}; std::atomic rendererBusEnabled{false}; std::atomic rendererBusGain{1.0f}; // Consumer-side prefill-gate state. Only the live output callback touches // it, but duplex/split hand-offs cross threads — atomic keeps that safe. std::atomic rendererBusPrimed{false}; // Producer-thread-only linear-resampler state (fractional read position // into the incoming chunk + the previous chunk's last frame for // interpolation continuity across pushes). double rendererBusSrcPos = 0.0; float rendererBusPrevL = 0.0f, rendererBusPrevR = 0.0f; // Shared consumer step for the duplex and split output paths: drain one // block from the renderer-bus ring into `dest` (stereo, bus gain applied, // dest cleared first). Returns numSamples on success, 0 when gated // (disabled, priming, underflow, scratch undersized). Single consumer — // call exactly once per output block; the caller mixes the pulled block // into the device output AND hands it to composeAndPushStreamMix so the // streamer submix carries renderer-fed song audio too. int pullRendererBus(juce::AudioBuffer& dest, int numSamples); // Scratch for the per-block renderer-bus pull. Fixed capacity, sized once // in about-to-start next to the stream scratches (same no-realloc rule). juce::AudioBuffer rendererBusPullScratch; std::atomic outputUnderflowCount{0}; std::atomic inputOverflowCount{0}; // Pre-sized to outputBlockSize so the pull loop never allocates. std::vector outputPullScratchL; std::vector outputPullScratchR; juce::AudioBuffer outputBackingBuffer; bool outputCallbackRegistered = false; // Same guard for the primary INPUT callback (`this`): audioRunning can be // cleared by a transient audioDeviceStopped() while the callback stays // attached, and an unguarded startAudio() re-add would dispatch it twice // per block (double DSP + double ring push → half-speed garbled audio) and // leave a live registration behind after stopAudio()'s single remove. bool inputCallbackRegistered = false; // ── Phase 2: additional input devices ──────────────────────────────────── // Each ADDITIONAL physical input device (a 2nd/3rd USB interface, e.g. two // separate cables) gets its own AudioDeviceManager + callback running on its // OWN hardware clock, packing its sources' mixed monitor into its own SPSC // ring. audioOutputCallback drains+sums every active ring (drop-oldest wrap // absorbs each device's drift independently — no cross-device resampling, the // failure mode that corrupts a software combine). deviceKey 0 = the primary // inputDeviceManager above; deviceKeys 1..kMaxExtraInputDevices map to // extraInputs[deviceKey-1]. When any extra device is active the engine runs // split (the primary also uses its ring) so the output sum is uniform. // (kMaxExtraInputDevices is declared up top, near kMaxSources.) // Forwards a JUCE device callback to the engine, tagged with the slot index. struct InputSlotCallback : juce::AudioIODeviceCallback { AudioEngine* engine = nullptr; int slot = -1; // index into extraInputs (deviceKey - 1) void audioDeviceIOCallbackWithContext(const float* const* inputData, int numInputChannels, float* const* outputData, int numOutputChannels, int numSamples, const juce::AudioIODeviceCallbackContext&) override { juce::ignoreUnused(outputData, numOutputChannels); if (engine) engine->extraInputCallback(slot, inputData, numInputChannels, numSamples); } void audioDeviceAboutToStart(juce::AudioIODevice* d) override { if (engine) engine->extraInputAboutToStart(slot, d); } void audioDeviceStopped() override { if (engine) engine->extraInputStopped(slot); } }; struct InputDeviceSlot { juce::AudioDeviceManager manager; InputSlotCallback callback; slopsmith::PackedStereoRing ring; std::atomic overflowCount{0}; std::atomic active{false}; // a device is bound + running std::atomic sampleRate{48000.0}; std::atomic blockSize{256}; // (extra input latency − primary input latency) in seconds — applied to // this device's sources' verifiers so their capture aligns with the // primary-corrected playhead. Computed when the device starts. std::atomic latencyDeltaSec{0.0}; // Audio-thread scratch — one set per slot since each slot's callback runs // on its own thread (can't share the primary's sourceMonitorScratch). juce::AudioBuffer fanScratch; // the 2ch mix target juce::AudioBuffer monitorScratch; // per-source render in the N>1 path int deviceKey = 0; // deviceKey this slot serves (slot+1) // The device the user WANTS bound here — persistent INTENT, distinct from // the transient `active` (currently open). Set by bindInputDevice, cleared // only by a user unbind. stopAudio()/reconfigure close the device but keep // this so startAudio() re-opens it; this is what survives a device change. // Mutated + read on the control thread only. juce::String desiredDeviceName; // Whether the NEXT extraInputStopped() for this slot is a PERMANENT unbind // (deactivate its sources) vs a transient close (keep them to resume). An // atomic the control thread sets and the device thread reads, so the // permanent-vs-transient decision never races on the juce::String above. std::atomic permanentUnbind { false }; }; std::array extraInputs; // Per-slot callback hooks (audio + device-management threads). void extraInputCallback(int slot, const float* const* inputData, int numInputChannels, int numSamples); void extraInputAboutToStart(int slot, juce::AudioIODevice* device); void extraInputStopped(int slot); // Close an extra device but KEEP its desiredDeviceName (transient close for // stop/reconfigure); reopenDesiredExtraInputs() restores them after a (re)start. bool closeExtraInputDevice(int slot); void reopenDesiredExtraInputs(); // Shared fan-out used by both the primary and each extra device's callback: // mix every active source bound to `deviceKey` into `mixBuf` (using the // caller-owned `monitorScratch` for the N>1 render so concurrent device // threads never share scratch). Returns the active source count for that key. int mixSourcesForDevice(int deviceKey, const float* const* inputData, int numInputChannels, juce::AudioBuffer& mixBuf, juce::AudioBuffer& monitorScratch, int effectiveOutputChannels, int numSamples); // ── Streamer mix output sink (PR1) ─────────────────────────────────────── // A second OUTPUT AudioDeviceManager on its OWN clock that drains a dedicated // SPSC ring fed by the main output path's composed stream submix. This mirrors // the InputDeviceSlot pattern INVERTED to the output side: the PRODUCER is the // primary/output callback (composeAndPushStreamMix), the CONSUMER is this extra // output device's callback (streamSinkCallback). Default off → no behaviour change. struct StreamSinkCallback : juce::AudioIODeviceCallback { AudioEngine* engine = nullptr; void audioDeviceIOCallbackWithContext(const float* const* inputData, int numInputChannels, float* const* outputData, int numOutputChannels, int numSamples, const juce::AudioIODeviceCallbackContext&) override { juce::ignoreUnused(inputData, numInputChannels); if (engine) engine->streamSinkCallback(outputData, numOutputChannels, numSamples); } void audioDeviceAboutToStart(juce::AudioIODevice* d) override { if (engine) engine->streamSinkAboutToStart(d); } void audioDeviceStopped() override { if (engine) engine->streamSinkStopped(); } }; struct StreamSink { StreamSinkCallback callback; slopsmith::PackedStereoRing ring; std::atomic underflowCount{0}; std::atomic overflowCount{0}; std::atomic active{false}; std::atomic sampleRate{48000.0}; std::atomic blockSize{256}; std::vector pullScratchL, pullScratchR; // sized in streamSinkAboutToStart bool callbackRegistered = false; bool initialised = false; // Declared LAST so it DESTRUCTS FIRST (members tear down in reverse // declaration order): the manager's dtor closes the device and detaches // `callback` while `callback`/`ring` are still alive — no use-after-free // even if an explicit teardown path is ever missed. stopAudio() / // closeStreamSinkDevice() also tear it down explicitly before this. juce::AudioDeviceManager manager; // Persistent INTENT (control thread only): the device the user chose. // Survives a stop/restart so reopenDesiredStreamSink() can re-open it. juce::String desiredTypeName; juce::String desiredDeviceName; }; StreamSink streamSink; std::atomic streamBusIncludeBacking{true}; std::atomic streamBusIncludeGuitar{true}; std::atomic streamBusGain{1.0f}; std::atomic streamSinkLevel{0.0f}; // Producer-side scratch (written by the primary/output callback): the guitar // monitor-mix snapshot (pre-backing) and the composed stream submix. Sized in // audioDeviceAboutToStart / audioOutputAboutToStart alongside the other scratch. juce::AudioBuffer streamGuitarScratch; juce::AudioBuffer streamMixScratch; // Clamp a requested stream gain to a finite, sane range so a NaN/Inf (or a // wild value) from the JS bridge can never be packed into the stream ring. static float sanitizeStreamGain(float g) { return slopsmith::sanitizeStreamGain(g); } void streamSinkCallback(float* const* outputData, int numOutputChannels, int numSamples); void streamSinkAboutToStart(juce::AudioIODevice* device); void streamSinkStopped(); void reopenDesiredStreamSink(); // Detach + close the stream-sink device but KEEP desiredTypeName/Name, so a // stopAudio()/startAudio() cycle re-opens it (intent survives, like extra // inputs). Also the single teardown used by the dtor and clearStreamOutput(). void closeStreamSinkDevice(); // Compose the stream submix from the captured guitar mix + the just-rendered // backing block + the just-pulled renderer-bus block and pack it into the // stream ring. Called from both output callbacks after backing render. // `backingBuf` / `rendererBuf` may be null (not playing / bus gated). // The renderer bus rides the includeBacking flag: it IS song audio, just // fed from the renderer instead of the native transport (bus gain already // applied by pullRendererBus). void composeAndPushStreamMix(const juce::AudioBuffer& guitarMix, const juce::AudioBuffer* backingBuf, int backingFrames, float backingVol, const juce::AudioBuffer* rendererBuf, int rendererFrames, int numSamples); JUCE_DECLARE_NON_COPYABLE_WITH_LEAK_DETECTOR(AudioEngine) };