mirror of
https://github.com/got-feedBack/feedBack-desktop.git
synced 2026-07-21 20:31:28 +00:00
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>
725 lines
32 KiB
C++
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
|