Files
feedBack-desktop/src/audio/AudioEngine.h
T
OmikronApexandClaude Fable 5 d8f5784c63 refactor(audio): extract BackingPlayer (phase 3)
Moves the backing-track cluster — AudioFormatManager/reader/transport,
TimeSliceThread read-ahead, signalsmith-stretch state, lock-free speed
hand-off, BackingLeveler, playhead caches, and renderBackingBlockLocked —
verbatim into src/audio/engine/BackingPlayer.{h,cpp}.

Boundary per the plan (§2.4): control-thread lifecycle + non-blocking
getters live on the class; the RT mix POLICY (try-lock pattern, RMS
metering, volume fader, stream-submix capture) stays in the engine's output
callbacks via getLock()/readyLocked()/renderBlockLocked()/renderBuffer() —
both callbacks keep holding the try-lock through their stream publish, so
the render buffer is never read while prepare() can resize it. The volume
fader atomic and level meter stay engine-side.

Synthetic-reader unit tests deferred (JUCE-linked, same constraint as
StreamSink); covered by the backing play/seek/speed integration surface.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-14 00:19:53 +02:00

635 lines
35 KiB
C++
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#pragma once
#include "SourceChain.h"
#include "GainSanitize.h"
#include "engine/PackedStereoRing.h"
#include "engine/EngineState.h"
#include "engine/RendererBus.h"
#include "engine/StreamSink.h"
#include "engine/BackingPlayer.h"
#include "BackingLeveler.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(); }
// 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<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); }
// 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 — transport moved to engine/BackingPlayer (TLC phase 3);
// the volume fader + level meter stay engine-side (mix policy).
void setBackingVolume(float vol) { backingVolume.store(slopsmith::sanitizeMasterGain(vol)); }
bool loadBackingTrack(const juce::File& file)
{
currentBackingLevel.store(0.0f);
return backing.load(file);
}
void setBackingPosition(double seconds) { backing.setPosition(seconds); }
void startBacking() { backing.start(); }
void stopBacking()
{
backing.stop();
currentBackingLevel.store(0.0f);
}
void setBackingSpeed(double speed) { backing.setSpeed(speed); }
// Non-blocking reads — never acquire the backing lock / block the audio callback
bool isBackingPlaying() const { return backing.isPlaying(); }
double getBackingPosition() const { return backing.getPosition(); }
double getBackingDuration() const { return backing.getDuration(); }
// Metering (read from any thread — atomic). Input level/peak are per-source
// (sources[0]); output level/peak are the post-mix master, engine-global.
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.isActive(); }
juce::String getStreamOutputDeviceName() const { return streamSink.getDesiredDeviceName(); }
// Bus content: include the backing/game, include the guitar monitor mix, and a
// linear output gain. All atomic — safe to set live. Gain is sanitised
// (finite, clamped 0..8) so a NaN/Inf from JS can never reach the stream ring.
void setStreamBus(bool includeBacking, bool includeGuitar, float gain) { streamSink.setBus(includeBacking, includeGuitar, gain); }
void setStreamBusGain(float gain) { streamSink.setBusGain(gain); }
// ── Renderer-audio bus (Phase 2: WebAudio master → engine output) ─────────
// The renderer pushes its WebAudio master mix here (via IPC) so song/stem
// 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) { rendererBus.setEnabled(enabled, gain); }
// Interleaved stereo frames at `sourceRate`; linear-resampled to the device
// rate on the producer thread (fractional position + previous frame carried
// across calls). Returns false when the bus is disabled or the engine is
// 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 streamSink.getLevel(); }
uint64_t getStreamUnderflowCount() const { return streamSink.getUnderflowCount(); }
// Producer overflow (drop-oldest): the consumer fell a full ring behind and
// frames were skipped. Exposed alongside underflow for stream drift diagnosis.
uint64_t getStreamOverflowCount() const { return streamSink.getOverflowCount(); }
// 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 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;
// 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<bool>& 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<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();
// Master output (post-mix) — engine-global, not per-source.
std::atomic<float> outputGain{1.0f};
std::atomic<float> backingVolume{0.8f};
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 — transport/stretch/leveler moved to engine/BackingPlayer
// (TLC phase 3). Declared after `state` (bound by reference).
slopsmith::BackingPlayer backing{state};
// audioRunning keeps its historical DEVICE-STATE semantics (isAudioRunning
// compat pin); the intent half is state.userWantsAudio — see EngineState.h.
std::atomic<bool>& audioRunning = state.deviceRunning;
std::atomic<double>& currentSampleRate = state.currentSampleRate;
std::atomic<int>& inputBlockSize = state.inputBlockSize;
std::atomic<int>& outputBlockSize = state.outputBlockSize;
// The per-input lock-free SPSC rings (pre-gate getInputFrame ring + post-gate
// getRawAudioFrame ring), the YIN/ML detectors, and the zero-output capture
// scratch now live on SourceChain — one set per input source. See
// SourceChain.h for the full lock-free / power-of-two / cold-start rationale.
// Split-mode SPSC ring (unused in duplex). 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<kOutputRingFrames> outputRing;
// ── Renderer-audio bus (see engine/RendererBus.h — moved in TLC phase 2)
slopsmith::RendererBus rendererBus;
// Shared consumer step for the duplex and split output paths: drain one
// block from the renderer-bus ring into `dest` (stereo, bus gain applied,
// dest cleared first). Returns numSamples on success, 0 when gated
// (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<float>& 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<float> rendererBusPullScratch;
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;
// 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<kOutputRingFrames> ring;
std::atomic<uint64_t> overflowCount{0};
std::atomic<bool> active{false}; // a device is bound + running
std::atomic<double> sampleRate{48000.0};
std::atomic<int> blockSize{256};
// (extra input latency primary input latency) in seconds — applied to
// this device's sources' verifiers so their capture aligns with the
// primary-corrected playhead. Computed when the device starts.
std::atomic<double> latencyDeltaSec{0.0};
// Audio-thread scratch — one set per slot since each slot's callback runs
// on its own thread (can't share the primary's sourceMonitorScratch).
juce::AudioBuffer<float> fanScratch; // the 2ch mix target
juce::AudioBuffer<float> monitorScratch; // per-source render in the N>1 path
int deviceKey = 0; // deviceKey this slot serves (slot+1)
// The device the user WANTS bound here — persistent INTENT, distinct from
// the transient `active` (currently open). Set by 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);
// ── Streamer mix output sink — moved to engine/StreamSink.{h,cpp} (TLC
// phase 2). Declared after `state` (bound by reference).
slopsmith::StreamSink streamSink{state};
// Producer-side guitar monitor-mix snapshot (pre-backing), written by the
// primary/output callback and handed to streamSink.publish(). Sized in
// audioDeviceAboutToStart / audioOutputAboutToStart alongside the other scratch.
juce::AudioBuffer<float> streamGuitarScratch;
// 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); }
JUCE_DECLARE_NON_COPYABLE_WITH_LEAK_DETECTOR(AudioEngine)
};