Post-open verification previously failed closed on any buffer-size delta on
every backend. The wedge this guards against (a driver accepting a request
without changing its buffer, then blocking the next in-place request) is
ASIO behaviour; ALSA rounds requests to period constraints and CoreAudio
can clamp, and both previously worked by storing the driver-adjusted
actuals. Keep exact buffer equality for ASIO only; other backends log and
accept the adjusted size. Rate and channel-mask verification stay strict
everywhere.
Also from review: print the real `options.compatible` in the live-probe log
instead of a hard-coded 1, and document that the live-endpoint reuse in
probeDual is safe only under runDeviceLifecycleOp's message-thread
serialisation (PR #113).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Address PR #114 review:
- failClosed now stores duplexMode=false so a failed reconfigure cannot
leave the engine reporting duplex-active on a closed device.
- Drop the dead BigInteger initializers in the verify block.
- Move the applyDuplex locate-guard ahead of the source slice in the
lifecycle test so marker drift fails with a clear message.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Review follow-up (PR #113, finding 1): ProbeDeviceOptions constructs and
destroys short-lived ASIO device objects (DeviceSetup::probeDual /
rateSupportedBy createDevice) on the Node thread - the same
create/destroy-off-the-message-thread pattern as the lifecycle ops, and a
probe ASIOAudioIODevice owns the same reset-timer machinery. Wrap it in
runDeviceLifecycleOp for consistency.
Finding 2 (timeout desync - op reported failed may still complete late)
is documented as a known limitation on the helper.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
A null MessageManager (pre-init or mid-shutdown) previously fell through
to inline execution on the caller's thread while reporting success -
exactly the unserialised device teardown this helper prevents. Return
false instead, per the documented unavailable/timeout contract.
Addresses CodeRabbit review on #113.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Tester crash (dump 2026-07-15, Focusrite USB ASIO): the driver's deferred
kAsioResetRequest fires a juce::Timer on the addon's JUCE message thread
(ASIOAudioIODevice::timerCallback -> reloadChannelNames) while the Node
thread concurrently destroys the device inside setAudioDevices/stopAudio —
use-after-free, ~5 minutes after every launch.
New runDeviceLifecycleOp() marshals every binding that can create or
destroy a juce::AudioIODevice (setDevice, device-type switches, start/stop,
stream output open/close, extra-input bind/unbind, add/removeSource) onto
the message thread on Windows, serialising them with those timers. Inline
on macOS (dispatch already inline) and Linux (ALSA main-thread contract
unchanged), and inline when already on the message thread to avoid
self-deadlock. Closures capture by value and return through shared_ptr so
a timed-out dispatch that runs late can't touch the caller's dead stack.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Review of #112: syncSelectedInputSource() treated an empty device name as
"nameless device — invalidate rather than guess" and called removeItem()
on the persisted selection. But the input dropdown's first option is
literally `<option value="">Default</option>`, so "" is the ordinary
"use the OS default" choice, not a nameless device.
init()'s auto-apply calls this on every startup, so a user sitting on
Default had their capability selection deleted at each launch — and that
selection is made in a DIFFERENT ui (the input_setup / tuner picker), so
this silently discarded a device they explicitly chose. Because
audioInputOpenHandler deliberately refuses to guess a device, a cleared
selection leaves plugins with no input at all: the same dead-guitar
symptom the PR set out to fix. Confirmed against the pre-fix build — a
sync with the Default value emits removeItem on the stored key.
"" now means "no opinion": leave the selection to the picker that owns it.
Also extract inputSourceNameKey() as the single place a named input's key
is built. Registration and selection were formatting the same template
independently and had already drifted on the nameless branch (registration
falls back to a positional key; sync emitted none). Tests pin the format,
the Default behaviour, and a round-trip through the open handler's own
parser regex.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
audioInputOpenHandler unconditionally called setDevice, so opening the
selected input (tuner/note_detect on song start) restarted the engine
even when the requested device was already bound — an audible dropout
and the 'Audio paused unexpectedly' seen in tester logs. Now the
handler reads the engine's actual binding (isAudioRunning +
getCurrentDevice) and returns the bound identity without touching the
device when input name and type already match; any read failure falls
through to the normal rebind path.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
A stale feedBack.audioInput.selectedLogicalSourceKey (persisted by the
audio-session capability) survived device changes made in the audio
settings screen. The next plugin to open the 'selected input' (tuner /
note_detect on song start) re-applied the stale device via
audioInputOpenHandler, clobbering the engine's input mid-session —
seen in the field as guitar input going dead when starting a song
(vorrin logs: ASIO M-Audio input replaced by 'Microphone (WO Mic
Device)' ~0.5s after playSong, input level 0.0005).
Fix: after a successful device apply (Apply button and init auto-
apply), select the matching source in the audio-session capability so
its in-memory selection and persisted key follow the user's choice;
fall back to rewriting/removing the localStorage key directly when the
capability or source isn't available yet.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
When the GPU process can't launch or keeps crashing, Chromium's default is to
give up and FATAL-abort the whole browser process — the user sees the app vanish
("GPU process isn't usable. Goodbye."). We hit this repeatedly: a machine with a
flaky GPU stack took the app down mid-song, and it is the likely reason behind
the "gig always defaults to the classic 2D highway" reports — those machines are
one GPU hiccup away from a crash, not just a fallback.
--disable-gpu-process-crash-limit tells Chromium to keep the browser alive and
fall back to software rendering instead of aborting. Software rasterization
stays enabled (we never pass --disable-software-rasterizer), so there is a path
to land on. The 3D highway then degrades to 2D / runs slow under SwiftShader —
far better than the whole app dying. Cross-platform, set before whenReady.
Validated against a build that reliably FATAL-crashed within seconds on GPU
process launch failure: with the switch it stayed alive 40s+ and never hit the
fatal path — Chromium fell back instead of aborting.
Also adds child-process-gone / render-process-gone log handlers: now that the
app SURVIVES a dead GPU, that log is the only remaining signal it happened,
which is exactly what a "highway is 2D / app was crashing" report needs to be
diagnosable. Log-only.
typecheck + lint clean.
The same finding as the clearChain/remove/moveProcessor fix, in a path I
missed on the first pass. CodeRabbit pointed at this line for the wrong reason
(it claimed the mutation was unguarded — it is guarded); the real defect is
WHERE the guard is taken.
LoadVST's macOS branch took a blocking lock_guard on chainMutationMutex on the
Node/main thread. On macOS that thread is also JUCE's message thread, and it
has no pump — its queue is drained by a libuv timer that only runs when the
thread is idle.
Meanwhile a LoadPresetWorker holding that mutex on a libuv thread calls JUCE's
*synchronous* createPluginInstance, which — when called off the message thread,
which is exactly where a worker calls it — posts an AsyncCreateMessage to the
message thread and blocks until it runs (juce_AudioPluginFormat.cpp,
createInstanceFromDescription). So: main thread blocks on the mutex → the
message queue stops draining → the worker's load never completes → the mutex is
never released → the app hangs permanently. Adding a VST while a preset load is
in flight was enough to trigger it.
The in-code comment asserted this was deadlock-safe because "loadVstSandboxAware's
JUCE_MAC branch is a synchronous load on the worker itself" — but JUCE's sync
load is not synchronous off the message thread, which is what makes the cycle.
The plugin INSTANTIATION has to stay on the main thread (JUCE requires it for
VST/AU on macOS), so only the mutation moves: it now goes through
queueChainSlotMutation(), a slot-id-returning sibling of queueChainMutation().
While the worker waits for the mutex the main thread stays free to drain the
queue, so the in-flight load completes and releases it.
The branch is portable C++, so it was compile-checked on Linux by forcing the
#if; the macOS addon lane is the real gate.
CodeRabbit caught a real bug in the previous commit's fix. The latch mirrors
the NATIVE refcount, but it was flipped before the invoke resolved: a rejected
release left it reading "released" while the engine still held the
suppression, so every later release short-circuited and monitor mute stayed
suppressed for good — the same stuck-suppression bug the latch exists to
prevent, just one level up.
The latch now only stays flipped if the call actually landed, and rolls back
otherwise (guarded so a newer call can't be clobbered by a stale rejection). A
downlevel addon with no arbiter leaves the latch untouched instead of
recording a hold it never acquired.
Pins the whole contract with a vm-extracted unit test on the real screen.js
function: unpaired acquires hold at most one native suppression, cycles stay
balanced across 25 song loads, a rejected release retries, and both the
downlevel and sync-throw paths are clean. Fails 3/5 against the original
branch (the refcount leak) and 2/5 against the pre-rollback version.
Seven fixes on top of the audio-engine TLC branch, each with the gate that
catches its regression.
Blocking:
- Monitor-mute suppression leaked its refcount. setMonitorMuteSuppressed()
became a refcounted acquire/release, but screen.js's callers are
deliberately unpaired: resolveChainRebuildGuard() leaves the suppression on
when a rebuild yields an empty chain, and returns early without releasing
while a provider route is still resolving. Harmless against the old latched
bool, a permanent +1 each against a refcount — after a failed tone rebuild
the count never returned to zero and monitor mute was silently dead for the
rest of the session. The renderer now holds at most one suppression.
- Slot ids are monotonic HANDLES (nextSlotId, never reset by clear()), not
bounded indices, so argSlotId's 4096 ceiling meant that once a session
created its 4096th processor EVERY guarded binding — setBypass,
setParameter, remove/moveProcessor, open/closePluginEditor — silently
no-opped for the rest of the run. Ceiling removed (same for
SetMultiBypass's hardcoded 4096); unknown ids are still rejected by
SignalChain::findSlotIndex.
- clearChain / removeProcessor / moveProcessor took chainMutationMutex with a
blocking lock_guard on the N-API thread — Electron's main thread, and on
macOS also the JUCE message thread. LoadPreset/LoadVST hold that mutex
across an unbounded plugin init (done->wait() has no timeout by design), so
a slow plugin froze the whole main process, every IPC channel with it. They
now queue on a libuv worker via queueChainMutation() and resolve a promise;
the bridge awaits them so callers still observe the mutation applied.
Also:
- getChainState() dereferenced raw ProcessorSlot* returned by getAllSlots()
after the lock was dropped — a concurrent clear() frees them under the
reader. Replaced with SignalChain::getSlotSummaries(), which copies under
the lock. getAllSlots() is gone (it had one caller).
- The device-settings migration removed the localStorage copy even when the
file-store save failed or was unavailable, losing the user's settings.
- SetSlotState and GetParameters kept the raw Int32Value() path: IsNumber()
is true for NaN, so setSlotState(NaN) wrote onto slot 0 — the same
coercion class the rest of the branch fixed.
- RendererBus flushed to the LIVE writeIndex, so a disable→re-enable with no
pull in between discarded the freshly pushed audio along with the stale
tail. It now snapshots the flush target at disable time.
- LoadPreset's rebuild barrier is now released by a scope guard, so a throw
between arming it and Queue() can't block editor opens forever.
Gates: new renderer-bus case (fails on the old flush), new slot-id-handle
case (fails on the old ceiling). ctest 9/9, npm test 79 pass / 0 fail,
chain-mutation storm green, addon export contract unchanged.
PR #107 round-2 review: beginChainRebuild() ran before
info[0].As<Napi::String>(), so a non-string argument threw between begin
and the worker taking ownership — leaking the barrier and blocking editor
opens permanently. Validate + read the argument first; the barrier is now
armed only on a path where every exit releases it.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
All 8 CodeRabbit findings verified against the code and fixed:
- ChainOps: macOS LoadVST routes its addProcessor through chainMutationMutex
(macOS is a first-class platform; deadlock-safe — a worker holding the
mutex never waits on the Node/main thread there). All four single-slot
workers (LoadVST/NAM/IR/ReplaceIR) now bump chainGeneration so the
executor's foreign-write detection sees direct loads, not just presets.
- Rebuild barrier (beginChainRebuild/endChainRebuild): LoadPreset and
ClearChain arm it before editor teardown; OpenPluginEditor refuses to
open while a teardown+clear/rebuild is pending (#56 window between
closeAllPluginEditorWindows returning and the worker taking the mutex).
- EditorWindows: all slot/processor resolution in editor lambdas runs under
a try_lock of chainMutationMutex (try_lock, never blocking — workers
holding the mutex block-wait on the message thread). Sandbox promotion
bumps chainGeneration. editorWindows map is now message-thread-only
(duplicate-window check and close-erase moved into the queued lambdas).
Null slot->processor recheck after a faulted promotion capture.
- closeAllPluginEditorWindows returns false on refused post / 15s timeout;
ClearChain skips the clear and LoadPreset resolves {success:false}
instead of freeing processors under a live editor.
- AddonContext: dispatchOnMessageThread reports refused-post/timeout;
doShutdown leaves the message thread running when teardown didn't
complete instead of unloading mid-destruction.
- RendererBus::push rejects NaN/Inf/non-positive rates and a step that
underflows to zero; new testRejectsUnusableRates unit case.
Verified: addon builds clean, all 78 JS tests pass (storm, contracts,
executor, N-API fuzz), all 5 engine_units native tests pass.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Both tests pre-dated this branch and failed only on Windows checkouts — the
product code was correct in both cases:
- audio-effects-executor 'preload exposes the trusted surface': asserted a
byte-exact two-line bridge snippet with \n, which never matches a
core.autocrlf (CRLF) working tree. Line endings are now normalized before
the includes checks.
- config-paths 'SAFETY: ... ONLY in optInExtras': rebuilt the expected ML
cache paths with host-native path.join, producing backslash paths that
never equal the forward-slash simulated envs — failing the mlCaches
equality and, worse, making the protected-root child checks vacuously
pass on Windows (a silent coverage gap in the safety assertions). The
test now uses the envs' resolved torchHome/hfHome fields, exactly what
production returns, with '/' as the child separator.
npm test: 78/78 passing (1 quarantined storm gate, green under CHAIN_STORM=1).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Deep-read §5: latency had three unreconciled truths — getLatencyMs' static
half-capacity ring guess (42.7 ms), the verifier's input-delta-only offset,
and the renderer bus adding prime+fill+resample that no figure surfaced.
New engine API + export: per-term breakdown (deviceBufferMs, input/output
driver latency, MEASURED split-ring residency, monitor total) plus the
renderer-bus song-audio delay (measured bus fill) as its own term. On the
user's split exclusive setup the measured ring sits at ~10 ms — the legacy
figure overstated monitor latency by ~33 ms (102.7 reported vs ~70 real).
getLatencyMs is unchanged for compatibility; UI adoption of the breakdown
is renderer follow-up. Snapshots regenerated (104 exports).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The device config was persisted in TWO stores — the main process's
file-backed settings AND localStorage['slopsmith-audio-device'] — merged on
load by newest-savedAt. A main-side migration/reset left stale localStorage
that could win the timestamp race and resurrect wiped settings, and a device
re-save from either path re-persisted mute flags captured at that moment,
interleaving with the (now-arbitrated) runtime mute writers.
The file store is now the only write target. localStorage is treated as a
one-time migration source: a strictly-newer browser copy is imported into
the file store, then the key is deleted either way — after the first load
the file is the single source of truth.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The old single monitorMuted atomic had five writers fighting
last-writer-wins: the settings checkbox, startup restore, the executor's
preload read-force-restore, releaseRoute's unconditional setMonitorMute(true)
(which clobbered the user's persisted preference), and the renderer's
song-load suppression (un-refcounted — overlapping windows un-suppressed
each other early).
Native arbiter on SourceChain: userMonitorMute (the preference — checkbox +
restore only), refcounted monitorMuteHolds (force-mute overrides), and
refcounted suppressions (setMonitorMuteSuppressed keeps its bool surface;
true=acquire, false=release, clamped at 0). Effective dry-mute =
(holds || pref) && chain empty && no suppression — the suppressed-beats-muted
precedence is unchanged. New exports: acquire/releaseMonitorMuteHold,
getMonitorMuteState (diag); snapshots regenerated.
Executor rewrite: acquires a suppression (dry-during-load, the default) or a
hold, and releases exactly what it acquired via a single-fire closure that
runs UNCONDITIONALLY (each load owns its acquisition — the stale-snapshot
race against a mid-hold user toggle is structurally gone). releaseRoute no
longer touches mute state at all. The ownership test now pins: preference
API never called, acquire/release balanced.
Renderer callers are unchanged: the checkbox writes the preference as
before, and the song-load suppression sites now compose instead of racing.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Splits the remaining 88 handlers out of NodeAddon.cpp, grouped to match the
preload API sections (plan §3.5): DeviceBindings (enumeration/selection/
audio-control/stream sink), ControlBindings (gain/metering/MIDI/debug
logging), DetectionBindings (pitch/chart/verdict/source-indexed, owns the
shared getValidatedSource), ChainBindings (slot/state/preset), and
BackingBindings. Declarations live in addon/Bindings.h; NodeAddon.cpp keeps
Init/Shutdown and the exports table — which now doubles as the API index the
old 3699-line file lacked — at 459 lines.
This completes the Part IV decomposition: AudioEngine.{h,cpp} 819+3223 →
509+1284 across seven engine/ units; NodeAddon.cpp 3699 → 459 across seven
addon/ units. All gates green (contract-check, storm, arg-fuzz, full suite).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Moves the five chain-mutating async workers (LoadPreset/LoadVST/LoadNAM/
LoadIR/ReplaceIR), their N-API handlers, loadVstSandboxAware, and the shared
load helpers (decodeStateBlob, loadSafeSampleRate/BlockSize) verbatim into
src/audio/addon/ChainOps.cpp — joining the phase-7a serialization primitives
in their planned home (§3.3). NodeAddon keeps using-declarations; the export
table is unchanged. Storm and arg-fuzz gates stay green.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Moves the in-process plugin editor cluster — PluginEditorWindow, the
slotId→window map, the message-thread teardown pair (closeAll / destroyAll,
the #56 use-after-free guards), OpenPluginEditor with the full Windows
sandbox-promotion flow, and ClosePluginEditor — verbatim into
src/audio/addon/EditorWindows.{h,cpp}. NodeAddon keeps using-declarations;
the export table and ClearChain/LoadPreset teardown calls are unchanged.
The two bindings pick up NapiHelpers slot-id validation while moving (the
same deep-read §2 fix the other bindings got in phase 6 — a NaN slot id
used to coerce to slot 0 and open/close the wrong editor).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The JS half of the phase-7a serializer (TLC Part II §1, executor-state
hazard): the executor's stageSlots map (stageId → native slotId) is built at
load time, but any direct loadPreset/clearChain from the audio_engine bundle
or rig_builder's legacy path silently invalidated it — subsequent
setStageBypass/setStageParameter/activateSegment flipped bypass/params on
the WRONG slots or returned no-target with nothing detecting the divergence.
Now: the route records the chainGeneration its load returned; every stage
operation compares it against getChainGeneration() first and reports a
stale-route no-target ('re-load the plan', with expected/current generations)
instead of mutating someone else's chain. loadChainPlan also verifies the
generation didn't move between its loadPreset and the getChainState slot
mapping, rolling back if a foreign write landed in that window. Old addons
without the counter degrade gracefully (checks no-op).
Pinned by a new executor test: fresh route flows, foreign bump → all three
stage ops refuse without touching native slots.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Two deep-read fixes now homed in their phase-1/2 units:
RendererBus (§4): setEnabled(false) no longer writes readIndex from the
control thread — the ring's designated consumer-side writer is pull(). The
drop-on-disable is now a flushRequested atomic the consumer honors at its
next pull, closing the last SPSC-discipline hole (a concurrent pull
mid-drain could overwrite the control thread's store and replay a stale
tail after re-enable). New unit test pins flush-then-fresh-audio.
setAudioDevices (§3): the restart decision reads state.userWantsAudio
(intent, written only by start/stopAudio) instead of the racy device-state
flag that transient audioDeviceStopped() fires clear — a reconfigure landing
inside a transient-stop window no longer leaves the engine configured but
stopped ('no audio until Start/Apply is pressed again').
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The single highest-value fix of the TLC pass (deep-read §1): one native
chain-mutation mutex (addon/ChainOps) held across the FULL Execute() of
every chain worker (LoadPreset/LoadVST/LoadNAM/LoadIR/ReplaceIR) and the
synchronous mutators (clearChain/removeProcessor/moveProcessor). Two
overlapping loadPreset calls can no longer interleave clear()/addProcessor()
into a merged-garbage chain — the plugin-vs-plugin fight becomes
last-writer-wins.
chainGeneration (monotonic, bumped under the mutex) is returned in loadPreset
results and exposed as getChainGeneration (new export, snapshot regenerated),
so the audio-effects executor can detect a foreign write invalidated its
stageSlots map and re-sync instead of flipping bypass/params on wrong slots —
the prerequisite for the single-chain-owner ownership track.
Also rides here: LoadPresetWorker's slot-state restore goes through
setSlotState() instead of const_cast (deep-read §9).
The phase-0 storm test flips from expected-fail to a hard gate: 50 iterations
of concurrent loadPreset now always end with exactly one caller's chain.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
AddonContext (src/audio/addon/): engine/vstHost lifetime + snapshot rule,
the JUCE message thread with the macOS no-pump fork quarantined into ONE
file, the shutdown latch (exposed as isShuttingDown), doShutdown with a UI
teardown hook (NodeAddon points it at the editor-window nuke, #56), and the
pending-async-load registry. NodeAddon keeps using-declarations so the
binding bodies are unchanged. Also fixes SetBackingSpeed's bare `engine`
dereference — the one binding that dodged the file's own snapshot rule.
NapiHelpers (typed extractors argInt/argSlotId/argFiniteFloat/argBool/
argMidiChannel/argMidiByte) + rewrites of the unguarded bindings — the
deep-read §2 fix, done once: SetParameter/SetBypass/RemoveProcessor/
MoveProcessor/SetMultiBypass/SendMidiToSlot/SetGain plus the St-1 routing
quartet (SetPan/SetPostGain/SetBranch/SetBranchSrc). NaN slot ids no longer
coerce to slot 0; MIDI channel/program are range-checked before JUCE.
New gate: tests/napi-arg-fuzz.test.js — table-driven garbage (NaN/Inf/
negative/string/missing/object) against the real addon; chain state must be
byte-identical after the storm and a valid call must still apply.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Moves the additional-input-device registry — InputDeviceSlot (manager,
callback, ring, scratches, latency delta, desired-name intent,
permanent-unbind flag), bind/unbind/closeSlot/reopenDesired, the bindable
enumeration, and the per-slot device-callback trio — verbatim into
src/audio/engine/ExtraInputs.{h,cpp}. Sources are prepared/released through
the bound SourcePool (same locking as before); the primary manager reference
serves the duplicate-binding check, latency delta, and enumeration. The
slots array stays public so the split output callback's ring-drain loop is
unchanged; addSource resolves per-slot readiness via resolveForSource().
The (typeName, name) device-identity limitation moves with its honest
comment — its fix lands here later without touching the engine again
(plan §2.3). Completes phase 5; live 28-stage split-mode probe on real
devices behaves identically to pre-move.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Moves the fixed SourceChain pool, add/remove/reclaim lifecycle, the
per-deviceKey callbacksInFlight quiescence handshake, deferred-release
parking, and mixSourcesForDevice verbatim into
src/audio/engine/SourcePool.{h,cpp}. Device callbacks now hold an RAII
CallbackGuard (identical increment/decrement points — no early returns
existed between them) and call pool.mixForDevice(); the device hooks use
prepare/releaseDeviceSources and withDeviceSources, preserving each site's
original locking (the primary about-to-start prepare loop stays deliberately
lockless, as before).
addSource's extra-device resolution (registry reads) stays on the engine
facade, which passes resolved readiness/format/latency into
pool.addResolved() — the pool has no dependency on the InputDeviceSlot
registry, which phase 5b extracts next.
Threaded storm unit test deferred (SourceChain is JUCE-linked; TSAN
unavailable on MSVC) — multi-source.test.js covers the pool through the
addon and is green against the rebuilt binary.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
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>
Moves the backing-track cluster — AudioFormatManager/reader/transport,
TimeSliceThread read-ahead, signalsmith-stretch state, lock-free speed
hand-off, BackingLeveler, playhead caches, and renderBackingBlockLocked —
verbatim into src/audio/engine/BackingPlayer.{h,cpp}.
Boundary per the plan (§2.4): control-thread lifecycle + non-blocking
getters live on the class; the RT mix POLICY (try-lock pattern, RMS
metering, volume fader, stream-submix capture) stays in the engine's output
callbacks via getLock()/readyLocked()/renderBlockLocked()/renderBuffer() —
both callbacks keep holding the try-lock through their stream publish, so
the render buffer is never read while prepare() can resize it. The volume
fader atomic and level meter stay engine-side.
Synthetic-reader unit tests deferred (JUCE-linked, same constraint as
StreamSink); covered by the backing play/seek/speed integration surface.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Promotes the streamer-mix output sink to a class owning its
AudioDeviceManager, drain callback, ring, scratches, submix compose
(publish, was composeAndPushStreamMix), and open/close/clear/reopen
lifecycle — moved verbatim into src/audio/engine/StreamSink.{h,cpp}. Bus
flags (includeBacking/includeGuitar/gain) and the level meter move in;
engine sample rate / output block size are read through the bound
EngineState&. AudioEngine keeps thin facades so the NodeAddon surface is
unchanged; the guitar-snapshot scratch stays on the engine (it snapshots
the engine's own mix).
Compose-matrix unit tests are deferred: they need juce::AudioBuffer, which
the JUCE-free engine_units harness doesn't link — covered meanwhile by the
stream under/overflow counters + level meter over IPC and the OBS-capture
manual smoke.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Moves the WebAudio→engine bus — ring, producer-side linear resampler,
prefill gate, fill clamp, metrics — verbatim into
src/audio/engine/RendererBus.h. AudioEngine keeps thin facades
(setRendererBus/pushRendererAudio/pullRendererBus/getRendererBusMetrics) so
the NodeAddon surface is unchanged. JUCE-free: pull() takes raw channel
pointers, which is what lets tests/engine_units drive the resampler
continuity, prime/underflow/clamp, and metrics cases without a device.
The control-thread readIndex write on disable (deep-read §4) is preserved
verbatim and marked; its flush-flag fix lands as the phase-8 commit.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Moves the shared run-state atomics (currentSampleRate, block sizes,
duplexMode, run flags) into slopsmith::EngineState (src/audio/engine/) so
later extracted units take EngineState& and stay unit-testable without JUCE
devices. AudioEngine binds the members back by reference under their
historical names — zero call-site churn, behavior-identical.
The old audioRunning conflated user intent with device state (deep-read
§3/§6); it is now state.deviceRunning (same semantics, isAudioRunning compat
pinned) plus a new state.userWantsAudio written only by startAudio/stopAudio.
Nothing reads the intent flag yet — phase 8 flips setAudioDevices' restart
decision onto it.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Replaces the four hand-maintained copies of the packed-LR SPSC design
(split-mode output ring, per-InputDeviceSlot rings, stream-sink ring,
renderer-bus ring) with slopsmith::PackedStereoRing<NFrames>
(src/audio/engine/PackedStereoRing.h). The template owns the storage,
power-of-two/lock-free asserts, pack/unpack, producer publish, reset, the
w<r resync, and the lapped catch-up; per-site consumer policy (pull-vs-
consume skew, renderer prime/fill-clamp) stays verbatim at the call sites.
Pure code move per the TLC plan — no behavior change; the renderer bus's
control-thread readIndex write on disable (deep-read §4) is deliberately
preserved and gets its flush-flag fix in phase 2.
Unit-tested in tests/engine_units/packed_stereo_ring_test.cpp: threaded
tear-freedom under lapping (2M frames), drop-oldest catch-up, index-reset
resync, pull-vs-consume skew.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Documents TLC deep-read §1: concurrent loadPreset workers interleave
clear()/addProcessor() and merge both presets — reproduces first iteration
([storm-ir-1-0, storm-ir-2-0, storm-ir-2-1]). Quarantined behind
CHAIN_STORM=1; flips to a hard gate when ChainOps lands the serializer
(plan phase 7).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
NaN/Inf from any JS caller (audio:setGain does no validation) previously
reached the gain atomics raw; a NaN master gain multiplies the whole device
output to NaN and poisons the peak meters (TLC deep-read §2). Clamp at the
four setters — the single choke point covering the legacy facade, the
source-indexed API, and the audio-effects executor.
Bounds 0..32 match the executor's clampGain (Phase 0.b compat pin); stream/
renderer-bus keep their historical 0..8 via the same JUCE-free helper, now
testable in the new tests/engine_units target (Phase 0.c harness).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Snapshot the three public surfaces the audio-engine decomposition must not
change (docs/audio-engine-tlc.md Part IV §4): addon export table, audio-bridge
IPC channels, preload audio/audioEffects API keys. contract-check.test.js
diffs regenerated surfaces against the committed snapshots.
result-shapes.json (golden result key/type shapes) is deferred until the
engine_units harness can run the addon against a null device.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Add got-feedback/feedback-plugin-bongocat to the clone_slopsmith plugin
list so CI/release builds ship Bongo Cat's Rhythm Trainer.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Adds an opt-in preference that launches the main window in fullscreen,
driven by feedBack core's Settings → System "Fullscreen" toggle. Wires
the window.feedBackDesktop.window bridge (getStartFullscreen /
setStartFullscreen) that core's setupWindowOptions() gates on, persists
the flag in the desktop config (DesktopConfig.startFullscreen, alongside
windowBounds), and passes `fullscreen: true` at BrowserWindow creation
when set.
Persistence lives here (not renderer localStorage) because the main
process must read the pref at window-creation time. setStartFullscreen
live-applies via setFullScreen so the toggle is responsive on
Windows/Linux; on macOS the first programmatic fullscreen-enter on a
window created windowed is dropped by AppKit, so there it takes effect on
next launch — the core Settings copy notes this. This intentionally
narrows the earlier "never launch fullscreen" default to an opt-in.
Signed-off-by: gionnibgud <gionnibgud@gmail.com>
Reported in testing: pop out the stem mixer, minimize it, and it is gone. Not
hidden — gone. It appears in no taskbar, no alt-tab list, nothing.
The chain:
- A pane window is skipTaskbar, so it never masquerades as a second fee[dB]ack.
- It is parented to the main window (so it can't go behind the app), which makes
it an OWNED window — and Windows will not give an owned window a taskbar
button in any case.
- On minimize we then hid it "to the tray".
So a minimized pane had no taskbar entry and no alt-tab entry, and the only route
back was the tray — which Windows tucks behind the overflow chevron by default.
"It's in the tray" is not an answer when the user cannot see the tray.
The window is no longer minimizable. Every remaining way to put a pane away is one
the user can undo from something visible:
- close it → the panel returns to the app, where it came from
- the tray → per-pane toggle, plus Show/Hide all panes
- the chip's stub → in the app, exactly where the panel used to be
Plus a restore() on 'minimize' in case anything else (a window manager, a
shortcut, a future code path) minimizes it anyway.
This is the same class of mistake as hiding the panel behind the pop-out chip: a
place to put something, with no way back that the user can find.
Signed-off-by: topkoa <topkoa@gmail.com>