From 6ace5a209a29a93fd16741a4255304c194f4e211 Mon Sep 17 00:00:00 2001 From: OmikronApex Date: Tue, 14 Jul 2026 00:27:51 +0200 Subject: [PATCH] refactor(audio): extract DeviceSetup + shared rate-match helpers (phase 4) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Moves probeDeviceOptionsDual, applyDuplexSetup, applySplitSetup, and teardownSplitMode verbatim into src/audio/engine/DeviceSetup.{h,cpp}. The component holds references to the two device managers + EngineState and owns no lifetime; engine-owned collaborators (monitor chain, split output ring + counters, output callback registration) are passed by reference per call. setAudioDevices stays on the facade as the orchestrator. The public DeviceOptions/DeviceConfig/DeviceConfigResult shapes move to the slopsmith namespace with using-aliases on AudioEngine, so the NodeAddon spelling is unchanged. Lands the deep-read §7 dedupe structurally: the <=0.5 rate tolerance, midpoint-rounding fail-closed candidate, and empty-name→first-enumerated resolution now exist once (RateMatch.h — JUCE-free + unit-tested boundary cases — and DeviceSetup::resolveDeviceName/rateSupportedBy) instead of three hand-synced copies. Full device-matrix validation (WASAPI shared/exclusive, ASIO, dual-type split) rides the next tester build per the plan's phase-4 gate. Co-Authored-By: Claude Fable 5 --- src/audio/AudioEngine.cpp | 625 +------------------------ src/audio/AudioEngine.h | 49 +- src/audio/CMakeLists.txt | 1 + src/audio/engine/DeviceSetup.cpp | 623 ++++++++++++++++++++++++ src/audio/engine/DeviceSetup.h | 124 +++++ src/audio/engine/RateMatch.h | 33 ++ tests/engine_units/CMakeLists.txt | 4 + tests/engine_units/rate_match_test.cpp | 46 ++ 8 files changed, 850 insertions(+), 655 deletions(-) create mode 100644 src/audio/engine/DeviceSetup.cpp create mode 100644 src/audio/engine/DeviceSetup.h create mode 100644 src/audio/engine/RateMatch.h create mode 100644 tests/engine_units/rate_match_test.cpp diff --git a/src/audio/AudioEngine.cpp b/src/audio/AudioEngine.cpp index 2069e79..2be1ca2 100644 --- a/src/audio/AudioEngine.cpp +++ b/src/audio/AudioEngine.cpp @@ -190,212 +190,7 @@ AudioEngine::DeviceOptions AudioEngine::probeDeviceOptionsDual(const juce::Strin const juce::String& outputTypeName, const juce::String& outputName) { - DeviceOptions options; - options.inputType = inputTypeName; - options.outputType = outputTypeName.isEmpty() ? inputTypeName : outputTypeName; - options.type = options.inputType; // legacy alias - - // Resolve each side from its own manager so probe stays consistent with - // applySplitSetup()/setOutputDeviceType(), which mutate the manager that - // owns the side they're configuring. Using inputDeviceManager for the - // output lookup would silently fall back to whatever input has scanned, - // which can miss output-only backends. - auto findType = [](juce::AudioDeviceManager& manager, - const juce::String& wanted) -> juce::AudioIODeviceType* { - juce::AudioIODeviceType* match = nullptr; - for (auto* type : manager.getAvailableDeviceTypes()) - { - if ((wanted.isNotEmpty() && type->getTypeName() == wanted) - || (wanted.isEmpty() && match == nullptr)) - { - match = type; - if (wanted.isNotEmpty()) break; - } - } - return match; - }; - - auto* inputType = findType(inputDeviceManager, options.inputType); - - // Match setAudioDevices's resolution: when the caller didn't specify - // an output type, default it to the SAME type the input side resolved - // to (using the type's name, looked up in outputDeviceManager). Without - // this, an empty `options.outputType` would let findType pick whatever - // outputDeviceManager enumerates first — potentially a different - // backend than inputDeviceManager picked from the empty string, which - // then disagrees with the apply path's duplex classification. - juce::String effectiveOutputTypeName = options.outputType; - if (effectiveOutputTypeName.isEmpty() && inputType != nullptr) - effectiveOutputTypeName = inputType->getTypeName(); - auto* outputType = findType(outputDeviceManager, effectiveOutputTypeName); - - if (inputType == nullptr) - { - options.error = "Input device type not found"; - options.compatible = false; - return options; - } - if (outputType == nullptr) - { - options.error = "Output device type not found"; - options.compatible = false; - return options; - } - - try - { - options.inputType = inputType->getTypeName(); - options.outputType = outputType->getTypeName(); - options.type = options.inputType; - - options.input = inputName; - options.output = outputName; - - // For probing we still need a concrete device to instantiate. - // Resolve empty names to first-enumerated ONLY for the probe-device - // creation below — DON'T write back into options.input/options.output; - // those flow to the UI and the apply path, which treat empty as - // "OS default" per side. - auto inputs = inputType->getDeviceNames(true); - auto outputs = outputType->getDeviceNames(false); - const juce::String probeInputName = - options.input.isEmpty() && inputs.size() > 0 ? inputs[0] : options.input; - const juce::String probeOutputName = - options.output.isEmpty() && outputs.size() > 0 ? outputs[0] : options.output; - - // Probe the SAME way setAudioDevices() will actually apply, or the - // startup auto-apply mis-fires: init() fail-closes on this probe's - // `compatible` verdict, so if the probe measures a combined duplex device - // but apply then opens split (or vice-versa), the verdict describes a - // config that won't be the one used — the classic symptom being "no audio - // until I press Apply". Duplex is only attempted for the SAME physical - // endpoint (a true single-clock device); two different endpoints of the - // same backend (USB cable in + separate speakers out) are two clocks and - // go split. Mirror setAudioDevices()'s sameEndpointIntent exactly. - bool isDuplex = (options.inputType == options.outputType) - && (options.input == options.output); - - if (isDuplex) - { - std::unique_ptr dev( - inputType->createDevice(probeOutputName, probeInputName)); - if (dev) - { - options.inputChannels = dev->getInputChannelNames(); - options.outputChannels = dev->getOutputChannelNames(); - for (auto rate : dev->getAvailableSampleRates()) - options.sampleRates.addIfNotAlreadyThere(rate); - for (auto size : dev->getAvailableBufferSizes()) - options.bufferSizes.addIfNotAlreadyThere(size); - } - else - { - isDuplex = false; - } - } - if (!isDuplex) - { - std::unique_ptr inDev( - inputType->createDevice({}, probeInputName)); - std::unique_ptr outDev( - outputType->createDevice(probeOutputName, {})); - if (!inDev || !outDev) - { - options.error = "Could not create dual probe devices"; - options.compatible = false; - return options; - } - - options.inputChannels = inDev->getInputChannelNames(); - options.outputChannels = outDev->getOutputChannelNames(); - - // Tolerance covers backends that report fractional drift around the nominal rate. - const auto inRates = inDev->getAvailableSampleRates(); - const auto outRates = outDev->getAvailableSampleRates(); - for (auto r : inRates) - { - for (auto r2 : outRates) - { - // <= 0.5 (not <) to match applySplitSetup's rateSupportedBy - // check. A backend reporting 47999.5 on both sides has - // |r - r2| = 0 (matches anyway) but a backend mixing - // 47999.5 in / 48000.0 out has |diff| = 0.5 exactly, which - // < 0.5 would reject from the probe even though the - // apply-side check accepts it. - if (std::abs(r - r2) <= 0.5) - { - // Round the midpoint to a clean nominal rate - // (backends sometimes report fractional near-48000 - // rates; surfacing the raw value would fail the - // apply-side setAudioDeviceSetup, which expects an - // exact supported nominal). Re-check the rounded - // candidate is within tolerance of BOTH sides — a - // matched pair like 48000.4/48000.6 passes the |r-r2| - // check but std::round(48000.4)=48000 would fall - // outside tolerance of 48000.6 (diff 0.6). Skip - // those so the probe stays fail-closed. - const double candidate = std::round((r + r2) * 0.5); - if (std::abs(r - candidate) <= 0.5 - && std::abs(r2 - candidate) <= 0.5) - { - options.sampleRates.addIfNotAlreadyThere(candidate); - } - break; - } - } - } - if (options.sampleRates.isEmpty()) - { - options.error = "Input and output devices share no common sample rate"; - options.compatible = false; - } - - // Split mode opens both sides with the same bufferSize, so the - // UI should only see sizes the intersection of both devices - // supports — a union would let the user pick a value that - // predictably fails at apply time on one side. - const auto inBufs = inDev->getAvailableBufferSizes(); - const auto outBufs = outDev->getAvailableBufferSizes(); - for (auto b : inBufs) - { - for (auto b2 : outBufs) - { - if (b == b2) - { - options.bufferSizes.addIfNotAlreadyThere(b); - break; - } - } - } - // An empty intersection means there's no buffer size both sides - // accept; setting compatible=false stops the UI from re-enabling - // Apply against a guaranteed-fail config. - if (options.bufferSizes.isEmpty() && options.error.isEmpty()) - { - options.error = "Input and output devices share no common buffer size"; - options.compatible = false; - } - } - - fprintf(stderr, "[AudioEngine] Probed device options: inType='%s' outType='%s' in='%s' out='%s' " - "duplex=%d inputs=%d outputs=%d rates=%d buffers=%d compatible=%d\n", - options.inputType.toRawUTF8(), options.outputType.toRawUTF8(), - options.input.toRawUTF8(), options.output.toRawUTF8(), - (int) isDuplex, options.inputChannels.size(), options.outputChannels.size(), - options.sampleRates.size(), options.bufferSizes.size(), (int) options.compatible); - } - catch (const std::exception& e) - { - options.error = e.what(); - options.compatible = false; - } - catch (...) - { - options.error = "Probe failed"; - options.compatible = false; - } - - return options; + return deviceSetup.probeDual(inputTypeName, inputName, outputTypeName, outputName); } juce::String AudioEngine::getCurrentDeviceType() @@ -719,8 +514,9 @@ AudioEngine::DeviceConfigResult AudioEngine::setAudioDevices(const DeviceConfig& { teardownSplitMode(); - const juce::String err = applyDuplexSetup(resolvedInput, resolvedOutput, - requestedSampleRate, requestedBufferSize); + const juce::String err = deviceSetup.applyDuplex(resolvedInput, resolvedOutput, + requestedSampleRate, requestedBufferSize, + source0()); if (err.isEmpty()) { duplexMode.store(true, std::memory_order_relaxed); @@ -754,7 +550,9 @@ AudioEngine::DeviceConfigResult AudioEngine::setAudioDevices(const DeviceConfig& resolved.sampleRate = requestedSampleRate; resolved.bufferSize = requestedBufferSize; - res = applySplitSetup(resolved); + res = deviceSetup.applySplit(resolved, source0(), outputRing, + outputUnderflowCount, inputOverflowCount, + outputCallback, outputCallbackRegistered); if (!res.ok) return res; duplexMode.store(false, std::memory_order_relaxed); @@ -768,416 +566,9 @@ AudioEngine::DeviceConfigResult AudioEngine::setAudioDevices(const DeviceConfig& return res; } -juce::String AudioEngine::applyDuplexSetup(const juce::String& inputName, - const juce::String& outputName, - double sampleRate, int bufferSize) -{ - juce::AudioDeviceManager::AudioDeviceSetup setup; - setup.inputDeviceName = inputName; - setup.outputDeviceName = outputName; - setup.sampleRate = sampleRate > 0 ? sampleRate : 48000.0; - setup.bufferSize = bufferSize > 0 ? bufferSize : 256; - setup.useDefaultInputChannels = inputName.isEmpty(); - setup.useDefaultOutputChannels = outputName.isEmpty(); - - // Channel masks must match too — high-numbered selectedInputChannel needs - // the expanded mask that an older session may not have opened. - if (auto* currentDevice = inputDeviceManager.getCurrentAudioDevice()) - { - try - { - juce::AudioDeviceManager::AudioDeviceSetup current; - inputDeviceManager.getAudioDeviceSetup(current); - - const int advertisedInputs = currentDevice->getInputChannelNames().size(); - juce::BigInteger expectedInputs; - expectedInputs.setRange(0, advertisedInputs > 0 ? advertisedInputs : 2, true); - - const int advertisedOutputs = currentDevice->getOutputChannelNames().size(); - juce::BigInteger expectedOutputs; - expectedOutputs.setRange(0, juce::jmin(advertisedOutputs > 0 ? advertisedOutputs : 2, 2), true); - - if (current.inputDeviceName == setup.inputDeviceName - && current.outputDeviceName == setup.outputDeviceName - && current.sampleRate == setup.sampleRate - && current.bufferSize == setup.bufferSize - && current.useDefaultInputChannels == setup.useDefaultInputChannels - && current.useDefaultOutputChannels == setup.useDefaultOutputChannels - && current.inputChannels == expectedInputs - && current.outputChannels == expectedOutputs - && duplexMode.load(std::memory_order_relaxed)) - { - fprintf(stderr, "[AudioEngine] Duplex device already configured with same settings, skipping\n"); - return {}; - } - } - catch (const std::exception& e) - { - fprintf(stderr, "[AudioEngine] Current device channel check failed: %s\n", e.what()); - } - catch (...) - { - fprintf(stderr, "[AudioEngine] Current device channel check failed (unknown)\n"); - } - } - - // ALSA deadlocks on reconfigure unless we fully close first. WASAPI - // reconfigures in place and is much slower if closed. -#if JUCE_LINUX - juce::String currentTypeName; - if (auto* currentType = inputDeviceManager.getCurrentDeviceTypeObject()) - currentTypeName = currentType->getTypeName(); - if (inputDeviceManager.getCurrentAudioDevice() != nullptr) - { - try { - inputDeviceManager.closeAudioDevice(); - fprintf(stderr, "[AudioEngine] Closed device for reconfiguration\n"); - if (currentTypeName.isNotEmpty()) - inputDeviceManager.setCurrentAudioDeviceType(currentTypeName, true); - } catch (...) { - fprintf(stderr, "[AudioEngine] closeAudioDevice crashed, continuing\n"); - } - } -#endif - - int inputChannelCount = 0; - int outputChannelCount = 0; - if (auto* type = inputDeviceManager.getCurrentDeviceTypeObject()) - { - try - { - if (auto probe = std::unique_ptr(type->createDevice(outputName, inputName))) - { - inputChannelCount = probe->getInputChannelNames().size(); - outputChannelCount = probe->getOutputChannelNames().size(); - } - } - catch (const std::exception& e) - { - fprintf(stderr, "[AudioEngine] Channel probe failed: %s\n", e.what()); - } - catch (...) - { - fprintf(stderr, "[AudioEngine] Channel probe failed (unknown)\n"); - } - } - if (inputChannelCount <= 0) inputChannelCount = 2; - if (outputChannelCount <= 0) outputChannelCount = 2; - - setup.inputChannels.setRange(0, inputChannelCount, true); - setup.outputChannels.setRange(0, juce::jmin(outputChannelCount, 2), true); - - juce::String result; - try { - result = inputDeviceManager.setAudioDeviceSetup(setup, true); - } catch (...) { - return "setAudioDeviceSetup threw"; - } - if (result.isNotEmpty()) - { - fprintf(stderr, "[AudioEngine] Device setup error: %s\n", result.toRawUTF8()); - try { - result = inputDeviceManager.initialiseWithDefaultDevices(2, 2); - } catch (...) { - return "fallback initialiseWithDefaultDevices threw"; - } - if (result.isNotEmpty()) - return "device setup failed: " + result; - } - - if (auto* configuredDevice = inputDeviceManager.getCurrentAudioDevice()) - { - const double sr = configuredDevice->getCurrentSampleRate(); - const int bs = configuredDevice->getCurrentBufferSizeSamples(); - currentSampleRate.store(sr, std::memory_order_relaxed); - inputBlockSize.store(bs, std::memory_order_relaxed); - outputBlockSize.store(bs, std::memory_order_relaxed); - - fprintf(stderr, "[AudioEngine] Duplex device configured OK. Current device: %s\n", - configuredDevice->getName().toRawUTF8()); - fprintf(stderr, "[AudioEngine] Actual device setup: sr=%.0f bs=%d (requested bs=%d)\n", - sr, bs, bufferSize); - - source0().prepareMonitorChain(sr, bs); - return {}; - } - currentSampleRate.store(0.0, std::memory_order_relaxed); - inputBlockSize.store(0, std::memory_order_relaxed); - outputBlockSize.store(0, std::memory_order_relaxed); - source0().releaseMonitorChain(); - return "no current device after setup"; -} - -AudioEngine::DeviceConfigResult AudioEngine::applySplitSetup(const DeviceConfig& config) -{ - DeviceConfigResult res; - res.duplex = false; - - // The split-mode output ring is fixed at kOutputRingFrames samples - // (~85ms @ 48kHz). A single callback at bufferSize > kOutputRingFrames - // would overrun the ring in one go, guaranteeing immediate - // overwrite/wrap and audible glitches. Reject those configurations up - // front — duplex still works fine since it bypasses the ring entirely. - if (config.bufferSize > kOutputRingFrames) - { - res.error = "Buffer size " + juce::String(config.bufferSize) - + " exceeds split-mode ring capacity (" - + juce::String(kOutputRingFrames) + "). Pick a smaller buffer size or use duplex."; - return res; - } - - // setCurrentAudioDeviceType can throw from JUCE backends (ASIO). - // Catch so the failure surfaces as a structured error rather than an - // exception crossing the N-API boundary. - try - { - if (auto* current = outputDeviceManager.getCurrentDeviceTypeObject()) - { - if (current->getTypeName() != config.outputType) - outputDeviceManager.setCurrentAudioDeviceType(config.outputType, true); - } - else - { - outputDeviceManager.setCurrentAudioDeviceType(config.outputType, true); - } - } - catch (...) - { - res.error = "setCurrentAudioDeviceType threw for output type '" + config.outputType + "'"; - return res; - } - - // v1 forces matching nominal SR — no adaptive resampler yet. - // Resolve empty name to first-enumerated for the createDevice probe - // call (matches probeDeviceOptionsDual's strategy). createDevice("") - // is implementation-defined per backend — some return the default, - // some return null. Using first-enumerated keeps probe and apply - // checking the SAME concrete device, so an empty-name config can't - // pass the UI probe and then fail this check. - auto rateSupportedBy = [&](juce::AudioIODeviceType* t, - const juce::String& dev, bool isInput, double sr) { - if (!t) return false; - juce::String resolved = dev; - if (resolved.isEmpty()) - { - auto names = t->getDeviceNames(isInput); - if (names.size() > 0) resolved = names[0]; - } - std::unique_ptr probe( - isInput ? t->createDevice({}, resolved) : t->createDevice(resolved, {})); - if (!probe) return false; - // Tolerance matches the probe-side rounding: probeDeviceOptionsDual - // rounds the matched rate to the nearest integer (see :208), so a - // backend reporting e.g. 47999.5 surfaces 48000 in the UI. If we - // kept `< 0.5` here, the round-trip would fail at apply time because - // |47999.5 - 48000.0| is exactly 0.5. Use `<= 0.5` so the boundary - // case the probe accepted is also accepted at apply. - for (auto r : probe->getAvailableSampleRates()) - if (std::abs(r - sr) <= 0.5) return true; - return false; - }; - juce::AudioIODeviceType* inputType = nullptr; - juce::AudioIODeviceType* outputType = nullptr; - for (auto* t : inputDeviceManager.getAvailableDeviceTypes()) - if (t->getTypeName() == config.inputType) { inputType = t; break; } - for (auto* t : outputDeviceManager.getAvailableDeviceTypes()) - if (t->getTypeName() == config.outputType) { outputType = t; break; } - if (!inputType || !outputType) - { - res.error = "Device type not found"; - return res; - } - if (!rateSupportedBy(inputType, config.inputDevice, true, config.sampleRate) - || !rateSupportedBy(outputType, config.outputDevice, false, config.sampleRate)) - { - res.error = "Sample rate not supported by both input and output devices"; - return res; - } - - juce::AudioDeviceManager::AudioDeviceSetup inSetup; - // Resolve empty name to first-enumerated input device — matches the - // rateSupportedBy preflight above AND probeDeviceOptionsDual. Using - // empty + useDefault*Channels here would make JUCE open the OS - // default, which can differ from inputs[0] on platforms where the - // OS-default differs from JUCE's enumeration order. The probe + SR - // preflight + actual open all need to agree on the same concrete - // device for the apply path to behave consistently with what the UI - // showed the user. - juce::String resolvedInputName = config.inputDevice; - if (resolvedInputName.isEmpty()) - { - auto names = inputType->getDeviceNames(true); - if (names.size() > 0) resolvedInputName = names[0]; - } - - inSetup.inputDeviceName = resolvedInputName; - inSetup.outputDeviceName = ""; - inSetup.sampleRate = config.sampleRate; - inSetup.bufferSize = config.bufferSize; - inSetup.useDefaultInputChannels = false; - inSetup.useDefaultOutputChannels = false; - - int inputChannelCount = 0; - { - try { - std::unique_ptr probe(inputType->createDevice({}, resolvedInputName)); - if (probe) inputChannelCount = probe->getInputChannelNames().size(); - } catch (...) {} - } - if (inputChannelCount <= 0) inputChannelCount = 2; - inSetup.inputChannels.setRange(0, inputChannelCount, true); - inSetup.outputChannels.clear(); - - // Rollback helper: on any failure path after a side has been opened, - // close both managers' devices so we don't leave the OS audio resource - // held (sometimes exclusively, e.g. ASIO) while setDevice reports a - // failure. closeAudioDevice is idempotent so unconditional calls are - // safe even when only the input or neither side opened. - auto rollbackOpenedDevices = [&]() { - // Drop any callback we already attached to the output manager — - // closeAudioDevice() does not invoke removeAudioCallback, and leaving - // outputCallbackRegistered=true would cause the next startAudio() - // to skip the re-attach (it gates on !outputCallbackRegistered), - // leaving split-mode output silent after a partial-open failure. - if (outputCallbackRegistered) - { - try { outputDeviceManager.removeAudioCallback(&outputCallback); } catch (...) {} - outputCallbackRegistered = false; - } - try { inputDeviceManager.closeAudioDevice(); } catch (...) {} - try { outputDeviceManager.closeAudioDevice(); } catch (...) {} - }; - - // Mirror applyDuplexSetup's JUCE_LINUX close-before-reconfigure pattern: - // ALSA deadlocks if we let setAudioDeviceSetup mutate a live device. The - // device type is re-asserted afterwards so the close doesn't drop us back - // to whatever JUCE picked at startup. closeAudioDevice/setCurrentAudioDeviceType - // throwing is non-fatal — we still try the setup below and surface its error. -#if JUCE_LINUX - { - juce::String currentInputTypeName; - if (auto* currentType = inputDeviceManager.getCurrentDeviceTypeObject()) - currentInputTypeName = currentType->getTypeName(); - if (inputDeviceManager.getCurrentAudioDevice() != nullptr) - { - try { - inputDeviceManager.closeAudioDevice(); - if (currentInputTypeName.isNotEmpty()) - inputDeviceManager.setCurrentAudioDeviceType(currentInputTypeName, true); - } catch (...) { - fprintf(stderr, "[AudioEngine] split-mode input close threw, continuing\n"); - } - } - } -#endif - - juce::String inErr; - try { inErr = inputDeviceManager.setAudioDeviceSetup(inSetup, true); } - catch (...) { res.error = "input setAudioDeviceSetup threw"; rollbackOpenedDevices(); return res; } - if (inErr.isNotEmpty()) { res.error = "input setup: " + inErr; rollbackOpenedDevices(); return res; } - - auto* inDev = inputDeviceManager.getCurrentAudioDevice(); - if (!inDev) { res.error = "no input device after setup"; rollbackOpenedDevices(); return res; } - const double inSr = inDev->getCurrentSampleRate(); - const int inBs = inDev->getCurrentBufferSizeSamples(); - - // Same first-enumerated resolution on the output side — see input note - // above for why this matches the probe + SR preflight strategy. - juce::String resolvedOutputName = config.outputDevice; - if (resolvedOutputName.isEmpty()) - { - auto names = outputType->getDeviceNames(false); - if (names.size() > 0) resolvedOutputName = names[0]; - } - - juce::AudioDeviceManager::AudioDeviceSetup outSetup; - outSetup.inputDeviceName = ""; - outSetup.outputDeviceName = resolvedOutputName; - outSetup.sampleRate = config.sampleRate; - outSetup.bufferSize = config.bufferSize; - outSetup.useDefaultInputChannels = false; - outSetup.useDefaultOutputChannels = false; - - int outputChannelCount = 0; - { - try { - std::unique_ptr probe(outputType->createDevice(resolvedOutputName, {})); - if (probe) outputChannelCount = probe->getOutputChannelNames().size(); - } catch (...) {} - } - if (outputChannelCount <= 0) outputChannelCount = 2; - outSetup.inputChannels.clear(); - outSetup.outputChannels.setRange(0, juce::jmin(outputChannelCount, 2), true); - - // Same JUCE_LINUX close-before-reconfigure as the input side above — also - // protects when split mode is re-applied with a different output device. -#if JUCE_LINUX - { - juce::String currentOutputTypeName; - if (auto* currentType = outputDeviceManager.getCurrentDeviceTypeObject()) - currentOutputTypeName = currentType->getTypeName(); - if (outputDeviceManager.getCurrentAudioDevice() != nullptr) - { - try { - outputDeviceManager.closeAudioDevice(); - if (currentOutputTypeName.isNotEmpty()) - outputDeviceManager.setCurrentAudioDeviceType(currentOutputTypeName, true); - } catch (...) { - fprintf(stderr, "[AudioEngine] split-mode output close threw, continuing\n"); - } - } - } -#endif - - juce::String outErr; - try { outErr = outputDeviceManager.setAudioDeviceSetup(outSetup, true); } - catch (...) { res.error = "output setAudioDeviceSetup threw"; rollbackOpenedDevices(); return res; } - if (outErr.isNotEmpty()) { res.error = "output setup: " + outErr; rollbackOpenedDevices(); return res; } - - auto* outDev = outputDeviceManager.getCurrentAudioDevice(); - if (!outDev) { res.error = "no output device after setup"; rollbackOpenedDevices(); return res; } - const double outSr = outDev->getCurrentSampleRate(); - const int outBs = outDev->getCurrentBufferSizeSamples(); - - if (std::abs(inSr - outSr) > 0.5) - { - res.error = "Input and output devices opened at different sample rates"; - rollbackOpenedDevices(); - return res; - } - - currentSampleRate.store(inSr, std::memory_order_relaxed); - inputBlockSize.store(inBs, std::memory_order_relaxed); - outputBlockSize.store(outBs, std::memory_order_relaxed); - - fprintf(stderr, "[AudioEngine] Split mode configured: inSr=%.0f inBs=%d outSr=%.0f outBs=%d\n", - inSr, inBs, outSr, outBs); - - outputRing.reset(); - outputUnderflowCount.store(0, std::memory_order_relaxed); - inputOverflowCount.store(0, std::memory_order_relaxed); - - source0().prepareMonitorChain(inSr, inBs); - - res.ok = true; - res.sampleRate = inSr; - res.inputBlockSize = inBs; - res.outputBlockSize = outBs; - return res; -} - void AudioEngine::teardownSplitMode() { - // Unconditional remove — JUCE's removeAudioCallback is idempotent - // (no-op if the callback isn't registered), so we don't need the - // outputCallbackRegistered guard here. This makes teardown robust - // against a stale flag left over from a previous failed split setup. - outputDeviceManager.removeAudioCallback(&outputCallback); - outputCallbackRegistered = false; - try { outputDeviceManager.closeAudioDevice(); } - catch (...) { fprintf(stderr, "[AudioEngine] teardownSplitMode: output close threw\n"); } - - outputRing.reset(); + deviceSetup.teardownSplit(outputRing, outputCallback, outputCallbackRegistered); } // ── Audio Control ───────────────────────────────────────────────────────────── diff --git a/src/audio/AudioEngine.h b/src/audio/AudioEngine.h index f74d05b..0f3912b 100644 --- a/src/audio/AudioEngine.h +++ b/src/audio/AudioEngine.h @@ -6,6 +6,7 @@ #include "engine/RendererBus.h" #include "engine/StreamSink.h" #include "engine/BackingPlayer.h" +#include "engine/DeviceSetup.h" #include "BackingLeveler.h" #include "signalsmith-stretch.h" #include @@ -68,39 +69,12 @@ public: juce::StringArray inputDevices; juce::StringArray outputDevices; }; - struct DeviceOptions - { - juce::String type; // legacy alias = inputType - juce::String inputType; - juce::String outputType; - juce::String input; - juce::String output; - juce::StringArray inputChannels; - juce::StringArray outputChannels; - juce::Array sampleRates; // intersection when dual-type - juce::Array bufferSizes; - bool compatible = true; // false when types share no usable sample rate - juce::String error; - }; - - struct DeviceConfig - { - juce::String inputType; - juce::String inputDevice; - juce::String outputType; - juce::String outputDevice; - double sampleRate = 48000.0; - int bufferSize = 256; - }; - struct DeviceConfigResult - { - bool ok = false; - juce::String error; - double sampleRate = 0.0; - int inputBlockSize = 0; - int outputBlockSize = 0; - bool duplex = true; - }; + // Device-config shapes moved to engine/DeviceSetup.h (TLC phase 4); + // aliased so the AudioEngine::DeviceOptions etc. spelling NodeAddon uses + // is unchanged. + using DeviceOptions = slopsmith::DeviceOptions; + using DeviceConfig = slopsmith::DeviceConfig; + using DeviceConfigResult = slopsmith::DeviceConfigResult; struct DeviceMetrics { @@ -417,11 +391,8 @@ private: }; OutputCallback outputCallback{ *this }; - juce::String applyDuplexSetup(const juce::String& inputName, - const juce::String& outputName, - double sampleRate, - int bufferSize); - DeviceConfigResult applySplitSetup(const DeviceConfig& config); + // Probe/apply/teardown moved to engine/DeviceSetup (TLC phase 4); + // setAudioDevices stays here as the orchestrator. void teardownSplitMode(); // Duplex mode: inputDeviceManager owns both directions, outputDeviceManager idle. @@ -435,6 +406,8 @@ private: // untouched; extracted units take `state` (EngineState&) directly. slopsmith::EngineState state; std::atomic& duplexMode = state.duplexMode; + // Probe/apply/teardown component (TLC phase 4). Holds references only. + slopsmith::DeviceSetup deviceSetup{ inputDeviceManager, outputDeviceManager, state }; // Per-input capture+detect+monitor chains. A FIXED pool, all constructed up // front, so adding/removing a source never reassigns a pointer the audio diff --git a/src/audio/CMakeLists.txt b/src/audio/CMakeLists.txt index 9da8379..629e1af 100644 --- a/src/audio/CMakeLists.txt +++ b/src/audio/CMakeLists.txt @@ -8,6 +8,7 @@ set(AUDIO_SOURCES AudioEngine.cpp engine/StreamSink.cpp engine/BackingPlayer.cpp + engine/DeviceSetup.cpp SourceChain.cpp SignalChain.cpp VSTHost.cpp diff --git a/src/audio/engine/DeviceSetup.cpp b/src/audio/engine/DeviceSetup.cpp new file mode 100644 index 0000000..07160fd --- /dev/null +++ b/src/audio/engine/DeviceSetup.cpp @@ -0,0 +1,623 @@ +// DeviceSetup implementation — moved verbatim from AudioEngine.cpp (TLC plan +// phase 4 / §2.7). The only edits beyond member renames are the extraction of +// the three previously hand-synced helpers (ratesMatch / resolveDeviceName / +// rateSupportedBy), which each site now calls instead of open-coding. + +#include "DeviceSetup.h" + +#include +#include +#include + +namespace slopsmith { + +juce::String DeviceSetup::resolveDeviceName(juce::AudioIODeviceType* t, + bool isInput, const juce::String& name) +{ + if (t == nullptr || name.isNotEmpty()) return name; + auto names = t->getDeviceNames(isInput); + return names.size() > 0 ? names[0] : name; +} + +bool DeviceSetup::rateSupportedBy(juce::AudioIODeviceType* t, const juce::String& dev, + bool isInput, double sr) +{ + // v1 forces matching nominal SR — no adaptive resampler yet. Resolve empty + // name to first-enumerated for the createDevice probe call (matches + // probeDual's strategy). createDevice("") is implementation-defined per + // backend — some return the default, some return null. Using + // first-enumerated keeps probe and apply checking the SAME concrete + // device, so an empty-name config can't pass the UI probe and then fail + // this check. + if (!t) return false; + const juce::String resolved = resolveDeviceName(t, isInput, dev); + std::unique_ptr probe( + isInput ? t->createDevice({}, resolved) : t->createDevice(resolved, {})); + if (!probe) return false; + // Tolerance matches the probe-side rounding: probeDual rounds the matched + // rate to the nearest integer, so a backend reporting e.g. 47999.5 + // surfaces 48000 in the UI. If we kept `< 0.5` here, the round-trip would + // fail at apply time because |47999.5 - 48000.0| is exactly 0.5. + for (auto r : probe->getAvailableSampleRates()) + if (ratesMatch(r, sr)) return true; + return false; +} + +DeviceOptions DeviceSetup::probeDual(const juce::String& inputTypeName, + const juce::String& inputName, + const juce::String& outputTypeName, + const juce::String& outputName) +{ + DeviceOptions options; + options.inputType = inputTypeName; + options.outputType = outputTypeName.isEmpty() ? inputTypeName : outputTypeName; + options.type = options.inputType; // legacy alias + + // Resolve each side from its own manager so probe stays consistent with + // applySplit()/setOutputDeviceType(), which mutate the manager that owns + // the side they're configuring. Using the input manager for the output + // lookup would silently fall back to whatever input has scanned, which + // can miss output-only backends. + auto findType = [](juce::AudioDeviceManager& manager, + const juce::String& wanted) -> juce::AudioIODeviceType* { + juce::AudioIODeviceType* match = nullptr; + for (auto* type : manager.getAvailableDeviceTypes()) + { + if ((wanted.isNotEmpty() && type->getTypeName() == wanted) + || (wanted.isEmpty() && match == nullptr)) + { + match = type; + if (wanted.isNotEmpty()) break; + } + } + return match; + }; + + auto* inputType = findType(inMgr, options.inputType); + + // Match setAudioDevices's resolution: when the caller didn't specify + // an output type, default it to the SAME type the input side resolved + // to (using the type's name, looked up in the output manager). Without + // this, an empty `options.outputType` would let findType pick whatever + // the output manager enumerates first — potentially a different backend + // than the input manager picked from the empty string, which then + // disagrees with the apply path's duplex classification. + juce::String effectiveOutputTypeName = options.outputType; + if (effectiveOutputTypeName.isEmpty() && inputType != nullptr) + effectiveOutputTypeName = inputType->getTypeName(); + auto* outputType = findType(outMgr, effectiveOutputTypeName); + + if (inputType == nullptr) + { + options.error = "Input device type not found"; + options.compatible = false; + return options; + } + if (outputType == nullptr) + { + options.error = "Output device type not found"; + options.compatible = false; + return options; + } + + try + { + options.inputType = inputType->getTypeName(); + options.outputType = outputType->getTypeName(); + options.type = options.inputType; + + options.input = inputName; + options.output = outputName; + + // For probing we still need a concrete device to instantiate. + // Resolve empty names to first-enumerated ONLY for the probe-device + // creation below — DON'T write back into options.input/options.output; + // those flow to the UI and the apply path, which treat empty as + // "OS default" per side. + const juce::String probeInputName = resolveDeviceName(inputType, true, options.input); + const juce::String probeOutputName = resolveDeviceName(outputType, false, options.output); + + // Probe the SAME way setAudioDevices() will actually apply, or the + // startup auto-apply mis-fires: init() fail-closes on this probe's + // `compatible` verdict, so if the probe measures a combined duplex device + // but apply then opens split (or vice-versa), the verdict describes a + // config that won't be the one used — the classic symptom being "no audio + // until I press Apply". Duplex is only attempted for the SAME physical + // endpoint (a true single-clock device); two different endpoints of the + // same backend (USB cable in + separate speakers out) are two clocks and + // go split. Mirror setAudioDevices()'s sameEndpointIntent exactly. + bool isDuplex = (options.inputType == options.outputType) + && (options.input == options.output); + + if (isDuplex) + { + std::unique_ptr dev( + inputType->createDevice(probeOutputName, probeInputName)); + if (dev) + { + options.inputChannels = dev->getInputChannelNames(); + options.outputChannels = dev->getOutputChannelNames(); + for (auto rate : dev->getAvailableSampleRates()) + options.sampleRates.addIfNotAlreadyThere(rate); + for (auto size : dev->getAvailableBufferSizes()) + options.bufferSizes.addIfNotAlreadyThere(size); + } + else + { + isDuplex = false; + } + } + if (!isDuplex) + { + std::unique_ptr inDev( + inputType->createDevice({}, probeInputName)); + std::unique_ptr outDev( + outputType->createDevice(probeOutputName, {})); + if (!inDev || !outDev) + { + options.error = "Could not create dual probe devices"; + options.compatible = false; + return options; + } + + options.inputChannels = inDev->getInputChannelNames(); + options.outputChannels = outDev->getOutputChannelNames(); + + // Tolerance covers backends that report fractional drift around + // the nominal rate — ratesMatch is the same <= 0.5 the apply-side + // rateSupportedBy check uses, so the probe can't reject a + // boundary case the apply would accept (or vice versa). + const auto inRates = inDev->getAvailableSampleRates(); + const auto outRates = outDev->getAvailableSampleRates(); + for (auto r : inRates) + { + for (auto r2 : outRates) + { + if (ratesMatch(r, r2)) + { + // Midpoint-rounded clean nominal, fail-closed when the + // rounded value falls outside tolerance of either side + // — see nominalRateCandidate (RateMatch.h). + double candidate = 0.0; + if (nominalRateCandidate(r, r2, candidate)) + options.sampleRates.addIfNotAlreadyThere(candidate); + break; + } + } + } + if (options.sampleRates.isEmpty()) + { + options.error = "Input and output devices share no common sample rate"; + options.compatible = false; + } + + // Split mode opens both sides with the same bufferSize, so the + // UI should only see sizes the intersection of both devices + // supports — a union would let the user pick a value that + // predictably fails at apply time on one side. + const auto inBufs = inDev->getAvailableBufferSizes(); + const auto outBufs = outDev->getAvailableBufferSizes(); + for (auto b : inBufs) + { + for (auto b2 : outBufs) + { + if (b == b2) + { + options.bufferSizes.addIfNotAlreadyThere(b); + break; + } + } + } + // An empty intersection means there's no buffer size both sides + // accept; setting compatible=false stops the UI from re-enabling + // Apply against a guaranteed-fail config. + if (options.bufferSizes.isEmpty() && options.error.isEmpty()) + { + options.error = "Input and output devices share no common buffer size"; + options.compatible = false; + } + } + + fprintf(stderr, "[AudioEngine] Probed device options: inType='%s' outType='%s' in='%s' out='%s' " + "duplex=%d inputs=%d outputs=%d rates=%d buffers=%d compatible=%d\n", + options.inputType.toRawUTF8(), options.outputType.toRawUTF8(), + options.input.toRawUTF8(), options.output.toRawUTF8(), + (int) isDuplex, options.inputChannels.size(), options.outputChannels.size(), + options.sampleRates.size(), options.bufferSizes.size(), (int) options.compatible); + } + catch (const std::exception& e) + { + options.error = e.what(); + options.compatible = false; + } + catch (...) + { + options.error = "Probe failed"; + options.compatible = false; + } + + return options; +} + +juce::String DeviceSetup::applyDuplex(const juce::String& inputName, + const juce::String& outputName, + double sampleRate, int bufferSize, + SourceChain& monitorChain) +{ + juce::AudioDeviceManager::AudioDeviceSetup setup; + setup.inputDeviceName = inputName; + setup.outputDeviceName = outputName; + setup.sampleRate = sampleRate > 0 ? sampleRate : 48000.0; + setup.bufferSize = bufferSize > 0 ? bufferSize : 256; + setup.useDefaultInputChannels = inputName.isEmpty(); + setup.useDefaultOutputChannels = outputName.isEmpty(); + + // Channel masks must match too — high-numbered selectedInputChannel needs + // the expanded mask that an older session may not have opened. + if (auto* currentDevice = inMgr.getCurrentAudioDevice()) + { + try + { + juce::AudioDeviceManager::AudioDeviceSetup current; + inMgr.getAudioDeviceSetup(current); + + const int advertisedInputs = currentDevice->getInputChannelNames().size(); + juce::BigInteger expectedInputs; + expectedInputs.setRange(0, advertisedInputs > 0 ? advertisedInputs : 2, true); + + const int advertisedOutputs = currentDevice->getOutputChannelNames().size(); + juce::BigInteger expectedOutputs; + expectedOutputs.setRange(0, juce::jmin(advertisedOutputs > 0 ? advertisedOutputs : 2, 2), true); + + if (current.inputDeviceName == setup.inputDeviceName + && current.outputDeviceName == setup.outputDeviceName + && current.sampleRate == setup.sampleRate + && current.bufferSize == setup.bufferSize + && current.useDefaultInputChannels == setup.useDefaultInputChannels + && current.useDefaultOutputChannels == setup.useDefaultOutputChannels + && current.inputChannels == expectedInputs + && current.outputChannels == expectedOutputs + && state.duplexMode.load(std::memory_order_relaxed)) + { + fprintf(stderr, "[AudioEngine] Duplex device already configured with same settings, skipping\n"); + return {}; + } + } + catch (const std::exception& e) + { + fprintf(stderr, "[AudioEngine] Current device channel check failed: %s\n", e.what()); + } + catch (...) + { + fprintf(stderr, "[AudioEngine] Current device channel check failed (unknown)\n"); + } + } + + // ALSA deadlocks on reconfigure unless we fully close first. WASAPI + // reconfigures in place and is much slower if closed. +#if JUCE_LINUX + juce::String currentTypeName; + if (auto* currentType = inMgr.getCurrentDeviceTypeObject()) + currentTypeName = currentType->getTypeName(); + if (inMgr.getCurrentAudioDevice() != nullptr) + { + try { + inMgr.closeAudioDevice(); + fprintf(stderr, "[AudioEngine] Closed device for reconfiguration\n"); + if (currentTypeName.isNotEmpty()) + inMgr.setCurrentAudioDeviceType(currentTypeName, true); + } catch (...) { + fprintf(stderr, "[AudioEngine] closeAudioDevice crashed, continuing\n"); + } + } +#endif + + int inputChannelCount = 0; + int outputChannelCount = 0; + if (auto* type = inMgr.getCurrentDeviceTypeObject()) + { + try + { + if (auto probe = std::unique_ptr(type->createDevice(outputName, inputName))) + { + inputChannelCount = probe->getInputChannelNames().size(); + outputChannelCount = probe->getOutputChannelNames().size(); + } + } + catch (const std::exception& e) + { + fprintf(stderr, "[AudioEngine] Channel probe failed: %s\n", e.what()); + } + catch (...) + { + fprintf(stderr, "[AudioEngine] Channel probe failed (unknown)\n"); + } + } + if (inputChannelCount <= 0) inputChannelCount = 2; + if (outputChannelCount <= 0) outputChannelCount = 2; + + setup.inputChannels.setRange(0, inputChannelCount, true); + setup.outputChannels.setRange(0, juce::jmin(outputChannelCount, 2), true); + + juce::String result; + try { + result = inMgr.setAudioDeviceSetup(setup, true); + } catch (...) { + return "setAudioDeviceSetup threw"; + } + if (result.isNotEmpty()) + { + fprintf(stderr, "[AudioEngine] Device setup error: %s\n", result.toRawUTF8()); + try { + result = inMgr.initialiseWithDefaultDevices(2, 2); + } catch (...) { + return "fallback initialiseWithDefaultDevices threw"; + } + if (result.isNotEmpty()) + return "device setup failed: " + result; + } + + if (auto* configuredDevice = inMgr.getCurrentAudioDevice()) + { + const double sr = configuredDevice->getCurrentSampleRate(); + const int bs = configuredDevice->getCurrentBufferSizeSamples(); + state.currentSampleRate.store(sr, std::memory_order_relaxed); + state.inputBlockSize.store(bs, std::memory_order_relaxed); + state.outputBlockSize.store(bs, std::memory_order_relaxed); + + fprintf(stderr, "[AudioEngine] Duplex device configured OK. Current device: %s\n", + configuredDevice->getName().toRawUTF8()); + fprintf(stderr, "[AudioEngine] Actual device setup: sr=%.0f bs=%d (requested bs=%d)\n", + sr, bs, bufferSize); + + monitorChain.prepareMonitorChain(sr, bs); + return {}; + } + state.currentSampleRate.store(0.0, std::memory_order_relaxed); + state.inputBlockSize.store(0, std::memory_order_relaxed); + state.outputBlockSize.store(0, std::memory_order_relaxed); + monitorChain.releaseMonitorChain(); + return "no current device after setup"; +} + +DeviceConfigResult DeviceSetup::applySplit(const DeviceConfig& config, + SourceChain& monitorChain, + OutputRing& outputRing, + std::atomic& outputUnderflowCount, + std::atomic& inputOverflowCount, + juce::AudioIODeviceCallback& outputCallback, + bool& outputCallbackRegistered) +{ + DeviceConfigResult res; + res.duplex = false; + + // The split-mode output ring is fixed at kOutputRingFrames samples + // (~85ms @ 48kHz). A single callback at bufferSize > kOutputRingFrames + // would overrun the ring in one go, guaranteeing immediate + // overwrite/wrap and audible glitches. Reject those configurations up + // front — duplex still works fine since it bypasses the ring entirely. + if (config.bufferSize > kOutputRingFrames) + { + res.error = "Buffer size " + juce::String(config.bufferSize) + + " exceeds split-mode ring capacity (" + + juce::String(kOutputRingFrames) + "). Pick a smaller buffer size or use duplex."; + return res; + } + + // setCurrentAudioDeviceType can throw from JUCE backends (ASIO). + // Catch so the failure surfaces as a structured error rather than an + // exception crossing the N-API boundary. + try + { + if (auto* current = outMgr.getCurrentDeviceTypeObject()) + { + if (current->getTypeName() != config.outputType) + outMgr.setCurrentAudioDeviceType(config.outputType, true); + } + else + { + outMgr.setCurrentAudioDeviceType(config.outputType, true); + } + } + catch (...) + { + res.error = "setCurrentAudioDeviceType threw for output type '" + config.outputType + "'"; + return res; + } + + juce::AudioIODeviceType* inputType = nullptr; + juce::AudioIODeviceType* outputType = nullptr; + for (auto* t : inMgr.getAvailableDeviceTypes()) + if (t->getTypeName() == config.inputType) { inputType = t; break; } + for (auto* t : outMgr.getAvailableDeviceTypes()) + if (t->getTypeName() == config.outputType) { outputType = t; break; } + if (!inputType || !outputType) + { + res.error = "Device type not found"; + return res; + } + if (!rateSupportedBy(inputType, config.inputDevice, true, config.sampleRate) + || !rateSupportedBy(outputType, config.outputDevice, false, config.sampleRate)) + { + res.error = "Sample rate not supported by both input and output devices"; + return res; + } + + juce::AudioDeviceManager::AudioDeviceSetup inSetup; + // Resolve empty name to first-enumerated input device — matches the + // rateSupportedBy preflight above AND probeDual. Using empty + + // useDefault*Channels here would make JUCE open the OS default, which can + // differ from inputs[0] on platforms where the OS-default differs from + // JUCE's enumeration order. The probe + SR preflight + actual open all + // need to agree on the same concrete device for the apply path to behave + // consistently with what the UI showed the user. + const juce::String resolvedInputName = resolveDeviceName(inputType, true, config.inputDevice); + + inSetup.inputDeviceName = resolvedInputName; + inSetup.outputDeviceName = ""; + inSetup.sampleRate = config.sampleRate; + inSetup.bufferSize = config.bufferSize; + inSetup.useDefaultInputChannels = false; + inSetup.useDefaultOutputChannels = false; + + int inputChannelCount = 0; + { + try { + std::unique_ptr probe(inputType->createDevice({}, resolvedInputName)); + if (probe) inputChannelCount = probe->getInputChannelNames().size(); + } catch (...) {} + } + if (inputChannelCount <= 0) inputChannelCount = 2; + inSetup.inputChannels.setRange(0, inputChannelCount, true); + inSetup.outputChannels.clear(); + + // Rollback helper: on any failure path after a side has been opened, + // close both managers' devices so we don't leave the OS audio resource + // held (sometimes exclusively, e.g. ASIO) while setDevice reports a + // failure. closeAudioDevice is idempotent so unconditional calls are + // safe even when only the input or neither side opened. + auto rollbackOpenedDevices = [&]() { + // Drop any callback we already attached to the output manager — + // closeAudioDevice() does not invoke removeAudioCallback, and leaving + // outputCallbackRegistered=true would cause the next startAudio() + // to skip the re-attach (it gates on !outputCallbackRegistered), + // leaving split-mode output silent after a partial-open failure. + if (outputCallbackRegistered) + { + try { outMgr.removeAudioCallback(&outputCallback); } catch (...) {} + outputCallbackRegistered = false; + } + try { inMgr.closeAudioDevice(); } catch (...) {} + try { outMgr.closeAudioDevice(); } catch (...) {} + }; + + // Mirror applyDuplex's JUCE_LINUX close-before-reconfigure pattern: + // ALSA deadlocks if we let setAudioDeviceSetup mutate a live device. The + // device type is re-asserted afterwards so the close doesn't drop us back + // to whatever JUCE picked at startup. closeAudioDevice/setCurrentAudioDeviceType + // throwing is non-fatal — we still try the setup below and surface its error. +#if JUCE_LINUX + { + juce::String currentInputTypeName; + if (auto* currentType = inMgr.getCurrentDeviceTypeObject()) + currentInputTypeName = currentType->getTypeName(); + if (inMgr.getCurrentAudioDevice() != nullptr) + { + try { + inMgr.closeAudioDevice(); + if (currentInputTypeName.isNotEmpty()) + inMgr.setCurrentAudioDeviceType(currentInputTypeName, true); + } catch (...) { + fprintf(stderr, "[AudioEngine] split-mode input close threw, continuing\n"); + } + } + } +#endif + + juce::String inErr; + try { inErr = inMgr.setAudioDeviceSetup(inSetup, true); } + catch (...) { res.error = "input setAudioDeviceSetup threw"; rollbackOpenedDevices(); return res; } + if (inErr.isNotEmpty()) { res.error = "input setup: " + inErr; rollbackOpenedDevices(); return res; } + + auto* inDev = inMgr.getCurrentAudioDevice(); + if (!inDev) { res.error = "no input device after setup"; rollbackOpenedDevices(); return res; } + const double inSr = inDev->getCurrentSampleRate(); + const int inBs = inDev->getCurrentBufferSizeSamples(); + + // Same first-enumerated resolution on the output side — see input note + // above for why this matches the probe + SR preflight strategy. + const juce::String resolvedOutputName = resolveDeviceName(outputType, false, config.outputDevice); + + juce::AudioDeviceManager::AudioDeviceSetup outSetup; + outSetup.inputDeviceName = ""; + outSetup.outputDeviceName = resolvedOutputName; + outSetup.sampleRate = config.sampleRate; + outSetup.bufferSize = config.bufferSize; + outSetup.useDefaultInputChannels = false; + outSetup.useDefaultOutputChannels = false; + + int outputChannelCount = 0; + { + try { + std::unique_ptr probe(outputType->createDevice(resolvedOutputName, {})); + if (probe) outputChannelCount = probe->getOutputChannelNames().size(); + } catch (...) {} + } + if (outputChannelCount <= 0) outputChannelCount = 2; + outSetup.inputChannels.clear(); + outSetup.outputChannels.setRange(0, juce::jmin(outputChannelCount, 2), true); + + // Same JUCE_LINUX close-before-reconfigure as the input side above — also + // protects when split mode is re-applied with a different output device. +#if JUCE_LINUX + { + juce::String currentOutputTypeName; + if (auto* currentType = outMgr.getCurrentDeviceTypeObject()) + currentOutputTypeName = currentType->getTypeName(); + if (outMgr.getCurrentAudioDevice() != nullptr) + { + try { + outMgr.closeAudioDevice(); + if (currentOutputTypeName.isNotEmpty()) + outMgr.setCurrentAudioDeviceType(currentOutputTypeName, true); + } catch (...) { + fprintf(stderr, "[AudioEngine] split-mode output close threw, continuing\n"); + } + } + } +#endif + + juce::String outErr; + try { outErr = outMgr.setAudioDeviceSetup(outSetup, true); } + catch (...) { res.error = "output setAudioDeviceSetup threw"; rollbackOpenedDevices(); return res; } + if (outErr.isNotEmpty()) { res.error = "output setup: " + outErr; rollbackOpenedDevices(); return res; } + + auto* outDev = outMgr.getCurrentAudioDevice(); + if (!outDev) { res.error = "no output device after setup"; rollbackOpenedDevices(); return res; } + const double outSr = outDev->getCurrentSampleRate(); + const int outBs = outDev->getCurrentBufferSizeSamples(); + + if (!ratesMatch(inSr, outSr)) + { + res.error = "Input and output devices opened at different sample rates"; + rollbackOpenedDevices(); + return res; + } + + state.currentSampleRate.store(inSr, std::memory_order_relaxed); + state.inputBlockSize.store(inBs, std::memory_order_relaxed); + state.outputBlockSize.store(outBs, std::memory_order_relaxed); + + fprintf(stderr, "[AudioEngine] Split mode configured: inSr=%.0f inBs=%d outSr=%.0f outBs=%d\n", + inSr, inBs, outSr, outBs); + + outputRing.reset(); + outputUnderflowCount.store(0, std::memory_order_relaxed); + inputOverflowCount.store(0, std::memory_order_relaxed); + + monitorChain.prepareMonitorChain(inSr, inBs); + + res.ok = true; + res.sampleRate = inSr; + res.inputBlockSize = inBs; + res.outputBlockSize = outBs; + return res; +} + +void DeviceSetup::teardownSplit(OutputRing& outputRing, + juce::AudioIODeviceCallback& outputCallback, + bool& outputCallbackRegistered) +{ + // Unconditional remove — JUCE's removeAudioCallback is idempotent + // (no-op if the callback isn't registered), so we don't need the + // outputCallbackRegistered guard here. This makes teardown robust + // against a stale flag left over from a previous failed split setup. + outMgr.removeAudioCallback(&outputCallback); + outputCallbackRegistered = false; + try { outMgr.closeAudioDevice(); } + catch (...) { fprintf(stderr, "[AudioEngine] teardownSplitMode: output close threw\n"); } + + outputRing.reset(); +} + +} // namespace slopsmith diff --git a/src/audio/engine/DeviceSetup.h b/src/audio/engine/DeviceSetup.h new file mode 100644 index 0000000..3921e42 --- /dev/null +++ b/src/audio/engine/DeviceSetup.h @@ -0,0 +1,124 @@ +#pragma once + +// DeviceSetup — probe/apply/teardown for duplex + split device configs (TLC +// plan phase 4 / §2.7). Moved verbatim from AudioEngine; owns no lifetime — +// it holds references to the engine's two AudioDeviceManagers and its +// EngineState, and the engine-owned collaborators a specific operation needs +// (monitor chain, split output ring, output callback registration) are passed +// by reference at the call. setAudioDevices stays on the AudioEngine facade +// as the orchestrator (stop → resolve → duplex-or-split → restart). +// +// The rate-tolerance (`<= 0.5`, probe/preflight/verify), midpoint-rounding, +// and empty-name→first-enumerated resolution logic that used to live in three +// hand-synced copies is extracted into the shared helpers at the bottom — +// the deep-read §7 dedupe, landed structurally by this move. + +#include "EngineState.h" +#include "PackedStereoRing.h" +#include "RateMatch.h" +#include "../SourceChain.h" + +#include + +namespace slopsmith { + +// Public device-config shapes — aliased back as AudioEngine::DeviceOptions +// etc., so the NodeAddon surface is unchanged. +struct DeviceOptions +{ + juce::String type; // legacy alias = inputType + juce::String inputType; + juce::String outputType; + juce::String input; + juce::String output; + juce::StringArray inputChannels; + juce::StringArray outputChannels; + juce::Array sampleRates; // intersection when dual-type + juce::Array bufferSizes; + bool compatible = true; // false when types share no usable sample rate + juce::String error; +}; + +struct DeviceConfig +{ + juce::String inputType; + juce::String inputDevice; + juce::String outputType; + juce::String outputDevice; + double sampleRate = 48000.0; + int bufferSize = 256; +}; + +struct DeviceConfigResult +{ + bool ok = false; + juce::String error; + double sampleRate = 0.0; + int inputBlockSize = 0; + int outputBlockSize = 0; + bool duplex = true; +}; + +class DeviceSetup +{ +public: + // Must equal the engine's split-mode ring capacity. + static constexpr int kOutputRingFrames = 4096; + using OutputRing = PackedStereoRing; + + DeviceSetup(juce::AudioDeviceManager& inputManager, + juce::AudioDeviceManager& outputManager, + EngineState& engineState) + : inMgr(inputManager), outMgr(outputManager), state(engineState) {} + + // Probe what a (typeName, deviceName) pair supports — duplex when input + // and output are the same endpoint, else the dual/split intersection. + DeviceOptions probeDual(const juce::String& inputTypeName, + const juce::String& inputName, + const juce::String& outputTypeName, + const juce::String& outputName); + + // Open the combined (single-clock) duplex device on the input manager. + // Empty error string = success; on success stores the achieved format + // into EngineState and prepares `monitorChain`. + juce::String applyDuplex(const juce::String& inputName, + const juce::String& outputName, + double sampleRate, int bufferSize, + SourceChain& monitorChain); + + // Open input-only + output-only devices at a shared nominal rate. On + // success stores the achieved format, resets the split ring + counters, + // and prepares `monitorChain`. `outputCallback`/`outputCallbackRegistered` + // are needed by the partial-open rollback (a failure after the callback + // was attached must detach it, or the next startAudio() skips re-attach). + DeviceConfigResult applySplit(const DeviceConfig& config, + SourceChain& monitorChain, + OutputRing& outputRing, + std::atomic& outputUnderflowCount, + std::atomic& inputOverflowCount, + juce::AudioIODeviceCallback& outputCallback, + bool& outputCallbackRegistered); + + // Detach the output callback + close the output device + drain the ring. + void teardownSplit(OutputRing& outputRing, + juce::AudioIODeviceCallback& outputCallback, + bool& outputCallbackRegistered); + + // ── Shared helpers (the three previously hand-synced copies) ────────── + // ratesMatch / nominalRateCandidate live in RateMatch.h (JUCE-free, unit- + // tested); the device-name resolution helpers below need JUCE types. + // Empty device name → first-enumerated for that type/direction (probe, + // SR preflight, and split open must all check the SAME concrete device). + static juce::String resolveDeviceName(juce::AudioIODeviceType* t, + bool isInput, const juce::String& name); + // Whether `dev` (resolved) supports `sr` within tolerance. + static bool rateSupportedBy(juce::AudioIODeviceType* t, const juce::String& dev, + bool isInput, double sr); + +private: + juce::AudioDeviceManager& inMgr; + juce::AudioDeviceManager& outMgr; + EngineState& state; +}; + +} // namespace slopsmith diff --git a/src/audio/engine/RateMatch.h b/src/audio/engine/RateMatch.h new file mode 100644 index 0000000..7e11406 --- /dev/null +++ b/src/audio/engine/RateMatch.h @@ -0,0 +1,33 @@ +#pragma once + +// Pure sample-rate matching math shared by probe, preflight, and post-open +// verify (TLC phase 4, deep-read §7 — previously three hand-synced copies in +// AudioEngine.cpp). JUCE-free so tests/engine_units can pin the boundary +// cases the old sites narrated in comments. + +#include + +namespace slopsmith { + +// <= 0.5 (not <): a backend reporting 47999.5 against a 48000 nominal has +// |diff| = 0.5 exactly and must pass at every stage the probe accepted it. +inline bool ratesMatch(double a, double b) noexcept +{ + return std::abs(a - b) <= 0.5; +} + +// Given a matching in/out rate pair, the clean nominal the probe surfaces to +// the UI (backends sometimes report fractional near-48000 rates; the raw +// value would fail the apply-side setAudioDeviceSetup, which expects an exact +// supported nominal). Returns false when the rounded midpoint falls outside +// tolerance of either side — a matched pair like 48000.4/48000.6 passes the +// |r-r2| check but round(48000.5)=48000/48001 can sit 0.6 from one side; the +// probe stays fail-closed on those. +inline bool nominalRateCandidate(double r, double r2, double& candidate) noexcept +{ + if (!ratesMatch(r, r2)) return false; + candidate = std::round((r + r2) * 0.5); + return ratesMatch(r, candidate) && ratesMatch(r2, candidate); +} + +} // namespace slopsmith diff --git a/tests/engine_units/CMakeLists.txt b/tests/engine_units/CMakeLists.txt index 6eca508..b838a8f 100644 --- a/tests/engine_units/CMakeLists.txt +++ b/tests/engine_units/CMakeLists.txt @@ -20,3 +20,7 @@ add_test(NAME engine_state COMMAND engine_state_test) add_executable(renderer_bus_test renderer_bus_test.cpp) target_compile_features(renderer_bus_test PRIVATE cxx_std_20) add_test(NAME renderer_bus COMMAND renderer_bus_test) + +add_executable(rate_match_test rate_match_test.cpp) +target_compile_features(rate_match_test PRIVATE cxx_std_17) +add_test(NAME rate_match COMMAND rate_match_test) diff --git a/tests/engine_units/rate_match_test.cpp b/tests/engine_units/rate_match_test.cpp new file mode 100644 index 0000000..e9c9293 --- /dev/null +++ b/tests/engine_units/rate_match_test.cpp @@ -0,0 +1,46 @@ +// Phase 4 unit tests (docs/audio-engine-tlc.md §5): the rate-tolerance and +// midpoint-rounding boundary cases the three previously hand-synced sites in +// AudioEngine.cpp narrated in comments, now pinned against the one shared +// implementation in engine/RateMatch.h. + +#include "../../src/audio/engine/RateMatch.h" + +#include +#include + +using slopsmith::ratesMatch; +using slopsmith::nominalRateCandidate; + +int main() +{ + // Tolerance is <= 0.5 (not <): a backend reporting 47999.5 against a + // 48000 nominal sits exactly on the boundary and MUST pass — the probe + // accepted it, so preflight and post-open verify must too. + assert(ratesMatch(47999.5, 48000.0)); + assert(ratesMatch(48000.0, 47999.5)); + assert(ratesMatch(48000.0, 48000.0)); + assert(!ratesMatch(47999.4, 48000.0)); // 0.6 apart → reject + assert(!ratesMatch(44100.0, 48000.0)); + + double c = 0.0; + + // Exact pair → exact nominal. + assert(nominalRateCandidate(48000.0, 48000.0, c) && c == 48000.0); + + // Fractional drift on both sides rounds to the clean nominal. + assert(nominalRateCandidate(47999.5, 48000.0, c) && c == 48000.0); + assert(nominalRateCandidate(48000.4, 48000.1, c) && c == 48000.0); + + // Fail-closed midpoint case from the original comment: 48000.4/48000.6 + // passes the pair check (diff 0.2) but rounds to 48001 (midpoint 48000.5 + // rounds up), which is 0.6 from 48000.4 — outside tolerance of one side, + // so no candidate is surfaced. + const bool ok = nominalRateCandidate(48000.4, 48000.6, c); + assert(!ok && "midpoint-rounding must stay fail-closed"); + + // Non-matching pair → no candidate at all. + assert(!nominalRateCandidate(44100.0, 48000.0, c)); + + std::puts("rate_match: all cases passed"); + return 0; +}