Files
feedBack-desktop/src/audio/AudioEngine.h
T
OmikronApexandClaude Fable 5 eb40b87dea refactor(audio): extract EngineState with intent/state split (phase 1)
Moves the shared run-state atomics (currentSampleRate, block sizes,
duplexMode, run flags) into slopsmith::EngineState (src/audio/engine/) so
later extracted units take EngineState& and stay unit-testable without JUCE
devices. AudioEngine binds the members back by reference under their
historical names — zero call-site churn, behavior-identical.

The old audioRunning conflated user intent with device state (deep-read
§3/§6); it is now state.deviceRunning (same semantics, isAudioRunning compat
pinned) plus a new state.userWantsAudio written only by startAudio/stopAudio.
Nothing reads the intent flag yet — phase 8 flips setAudioDevices' restart
decision onto it.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-13 23:42:29 +02:00

781 lines
44 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 "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
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<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;
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<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();
juce::AudioFormatManager formatManager;
// Master output (post-mix) — engine-global, not per-source.
std::atomic<float> outputGain{1.0f};
std::atomic<float> backingVolume{0.8f};
// Per-song loudness normalizer for the backing track (applied in
// renderBackingBlockLocked, pre-fader). Owned + driven by the audio thread.
BackingLeveler backingLeveler;
double backingLevelerSr = 0.0;
std::atomic<float> currentOutputLevel{0.0f};
// 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
// 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<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;
// 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 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<kRendererBusFrames> rendererBusRing;
std::atomic<uint64_t> rendererBusPushedFrames{0};
std::atomic<uint64_t> rendererBusConsumedFrames{0};
std::atomic<uint64_t> rendererBusUnderflowCount{0};
std::atomic<uint64_t> rendererBusOverflowCount{0};
std::atomic<bool> rendererBusEnabled{false};
std::atomic<float> rendererBusGain{1.0f};
// Consumer-side prefill-gate state. Only the live output callback touches
// it, but duplex/split hand-offs cross threads — atomic keeps that safe.
std::atomic<bool> rendererBusPrimed{false};
// Producer-thread-only linear-resampler state (fractional read position
// into the incoming chunk + the previous chunk's last frame for
// interpolation continuity across pushes).
double rendererBusSrcPos = 0.0;
float rendererBusPrevL = 0.0f, rendererBusPrevR = 0.0f;
// Shared consumer step for the duplex and split output paths: drain one
// 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 (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<kOutputRingFrames> ring;
std::atomic<uint64_t> underflowCount{0};
std::atomic<uint64_t> overflowCount{0};
std::atomic<bool> active{false};
std::atomic<double> sampleRate{48000.0};
std::atomic<int> blockSize{256};
std::vector<float> pullScratchL, pullScratchR; // sized in streamSinkAboutToStart
bool callbackRegistered = false;
bool initialised = false;
// Declared LAST so it DESTRUCTS FIRST (members tear down in reverse
// declaration order): the manager's dtor closes the device and detaches
// `callback` while `callback`/`ring` are still alive — no use-after-free
// even if an explicit teardown path is ever missed. stopAudio() /
// closeStreamSinkDevice() also tear it down explicitly before this.
juce::AudioDeviceManager manager;
// Persistent INTENT (control thread only): the device the user chose.
// Survives a stop/restart so reopenDesiredStreamSink() can re-open it.
juce::String desiredTypeName;
juce::String desiredDeviceName;
};
StreamSink streamSink;
std::atomic<bool> streamBusIncludeBacking{true};
std::atomic<bool> streamBusIncludeGuitar{true};
std::atomic<float> streamBusGain{1.0f};
std::atomic<float> streamSinkLevel{0.0f};
// Producer-side scratch (written by the primary/output callback): the guitar
// monitor-mix snapshot (pre-backing) and the composed stream submix. Sized in
// audioDeviceAboutToStart / audioOutputAboutToStart alongside the other scratch.
juce::AudioBuffer<float> streamGuitarScratch;
juce::AudioBuffer<float> 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<float>& guitarMix,
const juce::AudioBuffer<float>* backingBuf,
int backingFrames, float backingVol,
const juce::AudioBuffer<float>* rendererBuf,
int rendererFrames, int numSamples);
JUCE_DECLARE_NON_COPYABLE_WITH_LEAK_DETECTOR(AudioEngine)
};