mirror of
https://github.com/got-feedBack/feedBack-desktop.git
synced 2026-09-10 23:54:10 +00:00
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>
781 lines
44 KiB
C++
781 lines
44 KiB
C++
#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)
|
||
};
|