feedBack-desktop/src/audio/engine/DeviceSetup.cpp
OmikronApex cd93159751 fix(audio): clear duplexMode on duplex setup failure; review cleanups
Address PR #114 review:
- failClosed now stores duplexMode=false so a failed reconfigure cannot
  leave the engine reporting duplex-active on a closed device.
- Drop the dead BigInteger initializers in the verify block.
- Move the applyDuplex locate-guard ahead of the source slice in the
  lifecycle test so marker drift fails with a clear message.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-17 14:03:56 +02:00

725 lines
32 KiB
C++

// 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 <cmath>
#include <cstdio>
#include <memory>
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<juce::AudioIODevice> 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<juce::AudioIODevice> 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<juce::AudioIODevice> inDev(
inputType->createDevice({}, probeInputName));
std::unique_ptr<juce::AudioIODevice> outDev(
outputType->createDevice(probeOutputName, {}));
if (!inDev || !outDev)
{
options.error = "Could not create dual probe devices";
options.compatible = false;
return options;
}
options.inputChannels = inDev->getInputChannelNames();
options.outputChannels = outDev->getOutputChannelNames();
// Tolerance covers backends that report fractional drift around
// the nominal rate — 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();
// Every unsuccessful reconfiguration must leave the manager and the
// externally readable engine format in one truthful state: closed/zero.
// In particular, never keep a stale ASIO device or the previous 256-sample
// state alive after a failed request for 512.
auto failClosed = [&](const juce::String& error) -> juce::String {
fprintf(stderr, "[AudioEngine] Duplex reconfigure failed: %s; closing device\n",
error.toRawUTF8());
try { inMgr.closeAudioDevice(); }
catch (...) {
fprintf(stderr, "[AudioEngine] Duplex failure cleanup: closeAudioDevice threw\n");
}
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);
state.duplexMode.store(false, std::memory_order_relaxed);
try { monitorChain.releaseMonitorChain(); }
catch (...) {
fprintf(stderr, "[AudioEngine] Duplex failure cleanup: monitor release threw\n");
}
return error;
};
// 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
&& currentDevice->isOpen()
&& 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 and Windows ASIO both need a full close before reconfiguration.
// The Helix driver was observed accepting a first request without changing
// its buffer, then wedging JUCE's message thread on the next in-place
// request. Close BEFORE the temporary channel probe too: constructing an
// ASIO device initialises the driver and briefly starts dummy buffers, so a
// probe must never overlap the live primary instance. WASAPI remains
// in-place because closing it is materially slower and this failure mode is
// specific to ASIO.
juce::String currentTypeName;
if (auto* currentType = inMgr.getCurrentDeviceTypeObject())
currentTypeName = currentType->getTypeName();
bool closeBeforeReconfigure = false;
#if JUCE_LINUX
closeBeforeReconfigure = true;
#elif JUCE_WINDOWS
closeBeforeReconfigure = (currentTypeName == "ASIO");
#endif
if (closeBeforeReconfigure && inMgr.getCurrentAudioDevice() != nullptr)
{
fprintf(stderr, "[AudioEngine] Duplex reconfigure phase=close begin type='%s'\n",
currentTypeName.toRawUTF8());
try {
inMgr.closeAudioDevice();
// AudioDeviceManager::closeAudioDevice() preserves its current
// device type/setup. setAudioDeviceSetup() below sees a null
// device and creates a fresh instance of that same type.
} catch (...) {
return failClosed("closeAudioDevice threw before reconfiguration");
}
fprintf(stderr, "[AudioEngine] Duplex reconfigure phase=close complete\n");
}
int inputChannelCount = 0;
int outputChannelCount = 0;
if (auto* type = inMgr.getCurrentDeviceTypeObject())
{
fprintf(stderr, "[AudioEngine] Duplex reconfigure phase=probe begin\n");
try
{
if (auto probe = std::unique_ptr<juce::AudioIODevice>(type->createDevice(outputName, inputName)))
{
inputChannelCount = probe->getInputChannelNames().size();
outputChannelCount = probe->getOutputChannelNames().size();
}
}
catch (const std::exception& e)
{
fprintf(stderr, "[AudioEngine] Channel probe failed: %s\n", e.what());
}
catch (...)
{
fprintf(stderr, "[AudioEngine] Channel probe failed (unknown)\n");
}
fprintf(stderr, "[AudioEngine] Duplex reconfigure phase=probe complete inputs=%d outputs=%d\n",
inputChannelCount, outputChannelCount);
}
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;
fprintf(stderr, "[AudioEngine] Duplex reconfigure phase=open begin sr=%.0f bs=%d\n",
setup.sampleRate, setup.bufferSize);
try {
result = inMgr.setAudioDeviceSetup(setup, true);
} catch (...) {
return failClosed("setAudioDeviceSetup threw");
}
fprintf(stderr, "[AudioEngine] Duplex reconfigure phase=open complete error='%s'\n",
result.toRawUTF8());
if (result.isNotEmpty())
{
// A default-device fallback used to convert this failure into success,
// leaving only two channels active while the UI saved the requested
// ASIO device. Preserve the original error and stay closed instead.
return failClosed("device setup failed: " + result);
}
if (auto* configuredDevice = inMgr.getCurrentAudioDevice())
{
if (!configuredDevice->isOpen())
return failClosed("device is not open after setup");
const double sr = configuredDevice->getCurrentSampleRate();
const int bs = configuredDevice->getCurrentBufferSizeSamples();
// For explicitly named endpoints, "all inputs / first two outputs" is
// the requested contract. Rebuild those masks from the opened device's
// advertised channels so a failed pre-open probe cannot silently
// collapse an 8-input ASIO interface to the old two-channel fallback.
juce::BigInteger expectedInputs;
if (setup.useDefaultInputChannels)
expectedInputs = setup.inputChannels;
else
expectedInputs.setRange(
0, configuredDevice->getInputChannelNames().size(), true);
juce::BigInteger expectedOutputs;
if (setup.useDefaultOutputChannels)
expectedOutputs = setup.outputChannels;
else
expectedOutputs.setRange(
0, juce::jmin(configuredDevice->getOutputChannelNames().size(), 2), true);
const auto actualInputs = configuredDevice->getActiveInputChannels();
const auto actualOutputs = configuredDevice->getActiveOutputChannels();
const bool inputChannelsMatch =
setup.useDefaultInputChannels || actualInputs == expectedInputs;
const bool outputChannelsMatch =
setup.useDefaultOutputChannels || actualOutputs == expectedOutputs;
fprintf(stderr,
"[AudioEngine] Duplex reconfigure phase=verify requested(sr=%.0f bs=%d in=%s out=%s) "
"actual(sr=%.0f bs=%d in=%s out=%s)\n",
setup.sampleRate, setup.bufferSize,
expectedInputs.toString(2).toRawUTF8(),
expectedOutputs.toString(2).toRawUTF8(),
sr, bs,
actualInputs.toString(2).toRawUTF8(),
actualOutputs.toString(2).toRawUTF8());
switch (validateOpenedDeviceFormat(
setup.sampleRate, setup.bufferSize, sr, bs,
inputChannelsMatch, outputChannelsMatch))
{
case DeviceFormatMismatch::sampleRate:
return failClosed(
"device opened at sample rate " + juce::String(sr)
+ " (requested " + juce::String(setup.sampleRate) + ")");
case DeviceFormatMismatch::bufferSize:
return failClosed(
"device opened at buffer size " + juce::String(bs)
+ " (requested " + juce::String(setup.bufferSize) + ")");
case DeviceFormatMismatch::inputChannels:
return failClosed(
"device opened with input channel mask "
+ actualInputs.toString(2) + " (requested "
+ expectedInputs.toString(2) + ")");
case DeviceFormatMismatch::outputChannels:
return failClosed(
"device opened with output channel mask "
+ actualOutputs.toString(2) + " (requested "
+ expectedOutputs.toString(2) + ")");
case DeviceFormatMismatch::none:
break;
}
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 {};
}
return failClosed("no current device after setup");
}
DeviceConfigResult DeviceSetup::applySplit(const DeviceConfig& config,
SourceChain& monitorChain,
OutputRing& outputRing,
std::atomic<uint64_t>& outputUnderflowCount,
std::atomic<uint64_t>& 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<juce::AudioIODevice> probe(inputType->createDevice({}, resolvedInputName));
if (probe) inputChannelCount = probe->getInputChannelNames().size();
} catch (...) {}
}
if (inputChannelCount <= 0) inputChannelCount = 2;
inSetup.inputChannels.setRange(0, inputChannelCount, true);
inSetup.outputChannels.clear();
// Rollback helper: on any failure path after a side has been opened,
// close both managers' devices so we don't leave the OS audio resource
// held (sometimes exclusively, e.g. ASIO) while setDevice reports a
// failure. closeAudioDevice is idempotent so unconditional calls are
// safe even when only the input or neither side opened.
auto rollbackOpenedDevices = [&]() {
// Drop any callback we already attached to the output manager —
// closeAudioDevice() does not invoke removeAudioCallback, and leaving
// outputCallbackRegistered=true would cause the next startAudio()
// to skip the re-attach (it gates on !outputCallbackRegistered),
// leaving split-mode output silent after a partial-open failure.
if (outputCallbackRegistered)
{
try { 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<juce::AudioIODevice> probe(outputType->createDevice(resolvedOutputName, {}));
if (probe) outputChannelCount = probe->getOutputChannelNames().size();
} catch (...) {}
}
if (outputChannelCount <= 0) outputChannelCount = 2;
outSetup.inputChannels.clear();
outSetup.outputChannels.setRange(0, juce::jmin(outputChannelCount, 2), true);
// Same JUCE_LINUX close-before-reconfigure as the input side above — also
// protects when split mode is re-applied with a different output device.
#if JUCE_LINUX
{
juce::String currentOutputTypeName;
if (auto* currentType = 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