Compare commits
2 Commits
call_noise
...
rnnoise
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0788e56c51 | ||
|
|
411e18c48a |
@@ -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<NoiseGateParams>): 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),
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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,20 @@ export const SettingsModal: FC<Props> = ({
|
||||
|
||||
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 a = noiseGateAttackSetting.defaultValue;
|
||||
const h = noiseGateHoldSetting.defaultValue;
|
||||
@@ -293,6 +310,67 @@ export const SettingsModal: FC<Props> = ({
|
||||
</>
|
||||
)}
|
||||
</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>
|
||||
</>
|
||||
),
|
||||
};
|
||||
|
||||
@@ -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
|
||||
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 {
|
||||
Legacy = "legacy",
|
||||
Compatibility = "compatibility",
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user