From 6b0bfb7be3d562beba67da1faad3bbb5f5d20da6 Mon Sep 17 00:00:00 2001 From: OmikronApex Date: Tue, 14 Jul 2026 01:29:16 +0200 Subject: [PATCH] refactor(audio): extract ExtraInputs (phase 5b) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- src/audio/AudioEngine.cpp | 407 ++----------------------------- src/audio/AudioEngine.h | 77 +----- src/audio/CMakeLists.txt | 1 + src/audio/engine/ExtraInputs.cpp | 389 +++++++++++++++++++++++++++++ src/audio/engine/ExtraInputs.h | 160 ++++++++++++ 5 files changed, 572 insertions(+), 462 deletions(-) create mode 100644 src/audio/engine/ExtraInputs.cpp create mode 100644 src/audio/engine/ExtraInputs.h diff --git a/src/audio/AudioEngine.cpp b/src/audio/AudioEngine.cpp index 595e0b3..2015263 100644 --- a/src/audio/AudioEngine.cpp +++ b/src/audio/AudioEngine.cpp @@ -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::getDeviceTypes() std::vector AudioEngine::getBindableInputDevices() { - std::vector 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 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 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 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); diff --git a/src/audio/AudioEngine.h b/src/audio/AudioEngine.h index e75266c..c578afc 100644 --- a/src/audio/AudioEngine.h +++ b/src/audio/AudioEngine.h @@ -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 @@ -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 getBindableInputDevices(); juce::Array 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 ring; - std::atomic overflowCount{0}; - std::atomic active{false}; // a device is bound + running - std::atomic sampleRate{48000.0}; - std::atomic 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 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 fanScratch; // the 2ch mix target - juce::AudioBuffer 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 permanentUnbind { false }; - }; - std::array 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.) diff --git a/src/audio/CMakeLists.txt b/src/audio/CMakeLists.txt index 954e764..1c44e19 100644 --- a/src/audio/CMakeLists.txt +++ b/src/audio/CMakeLists.txt @@ -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 diff --git a/src/audio/engine/ExtraInputs.cpp b/src/audio/engine/ExtraInputs.cpp new file mode 100644 index 0000000..2928a27 --- /dev/null +++ b/src/audio/engine/ExtraInputs.cpp @@ -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 +#include +#include + +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 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 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::listBindable() +{ + std::vector 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 diff --git a/src/audio/engine/ExtraInputs.h b/src/audio/engine/ExtraInputs.h new file mode 100644 index 0000000..e3cfaf2 --- /dev/null +++ b/src/audio/engine/ExtraInputs.h @@ -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 + +#include +#include +#include + +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 ring; + std::atomic overflowCount{0}; + std::atomic active{false}; // a device is bound + running + std::atomic sampleRate{48000.0}; + std::atomic 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 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 fanScratch; // the 2ch mix target + juce::AudioBuffer 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 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 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 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