mirror of
https://github.com/got-feedBack/feedBack-desktop.git
synced 2026-09-10 23:04:09 +00:00
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:
co-authored by
Claude Fable 5
parent
d8f5784c63
commit
6ace5a209a
+8
-617
@@ -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
@@ -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
|
||||
|
||||
@@ -8,6 +8,7 @@ set(AUDIO_SOURCES
|
||||
AudioEngine.cpp
|
||||
engine/StreamSink.cpp
|
||||
engine/BackingPlayer.cpp
|
||||
engine/DeviceSetup.cpp
|
||||
SourceChain.cpp
|
||||
SignalChain.cpp
|
||||
VSTHost.cpp
|
||||
|
||||
@@ -0,0 +1,623 @@
|
||||
// DeviceSetup implementation — moved verbatim from AudioEngine.cpp (TLC plan
|
||||
// phase 4 / §2.7). The only edits beyond member renames are the extraction of
|
||||
// the three previously hand-synced helpers (ratesMatch / resolveDeviceName /
|
||||
// rateSupportedBy), which each site now calls instead of open-coding.
|
||||
|
||||
#include "DeviceSetup.h"
|
||||
|
||||
#include <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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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)
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user