refactor(audio): extract ExtraInputs (phase 5b)

Moves the additional-input-device registry — InputDeviceSlot (manager,
callback, ring, scratches, latency delta, desired-name intent,
permanent-unbind flag), bind/unbind/closeSlot/reopenDesired, the bindable
enumeration, and the per-slot device-callback trio — verbatim into
src/audio/engine/ExtraInputs.{h,cpp}. Sources are prepared/released through
the bound SourcePool (same locking as before); the primary manager reference
serves the duplicate-binding check, latency delta, and enumeration. The
slots array stays public so the split output callback's ring-drain loop is
unchanged; addSource resolves per-slot readiness via resolveForSource().

The (typeName, name) device-identity limitation moves with its honest
comment — its fix lands here later without touching the engine again
(plan §2.3). Completes phase 5; live 28-stage split-mode probe on real
devices behaves identically to pre-move.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
OmikronApex 2026-07-14 01:29:16 +02:00
parent 827f02b4b4
commit 6b0bfb7be3
5 changed files with 572 additions and 462 deletions

View File

@ -36,15 +36,7 @@ AudioEngine::AudioEngine()
// The source pool (chains, quiescence handshake) lives on SourcePool now.
// 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;
}
// Extra-input slot registry lives on ExtraInputs now.
auto result = inputDeviceManager.initialiseWithDefaultDevices(2, 2);
if (result.isNotEmpty())
@ -75,11 +67,7 @@ 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);
}
extraInputs.closeAllForShutdown();
stopAudio();
stopBacking();
}
@ -107,45 +95,7 @@ juce::Array<AudioEngine::DeviceTypeInfo> AudioEngine::getDeviceTypes()
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;
return extraInputs.listBindable();
}
juce::Array<double> AudioEngine::getSampleRates()
@ -618,7 +568,7 @@ void AudioEngine::startAudio()
// 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();
extraInputs.reopenDesired();
// Restore the streamer-mix output device too (same intent-survives-restart
// pattern). Best-effort — a failure leaves the sink inactive, never blocks.
@ -650,7 +600,7 @@ void AudioEngine::stopAudio()
// 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);
extraInputs.closeSlot(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",
@ -690,8 +640,7 @@ int AudioEngine::addSource(int inputChannel, int deviceKey)
// 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())
if (! extraInputs.resolveForSource(deviceKey).usable)
return -1;
}
@ -703,11 +652,11 @@ int AudioEngine::addSource(int inputChannel, int deviceKey)
double latencyDeltaSec = 0.0;
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);
latencyDeltaSec = es.latencyDeltaSec.load(std::memory_order_relaxed);
const auto r = extraInputs.resolveForSource(deviceKey);
deviceReady = r.ready;
sr = r.sr;
bs = r.bs;
latencyDeltaSec = r.latencyDelta;
}
return pool.addResolved(inputChannel, deviceKey, deviceReady, sr, bs, latencyDeltaSec);
}
@ -1101,345 +1050,19 @@ void AudioEngine::audioDeviceIOCallbackWithContext(
// Body done (CallbackGuard dtor) — pairs with remove()/reclaim acquire loads.
}
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;
const slopsmith::SourcePool::CallbackGuard cbGuard(pool, s.deviceKey);
// 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);
pool.mixForDevice(s.deviceKey, inputData, numInputChannels, mix, s.monitorScratch, 2, numSamples);
s.ring.push(mix.getReadPointer(0), mix.getReadPointer(1), numSamples);
}
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.ring.reset();
// 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.
pool.prepareDeviceSources(s.deviceKey, sr, bs, deltaSec, true);
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);
// PERMANENT unbind (user removed this device) deactivates its sources too;
// 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.
pool.releaseDeviceSources(s.deviceKey, true,
s.permanentUnbind.load(std::memory_order_acquire));
s.ring.resetIndices();
}
int AudioEngine::activeExtraInputCount() const
{
int n = 0;
for (const auto& s : extraInputs)
if (s.active.load(std::memory_order_acquire)) ++n;
return n;
return extraInputs.activeCount();
}
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 {};
return extraInputs.bind(deviceKey, deviceName);
}
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))
{
pool.withDeviceSources(deviceKey, [](SourceChain& s) {
s.releaseResources();
s.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;
pool.withDeviceSources(dk, [](SourceChain& s) { s.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 = {};
pool.withDeviceSources(dk, [](SourceChain& src) { src.setActive(false); });
}
}
return extraInputs.unbind(deviceKey);
}
void AudioEngine::audioOutputCallback(const float* const* /*inputData*/,
@ -1523,7 +1146,7 @@ void AudioEngine::audioOutputCallback(const float* const* /*inputData*/,
// 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)
for (auto& s : extraInputs.slots)
{
if (! s.active.load(std::memory_order_acquire)) continue;
uint64_t er = s.ring.readIndex.load(std::memory_order_relaxed);

View File

@ -8,6 +8,7 @@
#include "engine/BackingPlayer.h"
#include "engine/DeviceSetup.h"
#include "engine/SourcePool.h"
#include "engine/ExtraInputs.h"
#include "BackingLeveler.h"
#include "signalsmith-stretch.h"
#include <juce_audio_devices/juce_audio_devices.h>
@ -96,7 +97,7 @@ public:
// with an ALSA primary), minus the device already open as the primary (that's
// "Main") and minus monitor/loopback pseudo-inputs. Keeps the per-panel device
// picker to a compatible, sensible set instead of every capture node.
struct BindableInput { juce::String typeName; juce::String name; };
using BindableInput = slopsmith::ExtraInputs::Bindable;
std::vector<BindableInput> getBindableInputDevices();
juce::Array<double> getSampleRates();
@ -485,75 +486,11 @@ private:
// leave a live registration behind after stopAudio()'s single remove.
bool inputCallbackRegistered = false;
// ── Phase 2: additional input devices ────────────────────────────────────
// Each ADDITIONAL physical input device (a 2nd/3rd USB interface, e.g. two
// separate cables) gets its own AudioDeviceManager + callback running on its
// OWN hardware clock, packing its sources' mixed monitor into its own SPSC
// ring. audioOutputCallback drains+sums every active ring (drop-oldest wrap
// absorbs each device's drift independently — no cross-device resampling, the
// failure mode that corrupts a software combine). deviceKey 0 = the primary
// inputDeviceManager above; deviceKeys 1..kMaxExtraInputDevices map to
// extraInputs[deviceKey-1]. When any extra device is active the engine runs
// split (the primary also uses its ring) so the output sum is uniform.
// (kMaxExtraInputDevices is declared up top, near kMaxSources.)
// Forwards a JUCE device callback to the engine, tagged with the slot index.
struct InputSlotCallback : juce::AudioIODeviceCallback
{
AudioEngine* engine = nullptr;
int slot = -1; // index into extraInputs (deviceKey - 1)
void audioDeviceIOCallbackWithContext(const float* const* inputData, int numInputChannels,
float* const* outputData, int numOutputChannels,
int numSamples,
const juce::AudioIODeviceCallbackContext&) override
{
juce::ignoreUnused(outputData, numOutputChannels);
if (engine) engine->extraInputCallback(slot, inputData, numInputChannels, numSamples);
}
void audioDeviceAboutToStart(juce::AudioIODevice* d) override { if (engine) engine->extraInputAboutToStart(slot, d); }
void audioDeviceStopped() override { if (engine) engine->extraInputStopped(slot); }
};
struct InputDeviceSlot
{
juce::AudioDeviceManager manager;
InputSlotCallback callback;
slopsmith::PackedStereoRing<kOutputRingFrames> ring;
std::atomic<uint64_t> overflowCount{0};
std::atomic<bool> active{false}; // a device is bound + running
std::atomic<double> sampleRate{48000.0};
std::atomic<int> blockSize{256};
// (extra input latency primary input latency) in seconds — applied to
// this device's sources' verifiers so their capture aligns with the
// primary-corrected playhead. Computed when the device starts.
std::atomic<double> latencyDeltaSec{0.0};
// Audio-thread scratch — one set per slot since each slot's callback runs
// on its own thread (can't share the primary's sourceMonitorScratch).
juce::AudioBuffer<float> fanScratch; // the 2ch mix target
juce::AudioBuffer<float> monitorScratch; // per-source render in the N>1 path
int deviceKey = 0; // deviceKey this slot serves (slot+1)
// The device the user WANTS bound here — persistent INTENT, distinct from
// the transient `active` (currently open). Set by bindInputDevice, cleared
// only by a user unbind. stopAudio()/reconfigure close the device but keep
// this so startAudio() re-opens it; this is what survives a device change.
// Mutated + read on the control thread only.
juce::String desiredDeviceName;
// Whether the NEXT extraInputStopped() for this slot is a PERMANENT unbind
// (deactivate its sources) vs a transient close (keep them to resume). An
// atomic the control thread sets and the device thread reads, so the
// permanent-vs-transient decision never races on the juce::String above.
std::atomic<bool> permanentUnbind { false };
};
std::array<InputDeviceSlot, kMaxExtraInputDevices> extraInputs;
// Per-slot callback hooks (audio + device-management threads).
void extraInputCallback(int slot, const float* const* inputData, int numInputChannels, int numSamples);
void extraInputAboutToStart(int slot, juce::AudioIODevice* device);
void extraInputStopped(int slot);
// Close an extra device but KEEP its desiredDeviceName (transient close for
// stop/reconfigure); reopenDesiredExtraInputs() restores them after a (re)start.
bool closeExtraInputDevice(int slot);
void reopenDesiredExtraInputs();
// ── Additional input devices — moved to engine/ExtraInputs.{h,cpp}
// (TLC phase 5). The split output callback drains extraInputs.slots
// directly; declared after pool/state (bound by reference).
slopsmith::ExtraInputs extraInputs{ pool, state, inputDeviceManager };
using InputDeviceSlot = slopsmith::ExtraInputs::InputDeviceSlot;
// (mixSourcesForDevice moved to SourcePool::mixForDevice — TLC phase 5.)

View File

@ -10,6 +10,7 @@ set(AUDIO_SOURCES
engine/BackingPlayer.cpp
engine/DeviceSetup.cpp
engine/SourcePool.cpp
engine/ExtraInputs.cpp
SourceChain.cpp
SignalChain.cpp
VSTHost.cpp

View File

@ -0,0 +1,389 @@
// ExtraInputs implementation — moved verbatim from AudioEngine.cpp (TLC plan
// phase 5 / §2.3). Member renames only: extraInputs[...] → slots[...],
// inputDeviceManager → primaryManager, engine atomics → EngineState, source
// loops → SourcePool helpers (same locking as the engine sites had).
#include "ExtraInputs.h"
#include <cmath>
#include <cstdio>
#include <memory>
namespace slopsmith {
void ExtraInputs::slotCallback(int slot, const float* const* inputData, int numInputChannels, int numSamples)
{
if (slot < 0 || slot >= kMaxExtraInputDevices) return;
InputDeviceSlot& s = slots[(size_t) slot];
if (! s.active.load(std::memory_order_acquire)) return;
const SourcePool::CallbackGuard cbGuard(pool, s.deviceKey);
// Clamp to the per-slot scratch sized in slotAboutToStart 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);
pool.mixForDevice(s.deviceKey, inputData, numInputChannels, mix, s.monitorScratch, 2, numSamples);
s.ring.push(mix.getReadPointer(0), mix.getReadPointer(1), numSamples);
}
void ExtraInputs::slotAboutToStart(int slot, juce::AudioIODevice* device)
{
if (slot < 0 || slot >= kMaxExtraInputDevices || device == nullptr) return;
InputDeviceSlot& s = slots[(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.
// bind() 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 = state.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.ring.reset();
// 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 = primaryManager.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.
pool.prepareDeviceSources(s.deviceKey, sr, bs, deltaSec, true);
s.active.store(true, std::memory_order_release);
}
void ExtraInputs::slotStopped(int slot)
{
if (slot < 0 || slot >= kMaxExtraInputDevices) return;
InputDeviceSlot& s = slots[(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);
// PERMANENT unbind (user removed this device) deactivates its sources too;
// 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.
pool.releaseDeviceSources(s.deviceKey, true,
s.permanentUnbind.load(std::memory_order_acquire));
s.ring.resetIndices();
}
juce::String ExtraInputs::bind(int deviceKey, const juce::String& deviceName)
{
if (deviceKey < 1 || deviceKey > kMaxExtraInputDevices)
return "deviceKey out of range";
const int slot = deviceKey - 1;
InputDeviceSlot& s = slots[(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 && slots[(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 (reopenDesired() then drops the
// now-invalid binding via its failure handling).
if (auto* primary = primaryManager.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 (state.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 slotAboutToStart() 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 = primaryManager.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 = state.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; reopenDesired() re-opens it (and
// re-attaches the callback) when the engine next starts.
if (! state.deviceRunning.load(std::memory_order_relaxed))
{
s.manager.closeAudioDevice();
return {};
}
// Attach the callback — fires slotAboutToStart (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 ExtraInputs::unbind(int deviceKey)
{
if (deviceKey < 1 || deviceKey > kMaxExtraInputDevices)
return false;
const int slot = deviceKey - 1;
// User-initiated unbind: mark it PERMANENT (the device thread's slotStopped
// reads the flag) and clear the intent so no restore path resurrects a device
// deliberately removed.
slots[(size_t) slot].permanentUnbind.store(true, std::memory_order_release);
slots[(size_t) slot].desiredDeviceName = {};
// If the device is open, closing it fires slotStopped(), 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), slotStopped() will NOT run, so we
// must deactivate them here — otherwise they linger as ghost sources stranding
// pool slots and showing in listSources().
if (! closeSlot(slot))
{
pool.withDeviceSources(deviceKey, [](SourceChain& s) {
s.releaseResources();
s.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 unbind() clears the intent first (permanent removal).
bool ExtraInputs::closeSlot(int slot)
{
if (slot < 0 || slot >= kMaxExtraInputDevices)
return false;
InputDeviceSlot& s = slots[(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 slotStopped() — 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 → slotStopped (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 ExtraInputs::reopenDesired()
{
if (state.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 (slots[(size_t) (dk - 1)].desiredDeviceName.isEmpty())
continue;
pool.withDeviceSources(dk, [](SourceChain& s) { s.resetInputMeters(); });
}
return;
}
for (int dk = 1; dk <= kMaxExtraInputDevices; ++dk)
{
InputDeviceSlot& s = slots[(size_t) (dk - 1)];
if (s.desiredDeviceName.isEmpty() || s.active.load(std::memory_order_acquire))
continue;
const juce::String err = bind(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 = {};
pool.withDeviceSources(dk, [](SourceChain& src) { src.setActive(false); });
}
}
}
std::vector<ExtraInputs::Bindable> ExtraInputs::listBindable()
{
std::vector<Bindable> 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 = primaryManager.getCurrentAudioDevice())
primaryName = dev->getName();
// Enumerate across ALL device types, not just the primary's current one —
// bind() 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 bind() 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 : primaryManager.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;
}
} // namespace slopsmith

View File

@ -0,0 +1,160 @@
#pragma once
// ExtraInputs — the additional-physical-input-device registry (TLC plan
// phase 5 / §2.3, was "Phase 2: additional input devices" inside AudioEngine).
// Each ADDITIONAL device (a 2nd/3rd USB interface) gets its own
// AudioDeviceManager + callback running on its OWN hardware clock, packing
// its sources' mixed monitor into its own SPSC ring. The engine's split
// output callback drains + sums every active ring (drop-oldest absorbs each
// device's drift independently — no cross-device resampling). deviceKey 0 =
// the primary input manager; deviceKeys 1..kMaxExtraInputDevices map to
// slots[deviceKey-1]. When any extra device is active the engine runs split.
//
// Moved verbatim from AudioEngine. The slots array stays PUBLIC so the split
// output callback keeps its ring-drain loop unchanged; sources are prepared/
// released through the bound SourcePool; engine format/run state through
// EngineState; the primary manager reference serves the primary-device
// checks (duplicate binding, latency delta, bindable enumeration).
#include "EngineState.h"
#include "PackedStereoRing.h"
#include "SourcePool.h"
#include <juce_audio_devices/juce_audio_devices.h>
#include <array>
#include <atomic>
#include <vector>
namespace slopsmith {
class ExtraInputs
{
public:
static constexpr int kMaxExtraInputDevices = SourcePool::kMaxExtraInputDevices;
// Ring capacity matches the engine's split-mode ring.
static constexpr int kRingFrames = 4096;
// Forwards a JUCE device callback to the registry, tagged with the slot index.
struct SlotCallback : juce::AudioIODeviceCallback
{
ExtraInputs* owner = nullptr;
int slot = -1; // index into slots (deviceKey - 1)
void audioDeviceIOCallbackWithContext(const float* const* inputData, int numInputChannels,
float* const* outputData, int numOutputChannels,
int numSamples,
const juce::AudioIODeviceCallbackContext&) override
{
juce::ignoreUnused(outputData, numOutputChannels);
if (owner) owner->slotCallback(slot, inputData, numInputChannels, numSamples);
}
void audioDeviceAboutToStart(juce::AudioIODevice* d) override { if (owner) owner->slotAboutToStart(slot, d); }
void audioDeviceStopped() override { if (owner) owner->slotStopped(slot); }
};
struct InputDeviceSlot
{
juce::AudioDeviceManager manager;
SlotCallback callback;
PackedStereoRing<kRingFrames> ring;
std::atomic<uint64_t> overflowCount{0};
std::atomic<bool> active{false}; // a device is bound + running
std::atomic<double> sampleRate{48000.0};
std::atomic<int> blockSize{256};
// (extra input latency primary input latency) in seconds — applied to
// this device's sources' verifiers so their capture aligns with the
// primary-corrected playhead. Computed when the device starts.
std::atomic<double> latencyDeltaSec{0.0};
// Audio-thread scratch — one set per slot since each slot's callback runs
// on its own thread (can't share the primary's sourceMonitorScratch).
juce::AudioBuffer<float> fanScratch; // the 2ch mix target
juce::AudioBuffer<float> monitorScratch; // per-source render in the N>1 path
int deviceKey = 0; // deviceKey this slot serves (slot+1)
// The device the user WANTS bound here — persistent INTENT, distinct from
// the transient `active` (currently open). Set by bind(), cleared only by a
// user unbind. stopAudio()/reconfigure close the device but keep this so
// startAudio() re-opens it; this is what survives a device change.
// Mutated + read on the control thread only.
juce::String desiredDeviceName;
// Whether the NEXT slotStopped() for this slot is a PERMANENT unbind
// (deactivate its sources) vs a transient close (keep them to resume). An
// atomic the control thread sets and the device thread reads, so the
// permanent-vs-transient decision never races on the juce::String above.
std::atomic<bool> permanentUnbind { false };
};
ExtraInputs(SourcePool& sourcePool, EngineState& engineState,
juce::AudioDeviceManager& primaryInputManager)
: pool(sourcePool), state(engineState), primaryManager(primaryInputManager)
{
for (int i = 0; i < kMaxExtraInputDevices; ++i)
{
slots[(size_t) i].callback.owner = this;
slots[(size_t) i].callback.slot = i;
slots[(size_t) i].deviceKey = i + 1;
}
}
// ── Control thread ────────────────────────────────────────────────────
juce::String bind(int deviceKey, const juce::String& deviceName);
bool unbind(int deviceKey);
// Close a slot's device but KEEP desiredDeviceName (transient close for
// stop/reconfigure); reopenDesired() restores them after a (re)start.
bool closeSlot(int slot);
void reopenDesired();
// Shutdown path: stop every slot device FIRST so no slot callback can fire
// into a half-destroyed engine. closeAudioDevice blocks for the callback.
void closeAllForShutdown()
{
for (auto& s : slots)
{
s.manager.closeAudioDevice();
s.manager.removeAudioCallback(&s.callback);
}
}
int activeCount() const
{
int n = 0;
for (const auto& s : slots)
if (s.active.load(std::memory_order_acquire)) ++n;
return n;
}
struct Bindable { juce::String typeName; juce::String name; };
std::vector<Bindable> listBindable();
// Resolution for SourcePool::addResolved — the per-slot readiness/format/
// latency the pool needs, plus whether the key is usable at all.
struct Resolved { bool usable = false; bool ready = false; double sr = 0.0; int bs = 0; double latencyDelta = 0.0; };
Resolved resolveForSource(int deviceKey) const
{
Resolved r;
if (deviceKey < 1 || deviceKey > kMaxExtraInputDevices) return r;
const InputDeviceSlot& es = slots[(size_t) (deviceKey - 1)];
// Bound — either currently open (active) or DEFERRED (validated + desired
// while the engine is stopped, to be reopened by startAudio()).
r.usable = es.active.load(std::memory_order_acquire) || es.desiredDeviceName.isNotEmpty();
r.ready = es.active.load(std::memory_order_acquire);
r.sr = es.sampleRate.load(std::memory_order_relaxed);
r.bs = es.blockSize.load(std::memory_order_relaxed);
r.latencyDelta = es.latencyDeltaSec.load(std::memory_order_relaxed);
return r;
}
// PUBLIC: the split output callback drains every active slot's ring in
// place (same loop as before the move).
std::array<InputDeviceSlot, kMaxExtraInputDevices> slots;
private:
// Per-slot device-callback hooks (audio + device-management threads).
void slotCallback(int slot, const float* const* inputData, int numInputChannels, int numSamples);
void slotAboutToStart(int slot, juce::AudioIODevice* device);
void slotStopped(int slot);
SourcePool& pool;
EngineState& state;
juce::AudioDeviceManager& primaryManager;
};
} // namespace slopsmith