diff --git a/src/livekit/NoiseGateProcessor.worklet.ts b/src/livekit/NoiseGateProcessor.worklet.ts index b26f6321..fa620dcf 100644 --- a/src/livekit/NoiseGateProcessor.worklet.ts +++ b/src/livekit/NoiseGateProcessor.worklet.ts @@ -25,6 +25,9 @@ interface NoiseGateParams { attackMs: number; holdMs: 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 { @@ -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 - * instantaneous peak exceeds the threshold and closes when it drops below. + * Noise gate: opens when instantaneous peak exceeds threshold, closes below. * 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 { + // Noise gate state private threshold = dbToLinear(-60); - private attackRate = 1.0 / (0.025 * 48000); - private releaseRate = 1.0 / (0.15 * 48000); + private attackRate = 1.0 / (0.025 * sampleRate); + private releaseRate = 1.0 / (0.15 * sampleRate); private holdTime = 0.2; - private isOpen = false; - private attenuation = 0; + private gateAttenuation = 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; public constructor() { @@ -54,7 +72,10 @@ class NoiseGateProcessor extends AudioWorkletProcessor { this.port.onmessage = (e: MessageEvent): void => { 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 }); } @@ -63,12 +84,14 @@ class NoiseGateProcessor extends AudioWorkletProcessor { this.attackRate = 1.0 / ((p.attackMs / 1000) * sampleRate); this.releaseRate = 1.0 / ((p.releaseMs / 1000) * sampleRate); 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({ type: "log", - msg: "[NoiseGate worklet] params: threshold=" + this.threshold.toFixed(5) - + " attack=" + this.attackRate.toFixed(8) - + " release=" + this.releaseRate.toFixed(8) - + " hold=" + this.holdTime, + msg: "[NoiseGate worklet] params updated: threshold=" + p.threshold + + " transientEnabled=" + p.transientEnabled + + " transientThresholdDb=" + p.transientThresholdDb, }); } @@ -79,7 +102,7 @@ class NoiseGateProcessor extends AudioWorkletProcessor { const channels = input.length; const blockSize = input[0]?.length ?? 128; - const sampleRateI = 1.0 / sampleRate; + const samplePeriod = 1.0 / sampleRate; for (let i = 0; i < blockSize; i++) { // Peak detection across all channels @@ -88,32 +111,50 @@ class NoiseGateProcessor extends AudioWorkletProcessor { 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) { this.isOpen = true; } - - // Close gate when signal drops below threshold; hold timer starts if (curLevel <= this.threshold && this.isOpen) { this.heldTime = 0; this.isOpen = false; } - - // Ramp attenuation toward 1 (open) or wait for hold then ramp toward 0 (closed) if (this.isOpen) { - this.attenuation = Math.min(1.0, this.attenuation + this.attackRate); + this.gateAttenuation = Math.min(1.0, this.gateAttenuation + this.attackRate); } else { - this.heldTime += sampleRateI; + this.heldTime += samplePeriod; 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++) { const inCh = input[c] ?? input[0]; const outCh = output[c]; 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) { this.port.postMessage({ type: "log", - msg: "[NoiseGate worklet] isOpen=" + this.isOpen - + " attenuation=" + this.attenuation.toFixed(3) - + " heldTime=" + this.heldTime.toFixed(3), + msg: "[NoiseGate worklet] gateOpen=" + this.isOpen + + " gateAtten=" + this.gateAttenuation.toFixed(3) + + " transientAtten=" + this.transientAttenuation.toFixed(3) + + " slowRms=" + this.slowRms.toFixed(5), }); } diff --git a/src/livekit/NoiseGateTransformer.ts b/src/livekit/NoiseGateTransformer.ts index 765e2e2b..02d52a3f 100644 --- a/src/livekit/NoiseGateTransformer.ts +++ b/src/livekit/NoiseGateTransformer.ts @@ -15,6 +15,9 @@ export interface NoiseGateParams { attackMs: number; holdMs: number; releaseMs: number; + transientEnabled: boolean; + transientThresholdDb: number; // dB above background RMS that triggers suppression + transientReleaseMs: number; // ms for suppression to fade after transient ends } /** diff --git a/src/settings/SettingsModal.tsx b/src/settings/SettingsModal.tsx index f7ac1f39..90d5e839 100644 --- a/src/settings/SettingsModal.tsx +++ b/src/settings/SettingsModal.tsx @@ -29,6 +29,9 @@ import { noiseGateAttack as noiseGateAttackSetting, noiseGateHold as noiseGateHoldSetting, noiseGateRelease as noiseGateReleaseSetting, + transientSuppressorEnabled as transientSuppressorEnabledSetting, + transientThreshold as transientThresholdSetting, + transientRelease as transientReleaseSetting, } from "./settings"; import { PreferencesSettingsTab } from "./PreferencesSettingsTab"; import { Slider } from "../Slider"; @@ -126,6 +129,13 @@ export const SettingsModal: FC = ({ 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 resetGateDefaults = useCallback((): void => { const a = noiseGateAttackSetting.defaultValue; const h = noiseGateHoldSetting.defaultValue; @@ -293,6 +303,62 @@ export const SettingsModal: FC = ({ )} +
+ + Transient Suppressor + + + + ): void => + setTransientEnabled(e.target.checked) + } + /> + + {transientEnabled && ( + <> +
+ Sensitivity: {transientThresholdRaw} dB above background +

Lower values catch more impacts; higher values only catch the loudest ones.

+ +
+
+ Release: {transientReleaseRaw} ms +

How quickly audio returns after suppression.

+ +
+ + )} +
), }; diff --git a/src/settings/settings.ts b/src/settings/settings.ts index 8eab8000..2fa75a7d 100644 --- a/src/settings/settings.ts +++ b/src/settings/settings.ts @@ -145,6 +145,21 @@ export const noiseGateHold = new Setting("noise-gate-hold", 200); // Time in ms for the gate to fully close after hold expires export const noiseGateRelease = new Setting("noise-gate-release", 150); +export const transientSuppressorEnabled = new Setting( + "transient-suppressor-enabled", + false, +); +// How many dB above the background RMS a peak must be to trigger suppression +export const transientThreshold = new Setting( + "transient-suppressor-threshold", + 15, +); +// Time in ms for suppression to fade after transient ends +export const transientRelease = new Setting( + "transient-suppressor-release", + 80, +); + export enum MatrixRTCMode { Legacy = "legacy", Compatibility = "compatibility", diff --git a/src/state/CallViewModel/localMember/Publisher.ts b/src/state/CallViewModel/localMember/Publisher.ts index 885593a0..733594d0 100644 --- a/src/state/CallViewModel/localMember/Publisher.ts +++ b/src/state/CallViewModel/localMember/Publisher.ts @@ -38,6 +38,9 @@ import { noiseGateAttack, noiseGateHold, noiseGateRelease, + transientSuppressorEnabled, + transientThreshold, + transientRelease, } from "../../../settings/settings.ts"; import { type NoiseGateParams, @@ -438,6 +441,9 @@ export class Publisher { attackMs: noiseGateAttack.getValue(), holdMs: noiseGateHold.getValue(), releaseMs: noiseGateRelease.getValue(), + transientEnabled: transientSuppressorEnabled.getValue(), + transientThresholdDb: transientThreshold.getValue(), + transientReleaseMs: transientRelease.getValue(), }); // Attach / detach processor when enabled state or the track changes. @@ -482,10 +488,17 @@ export class Publisher { noiseGateAttack.value$, noiseGateHold.value$, noiseGateRelease.value$, + transientSuppressorEnabled.value$, + transientThreshold.value$, + transientRelease.value$, ]) .pipe(scope.bind()) - .subscribe(([threshold, attackMs, holdMs, releaseMs]) => { - transformer?.updateParams({ threshold, attackMs, holdMs, releaseMs }); + .subscribe(([threshold, attackMs, holdMs, releaseMs, + transientEnabled, transientThresholdDb, transientReleaseMs]) => { + transformer?.updateParams({ + threshold, attackMs, holdMs, releaseMs, + transientEnabled, transientThresholdDb, transientReleaseMs, + }); }); }