Commit Graph
78 Commits
Author SHA1 Message Date
OmikronApexandGitHub 8fa28cf2c2 Merge pull request #94 from got-feedBack/fix/nightly-velopack-entitlements
fix(ci): complete #93 — vpk .entitlements rename + core bundle fixes
2026-07-10 21:03:08 +02:00
OmikronApexandClaude Fable 5 09617aa417 fix(bundle): copy all top-level core python packages (routers/ etc.)
core#834 extracted routes into a new top-level routers/ package; the
bundle script only copied lib/, so the packaged backend would die with
ModuleNotFoundError: No module named 'routers'. Copy every top-level
package (dir with __init__.py) except plugins/ (bundled selectively
below) and tests/ (never ships), so the ongoing R3 extraction series
can't keep breaking packaged builds.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-10 16:47:32 +02:00
OmikronApexandClaude Fable 5 add4facb85 fix(bundle): copy all top-level core .py modules, not just server.py
Core added appstate.py (core#833); bundle-slopsmith.sh's whitelist only
copied server.py, so packaged builds crashed on startup with
ModuleNotFoundError: No module named 'appstate'. Glob top-level *.py so
future sibling modules can't repeat this.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-10 16:22:05 +02:00
OmikronApexandClaude Fable 5 83b8b66fe7 fix(ci): rename entitlements file — vpk requires .entitlements extension
Nightly run 29093119570 failed:
  --signEntitlements does not have an .entitlements extension
vpk validates the file extension. Rename
resources/entitlements.mac.plist -> entitlements.mac.entitlements
and update all references (electron-builder config, sign-macos-binaries.sh,
both workflows). Content unchanged; codesign/electron-builder accept any
extension.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-10 15:04:31 +02:00
OmikronApexandGitHub eca559de76 Merge pull request #93 from got-feedBack/fix/nightly-velopack-entitlements
Addon CI / addon (arm64, macos-14, mac) (push) Waiting to run
Addon CI / addon (x64, ubuntu-22.04, linux) (push) Waiting to run
Addon CI / addon (x64, windows-latest, win) (push) Waiting to run
Ship CI / CI (push) Waiting to run
fix(ci): preserve mac entitlements through Velopack re-sign
2026-07-10 14:36:20 +02:00
OmikronApexandClaude Fable 5 1046b71f34 fix(ci): anchor --signEntitlements path with $PWD
vpk signing docs recommend absolute paths; relative path breaks if vpk
changes cwd internally. CodeRabbit review nit on #93.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-10 14:18:47 +02:00
OmikronApexandClaude Fable 5 077309cf00 fix(build): preserve mac entitlements through Velopack re-sign
Same gap as nightly.yml: vpk pack --signAppIdentity without
--signEntitlements strips electron-builder's entitlements
(audio-input, allow-jit, ...) from the release feed's .app.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-10 14:04:45 +02:00
OmikronApexandClaude Fable 5 782aea76f9 fix(nightly): preserve mac entitlements through Velopack re-sign
vpk pack re-signs the .app; without --signEntitlements the codesign
strips electron-builder's entitlements (com.apple.security.device.audio-input
et al). Hardened-runtime app then gets microphone auto-denied by TCC with
no prompt — root cause of silent-input nightly reports.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-10 14:03:15 +02:00
OmikronApexandGitHub bcbc7963d3 Merge pull request #92 from got-feedBack/fix/mac-tcc-diagnostic-logging
Addon CI / addon (arm64, macos-14, mac) (push) Waiting to run
Addon CI / addon (x64, ubuntu-22.04, linux) (push) Waiting to run
Addon CI / addon (x64, windows-latest, win) (push) Waiting to run
Ship CI / CI (push) Waiting to run
fix(mac): add diagnostic logging to microphone TCC permission flow
2026-07-10 00:11:14 +02:00
OmikronApexandClaude 0645ce724d fix(mac): add diagnostic logging to ensureMicrophoneAccess TCC flow
Every decision point in the macOS microphone permission path now logs its
status and context to the debug log, including the previously silent
'granted' early-return path — which is the prime suspect for the stale-
grant bug (TCC reports 'granted' for a signature-keyed entry that no
longer matches the running binary).

Also logs:
- platform gate skip
- app.isPackaged === false skip (and why it matters re: NSMicrophoneUsageDescription)
- getMediaAccessStatus return value in all branches
- app identity (name, version, exe path) on 'granted' early return
- askForMediaAccess result + extra warning on user denial
- full error stack on exceptions (not just the message)

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-09 23:54:20 +02:00
31248617a5 feat(audio): renderer-audio bus — mix renderer WebAudio master into engine output (Phase 2) (#91)
* fix(audio): prefer same-backend duplex routing

* fix(audio): centre mono input; limit duplex to same-endpoint devices

Two issues found while testing the USB-guitar-cable path on Windows:

1. Centre a mono input. SourceChain::processBlock fell into the
   pass-through branch for a 1-channel input, filling only
   min(inputChannels, outputChannels) = 1 output channel and zeroing the
   rest, so a mono USB guitar cable played out of the left speaker only.
   A single-channel input is now broadcast across every output channel.

2. Only attempt the combined (duplex) device when input and output are
   the SAME physical endpoint. Two different endpoints of the same
   backend (USB cable in + separate speakers out) are independent
   hardware clocks; routing them through one duplex device was unstable
   across the app lifecycle (no audio until an explicit Apply, then
   distortion / dropouts / silent-in-song on navigation). Different
   endpoints now use the split path, whose ring bridges the two clocks.
   Same-endpoint duplex (one interface for in and out) keeps the
   low-latency win. Low latency for the two-device case is a follow-up
   that needs the device-lifecycle work (startup restore + reconfigure
   on navigation).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JEoFeTPSnz4NpwwCG52hnu

* fix(audio): probe same-endpoint duplex the same way apply routes it

Startup auto-apply (renderer init) fail-closes on probeDeviceOptionsDual's
`compatible` verdict, but the probe still measured a COMBINED duplex device
for any same-backend pair while setAudioDevices now opens split for
different endpoints. That mismatch made the startup probe describe a config
that isn't the one applied — surfacing as "no audio until I press Apply" for
a USB cable + separate speakers. Gate the probe's duplex path on the same
sameEndpointIntent (same type AND same device) the apply path uses, so a
two-device pair is probed via the split path it will actually run on.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JEoFeTPSnz4NpwwCG52hnu

* fix(audio): never feed chain processors blocks larger than prepared size

WASAPI shared mode can deliver oversized blocks right after a device
start. The NAM core pre-allocates its conv ring/output buffers to the
Reset() maxBufferSize and only asserts (release no-op) on larger blocks;
one oversized block corrupts the conv ring state and garbles all
subsequent audio until the next Reset() — the 'first start heavily
distorted until tone reset / engine restart' bug.

- NAMProcessor::processBlock: process in slices of at most the prepared
  block size.
- SignalChain::process: slice oversized device blocks into prepared-size
  chunks before any slot (VST/NAM/IR) sees them.

See docs/audio-distortion-first-start-investigation.md.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(audio): close stale-format race when processors are added mid-reconfigure

addProcessor/replaceProcessor prepare the incoming processor off the
audio lock on N-API worker threads. A concurrent device reconfigure's
SignalChain::prepare() can't see that processor (not slotted yet), so a
slot could go live prepared at a stale sample rate / block size and stay
wrong until the next device restart — heard as pitch-shifted/garbled
monitoring when a chain loads while the device is being (re)opened
(widest window: WASAPI exclusive mode's slower open).

Re-check the chain's current format under the lock at insert/swap time
and re-prepare if it moved; log the transition to stderr so tester logs
show when the race fired. prepare() now publishes the format under the
lock so the check can't tear.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(effects): align executor plan schema with rebranded capability layer

The rebrand left a three-way schema split: rig_builder sent the old
'slopsmith.audio_effects.chain_plan.v1', the renderer capability layer
validated against the new 'feedBack.…' id (rejecting every plan), and
this executor still expected the old one. Result: every song chain load
fell back to legacy clearChain+loadPreset — a full multi-VST rebuild per
currentSong poll cycle, heard as continuous distortion during playback
(tester logs: 4-6 rebuilds/session, slot IDs into the 90s).

Executor now uses the rebranded id and accepts the legacy one as an
alias (matching the capability layer's new alias), so neither side of
the handoff can break on old plugin bundles.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(audio): guard input callback against double registration

Tester main-process log showed two consecutive 'startAudio: duplex=0'
lines: a transient audioDeviceStopped() (WASAPI exclusive opens fire one
mid-start) cleared audioRunning while the input callback stayed
attached, so the second startAudio() re-added it. JUCE then dispatched
the input callback twice per block: DSP ran twice and each block was
pushed into the split ring twice — every sample played twice (half
speed, one octave down, garbled). stopAudio()'s single
removeAudioCallback left the duplicate registration alive, wedging the
engine (restart no longer helped) and keeping the exclusive-mode device
open even after app close.

Mirror the existing outputCallbackRegistered guard for the input side.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* chore(audio): diagnostic instrumentation for tester repro builds

Main-process stderr logging on every open lead, RT paths rate-limited
(first-25 per anomaly + ~5s heartbeats per callback clock):

- primary callback re-entrancy (duplicate registration detector)
- oversized blocks on primary/output callbacks, SignalChain slicing,
  NAM chunking (pre-fix corruption trigger visibility)
- ring fill + under/overflow counters (split-mode pacing)
- device lifecycle: aboutToStart/stopped on both managers with sr/bs and
  callback-registration flags; startAudio guard-skip; stopAudio state
- SourceChain.prepare format trace (stale-rate lead)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* chore(audio): drop diagnostic heartbeats, keep anomaly detectors

The 5s ring/format heartbeats served the distortion hunt and are noise
now. Keep the cheap anomaly-only diagnostics (callback re-entrancy,
oversized-block detectors, chain slicing/chunking, stale-format
re-prepare, device lifecycle) — they log only on misbehavior and stay
relevant for the exclusive-mode playback work.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* docs: keep investigation notes out of the PR (local working notes)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* test: pin JUCE WASAPI exclusive device-type name

The shared player bundle (feedBack#824) detects exclusive-style output
by string-matching getCurrentDevice().outputType. The name is hardcoded
in vendored JUCE; a JUCE upgrade renaming it would silently disable the
feedpak-under-exclusive routing. Fail the build instead.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(audio): renderer-audio bus — mix renderer WebAudio master into engine output (Phase 2)

SPSC packed-LR ring (64K frames) fed over IPC by the renderer, consumed by
whichever output callback is live (duplex or split), mixed like a backing
track before master gain. Producer-side linear resampling with cross-chunk
continuity; drop-oldest on overflow. Prefill gate (~10.7 ms) and fill clamp
(~85 ms → trim to prime target) added from fix12 tester spike data, which
also confirmed clock stability (drift → 0, zero overflow over 8 min).
Off by default — zero behavior change until the renderer enables it.

Exposed as setRendererBus / pushRendererAudio (fire-and-forget IPC,
~100 msgs/s) / getRendererBusMetrics. Spike script included for tester
go/no-go runs. Consumed by the feeder in feedBack#824's Phase 2 follow-up.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* chore(audio): gate lifecycle [diag] logs behind SLOPSMITH_SANDBOX_DEBUG

Review follow-up (#86): the six lifecycle diagnostics (stopAudio,
audioDeviceAboutToStart/Stopped, audioOutputAboutToStart/Stopped,
SourceChain::prepare) printed unconditionally while the PR body claimed
they were verbose-gated. Gate them behind the existing
slopsmith_vst_trace::isEnabled() runtime flag (SLOPSMITH_SANDBOX_DEBUG —
already flipped by the app's debug-logging switch, so tester debug runs
still capture them). The RT-path anomaly detectors (primary re-entry,
oversized-block) keep their firstN/anomaly bounds unchanged, as reviewed.

VSTTrace.h now defines NOMINMAX/WIN32_LEAN_AND_MEAN before windows.h so
including it from engine TUs doesn't clobber std::min.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: ChrisBeWithYou <chris@rifflarr.local>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-09 23:03:31 +02:00
2d0dd12abe fix(audio): input-callback double registration, stale-format race, effects schema alignment (#86)
* fix(audio): prefer same-backend duplex routing

* fix(audio): centre mono input; limit duplex to same-endpoint devices

Two issues found while testing the USB-guitar-cable path on Windows:

1. Centre a mono input. SourceChain::processBlock fell into the
   pass-through branch for a 1-channel input, filling only
   min(inputChannels, outputChannels) = 1 output channel and zeroing the
   rest, so a mono USB guitar cable played out of the left speaker only.
   A single-channel input is now broadcast across every output channel.

2. Only attempt the combined (duplex) device when input and output are
   the SAME physical endpoint. Two different endpoints of the same
   backend (USB cable in + separate speakers out) are independent
   hardware clocks; routing them through one duplex device was unstable
   across the app lifecycle (no audio until an explicit Apply, then
   distortion / dropouts / silent-in-song on navigation). Different
   endpoints now use the split path, whose ring bridges the two clocks.
   Same-endpoint duplex (one interface for in and out) keeps the
   low-latency win. Low latency for the two-device case is a follow-up
   that needs the device-lifecycle work (startup restore + reconfigure
   on navigation).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JEoFeTPSnz4NpwwCG52hnu

* fix(audio): probe same-endpoint duplex the same way apply routes it

Startup auto-apply (renderer init) fail-closes on probeDeviceOptionsDual's
`compatible` verdict, but the probe still measured a COMBINED duplex device
for any same-backend pair while setAudioDevices now opens split for
different endpoints. That mismatch made the startup probe describe a config
that isn't the one applied — surfacing as "no audio until I press Apply" for
a USB cable + separate speakers. Gate the probe's duplex path on the same
sameEndpointIntent (same type AND same device) the apply path uses, so a
two-device pair is probed via the split path it will actually run on.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JEoFeTPSnz4NpwwCG52hnu

* fix(audio): never feed chain processors blocks larger than prepared size

WASAPI shared mode can deliver oversized blocks right after a device
start. The NAM core pre-allocates its conv ring/output buffers to the
Reset() maxBufferSize and only asserts (release no-op) on larger blocks;
one oversized block corrupts the conv ring state and garbles all
subsequent audio until the next Reset() — the 'first start heavily
distorted until tone reset / engine restart' bug.

- NAMProcessor::processBlock: process in slices of at most the prepared
  block size.
- SignalChain::process: slice oversized device blocks into prepared-size
  chunks before any slot (VST/NAM/IR) sees them.

See docs/audio-distortion-first-start-investigation.md.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(audio): close stale-format race when processors are added mid-reconfigure

addProcessor/replaceProcessor prepare the incoming processor off the
audio lock on N-API worker threads. A concurrent device reconfigure's
SignalChain::prepare() can't see that processor (not slotted yet), so a
slot could go live prepared at a stale sample rate / block size and stay
wrong until the next device restart — heard as pitch-shifted/garbled
monitoring when a chain loads while the device is being (re)opened
(widest window: WASAPI exclusive mode's slower open).

Re-check the chain's current format under the lock at insert/swap time
and re-prepare if it moved; log the transition to stderr so tester logs
show when the race fired. prepare() now publishes the format under the
lock so the check can't tear.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(effects): align executor plan schema with rebranded capability layer

The rebrand left a three-way schema split: rig_builder sent the old
'slopsmith.audio_effects.chain_plan.v1', the renderer capability layer
validated against the new 'feedBack.…' id (rejecting every plan), and
this executor still expected the old one. Result: every song chain load
fell back to legacy clearChain+loadPreset — a full multi-VST rebuild per
currentSong poll cycle, heard as continuous distortion during playback
(tester logs: 4-6 rebuilds/session, slot IDs into the 90s).

Executor now uses the rebranded id and accepts the legacy one as an
alias (matching the capability layer's new alias), so neither side of
the handoff can break on old plugin bundles.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(audio): guard input callback against double registration

Tester main-process log showed two consecutive 'startAudio: duplex=0'
lines: a transient audioDeviceStopped() (WASAPI exclusive opens fire one
mid-start) cleared audioRunning while the input callback stayed
attached, so the second startAudio() re-added it. JUCE then dispatched
the input callback twice per block: DSP ran twice and each block was
pushed into the split ring twice — every sample played twice (half
speed, one octave down, garbled). stopAudio()'s single
removeAudioCallback left the duplicate registration alive, wedging the
engine (restart no longer helped) and keeping the exclusive-mode device
open even after app close.

Mirror the existing outputCallbackRegistered guard for the input side.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* chore(audio): diagnostic instrumentation for tester repro builds

Main-process stderr logging on every open lead, RT paths rate-limited
(first-25 per anomaly + ~5s heartbeats per callback clock):

- primary callback re-entrancy (duplicate registration detector)
- oversized blocks on primary/output callbacks, SignalChain slicing,
  NAM chunking (pre-fix corruption trigger visibility)
- ring fill + under/overflow counters (split-mode pacing)
- device lifecycle: aboutToStart/stopped on both managers with sr/bs and
  callback-registration flags; startAudio guard-skip; stopAudio state
- SourceChain.prepare format trace (stale-rate lead)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* chore(audio): drop diagnostic heartbeats, keep anomaly detectors

The 5s ring/format heartbeats served the distortion hunt and are noise
now. Keep the cheap anomaly-only diagnostics (callback re-entrancy,
oversized-block detectors, chain slicing/chunking, stale-format
re-prepare, device lifecycle) — they log only on misbehavior and stay
relevant for the exclusive-mode playback work.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* docs: keep investigation notes out of the PR (local working notes)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* chore(audio): gate lifecycle [diag] logs behind SLOPSMITH_SANDBOX_DEBUG

Review follow-up (#86): the six lifecycle diagnostics (stopAudio,
audioDeviceAboutToStart/Stopped, audioOutputAboutToStart/Stopped,
SourceChain::prepare) printed unconditionally while the PR body claimed
they were verbose-gated. Gate them behind the existing
slopsmith_vst_trace::isEnabled() runtime flag (SLOPSMITH_SANDBOX_DEBUG —
already flipped by the app's debug-logging switch, so tester debug runs
still capture them). The RT-path anomaly detectors (primary re-entry,
oversized-block) keep their firstN/anomaly bounds unchanged, as reviewed.

VSTTrace.h now defines NOMINMAX/WIN32_LEAN_AND_MEAN before windows.h so
including it from engine TUs doesn't clobber std::min.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: ChrisBeWithYou <chris@rifflarr.local>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-09 22:44:15 +02:00
91c4e0037f test: pin JUCE WASAPI exclusive device-type name (#90)
The shared player bundle (feedBack#824) detects exclusive-style output
by string-matching getCurrentDevice().outputType. The name is hardcoded
in vendored JUCE; a JUCE upgrade renaming it would silently disable the
feedpak-under-exclusive routing. Fail the build instead.

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-09 22:18:04 +02:00
b2be91808c fix(audio): accept standard base64 for IR/NAM slot state (was silently dropped) (#88)
Addon CI / addon (arm64, macos-14, mac) (push) Waiting to run
Addon CI / addon (x64, ubuntu-22.04, linux) (push) Waiting to run
Addon CI / addon (x64, windows-latest, win) (push) Waiting to run
Ship CI / CI (push) Waiting to run
MemoryBlock::fromBase64Encoding only parses JUCE's proprietary
"<size>.<alphabet>" format and returns false on standard RFC-4648 base64 —
which is what the Python-side plugins (rig_builder) emit for per-slot state.
That silent false meant LoadPresetWorker/SetSlotState never called setState()
for those slots, so IR stages lost their per-stage `gain` (cab loudness makeup,
amp-trim impulse compensation) on every chain load.

Add decodeStateBlob(): JUCE format first (engine-native saves unchanged), then
a standard-base64 fallback via juce::Base64 — gated to IR/NAM slots only, whose
processors take exactly the JSON these states carry ({"irPath","gain"} /
{"modelPath",...}). VST slots keep the JUCE-only decode: their plugin-emitted
blobs are metadata wrappers, not real setStateInformation() chunks.

Co-authored-by: Jafz2001 <ignacio.fritis@mundotelecomunicaciones.cl>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-09 16:14:14 +02:00
3e3f1f868c fix(audio): never feed chain processors blocks larger than prepared size (#85)
Addon CI / addon (arm64, macos-14, mac) (push) Waiting to run
Addon CI / addon (x64, ubuntu-22.04, linux) (push) Waiting to run
Addon CI / addon (x64, windows-latest, win) (push) Waiting to run
Ship CI / CI (push) Waiting to run
* fix(audio): never feed chain processors blocks larger than prepared size

WASAPI shared mode can deliver oversized blocks right after a device
start. The NAM core pre-allocates its conv ring/output buffers to the
Reset() maxBufferSize and only asserts (release no-op) on larger blocks;
one oversized block corrupts the conv ring state and garbles all
subsequent audio until the next Reset() — the 'first start heavily
distorted until tone reset / engine restart' bug.

- NAMProcessor::processBlock: process in slices of at most the prepared
  block size.
- SignalChain::process: slice oversized device blocks into prepared-size
  chunks before any slot (VST/NAM/IR) sees them.

See docs/audio-distortion-first-start-investigation.md.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(audio): prefer same-backend duplex routing

* fix(audio): centre mono input; limit duplex to same-endpoint devices

Two issues found while testing the USB-guitar-cable path on Windows:

1. Centre a mono input. SourceChain::processBlock fell into the
   pass-through branch for a 1-channel input, filling only
   min(inputChannels, outputChannels) = 1 output channel and zeroing the
   rest, so a mono USB guitar cable played out of the left speaker only.
   A single-channel input is now broadcast across every output channel.

2. Only attempt the combined (duplex) device when input and output are
   the SAME physical endpoint. Two different endpoints of the same
   backend (USB cable in + separate speakers out) are independent
   hardware clocks; routing them through one duplex device was unstable
   across the app lifecycle (no audio until an explicit Apply, then
   distortion / dropouts / silent-in-song on navigation). Different
   endpoints now use the split path, whose ring bridges the two clocks.
   Same-endpoint duplex (one interface for in and out) keeps the
   low-latency win. Low latency for the two-device case is a follow-up
   that needs the device-lifecycle work (startup restore + reconfigure
   on navigation).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JEoFeTPSnz4NpwwCG52hnu

* fix(audio): probe same-endpoint duplex the same way apply routes it

Startup auto-apply (renderer init) fail-closes on probeDeviceOptionsDual's
`compatible` verdict, but the probe still measured a COMBINED duplex device
for any same-backend pair while setAudioDevices now opens split for
different endpoints. That mismatch made the startup probe describe a config
that isn't the one applied — surfacing as "no audio until I press Apply" for
a USB cable + separate speakers. Gate the probe's duplex path on the same
sameEndpointIntent (same type AND same device) the apply path uses, so a
two-device pair is probed via the split path it will actually run on.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JEoFeTPSnz4NpwwCG52hnu

* fix(audio): close stale-format race when processors are added mid-reconfigure

addProcessor/replaceProcessor prepare the incoming processor off the
audio lock on N-API worker threads. A concurrent device reconfigure's
SignalChain::prepare() can't see that processor (not slotted yet), so a
slot could go live prepared at a stale sample rate / block size and stay
wrong until the next device restart — heard as pitch-shifted/garbled
monitoring when a chain loads while the device is being (re)opened
(widest window: WASAPI exclusive mode's slower open).

Re-check the chain's current format under the lock at insert/swap time
and re-prepare if it moved; log the transition to stderr so tester logs
show when the race fired. prepare() now publishes the format under the
lock so the check can't tear.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Co-authored-by: ChrisBeWithYou <chris@rifflarr.local>
2026-07-08 22:44:26 +02:00
56e929da4e audio: replaceIR(slotId, path, gain) for in-place cab/IR swap (#83)
* audio: add replaceIR(slotId, path, gain) for in-place cab/IR swap

Swap an existing convolution slot's IR without a full loadPreset, so the rest
of the chain — the amp VST above all — is not torn down and rebuilt (that
teardown is the ~1-2 s wait when changing cabs / mic position). Mirrors the
existing loadIR worker but calls SignalChain::replaceProcessor(slotId, ...);
optional gain updates the slot post-gain (the cab makeup).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* audio: expose replaceIR over the IPC bridge

Wire the native replaceIR(slotId, path, gain) through audio-bridge (ipcMain
handle) + preload, so renderers get feedBackDesktop.audio.replaceIR. Lets the
rig-builder cab room swap a cab's IRs in place instead of a full loadPreset +
param re-apply (that re-apply was the brief 'can't move the mic yet' lag).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* audio: replaceIR updates slot name/path so getChainState reflects the swap

replaceProcessor deliberately preserves the target slot's name/path during
the prepare/fault window (so a fault in prepareToPlay is blocklisted against
the right plugin path). It kept them even on success, so after a successful
replaceIR the slot's audio was the new IR but getChainState()/preset-save
still reported the OLD IR name+path — a footgun for any consumer that
persists a chain read back from getChainState().

Add optional newName/newPath to replaceProcessor, applied under the swap lock
ONLY on success (empty = keep, so the sandbox-promotion caller is unchanged).
ReplaceIRWorker passes "IR: <name>" + the new path, mirroring LoadIRWorker.

Built (npm run build:audio) clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Jafz2001 <ignacio.fritis@mundotelecomunicaciones.cl>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-authored-by: byrongamatos <xasiklas@gmail.com>
2026-07-08 22:20:06 +02:00
OmikronApexandGitHub a53fd38732 Merge pull request #84 from got-feedBack/chore/plugin-manager-rebrand
Addon CI / addon (arm64, macos-14, mac) (push) Waiting to run
Addon CI / addon (x64, ubuntu-22.04, linux) (push) Waiting to run
Addon CI / addon (x64, windows-latest, win) (push) Waiting to run
Ship CI / CI (push) Waiting to run
chore(plugin-manager): rebrand UI strings from Slopsmith to fee[dB]ack
2026-07-08 09:55:58 +02:00
OmikronApexandClaude Fable 5 f3c14271cc chore(plugin-manager): rebrand UI strings from Slopsmith to fee[dB]ack
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-08 09:22:46 +02:00
OmikronApexandGitHub ac824d5cc5 Merge pull request #81 from got-feedBack/chore/drop-setlist-plugin
Addon CI / addon (arm64, macos-14, mac) (push) Waiting to run
Addon CI / addon (x64, ubuntu-22.04, linux) (push) Waiting to run
Addon CI / addon (x64, windows-latest, win) (push) Waiting to run
Ship CI / CI (push) Waiting to run
chore(build): stop bundling feedback-plugin-setlist
2026-07-07 19:20:30 +02:00
OmikronApexandClaude Fable 5 524c2e0dca chore(build): stop bundling feedback-plugin-setlist
Playlists are integrated into the core app, so the setlist plugin is
obsolete and its repo is being archived.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-07 19:17:54 +02:00
dd7ad9b786 feat(update): add nightly Velopack update channel (#80)
Addon CI / addon (arm64, macos-14, mac) (push) Waiting to run
Addon CI / addon (x64, ubuntu-22.04, linux) (push) Waiting to run
Addon CI / addon (x64, windows-latest, win) (push) Waiting to run
Ship CI / CI (push) Waiting to run
Adds `nightly` as a selectable auto-update channel (Windows + macOS).

Client:
- update-manager.ts: add 'nightly' to UpdateChannel (veloChannel already
  yields win-x64-nightly / osx-arm64-nightly, so no logic change)
- main.ts: allow 'nightly' in the runtime IPC channel guard
- preload.ts: add 'nightly' to the preload-local UpdateChannel union
- screen.js / settings.html: add the Nightly option + helper text

CI (nightly.yml):
- derive <pkg>-nightly.<UTC date> version in the setup job
- setup-dotnet (pinned from .build-config.json) so the vpk CLI has net8,
  matching build.yml
- vpk pack win-x64-nightly / osx-arm64-nightly (mac signed + notarized,
  mirroring build.yml's signed/unsigned fallback)
- publish a rolling `nightly` GitHub Release (prerelease=false, latest=false)
  that the in-app updater reads for the nightly channel
- concurrency guard so an overlapping dispatch can't race the rolling release

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-07 00:10:03 +02:00
OmikronApexandGitHub 34e1aa099d Change nightly workflow schedule to 23:00
Addon CI / addon (arm64, macos-14, mac) (push) Has been cancelled
Addon CI / addon (x64, ubuntu-22.04, linux) (push) Has been cancelled
Addon CI / addon (x64, windows-latest, win) (push) Has been cancelled
Ship CI / CI (push) Has been cancelled
2026-07-05 22:48:36 +02:00
59145e4663 fix(library): create the default library folder on first run (#79)
The Python server only seeds bundled starter content (and scans) when
DLC_DIR.is_dir() is true, and it can't bootstrap the folder itself — the
seed's mkdir runs only after _get_dlc_dir() already resolves a directory. On a
fresh install the default library path didn't exist, so the scan bailed with
"DLC folder not configured" and starter content never seeded.

Create the resolved DLC dir in startPython() before spawning the server so the
first scan seeds the bundled songs. Also modernize the default library path to
~/.local/share/feedback/library, keeping the legacy slopsmith paths as
fallbacks so existing installs that relied on the default keep their populated
library.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
v0.3.0-alpha.1
2026-07-03 23:42:01 +02:00
336cbda8ef build: bundle starter content + fix diagnostic sloppak name skew (#78)
Copy core's content/starter/*.feedpak into the bundle so
server._seed_builtin_starter_content() can seed it into the library on first
run of packaged builds. Also fix a rename skew: the builtin diagnostic copy
looked for docs/diagnostics/slopsmith-diagnostic-basic-guitar.sloppak, but core
renamed it to feedBack-diagnostic-basic-guitar.sloppak — so the diagnostic
seeding was silently skipped in packaged builds ("not found" warning). Point at
the feedBack-* name to match server.py's _BUILTIN_DIAGNOSTIC_SOURCES.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-03 21:50:33 +02:00
OmikronApexandGitHub 8083fc2b2c Merge pull request #77 from got-feedBack/ci/fix-slopsmith-rename
ci(build): stop full matrix on pushes to main
2026-07-03 18:07:00 +02:00
OmikronApex 371a4fb36b ci(build): stop full matrix on pushes to main
ship-ci.yml covers checks and nightly.yml builds main daily; the
3-platform matrix only needs to run on tags and manual dispatch.
2026-07-03 18:02:31 +02:00
OmikronApexandGitHub 8ca63f2a09 Merge pull request #76 from got-feedBack/ci/fix-slopsmith-rename
ci(nightly): fix macOS/Windows packaging broken by fee[dB]ack rename
2026-07-03 17:43:25 +02:00
OmikronApexandClaude Fable 5 1df4c753d9 ci(nightly): fix macOS/Windows packaging broken by fee[dB]ack rename
nightly.yml still globbed for the old Slopsmith.app bundle name and
failed every night. Find the .app dynamically (name-agnostic *.app glob
- the new name contains [dB], which a shell glob reads as a character
class) and derive zip names from the bundle, matching the fix already
on main in rc.yml/build.yml. Also refresh stale Slopsmith comments in
build.yml.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-03 17:33:46 +02:00
b5dce9af4d fix(audio): read-ahead the backing track off the RT audio thread (#60)
* fix(audio): read-ahead the backing track off the RT audio thread

The backing AudioTransportSource was set up with no read-ahead buffer and
no reader thread — setSource(src, 0, nullptr, rate) — so the realtime audio
callback decoded the backing file synchronously inside getNextAudioBlock on
every block while a song plays. Any disk seek or codec spike (worst on
compressed formats) then blew the block's realtime budget, producing
underruns heard as glitches / brief mutes.

Interpose a juce::TimeSliceThread with 32768 source frames (~0.68 s @ 48 kHz)
of look-ahead so decode happens off the audio thread. The thread is declared
before backingTransport (so it is destroyed after it — the transport's
BufferingAudioSource holds a pointer to it) and started once in the ctor.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* docs(audio): document the bounded BufferingAudioSource lock residual

Codex review: readBufferSection() holds callbackLock across a refill
chunk decode and the RT callback takes the same lock. Accepted — the
window is bounded (2048-frame chunks) and only hit mid-refill, vs. the
old guaranteed synchronous decode every block; note it in the comment so
nobody mistakes the transport stack for fully RT-safe.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Jafz2001 <ignacio.fritis@mundotelecomunicaciones.cl>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-authored-by: byrongamatos <xasiklas@gmail.com>
2026-07-03 16:16:27 +02:00
f785fb9ab1 fix(renderer): keep Rig Builder's tone out of the user's manual VST chain (#73)
* fix(renderer): keep Rig Builder's tone out of the user's manual VST chain

Rig Builder's chain preloader is always on, so it loads its whole tone (amp /
pedals / racks / master pre-post / RB Final Leveler) into the SHARED engine
chain. The Audio menu's 'Save Current Chain' and auto-persist captured the LIVE
engine via getChainState()/savePreset(), baking those stages into the user's
manual chain — so a user who built their own VST chain saw it sprout a full Rig
Builder rig they never added.

Add aeIsRigBuilderStage() (path under /rig_builder/, 'RB Final Leveler', rs_gear
__rb*, or slot master_pre/post) + aeStripRigBuilderFromNativePreset(), and apply
them at save (items + native blob), the app-init restore loop, the preset-load
path (with an empty-guard), and refreshChain (display filter) so the manual
chain only ever holds the user's own processors.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(renderer): skip Rig Builder stages in the tone-switch preload paths too

Codex review: legacy polluted presets were only sanitized in
replaceChainWithPresetBlob(), but the tone-switch preloads load directly
from raw preset.items + nativePreset.chain (loadPresetItemsWithState in
IIFE 1 and the deliberately-inline copy in IIFE 2). Skip Rig Builder
stages by index in both loops — index-skips keep the items/nativeChain
alignment for the remaining pairs — and expose the detector as
window._aeIsRigBuilderStage for IIFE 2.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(renderer): load fully-polluted presets as empty instead of falling back

Codex review round 2: the never-empty guard restored the ORIGINAL
polluted blob whenever stripping emptied the chain — but a preset that
empties completely was 100% Rig Builder's tone, exactly the case the
sanitizer exists for. Load the stripped (empty) chain and warn; empty-
chain presets are a supported shape.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Jafz2001 <ignacio.fritis@mundotelecomunicaciones.cl>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Co-authored-by: byrongamatos <xasiklas@gmail.com>
2026-07-03 15:59:34 +02:00
fad294c6fc fix(release): rebrand Velopack packId Slopsmith -> feedback (#74)
The nupkg/installer artifact names were still 'Slopsmith-*' (packId), mismatching the app + the feedback-*.deb/.AppImage. Rebrand to 'feedback' now — safe before any installed base exists (Velopack matches the installed app's packId on update; changing it after users install would orphan their auto-updates). The client resolves updates by channel manifest (releases.<ch>.json), not packId, so no client change is needed.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-03 15:26:56 +02:00
e22981405c fix(audio): stop signal-chain duplication on renderer re-evaluation (#71)
Testers on 0.3.0-alpha.1 reported the signal chain duplicating (every
VST/NAM/IR exactly twice) with blown-out gain after leaving the Audio
menu, plus VST edit windows closing and the Edit button going dead.

Root cause: the native JUCE chain lives in the Electron main process and
survives renderer reloads and screen.js re-evaluations (host re-hydration
after a backend restart), but init() unconditionally restored the
localStorage-saved chain by APPENDING — aeRestoreSavedChain never clears.
The #50 review added a clear-before-restore in the amp-sims toggle
handler only; the identical hazard at init() remained. Since the saved
chain mirrors the live chain, every init re-run produced an exact 2x
duplicate (two amp stages in series = the blown-out gain).

Fixes:
- init(): probe getChainState() first and skip ALL auto-load (default
  preset and saved-chain restore) when the engine already has a live
  chain. Also covers splitscreen pop-out windows re-running init.
- saveChainStateFromChain(): never persist a Rig-Builder-owned chain
  (identified by its _rb_unit_impulse / RB Final Leveler plumbing
  stages). Rig Builder reloads its default tone off-screen on its own
  schedule, so it is routinely the ambient live chain; snapshotting it
  made the saved chain resurrect Rig Builder's tone on restore — the
  exact processor set in the tester screenshot.
- aeRestoreSavedChain(): drop Rig Builder plumbing stages from legacy
  polluted saves and rewrite the cleaned list (self-healing).
- _aeOpenEditor(): a false return means the baked-in slot id went stale
  (chain rebuilt while the list was on screen); refresh the chain list
  instead of silently doing nothing.
- Install-once guard (hookState) on the arrangement:changed/song:ready
  reapply listeners — they stacked one pair per re-evaluation, running N
  racing clear+load sequences per song load after a re-eval.

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-03 14:44:55 +02:00
be71e7a13a chore: remove stale root README (#70)
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-03 13:48:56 +02:00
cdbc88036b fix(release): glob the win launcher exe (productName is sanitized to feedback.exe) (#69)
electron-builder strips the brackets from productName 'fee[dB]ack' → the win launcher is 'feedback.exe', so deriving --mainExe from raw productName ('fee[dB]ack.exe') didn't match. Glob release/win-unpacked/*.exe instead (single root exe), mirroring the mac .app glob. Confirmed via the fail-loud guard on the v0.3.0-alpha.1 build.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-03 11:31:53 +02:00
e84f675dad ci: remove duplicate release.yml (build.yml is the sole build+release workflow) (#68)
release.yml and build.yml were added in the same commit and are byte-near-duplicates: both build the 3-OS matrix, Velopack-pack, create the GitHub release, and notify core on v* tags. release.yml has been untouched since creation and still carried the Slopsmith rename skew (so it failed on mac and double-built every tag against build.yml). build.yml is the maintained twin (skew fixed) and fully covers the release path. No status check depends on release.yml.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-03 11:30:03 +02:00
921ae0b66d ci: adapt workflows to trunk-based development (#62)
* ci: adapt workflows to trunk-based development

Nightly builds main directly (old release/v* discovery pinned nightlies
to shipped branches forever). ship-ci adds push triggers on main and
release/** for post-merge signal. New rc.yml builds the desktop app
matrix from release branches during stabilization (signed, not
notarized, 14-day artifacts).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* ci: fix rc.yml mac-zip rename skew + drop duplicate build-core

rc.yml: glob release/mac*/*.app (was hardcoded Slopsmith.app, which no longer exists after the fee[dB]ack rebrand) and derive the zip name from the bundle; rename the win zip + comments off 'Slopsmith'. nightly.yml: remove build-core — core's own nightly already pushes ghcr feedback:nightly on the same cron (duplicate/race, per PR author's open question).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Co-authored-by: byrongamatos <xasiklas@gmail.com>
2026-07-03 11:29:33 +02:00
b4c63f82e7 fix(release): resolve app bundle/exe name dynamically in Velopack pack (rename skew) (#67)
The mac + win Velopack pack steps hardcoded 'Slopsmith' (the pre-rebrand name), so vpk looked for Slopsmith.app / Slopsmith.exe and failed after the app was renamed to productName 'fee[dB]ack'. Now: mac globs release/mac-arm64/*.app and derives --mainExe from its basename; win derives the launcher from package.json build.productName with a fail-loud guard; the non-tag mac tester-zip globs *.app too. packId stays 'Slopsmith' (the installed-client update contract). Linux unaffected (electron-builder names artifacts feedback-*, release globs by extension).

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-03 11:11:57 +02:00
896e0f0ce4 chore(release): bump version to 0.3.0 for the 0.3.0-alpha series (#66)
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-03 10:50:26 +02:00
9560a12680 chore: drop redundant drum/keys-highway-3d from plugin clone list (#65)
Both derive to dirnames (drum_highway_3d, keys_highway_3d) that core already ships as committed plugins/ dirs. The clone loop runs after core is in place, so these two always fail-to-clone-and-skip (dir exists) — dead entries. Core remains the authoritative source for both; no functional change to the shipped app.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-03 10:40:24 +02:00
f8829fec3c chore: stop bundling the find-more plugin in desktop builds (#64)
feedBack-plugin-find-more is a deliberately-withheld repo (kept private); it must not be bundled into or shipped with the desktop app. Drop it from the plugin clone/bundle list.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-03 10:31:53 +02:00
8c57e5e639 chore: stop bundling the update-manager plugin in desktop builds (#63)
The feedBack-plugin-update-manager repo is private and not being shipped, so drop it from the plugin clone/bundle list in build-common.sh. The desktop app's own Velopack auto-updater (src/main/update-manager.ts) is unrelated and untouched.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-03 10:19:07 +02:00
bc3765276d build: bundle Camera Director plugin in multi-platform builds (#61)
The Camera Director plugin (got-feedback/cameradirector_feedback, our fork of
nimuart's) only appeared in locally-built AppImages because the entry adding it
to build-common.sh's plugin clone list was never committed — it existed solely
as a working-tree edit. Fresh-clone builds (CI, Windows, tester AppImages) clone
core plus this fixed plugin list and ignore the resources/ copies, so the plugin
showed for the maintainer but not for testers.

Its only dependency, the highway_3d `window.__h3dCamCtl` freecam bridge, is
already on core main, and there is no per-plugin build step, so adding the repo
to the clone list is enough to bundle it for everyone.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-03 00:30:46 +02:00
1524e349cb fix(build): bundle Rig Builder by its current repo name, not the pre-rename redirect (#59)
The bundled-plugin list cloned got-feedback/rig_builder, a name two
renames behind the current feedBack-plugin-rig-builder. That works only
via GitHub's rename redirect, which silently breaks the moment any new
repo takes the old name — a nightly would then bundle the wrong code
without failing. Point at the canonical repo with an explicit :rig_builder
dirname (capital B means the lowercase feedback-plugin- prefix strip
doesn't apply), which keeps the bundled module name byte-identical.

The other lowercase feedback-plugin-* entries are deliberately untouched:
GitHub repo names are case-insensitively unique, so a case-only mismatch
can never be shadowed or break, and canonicalizing them would force a
case-insensitive rewrite of the prefix strip (risky on the macOS build
host's bash 3.2) for zero functional gain.


Claude-Session: https://claude.ai/code/session_01H1ZBEcZoJinde9ms5fAjwc

Co-authored-by: ChrisBeWithYou <chris@rifflarr.local>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-02 20:58:20 +02:00
dcfa6be19d fix(menu): make View → Zoom In accept the unshifted Ctrl+= key (#55)
The app relied on Electron's default application menu, whose View → Zoom In
binds only `CommandOrControl+Plus`. On US / most keyboard layouts "+" is the
shifted form of `=`, so pressing Ctrl with the unshifted +/= key sends Ctrl+=
and nothing happened — while Zoom Out (`Ctrl+-`, no Shift) worked, making zoom
feel half-broken.

Install an explicit application menu that mirrors Electron's default via
role-based submenus and hand-builds only View, where Zoom In also accepts
`Ctrl+=` and numpad `+` (hidden sibling items keep `Ctrl+Shift+=` and numpad
working). Strictly additive — no other menu behaviour changes.


Claude-Session: https://claude.ai/code/session_01QbexxfTt8q2tAn436MqGWF

Co-authored-by: ChrisBeWithYou <chris@rifflarr.local>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-02 20:58:10 +02:00
c8415113c8 audio: per-slot postGain for parallel-branch loudness leveling (#58)
Adds an optional per-slot output gain (ProcessorSlot.postGain, default 1.0 = no-op) applied in SignalChain::runSlot after processBlock + pan, plumbed through setPostGain / IPC / preload / loadPreset / getChainState (mirroring setPan). Gives each parallel branch its own loudness trim.

Review hardening (multi-angle + Codex): savePreset() now serializes postGain (it was read back but never written → save/load dropped it); setPostGain() rejects non-finite input (NaN would poison the buffer); new signalchain_postgain_test.cpp asserts gain scaling, NaN rejection, and serialization.

Verified locally: build:audio clean; signalchain_postgain_test 5/5 pass. (Org CI runners failed to start — infra/billing, unrelated to the change; changes are platform-neutral C++.)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-02 12:50:47 +02:00
27e8f56ad8 fix(audio): promote in-process VST3 to sandbox on editor-open (Windows crash) (#54)
Windows-only crash fix: opening an in-process VST3 editor faults via WndProc on the background message thread. OpenPluginEditor now promotes the slot to the out-of-process sandbox (state transferred via get/setStateInformation) via the new SignalChain::replaceProcessor, and opens the editor there.

Review hardening (multi-angle + Codex): state capture runs under the audio lock + SEH guard (SignalChain::captureVstStateForPromotion) so it can't race processBlock or fault the app; the transient sandbox pin is undone on promotion failure (isCrashedPlugin/removeCrashedPlugin) so a healthy plugin isn't stranded; replaceProcessor stages type/name/path for correct blocklist attribution; shared prepareForPlayback helper.

CI green incl. addon (windows-latest). Editor-open crash repro on a real Windows host still recommended as follow-up.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-02 10:32:18 +02:00
850d0926c7 fix(audio): close plugin editor windows before freeing their processors (pause UAF #56) (#57)
* fix(audio): close in-process plugin editor windows before freeing their processors (#56)

A `PluginEditorWindow` owns an `AudioProcessorEditor` bound to its slot's
processor, but nothing tore those windows down when the chain was freed. On
pause the renderer clears/reloads the chain (`clearChain` / `loadPreset`), which
destroys every slot processor — leaving any open editor pointing at freed
memory. Its next timer/paint callback then jumps through a dangling pointer:
the reported ACCESS_VIOLATION / DEP-execute at an unmapped address, seconds
after pausing (thread stack thick with `RB Final Leveler.vst3` editor-window
frames calling back into slopsmith_audio.node).

Fix: destroy the in-process editor windows BEFORE the processors they reference,
in all three teardown paths:
- ClearChain (JS thread) — close editors, then clear().
- LoadPresetWorker::Execute (libuv worker) — close editors, then clear() before
  rebuilding the chain.
- doShutdown — destroy editors first inside the existing message-thread lambda,
  before engine.reset().

editorWindows holds JUCE GUI objects, so teardown must happen on the message
thread. `closeAllPluginEditorWindows()` marshals via `dispatchOnMessageThread`
(post-and-wait) so the caller blocks until every editor is gone — guaranteeing
editors die before their processors. Callers already on the message thread
(doShutdown) use the inline `destroyAllPluginEditorWindowsOnMessageThread()` to
avoid a post-and-wait-on-self deadlock. On Linux/Windows the JUCE message thread
is a dedicated std::thread, so ClearChain (Node) and the worker never deadlock;
on macOS dispatch runs inline and in-process editors don't exist (sandboxed).

Native addon builds clean (Release).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* review: fix macOS worker-thread editor teardown; assert precondition; report post/timeout

Codex [P2]: closeAllPluginEditorWindows() delegated to dispatchOnMessageThread(),
which runs inline under JUCE_MAC — so LoadPresetWorker::Execute() (libuv worker)
could destroy JUCE DocumentWindow/AudioProcessorEditor objects off the message
thread on macOS. Now branch on the caller's actual thread: run inline only when
already on the message thread (else deadlock), otherwise post via
MessageManager::callAsync (drained by the JUCE thread on Linux/Windows and the
Node-main libuv timer on macOS) and wait. Correct on all platforms.

Copilot: report a refused post / 15s wait timeout via stderr instead of silently
assuming teardown completed (the previous "guarantee" wording overstated it);
add JUCE_ASSERT_MESSAGE_THREAD to the inline variant as a debug tripwire.

Native addon builds clean (Release).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* review(codex): don't callAsync+wait on an unpumped macOS MessageManager

Codex round 2 [P2]: my round-1 fix routed the off-message-thread teardown
through MessageManager::callAsync + WaitableEvent::wait on ALL platforms. On
macOS there is no separate message-thread pump (startJuceMessageThread's
JUCE_MAC branch only creates the manager; there is no dispatch loop), so a
callAsync+wait from LoadPresetWorker's libuv worker would stall the full 15s
timeout and then proceed with the editor still alive — the very UAF this targets.

Platform-split the off-thread path, matching loadVstSandboxAware()'s existing
JUCE_MAC handling:
- Already on the message thread → inline (doShutdown; ClearChain on macOS).
- Linux/Windows off-thread → post to the dedicated JUCE message thread + wait
  (with refused-post / timeout reporting).
- macOS off-thread → clear inline (the pre-existing macOS worker-thread
  limitation). editorWindows is empty on macOS in practice (in-process editors
  route to the sandbox child), and the editor/processor UAF this targets is
  Windows-specific, so no message-thread hop is needed there.

Native addon builds clean (Release).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* review(codex): tear down editors from LoadPreset (main thread), not the worker

Codex flagged a genuine dilemma in the previous approach: closing editor windows
from LoadPresetWorker::Execute (a libuv worker) is unsafe either way on macOS —
callAsync+wait stalls (no message-thread pump; the "libuv timer" the comment
promises was never implemented) AND clearing inline destroys JUCE GUI objects
off the message thread.

Resolve it by not tearing down from the worker at all: LoadPreset() (the N-API
entry, on the Node/main thread) now closes editors before queuing the
AsyncWorker. That is safe on every platform — macOS: main thread IS the message
thread (inline); Linux/Windows: post to the dedicated JUCE message thread and
wait — and still guarantees editors die before Execute() frees the chain's
processors. closeAllPluginEditorWindows() is consequently never called off a
worker thread, so its macOS special-case is gone and it reduces to the uniform
on-message-thread / post-and-wait form.

Native addon builds clean (Release).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-01 15:05:54 +02:00
3eefa3646a Force high-performance GPU on Windows to stabilize 3D Highway resolution (#53)
On hybrid-GPU Windows laptops (Intel iGPU + NVIDIA/AMD dGPU), Chromium's
GPU-process adapter selection is non-deterministic across launches. When
it binds the iGPU, the 3D Highway's per-frame WebGL cost blows the draw
budget and the load-adaptive resolution scaler (feedBack#654) silently
drops the canvas to as low as quarter-res — so the highway renders
pixelated even with Quality pinned at HD, varying launch to launch. The
renderer's `powerPreference: 'high-performance'` WebGL hint is only
advisory and doesn't reliably override the OS/Chromium adapter choice.

Append the Chromium `force_high_performance_gpu` switch on win32 (before
app.whenReady, so it's read during Chromium init) so the discrete adapter
is selected consistently and the scaler rarely engages. Single-GPU
machines are unaffected; on dual-GPU desktops it likewise picks discrete.

Fixes #52

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-30 20:18:34 +02:00
92a78b4c9a perf(audio): gate ML note-detection pipeline (default OFF, arm on demand) (#51)
* fix(audio-input): stable name-based input identity + fail-loud open + bound read-back

Replace the positional-index logicalSourceKey with a name-encoded one so a
named device survives reorder/hotplug; resolve by name and fail loud instead of
silently opening the default mic; read back and return the actually-bound device.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* perf(audio): gate the ML note-detection pipeline behind a master enable

The Basic-Pitch ONNX detector is the most expensive thing in the engine
(~30 ms inference every hop) and on the default desktop path nothing reads
it: note detection is scored by the harmonic-comb NoteVerifier, and the
always-on home tuner runs its own YIN over raw frames. Yet the pipeline ran
unconditionally from construction, pinning a core on an idle home screen.

Add a master gate so ML only runs when a consumer actually needs it:

- MlNoteDetector: std::atomic<bool> enabled{false}. pushSamples() early-
  returns on the audio thread (lock-free relaxed load, no feed) and
  runInferenceIfDue() early-returns on the inference thread (no Run()), so
  the whole pipeline is dormant until armed. setEnabled(false) clears the
  rolling window + published snapshot (clearAudioState resets hasPublished),
  so a re-arm starts cold and serves the YIN fallback until the first fresh
  inference. The inference thread stays alive but idle — toggling needs no
  thread restart. isEnabled() for symmetry; no-op stubs in the ONNX-off build.
- AudioEngine::setMlNoteDetectionEnabled(bool) fans to every source's detector
  (whole pool, so a later-activated source inherits the arm state).
- NodeAddon setNoteDetectionEnabled + audio-bridge ipc + preload, all typeof/
  try-guarded so a downlevel addon ignores it (fail-safe to current behaviour).

The renderer (note_detect) arms this true only while it will read ML notes
(native-frame detection / non-verifier fallback) and false otherwise — a
follow-up renderer change. Default OFF means the shipped verifier path and
the home tuner pay nothing for ML.

Verified: native addon builds clean (ONNX path); the standalone mlnd_test
detects the full C-major triad when armed (3/3); ml-note-detection +
multi-source JS suites pass (16/16). mlnotedetector/test.cpp arms the
detector after prepare() to match the new default.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(audio): make the ML gate reset race-free (thread-owned cold start)

The first cut cleared the rolling window/FIFO from setEnabled() on the N-API
thread while the inference thread was still alive — a data race on the buffers.
Move the reset onto the thread that owns them, and fix two follow-on issues
Codex flagged:

- fifo.reset() TOCTOU: resetting the FIFO on the inference thread can still race
  an in-flight pushSamples() that passed the resetPending gate just before it was
  set (the >=8 ms callback gap is not a guarantee). Fix: the thread-side cold
  start DRAINS the FIFO (fifo.finishedRead(getNumReady()) — advances only the
  consumer's read index, safe SPSC) instead of fifo.reset(). clearAudioState()
  (with the real reset) is kept for the prepare()/stop() paths where the thread
  is already joined. resetPending stays set through the drain so pushSamples()
  is gated off the FIFO the whole time, then is released.
- stale readiness on re-arm: setEnabled(true) exposed enabled=true immediately
  while hasPublished stayed true from the previous arm, so isReady() briefly
  served the old snapshot. Fix: drop hasPublished synchronously BEFORE storing
  enabled=true (release/acquire ordering: isReady() loads enabled before
  hasPublished, so seeing enabled=true guarantees seeing hasPublished=false).

Other gate mechanics: the enabled-gate is at the top of the inference callback
(disabled ⇒ no ingest, no inference), pushSamples() no-ops when !enabled or
resetPending, and isReady() gates on enabled so a suspended detector serves the
YIN fallback rather than a stale snapshot.

mlnotedetector/test.cpp asserts both directions: fed the chord region while
DISABLED, the detector publishes nothing and never becomes ready; armed, it
still detects the full C-major triad (3/3). Addon rebuilds clean; tsc clean;
ml-note-detection + multi-source JS suites pass (16/16).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-29 23:12:31 +02:00
06c68262a9 Streamer mix outputs (PR1): one stream bus → a 2nd output device (#49)
* feat(audio): streamer mix outputs — one stream bus to a 2nd output device (PR1)

Built-in routing so a streamer can send a separate mix (game ± their guitar
tone) to a second output device for OBS/Discord capture, while still monitoring
locally — no VoiceMeeter/Reaper. PR1 of the design in docs/streamer-mix-outputs.md.

Architecture: this inverts the engine's proven Phase-2 multi-INPUT-device pattern
to the output side. A new StreamSink = its own AudioDeviceManager + drain callback
+ packed drop-oldest SPSC ring (a mirror of InputDeviceSlot). The PRODUCER is the
main output path (both the duplex callback and the split audioOutputCallback): it
snapshots the guitar monitor mix BEFORE backing is added, then composes the stream
submix (includeGuitar ? guitar : 0) + (includeBacking ? backing : 0) × gain and
packs it into the sink ring. The CONSUMER (streamSinkCallback) drains the ring to
the second device. Backing is rendered once on the master clock and fanned to the
stream ring (never re-advances the transport / touches backingLock). Default off →
zero behaviour change; the sink reopens across restarts (reopenDesiredStreamSink,
mirroring reopenDesiredExtraInputs).

Surface: NodeAddon setStreamOutputDevice/clearStreamOutput/setStreamBus/
setStreamBusGain/getStreamSinkLevel/isStreamOutputActive/getStreamUnderflowCount
→ audio:* IPC → preload → a new "Streaming & Extra Outputs" section on the Audio
page (device picker, game/guitar toggles, gain, a meter mirroring what OBS/Discord
receives; persisted to localStorage). v1 rejects a sample-rate-mismatched sink with
a clear error (async SRC is PR3).

Scope (PR1): ONE stream bus = game ± the guitar monitor mix. Per-source A/B mixes
(re-amped DI vs wet as separate OBS tracks) and per-bus mute that lets a local
monitor-kill (#47) NOT silence the stream are PR2 (see the doc). No virtual driver
shipped — route to a spare output / virtual cable / Go-Live capture.

NOT compiled or run on the author's box — this is native C++ (AudioEngine /
NodeAddon) that needs a desktop build. Renderer JS verified with node --check;
TS bridge/preload are additive (AudioModule is an index type so the calls
typecheck). Draft pending a build + a tester pass (see the PR checklist).

Refs got-feedback/feedBack-desktop#48 (tracking), #46/#47 (audio-engine family).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QbexxfTt8q2tAn436MqGWF

* fix(audio): harden streamer-mix sink lifecycle/RT-safety (review on PR #49)

Addresses the P0/P1/P2/P3 findings from the Codex + manual review.

P0 — shutdown UAF: ~AudioEngine() never tore down the stream sink, and
StreamSink declared `manager` before `callback`/`ring`, so the manager
could be destroyed after the callback/ring it drives. stopAudio() now
closes the sink (and the dtor calls stopAudio()), and `manager` is
declared LAST so it destructs first even if a teardown path is missed.

P1 — stopAudio() ignored the sink: the 2nd output device kept running and
underflowing while "stopped". It now closes via closeStreamSinkDevice()
(intent preserved → startAudio() reopens, like extra inputs).

P1 — split-path producer buffers could realloc under a live callback:
streamGuitarScratch/streamMixScratch are now sized to a fixed capacity
(>= the ring) so a same/smaller-block device restart on either clock never
reallocates them mid-use.

P1 — split path read backingBuffer OUTSIDE backingLock (duplex held it):
composeAndPushStreamMix in audioOutputCallback now runs inside the lock
scope, so backingBuffer is read under the lock that guards its resize.

P1 — live setStreamOutputDevice() broke the SPSC single-writer invariant:
streamSinkAboutToStart() resets the ring while the producer might still be
writing. It now clears `active` before reconfiguring so the producer
stops, and only re-arms after a clean open.

P1 — failed open left stale state: a shared `fail()` path now closes the
device and drops the desired intent, so a deterministic failure (e.g. SR
mismatch) isn't retried every start and never reports active with no
device. The renderer keeps its own persisted choice.

P2 — streamSinkStopped() was empty: now marks the sink inactive (and
clears the meter) on an unplanned device loss, preserving intent.

P2 — no ring-capacity guard on the duplex path: composeAndPushStreamMix
skips (and counts) a block larger than the ring instead of wrapping.

P2 — gain NaN/Inf + bridge bool coercion: native sanitizeStreamGain()
(finite, clamped 0..8); the TS bridge requires real booleans (no
Boolean("false")===true) and a finite gain.

P3 — drop-oldest now counted via streamSink.overflowCount, exposed as
getStreamOverflowCount() through the addon/bridge/preload (mirrors
underflow) for drift diagnosis.

Still NOT compiled here (needs a desktop build). TS typechecks clean
(tsc --noEmit); renderer node --check clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(audio): make stream scratch fixed-capacity; document reconfig tail

Follow-up to the review-fix commit, closing the two residual edge cases
from the Codex re-review:

- Producer scratch (streamGuitarScratch/streamMixScratch) is now sized to a
  FIXED capacity == the ring and never grown with the block size. Oversized
  blocks are already skipped by the capacity guard, so a fixed cap is
  sufficient and means the buffers allocate exactly once — they can never
  realloc under a live split-mode producer for ANY later/hotplug block size
  (previously a larger restart block could still realloc).

- Reworded the setStreamOutputDevice() comment to stop overstating the
  active=false barrier: it prevents NEW producer pushes, but a block already
  in flight can finish one push before the (much slower) device reopen drives
  streamSinkAboutToStart's ring reset. Net worst case is one imperfect block
  on the stream bus (never the local monitor) during a manual device switch —
  atomic, no data race, no UAF. Documented as a known PR1 limitation.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(audio): count oversized stream blocks (capacity guard before scratch guard)

Codex re-review nit: with the fixed-size scratch (== ring), an oversized
block tripped the undersized-scratch guard first and was dropped without
being counted. Check the ring-capacity guard FIRST so oversized duplex
blocks are always counted as stream overflows; keep the scratch guard
after it as cold-start/reconfig defense.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: ChrisBeWithYou <chris@rifflarr.local>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: byrongamatos <xasiklas@gmail.com>
2026-06-29 00:49:25 +02:00