Add "Disable input monitoring" (full monitor kill) for own-rig players (#47)

* audio: add "Disable input monitoring" (full monitor kill) for own-rig players

The "Mute direct monitoring" control only mutes the DRY pass-through: by
design it's bypassed when the signal chain has processors
(SourceChain.cpp — `monitorMuted && !hasProcessors`). So with an amp sim
loaded (the opt-out default), the processed signal still reaches the
output and the mute is a no-op — you can't silence in-app monitoring, and
an idle input through a high-gain amp sim is a constant distorted buzz.

Add an additive, default-OFF "monitor kill" that silences the guitar bus
unconditionally (dry AND processed), independent of the dry-mute and not
subject to the song-load suppression guard. It runs after the chain so the
pitch detector / metering still see real signal, and before the backing-
track mix so playback is unaffected. Wired end to end:

  SourceChain (flag + processBlock gate) -> AudioEngine facade
  -> NodeAddon setMonitorKill (IsBoolean-guarded) -> audio:setMonitorKill
  -> preload setMonitorKill -> Audio settings "Disable input monitoring"
  checkbox (persisted/restored like monitorMute).

Default off means existing amp-sim monitoring is byte-for-byte unchanged;
fail-soft at every layer (IsBoolean guard / typeof guard / optional call)
so a downlevel addon or renderer is a clean no-op.

Addresses got-feedback/feedBack-desktop#46 (the monitor-kill half). The
amp-sim/NAM opt-in onboarding remains a follow-up tracked there.

NOTE: not compiled/run on the author's box — the native addon needs a
desktop build. Logic mirrors the existing setMonitorMute path; verified by
inspection + `node --check` on the renderer. Needs a build + tester check.

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

* audio: fix monitor-kill persistence + make it global; clarify mute copy

Addresses review findings on the "Disable input monitoring" PR.

P1 (blocker): monitorKill never persisted across restart. Both
normalizeDeviceSettings whitelists (renderer screen.js + main
audio-bridge.ts) and the AudioDeviceSettings type only carried
monitorMute, so the saved flag was stripped on every load before the
restore block could read it. Carry monitorKill through both normalizers
and the TS interface, mirroring monitorMute.

P2: the kill is a global "play through my own rig" preference but
AudioEngine::setMonitorKill only touched source0(), so additional active
sources (multi-input) stayed audible while the UI claimed it silences
"all in-app monitoring". Apply it to every pooled source; addSource
never resets the flag, so later-activated sources inherit it. Pool
pointers are fixed and these are atomic stores, so the control-thread
iteration is race-free.

P3: clarify the existing "Mute direct monitoring" helper text so the two
controls aren't confused — it mutes the dry passthrough only and a
loaded amp sim is still heard; point users to "Disable input monitoring".

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>
This commit is contained in:
ChrisBeWithYou
2026-06-28 22:22:42 +02:00
committed by GitHub
co-authored by Claude Opus 4.8 ChrisBeWithYou byrongamatos
parent e7b7a08f44
commit 9facf78c98
8 changed files with 86 additions and 1 deletions
+15
View File
@@ -174,6 +174,21 @@ public:
void setMonitorMuteSuppressed(bool suppressed) { source0().setMonitorMuteSuppressed(suppressed); }
bool isMonitorMuteSuppressed() const { return source0().isMonitorMuteSuppressed(); }
// Full monitor kill — silences the guitar bus entirely (dry + processed),
// for monitoring through an external rig. Unlike the per-source mute/gain
// controls (which delegate to source0()), this is a GLOBAL "play through my
// own rig" preference, so it's applied to EVERY pooled source — active or
// not — so additional inputs are silenced too and a later addSource()
// inherits it (addSource never resets the flag). The fixed pool's pointers
// are never reassigned, and these are plain atomic stores, so iterating it
// off the control thread is race-free. Default off; see SourceChain.
void setMonitorKill(bool kill)
{
for (auto& s : sources)
if (s) s->setMonitorKill(kill);
}
bool isMonitorKilled() const { return source0().isMonitorKilled(); }
// Number of audio blocks whose signal-chain output had to be scrubbed for
// non-finite/runaway samples (issue #403). A nonzero value means the chain
// (NAM/IR/VST) emitted garbage that was contained before it reached the
+11
View File
@@ -627,6 +627,16 @@ static Napi::Value SetMonitorMuteSuppressed(const Napi::CallbackInfo& info)
return info.Env().Undefined();
}
static Napi::Value SetMonitorKill(const Napi::CallbackInfo& info)
{
// IsBoolean()-guarded (fail-soft no-op on a downlevel/mismatched caller),
// mirroring SetMonitorMuteSuppressed.
auto liveEngine = snapshotEngine();
if (liveEngine && info.Length() > 0 && info[0].IsBoolean())
liveEngine->setMonitorKill(info[0].As<Napi::Boolean>().Value());
return info.Env().Undefined();
}
static Napi::Value SetNoiseGate(const Napi::CallbackInfo& info)
{
auto env = info.Env();
@@ -3130,6 +3140,7 @@ static Napi::Object InitModule(Napi::Env env, Napi::Object exports)
exports.Set("setMonitorMute", Napi::Function::New(env, SetMonitorMute));
exports.Set("setMonitorMuteSuppressed", Napi::Function::New(env, SetMonitorMuteSuppressed));
exports.Set("isMonitorMuted", Napi::Function::New(env, IsMonitorMuted));
exports.Set("setMonitorKill", Napi::Function::New(env, SetMonitorKill));
exports.Set("setNoiseGate", Napi::Function::New(env, SetNoiseGate));
exports.Set("setTonePolish", Napi::Function::New(env, SetTonePolish));
+9
View File
@@ -236,6 +236,15 @@ void SourceChain::processBlock(const float* const* inputData, int numInputChanne
if (monitorMuted.load() && !hasProcessors && !monitorMuteSuppressed.load())
buffer.clear();
// Full monitor kill: silence the guitar bus unconditionally — dry AND the
// processed/amp-sim signal — for users who monitor through their own external
// rig. Distinct from the dry-only mute above (which a loaded chain bypasses),
// and NOT subject to the suppression guard. Runs after the chain so the pitch
// detector / metering still see real signal; the backing track is mixed in
// later, so it keeps playing.
if (monitorKill.load())
buffer.clear();
// Chain output gain — the amp/tone's output level. Applied to the guitar
// signal ONLY, before the backing track is mixed in, so switching tone presets
// changes the guitar level without touching the song volume.
+8
View File
@@ -143,6 +143,13 @@ public:
bool isMonitorMuted() const { return monitorMuted.load(); }
void setMonitorMuteSuppressed(bool s) { monitorMuteSuppressed.store(s); }
bool isMonitorMuteSuppressed() const { return monitorMuteSuppressed.load(); }
// Full monitor kill — silences the guitar bus UNCONDITIONALLY (dry AND the
// processed/amp-sim signal), unlike setMonitorMute which only mutes the dry
// pass-through when no processors are loaded. For users who monitor through
// their own external rig and want zero in-app monitoring. Default off, so
// existing amp-sim monitoring is unaffected.
void setMonitorKill(bool kill) { monitorKill.store(kill); }
bool isMonitorKilled() const { return monitorKill.load(); }
float getInputLevel() const { return currentInputLevel.load(); }
float getInputPeak() const { return inputPeak.load(); }
@@ -192,6 +199,7 @@ private:
std::atomic<double> verifierUserOffset{0.0}; // renderer: manual fine-tune
std::atomic<bool> monitorMuted{true};
std::atomic<bool> monitorMuteSuppressed{false};
std::atomic<bool> monitorKill{false};
std::atomic<uint32_t> nonFiniteChainBlocks{0};
// ── Lock-free SPSC input rings (see AudioEngine.h for the full rationale) ──
+13
View File
@@ -24,6 +24,7 @@ type AudioDeviceSettings = {
bufferSize: string;
inputChannel: string;
monitorMute?: boolean;
monitorKill?: boolean;
savedAt?: number;
};
@@ -85,6 +86,9 @@ function normalizeDeviceSettings(settings: unknown): AudioDeviceSettings | null
if (typeof record.monitorMute === 'boolean') {
normalized.monitorMute = record.monitorMute;
}
if (typeof record.monitorKill === 'boolean') {
normalized.monitorKill = record.monitorKill;
}
const savedAt = Number(record.savedAt);
if (Number.isFinite(savedAt) && savedAt > 0) {
normalized.savedAt = savedAt;
@@ -536,6 +540,15 @@ export function initAudioBridge(): void {
return audio?.isMonitorMuted() ?? true;
});
ipcMain.handle('audio:setMonitorKill', (_event, kill: boolean) => {
// typeof-guarded fail-soft: a downlevel addon without setMonitorKill is a
// no-op rather than a thrown IPC error. Coerced to a real boolean so the
// N-API IsBoolean() guard sees a clean value.
if (audio && typeof audio.setMonitorKill === 'function') {
audio.setMonitorKill(Boolean(kill));
}
});
ipcMain.handle(
'audio:setNoiseGate',
(
+1
View File
@@ -241,6 +241,7 @@ const feedBackDesktopApi = {
setMonitorMuteSuppressed: (suppressed: boolean) =>
ipcRenderer.invoke('audio:setMonitorMuteSuppressed', suppressed),
isMonitorMuted: () => ipcRenderer.invoke('audio:isMonitorMuted'),
setMonitorKill: (kill: boolean) => ipcRenderer.invoke('audio:setMonitorKill', kill),
setNoiseGate: (payload: {
enabled: boolean;
thresholdDb: number;
+9 -1
View File
@@ -40,7 +40,15 @@
<input type="checkbox" id="ae-monitor-mute" checked
class="w-4 h-4 rounded bg-slate-700 border-slate-600 accent-emerald-500">
<span class="text-sm text-slate-300">Mute direct monitoring</span>
<span class="text-xs text-slate-500">(only hear guitar through signal chain plugins)</span>
<span class="text-xs text-slate-500">(mutes the dry passthrough only — a loaded amp sim is still heard; use “Disable input monitoring” below to silence everything)</span>
</label>
</div>
<div class="md:col-span-2">
<label class="flex items-center gap-2 cursor-pointer">
<input type="checkbox" id="ae-monitor-kill"
class="w-4 h-4 rounded bg-slate-700 border-slate-600 accent-emerald-500">
<span class="text-sm text-slate-300">Disable input monitoring</span>
<span class="text-xs text-slate-500">(play through your own amp/rig — silences all in-app monitoring, including amp sims)</span>
</label>
</div>
<div>
+20
View File
@@ -55,6 +55,7 @@ window.__feedBackDesktopAudioHooks = window.__feedBackDesktopAudioHooks || {};
const inputGainLabel = $('ae-input-gain-label');
const outputGainLabel = $('ae-output-gain-label');
const monitorMuteCheckbox = $('ae-monitor-mute');
const monitorKillCheckbox = $('ae-monitor-kill');
const chainContainer = $('ae-chain');
const addVstBtn = $('ae-add-vst');
const addNamBtn = $('ae-add-nam');
@@ -122,6 +123,7 @@ window.__feedBackDesktopAudioHooks = window.__feedBackDesktopAudioHooks || {};
bufferSize: bufferSizeSelect.value,
inputChannel: inputChannelSelect.value,
monitorMute: monitorMuteCheckbox.checked,
monitorKill: monitorKillCheckbox.checked,
};
}
@@ -181,6 +183,7 @@ window.__feedBackDesktopAudioHooks = window.__feedBackDesktopAudioHooks || {};
inputChannel: normalizeSelectSettingValue(settings.inputChannel),
};
if (typeof settings.monitorMute === 'boolean') normalized.monitorMute = settings.monitorMute;
if (typeof settings.monitorKill === 'boolean') normalized.monitorKill = settings.monitorKill;
const savedAt = Number(settings.savedAt);
if (Number.isFinite(savedAt) && savedAt > 0) normalized.savedAt = savedAt;
return normalized;
@@ -842,6 +845,14 @@ window.__feedBackDesktopAudioHooks = window.__feedBackDesktopAudioHooks || {};
try { await api.setMonitorMute(saved.monitorMute); }
catch (e) { console.warn('[audio-engine] setMonitorMute restore failed:', e); }
}
if (saved.monitorKill !== undefined) {
monitorKillCheckbox.checked = saved.monitorKill;
// Push immediately too — same reason as monitorMute above; the
// native default is monitorKill{false}, so a saved-true must be
// applied even if the device probe below fails.
try { await api.setMonitorKill?.(saved.monitorKill); }
catch (e) { console.warn('[audio-engine] setMonitorKill restore failed:', e); }
}
// Respect refreshDeviceOptions's fail-closed verdict: if the
// probe didn't explicitly confirm compatible=true, skip the
@@ -1272,6 +1283,7 @@ window.__feedBackDesktopAudioHooks = window.__feedBackDesktopAudioHooks || {};
const inputChannel = parseInt(inputChannelSelect.value);
if (Number.isFinite(inputChannel)) await api.setInputChannel(inputChannel);
await api.setMonitorMute(monitorMuteCheckbox.checked);
await api.setMonitorKill?.(monitorKillCheckbox.checked);
await api.startAudio();
audioRunning = true;
toggleBtn.textContent = 'Stop';
@@ -1309,6 +1321,14 @@ window.__feedBackDesktopAudioHooks = window.__feedBackDesktopAudioHooks || {};
await saveAppliedDeviceSettings({ monitorMute: monitorMuteCheckbox.checked });
});
// Disable input monitoring (full kill) — for playing through an external
// rig. Applies live; the dry-only "Mute direct monitoring" can't silence a
// loaded amp sim, this can.
monitorKillCheckbox.addEventListener('change', async () => {
await api.setMonitorKill?.(monitorKillCheckbox.checked);
await saveAppliedDeviceSettings({ monitorKill: monitorKillCheckbox.checked });
});
// Gain sliders (UI dB → linear amplitude for engine)
inputGainSlider.addEventListener('input', () => {
const db = parseFloat(inputGainSlider.value);