fix(audio): sanitize input/chain/output/backing gains at the engine setters

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>
This commit is contained in:
OmikronApex
2026-07-13 23:28:34 +02:00
co-authored by Claude Fable 5
parent bbb3b58db8
commit 3c8dd62ecb
6 changed files with 104 additions and 5 deletions
+7 -3
View File
@@ -1,5 +1,6 @@
#pragma once
#include "SourceChain.h"
#include "GainSanitize.h"
#include "BackingLeveler.h"
#include "signalsmith-stretch.h"
#include <juce_audio_devices/juce_audio_devices.h>
@@ -154,7 +155,10 @@ public:
// Gain controls. Input + chain-output gain are per-source (sources[0]);
// output gain is the post-mix master and stays engine-global.
void setInputGain(float gain) { source0().setInputGain(gain); }
void setOutputGain(float gain) { outputGain.store(gain); }
// Sanitized (see GainSanitize.h): a NaN/Inf master gain from JS would
// multiply the whole device output to NaN downstream of the per-source
// scrub — clamp at the store so every caller is covered.
void setOutputGain(float gain) { outputGain.store(slopsmith::sanitizeMasterGain(gain)); }
float getInputGain() const { return source0().getInputGain(); }
float getOutputGain() const { return outputGain.load(); }
@@ -216,7 +220,7 @@ public:
void setTonePolishEnabled(bool enabled) { source0().setTonePolishEnabled(enabled); }
// Backing track
void setBackingVolume(float vol) { backingVolume.store(vol); }
void setBackingVolume(float vol) { backingVolume.store(slopsmith::sanitizeMasterGain(vol)); }
bool loadBackingTrack(const juce::File& file);
void setBackingPosition(double seconds);
void startBacking();
@@ -792,7 +796,7 @@ private:
// Clamp a requested stream gain to a finite, sane range so a NaN/Inf (or a
// wild value) from the JS bridge can never be packed into the stream ring.
static float sanitizeStreamGain(float g) { return std::isfinite(g) ? juce::jlimit(0.0f, 8.0f, g) : 0.0f; }
static float sanitizeStreamGain(float g) { return slopsmith::sanitizeStreamGain(g); }
void streamSinkCallback(float* const* outputData, int numOutputChannels, int numSamples);
void streamSinkAboutToStart(juce::AudioIODevice* device);
+36
View File
@@ -0,0 +1,36 @@
#pragma once
// Gain-argument containment (audio-engine TLC, deep-read §2).
//
// N-API's Number coercion lets NaN/Infinity from JS reach the engine's gain
// atomics raw — a NaN master gain multiplies the whole device output to NaN
// (buffer.applyGain) and poisons the peak meters, and nothing downstream
// scrubs it (the per-source NaN scrub runs before the master gain). Clamping
// at the engine setters is the single choke point that fixes every caller:
// audio:setGain, the source-indexed API, and the audio-effects executor.
//
// Bounds: 0..32 for input/chain/output/backing — matching the executor's
// JS-side clampGain so a legit high rig gain is never under-shot (compat pin,
// docs/audio-engine-tlc.md Phase 0.b). The stream/renderer-bus gains keep
// their tighter historical 0..8 (previously sanitizeStreamGain).
//
// JUCE-free on purpose, like AudioSanitize.h, so tests/engine_units can test
// it without a device.
#include <cmath>
namespace slopsmith {
// Non-finite → 0 (silence beats a poisoned mix); otherwise clamp to [0, max].
inline float sanitizeGain(float g, float maxGain) noexcept
{
if (!std::isfinite(g)) return 0.0f;
if (g < 0.0f) return 0.0f;
if (g > maxGain) return maxGain;
return g;
}
inline float sanitizeMasterGain(float g) noexcept { return sanitizeGain(g, 32.0f); }
inline float sanitizeStreamGain(float g) noexcept { return sanitizeGain(g, 8.0f); }
} // namespace slopsmith
+5 -2
View File
@@ -3,6 +3,7 @@
#include "NoiseGate.h"
#include "TonePolish.h"
#include "SignalChain.h"
#include "GainSanitize.h"
#include "PitchDetector.h"
#include "ChordScorer.h"
#include "MlNoteDetector.h"
@@ -125,9 +126,11 @@ public:
}
void setTonePolishEnabled(bool enabled) { tonePolish.setEnabled(enabled); }
void setInputGain(float gain) { inputGain.store(gain); }
// Sanitized (see GainSanitize.h) so NaN/Inf from the JS bridge can't
// reach the audio thread via either the legacy or the indexed API.
void setInputGain(float gain) { inputGain.store(slopsmith::sanitizeMasterGain(gain)); }
float getInputGain() const { return inputGain.load(); }
void setChainOutputGain(float gain) { chainOutputGain.store(gain); }
void setChainOutputGain(float gain) { chainOutputGain.store(slopsmith::sanitizeMasterGain(gain)); }
float getChainOutputGain() const { return chainOutputGain.load(); }
void setInputChannel(int channel) { selectedInputChannel.store(channel); }
int getInputChannel() const { return selectedInputChannel.load(); }