2 Commits

Author SHA1 Message Date
mk
0788e56c51 feat: add restore defaults button to transient suppressor
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-23 23:21:20 -03:00
mk
411e18c48a feat: add transient suppressor to audio pipeline
Implements a per-sample transient suppressor in the noise gate AudioWorklet
that instantly cuts gain when a sudden loud peak (desk hit, mic bump) exceeds
the slow background RMS by a configurable threshold, then releases over a
short window. Exposes enable, sensitivity, and release controls in the audio
settings tab.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-23 23:13:57 -03:00
5 changed files with 178 additions and 27 deletions

View File

@@ -25,6 +25,9 @@ interface NoiseGateParams {
attackMs: number; attackMs: number;
holdMs: number; holdMs: number;
releaseMs: number; releaseMs: number;
transientEnabled: boolean;
transientThresholdDb: number; // dB above background RMS that triggers suppression
transientReleaseMs: number; // how quickly suppression fades after transient ends
} }
function dbToLinear(db: number): number { function dbToLinear(db: number): number {
@@ -32,21 +35,36 @@ function dbToLinear(db: number): number {
} }
/** /**
* AudioWorkletProcessor implementing a noise gate. * AudioWorkletProcessor implementing a noise gate and an optional transient
* suppressor, both running per-sample in a single pass.
* *
* Per-sample peak detection across all channels. Gate opens when the * Noise gate: opens when instantaneous peak exceeds threshold, closes below.
* instantaneous peak exceeds the threshold and closes when it drops below.
* Attack, hold, and release times smooth the attenuation envelope. * Attack, hold, and release times smooth the attenuation envelope.
*
* Transient suppressor: tracks a slow-moving RMS background level. When the
* instantaneous peak exceeds the background by more than transientThresholdDb,
* gain is instantly cut to 0 and releases over transientReleaseMs. This catches
* desk hits, mic bumps, and other sudden loud impacts without affecting speech.
*/ */
class NoiseGateProcessor extends AudioWorkletProcessor { class NoiseGateProcessor extends AudioWorkletProcessor {
// Noise gate state
private threshold = dbToLinear(-60); private threshold = dbToLinear(-60);
private attackRate = 1.0 / (0.025 * 48000); private attackRate = 1.0 / (0.025 * sampleRate);
private releaseRate = 1.0 / (0.15 * 48000); private releaseRate = 1.0 / (0.15 * sampleRate);
private holdTime = 0.2; private holdTime = 0.2;
private isOpen = false; private isOpen = false;
private attenuation = 0; private gateAttenuation = 0;
private heldTime = 0; private heldTime = 0;
// Transient suppressor state
private transientEnabled = false;
private transientRatio = dbToLinear(15); // peak must exceed rms by this factor
private transientReleaseRate = 1.0 / (0.08 * sampleRate);
private transientAttenuation = 1.0; // 1 = fully open, ramps to 0 on transient
private slowRms = 0;
// Exponential smoothing coefficient for background RMS (~200ms time constant)
private rmsCoeff = Math.exp(-1.0 / (0.2 * sampleRate));
private logCounter = 0; private logCounter = 0;
public constructor() { public constructor() {
@@ -54,7 +72,10 @@ class NoiseGateProcessor extends AudioWorkletProcessor {
this.port.onmessage = (e: MessageEvent<NoiseGateParams>): void => { this.port.onmessage = (e: MessageEvent<NoiseGateParams>): void => {
this.updateParams(e.data); this.updateParams(e.data);
}; };
this.updateParams({ threshold: -60, attackMs: 25, holdMs: 200, releaseMs: 150 }); this.updateParams({
threshold: -60, attackMs: 25, holdMs: 200, releaseMs: 150,
transientEnabled: false, transientThresholdDb: 15, transientReleaseMs: 80,
});
this.port.postMessage({ type: "log", msg: "[NoiseGate worklet] constructor called, sampleRate=" + sampleRate }); this.port.postMessage({ type: "log", msg: "[NoiseGate worklet] constructor called, sampleRate=" + sampleRate });
} }
@@ -63,12 +84,14 @@ class NoiseGateProcessor extends AudioWorkletProcessor {
this.attackRate = 1.0 / ((p.attackMs / 1000) * sampleRate); this.attackRate = 1.0 / ((p.attackMs / 1000) * sampleRate);
this.releaseRate = 1.0 / ((p.releaseMs / 1000) * sampleRate); this.releaseRate = 1.0 / ((p.releaseMs / 1000) * sampleRate);
this.holdTime = p.holdMs / 1000; this.holdTime = p.holdMs / 1000;
this.transientEnabled = p.transientEnabled;
this.transientRatio = dbToLinear(p.transientThresholdDb);
this.transientReleaseRate = 1.0 / ((p.transientReleaseMs / 1000) * sampleRate);
this.port.postMessage({ this.port.postMessage({
type: "log", type: "log",
msg: "[NoiseGate worklet] params: threshold=" + this.threshold.toFixed(5) msg: "[NoiseGate worklet] params updated: threshold=" + p.threshold
+ " attack=" + this.attackRate.toFixed(8) + " transientEnabled=" + p.transientEnabled
+ " release=" + this.releaseRate.toFixed(8) + " transientThresholdDb=" + p.transientThresholdDb,
+ " hold=" + this.holdTime,
}); });
} }
@@ -79,7 +102,7 @@ class NoiseGateProcessor extends AudioWorkletProcessor {
const channels = input.length; const channels = input.length;
const blockSize = input[0]?.length ?? 128; const blockSize = input[0]?.length ?? 128;
const sampleRateI = 1.0 / sampleRate; const samplePeriod = 1.0 / sampleRate;
for (let i = 0; i < blockSize; i++) { for (let i = 0; i < blockSize; i++) {
// Peak detection across all channels // Peak detection across all channels
@@ -88,32 +111,50 @@ class NoiseGateProcessor extends AudioWorkletProcessor {
curLevel = Math.max(curLevel, Math.abs(input[j]?.[i] ?? 0)); curLevel = Math.max(curLevel, Math.abs(input[j]?.[i] ?? 0));
} }
// Open gate when signal exceeds threshold // --- Transient suppressor ---
let transientGain = 1.0;
if (this.transientEnabled) {
// Update slow RMS background (exponential moving average of energy)
this.slowRms = Math.sqrt(
this.rmsCoeff * this.slowRms * this.slowRms +
(1.0 - this.rmsCoeff) * curLevel * curLevel,
);
const background = Math.max(this.slowRms, 1e-6);
if (curLevel > background * this.transientRatio) {
// Transient detected — instantly cut gain
this.transientAttenuation = 0.0;
} else {
// Release: ramp back toward 1
this.transientAttenuation = Math.min(1.0, this.transientAttenuation + this.transientReleaseRate);
}
transientGain = this.transientAttenuation;
}
// --- Noise gate ---
if (curLevel > this.threshold && !this.isOpen) { if (curLevel > this.threshold && !this.isOpen) {
this.isOpen = true; this.isOpen = true;
} }
// Close gate when signal drops below threshold; hold timer starts
if (curLevel <= this.threshold && this.isOpen) { if (curLevel <= this.threshold && this.isOpen) {
this.heldTime = 0; this.heldTime = 0;
this.isOpen = false; this.isOpen = false;
} }
// Ramp attenuation toward 1 (open) or wait for hold then ramp toward 0 (closed)
if (this.isOpen) { if (this.isOpen) {
this.attenuation = Math.min(1.0, this.attenuation + this.attackRate); this.gateAttenuation = Math.min(1.0, this.gateAttenuation + this.attackRate);
} else { } else {
this.heldTime += sampleRateI; this.heldTime += samplePeriod;
if (this.heldTime > this.holdTime) { if (this.heldTime > this.holdTime) {
this.attenuation = Math.max(0.0, this.attenuation - this.releaseRate); this.gateAttenuation = Math.max(0.0, this.gateAttenuation - this.releaseRate);
} }
} }
const gain = this.gateAttenuation * transientGain;
for (let c = 0; c < output.length; c++) { for (let c = 0; c < output.length; c++) {
const inCh = input[c] ?? input[0]; const inCh = input[c] ?? input[0];
const outCh = output[c]; const outCh = output[c];
if (inCh && outCh) { if (inCh && outCh) {
outCh[i] = (inCh[i] ?? 0) * this.attenuation; outCh[i] = (inCh[i] ?? 0) * gain;
} }
} }
} }
@@ -122,9 +163,10 @@ class NoiseGateProcessor extends AudioWorkletProcessor {
if (this.logCounter % 375 === 0) { if (this.logCounter % 375 === 0) {
this.port.postMessage({ this.port.postMessage({
type: "log", type: "log",
msg: "[NoiseGate worklet] isOpen=" + this.isOpen msg: "[NoiseGate worklet] gateOpen=" + this.isOpen
+ " attenuation=" + this.attenuation.toFixed(3) + " gateAtten=" + this.gateAttenuation.toFixed(3)
+ " heldTime=" + this.heldTime.toFixed(3), + " transientAtten=" + this.transientAttenuation.toFixed(3)
+ " slowRms=" + this.slowRms.toFixed(5),
}); });
} }

View File

@@ -15,6 +15,9 @@ export interface NoiseGateParams {
attackMs: number; attackMs: number;
holdMs: number; holdMs: number;
releaseMs: number; releaseMs: number;
transientEnabled: boolean;
transientThresholdDb: number; // dB above background RMS that triggers suppression
transientReleaseMs: number; // ms for suppression to fade after transient ends
} }
/** /**

View File

@@ -29,6 +29,9 @@ import {
noiseGateAttack as noiseGateAttackSetting, noiseGateAttack as noiseGateAttackSetting,
noiseGateHold as noiseGateHoldSetting, noiseGateHold as noiseGateHoldSetting,
noiseGateRelease as noiseGateReleaseSetting, noiseGateRelease as noiseGateReleaseSetting,
transientSuppressorEnabled as transientSuppressorEnabledSetting,
transientThreshold as transientThresholdSetting,
transientRelease as transientReleaseSetting,
} from "./settings"; } from "./settings";
import { PreferencesSettingsTab } from "./PreferencesSettingsTab"; import { PreferencesSettingsTab } from "./PreferencesSettingsTab";
import { Slider } from "../Slider"; import { Slider } from "../Slider";
@@ -126,6 +129,20 @@ export const SettingsModal: FC<Props> = ({
const [showAdvancedGate, setShowAdvancedGate] = useState(false); const [showAdvancedGate, setShowAdvancedGate] = useState(false);
// Transient suppressor settings
const [transientEnabled, setTransientEnabled] = useSetting(transientSuppressorEnabledSetting);
const [transientThreshold, setTransientThreshold] = useSetting(transientThresholdSetting);
const [transientThresholdRaw, setTransientThresholdRaw] = useState(transientThreshold);
const [transientRelease, setTransientRelease] = useSetting(transientReleaseSetting);
const [transientReleaseRaw, setTransientReleaseRaw] = useState(transientRelease);
const resetTransientDefaults = useCallback((): void => {
const t = transientThresholdSetting.defaultValue;
const r = transientReleaseSetting.defaultValue;
setTransientThreshold(t); setTransientThresholdRaw(t);
setTransientRelease(r); setTransientReleaseRaw(r);
}, [setTransientThreshold, setTransientRelease]);
const resetGateDefaults = useCallback((): void => { const resetGateDefaults = useCallback((): void => {
const a = noiseGateAttackSetting.defaultValue; const a = noiseGateAttackSetting.defaultValue;
const h = noiseGateHoldSetting.defaultValue; const h = noiseGateHoldSetting.defaultValue;
@@ -293,6 +310,67 @@ export const SettingsModal: FC<Props> = ({
</> </>
)} )}
</div> </div>
<div className={styles.noiseGateSection}>
<Heading
type="body"
weight="semibold"
size="sm"
as="h4"
className={styles.noiseGateHeading}
>
Transient Suppressor
</Heading>
<Separator className={styles.noiseGateSeparator} />
<FieldRow>
<InputField
id="transientEnabled"
type="checkbox"
label="Enable transient suppressor"
description="Cut sudden loud impacts like desk hits or mic bumps."
checked={transientEnabled}
onChange={(e: ChangeEvent<HTMLInputElement>): void =>
setTransientEnabled(e.target.checked)
}
/>
</FieldRow>
{transientEnabled && (
<>
<div className={`${styles.volumeSlider} ${styles.thresholdSlider}`}>
<span className={styles.sliderLabel}>Sensitivity: {transientThresholdRaw} dB above background</span>
<p>Lower values catch more impacts; higher values only catch the loudest ones.</p>
<Slider
label="Transient threshold"
value={transientThresholdRaw}
onValueChange={setTransientThresholdRaw}
onValueCommit={setTransientThreshold}
min={8}
max={30}
step={1}
tooltip={false}
/>
</div>
<div className={styles.volumeSlider}>
<span className={styles.sliderLabel}>Release: {transientReleaseRaw} ms</span>
<p>How quickly audio returns after suppression.</p>
<Slider
label="Transient release"
value={transientReleaseRaw}
onValueChange={setTransientReleaseRaw}
onValueCommit={setTransientRelease}
min={20}
max={200}
step={10}
tooltip={false}
/>
</div>
<div className={styles.restoreDefaults}>
<Button kind="secondary" size="sm" onClick={resetTransientDefaults}>
Restore defaults
</Button>
</div>
</>
)}
</div>
</> </>
), ),
}; };

View File

@@ -145,6 +145,21 @@ export const noiseGateHold = new Setting<number>("noise-gate-hold", 200);
// Time in ms for the gate to fully close after hold expires // Time in ms for the gate to fully close after hold expires
export const noiseGateRelease = new Setting<number>("noise-gate-release", 150); export const noiseGateRelease = new Setting<number>("noise-gate-release", 150);
export const transientSuppressorEnabled = new Setting<boolean>(
"transient-suppressor-enabled",
false,
);
// How many dB above the background RMS a peak must be to trigger suppression
export const transientThreshold = new Setting<number>(
"transient-suppressor-threshold",
15,
);
// Time in ms for suppression to fade after transient ends
export const transientRelease = new Setting<number>(
"transient-suppressor-release",
80,
);
export enum MatrixRTCMode { export enum MatrixRTCMode {
Legacy = "legacy", Legacy = "legacy",
Compatibility = "compatibility", Compatibility = "compatibility",

View File

@@ -38,6 +38,9 @@ import {
noiseGateAttack, noiseGateAttack,
noiseGateHold, noiseGateHold,
noiseGateRelease, noiseGateRelease,
transientSuppressorEnabled,
transientThreshold,
transientRelease,
} from "../../../settings/settings.ts"; } from "../../../settings/settings.ts";
import { import {
type NoiseGateParams, type NoiseGateParams,
@@ -438,6 +441,9 @@ export class Publisher {
attackMs: noiseGateAttack.getValue(), attackMs: noiseGateAttack.getValue(),
holdMs: noiseGateHold.getValue(), holdMs: noiseGateHold.getValue(),
releaseMs: noiseGateRelease.getValue(), releaseMs: noiseGateRelease.getValue(),
transientEnabled: transientSuppressorEnabled.getValue(),
transientThresholdDb: transientThreshold.getValue(),
transientReleaseMs: transientRelease.getValue(),
}); });
// Attach / detach processor when enabled state or the track changes. // Attach / detach processor when enabled state or the track changes.
@@ -482,10 +488,17 @@ export class Publisher {
noiseGateAttack.value$, noiseGateAttack.value$,
noiseGateHold.value$, noiseGateHold.value$,
noiseGateRelease.value$, noiseGateRelease.value$,
transientSuppressorEnabled.value$,
transientThreshold.value$,
transientRelease.value$,
]) ])
.pipe(scope.bind()) .pipe(scope.bind())
.subscribe(([threshold, attackMs, holdMs, releaseMs]) => { .subscribe(([threshold, attackMs, holdMs, releaseMs,
transformer?.updateParams({ threshold, attackMs, holdMs, releaseMs }); transientEnabled, transientThresholdDb, transientReleaseMs]) => {
transformer?.updateParams({
threshold, attackMs, holdMs, releaseMs,
transientEnabled, transientThresholdDb, transientReleaseMs,
});
}); });
} }