mirror of
https://github.com/got-feedBack/feedBack-desktop.git
synced 2026-07-20 20:01:23 +00:00
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:
parent
827f02b4b4
commit
6b0bfb7be3
@ -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);
|
||||
|
||||
@ -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.)
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
389
src/audio/engine/ExtraInputs.cpp
Normal file
389
src/audio/engine/ExtraInputs.cpp
Normal 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
|
||||
160
src/audio/engine/ExtraInputs.h
Normal file
160
src/audio/engine/ExtraInputs.h
Normal 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
|
||||
Loading…
Reference in New Issue
Block a user