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>
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>
A pane is a control surface for the thing you are looking at. Clicking the
highway to play — the single most common thing anyone does here — raises the
main window, and a plain sibling window slides straight behind it. You would
fish the mixer back out from behind the game every time you touched the game.
That is not a pop-out, it is a hiding place.
Parent each pane window to the main window. That is the precise amount of "in
front": the pane always floats above fee[dB]ack, and behaves like any other
window against everything else.
Deliberately NOT setAlwaysOnTop. That would put a pane above the user's browser
and editor too — a surprising thing to inflict on someone for opening a mixer.
alwaysOnTop still composes on top of this for a pane the user explicitly wants
above everything.
The trade, accepted: the OS ties parent and child together, so minimizing
fee[dB]ack hides its panes and restoring brings them back. That is what a
companion window should do.
Failure is non-fatal — a pane that can be buried is still a working pane.
Signed-off-by: topkoa <topkoa@gmail.com>
The tray menu asked "do we own a window for this pane?" and then acted on the
answer. Windows are destroyed asynchronously, so between the question and the
act the answer can go stale: hasWindow() says yes, the window is destroyed,
toggleWindow() returns false, the handler has already committed to the
main-process path and returns — and the click lands on nothing.
A tray item that silently does nothing is the worst possible failure here,
because the tray IS the recovery path when a pane is out of sight.
toggleWindow() already reports whether it did anything. Use that: if it toggled,
we're done; if it didn't, we never had that window (or just lost it), and only
the renderer can decide what opening the pane means — it might belong in the
dock, and its element lives there.
hasWindow()/hasPaneWindow() existed only to support the racy check, so they're
gone rather than left lying around for someone to reintroduce the race with.
Signed-off-by: topkoa <topkoa@gmail.com>
savedFor() assumed paneWindows[paneId] was an object. It isn't necessarily:
the desktop config is a JSON file on disk, hand-editable, and writable by any
build. `{"camera_director": null}` passes the own-property check and then throws
on `saved.bounds`.
This runs in the MAIN process, at window-adoption time. A TypeError there is not
a bad pane — it is the app failing.
Anything that isn't a plain object now degrades to "nothing saved", which is
exactly what an unreadable entry means. Same guard on `paneWindows` itself (a
string or an array would have got past `?? {}`).
persist() gets the same treatment, and for a sharper reason: it read the map,
copied every entry forward, and wrote it back. A corrupt entry would have been
faithfully preserved on every save — so a single hand-edit would keep crashing
the next launch, forever. Corrupt entries are now dropped rather than carried.
Signed-off-by: topkoa <topkoa@gmail.com>
1. pane:sync was accepted from ANY renderer with the preload bridge. Pane
windows are same-origin top-level frames, so preload's isMainFrame gate hands
them the bridge too — which means a pane window (or any allowed pop-up) could
send pane:sync and overwrite the tray's registry, most simply by pushing an
empty list and emptying the menu.
Exactly one renderer owns the pane registry. It is now accepted from that one
only: event.sender must be the main window's webContents.
2. sanitizeWindowBounds returned sizing.defaultWidth/Height unclamped. The min
clamp only runs when `saved` parses, so a caller whose defaults undercut its
own minimums would get a window below the floor on precisely the paths where
nothing is saved — first launch, or a corrupt config — and a correctly sized
one everywhere else.
That is the worst shape a bug can have: invisible in the common case, and
visible only to a new user. The fallback is clamped to the floor now, with a
test.
window-bounds: 14/14.
Signed-off-by: topkoa <topkoa@gmail.com>
Five more from review.
1. alwaysOnTop was RESTORED but never SAVED. A dead read: the only way to turn
it on was to hand-edit the config file. There is now one snapshot() that
decides what "remembered" means, used by the debounced save, the flush on
close, and the flush on quit — so the three can never disagree about it again.
2. GEOMETRY WAS LOST ON QUIT. closeAllPanes() calls destroy(), and destroy()
does not fire 'close' — so the flush wired to that event never ran on the one
path every user takes. Combined with the 400ms debounce: move a pane, quit two
seconds later, and its position was gone. Every pane is flushed before its
window is destroyed.
3. The tray-icon comment claimed a 16px PNG; build:ts copies the 32px one. Also
spelled out what the macOS consequence actually is (a colour icon rather than
one that adapts to light/dark menu bars), rather than gesturing at it.
4. sanitizeWindowBounds' new `sizing` parameter had no tests — and it is the
whole reason the function was touched. Without it a 380x560 pane restored
through the MAIN window's 800x600 floor is silently inflated to three times
the size the plugin asked for. Four tests now pin it: a small pane is not
inflated, the min clamp uses the override, corrupt input falls back to the
override's defaults, and — the one that protects everyone else — omitting
`sizing` leaves the main window's behaviour byte-for-byte unchanged.
window-bounds tests: 13/13.
Signed-off-by: topkoa <topkoa@gmail.com>
Five findings from CodeRabbit on #103.
1. CIRCULAR IMPORT. pane-tray imported pane-hosts while pane-hosts imported
pane-tray. In the main process that is not a style question: whichever module
loses the load race sees the other's exports half-initialised, and it fails at
whatever moment the graph happens to resolve in — which is to say, not on your
machine.
One direction only now: pane-hosts → pane-tray. What the tray needs from the
host (toggle/showAll/hideAll/hasWindow/getMainWindow) is INJECTED through
initTray(), wired in main.ts.
2. SYNCHRONOUS DISK WRITES ON EVERY DRAG FRAME. `save()` was wired straight to
'moved'/'resized', and setDesktopConfig is writeFileSync + renameSync. On
macOS that is dozens of blocking writes per second, in the main process,
while the user drags.
Debounced to 400ms — and then flushed on 'close', because a debounce that
drops the last move is worse than no debounce: nudge a pane, close it a moment
later, and you would lose the position you just chose, which is the exact thing
remembered geometry exists to prevent. 'close' (not 'closed') because the
window has to still exist to be measured.
3. A MINIMIZED PANE COULD NOT BE BROUGHT BACK. Panes go to the tray by being
minimized and then hidden — and hiding a minimized window does not un-minimize
it. So show() from the tray restored a window that was still minimized:
present, but not on screen. Which reads as the tray being broken. Everything
now goes through reveal(), which restores first.
4. PROTOTYPE POLLUTION VIA PANE ID. A pane id arrives from the RENDERER (it is
the tail of the frame name window.open() supplied) and is used as a KEY in the
persisted paneWindows map. `__proto__` is not an id, it is a way to mutate
Object.prototype from a plugin. Rejected on write, the map is rebuilt on a
null-prototype object, and reads are own-property checks — otherwise a polluted
or hand-edited config hands back geometry for a pane that was never saved.
5. Removed getMainWindowRef(), exported and referenced nowhere.
Not applicable: the finding about req.width/req.height/req.title reaching
BrowserWindow unvalidated. That was the `pane:open` IPC, which no longer exists —
main does not create pane windows at all now (the renderer must, so it can adopt
its element into them). The sizes now come from the window Electron already made.
Signed-off-by: topkoa <topkoa@gmail.com>
Follows the core change: a pane is now the plugin's REAL panel element,
moved into the pop-out window and still running the plugin's own code
(got-feedback/feedback#928).
That forces one thing here, and it is worth being loud about it:
WE MUST NOT CREATE THE PANE WINDOW.
To move a live DOM node into another window, the renderer needs a handle on
that window's document. A BrowserWindow we construct in the main process
gives it no such handle. So the renderer opens the window itself with
window.open(), Electron's setWindowOpenHandler turns that into a real
BrowserWindow anyway, and we recognise it in did-create-window by the frame
name the renderer gave it (`fbpane-<paneId>`) and attach the OS behaviour:
remembered bounds, off the taskbar, minimize-to-tray, listed in the tray.
Create the window here instead and the whole feature collapses back into
"reimplement the panel in the pop-out and sync it over IPC" — which is
exactly what we just deleted.
The IPC surface shrinks to two channels, because main never creates or
destroys a pane window and never looks inside one:
pane:sync renderer → main the registry, so the tray can list panes
pane:toggle main → renderer the tray asking for a pane; only the
renderer knows what opening one means (it
might belong in the dock, and its element
lives there)
Gone: pane:open, pane:close, pane:focus, pane:setAlwaysOnTop, pane:closed.
The renderer holds the WindowProxy for a window it opened, so it already
knows when the user closes it — and it has to, because its element is inside
and must be brought home.
Signed-off-by: topkoa <topkoa@gmail.com>
feedBack core gained a pane system (window.feedBack.panes): live UI — a
mixer, a camera rig, a readout — authored once and hostable anywhere. In
a browser it pops out via window.open(). This gives it the desktop
treatment: a real BrowserWindow that remembers where you put it, can
float above everything, and lives in the system tray.
First Tray in the app. It exists because a popped-out pane is furniture:
you want it out of the way while you play and back instantly when you
don't — not hunted for behind the main window, and not cluttering the
taskbar. Minimizing a pane sends it to the tray; the tray menu lists
every pane with a checkmark and toggles it.
## The renderer owns the truth
Main never looks inside a pane. It owns OS surfaces only — windows and
their geometry — and learns what panes exist from a `pane:sync` push. The
tray menu is a VIEW of the renderer's registry, not a second copy of it.
When the tray toggles a pane it has no window for, it asks the renderer,
because only the renderer knows what opening one means (it might belong
in the dock).
## The pane window loads OUR origin, and that is load-bearing
A pane is fed over BroadcastChannel, which only reaches windows in the
same Chromium instance and origin. Push the URL anywhere else and the
pane opens looking perfect and never updates again. So `pane:open`
validates the URL against the same origin predicate the navigation guards
use (makeRendererOriginPredicate) and refuses anything else outright —
which also means we can never open arbitrary web content with the full
preload bridge attached. It is the same reason main.ts's
setWindowOpenHandler answers same-origin URLs with `allow` rather than
`deny` + openExternal.
## Details that bite
- sanitizeWindowBounds hard-floored at the MAIN window's 800x600. A 380x560
pane restored through it would be silently inflated threefold. It now takes
a WindowSizing; the main window passes its old values as the default, so
every existing call site and the existing test are byte-for-byte unchanged.
- Pane geometry lives in the DESKTOP config, not the renderer's localStorage
— localStorage is shared with the pane windows themselves (same origin), so
a second writer there would race. setDesktopConfig merges shallowly, so
paneWindows is read-modify-written or one pane's save would drop the rest.
- Pane windows are destroyed when the main window closes. Without the
renderer there is nothing on the other end of their channel, so they would
sit showing a frozen playhead forever — and a pane HIDDEN in the tray is
still an open window, which would stop `window-all-closed` from ever firing
and leave the app running as an invisible process.
- Geometry is persisted on move/resize, not only on close: a pane window can
outlive the app in a crash, and the entire point is that you never place it
twice.
- Electron's 'minimize' is not cancellable here (the listener takes no event),
so a pane hides right after minimizing rather than preventing it. The window
is skipTaskbar, so there is no animation to see.
- The tray icon is copied to dist/main/ by build:ts, the same trick
splash.html and spinner.json use — so __dirname resolves it identically in
dev and inside a packaged asar, with no app.isPackaged branch and nothing
added to electron-builder's extraResources. An unreadable icon logs and
skips the tray rather than creating an invisible one whose menu no one can
ever reach.
Needs the matching core change (got-feedback/feedback#928), which registers
the `desktop` host when this bridge is present and falls back to a browser
pop-up when it isn't. An older core simply never calls these channels.
Signed-off-by: topkoa <topkoa@gmail.com>