refactor(audio): extract DeviceSetup + shared rate-match helpers (phase 4)

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 <noreply@anthropic.com>
This commit is contained in:
OmikronApex
2026-07-14 00:27:51 +02:00
co-authored by Claude Fable 5
parent d8f5784c63
commit 6ace5a209a
8 changed files with 850 additions and 655 deletions
+8 -617
View File
@@ -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<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.
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<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");
}
}
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<juce::AudioIODevice> 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<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 { 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<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 = 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 ─────────────────────────────────────────────────────────────
+11 -38
View File
@@ -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 <juce_audio_devices/juce_audio_devices.h>
@@ -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<double> sampleRates; // intersection when dual-type
juce::Array<int> 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<bool>& 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
+1
View File
@@ -8,6 +8,7 @@ set(AUDIO_SOURCES
AudioEngine.cpp
engine/StreamSink.cpp
engine/BackingPlayer.cpp
engine/DeviceSetup.cpp
SourceChain.cpp
SignalChain.cpp
VSTHost.cpp
+623
View File
@@ -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 <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();
// 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<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");
}
}
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<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
+124
View File
@@ -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 <juce_audio_devices/juce_audio_devices.h>
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<double> sampleRates; // intersection when dual-type
juce::Array<int> 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<kOutputRingFrames>;
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<uint64_t>& outputUnderflowCount,
std::atomic<uint64_t>& 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
+33
View File
@@ -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 <cmath>
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
+4
View File
@@ -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)
+46
View File
@@ -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 <cassert>
#include <cstdio>
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;
}