feedBack-desktop/src/audio/AudioEngine.cpp
Jorge Fritis b5dce9af4d
fix(audio): read-ahead the backing track off the RT audio thread (#60)
* fix(audio): read-ahead the backing track off the RT audio thread

The backing AudioTransportSource was set up with no read-ahead buffer and
no reader thread — setSource(src, 0, nullptr, rate) — so the realtime audio
callback decoded the backing file synchronously inside getNextAudioBlock on
every block while a song plays. Any disk seek or codec spike (worst on
compressed formats) then blew the block's realtime budget, producing
underruns heard as glitches / brief mutes.

Interpose a juce::TimeSliceThread with 32768 source frames (~0.68 s @ 48 kHz)
of look-ahead so decode happens off the audio thread. The thread is declared
before backingTransport (so it is destroyed after it — the transport's
BufferingAudioSource holds a pointer to it) and started once in the ctor.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* docs(audio): document the bounded BufferingAudioSource lock residual

Codex review: readBufferSection() holds callbackLock across a refill
chunk decode and the RT callback takes the same lock. Accepted — the
window is bounded (2048-frame chunks) and only hit mid-refill, vs. the
old guaranteed synchronous decode every block; note it in the comment so
nobody mistakes the transport stack for fully RT-safe.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Jafz2001 <ignacio.fritis@mundotelecomunicaciones.cl>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-authored-by: byrongamatos <xasiklas@gmail.com>
2026-07-03 16:16:27 +02:00

2978 lines
140 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.

#include "AudioEngine.h"
#include "AudioSanitize.h"
#include <algorithm>
#include <chrono>
#include <cmath>
#include <thread>
// Hard ceiling on backing playback speed. This drives input buffer sizing and runtime clamp.
static constexpr double kMaxBackingSpeed = 4.0;
// Transparent full-speed path — skip the stretcher when rate is effectively 1×.
static constexpr double kBackingSpeedBypassEpsilon = 1.0e-4;
// On Windows, ASIO drivers can crash with access violations.
// We catch C++ exceptions but can't easily catch SEH in functions with dtors.
// The try/catch blocks around device operations are the best we can do
// without restructuring the code into SEH-safe wrapper functions.
AudioEngine::AudioEngine()
{
formatManager.registerBasicFormats();
// Start the backing read-ahead worker so the transport's BufferingAudioSource
// always has a live thread to pull decoded audio on. It sleeps while idle and
// costs nothing until a track is loaded.
backingReadThread.startThread();
// Construct the full source pool up front so addSource/removeSource never
// reassign a pointer the audio thread reads — they only flip `active`. Each
// chain reads the engine's audioRunning / currentSampleRate atomics by
// reference (both already constructed as members before this body runs).
// sources[0] is the permanent default input, active from the start; the rest
// are inactive (no threads — NoteVerifier's worker only starts in prepare()).
for (int i = 0; i < kMaxSources; ++i)
sources[(size_t) i] = std::make_unique<SourceChain>(i, audioRunning, currentSampleRate);
sources[0]->setActive(true);
// Phase 2: tag each additional-input slot with its identity so its JUCE
// callback can route back to the engine. deviceKey = slot index + 1 (0 is the
// primary inputDeviceManager). The managers stay idle until bindInputDevice.
for (int i = 0; i < kMaxExtraInputDevices; ++i)
{
extraInputs[(size_t) i].callback.engine = this;
extraInputs[(size_t) i].callback.slot = i;
extraInputs[(size_t) i].deviceKey = i + 1;
}
auto result = inputDeviceManager.initialiseWithDefaultDevices(2, 2);
if (result.isNotEmpty())
std::cerr << "[AudioEngine] input init note: " << result.toStdString() << std::endl;
auto outResult = outputDeviceManager.initialiseWithDefaultDevices(0, 2);
if (outResult.isNotEmpty())
std::cerr << "[AudioEngine] output init note: " << outResult.toStdString() << std::endl;
// Some backends (WASAPI) bind to a default device on init and would
// hold it exclusive against the duplex codepath. Idle until split mode.
outputDeviceManager.closeAudioDevice();
auto& availableTypes = inputDeviceManager.getAvailableDeviceTypes();
std::cerr << "[AudioEngine] Available device types: " << availableTypes.size() << std::endl;
for (auto* type : availableTypes)
{
type->scanForDevices();
std::cerr << "[AudioEngine] " << type->getTypeName().toStdString()
<< " - inputs: " << type->getDeviceNames(true).size()
<< ", outputs: " << type->getDeviceNames(false).size() << std::endl;
}
for (auto* type : outputDeviceManager.getAvailableDeviceTypes())
type->scanForDevices();
}
AudioEngine::~AudioEngine()
{
// Stop every extra input device FIRST so no slot callback can fire into a
// half-destroyed engine. closeAudioDevice blocks for the callback thread.
for (int i = 0; i < kMaxExtraInputDevices; ++i)
{
extraInputs[(size_t) i].manager.closeAudioDevice();
extraInputs[(size_t) i].manager.removeAudioCallback(&extraInputs[(size_t) i].callback);
}
stopAudio();
stopBacking();
}
// ── Device Enumeration ────────────────────────────────────────────────────────
juce::Array<AudioEngine::DeviceTypeInfo> AudioEngine::getDeviceTypes()
{
juce::Array<DeviceTypeInfo> types;
for (auto* type : inputDeviceManager.getAvailableDeviceTypes())
{
DeviceTypeInfo info;
info.name = type->getTypeName();
// Use already-scanned device names (scanForDevices was called during init)
info.inputDevices = type->getDeviceNames(true);
info.outputDevices = type->getDeviceNames(false);
types.add(std::move(info));
}
return types;
}
std::vector<AudioEngine::BindableInput> AudioEngine::getBindableInputDevices()
{
std::vector<BindableInput> out;
// The device already open as the primary input IS "Main" — don't offer it as
// an extra (would double-open the same hardware on two managers).
juce::String primaryName;
if (auto* dev = inputDeviceManager.getCurrentAudioDevice())
primaryName = dev->getName();
// Enumerate across ALL device types, not just the primary's current one —
// bindInputDevice() can open a device under any backend (JACK/ALSA/CoreAudio/…),
// so an extra interface exposed under a DIFFERENT backend than the primary must
// still be offered, or the multi-device path is unreachable from the picker.
//
// KNOWN LIMITATION: identity is the display name. JUCE opens input devices BY
// NAME, so two interfaces sharing a label (e.g. two identical USB cables) cannot
// be distinguished or independently opened without a backend-specific device-id
// rework — they collapse to one entry here. The SAME root cause makes a device
// exposed under MULTIPLE backends (e.g. ALSA + JACK/PipeWire on Linux) ambiguous:
// we dedup by name and bindInputDevice() re-derives the backend (preferring the
// primary's), so we may bind the wrong backend if only another would open. A real
// fix needs (typeName, name) identity threaded through bind/reopen. Distinct-name,
// single-backend rigs (the common case, and the validated GP-5 + Spark setup) are
// unaffected.
juce::StringArray seen;
for (auto* t : inputDeviceManager.getAvailableDeviceTypes())
{
if (!t) continue;
t->scanForDevices();
const juce::String typeName = t->getTypeName();
for (const auto& name : t->getDeviceNames(true))
{
if (name == primaryName || seen.contains(name)) continue; // dedup across backends
// Skip monitor / loopback pseudo-inputs — not instrument inputs, only
// confuse the picker.
const juce::String lower = name.toLowerCase();
if (lower.contains("monitor") || lower.contains("loopback")) continue;
seen.add(name);
out.push_back({ typeName, name });
}
}
return out;
}
juce::Array<double> AudioEngine::getSampleRates()
{
juce::Array<double> rates;
if (auto* device = inputDeviceManager.getCurrentAudioDevice())
{
for (auto rate : device->getAvailableSampleRates())
rates.add(rate);
}
return rates;
}
juce::Array<int> AudioEngine::getBufferSizes()
{
juce::Array<int> sizes;
if (auto* device = inputDeviceManager.getCurrentAudioDevice())
{
for (auto size : device->getAvailableBufferSizes())
sizes.add(size);
}
return sizes;
}
AudioEngine::DeviceOptions AudioEngine::probeDeviceOptions(const juce::String& typeName,
const juce::String& inputName,
const juce::String& outputName)
{
return probeDeviceOptionsDual(typeName, inputName, typeName, outputName);
}
AudioEngine::DeviceOptions AudioEngine::probeDeviceOptionsDual(const juce::String& inputTypeName,
const juce::String& inputName,
const juce::String& outputTypeName,
const juce::String& outputName)
{
DeviceOptions options;
options.inputType = inputTypeName;
options.outputType = outputTypeName.isEmpty() ? inputTypeName : outputTypeName;
options.type = options.inputType; // legacy alias
// Resolve each side from its own manager so probe stays consistent with
// applySplitSetup()/setOutputDeviceType(), which mutate the manager that
// owns the side they're configuring. Using inputDeviceManager for the
// output lookup would silently fall back to whatever input has scanned,
// which can miss output-only backends.
auto findType = [](juce::AudioDeviceManager& manager,
const juce::String& wanted) -> juce::AudioIODeviceType* {
juce::AudioIODeviceType* match = nullptr;
for (auto* type : manager.getAvailableDeviceTypes())
{
if ((wanted.isNotEmpty() && type->getTypeName() == wanted)
|| (wanted.isEmpty() && match == nullptr))
{
match = type;
if (wanted.isNotEmpty()) break;
}
}
return match;
};
auto* inputType = findType(inputDeviceManager, options.inputType);
// Match setAudioDevices's resolution: when the caller didn't specify
// an output type, default it to the SAME type the input side resolved
// to (using the type's name, looked up in outputDeviceManager). Without
// this, an empty `options.outputType` would let findType pick whatever
// outputDeviceManager enumerates first — potentially a different
// backend than inputDeviceManager picked from the empty string, which
// then disagrees with the apply path's duplex classification.
juce::String effectiveOutputTypeName = options.outputType;
if (effectiveOutputTypeName.isEmpty() && inputType != nullptr)
effectiveOutputTypeName = inputType->getTypeName();
auto* outputType = findType(outputDeviceManager, effectiveOutputTypeName);
if (inputType == nullptr)
{
options.error = "Input device type not found";
options.compatible = false;
return options;
}
if (outputType == nullptr)
{
options.error = "Output device type not found";
options.compatible = false;
return options;
}
try
{
options.inputType = inputType->getTypeName();
options.outputType = outputType->getTypeName();
options.type = options.inputType;
options.input = inputName;
options.output = outputName;
// userIntendsDuplex matches setAudioDevices's classification:
// identical names on both sides (typically both empty = OS default)
// means we'll go duplex regardless of which specific devices the
// first-enumerated lookup would have produced.
const bool userIntendsDuplex = (options.inputType == options.outputType
&& options.input == options.output);
// For probing we still need a concrete device to instantiate.
// Resolve empty names to first-enumerated ONLY for the probe-device
// creation below — DON'T write back into options.input/options.output;
// those flow to the UI and the apply path, which treat empty as
// "OS default" per side.
auto inputs = inputType->getDeviceNames(true);
auto outputs = outputType->getDeviceNames(false);
const juce::String probeInputName =
options.input.isEmpty() && inputs.size() > 0 ? inputs[0] : options.input;
const juce::String probeOutputName =
options.output.isEmpty() && outputs.size() > 0 ? outputs[0] : options.output;
const bool isDuplex = userIntendsDuplex
|| (options.inputType == options.outputType
&& options.input == options.output
&& options.input.isNotEmpty());
if (isDuplex)
{
std::unique_ptr<juce::AudioIODevice> dev(
inputType->createDevice(probeOutputName, probeInputName));
if (!dev) { options.error = "Could not create probe device"; options.compatible = false; return options; }
options.inputChannels = dev->getInputChannelNames();
options.outputChannels = dev->getOutputChannelNames();
for (auto rate : dev->getAvailableSampleRates())
options.sampleRates.addIfNotAlreadyThere(rate);
for (auto size : dev->getAvailableBufferSizes())
options.bufferSizes.addIfNotAlreadyThere(size);
}
else
{
std::unique_ptr<juce::AudioIODevice> inDev(
inputType->createDevice({}, probeInputName));
std::unique_ptr<juce::AudioIODevice> outDev(
outputType->createDevice(probeOutputName, {}));
if (!inDev || !outDev)
{
options.error = "Could not create dual probe devices";
options.compatible = false;
return options;
}
options.inputChannels = inDev->getInputChannelNames();
options.outputChannels = outDev->getOutputChannelNames();
// Tolerance covers backends that report fractional drift around the nominal rate.
const auto inRates = inDev->getAvailableSampleRates();
const auto outRates = outDev->getAvailableSampleRates();
for (auto r : inRates)
{
for (auto r2 : outRates)
{
// <= 0.5 (not <) to match applySplitSetup's rateSupportedBy
// check. A backend reporting 47999.5 on both sides has
// |r - r2| = 0 (matches anyway) but a backend mixing
// 47999.5 in / 48000.0 out has |diff| = 0.5 exactly, which
// < 0.5 would reject from the probe even though the
// apply-side check accepts it.
if (std::abs(r - r2) <= 0.5)
{
// Round the midpoint to a clean nominal rate
// (backends sometimes report fractional near-48000
// rates; surfacing the raw value would fail the
// apply-side setAudioDeviceSetup, which expects an
// exact supported nominal). Re-check the rounded
// candidate is within tolerance of BOTH sides — a
// matched pair like 48000.4/48000.6 passes the |r-r2|
// check but std::round(48000.4)=48000 would fall
// outside tolerance of 48000.6 (diff 0.6). Skip
// those so the probe stays fail-closed.
const double candidate = std::round((r + r2) * 0.5);
if (std::abs(r - candidate) <= 0.5
&& std::abs(r2 - candidate) <= 0.5)
{
options.sampleRates.addIfNotAlreadyThere(candidate);
}
break;
}
}
}
if (options.sampleRates.isEmpty())
{
options.error = "Input and output devices share no common sample rate";
options.compatible = false;
}
// Split mode opens both sides with the same bufferSize, so the
// UI should only see sizes the intersection of both devices
// supports — a union would let the user pick a value that
// predictably fails at apply time on one side.
const auto inBufs = inDev->getAvailableBufferSizes();
const auto outBufs = outDev->getAvailableBufferSizes();
for (auto b : inBufs)
{
for (auto b2 : outBufs)
{
if (b == b2)
{
options.bufferSizes.addIfNotAlreadyThere(b);
break;
}
}
}
// An empty intersection means there's no buffer size both sides
// accept; setting compatible=false stops the UI from re-enabling
// Apply against a guaranteed-fail config.
if (options.bufferSizes.isEmpty() && options.error.isEmpty())
{
options.error = "Input and output devices share no common buffer size";
options.compatible = false;
}
}
fprintf(stderr, "[AudioEngine] Probed device options: inType='%s' outType='%s' in='%s' out='%s' "
"duplex=%d inputs=%d outputs=%d rates=%d buffers=%d compatible=%d\n",
options.inputType.toRawUTF8(), options.outputType.toRawUTF8(),
options.input.toRawUTF8(), options.output.toRawUTF8(),
(int) isDuplex, options.inputChannels.size(), options.outputChannels.size(),
options.sampleRates.size(), options.bufferSizes.size(), (int) options.compatible);
}
catch (const std::exception& e)
{
options.error = e.what();
options.compatible = false;
}
catch (...)
{
options.error = "Probe failed";
options.compatible = false;
}
return options;
}
juce::String AudioEngine::getCurrentDeviceType()
{
return getCurrentInputDeviceType();
}
juce::String AudioEngine::getCurrentInputDeviceType()
{
if (auto* type = inputDeviceManager.getCurrentDeviceTypeObject())
return type->getTypeName();
return {};
}
juce::String AudioEngine::getCurrentOutputDeviceType()
{
if (duplexMode.load(std::memory_order_relaxed))
return getCurrentInputDeviceType();
if (auto* type = outputDeviceManager.getCurrentDeviceTypeObject())
return type->getTypeName();
return {};
}
juce::String AudioEngine::getCurrentInputDevice()
{
if (auto* device = inputDeviceManager.getCurrentAudioDevice())
{
auto setup = inputDeviceManager.getAudioDeviceSetup();
return setup.inputDeviceName;
}
return {};
}
juce::String AudioEngine::getCurrentOutputDevice()
{
auto& mgr = duplexMode.load(std::memory_order_relaxed)
? inputDeviceManager : outputDeviceManager;
if (mgr.getCurrentAudioDevice() == nullptr) return {};
return mgr.getAudioDeviceSetup().outputDeviceName;
}
AudioEngine::DeviceMetrics AudioEngine::getDeviceMetrics() const
{
DeviceMetrics m;
m.duplex = duplexMode.load(std::memory_order_relaxed);
m.inputOverflowCount = inputOverflowCount.load(std::memory_order_relaxed);
m.outputUnderflowCount = outputUnderflowCount.load(std::memory_order_relaxed);
// The output ring is only used in split mode. Report 0/0 in duplex so
// consumers don't think there's a live ring buffer to monitor when
// there isn't one. Capacity reads-as-zero in duplex matches the
// "no ring activity" semantic — the ring is structurally inert.
if (! m.duplex)
{
m.outputRingCapacityFrames = kOutputRingFrames;
// Output device can stop while the input keeps writing, leaving
// (w - r) larger than capacity. Clamp uint64 → int via the
// capacity ceiling so the consumer-facing field never overflows
// or goes negative.
const uint64_t w = outputRingWriteIndex.load(std::memory_order_acquire);
const uint64_t r = outputRingReadIndex.load(std::memory_order_acquire);
const uint64_t fill = (w >= r) ? (w - r) : 0;
m.outputRingFillFrames = (int) std::min(fill, (uint64_t) kOutputRingFrames);
}
return m;
}
double AudioEngine::getLatencyMs() const
{
const double sr = currentSampleRate.load(std::memory_order_relaxed);
if (sr <= 0.0) return 0.0;
if (duplexMode.load(std::memory_order_relaxed))
{
if (auto* device = inputDeviceManager.getCurrentAudioDevice())
{
int latencySamples = device->getCurrentBufferSizeSamples()
+ device->getInputLatencyInSamples()
+ device->getOutputLatencyInSamples();
return (latencySamples / sr) * 1000.0;
}
return 0.0;
}
int totalSamples = 0;
if (auto* in = inputDeviceManager.getCurrentAudioDevice())
totalSamples += in->getCurrentBufferSizeSamples() + in->getInputLatencyInSamples();
if (auto* out = outputDeviceManager.getCurrentAudioDevice())
totalSamples += out->getCurrentBufferSizeSamples() + out->getOutputLatencyInSamples();
// Steady-state ring residency ≈ half capacity once both clocks stabilize.
totalSamples += kOutputRingFrames / 2;
return (totalSamples / sr) * 1000.0;
}
// ── Device Selection ──────────────────────────────────────────────────────────
bool AudioEngine::setDeviceType(const juce::String& typeName)
{
if (auto* currentType = inputDeviceManager.getCurrentDeviceTypeObject())
{
if (currentType->getTypeName() == typeName)
{
fprintf(stderr, "[AudioEngine] Input device type already selected: %s\n", typeName.toRawUTF8());
return true;
}
}
for (auto* type : inputDeviceManager.getAvailableDeviceTypes())
{
if (type->getTypeName() == typeName)
{
try {
fprintf(stderr, "[AudioEngine] Setting input device type: %s\n", typeName.toRawUTF8());
inputDeviceManager.setCurrentAudioDeviceType(typeName, true);
// Legacy single-type API contract: callers expect both
// managers to track the same backend so a subsequent
// duplex configure or probe sees a consistent dual state.
// setOutputDeviceType() exists for callers that want to
// diverge the two sides intentionally. Best-effort — if
// the output side doesn't expose this backend the input
// change still stands so duplex on the matched backend
// keeps working.
if (auto* currentOutputType = outputDeviceManager.getCurrentDeviceTypeObject())
{
if (currentOutputType->getTypeName() != typeName)
{
for (auto* outType : outputDeviceManager.getAvailableDeviceTypes())
{
if (outType->getTypeName() == typeName)
{
try { outputDeviceManager.setCurrentAudioDeviceType(typeName, true); }
catch (...) {
fprintf(stderr, "[AudioEngine] setDeviceType: output sync threw (continuing)\n");
}
break;
}
}
}
}
return true;
} catch (const std::exception& e) {
fprintf(stderr, "[AudioEngine] setDeviceType crashed: %s\n", e.what());
return false;
} catch (...) {
fprintf(stderr, "[AudioEngine] setDeviceType crashed (unknown)\n");
return false;
}
}
}
return false;
}
bool AudioEngine::setOutputDeviceType(const juce::String& typeName)
{
if (duplexMode.load(std::memory_order_relaxed))
return setDeviceType(typeName);
if (auto* currentType = outputDeviceManager.getCurrentDeviceTypeObject())
{
if (currentType->getTypeName() == typeName)
return true;
}
for (auto* type : outputDeviceManager.getAvailableDeviceTypes())
{
if (type->getTypeName() == typeName)
{
try {
fprintf(stderr, "[AudioEngine] Setting output device type: %s\n", typeName.toRawUTF8());
outputDeviceManager.setCurrentAudioDeviceType(typeName, true);
return true;
} catch (...) {
fprintf(stderr, "[AudioEngine] setOutputDeviceType crashed\n");
return false;
}
}
}
return false;
}
bool AudioEngine::setAudioDevice(const juce::String& inputName, const juce::String& outputName,
double sampleRate, int bufferSize)
{
DeviceConfig c;
c.inputType = getCurrentInputDeviceType();
c.outputType = c.inputType;
c.inputDevice = inputName;
c.outputDevice = outputName;
c.sampleRate = sampleRate > 0 ? sampleRate : 48000.0;
c.bufferSize = bufferSize > 0 ? bufferSize : 256;
return setAudioDevices(c).ok;
}
AudioEngine::DeviceConfigResult AudioEngine::setAudioDevices(const DeviceConfig& config)
{
DeviceConfigResult res;
fprintf(stderr, "[AudioEngine] setAudioDevices: inType='%s' inDev='%s' outType='%s' outDev='%s' sr=%.0f bs=%d\n",
config.inputType.toRawUTF8(), config.inputDevice.toRawUTF8(),
config.outputType.toRawUTF8(), config.outputDevice.toRawUTF8(),
config.sampleRate, config.bufferSize);
// Platform-preferred backend when unspecified. Linux prefers ALSA over
// JACK (jackd may be installed but not running).
juce::String resolvedInputType = config.inputType;
if (resolvedInputType.isEmpty())
{
if (auto* t = inputDeviceManager.getCurrentDeviceTypeObject())
resolvedInputType = t->getTypeName();
}
if (resolvedInputType.isEmpty())
{
#if JUCE_LINUX
const juce::StringArray preferredOrder { "ALSA", "JACK" };
#elif JUCE_MAC
const juce::StringArray preferredOrder { "CoreAudio" };
#elif JUCE_WINDOWS
const juce::StringArray preferredOrder { "Windows Audio", "ASIO" };
#else
const juce::StringArray preferredOrder;
#endif
const auto& available = inputDeviceManager.getAvailableDeviceTypes();
for (const auto& want : preferredOrder)
{
for (auto* type : available)
{
if (type->getTypeName() == want)
{
resolvedInputType = want;
break;
}
}
if (resolvedInputType.isNotEmpty()) break;
}
if (resolvedInputType.isEmpty() && !available.isEmpty())
resolvedInputType = available.getFirst()->getTypeName();
}
juce::String resolvedOutputType = config.outputType.isEmpty()
? resolvedInputType : config.outputType;
// Stop audio BEFORE mutating device-type or device setup. JUCE can
// tear down and re-scan devices inside setCurrentAudioDeviceType /
// setAudioDeviceSetup, and doing that while the audio callback is
// still attached risks crashes/deadlocks on some backends (ASIO is
// the usual culprit). stopAudio() detaches both callbacks first;
// we'll re-start at the end if we were running.
//
// Unconditional: audioDeviceStopped() can clear audioRunning during a
// transient input stop while the output callback intentionally stays
// registered for JUCE's auto-restart. A reconfigure that races that
// window would otherwise skip the stopAudio() detach and leave the
// stale output callback attached. stopAudio() is itself idempotent
// (R9 fix — removeAudioCallback is a no-op when not registered), so
// running it unconditionally is safe regardless of audioRunning.
const bool wasRunning = audioRunning.load(std::memory_order_relaxed);
// stopAudio() closes every extra input device but KEEPS its desiredDeviceName;
// the startAudio() below re-opens them at the new config (so panels using a
// second interface survive a device/sample-rate/buffer change automatically).
stopAudio();
// setCurrentAudioDeviceType can throw from inside JUCE backends (ASIO
// is the usual culprit). Catch and propagate as a structured error so
// the N-API caller doesn't see the exception cross the boundary.
try
{
if (auto* current = inputDeviceManager.getCurrentDeviceTypeObject())
{
if (current->getTypeName() != resolvedInputType)
inputDeviceManager.setCurrentAudioDeviceType(resolvedInputType, true);
}
else
{
inputDeviceManager.setCurrentAudioDeviceType(resolvedInputType, true);
}
}
catch (...)
{
res.error = "setCurrentAudioDeviceType threw for input type '" + resolvedInputType + "'";
return res;
}
// User-intent duplex: both sides came in identical (typically both
// empty = "system default", or both naming the same explicit device).
// Capture before we resolve names, otherwise the resolve loop below
// fills empty-input with first-input-device and empty-output with
// first-output-device — those usually differ (especially on macOS
// where defaults are separate input/output devices), and the engine
// would silently route into split mode with ~85ms of ring-buffer
// latency for a config the user expected to be duplex. Legacy
// pre-PR settings commonly use empty names; preserve their behavior.
const bool userIntendsDuplex = (resolvedInputType == resolvedOutputType
&& config.inputDevice == config.outputDevice);
// Don't resolve empty names to first-device-of-each-type. Pre-PR
// behavior — and Copilot's fail-closed concern — treat empty names
// as "OS default" per side. Filling them with inputs[0] / outputs[0]
// is JUCE-enumeration-order dependent and can pick the wrong device
// (e.g. an audio interface that isn't the system default). Both
// applyDuplexSetup and applySplitSetup handle empty names by setting
// useDefault*Channels=true, letting JUCE select the OS default.
const juce::String& resolvedInput = config.inputDevice;
const juce::String& resolvedOutput = config.outputDevice;
const bool isDuplex = userIntendsDuplex
|| (resolvedInputType == resolvedOutputType
&& resolvedInput == resolvedOutput
&& resolvedInput.isNotEmpty());
// Normalize before branching — applyDuplexSetup() only checks `> 0` and
// would otherwise let Infinity (or NaN slipping past N-API) reach JUCE
// on the legacy positional setDevice() path. Finite-and-positive is the
// baseline both modes need.
const double requestedSampleRate =
(std::isfinite(config.sampleRate) && config.sampleRate > 0.0)
? config.sampleRate
: 48000.0;
const int requestedBufferSize = config.bufferSize > 0 ? config.bufferSize : 256;
// (Extra input devices were closed by the stopAudio() above with their intent
// kept; startAudio() below re-opens them at the new config — split mode only.)
if (isDuplex)
{
teardownSplitMode();
const juce::String err = applyDuplexSetup(resolvedInput, resolvedOutput,
requestedSampleRate, requestedBufferSize);
if (err.isNotEmpty())
{
res.error = err;
res.duplex = true;
return res;
}
duplexMode.store(true, std::memory_order_relaxed);
if (auto* dev = inputDeviceManager.getCurrentAudioDevice())
{
res.sampleRate = dev->getCurrentSampleRate();
res.inputBlockSize = dev->getCurrentBufferSizeSamples();
res.outputBlockSize = res.inputBlockSize;
}
res.ok = true;
res.duplex = true;
}
else
{
DeviceConfig resolved = config;
resolved.inputType = resolvedInputType;
resolved.outputType = resolvedOutputType;
resolved.inputDevice = resolvedInput;
resolved.outputDevice = resolvedOutput;
resolved.sampleRate = requestedSampleRate;
resolved.bufferSize = requestedBufferSize;
res = applySplitSetup(resolved);
if (!res.ok)
return res;
duplexMode.store(false, std::memory_order_relaxed);
}
// startAudio() re-opens the desired extra input devices at the new config
// (bindInputDevice forces + verifies the new rate). If the engine was not
// running, they stay closed with their intent kept until the next startAudio().
if (wasRunning) startAudio();
return res;
}
juce::String AudioEngine::applyDuplexSetup(const juce::String& inputName,
const juce::String& outputName,
double sampleRate, int bufferSize)
{
juce::AudioDeviceManager::AudioDeviceSetup setup;
setup.inputDeviceName = inputName;
setup.outputDeviceName = outputName;
setup.sampleRate = sampleRate > 0 ? sampleRate : 48000.0;
setup.bufferSize = bufferSize > 0 ? bufferSize : 256;
setup.useDefaultInputChannels = inputName.isEmpty();
setup.useDefaultOutputChannels = outputName.isEmpty();
// Channel masks must match too — high-numbered selectedInputChannel needs
// the expanded mask that an older session may not have opened.
if (auto* currentDevice = inputDeviceManager.getCurrentAudioDevice())
{
try
{
juce::AudioDeviceManager::AudioDeviceSetup current;
inputDeviceManager.getAudioDeviceSetup(current);
const int advertisedInputs = currentDevice->getInputChannelNames().size();
juce::BigInteger expectedInputs;
expectedInputs.setRange(0, advertisedInputs > 0 ? advertisedInputs : 2, true);
const int advertisedOutputs = currentDevice->getOutputChannelNames().size();
juce::BigInteger expectedOutputs;
expectedOutputs.setRange(0, juce::jmin(advertisedOutputs > 0 ? advertisedOutputs : 2, 2), true);
if (current.inputDeviceName == setup.inputDeviceName
&& current.outputDeviceName == setup.outputDeviceName
&& current.sampleRate == setup.sampleRate
&& current.bufferSize == setup.bufferSize
&& current.useDefaultInputChannels == setup.useDefaultInputChannels
&& current.useDefaultOutputChannels == setup.useDefaultOutputChannels
&& current.inputChannels == expectedInputs
&& current.outputChannels == expectedOutputs
&& duplexMode.load(std::memory_order_relaxed))
{
fprintf(stderr, "[AudioEngine] Duplex device already configured with same settings, skipping\n");
return {};
}
}
catch (const std::exception& e)
{
fprintf(stderr, "[AudioEngine] Current device channel check failed: %s\n", e.what());
}
catch (...)
{
fprintf(stderr, "[AudioEngine] Current device channel check failed (unknown)\n");
}
}
// ALSA deadlocks on reconfigure unless we fully close first. WASAPI
// reconfigures in place and is much slower if closed.
#if JUCE_LINUX
juce::String currentTypeName;
if (auto* currentType = inputDeviceManager.getCurrentDeviceTypeObject())
currentTypeName = currentType->getTypeName();
if (inputDeviceManager.getCurrentAudioDevice() != nullptr)
{
try {
inputDeviceManager.closeAudioDevice();
fprintf(stderr, "[AudioEngine] Closed device for reconfiguration\n");
if (currentTypeName.isNotEmpty())
inputDeviceManager.setCurrentAudioDeviceType(currentTypeName, true);
} catch (...) {
fprintf(stderr, "[AudioEngine] closeAudioDevice crashed, continuing\n");
}
}
#endif
int inputChannelCount = 0;
int outputChannelCount = 0;
if (auto* type = inputDeviceManager.getCurrentDeviceTypeObject())
{
try
{
if (auto probe = std::unique_ptr<juce::AudioIODevice>(type->createDevice(outputName, inputName)))
{
inputChannelCount = probe->getInputChannelNames().size();
outputChannelCount = probe->getOutputChannelNames().size();
}
}
catch (const std::exception& e)
{
fprintf(stderr, "[AudioEngine] Channel probe failed: %s\n", e.what());
}
catch (...)
{
fprintf(stderr, "[AudioEngine] Channel probe failed (unknown)\n");
}
}
if (inputChannelCount <= 0) inputChannelCount = 2;
if (outputChannelCount <= 0) outputChannelCount = 2;
setup.inputChannels.setRange(0, inputChannelCount, true);
setup.outputChannels.setRange(0, juce::jmin(outputChannelCount, 2), true);
juce::String result;
try {
result = inputDeviceManager.setAudioDeviceSetup(setup, true);
} catch (...) {
return "setAudioDeviceSetup threw";
}
if (result.isNotEmpty())
{
fprintf(stderr, "[AudioEngine] Device setup error: %s\n", result.toRawUTF8());
try {
result = inputDeviceManager.initialiseWithDefaultDevices(2, 2);
} catch (...) {
return "fallback initialiseWithDefaultDevices threw";
}
if (result.isNotEmpty())
return "device setup failed: " + result;
}
if (auto* configuredDevice = inputDeviceManager.getCurrentAudioDevice())
{
const double sr = configuredDevice->getCurrentSampleRate();
const int bs = configuredDevice->getCurrentBufferSizeSamples();
currentSampleRate.store(sr, std::memory_order_relaxed);
inputBlockSize.store(bs, std::memory_order_relaxed);
outputBlockSize.store(bs, std::memory_order_relaxed);
fprintf(stderr, "[AudioEngine] Duplex device configured OK. Current device: %s\n",
configuredDevice->getName().toRawUTF8());
fprintf(stderr, "[AudioEngine] Actual device setup: sr=%.0f bs=%d (requested bs=%d)\n",
sr, bs, bufferSize);
source0().prepareMonitorChain(sr, bs);
return {};
}
currentSampleRate.store(0.0, std::memory_order_relaxed);
inputBlockSize.store(0, std::memory_order_relaxed);
outputBlockSize.store(0, std::memory_order_relaxed);
source0().releaseMonitorChain();
return "no current device after setup";
}
AudioEngine::DeviceConfigResult AudioEngine::applySplitSetup(const DeviceConfig& config)
{
DeviceConfigResult res;
res.duplex = false;
// The split-mode output ring is fixed at kOutputRingFrames samples
// (~85ms @ 48kHz). A single callback at bufferSize > kOutputRingFrames
// would overrun the ring in one go, guaranteeing immediate
// overwrite/wrap and audible glitches. Reject those configurations up
// front — duplex still works fine since it bypasses the ring entirely.
if (config.bufferSize > kOutputRingFrames)
{
res.error = "Buffer size " + juce::String(config.bufferSize)
+ " exceeds split-mode ring capacity ("
+ juce::String(kOutputRingFrames) + "). Pick a smaller buffer size or use duplex.";
return res;
}
// setCurrentAudioDeviceType can throw from JUCE backends (ASIO).
// Catch so the failure surfaces as a structured error rather than an
// exception crossing the N-API boundary.
try
{
if (auto* current = outputDeviceManager.getCurrentDeviceTypeObject())
{
if (current->getTypeName() != config.outputType)
outputDeviceManager.setCurrentAudioDeviceType(config.outputType, true);
}
else
{
outputDeviceManager.setCurrentAudioDeviceType(config.outputType, true);
}
}
catch (...)
{
res.error = "setCurrentAudioDeviceType threw for output type '" + config.outputType + "'";
return res;
}
// v1 forces matching nominal SR — no adaptive resampler yet.
// Resolve empty name to first-enumerated for the createDevice probe
// call (matches probeDeviceOptionsDual's strategy). createDevice("")
// is implementation-defined per backend — some return the default,
// some return null. Using first-enumerated keeps probe and apply
// checking the SAME concrete device, so an empty-name config can't
// pass the UI probe and then fail this check.
auto rateSupportedBy = [&](juce::AudioIODeviceType* t,
const juce::String& dev, bool isInput, double sr) {
if (!t) return false;
juce::String resolved = dev;
if (resolved.isEmpty())
{
auto names = t->getDeviceNames(isInput);
if (names.size() > 0) resolved = names[0];
}
std::unique_ptr<juce::AudioIODevice> probe(
isInput ? t->createDevice({}, resolved) : t->createDevice(resolved, {}));
if (!probe) return false;
// Tolerance matches the probe-side rounding: probeDeviceOptionsDual
// rounds the matched rate to the nearest integer (see :208), so a
// backend reporting e.g. 47999.5 surfaces 48000 in the UI. If we
// kept `< 0.5` here, the round-trip would fail at apply time because
// |47999.5 - 48000.0| is exactly 0.5. Use `<= 0.5` so the boundary
// case the probe accepted is also accepted at apply.
for (auto r : probe->getAvailableSampleRates())
if (std::abs(r - sr) <= 0.5) return true;
return false;
};
juce::AudioIODeviceType* inputType = nullptr;
juce::AudioIODeviceType* outputType = nullptr;
for (auto* t : inputDeviceManager.getAvailableDeviceTypes())
if (t->getTypeName() == config.inputType) { inputType = t; break; }
for (auto* t : outputDeviceManager.getAvailableDeviceTypes())
if (t->getTypeName() == config.outputType) { outputType = t; break; }
if (!inputType || !outputType)
{
res.error = "Device type not found";
return res;
}
if (!rateSupportedBy(inputType, config.inputDevice, true, config.sampleRate)
|| !rateSupportedBy(outputType, config.outputDevice, false, config.sampleRate))
{
res.error = "Sample rate not supported by both input and output devices";
return res;
}
juce::AudioDeviceManager::AudioDeviceSetup inSetup;
// Resolve empty name to first-enumerated input device — matches the
// rateSupportedBy preflight above AND probeDeviceOptionsDual. Using
// empty + useDefault*Channels here would make JUCE open the OS
// default, which can differ from inputs[0] on platforms where the
// OS-default differs from JUCE's enumeration order. The probe + SR
// preflight + actual open all need to agree on the same concrete
// device for the apply path to behave consistently with what the UI
// showed the user.
juce::String resolvedInputName = config.inputDevice;
if (resolvedInputName.isEmpty())
{
auto names = inputType->getDeviceNames(true);
if (names.size() > 0) resolvedInputName = names[0];
}
inSetup.inputDeviceName = resolvedInputName;
inSetup.outputDeviceName = "";
inSetup.sampleRate = config.sampleRate;
inSetup.bufferSize = config.bufferSize;
inSetup.useDefaultInputChannels = false;
inSetup.useDefaultOutputChannels = false;
int inputChannelCount = 0;
{
try {
std::unique_ptr<juce::AudioIODevice> probe(inputType->createDevice({}, resolvedInputName));
if (probe) inputChannelCount = probe->getInputChannelNames().size();
} catch (...) {}
}
if (inputChannelCount <= 0) inputChannelCount = 2;
inSetup.inputChannels.setRange(0, inputChannelCount, true);
inSetup.outputChannels.clear();
// Rollback helper: on any failure path after a side has been opened,
// close both managers' devices so we don't leave the OS audio resource
// held (sometimes exclusively, e.g. ASIO) while setDevice reports a
// failure. closeAudioDevice is idempotent so unconditional calls are
// safe even when only the input or neither side opened.
auto rollbackOpenedDevices = [&]() {
// Drop any callback we already attached to the output manager —
// closeAudioDevice() does not invoke removeAudioCallback, and leaving
// outputCallbackRegistered=true would cause the next startAudio()
// to skip the re-attach (it gates on !outputCallbackRegistered),
// leaving split-mode output silent after a partial-open failure.
if (outputCallbackRegistered)
{
try { outputDeviceManager.removeAudioCallback(&outputCallback); } catch (...) {}
outputCallbackRegistered = false;
}
try { inputDeviceManager.closeAudioDevice(); } catch (...) {}
try { outputDeviceManager.closeAudioDevice(); } catch (...) {}
};
// Mirror applyDuplexSetup's JUCE_LINUX close-before-reconfigure pattern:
// ALSA deadlocks if we let setAudioDeviceSetup mutate a live device. The
// device type is re-asserted afterwards so the close doesn't drop us back
// to whatever JUCE picked at startup. closeAudioDevice/setCurrentAudioDeviceType
// throwing is non-fatal — we still try the setup below and surface its error.
#if JUCE_LINUX
{
juce::String currentInputTypeName;
if (auto* currentType = inputDeviceManager.getCurrentDeviceTypeObject())
currentInputTypeName = currentType->getTypeName();
if (inputDeviceManager.getCurrentAudioDevice() != nullptr)
{
try {
inputDeviceManager.closeAudioDevice();
if (currentInputTypeName.isNotEmpty())
inputDeviceManager.setCurrentAudioDeviceType(currentInputTypeName, true);
} catch (...) {
fprintf(stderr, "[AudioEngine] split-mode input close threw, continuing\n");
}
}
}
#endif
juce::String inErr;
try { inErr = inputDeviceManager.setAudioDeviceSetup(inSetup, true); }
catch (...) { res.error = "input setAudioDeviceSetup threw"; rollbackOpenedDevices(); return res; }
if (inErr.isNotEmpty()) { res.error = "input setup: " + inErr; rollbackOpenedDevices(); return res; }
auto* inDev = inputDeviceManager.getCurrentAudioDevice();
if (!inDev) { res.error = "no input device after setup"; rollbackOpenedDevices(); return res; }
const double inSr = inDev->getCurrentSampleRate();
const int inBs = inDev->getCurrentBufferSizeSamples();
// Same first-enumerated resolution on the output side — see input note
// above for why this matches the probe + SR preflight strategy.
juce::String resolvedOutputName = config.outputDevice;
if (resolvedOutputName.isEmpty())
{
auto names = outputType->getDeviceNames(false);
if (names.size() > 0) resolvedOutputName = names[0];
}
juce::AudioDeviceManager::AudioDeviceSetup outSetup;
outSetup.inputDeviceName = "";
outSetup.outputDeviceName = resolvedOutputName;
outSetup.sampleRate = config.sampleRate;
outSetup.bufferSize = config.bufferSize;
outSetup.useDefaultInputChannels = false;
outSetup.useDefaultOutputChannels = false;
int outputChannelCount = 0;
{
try {
std::unique_ptr<juce::AudioIODevice> probe(outputType->createDevice(resolvedOutputName, {}));
if (probe) outputChannelCount = probe->getOutputChannelNames().size();
} catch (...) {}
}
if (outputChannelCount <= 0) outputChannelCount = 2;
outSetup.inputChannels.clear();
outSetup.outputChannels.setRange(0, juce::jmin(outputChannelCount, 2), true);
// Same JUCE_LINUX close-before-reconfigure as the input side above — also
// protects when split mode is re-applied with a different output device.
#if JUCE_LINUX
{
juce::String currentOutputTypeName;
if (auto* currentType = outputDeviceManager.getCurrentDeviceTypeObject())
currentOutputTypeName = currentType->getTypeName();
if (outputDeviceManager.getCurrentAudioDevice() != nullptr)
{
try {
outputDeviceManager.closeAudioDevice();
if (currentOutputTypeName.isNotEmpty())
outputDeviceManager.setCurrentAudioDeviceType(currentOutputTypeName, true);
} catch (...) {
fprintf(stderr, "[AudioEngine] split-mode output close threw, continuing\n");
}
}
}
#endif
juce::String outErr;
try { outErr = outputDeviceManager.setAudioDeviceSetup(outSetup, true); }
catch (...) { res.error = "output setAudioDeviceSetup threw"; rollbackOpenedDevices(); return res; }
if (outErr.isNotEmpty()) { res.error = "output setup: " + outErr; rollbackOpenedDevices(); return res; }
auto* outDev = outputDeviceManager.getCurrentAudioDevice();
if (!outDev) { res.error = "no output device after setup"; rollbackOpenedDevices(); return res; }
const double outSr = outDev->getCurrentSampleRate();
const int outBs = outDev->getCurrentBufferSizeSamples();
if (std::abs(inSr - outSr) > 0.5)
{
res.error = "Input and output devices opened at different sample rates";
rollbackOpenedDevices();
return res;
}
currentSampleRate.store(inSr, std::memory_order_relaxed);
inputBlockSize.store(inBs, std::memory_order_relaxed);
outputBlockSize.store(outBs, std::memory_order_relaxed);
fprintf(stderr, "[AudioEngine] Split mode configured: inSr=%.0f inBs=%d outSr=%.0f outBs=%d\n",
inSr, inBs, outSr, outBs);
outputRingWriteIndex.store(0, std::memory_order_relaxed);
outputRingReadIndex.store(0, std::memory_order_relaxed);
outputUnderflowCount.store(0, std::memory_order_relaxed);
inputOverflowCount.store(0, std::memory_order_relaxed);
for (auto& slot : outputPendingRing)
slot.store(0u, std::memory_order_relaxed);
source0().prepareMonitorChain(inSr, inBs);
res.ok = true;
res.sampleRate = inSr;
res.inputBlockSize = inBs;
res.outputBlockSize = outBs;
return res;
}
void AudioEngine::teardownSplitMode()
{
// Unconditional remove — JUCE's removeAudioCallback is idempotent
// (no-op if the callback isn't registered), so we don't need the
// outputCallbackRegistered guard here. This makes teardown robust
// against a stale flag left over from a previous failed split setup.
outputDeviceManager.removeAudioCallback(&outputCallback);
outputCallbackRegistered = false;
try { outputDeviceManager.closeAudioDevice(); }
catch (...) { fprintf(stderr, "[AudioEngine] teardownSplitMode: output close threw\n"); }
outputRingWriteIndex.store(0, std::memory_order_relaxed);
outputRingReadIndex.store(0, std::memory_order_relaxed);
for (auto& slot : outputPendingRing)
slot.store(0u, std::memory_order_relaxed);
}
// ── Audio Control ─────────────────────────────────────────────────────────────
void AudioEngine::startAudio()
{
if (audioRunning.load(std::memory_order_relaxed))
{
fprintf(stderr, "[AudioEngine] startAudio: already running\n");
return;
}
// Input first so it has time to prefill the ring before the output
// callback pulls — otherwise split mode underflows once at start.
inputDeviceManager.addAudioCallback(this);
// Guard against double-registration: audioRunning can be cleared by
// audioDeviceStopped() on a transient input unplug while the output
// callback intentionally stays registered (JUCE auto-restart relies on
// that). A later startAudio() would then add the same callback again
// and JUCE would dispatch it twice per block.
if (!duplexMode.load(std::memory_order_relaxed) && !outputCallbackRegistered)
{
outputDeviceManager.addAudioCallback(&outputCallback);
outputCallbackRegistered = true;
}
audioRunning.store(true, std::memory_order_relaxed);
fprintf(stderr, "[AudioEngine] startAudio: duplex=%d input='%s' output='%s'\n",
(int) duplexMode.load(std::memory_order_relaxed),
inputDeviceManager.getCurrentAudioDevice()
? inputDeviceManager.getCurrentAudioDevice()->getName().toRawUTF8() : "none",
outputDeviceManager.getCurrentAudioDevice()
? outputDeviceManager.getCurrentAudioDevice()->getName().toRawUTF8() : "(duplex)");
// Restore any extra input devices the user still wants bound (stopAudio closed
// them but kept the intent). This is what makes a stop/start cycle or a device
// reconfigure transparently resume multi-input detection.
reopenDesiredExtraInputs();
// Restore the streamer-mix output device too (same intent-survives-restart
// pattern). Best-effort — a failure leaves the sink inactive, never blocks.
reopenDesiredStreamSink();
}
void AudioEngine::stopAudio()
{
// Always attempt to detach both callbacks — removeAudioCallback is
// idempotent. We don't gate on audioRunning here because that flag can
// be cleared externally by audioDeviceStopped() (input device
// hot-unplug); in split mode the output callback may still be registered
// even after the input side has reported itself stopped, and a guarded
// stopAudio() would no-op while leaving the output device producing.
// Output first so it doesn't pull from a stalling ring during detach.
outputDeviceManager.removeAudioCallback(&outputCallback);
outputCallbackRegistered = false;
inputDeviceManager.removeAudioCallback(this);
// Extra input devices are opened independently of startAudio(); close them here
// too so a stopped engine never leaves a second interface capturing, feeding
// detectors, and holding the hardware open in the background. KEEP their
// desiredDeviceName so the next startAudio() re-opens them (a stop/start cycle,
// or a device reconfigure, restores extra inputs automatically). No-op when none
// are bound — the single-device path is unchanged.
for (int dk = 1; dk <= kMaxExtraInputDevices; ++dk)
closeExtraInputDevice(dk - 1);
// Tear down the streamer-mix OUTPUT device too — KEEP its desired intent so the
// next startAudio() reopens it (same pattern as extra inputs). Without this the
// 2nd output device keeps running and underflowing while the engine is "stopped",
// and the destructor (which calls stopAudio()) would be the only path that ever
// closes it — closing the device here also makes that destructor teardown safe.
closeStreamSinkDevice();
audioRunning.store(false, std::memory_order_relaxed);
currentBackingLevel.store(0.0f);
}
// ── Backing Track ─────────────────────────────────────────────────────────────
bool AudioEngine::loadBackingTrack(const juce::File& file)
{
const juce::ScopedLock sl(backingLock);
stopBackingNoLock();
backingTransport.reset();
backingSource.reset();
const bool exists = file.existsAsFile();
std::cerr << "[AudioEngine] loadBackingTrack path="
<< file.getFullPathName().toStdString()
<< " exists=" << exists
<< " size=" << (exists ? (long long)file.getSize() : -1)
<< std::endl;
auto* reader = formatManager.createReaderFor(file);
if (!reader)
{
std::cerr << "[AudioEngine] loadBackingTrack: no reader for ext='"
<< file.getFileExtension().toStdString()
<< "' (registered formats=" << formatManager.getNumKnownFormats()
<< ")" << std::endl;
// Transport/source already reset above; clear cached state so the renderer
// doesn't keep displaying the previous track's position/duration.
cachedBackingPosition.store(0.0);
cachedBackingDuration.store(0.0);
return false;
}
const double readerSampleRate = reader->sampleRate;
const juce::int64 readerLengthInSamples = reader->lengthInSamples;
const double sr = currentSampleRate.load(std::memory_order_relaxed);
// Backing audio plays through the output device in both modes, so size
// against outputBlockSize. In duplex mode outputBlockSize == inputBlockSize;
// in split mode the output device's clock drives the backing pull.
const int bs = outputBlockSize.load(std::memory_order_relaxed);
backingSource = std::make_unique<juce::AudioFormatReaderSource>(reader, true);
backingTransport = std::make_unique<juce::AudioTransportSource>();
// Read-ahead on backingReadThread so the RT audio thread normally never
// touches the disk or the format codec. Previously this passed
// (…, 0, nullptr, …): with no read-ahead buffer the transport decoded the
// file synchronously inside getNextAudioBlock ON the audio callback, so any
// disk seek / decode spike (worst for compressed formats) blew the block
// budget → underruns heard as glitches or brief mutes while a song plays.
// 32768 source frames ≈ 0.68 s @ 48k of look-ahead absorbs those spikes.
//
// Known residual (accepted): juce::BufferingAudioSource is not fully
// RT-safe — readBufferSection() holds callbackLock across the decode of one
// refill chunk, and the callback's getNextAudioBlock() takes the same lock,
// so the RT thread can still block behind an in-flight chunk decode. The
// window is bounded (JUCE caps chunks at 2048 source frames) and only hit
// when a refill is mid-decode, vs. the old guaranteed full decode on every
// block; a truly lock-free ring would mean replacing the JUCE transport
// stack and isn't worth it here.
// The 4th arg makes AudioTransportSource SRC the file to device rate.
// Stretch always sees device-rate audio so that its presetDefault parameters match.
constexpr int kBackingReadAheadSamples = 32768;
backingTransport->setSource(backingSource.get(), kBackingReadAheadSamples,
&backingReadThread, readerSampleRate);
// Loading a backing track before the audio device has started leaves
// sr/bs at zero. presetDefault(2, 0.0f) would seed the stretcher with
// undefined internal timing, and prepareToPlay(0, 0) is similarly
// ill-defined. Defer the stretcher + buffer setup; the relevant
// audio*AboutToStart() re-runs the same block once a real sample
// rate / block size are known (audioDeviceAboutToStart for duplex,
// audioOutputAboutToStart for split).
if (sr > 0.0 && bs > 0)
{
// prepareToPlay's first arg is an upper bound on subsequent
// getNextAudioBlock requests, per the juce::AudioSource contract.
// The RT callback can pull ceil(bs * kMaxBackingSpeed) frames in a
// single block when the speed is above 1×, so prepare for that
// worst case — preparing with just `bs` would risk JUCE internal
// buffer overruns/asserts on the first faster-than-1× block.
const int maxInputFrames = (int) std::ceil(bs * kMaxBackingSpeed) + 64;
backingTransport->prepareToPlay(maxInputFrames, sr);
backingStretch.presetDefault(2, (float) sr);
backingStretch.reset();
backingStretchLatencySamples.store(backingStretch.outputLatency(), std::memory_order_relaxed);
backingInputBuffer.setSize(2, maxInputFrames, false, false, true);
backingBuffer.setSize(2, bs, false, false, true);
}
cachedBackingDuration.store(backingTransport->getLengthInSeconds());
cachedBackingPosition.store(0.0);
backingHeardPositionSec.store(0.0, std::memory_order_relaxed);
// Reset the loudness leveler for the new song: clearing the cached sample
// rate forces renderBackingBlockLocked() to re-prepare() it on the next
// block, dropping the previous track's AGC gain + limiter state. Otherwise
// the ~300 ms gain follower would carry over and briefly mis-level the start
// of a much louder/quieter next song. Safe here — loadBackingTrack holds
// backingLock, the same lock the render path runs under.
backingLevelerSr = 0.0;
std::cerr << "[AudioEngine] loadBackingTrack OK sr=" << readerSampleRate
<< " len=" << readerLengthInSamples
<< std::endl;
return true;
}
void AudioEngine::setBackingPosition(double seconds)
{
const juce::ScopedLock sl(backingLock);
if (backingTransport)
{
backingTransport->setPosition(seconds);
backingStretch.reset();
// Read back the actual position; the transport may clamp (e.g. negative or past EOF).
const double pos = backingTransport->getCurrentPosition();
cachedBackingPosition.store(pos);
backingHeardPositionSec.store(pos, std::memory_order_relaxed);
}
}
void AudioEngine::startBacking()
{
const juce::ScopedLock sl(backingLock);
if (backingTransport)
{
backingTransport->start();
backingPlaying.store(true);
backingHeardPositionSec.store(backingTransport->getCurrentPosition(),
std::memory_order_relaxed);
}
}
void AudioEngine::stopBackingNoLock()
{
if (backingTransport)
{
backingTransport->stop();
backingStretch.reset();
backingPlaying.store(false);
}
currentBackingLevel.store(0.0f);
}
void AudioEngine::stopBacking()
{
const juce::ScopedLock sl(backingLock);
stopBackingNoLock();
}
void AudioEngine::setBackingSpeed(double speed)
{
if (!std::isfinite(speed) || speed <= 0.0)
{
return;
}
const double clamped = juce::jlimit(0.01, kMaxBackingSpeed, speed);
// Dead-zone against the last *requested* rate to coalesce rapid slider
// ticks — but never skip a change that crosses the 1× bypass boundary, or a
// request just shy of 1× (e.g. 0.9995 -> 1.0, diff < 0.001) would leave the
// stretcher path engaged when the caller actually asked for transparent
// full speed.
const double prev = backingPendingSpeed.load(std::memory_order_relaxed);
const bool prevBypass = std::abs(prev - 1.0) < kBackingSpeedBypassEpsilon;
const bool newBypass = std::abs(clamped - 1.0) < kBackingSpeedBypassEpsilon;
if (std::abs(clamped - prev) < 0.001 && prevBypass == newBypass)
{
return;
}
// Lock-free hand-off to the audio thread. Publish the requested rate, then
// raise the pending flag with release so the RT thread is guaranteed to see
// the new rate once it observes the flag. renderBackingBlockLocked() adopts
// the rate and resets the stretcher together, on the audio thread, so:
// * a control-thread caller (e.g. a speed slider at 30-60 Hz) never takes
// backingLock and so never starves the RT tryLock into dropping a block;
// * the new rate is never processed with stale stretch state — the reset
// and the rate adoption happen in the same RT block (see PR #237).
// Multiple updates before the RT consumes them coalesce (latest wins), which
// naturally throttles stretcher resets during a drag.
backingPendingSpeed.store(clamped, std::memory_order_relaxed);
backingSpeedChangePending.store(true, std::memory_order_release);
}
void AudioEngine::resetPeaks()
{
// Input peak is per-source — clear EVERY active source (getSourceLevels() exposes
// each one's peak), not just source 0, or extra-device peaks latch forever.
for (auto& src : sources)
if (src->isActive())
src->resetInputPeak();
outputPeak.store(0.0f);
}
// ── Multi-input source management (control thread) ───────────────────────────
int AudioEngine::addSource(int inputChannel, int deviceKey)
{
// Only deviceKey 0 (primary) and 1..kMaxExtraInputDevices are ever serviced by
// a callback. An out-of-range key (e.g. stale persisted state) would make an
// "active" source that mixSourcesForDevice never processes — a ghost that never
// detects or outputs. Fail fast instead.
if (deviceKey < 0 || deviceKey > kMaxExtraInputDevices)
return -1;
// An extra-device source is only ever serviced by that device's callback, so the
// device must be BOUND — either currently open (active) or DEFERRED (validated +
// desired while the engine is stopped, to be reopened by startAudio()). Reject
// only when the slot is neither: a stale persisted deviceKey, or a slot a
// reconfigure fully unbound. A deferred source is added now and resumes when its
// device reopens; without this, a panel restored while audio is stopped can never
// configure its detector even though the device was successfully (deferred-)bound.
if (deviceKey >= 1)
{
const InputDeviceSlot& es = extraInputs[(size_t) (deviceKey - 1)];
if (! es.active.load(std::memory_order_acquire) && es.desiredDeviceName.isEmpty())
return -1;
}
std::lock_guard<std::mutex> lock(sourcesMutex);
reclaimPendingReleases(); // free up any slot whose release was deferred
// Find a free pooled slot (slot 0 is the permanent default). Skip a slot whose
// release is still pending — its chain/worker hasn't been torn down yet, so
// re-preparing it would double-start the verifier thread.
int slot = -1;
for (int i = 1; i < kMaxSources; ++i)
if (! sources[(size_t) i]->isActive() && ! pendingRelease[(size_t) i]) { slot = i; break; }
if (slot < 0)
return -1; // pool full
SourceChain& src = *sources[(size_t) slot];
src.setInputChannel(inputChannel);
src.setDeviceKey(deviceKey);
// Inherit the bound device's capture-latency correction (0 for the primary).
// The device usually started before the source was created (the user picks the
// device, then enables detect), so its delta is already known.
if (deviceKey >= 1 && deviceKey <= kMaxExtraInputDevices)
src.setVerifierAutoOffset(extraInputs[(size_t) (deviceKey - 1)].latencyDeltaSec.load(std::memory_order_relaxed));
else
src.setVerifierAutoOffset(0.0);
// Clear any MANUAL offset left on this pooled chain by a previous player — a
// freshly added source starts with no user fine-tune (the renderer re-applies
// its own via setSourceVerifierOffset). releaseResources() doesn't touch it.
src.setVerifierUserOffset(0.0);
// Likewise clear stale meters so this source doesn't briefly report the previous
// player's level/peak through getSourceLevels() until fresh audio arrives.
src.resetInputMeters();
// Prepare fully BEFORE making it visible to the audio thread, so the first
// callback that observes active==true sees a ready chain + rings. When audio
// isn't running yet, audioDeviceAboutToStart (primary) / extraInputAboutToStart
// (extra) prepares it later. An EXTRA-device source must be prepared with ITS
// device's sample rate / block size, not the primary's — the two can differ.
bool deviceReady = audioRunning.load(std::memory_order_relaxed);
double sr = currentSampleRate.load(std::memory_order_relaxed);
int bs = inputBlockSize.load(std::memory_order_relaxed);
if (deviceKey >= 1 && deviceKey <= kMaxExtraInputDevices)
{
const InputDeviceSlot& es = extraInputs[(size_t) (deviceKey - 1)];
deviceReady = es.active.load(std::memory_order_acquire);
sr = es.sampleRate.load(std::memory_order_relaxed);
bs = es.blockSize.load(std::memory_order_relaxed);
}
if (deviceReady && sr > 0.0 && bs > 0)
src.prepare(sr, bs);
src.setActive(true); // release-store: now picked up by the audio callback
return slot;
}
bool AudioEngine::removeSource(int id)
{
if (id <= 0 || id >= kMaxSources)
return false; // 0 is permanent; out-of-range rejected
std::lock_guard<std::mutex> lock(sourcesMutex);
reclaimPendingReleases(); // opportunistically reclaim earlier deferrals
SourceChain& src = *sources[(size_t) id];
if (! src.isActive())
return false;
// Hide it from the audio callback first; subsequent blocks snapshot active
// once and skip it. It is logically removed from here on, regardless of when
// its resources are reclaimed.
src.setActive(false);
// Reclaim now if we can confirm THIS SOURCE's device callback is not executing.
// Only the callback for the source's own deviceKey can touch it; that counter is
// decremented at the callback's real exit (release store), so observing 0
// (acquire) proves it is not inside processBlock right now; any callback that
// starts afterwards snapshots active and skips this (now-inactive) source — so
// releasing cannot race the audio thread. Keying on the source's deviceKey (not a
// global all-callbacks-idle check) is what lets removals reclaim during steady
// multi-device playback, when callbacks on independent clocks are never all idle
// at once. Bounded so a wedged device can't hang this thread.
const size_t dk = (size_t) src.getDeviceKey();
for (int spins = 0; spins < 200; ++spins) // ~200 ms cap
{
if (callbacksInFlight[dk].load(std::memory_order_acquire) == 0)
{
src.releaseResources(); // stops its threads + releases its chain
return true;
}
std::this_thread::sleep_for(std::chrono::milliseconds(1));
}
// A callback stayed wedged in-flight past the wait (a >200 ms block would be a
// catastrophic stall). Do NOT force a release that could race it — DEFER it.
// The source is already inactive so no future callback touches it; reclaim it
// later (next add/removeSource, or audioDeviceStopped) when the body is quiet.
pendingRelease[(size_t) id] = true;
return true;
}
void AudioEngine::reclaimPendingReleases()
{
// Caller holds sourcesMutex. A deferred source is inactive (future callbacks skip
// it); releasing it is safe once the callback for ITS deviceKey is not in a body.
// We key per-deviceKey (decremented by each callback at its real exit) so a
// pending release frees as soon as its OWN device is quiescent — not only when
// every device callback happens to be idle simultaneously (which, on independent
// clocks during steady multi-device playback, may never occur and would strand
// the slot until full stop). On the audioDeviceStopped path the relevant callback
// already left its count at 0.
for (int i = 1; i < kMaxSources; ++i)
{
if (! pendingRelease[(size_t) i]) continue;
const size_t dk = (size_t) sources[(size_t) i]->getDeviceKey();
if (callbacksInFlight[dk].load(std::memory_order_acquire) != 0)
continue; // this source's device is mid-body — try again later
sources[(size_t) i]->releaseResources();
pendingRelease[(size_t) i] = false;
}
}
// Lifetime/threading of getSource() + the NodeAddon *Source* methods:
// - getSource(), add/removeSource(), and the source-indexed methods all run on
// the single N-API/JS thread (V8-serialised), so a source can't be removed out
// from under a getSource() caller on that thread.
// - getSource() returns a pointer into the FIXED pool. releaseResources() (from
// removeSource or audioDeviceStopped) only stops the chain's threads + resets
// its rings — it NEVER frees the SourceChain object — so the pointer never
// dangles for the life of the engine (no use-after-free).
// - The only cross-thread overlap is a source-indexed method (N-API) running
// concurrently with audioDeviceStopped's releaseResources() (device thread).
// That window is IDENTICAL to the pre-existing legacy methods (scoreChord /
// getNoteVerdicts / getRawAudioFrame, which all operate on source 0's same
// chain) and is handled the same way: each component's internals are atomic /
// individually thread-safe, and the rings are lock-free. This change adds no
// new race class versus the original single-source engine.
SourceChain* AudioEngine::getSource(int id)
{
if (id < 0 || id >= kMaxSources)
return nullptr;
SourceChain& src = *sources[(size_t) id];
return (id == 0 || src.isActive()) ? &src : nullptr;
}
void AudioEngine::setMlNoteDetectionEnabled(bool e)
{
// Fan to every source in the fixed pool (not just active ones) so a source
// activated later inherits the current arm state instead of silently
// staying dormant. Each MlNoteDetector::setEnabled is a cheap atomic + a
// cold-state clear on a real transition.
for (int i = 0; i < kMaxSources; ++i)
sources[(size_t) i]->getMlNoteDetector().setEnabled(e);
}
std::vector<AudioEngine::SourceInfo> AudioEngine::listSources() const
{
std::vector<SourceInfo> out;
for (int i = 0; i < kMaxSources; ++i)
{
const SourceChain& src = *sources[(size_t) i];
if (! src.isActive()) continue;
SourceInfo info;
info.id = src.getId();
info.inputChannel = src.getInputChannel();
info.deviceKey = src.getDeviceKey();
info.active = true;
out.push_back(info);
}
return out;
}
int AudioEngine::renderBackingBlockLocked(int numSamples)
{
// Adopt any speed change requested since the last block (set lock-free by
// setBackingSpeed). Common (no-change) path is a plain acquire load — no
// locked RMW, so the flag's cache line stays shared and isn't bounced to
// this core every callback. Only the rare block that actually consumes a
// change does the exchange (clearing the flag atomically so a concurrent
// setBackingSpeed can't lose an update). The acquire pairs with the
// release-store in setBackingSpeed so the new rate is visible here. Reset
// the stretcher and re-anchor the heard position in the SAME block we adopt
// the rate, so a block is never processed at the new rate with stale stretch
// state. reset() only clears state (no allocation), so it's audio-thread safe.
if (backingSpeedChangePending.load(std::memory_order_acquire))
{
backingSpeedChangePending.exchange(false, std::memory_order_acquire);
backingSpeed.store(juce::jlimit(0.01, kMaxBackingSpeed,
backingPendingSpeed.load(std::memory_order_relaxed)),
std::memory_order_relaxed);
backingStretch.reset();
backingHeardPositionSec.store(backingTransport->getCurrentPosition(),
std::memory_order_relaxed);
}
const double rate = juce::jlimit(0.01, kMaxBackingSpeed, backingSpeed.load(std::memory_order_relaxed));
// Defensive clamp: the buffers are sized in audioDeviceAboutToStart() /
// audioOutputAboutToStart() from the device's nominal block size, but a
// callback can deliver a larger numSamples on a device-reconfig race. Drop
// the excess frames silently rather than reading/writing past the allocated
// span; the next callback after reconfig arrives at the new nominal size.
const int outCap = backingBuffer.getNumSamples();
const int inCap = backingInputBuffer.getNumSamples();
const int outSamples = juce::jmin(numSamples, outCap);
const double sr = currentSampleRate.load(std::memory_order_relaxed);
const bool bypassStretch = std::abs(rate - 1.0) < kBackingSpeedBypassEpsilon;
int sourceFramesPulled = 0;
if (bypassStretch)
{
// 1× — direct transport read, no phase-vocoder path. (The transport
// still sample-rate-converts the file to the device rate, so this is
// "no time-stretch", not necessarily bit-perfect.)
backingBuffer.clear(0, outSamples);
juce::AudioSourceChannelInfo info(&backingBuffer, 0, outSamples);
backingTransport->getNextAudioBlock(info);
sourceFramesPulled = outSamples;
}
else
{
// Slow/fast path — pull only the source frames needed for this output
// block (output * rate), then stretch in-process to fill outSamples.
const int inputFrames = juce::jmin((int) std::ceil(outSamples * rate), inCap);
backingInputBuffer.clear(0, inputFrames);
juce::AudioSourceChannelInfo info(&backingInputBuffer, 0, inputFrames);
backingTransport->getNextAudioBlock(info);
sourceFramesPulled = inputFrames;
backingBuffer.clear(0, outSamples);
const float* const* inPtrs = backingInputBuffer.getArrayOfReadPointers();
float* const* outPtrs = backingBuffer.getArrayOfWritePointers();
backingStretch.process(inPtrs, inputFrames, outPtrs, outSamples);
}
const double transportPos = backingTransport->getCurrentPosition();
if (sr > 0.0 && sourceFramesPulled > 0)
{
// Accumulate the heard (source) position, but clamp to the transport's
// actual position. sourceFramesPulled is the requested block size; a
// short read (e.g. at EOF, where the transport returns fewer real frames
// and zero-pads) would otherwise advance the playhead past the true
// source point and report progress beyond the track duration before
// backingPlaying flips false. getCurrentPosition() stays clamped to the
// real source position.
double heard = backingHeardPositionSec.load(std::memory_order_relaxed)
+ static_cast<double>(sourceFramesPulled) / sr;
heard = juce::jmin(heard, transportPos);
backingHeardPositionSec.store(heard, std::memory_order_relaxed);
// Bypass reads straight from the transport — no phase-vocoder output
// latency to compensate for. Only the stretch path adds latency.
const double latencyInputSec = bypassStretch
? 0.0
: (backingStretchLatencySamples.load(std::memory_order_relaxed) * rate) / sr;
cachedBackingPosition.store(juce::jmax(0.0, heard - latencyInputSec));
}
else
{
// currentSampleRate is transiently 0 during device teardown/reconfig.
// We can't accumulate (no Hz to divide by), so anchor both the heard
// accumulator and the published playhead to the real transport position
// rather than leaving a stale value visible to the UI.
backingHeardPositionSec.store(transportPos, std::memory_order_relaxed);
cachedBackingPosition.store(juce::jmax(0.0, transportPos));
}
// Sync the flag if transport stopped at EOF.
if (!backingTransport->isPlaying())
backingPlaying.store(false);
// Normalize the backing track to a consistent target loudness (-12 LUFS)
// BEFORE the mixer's backing-volume fader is applied (later in the RT
// callback), so every song sits at the same level while the fader still
// attenuates it. Standard BS.1770 K-weighting (full-mix music) + a brickwall
// limiter to keep boosted peaks safe. RT-safe (no allocation).
if (outSamples > 0 && sr > 0.0)
{
if (sr != backingLevelerSr) { backingLeveler.prepare(sr); backingLevelerSr = sr; }
backingLeveler.process(backingBuffer, outSamples, -12.0f);
}
return outSamples;
}
// setNoiseGate / setTonePolishEnabled are now inline forwarders to sources[0]
// (see AudioEngine.h).
// ── Audio Callback ────────────────────────────────────────────────────────────
void AudioEngine::audioDeviceAboutToStart(juce::AudioIODevice* device)
{
// Fires on the input manager — duplex serves output here too; split has
// audioOutputAboutToStart on the second manager.
//
// Also fires when JUCE auto-restarts the input after a transient stop
// (hot-replug, OS resume). audioDeviceStopped() cleared audioRunning;
// restore it here so scoreChord() / getActiveDetection() come back
// online without requiring a manual stopAudio()/startAudio() cycle,
// and so a subsequent setAudioDevices() sees the engine as running
// and runs its defensive stopAudio() before mutating device state.
audioRunning.store(true, std::memory_order_relaxed);
const double sr = device->getCurrentSampleRate();
const int bs = device->getCurrentBufferSizeSamples();
currentSampleRate.store(sr, std::memory_order_relaxed);
inputBlockSize.store(bs, std::memory_order_relaxed);
if (duplexMode.load(std::memory_order_relaxed))
outputBlockSize.store(bs, std::memory_order_relaxed);
// Input callback uses outputBackingBuffer as DSP scratch in split mode.
// Pre-size against input block size so the hot path can't allocate when
// inputBlockSize > outputBlockSize (audioOutputAboutToStart only sizes
// against output).
if (!duplexMode.load(std::memory_order_relaxed)
&& outputBackingBuffer.getNumSamples() < bs)
{
outputBackingBuffer.setSize(2, bs, false, false, true);
}
// Pre-size the multi-source mix scratch (2ch) so the audio thread never
// allocates when summing active sources.
if (sourceMonitorScratch.getNumSamples() < bs)
sourceMonitorScratch.setSize(2, bs, false, false, true);
// Producer-side stream-mix scratch (duplex path runs here). Sized to a FIXED
// capacity equal to the ring and NEVER grown with the block size: in split mode
// the OUTPUT callback is the producer that uses these buffers while THIS (input)
// about-to-start can fire on a hotplug/resume — a block-size-driven realloc here
// would free memory out from under the live producer. A block larger than the
// ring can't be published anyway and is skipped by the capacity guard in
// composeAndPushStreamMix(), so a fixed cap is sufficient AND allocates exactly
// once: it can never realloc under a live producer, for any later block size.
constexpr int streamScratchCap = (int) kOutputRingFrames;
if (streamGuitarScratch.getNumSamples() < streamScratchCap) streamGuitarScratch.setSize(2, streamScratchCap, false, false, true);
if (streamMixScratch.getNumSamples() < streamScratchCap) streamMixScratch.setSize(2, streamScratchCap, false, false, true);
// Prepare each ACTIVE PRIMARY-device source's DSP and reset its rings for a
// clean cold start. Inactive pooled chains stay unprepared (no threads). EXTRA-
// device sources (deviceKey > 0) are prepared by their own extraInputAboutToStart
// with THAT device's format — a primary restart must not clobber them with the
// primary's sample rate / block size (they run on a different hardware clock).
for (auto& src : sources)
if (src->isActive() && src->getDeviceKey() == 0)
src->prepare(sr, bs);
// Split mode preps the backing stretcher in audioOutputAboutToStart
// instead — that callback owns the device the backing audio actually
// plays on, and pulls from backingTransport at the output device's
// block size.
if (duplexMode.load(std::memory_order_relaxed))
{
const juce::ScopedLock sl(backingLock);
if (backingTransport)
{
// See loadBackingTrack() for why prepareToPlay uses maxInputFrames
// rather than bs: the RT callback can pull ceil(bs * kMaxBackingSpeed)
// frames in a single block at faster-than-1× speeds.
const int maxInputFrames = (int) std::ceil(bs * kMaxBackingSpeed) + 64;
backingTransport->prepareToPlay(maxInputFrames, sr);
backingStretch.presetDefault(2, (float) sr);
backingStretch.reset();
backingStretchLatencySamples.store(backingStretch.outputLatency(), std::memory_order_relaxed);
backingInputBuffer.setSize(2, maxInputFrames, false, false, true);
backingBuffer.setSize(2, bs, false, false, true);
}
}
}
void AudioEngine::audioDeviceStopped()
{
// JUCE calls audioDeviceStopped() only AFTER the PRIMARY input device has
// stopped invoking its IO callback (stop() blocks for the callback thread to
// finish), so the primary body is quiescent here. We release ONLY deviceKey-0
// sources: an EXTRA-device source is processed by that device's own callback,
// which may still be running on its own thread — releasing it here would race.
// Extra sources are released by extraInputStopped()/unbindInputDevice().
audioRunning.store(false, std::memory_order_relaxed);
{
std::lock_guard<std::mutex> lock(sourcesMutex);
// Release each ACTIVE primary-device source's chain and zero its rings.
// Inactive pooled chains were never prepared.
for (auto& src : sources)
if (src->isActive() && src->getDeviceKey() == 0)
src->releaseResources();
// Reclaim deferred removals only when ALL callback bodies are quiescent
// (an extra-device callback could still be mid-block).
reclaimPendingReleases();
}
outputRingWriteIndex.store(0, std::memory_order_relaxed);
outputRingReadIndex.store(0, std::memory_order_relaxed);
currentBackingLevel.store(0.0f);
// Note on split-mode lifecycle: we deliberately do NOT detach the
// output callback here. JUCE auto-restarts a transiently-stopped input
// device by re-firing audioDeviceAboutToStart() on its own; that path
// doesn't re-add the output callback, so detaching would break
// automatic recovery (output stays silent until a manual reconfigure).
// While input is down, the guitar/DSP side of the output goes silent
// (no producer feeding outputPendingRing, so the consumer's underflow
// branch zero-fills), but the backing track keeps playing — the output
// callback mixes backingTransport independently of ring state. That's
// intentional UX: a user unplugging their interface mid-song doesn't
// lose their place. Real teardown belongs to stopAudio() / teardownSplitMode().
}
void AudioEngine::audioOutputAboutToStart(juce::AudioIODevice* device)
{
const int bs = device->getCurrentBufferSizeSamples();
outputBlockSize.store(bs, std::memory_order_relaxed);
if ((int) outputPullScratchL.size() < bs) outputPullScratchL.assign((size_t) bs, 0.0f);
if ((int) outputPullScratchR.size() < bs) outputPullScratchR.assign((size_t) bs, 0.0f);
// Producer-side stream-mix scratch (split path composes the stream submix in
// this output callback). Fixed capacity == the ring, never grown — a block
// restart on EITHER clock can't realloc these under a live producer, for any
// block size (oversized blocks are skipped, not buffered). See the matching
// note in audioDeviceAboutToStart().
constexpr int streamScratchCap = (int) kOutputRingFrames;
if (streamGuitarScratch.getNumSamples() < streamScratchCap) streamGuitarScratch.setSize(2, streamScratchCap, false, false, true);
if (streamMixScratch.getNumSamples() < streamScratchCap) streamMixScratch.setSize(2, streamScratchCap, false, false, true);
// NOTE: outputBackingBuffer is sized by audioDeviceAboutToStart() from the
// INPUT device's block size — it's the split-input DSP scratch, not an
// output-side buffer. Don't touch it here: resizing from the output
// thread races with the input callback and can shrink the scratch below
// its expected size. The output side uses backingBuffer / backingInputBuffer,
// sized below.
// Prefer the output device's reported rate as authoritative. The
// stored currentSampleRate (set from the input side) was the right
// fallback during initial setup, but if the OS or driver reopened
// the output device at a different rate (format change, sleep/resume,
// user-changed default), trusting the stored value would seed the
// stretcher with a mismatched rate. Compare and warn so the
// discrepancy is visible in logs; the device-reported rate wins.
const double srStored = currentSampleRate.load(std::memory_order_relaxed);
const double srDev = device->getCurrentSampleRate();
const double sr = srDev > 0.0 ? srDev : srStored;
if (srStored > 0.0 && srDev > 0.0 && std::abs(srStored - srDev) > 0.5)
{
fprintf(stderr, "[AudioEngine] audioOutputAboutToStart: stored sr=%.0f differs from device sr=%.0f — using device\n",
srStored, srDev);
}
// Propagate the authoritative rate to currentSampleRate so downstream
// consumers (audioOutputCallback's latency comp, getLatencyMs(),
// scoreChord's fallback) all see the same value. Without this, a
// device-side rate change (sleep/resume, format change) would leave
// currentSampleRate stuck at the input-side seed value.
if (sr > 0.0) currentSampleRate.store(sr, std::memory_order_relaxed);
{
const juce::ScopedLock sl(backingLock);
if (backingTransport && sr > 0.0 && bs > 0)
{
// Mirror loadBackingTrack() / audioDeviceAboutToStart() — the
// output device drives backing playback in split mode, so this
// is where the stretcher gets sized for that side. prepareToPlay
// upper-bounds future getNextAudioBlock requests, and the
// RT callback can pull ceil(bs * kMaxBackingSpeed) at faster
// speeds.
const int maxInputFrames = (int) std::ceil(bs * kMaxBackingSpeed) + 64;
backingTransport->prepareToPlay(maxInputFrames, sr);
backingStretch.presetDefault(2, (float) sr);
backingStretch.reset();
backingStretchLatencySamples.store(backingStretch.outputLatency(), std::memory_order_relaxed);
backingInputBuffer.setSize(2, maxInputFrames, false, false, true);
backingBuffer.setSize(2, bs, false, false, true);
}
}
}
void AudioEngine::audioOutputStopped()
{
// No-op by design. The consumer's catch-up branch in audioOutputCallback
// handles both (w - r) > cap (producer lapped during the stop) and
// w < r (a future reset race) on the next output start, so we don't
// need to reset readIndex here. Resetting r to 0 while the producer
// keeps advancing w is equivalent — both end up in the catch-up branch
// — but it leaves a transient window where r appears reset without the
// ring invariants being re-established, which is harder to reason about.
}
// ── Streamer mix output sink (PR1) ──────────────────────────────────────────
// Producer (primary/output callback): compose the stream submix and pack it into
// the sink's ring. Consumer (streamSinkCallback): drain + write to the device.
// Mirrors the InputDeviceSlot ring discipline, inverted to the output side.
void AudioEngine::composeAndPushStreamMix(const juce::AudioBuffer<float>& guitarMix,
const juce::AudioBuffer<float>* backingBuf,
int backingFrames, float backingVol, int numSamples)
{
if (! streamSink.active.load(std::memory_order_acquire)) return;
// A block larger than the entire ring can't be published atomically (it would
// wrap and overwrite unread slots before writeIndex is bumped). Skip it and
// count an overflow. Checked FIRST (before the scratch guard) so an oversized
// block is always counted — the fixed-size scratch is exactly the ring, so an
// oversized block also trips the scratch guard below and would otherwise be
// dropped silently. The split path already rejects oversized devices at setup;
// this guards the duplex path, whose block size we don't pre-validate.
if (numSamples > (int) kOutputRingFrames)
{
streamSink.overflowCount.fetch_add(1, std::memory_order_relaxed);
return;
}
// Scratch not yet sized to the full ring (cold start before the producer's
// about-to-start ran, or a transient reconfig) — skip rather than alloc on RT.
// After warm-up the scratch is exactly the ring, so for an in-range block this
// never trips.
if (streamMixScratch.getNumSamples() < numSamples) return;
const bool ig = streamBusIncludeGuitar.load(std::memory_order_relaxed);
const bool ib = streamBusIncludeBacking.load(std::memory_order_relaxed);
const float gain = streamBusGain.load(std::memory_order_relaxed);
streamMixScratch.clear(0, 0, numSamples);
streamMixScratch.clear(1, 0, numSamples);
if (ig)
for (int ch = 0; ch < 2; ++ch)
streamMixScratch.addFrom(ch, 0, guitarMix,
juce::jmin(ch, guitarMix.getNumChannels() - 1), 0, numSamples);
if (ib && backingBuf != nullptr && backingFrames > 0)
{
const int n = juce::jmin(backingFrames, numSamples);
for (int ch = 0; ch < 2; ++ch)
streamMixScratch.addFrom(ch, 0, *backingBuf,
juce::jmin(ch, backingBuf->getNumChannels() - 1), 0, n, backingVol);
}
streamMixScratch.applyGain(0, 0, numSamples, gain);
streamMixScratch.applyGain(1, 0, numSamples, gain);
const float peak = juce::jmax(streamMixScratch.getMagnitude(0, 0, numSamples),
streamMixScratch.getMagnitude(1, 0, numSamples));
streamSinkLevel.store(peak, std::memory_order_relaxed);
packStereoIntoRing(streamMixScratch, numSamples, streamSink.ring, streamSink.writeIndex);
}
void AudioEngine::streamSinkCallback(float* const* outputData, int numOutputChannels, int numSamples)
{
const juce::ScopedNoDenormals noDenormals;
if (numOutputChannels <= 0) return;
juce::AudioBuffer<float> buffer(outputData, numOutputChannels, numSamples);
buffer.clear();
constexpr uint64_t kMask = (uint64_t) kOutputRingFrames - 1;
constexpr uint64_t kCap = (uint64_t) kOutputRingFrames;
const int scratchCap = (int) streamSink.pullScratchL.size();
const int outSamples = juce::jmin(numSamples, scratchCap);
uint64_t r = streamSink.readIndex.load(std::memory_order_relaxed);
const uint64_t w = streamSink.writeIndex.load(std::memory_order_acquire);
if (w < r) { r = w; streamSink.readIndex.store(r, std::memory_order_relaxed); }
if ((w - r) > kCap)
{
r = w - kCap;
streamSink.readIndex.store(r, std::memory_order_relaxed);
streamSink.overflowCount.fetch_add(1, std::memory_order_relaxed);
}
const uint64_t available = w - r;
const int pullCount = juce::jmin(outSamples, (int) available);
const int consumeCount = juce::jmin(numSamples, (int) available);
const int copyChannels = juce::jmin(numOutputChannels, 2);
for (int i = 0; i < pullCount; ++i)
{
const uint64_t slot = (r + (uint64_t) i) & kMask;
float l, rr;
unpackLR(streamSink.ring[(size_t) slot].load(std::memory_order_relaxed), l, rr);
buffer.setSample(0, i, l);
if (copyChannels > 1) buffer.setSample(1, i, rr);
}
if (pullCount < outSamples)
streamSink.underflowCount.fetch_add(1, std::memory_order_relaxed);
streamSink.readIndex.store(r + (uint64_t) consumeCount, std::memory_order_release);
}
void AudioEngine::streamSinkAboutToStart(juce::AudioIODevice* device)
{
if (device == nullptr) return;
const int bs = device->getCurrentBufferSizeSamples();
double sr = device->getCurrentSampleRate();
if (sr <= 0.0) sr = currentSampleRate.load(std::memory_order_relaxed);
streamSink.blockSize.store(bs, std::memory_order_relaxed);
streamSink.sampleRate.store(sr, std::memory_order_relaxed);
const int cap = juce::jmax(bs, 2048);
if ((int) streamSink.pullScratchL.size() < cap) streamSink.pullScratchL.assign((size_t) cap, 0.0f);
if ((int) streamSink.pullScratchR.size() < cap) streamSink.pullScratchR.assign((size_t) cap, 0.0f);
streamSink.writeIndex.store(0, std::memory_order_relaxed);
streamSink.readIndex.store(0, std::memory_order_relaxed);
streamSink.underflowCount.store(0, std::memory_order_relaxed);
streamSink.overflowCount.store(0, std::memory_order_relaxed);
for (auto& v : streamSink.ring) v.store(0, std::memory_order_relaxed);
}
void AudioEngine::streamSinkStopped()
{
// Fires on any stop of the stream device — an unplanned loss (unplug / driver
// reset) as well as our own teardown. Mark inactive so the producer stops
// filling a now-consumer-less ring and the meter clears; desiredTypeName/Name
// are deliberately left intact so reopenDesiredStreamSink() can restore it.
streamSink.active.store(false, std::memory_order_release);
streamSinkLevel.store(0.0f, std::memory_order_relaxed);
}
juce::String AudioEngine::setStreamOutputDevice(const juce::String& typeName, const juce::String& deviceName)
{
// Control-thread only. Opens an OUTPUT-only device on the stream sink's own
// AudioDeviceManager and attaches the drain callback. Mirrors applySplitSetup's
// output open. v1 requires the sink's nominal SR to match the engine rate (no
// async resampler yet — that's PR3); a mismatch is rejected with a clear error.
streamSink.desiredTypeName = typeName;
streamSink.desiredDeviceName = deviceName;
// Stop the producer from writing the ring while we (re)configure the device:
// setAudioDeviceSetup() below drives streamSinkAboutToStart(), which resets the
// ring indices and slots. Clearing `active` first stops the main callback from
// STARTING new pushes. A producer block already past the active-check can still
// finish one push, but the device close/reopen takes far longer than a single
// audio block, so that in-flight push completes well before about-to-start runs.
// Worst case is therefore one imperfect block on the STREAM bus (never the local
// monitor) during a manual device switch — atomic, no data race, no UAF. We only
// re-arm `active` after a fully clean open.
streamSink.active.store(false, std::memory_order_release);
// Any failure below: detach/close the half-open device AND drop the desired
// intent. A deterministic failure (e.g. SR mismatch) is then NOT retried on every
// startAudio(), and the engine never reports active with no device behind it. The
// renderer keeps its own persisted choice and re-applies it, so nothing is lost.
auto fail = [this](const juce::String& msg) -> juce::String {
closeStreamSinkDevice();
streamSink.desiredTypeName = {};
streamSink.desiredDeviceName = {};
return msg;
};
if (! streamSink.initialised)
{
streamSink.manager.initialise(0, 2, nullptr, false);
streamSink.initialised = true;
}
juce::AudioIODeviceType* outType = nullptr;
for (auto* t : streamSink.manager.getAvailableDeviceTypes())
if (t->getTypeName() == typeName) { outType = t; break; }
if (! outType) return fail("Stream output device type not found: " + typeName);
try {
if (auto* cur = streamSink.manager.getCurrentDeviceTypeObject())
{
if (cur->getTypeName() != typeName)
streamSink.manager.setCurrentAudioDeviceType(typeName, true);
}
else streamSink.manager.setCurrentAudioDeviceType(typeName, true);
} catch (...) { return fail("setCurrentAudioDeviceType threw for stream output type '" + typeName + "'"); }
juce::String resolved = deviceName;
if (resolved.isEmpty())
{
auto names = outType->getDeviceNames(false);
if (names.size() > 0) resolved = names[0];
}
juce::AudioDeviceManager::AudioDeviceSetup setup;
setup.inputDeviceName = "";
setup.outputDeviceName = resolved;
setup.sampleRate = currentSampleRate.load(std::memory_order_relaxed);
setup.bufferSize = outputBlockSize.load(std::memory_order_relaxed);
setup.useDefaultInputChannels = false;
setup.useDefaultOutputChannels = false;
setup.inputChannels.clear();
setup.outputChannels.setRange(0, 2, true);
juce::String err;
try { err = streamSink.manager.setAudioDeviceSetup(setup, true); }
catch (...) { return fail("stream output setAudioDeviceSetup threw"); }
if (err.isNotEmpty()) return fail("stream output setup: " + err);
auto* dev = streamSink.manager.getCurrentAudioDevice();
if (! dev) return fail("no stream output device after setup");
const double devSr = dev->getCurrentSampleRate();
const double engineSr = currentSampleRate.load(std::memory_order_relaxed);
if (engineSr > 0.0 && std::abs(devSr - engineSr) > 0.5)
return fail("Stream output sample rate (" + juce::String(devSr)
+ ") must match the engine rate (" + juce::String(engineSr)
+ "). Pick a device that supports " + juce::String(engineSr) + " Hz.");
streamSink.callback.engine = this;
if (! streamSink.callbackRegistered)
{
streamSink.manager.addAudioCallback(&streamSink.callback);
streamSink.callbackRegistered = true;
}
streamSink.active.store(true, std::memory_order_release);
fprintf(stderr, "[AudioEngine] stream output active: %s (%s)\n",
resolved.toRawUTF8(), typeName.toRawUTF8());
return {};
}
void AudioEngine::closeStreamSinkDevice()
{
// Detach the drain callback and close the device, leaving desiredTypeName/Name
// intact so startAudio()/reopenDesiredStreamSink() can restore it. active=false
// first so the producer stops pushing; removeAudioCallback() then blocks until
// the consumer callback is no longer in flight, so the ring/device go quiescent
// before close. Idempotent — safe when nothing is open.
streamSink.active.store(false, std::memory_order_release);
if (streamSink.callbackRegistered)
{
streamSink.manager.removeAudioCallback(&streamSink.callback);
streamSink.callbackRegistered = false;
}
try { streamSink.manager.closeAudioDevice(); } catch (...) {}
streamSinkLevel.store(0.0f, std::memory_order_relaxed);
}
void AudioEngine::clearStreamOutput()
{
closeStreamSinkDevice();
streamSink.desiredTypeName = {};
streamSink.desiredDeviceName = {};
}
void AudioEngine::reopenDesiredStreamSink()
{
// Copy the intent first: setStreamOutputDevice() mutates desiredTypeName/Name
// (and clears them on failure), so don't pass the members in by reference.
const juce::String t = streamSink.desiredTypeName;
const juce::String d = streamSink.desiredDeviceName;
if (d.isEmpty() && t.isEmpty()) return;
const juce::String err = setStreamOutputDevice(t, d);
if (err.isNotEmpty())
fprintf(stderr, "[AudioEngine] reopenDesiredStreamSink failed: %s\n", err.toRawUTF8());
}
void AudioEngine::audioDeviceIOCallbackWithContext(
const float* const* inputData, int numInputChannels,
float* const* outputData, int numOutputChannels,
int numSamples, const juce::AudioIODeviceCallbackContext&)
{
// Flush denormals (FTZ/DAZ) for the ENTIRE realtime callback. The chain is
// full of IIR state (NAM, cab IRs, VST amps/EQ/comp); after each note that
// state decays toward zero and lands in the denormal range, where every op
// is 10-100× slower — producing sporadic CPU spikes → buffer underruns heard
// as random "scratches" + frame stutter. Scoped so it only affects this path.
const juce::ScopedNoDenormals noDenormals;
// Publish that the callback body is executing so removeSource() and deferred-
// release reclamation know when no source is being processed (the body is
// quiescent) and a removed source can be safely released. Index 0 = primary.
callbacksInFlight[0].fetch_add(1, std::memory_order_acq_rel);
const bool duplex = duplexMode.load(std::memory_order_relaxed);
// Duplex writes outputData directly. Split runs DSP into a private 2-channel
// scratch and pushes the result to outputPendingRing for OutputCallback.
juce::AudioBuffer<float> buffer;
if (duplex)
{
buffer.setDataToReferTo(outputData, numOutputChannels, numSamples);
}
else
{
// outputBackingBuffer is pre-sized to inputBlockSize in
// audioDeviceAboutToStart(); never realloc on the RT thread. A
// reconfig race could transiently deliver a larger numSamples —
// clamp it (drop the tail) so the rest of the callback operates
// strictly within the allocated scratch.
const int scratchCap = outputBackingBuffer.getNumSamples();
if (numSamples > scratchCap)
numSamples = scratchCap;
outputBackingBuffer.clear(0, 0, numSamples);
outputBackingBuffer.clear(1, 0, numSamples);
buffer.setDataToReferTo(outputBackingBuffer.getArrayOfWritePointers(), 2, numSamples);
}
const int effectiveOutputChannels = duplex ? numOutputChannels : 2;
// Per-source capture + detect + monitor.
//
// Fast path — exactly one active source: process it in place on `buffer`,
// byte-identical to the single-pipeline engine (channel select / mono mix +
// input gain, input metering, ML + input-ring feed, noise gate, YIN + raw-ring
// feed, tone chain + NaN scrub, monitor mute, chain gain, tone polish).
//
// Multi-source path: each active source renders its own 2-channel monitor into
// sourceMonitorScratch (each builds its mono from its bound input channel +
// feeds its own rings/detectors/verifier), and the monitors are summed into the
// output. Each source scores its OWN arrangement independently.
// This is the PRIMARY input device's callback: it mixes only sources bound to
// device 0 (deviceKey == 0). Sources bound to an additional input device
// (Phase 2) are processed by that device's own callback on its own hardware
// clock — never here. With no extra device every source is deviceKey 0, so
// this is the single-device path unchanged. See mixSourcesForDevice for the
// fast-path / multi-source rationale; it is shared with each extra callback.
mixSourcesForDevice(0, inputData, numInputChannels, buffer, sourceMonitorScratch,
effectiveOutputChannels, numSamples);
// Duplex mixes backing + applies output gain + meters here.
// Split defers all three to OutputCallback (output device's clock).
if (duplex)
{
// Stream sink (producer): snapshot the guitar monitor mix BEFORE backing is
// added, so the stream submix can carry guitar independent of the local mix.
const bool streamActive = streamSink.active.load(std::memory_order_acquire);
int streamBackingFrames = 0;
float streamBackingVol = 0.0f;
bool streamBackingOn = false;
if (streamActive && streamGuitarScratch.getNumSamples() >= numSamples)
for (int ch = 0; ch < 2; ++ch)
streamGuitarScratch.copyFrom(ch, 0, buffer,
juce::jmin(ch, buffer.getNumChannels() - 1), 0, numSamples);
const juce::ScopedTryLock sl(backingLock);
if (sl.isLocked() && backingTransport && backingPlaying.load())
{
const int outSamples = renderBackingBlockLocked(numSamples);
const float bVol = backingVolume.load();
streamBackingFrames = outSamples; streamBackingVol = bVol; streamBackingOn = true;
const int mixChannels = juce::jmin(numOutputChannels, 2);
float backingLevelSq = 0.0f;
for (int ch = 0; ch < mixChannels; ++ch)
{
const float* const src = backingBuffer.getReadPointer(ch);
float sumSquares = 0.0f;
for (int i = 0; i < outSamples; ++i)
sumSquares += src[i] * src[i];
const float channelRms = (outSamples > 0)
? std::sqrt(sumSquares / outSamples) * bVol
: 0.0f;
backingLevelSq += channelRms * channelRms;
buffer.addFrom(ch, 0, backingBuffer, ch, 0, outSamples, bVol);
}
currentBackingLevel.store((mixChannels > 0)
? std::sqrt(backingLevelSq / mixChannels)
: 0.0f);
}
else
{
currentBackingLevel.store(0.0f);
}
// Stream sink: compose + push the stream submix (guitar snapshot + backing)
// BEFORE the local master output gain, so the stream level is independent.
if (streamActive)
composeAndPushStreamMix(streamGuitarScratch,
streamBackingOn ? &backingBuffer : nullptr,
streamBackingFrames, streamBackingVol, numSamples);
// Apply output gain
buffer.applyGain(outputGain.load());
// Output metering
float peak = 0.0f;
for (int ch = 0; ch < numOutputChannels; ++ch)
peak = juce::jmax(peak, buffer.getMagnitude(ch, 0, numSamples));
currentOutputLevel.store(peak);
float prevPeak = outputPeak.load();
if (peak > prevPeak) outputPeak.store(peak);
}
else
{
// Split: push processed stereo (pre-backing, pre-output-gain) into the
// primary ring. OutputCallback adds backing + output gain on its own clock
// and sums every extra-device ring alongside this one.
packStereoIntoRing(buffer, numSamples, outputPendingRing, outputRingWriteIndex);
}
// Body done — this callback is no longer processing a source. Pairs with
// removeSource()/reclaimPendingReleases() acquire loads. Index 0 = primary.
callbacksInFlight[0].fetch_sub(1, std::memory_order_acq_rel);
}
int AudioEngine::mixSourcesForDevice(int deviceKey, const float* const* inputData, int numInputChannels,
juce::AudioBuffer<float>& mixBuf, juce::AudioBuffer<float>& monitorScratch,
int effectiveOutputChannels, int numSamples)
{
// Snapshot each source's active flag ONCE so the count and the process/mix
// passes are consistent within this block — a concurrent add/removeSource
// flipping a flag between two reads must not change which branch runs.
// removeSource waits for all callback bodies to drain before releasing, so a
// source snapshotted active here is safe even if deactivated an instant later.
bool act[kMaxSources];
int firstActive = -1, activeCount = 0;
for (int i = 0; i < kMaxSources; ++i)
{
act[i] = sources[(size_t) i]->isActive()
&& sources[(size_t) i]->getDeviceKey() == deviceKey;
if (act[i]) { ++activeCount; if (firstActive < 0) firstActive = i; }
}
if (activeCount == 0)
{
// No source on this device → silence. An extra device with no bound source
// contributes nothing to the output sum. The primary always has sources[0]
// (deviceKey 0, active from construction), so it never reaches this branch.
for (int ch = 0; ch < effectiveOutputChannels; ++ch)
mixBuf.clear(ch, 0, numSamples);
return 0;
}
if (activeCount == 1)
{
// Fast path — exactly one source: process in place on mixBuf, byte-
// identical to the single-pipeline engine (channel select / mono mix +
// input gain, metering, ML + ring feed, gate, YIN, tone chain, monitor).
sources[(size_t) firstActive]
->processBlock(inputData, numInputChannels, mixBuf, effectiveOutputChannels, numSamples);
return 1;
}
// Multi-source: each renders its own 2-channel monitor into monitorScratch
// (each builds its mono from its bound channel + feeds its own rings /
// detectors / verifier), summed to STEREO (0/1). A >2-channel output keeps
// channels 2+ silent in multi-source mode (the fast path still broadcasts).
for (int ch = 0; ch < effectiveOutputChannels; ++ch)
mixBuf.clear(ch, 0, numSamples);
const int mixCh = juce::jmin(effectiveOutputChannels, 2);
const int n = juce::jmin(numSamples, monitorScratch.getNumSamples());
for (int i = 0; i < kMaxSources; ++i)
{
if (! act[i]) continue;
sources[(size_t) i]->processBlock(inputData, numInputChannels, monitorScratch, 2, n);
for (int ch = 0; ch < mixCh; ++ch)
mixBuf.addFrom(ch, 0, monitorScratch, ch, 0, n);
}
return activeCount;
}
void AudioEngine::packStereoIntoRing(const juce::AudioBuffer<float>& buf, int numSamples,
std::array<std::atomic<uint64_t>, kOutputRingFrames>& ring,
std::atomic<uint64_t>& writeIndex)
{
// Strict SPSC: the producer (one device callback) only ever writes writeIndex;
// the consumer (audioOutputCallback) is the sole writer of the paired
// readIndex. Drop-oldest = letting writeIndex lap the buffer; the consumer
// advances readIndex when it observes (w - r) > cap. The single packed store
// prevents an L/R tear when the producer wraps mid-callback (relaxed because
// ordering is established by the release on writeIndex below).
constexpr uint64_t kMask = (uint64_t) kOutputRingFrames - 1;
const uint64_t w = writeIndex.load(std::memory_order_relaxed);
const float* L = buf.getReadPointer(0);
const float* R = buf.getReadPointer(1);
for (int i = 0; i < numSamples; ++i)
{
const uint64_t slot = (w + (uint64_t) i) & kMask;
ring[slot].store(packLR(L[i], R[i]), std::memory_order_relaxed);
}
writeIndex.store(w + (uint64_t) numSamples, std::memory_order_release);
}
void AudioEngine::extraInputCallback(int slot, const float* const* inputData, int numInputChannels, int numSamples)
{
if (slot < 0 || slot >= kMaxExtraInputDevices) return;
InputDeviceSlot& s = extraInputs[(size_t) slot];
if (! s.active.load(std::memory_order_acquire)) return;
callbacksInFlight[(size_t) s.deviceKey].fetch_add(1, std::memory_order_acq_rel);
// Clamp to the per-slot scratch sized in extraInputAboutToStart so the hot
// loop never allocates if a reconfig race delivers a larger block.
const int cap = s.fanScratch.getNumSamples();
if (numSamples > cap) numSamples = cap;
juce::AudioBuffer<float> mix;
mix.setDataToReferTo(s.fanScratch.getArrayOfWritePointers(), 2, numSamples);
mixSourcesForDevice(s.deviceKey, inputData, numInputChannels, mix, s.monitorScratch, 2, numSamples);
packStereoIntoRing(mix, numSamples, s.ring, s.writeIndex);
callbacksInFlight[(size_t) s.deviceKey].fetch_sub(1, std::memory_order_acq_rel);
}
void AudioEngine::extraInputAboutToStart(int slot, juce::AudioIODevice* device)
{
if (slot < 0 || slot >= kMaxExtraInputDevices || device == nullptr) return;
InputDeviceSlot& s = extraInputs[(size_t) slot];
const int bs = device->getCurrentBufferSizeSamples();
s.blockSize.store(bs, std::memory_order_relaxed);
// Prepare against this DEVICE's actual sample rate — the source of truth.
// bindInputDevice forces it to (and verifies it equals) the engine rate, so the
// verifier (which reads the engine-wide currentSampleRate) and the detectors
// agree. Reading the device here rather than assuming currentSampleRate keeps
// the prepare correct even if a future path opens it differently.
double sr = device->getCurrentSampleRate();
if (sr <= 0.0) sr = currentSampleRate.load(std::memory_order_relaxed);
s.sampleRate.store(sr, std::memory_order_relaxed);
// Size per-slot scratch generously (cold-start guard) on this device-management
// thread — never the RT thread.
const int cap = juce::jmax(bs, 2048);
s.fanScratch.setSize(2, cap, false, false, true);
s.monitorScratch.setSize(2, cap, false, false, true);
s.fanScratch.clear();
s.monitorScratch.clear();
s.writeIndex.store(0, std::memory_order_relaxed);
s.readIndex.store(0, std::memory_order_relaxed);
for (auto& v : s.ring) v.store(0, std::memory_order_relaxed);
// Capture-latency correction: the renderer's playhead is aligned to the PRIMARY
// device's input latency, but this extra device captures with a different
// latency, so its audio sits at a different song-time than the playhead assumes.
// Set its sources' verifier offset to (extra primary) input latency so they
// match this device's just-captured audio against the right chart notes.
int extraLatSamples = device->getInputLatencyInSamples();
int primaryLatSamples = 0;
if (auto* pdev = inputDeviceManager.getCurrentAudioDevice())
primaryLatSamples = pdev->getInputLatencyInSamples();
// (extra primary) reported input latency. On JACK/PipeWire this is 0 (no
// latency reported); the residual per-device offset is instead dialed in by the
// user via setSourceVerifierOffset (a stable auto-measure isn't possible — the
// value is device-specific and signal-level-confounded). 0 here = no auto shift.
const double deltaSec = (sr > 0.0) ? (double) (extraLatSamples - primaryLatSamples) / sr : 0.0;
s.latencyDeltaSec.store(deltaSec, std::memory_order_relaxed);
// Prepare each source bound to this device so its verifier/detectors run, and
// apply the latency correction.
{
std::lock_guard<std::mutex> lock(sourcesMutex);
for (auto& src : sources)
if (src->isActive() && src->getDeviceKey() == s.deviceKey)
{
src->prepare(sr, bs);
src->setVerifierAutoOffset(deltaSec);
}
}
s.active.store(true, std::memory_order_release);
}
void AudioEngine::extraInputStopped(int slot)
{
if (slot < 0 || slot >= kMaxExtraInputDevices) return;
InputDeviceSlot& s = extraInputs[(size_t) slot];
// JUCE blocks for this slot's callback thread before firing this, so the
// slot's body is quiescent. Hide it from the output sum, then release ITS
// sources (no other callback touches them — they all filter by deviceKey).
s.active.store(false, std::memory_order_release);
{
std::lock_guard<std::mutex> lock(sourcesMutex);
// PERMANENT unbind (the user removed this device): the slot will never
// re-open, so DEACTIVATE its sources too — leaving them "active" would strand
// pooled slots no callback can ever service (a ghost detector in listSources).
// A TRANSIENT close (stopAudio/reconfigure/unplug) only releases them, so
// startAudio()'s re-open resumes them in place. Read the atomic flag (set by
// the control-thread unbind) rather than the juce::String desiredDeviceName,
// which this device-thread path must not race on.
const bool permanent = s.permanentUnbind.load(std::memory_order_acquire);
for (auto& src : sources)
if (src->isActive() && src->getDeviceKey() == s.deviceKey)
{
src->releaseResources();
// Zero the meters so getSourceLevels() reports silence while the
// device is gone — otherwise the renderer's per-source silence gate
// treats a stopped/unplugged input as still hearing audio (the last
// non-zero level latches). releaseResources() doesn't touch them.
src->resetInputMeters();
if (permanent) src->setActive(false);
}
// Retry any removeSource() cleanup that was deferred waiting for callbacks to
// drain. With this slot's callback now stopped, callbacksInFlight may finally
// be 0; audioDeviceStopped() (primary) might never observe that window during
// multi-device playback, so reclaim here too or a pending slot can leak until
// the next add/remove.
reclaimPendingReleases();
}
s.writeIndex.store(0, std::memory_order_relaxed);
s.readIndex.store(0, std::memory_order_relaxed);
}
int AudioEngine::activeExtraInputCount() const
{
int n = 0;
for (const auto& s : extraInputs)
if (s.active.load(std::memory_order_acquire)) ++n;
return n;
}
juce::String AudioEngine::bindInputDevice(int deviceKey, const juce::String& deviceName)
{
if (deviceKey < 1 || deviceKey > kMaxExtraInputDevices)
return "deviceKey out of range";
const int slot = deviceKey - 1;
InputDeviceSlot& s = extraInputs[(size_t) slot];
if (s.active.load(std::memory_order_acquire))
return "device slot already bound";
// Reject binding the SAME physical device into a second slot. Two callbacks
// reading one interface is wasteful (and fails outright on exclusive drivers);
// multiple sources that want this device should share its one deviceKey and pick
// different channels instead. Checks both open + deferred (desired) slots.
for (int other = 0; other < kMaxExtraInputDevices; ++other)
if (other != slot && extraInputs[(size_t) other].desiredDeviceName == deviceName)
return "device already bound to another input slot";
// Reject binding the device that is the PRIMARY input — it is already "Main", and
// opening it on this slot's manager too would double-open one interface on two
// managers (fatal on exclusive backends). Critically this also guards the REOPEN
// path: if the user makes a bound extra device the new main input, the preserved
// intent must NOT resurrect it as an extra (reopenDesiredExtraInputs() then drops
// the now-invalid binding via its failure handling).
if (auto* primary = inputDeviceManager.getCurrentAudioDevice())
if (primary->getName() == deviceName)
return "device is the primary input — use Main, not an extra slot";
// An extra input device requires SPLIT mode: the output callback owns the mix +
// backing + gain and sums every device ring. In DUPLEX the primary device owns
// both directions and the output manager is closed, so we cannot just flip the
// flag — that would leave the output mix path absent (silent / unrouted). Reject
// here so the renderer reconfigures to a separate output device first. Checked
// BEFORE the deferred path below — startAudio()'s reopen also skips duplex, so a
// deferred bind in duplex would silently never come up while reporting success.
if (duplexMode.load(std::memory_order_relaxed))
return "extra input requires split mode — select a separate output device first";
// Deregister any STALE callback BEFORE touching the manager. An earlier unplanned
// stop (USB unplug / backend restart) leaves s.callback registered; if we opened
// the manager (initialise / setAudioDeviceSetup) with it still attached, JUCE
// could dispatch it on the default/new device mid-setup — processing the wrong
// hardware, or even firing extraInputAboutToStart() during a stopped-engine
// validation open. Idempotent no-op when not registered.
s.manager.removeAudioCallback(&s.callback);
// Open `deviceName` input-only on this slot's own manager. initialise first so
// the manager has a device type, then switch to the requested input device with
// all its channels (the source picks a channel within).
s.manager.initialiseWithDefaultDevices(2, 0);
// The device name may belong to a device TYPE (ALSA / JACK / CoreAudio / …)
// different from the slot manager's default — a JACK device name won't resolve
// under ALSA and vice-versa ("No such device"). Find the type that actually
// lists this input device and switch the slot manager to it. Prefer the primary
// manager's current type (the devices the user already sees working).
juce::String chosenType;
if (auto* pt = inputDeviceManager.getCurrentDeviceTypeObject())
{
pt->scanForDevices();
if (pt->getDeviceNames(true).contains(deviceName))
chosenType = pt->getTypeName();
}
if (chosenType.isEmpty())
for (auto* t : s.manager.getAvailableDeviceTypes())
{
t->scanForDevices();
if (t->getDeviceNames(true).contains(deviceName)) { chosenType = t->getTypeName(); break; }
}
// setCurrentAudioDeviceType can THROW from inside some JUCE backends (ASIO, and
// misconfigured JACK/CoreAudio) — setAudioDevices() guards it for the primary, so
// this path must too, or a bad backend terminates the process instead of
// returning an error to the renderer. Close the slot manager on failure.
if (chosenType.isNotEmpty())
{
try { s.manager.setCurrentAudioDeviceType(chosenType, true); }
catch (...) { s.manager.closeAudioDevice(); return "extra-input setCurrentAudioDeviceType threw"; }
}
juce::AudioDeviceManager::AudioDeviceSetup setup;
s.manager.getAudioDeviceSetup(setup);
setup.inputDeviceName = deviceName;
setup.outputDeviceName = "";
// Open ALL of the device's capture channels (not just the default first pair),
// so a source bound to channel 2+ of a multi-channel extra interface actually
// receives audio — mirrors the primary device's explicit full-range open.
int inputChannelCount = 0;
if (auto* t = s.manager.getCurrentDeviceTypeObject())
{
std::unique_ptr<juce::AudioIODevice> probe(t->createDevice({}, deviceName));
if (probe) inputChannelCount = probe->getInputChannelNames().size();
}
if (inputChannelCount <= 0) inputChannelCount = 2;
setup.inputChannels.setRange(0, inputChannelCount, true);
setup.useDefaultInputChannels = false;
setup.useDefaultOutputChannels = false;
// Force the extra device to the ENGINE's sample rate. Each SourceChain's
// verifier/detectors read the engine-wide currentSampleRate (bound by
// reference at construction), so an extra input running at a different rate
// (e.g. a 44.1 kHz device in a 48 kHz engine) would be scored on the wrong
// clock — skewing pitch/timing for every source bound to it. Matching the
// engine rate here (the OS/driver resamples if needed) keeps them coherent; a
// device that cannot do this rate fails the setup below and is rejected.
const double engineSr = currentSampleRate.load(std::memory_order_relaxed);
if (engineSr > 0.0)
setup.sampleRate = engineSr;
// initialiseWithDefaultDevices above may have opened a default capture device on
// this slot manager; every failure path below must close it, or a failed bind
// leaves the interface captured until engine teardown (fatal on exclusive
// backends + breaks retries / other apps).
juce::String err;
try { err = s.manager.setAudioDeviceSetup(setup, true); }
catch (...) { s.manager.closeAudioDevice(); return "extra-input setAudioDeviceSetup threw"; }
if (err.isNotEmpty())
{
s.manager.closeAudioDevice();
return "extra input: " + err + (chosenType.isEmpty() ? " (no type lists this device)" : " (type " + chosenType + ")");
}
auto* extraDev = s.manager.getCurrentAudioDevice();
if (extraDev == nullptr)
{
s.manager.closeAudioDevice();
return "extra input device did not open";
}
// Some backends accept the rate request but actually open at a different rate.
// Since the SourceChain verifier reads the engine-wide currentSampleRate, a
// mismatch would score this device on the wrong clock — reject rather than
// ship silently-wrong timing. (Tolerant of a sub-Hz rounding difference.)
if (engineSr > 0.0 && std::abs(extraDev->getCurrentSampleRate() - engineSr) > 1.0)
{
const juce::String got = juce::String(extraDev->getCurrentSampleRate());
s.manager.closeAudioDevice();
return "extra input opened at " + got + " Hz, not the engine rate " + juce::String(engineSr) + " Hz";
}
// The device opened + validated. Record the INTENT now (not before the fallible
// open above), so it drives re-open across a reconfigure without lingering after
// a failed attach. Clear the permanent-unbind flag: a future stop on this slot is
// transient (resume) until the user explicitly unbinds again.
s.desiredDeviceName = deviceName;
s.permanentUnbind.store(false, std::memory_order_release);
// If the engine is not running, we opened only to VALIDATE eagerly (so an
// unplugged / wrong-rate device fails the bind NOW instead of silently dropping
// at the next startAudio). Close it again so a stopped engine never leaves an
// interface capturing in the background; reopenDesiredExtraInputs() re-opens it
// (and re-attaches the callback) when the engine next starts.
if (! audioRunning.load(std::memory_order_relaxed))
{
s.manager.closeAudioDevice();
return {};
}
// Attach the callback — fires extraInputAboutToStart (prepares + flips active).
// Any stale registration was already removed before the open above, so this
// registers exactly once.
s.manager.addAudioCallback(&s.callback);
return {};
}
bool AudioEngine::unbindInputDevice(int deviceKey)
{
if (deviceKey < 1 || deviceKey > kMaxExtraInputDevices)
return false;
const int slot = deviceKey - 1;
// User-initiated unbind: mark it PERMANENT (the device thread's extraInputStopped
// reads this atomic to deactivate the slot's sources) BEFORE closing, and forget
// the INTENT so a later startAudio() does not resurrect a device the user
// deliberately removed.
extraInputs[(size_t) slot].permanentUnbind.store(true, std::memory_order_release);
extraInputs[(size_t) slot].desiredDeviceName = {};
// If the device is open, closing it fires extraInputStopped(), which — with the
// intent now cleared — deactivates this deviceKey's sources. If it was ALREADY
// closed (e.g. a prior stopAudio() kept the intent + left the sources active for
// a resume that will now never come), extraInputStopped() will NOT run, so we
// must deactivate them here — otherwise they linger as ghost sources stranding
// pool slots and showing in listSources().
if (! closeExtraInputDevice(slot))
{
std::lock_guard<std::mutex> lock(sourcesMutex);
for (auto& src : sources)
if (src->isActive() && src->getDeviceKey() == deviceKey)
{
src->releaseResources();
src->setActive(false);
}
}
return true;
}
// Close the device open on a slot WITHOUT forgetting desiredDeviceName, so
// startAudio() re-opens it. Used by stopAudio()/reconfigure (transient close) — the
// public unbindInputDevice() clears the intent first (permanent removal).
bool AudioEngine::closeExtraInputDevice(int slot)
{
if (slot < 0 || slot >= kMaxExtraInputDevices)
return false;
InputDeviceSlot& s = extraInputs[(size_t) slot];
const bool wasActive = s.active.load(std::memory_order_acquire);
// Close + deregister UNCONDITIONALLY (not gated on `active`). An UNPLANNED stop
// (USB unplug / backend restart) fires extraInputStopped() — flipping active
// false — yet leaves the manager owning a (possibly auto-recovering) device and
// s.callback still registered. If we no-oped on !active, stopAudio()/reconfigure
// would never release it and the backend could resume callbacks after the engine
// is supposedly stopped. Both calls are idempotent when already closed/absent.
// For an ACTIVE slot, closeAudioDevice() blocks for the callback thread then fires
// audioDeviceStopped → extraInputStopped (releases this device's sources).
s.manager.closeAudioDevice();
s.manager.removeAudioCallback(&s.callback);
return wasActive;
}
// Re-open every slot that has a desiredDeviceName but is not currently active — the
// post-(re)start restore of extra inputs. No-op in duplex (extras need split) and
// when nothing is desired (the single-device path). Called from startAudio().
void AudioEngine::reopenDesiredExtraInputs()
{
if (duplexMode.load(std::memory_order_relaxed))
{
// Duplex has no consumer for extra-device rings, so the desired extras cannot
// open right now. PRESERVE their intent (so a later switch back to split
// auto-restores them — setAudioDevices() promises bindings survive a device
// change) and keep their sources active to resume in place; but ZERO their
// meters so getSourceLevels() reports silence while the device is gone (the
// renderer's per-source silence gate then won't treat a temporarily-unavailable
// source as still hearing audio, and there is no false detection). The sources
// are not "ghosts": split-restore reopens the device and they resume.
for (int dk = 1; dk <= kMaxExtraInputDevices; ++dk)
{
if (extraInputs[(size_t) (dk - 1)].desiredDeviceName.isEmpty())
continue;
std::lock_guard<std::mutex> lock(sourcesMutex);
for (auto& src : sources)
if (src->isActive() && src->getDeviceKey() == dk)
src->resetInputMeters();
}
return;
}
for (int dk = 1; dk <= kMaxExtraInputDevices; ++dk)
{
InputDeviceSlot& s = extraInputs[(size_t) (dk - 1)];
if (s.desiredDeviceName.isEmpty() || s.active.load(std::memory_order_acquire))
continue;
const juce::String err = bindInputDevice(dk, s.desiredDeviceName); // re-sets desired (idempotent)
if (err.isNotEmpty())
{
// Reopen failed — the interface was unplugged, or no longer supports the
// engine rate. The transient close kept this slot's sources ACTIVE to
// resume; since they now never will, give up cleanly: drop the intent and
// deactivate them so they do not linger as ghost sources stranding pool
// slots. The renderer re-binds + re-adds if the device returns.
s.desiredDeviceName = {};
std::lock_guard<std::mutex> lock(sourcesMutex);
for (auto& src : sources)
if (src->isActive() && src->getDeviceKey() == dk)
src->setActive(false);
}
}
}
void AudioEngine::audioOutputCallback(const float* const* /*inputData*/,
int /*numInputChannels*/,
float* const* outputData,
int numOutputChannels,
int numSamples)
{
// Split-mode output clock: this callback renders the backing track (phase
// vocoder + loudness leveler) and mixes it with the chain output. Those
// carry IIR/decay state too, so flush denormals here as well — the primary
// callback's ScopedNoDenormals does NOT reach this separate output thread.
const juce::ScopedNoDenormals noDenormals;
juce::AudioBuffer<float> buffer(outputData, numOutputChannels, numSamples);
if (numOutputChannels <= 0)
return;
constexpr uint64_t kMask = (uint64_t) kOutputRingFrames - 1;
constexpr uint64_t kCap = (uint64_t) kOutputRingFrames;
// Clamp the working size to the scratch capacity pre-allocated in
// audioOutputAboutToStart() so the .assign() calls below never realloc
// on the RT thread when a transient oversized block arrives (mirrors
// the backing path's clamp in audioDeviceIOCallbackWithContext).
const int scratchCap = (int) outputPullScratchL.size();
const int outSamples = juce::jmin(numSamples, scratchCap);
uint64_t r = outputRingReadIndex.load(std::memory_order_relaxed);
const uint64_t w = outputRingWriteIndex.load(std::memory_order_acquire);
// If audioDeviceStopped() raced between our two loads and reset both
// indices to 0, we can observe w < r. Treat that as an empty ring
// and resync — without this, the unsigned (w - r) wraps into a huge
// positive value and falls into the catch-up branch reading stale slots.
if (w < r)
{
r = w;
outputRingReadIndex.store(r, std::memory_order_relaxed);
}
// Catch up if the producer has lapped (drop-oldest is achieved via this
// single-writer consumer-side advance, not a producer-side write to r).
if ((w - r) > kCap)
{
r = w - kCap;
outputRingReadIndex.store(r, std::memory_order_relaxed);
inputOverflowCount.fetch_add(1, std::memory_order_relaxed);
}
const uint64_t available = w - r;
const int pullCount = juce::jmin(outSamples, (int) available);
// When scratchCap clamped outSamples below numSamples (device-reconfig
// race), we still need to consume the ring frames that match the
// device callback's full block size — otherwise those extras stay
// queued and play back late, accumulating ring/output-clock skew
// until the latency is audible. Drop them from the ring without
// copying into scratch.
const int consumeCount = juce::jmin(numSamples, (int) available);
for (int i = 0; i < pullCount; ++i)
{
const uint64_t slot = (r + (uint64_t) i) & kMask;
// Single atomic load → atomic unpack of both channels (matches the
// producer's packed store) so L and R always belong to the same frame.
float l, rr;
unpackLR(outputPendingRing[slot].load(std::memory_order_relaxed), l, rr);
outputPullScratchL[(size_t) i] = l;
outputPullScratchR[(size_t) i] = rr;
}
if (pullCount < outSamples)
{
for (int i = pullCount; i < outSamples; ++i)
{
outputPullScratchL[(size_t) i] = 0.0f;
outputPullScratchR[(size_t) i] = 0.0f;
}
outputUnderflowCount.fetch_add(1, std::memory_order_relaxed);
}
outputRingReadIndex.store(r + (uint64_t) consumeCount, std::memory_order_release);
buffer.clear();
const int copyChannels = juce::jmin(numOutputChannels, 2);
for (int i = 0; i < outSamples; ++i)
{
buffer.setSample(0, i, outputPullScratchL[(size_t) i]);
if (copyChannels > 1)
buffer.setSample(1, i, outputPullScratchR[(size_t) i]);
}
// Phase 2: sum every active EXTRA input device's ring on top of the primary's
// output. Each is an independent SPSC ring fed by that device's own callback at
// its own hardware clock; the same drop-oldest catch-up absorbs its drift, so
// two separate interfaces mix cleanly with no cross-device resampling.
for (auto& s : extraInputs)
{
if (! s.active.load(std::memory_order_acquire)) continue;
uint64_t er = s.readIndex.load(std::memory_order_relaxed);
const uint64_t ew = s.writeIndex.load(std::memory_order_acquire);
if (ew < er) { er = ew; s.readIndex.store(er, std::memory_order_relaxed); }
if ((ew - er) > kCap)
{
er = ew - kCap;
s.readIndex.store(er, std::memory_order_relaxed);
s.overflowCount.fetch_add(1, std::memory_order_relaxed);
}
const uint64_t eAvail = ew - er;
const int ePull = juce::jmin(outSamples, (int) eAvail);
const int eConsume = juce::jmin(numSamples, (int) eAvail);
for (int i = 0; i < ePull; ++i)
{
const uint64_t slot = (er + (uint64_t) i) & kMask;
float l, rr;
unpackLR(s.ring[(size_t) slot].load(std::memory_order_relaxed), l, rr);
buffer.addSample(0, i, l);
if (copyChannels > 1) buffer.addSample(1, i, rr);
}
s.readIndex.store(er + (uint64_t) eConsume, std::memory_order_release);
}
// Stream sink (producer, split clock): snapshot the full guitar mix (primary
// ring + extra inputs) BEFORE backing is added.
const bool streamActive = streamSink.active.load(std::memory_order_acquire);
int streamBackingFrames = 0;
float streamBackingVol = 0.0f;
bool streamBackingOn = false;
if (streamActive && streamGuitarScratch.getNumSamples() >= numSamples)
for (int ch = 0; ch < 2; ++ch)
streamGuitarScratch.copyFrom(ch, 0, buffer,
juce::jmin(ch, buffer.getNumChannels() - 1), 0, numSamples);
{
const juce::ScopedTryLock sl(backingLock);
if (sl.isLocked() && backingTransport && backingPlaying.load())
{
// Shared with the duplex path so the two callbacks can't drift.
const int backingOut = renderBackingBlockLocked(numSamples);
const float bVol = backingVolume.load();
streamBackingFrames = backingOut; streamBackingVol = bVol; streamBackingOn = true;
// RMS, computed identically to the duplex path so getBackingLevel()
// reports the same metric regardless of which device clock is active.
// copyChannels is already capped at 2, so it doubles as the mix count.
float backingLevelSq = 0.0f;
for (int ch = 0; ch < copyChannels; ++ch)
{
const float* const src = backingBuffer.getReadPointer(ch);
float sumSquares = 0.0f;
for (int i = 0; i < backingOut; ++i)
sumSquares += src[i] * src[i];
const float channelRms = (backingOut > 0)
? std::sqrt(sumSquares / backingOut) * bVol
: 0.0f;
backingLevelSq += channelRms * channelRms;
buffer.addFrom(ch, 0, backingBuffer, ch, 0, backingOut, bVol);
}
currentBackingLevel.store((copyChannels > 0)
? std::sqrt(backingLevelSq / copyChannels)
: 0.0f);
}
else
{
currentBackingLevel.store(0.0f);
}
// Stream sink: compose + push the stream submix before the local master
// gain. Done INSIDE the backingLock scope so backingBuffer is read under the
// very lock that guards its resize (audio*AboutToStart) — matching the duplex
// path. When the tryLock failed (streamBackingOn=false) we pass a null backing
// pointer and never touch backingBuffer, so there is nothing to protect.
if (streamActive)
composeAndPushStreamMix(streamGuitarScratch,
streamBackingOn ? &backingBuffer : nullptr,
streamBackingFrames, streamBackingVol, numSamples);
}
buffer.applyGain(outputGain.load());
float peak = 0.0f;
for (int ch = 0; ch < numOutputChannels; ++ch)
peak = juce::jmax(peak, buffer.getMagnitude(ch, 0, numSamples));
currentOutputLevel.store(peak);
float prevPeak = outputPeak.load();
if (peak > prevPeak) outputPeak.store(peak);
}