Compare commits
1 Commits
1ce8054558
...
call_noise
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
68d8bb1f92 |
@@ -31,6 +31,7 @@ interface Props {
|
||||
max: number;
|
||||
step: number;
|
||||
disabled?: boolean;
|
||||
tooltip?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -46,6 +47,7 @@ export const Slider: FC<Props> = ({
|
||||
max,
|
||||
step,
|
||||
disabled,
|
||||
tooltip = true,
|
||||
}) => {
|
||||
const onValueChange = useCallback(
|
||||
([v]: number[]) => onValueChangeProp(v),
|
||||
@@ -71,9 +73,13 @@ export const Slider: FC<Props> = ({
|
||||
<Range className={styles.highlight} />
|
||||
</Track>
|
||||
{/* Note: This is expected not to be visible on mobile.*/}
|
||||
<Tooltip placement="top" label={Math.round(value * 100).toString() + "%"}>
|
||||
{tooltip ? (
|
||||
<Tooltip placement="top" label={Math.round(value * 100).toString() + "%"}>
|
||||
<Thumb className={styles.handle} aria-label={label} />
|
||||
</Tooltip>
|
||||
) : (
|
||||
<Thumb className={styles.handle} aria-label={label} />
|
||||
</Tooltip>
|
||||
)}
|
||||
</Root>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -74,6 +74,8 @@ export function LivekitRoomAudioRenderer({
|
||||
)
|
||||
// Only keep audio tracks
|
||||
.filter((ref) => ref.publication.kind === Track.Kind.Audio)
|
||||
// Never render local participant's own audio back to themselves
|
||||
.filter((ref) => !ref.participant.isLocal)
|
||||
// Only keep tracks from participants that are in the validIdentities list
|
||||
.filter((ref) => {
|
||||
const isValid = validIdentities.includes(ref.participant.identity);
|
||||
|
||||
135
src/livekit/NoiseGateProcessor.worklet.ts
Normal file
135
src/livekit/NoiseGateProcessor.worklet.ts
Normal file
@@ -0,0 +1,135 @@
|
||||
/*
|
||||
Copyright 2026 New Vector Ltd.
|
||||
|
||||
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
Please see LICENSE in the repository root for full details.
|
||||
*/
|
||||
|
||||
declare const sampleRate: number;
|
||||
declare class AudioWorkletProcessor {
|
||||
public readonly port: MessagePort;
|
||||
public process(
|
||||
inputs: Float32Array[][],
|
||||
outputs: Float32Array[][],
|
||||
parameters: Record<string, Float32Array>,
|
||||
): boolean;
|
||||
}
|
||||
declare function registerProcessor(
|
||||
name: string,
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
processorCtor: new (...args: any[]) => AudioWorkletProcessor,
|
||||
): void;
|
||||
|
||||
interface NoiseGateParams {
|
||||
threshold: number; // dBFS — gate opens above this, closes below it
|
||||
attackMs: number;
|
||||
holdMs: number;
|
||||
releaseMs: number;
|
||||
}
|
||||
|
||||
function dbToLinear(db: number): number {
|
||||
return Math.pow(10, db / 20);
|
||||
}
|
||||
|
||||
/**
|
||||
* AudioWorkletProcessor implementing a noise gate.
|
||||
*
|
||||
* Per-sample peak detection across all channels. Gate opens when the
|
||||
* instantaneous peak exceeds the threshold and closes when it drops below.
|
||||
* Attack, hold, and release times smooth the attenuation envelope.
|
||||
*/
|
||||
class NoiseGateProcessor extends AudioWorkletProcessor {
|
||||
private threshold = dbToLinear(-60);
|
||||
private attackRate = 1.0 / (0.025 * 48000);
|
||||
private releaseRate = 1.0 / (0.15 * 48000);
|
||||
private holdTime = 0.2;
|
||||
|
||||
private isOpen = false;
|
||||
private attenuation = 0;
|
||||
private heldTime = 0;
|
||||
private logCounter = 0;
|
||||
|
||||
public constructor() {
|
||||
super();
|
||||
this.port.onmessage = (e: MessageEvent<NoiseGateParams>): void => {
|
||||
this.updateParams(e.data);
|
||||
};
|
||||
this.updateParams({ threshold: -60, attackMs: 25, holdMs: 200, releaseMs: 150 });
|
||||
this.port.postMessage({ type: "log", msg: "[NoiseGate worklet] constructor called, sampleRate=" + sampleRate });
|
||||
}
|
||||
|
||||
private updateParams(p: NoiseGateParams): void {
|
||||
this.threshold = dbToLinear(p.threshold);
|
||||
this.attackRate = 1.0 / ((p.attackMs / 1000) * sampleRate);
|
||||
this.releaseRate = 1.0 / ((p.releaseMs / 1000) * sampleRate);
|
||||
this.holdTime = p.holdMs / 1000;
|
||||
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,
|
||||
});
|
||||
}
|
||||
|
||||
public process(inputs: Float32Array[][], outputs: Float32Array[][]): boolean {
|
||||
const input = inputs[0];
|
||||
const output = outputs[0];
|
||||
if (!input || input.length === 0) return true;
|
||||
|
||||
const channels = input.length;
|
||||
const blockSize = input[0]?.length ?? 128;
|
||||
const sampleRateI = 1.0 / sampleRate;
|
||||
|
||||
for (let i = 0; i < blockSize; i++) {
|
||||
// Peak detection across all channels
|
||||
let curLevel = Math.abs(input[0]?.[i] ?? 0);
|
||||
for (let j = 1; j < channels; j++) {
|
||||
curLevel = Math.max(curLevel, Math.abs(input[j]?.[i] ?? 0));
|
||||
}
|
||||
|
||||
// Open gate when signal exceeds threshold
|
||||
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);
|
||||
} else {
|
||||
this.heldTime += sampleRateI;
|
||||
if (this.heldTime > this.holdTime) {
|
||||
this.attenuation = Math.max(0.0, this.attenuation - this.releaseRate);
|
||||
}
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
this.logCounter++;
|
||||
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),
|
||||
});
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
registerProcessor("noise-gate-processor", NoiseGateProcessor);
|
||||
124
src/livekit/NoiseGateTransformer.ts
Normal file
124
src/livekit/NoiseGateTransformer.ts
Normal file
@@ -0,0 +1,124 @@
|
||||
/*
|
||||
Copyright 2026 New Vector Ltd.
|
||||
|
||||
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
Please see LICENSE in the repository root for full details.
|
||||
*/
|
||||
|
||||
import { type Track } from "livekit-client";
|
||||
import { logger } from "matrix-js-sdk/lib/logger";
|
||||
|
||||
const log = logger.getChild("[NoiseGateTransformer]");
|
||||
|
||||
export interface NoiseGateParams {
|
||||
threshold: number; // dBFS — gate opens above this, closes below it
|
||||
attackMs: number;
|
||||
holdMs: number;
|
||||
releaseMs: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Matches LiveKit's AudioProcessorOptions (experimental API, not publicly
|
||||
* exported, so we declare it locally based on the type definitions).
|
||||
*/
|
||||
interface AudioProcessorOptions {
|
||||
kind: Track.Kind.Audio;
|
||||
track: MediaStreamTrack;
|
||||
audioContext: AudioContext;
|
||||
element?: HTMLMediaElement;
|
||||
}
|
||||
|
||||
/**
|
||||
* Matches LiveKit's TrackProcessor<Track.Kind.Audio> interface.
|
||||
*/
|
||||
export interface AudioTrackProcessor {
|
||||
name: string;
|
||||
processedTrack?: MediaStreamTrack;
|
||||
init(opts: AudioProcessorOptions): Promise<void>;
|
||||
restart(opts: AudioProcessorOptions): Promise<void>;
|
||||
destroy(): Promise<void>;
|
||||
}
|
||||
|
||||
/**
|
||||
* LiveKit audio track processor that applies the OBS-style noise gate via
|
||||
* AudioWorklet.
|
||||
*
|
||||
* Builds the audio graph: sourceNode → workletNode → destinationNode, then
|
||||
* exposes destinationNode's track as processedTrack for LiveKit to swap into
|
||||
* the WebRTC sender via sender.replaceTrack(processedTrack).
|
||||
*/
|
||||
export class NoiseGateTransformer implements AudioTrackProcessor {
|
||||
public readonly name = "noise-gate";
|
||||
public processedTrack?: MediaStreamTrack;
|
||||
|
||||
private workletNode?: AudioWorkletNode;
|
||||
private sourceNode?: MediaStreamAudioSourceNode;
|
||||
private destinationNode?: MediaStreamAudioDestinationNode;
|
||||
private params: NoiseGateParams;
|
||||
|
||||
public constructor(params: NoiseGateParams) {
|
||||
this.params = { ...params };
|
||||
}
|
||||
|
||||
public async init(opts: AudioProcessorOptions): Promise<void> {
|
||||
const { track, audioContext } = opts;
|
||||
|
||||
log.info("init() called, audioContext state:", audioContext.state, "params:", this.params);
|
||||
|
||||
const workletUrl = new URL(
|
||||
"./NoiseGateProcessor.worklet.ts",
|
||||
import.meta.url,
|
||||
);
|
||||
log.info("loading worklet from:", workletUrl.href);
|
||||
await audioContext.audioWorklet.addModule(workletUrl);
|
||||
log.info("worklet module loaded");
|
||||
|
||||
this.workletNode = new AudioWorkletNode(
|
||||
audioContext,
|
||||
"noise-gate-processor",
|
||||
);
|
||||
this.workletNode.port.onmessage = (e: MessageEvent<{ type: string; msg: string }>): void => {
|
||||
if (e.data?.type === "log") log.debug(e.data.msg);
|
||||
};
|
||||
this.sendParams();
|
||||
|
||||
this.sourceNode = audioContext.createMediaStreamSource(
|
||||
new MediaStream([track]),
|
||||
);
|
||||
this.destinationNode = audioContext.createMediaStreamDestination();
|
||||
|
||||
this.sourceNode.connect(this.workletNode);
|
||||
this.workletNode.connect(this.destinationNode);
|
||||
|
||||
this.processedTrack = this.destinationNode.stream.getAudioTracks()[0];
|
||||
log.info("graph wired, processedTrack:", this.processedTrack);
|
||||
}
|
||||
|
||||
public async restart(opts: AudioProcessorOptions): Promise<void> {
|
||||
await this.destroy();
|
||||
await this.init(opts);
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/require-await
|
||||
public async destroy(): Promise<void> {
|
||||
this.sourceNode?.disconnect();
|
||||
this.workletNode?.disconnect();
|
||||
this.destinationNode?.disconnect();
|
||||
this.sourceNode = undefined;
|
||||
this.workletNode = undefined;
|
||||
this.destinationNode = undefined;
|
||||
this.processedTrack = undefined;
|
||||
}
|
||||
|
||||
/** Push updated gate parameters to the running worklet. */
|
||||
public updateParams(params: NoiseGateParams): void {
|
||||
this.params = { ...params };
|
||||
this.sendParams();
|
||||
}
|
||||
|
||||
private sendParams(): void {
|
||||
if (!this.workletNode) return;
|
||||
log.debug("sendParams:", this.params);
|
||||
this.workletNode.port.postMessage(this.params);
|
||||
}
|
||||
}
|
||||
87
src/settings/NoiseLevelSlider.module.css
Normal file
87
src/settings/NoiseLevelSlider.module.css
Normal file
@@ -0,0 +1,87 @@
|
||||
/*
|
||||
Copyright 2026 New Vector Ltd.
|
||||
|
||||
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
Please see LICENSE in the repository root for full details.
|
||||
*/
|
||||
|
||||
.wrapper {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
position: relative;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.slider {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
position: relative;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.track {
|
||||
flex-grow: 1;
|
||||
border-radius: var(--cpd-radius-pill-effect);
|
||||
height: var(--cpd-space-4x);
|
||||
outline: var(--cpd-border-width-1) solid var(--cpd-color-border-interactive-primary);
|
||||
outline-offset: calc(-1 * var(--cpd-border-width-1));
|
||||
cursor: pointer;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
/* Base background */
|
||||
background: var(--cpd-color-bg-subtle-primary);
|
||||
}
|
||||
|
||||
/* Live mic level fill — driven by --mic-level CSS variable */
|
||||
.track::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
width: var(--mic-level, 0%);
|
||||
background: var(--cpd-color-gray-600, #808080);
|
||||
opacity: 0.55;
|
||||
border-radius: var(--cpd-radius-pill-effect);
|
||||
transition: width 40ms linear;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
/* Green when mic level is at or above the threshold */
|
||||
.track[data-active="true"]::before {
|
||||
background: var(--cpd-color-green-900, #1a7f4b);
|
||||
}
|
||||
|
||||
/* Threshold marker — driven by --threshold-pct CSS variable */
|
||||
.track::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
left: var(--threshold-pct, 50%);
|
||||
width: 2px;
|
||||
background: var(--cpd-color-text-primary);
|
||||
opacity: 0.6;
|
||||
pointer-events: none;
|
||||
transform: translateX(-50%);
|
||||
}
|
||||
|
||||
/* Hide the Radix Range highlight — we use our own visuals */
|
||||
.range {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.handle {
|
||||
display: block;
|
||||
block-size: var(--cpd-space-5x);
|
||||
inline-size: var(--cpd-space-5x);
|
||||
border-radius: var(--cpd-radius-pill-effect);
|
||||
background: var(--cpd-color-bg-action-primary-rest);
|
||||
box-shadow: 0 0 0 2px var(--cpd-color-bg-canvas-default);
|
||||
cursor: pointer;
|
||||
transition: background-color ease 0.15s;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.handle:focus-visible {
|
||||
outline: 2px solid var(--cpd-color-border-focused);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
139
src/settings/NoiseLevelSlider.tsx
Normal file
139
src/settings/NoiseLevelSlider.tsx
Normal file
@@ -0,0 +1,139 @@
|
||||
/*
|
||||
Copyright 2026 New Vector Ltd.
|
||||
|
||||
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
Please see LICENSE in the repository root for full details.
|
||||
*/
|
||||
|
||||
import { Root, Track, Range, Thumb } from "@radix-ui/react-slider";
|
||||
import { type FC, useCallback, useEffect, useRef } from "react";
|
||||
|
||||
import styles from "./NoiseLevelSlider.module.css";
|
||||
|
||||
interface Props {
|
||||
label: string;
|
||||
value: number;
|
||||
onValueChange: (value: number) => void;
|
||||
onValueCommit?: (value: number) => void;
|
||||
min: number;
|
||||
max: number;
|
||||
step: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Threshold slider that shows live microphone input level as a background fill,
|
||||
* similar to Discord's input sensitivity control.
|
||||
*
|
||||
* The green fill represents your current mic volume in real time.
|
||||
* Drag the handle to set the gate threshold — audio below it will be silenced.
|
||||
*/
|
||||
export const NoiseLevelSlider: FC<Props> = ({
|
||||
label,
|
||||
value,
|
||||
onValueChange: onValueChangeProp,
|
||||
onValueCommit: onValueCommitProp,
|
||||
min,
|
||||
max,
|
||||
step,
|
||||
}) => {
|
||||
const trackRef = useRef<HTMLSpanElement>(null);
|
||||
const animFrameRef = useRef<number>(0);
|
||||
const analyserRef = useRef<AnalyserNode | null>(null);
|
||||
const streamRef = useRef<MediaStream | null>(null);
|
||||
const dataRef = useRef<Float32Array<ArrayBuffer> | null>(null);
|
||||
const thresholdPctRef = useRef<number>(0);
|
||||
|
||||
// Start mic monitoring via AnalyserNode
|
||||
useEffect(() => {
|
||||
let ctx: AudioContext | null = null;
|
||||
|
||||
navigator.mediaDevices
|
||||
.getUserMedia({ audio: true, video: false })
|
||||
.then((stream) => {
|
||||
streamRef.current = stream;
|
||||
ctx = new AudioContext();
|
||||
const source = ctx.createMediaStreamSource(stream);
|
||||
const analyser = ctx.createAnalyser();
|
||||
analyser.fftSize = 1024;
|
||||
analyser.smoothingTimeConstant = 0.6;
|
||||
source.connect(analyser);
|
||||
analyserRef.current = analyser;
|
||||
dataRef.current = new Float32Array(analyser.fftSize) as Float32Array<ArrayBuffer>;
|
||||
})
|
||||
.catch(() => {
|
||||
// Mic not available — live level stays at 0
|
||||
});
|
||||
|
||||
return (): void => {
|
||||
cancelAnimationFrame(animFrameRef.current);
|
||||
streamRef.current?.getTracks().forEach((t) => t.stop());
|
||||
streamRef.current = null;
|
||||
analyserRef.current = null;
|
||||
void ctx?.close();
|
||||
};
|
||||
}, []);
|
||||
|
||||
// rAF loop — reads RMS level and updates CSS variable on the track element
|
||||
useEffect(() => {
|
||||
const tick = (): void => {
|
||||
animFrameRef.current = requestAnimationFrame(tick);
|
||||
|
||||
const analyser = analyserRef.current;
|
||||
const data = dataRef.current;
|
||||
const track = trackRef.current;
|
||||
if (!analyser || !data || !track) return;
|
||||
|
||||
analyser.getFloatTimeDomainData(data);
|
||||
|
||||
// Peak detection — matches the gate's own measurement method
|
||||
let peak = 0;
|
||||
for (const s of data) peak = Math.max(peak, Math.abs(s));
|
||||
const dbfs = peak > 0 ? 20 * Math.log10(peak) : -Infinity;
|
||||
|
||||
// Map dBFS value to slider percentage (same scale as min/max)
|
||||
const clampedDb = Math.max(min, Math.min(max, dbfs));
|
||||
const levelPct = ((clampedDb - min) / (max - min)) * 100;
|
||||
|
||||
track.style.setProperty("--mic-level", `${levelPct.toFixed(1)}%`);
|
||||
track.dataset.active = String(levelPct >= thresholdPctRef.current);
|
||||
};
|
||||
|
||||
animFrameRef.current = requestAnimationFrame(tick);
|
||||
return (): void => cancelAnimationFrame(animFrameRef.current);
|
||||
}, [min, max]);
|
||||
|
||||
// Keep threshold marker in sync with slider value
|
||||
useEffect(() => {
|
||||
const track = trackRef.current;
|
||||
if (!track) return;
|
||||
const thresholdPct = ((value - min) / (max - min)) * 100;
|
||||
thresholdPctRef.current = thresholdPct;
|
||||
track.style.setProperty("--threshold-pct", `${thresholdPct.toFixed(1)}%`);
|
||||
}, [value, min, max]);
|
||||
|
||||
const onValueChange = useCallback(
|
||||
([v]: number[]) => onValueChangeProp(v),
|
||||
[onValueChangeProp],
|
||||
);
|
||||
const onValueCommit = useCallback(
|
||||
([v]: number[]) => onValueCommitProp?.(v),
|
||||
[onValueCommitProp],
|
||||
);
|
||||
|
||||
return (
|
||||
<Root
|
||||
className={styles.slider}
|
||||
value={[value]}
|
||||
onValueChange={onValueChange}
|
||||
onValueCommit={onValueCommit}
|
||||
min={min}
|
||||
max={max}
|
||||
step={step}
|
||||
>
|
||||
<Track className={styles.track} ref={trackRef}>
|
||||
<Range className={styles.range} />
|
||||
</Track>
|
||||
<Thumb className={styles.handle} aria-label={label} />
|
||||
</Root>
|
||||
);
|
||||
};
|
||||
@@ -21,7 +21,8 @@ Please see LICENSE in the repository root for full details.
|
||||
margin-top: var(--cpd-space-2x);
|
||||
}
|
||||
|
||||
.volumeSlider > label {
|
||||
.volumeSlider > label,
|
||||
.sliderLabel {
|
||||
margin-bottom: var(--cpd-space-1x);
|
||||
display: block;
|
||||
}
|
||||
@@ -33,3 +34,40 @@ Please see LICENSE in the repository root for full details.
|
||||
.volumeSlider > p {
|
||||
color: var(--cpd-color-text-secondary);
|
||||
}
|
||||
|
||||
.noiseGateSection {
|
||||
margin-block-start: var(--cpd-space-6x);
|
||||
}
|
||||
|
||||
.noiseGateHeading {
|
||||
color: var(--cpd-color-text-secondary);
|
||||
margin-block: var(--cpd-space-3x) 0;
|
||||
}
|
||||
|
||||
.thresholdSlider {
|
||||
margin-block-start: calc(-32px + var(--cpd-space-2x));
|
||||
}
|
||||
|
||||
.noiseGateSeparator {
|
||||
margin-block: 6px var(--cpd-space-4x);
|
||||
}
|
||||
|
||||
.advancedGate {
|
||||
margin-top: var(--cpd-space-3x);
|
||||
}
|
||||
|
||||
.advancedGateToggle {
|
||||
all: unset;
|
||||
cursor: pointer;
|
||||
font: var(--cpd-font-body-sm-semibold);
|
||||
color: var(--cpd-color-text-secondary);
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.advancedGateToggle:hover {
|
||||
color: var(--cpd-color-text-primary);
|
||||
}
|
||||
|
||||
.restoreDefaults {
|
||||
margin-top: var(--cpd-space-6x);
|
||||
}
|
||||
|
||||
@@ -5,10 +5,10 @@ SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
Please see LICENSE in the repository root for full details.
|
||||
*/
|
||||
|
||||
import { type FC, type ReactNode, useEffect, useState } from "react";
|
||||
import { type ChangeEvent, type FC, type ReactNode, useEffect, useState, useCallback } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { type MatrixClient } from "matrix-js-sdk";
|
||||
import { Button, Root as Form, Separator } from "@vector-im/compound-web";
|
||||
import { Button, Heading, Root as Form, Separator } from "@vector-im/compound-web";
|
||||
import { type Room as LivekitRoom } from "livekit-client";
|
||||
|
||||
import { Modal } from "../Modal";
|
||||
@@ -24,9 +24,15 @@ import {
|
||||
soundEffectVolume as soundEffectVolumeSetting,
|
||||
backgroundBlur as backgroundBlurSetting,
|
||||
developerMode,
|
||||
noiseGateEnabled as noiseGateEnabledSetting,
|
||||
noiseGateThreshold as noiseGateThresholdSetting,
|
||||
noiseGateAttack as noiseGateAttackSetting,
|
||||
noiseGateHold as noiseGateHoldSetting,
|
||||
noiseGateRelease as noiseGateReleaseSetting,
|
||||
} from "./settings";
|
||||
import { PreferencesSettingsTab } from "./PreferencesSettingsTab";
|
||||
import { Slider } from "../Slider";
|
||||
import { NoiseLevelSlider } from "./NoiseLevelSlider";
|
||||
import { DeviceSelection } from "./DeviceSelection";
|
||||
import { useTrackProcessor } from "../livekit/TrackProcessorContext";
|
||||
import { DeveloperSettingsTab } from "./DeveloperSettingsTab";
|
||||
@@ -107,6 +113,28 @@ export const SettingsModal: FC<Props> = ({
|
||||
const [soundVolumeRaw, setSoundVolumeRaw] = useState(soundVolume);
|
||||
const [showDeveloperSettingsTab] = useSetting(developerMode);
|
||||
|
||||
// Noise gate settings
|
||||
const [noiseGateEnabled, setNoiseGateEnabled] = useSetting(noiseGateEnabledSetting);
|
||||
const [noiseGateThreshold, setNoiseGateThreshold] = useSetting(noiseGateThresholdSetting);
|
||||
const [noiseGateThresholdRaw, setNoiseGateThresholdRaw] = useState(noiseGateThreshold);
|
||||
const [noiseGateAttack, setNoiseGateAttack] = useSetting(noiseGateAttackSetting);
|
||||
const [noiseGateAttackRaw, setNoiseGateAttackRaw] = useState(noiseGateAttack);
|
||||
const [noiseGateHold, setNoiseGateHold] = useSetting(noiseGateHoldSetting);
|
||||
const [noiseGateHoldRaw, setNoiseGateHoldRaw] = useState(noiseGateHold);
|
||||
const [noiseGateRelease, setNoiseGateRelease] = useSetting(noiseGateReleaseSetting);
|
||||
const [noiseGateReleaseRaw, setNoiseGateReleaseRaw] = useState(noiseGateRelease);
|
||||
|
||||
const [showAdvancedGate, setShowAdvancedGate] = useState(false);
|
||||
|
||||
const resetGateDefaults = useCallback((): void => {
|
||||
const a = noiseGateAttackSetting.defaultValue;
|
||||
const h = noiseGateHoldSetting.defaultValue;
|
||||
const r = noiseGateReleaseSetting.defaultValue;
|
||||
setNoiseGateAttack(a); setNoiseGateAttackRaw(a);
|
||||
setNoiseGateHold(h); setNoiseGateHoldRaw(h);
|
||||
setNoiseGateRelease(r); setNoiseGateReleaseRaw(r);
|
||||
}, [setNoiseGateAttack, setNoiseGateHold, setNoiseGateRelease]);
|
||||
|
||||
const { available: isRageshakeAvailable } = useSubmitRageshake();
|
||||
|
||||
// For controlled devices, we will not show the input section:
|
||||
@@ -165,6 +193,106 @@ export const SettingsModal: FC<Props> = ({
|
||||
/>
|
||||
</div>
|
||||
</Form>
|
||||
<div className={styles.noiseGateSection}>
|
||||
<Heading
|
||||
type="body"
|
||||
weight="semibold"
|
||||
size="sm"
|
||||
as="h4"
|
||||
className={styles.noiseGateHeading}
|
||||
>
|
||||
Noise Gate
|
||||
</Heading>
|
||||
<Separator className={styles.noiseGateSeparator} />
|
||||
<FieldRow>
|
||||
<InputField
|
||||
id="noiseGateEnabled"
|
||||
type="checkbox"
|
||||
label="Enable noise gate"
|
||||
description="Suppress audio below a configurable threshold."
|
||||
checked={noiseGateEnabled}
|
||||
onChange={(e: ChangeEvent<HTMLInputElement>): void =>
|
||||
setNoiseGateEnabled(e.target.checked)
|
||||
}
|
||||
/>
|
||||
</FieldRow>
|
||||
{noiseGateEnabled && (
|
||||
<>
|
||||
<div className={`${styles.volumeSlider} ${styles.thresholdSlider}`}>
|
||||
<span className={styles.sliderLabel}>Threshold</span>
|
||||
<p>Gate opens above this level, closes below it.</p>
|
||||
<NoiseLevelSlider
|
||||
label="Noise gate threshold"
|
||||
value={noiseGateThresholdRaw}
|
||||
onValueChange={setNoiseGateThresholdRaw}
|
||||
onValueCommit={setNoiseGateThreshold}
|
||||
min={-100}
|
||||
max={0}
|
||||
step={1}
|
||||
/>
|
||||
</div>
|
||||
<div className={styles.advancedGate}>
|
||||
<button
|
||||
className={styles.advancedGateToggle}
|
||||
onClick={(): void => setShowAdvancedGate((v) => !v)}
|
||||
>
|
||||
{showAdvancedGate ? "▾" : "▸"} Advanced settings
|
||||
</button>
|
||||
{showAdvancedGate && (
|
||||
<>
|
||||
<div className={styles.volumeSlider}>
|
||||
<label>Attack: {noiseGateAttackRaw} ms</label>
|
||||
<p>How quickly the gate opens when signal exceeds threshold.</p>
|
||||
<Slider
|
||||
label="Noise gate attack"
|
||||
value={noiseGateAttackRaw}
|
||||
onValueChange={setNoiseGateAttackRaw}
|
||||
onValueCommit={setNoiseGateAttack}
|
||||
min={1}
|
||||
max={100}
|
||||
step={1}
|
||||
tooltip={false}
|
||||
/>
|
||||
</div>
|
||||
<div className={styles.volumeSlider}>
|
||||
<label>Hold: {noiseGateHoldRaw} ms</label>
|
||||
<p>How long the gate stays open after signal drops below threshold.</p>
|
||||
<Slider
|
||||
label="Noise gate hold"
|
||||
value={noiseGateHoldRaw}
|
||||
onValueChange={setNoiseGateHoldRaw}
|
||||
onValueCommit={setNoiseGateHold}
|
||||
min={0}
|
||||
max={500}
|
||||
step={10}
|
||||
tooltip={false}
|
||||
/>
|
||||
</div>
|
||||
<div className={styles.volumeSlider}>
|
||||
<label>Release: {noiseGateReleaseRaw} ms</label>
|
||||
<p>How quickly the gate closes after hold expires.</p>
|
||||
<Slider
|
||||
label="Noise gate release"
|
||||
value={noiseGateReleaseRaw}
|
||||
onValueChange={setNoiseGateReleaseRaw}
|
||||
onValueCommit={setNoiseGateRelease}
|
||||
min={10}
|
||||
max={500}
|
||||
step={10}
|
||||
tooltip={false}
|
||||
/>
|
||||
</div>
|
||||
<div className={styles.restoreDefaults}>
|
||||
<Button kind="secondary" size="sm" onClick={resetGateDefaults}>
|
||||
Restore defaults
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
),
|
||||
};
|
||||
|
||||
@@ -129,6 +129,22 @@ export const alwaysShowIphoneEarpiece = new Setting<boolean>(
|
||||
false,
|
||||
);
|
||||
|
||||
export const noiseGateEnabled = new Setting<boolean>(
|
||||
"noise-gate-enabled",
|
||||
false,
|
||||
);
|
||||
// Threshold in dBFS — gate opens above this, closes below it
|
||||
export const noiseGateThreshold = new Setting<number>(
|
||||
"noise-gate-threshold",
|
||||
-60,
|
||||
);
|
||||
// Time in ms for the gate to fully open after signal exceeds threshold
|
||||
export const noiseGateAttack = new Setting<number>("noise-gate-attack", 25);
|
||||
// Time in ms the gate stays open after signal drops below threshold
|
||||
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 enum MatrixRTCMode {
|
||||
Legacy = "legacy",
|
||||
Compatibility = "compatibility",
|
||||
|
||||
@@ -7,6 +7,7 @@ Please see LICENSE in the repository root for full details.
|
||||
*/
|
||||
import {
|
||||
ConnectionState as LivekitConnectionState,
|
||||
LocalAudioTrack,
|
||||
type LocalTrackPublication,
|
||||
LocalVideoTrack,
|
||||
ParticipantEvent,
|
||||
@@ -14,6 +15,7 @@ import {
|
||||
Track,
|
||||
} from "livekit-client";
|
||||
import {
|
||||
combineLatest,
|
||||
map,
|
||||
NEVER,
|
||||
type Observable,
|
||||
@@ -30,6 +32,17 @@ import {
|
||||
trackProcessorSync,
|
||||
} from "../../../livekit/TrackProcessorContext.tsx";
|
||||
import { getUrlParams } from "../../../UrlParams.ts";
|
||||
import {
|
||||
noiseGateEnabled,
|
||||
noiseGateThreshold,
|
||||
noiseGateAttack,
|
||||
noiseGateHold,
|
||||
noiseGateRelease,
|
||||
} from "../../../settings/settings.ts";
|
||||
import {
|
||||
type NoiseGateParams,
|
||||
NoiseGateTransformer,
|
||||
} from "../../../livekit/NoiseGateTransformer.ts";
|
||||
import { observeTrackReference$ } from "../../observeTrackReference";
|
||||
import { type Connection } from "../remoteMembers/Connection.ts";
|
||||
import { ObservableScope } from "../../ObservableScope.ts";
|
||||
@@ -73,6 +86,8 @@ export class Publisher {
|
||||
|
||||
// Setup track processor syncing (blur)
|
||||
this.observeTrackProcessors(this.scope, room, trackerProcessorState$);
|
||||
// Setup noise gate on the local microphone track
|
||||
this.applyNoiseGate(this.scope, room);
|
||||
// Observe media device changes and update LiveKit active devices accordingly
|
||||
this.observeMediaDevices(this.scope, devices, controlledAudioDevices);
|
||||
|
||||
@@ -400,6 +415,80 @@ export class Publisher {
|
||||
});
|
||||
}
|
||||
|
||||
private applyNoiseGate(scope: ObservableScope, room: LivekitRoom): void {
|
||||
// Observe the local microphone track
|
||||
const audioTrack$ = scope.behavior(
|
||||
observeTrackReference$(
|
||||
room.localParticipant,
|
||||
Track.Source.Microphone,
|
||||
).pipe(
|
||||
map((ref) => {
|
||||
const track = ref?.publication.track;
|
||||
return track instanceof LocalAudioTrack ? track : null;
|
||||
}),
|
||||
),
|
||||
null,
|
||||
);
|
||||
|
||||
let transformer: NoiseGateTransformer | null = null;
|
||||
let audioCtx: AudioContext | null = null;
|
||||
|
||||
const currentParams = (): NoiseGateParams => ({
|
||||
threshold: noiseGateThreshold.getValue(),
|
||||
attackMs: noiseGateAttack.getValue(),
|
||||
holdMs: noiseGateHold.getValue(),
|
||||
releaseMs: noiseGateRelease.getValue(),
|
||||
});
|
||||
|
||||
// Attach / detach processor when enabled state or the track changes.
|
||||
combineLatest([audioTrack$, noiseGateEnabled.value$])
|
||||
.pipe(scope.bind())
|
||||
.subscribe(([audioTrack, enabled]) => {
|
||||
if (!audioTrack) return;
|
||||
if (enabled && !audioTrack.getProcessor()) {
|
||||
const params = currentParams();
|
||||
this.logger.info("[NoiseGate] attaching processor, params:", params);
|
||||
transformer = new NoiseGateTransformer(params);
|
||||
audioCtx = new AudioContext();
|
||||
this.logger.info("[NoiseGate] AudioContext state before resume:", audioCtx.state);
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
(audioTrack as any).setAudioContext(audioCtx);
|
||||
audioCtx.resume().then(() => {
|
||||
this.logger.info("[NoiseGate] AudioContext state after resume:", audioCtx?.state);
|
||||
return audioTrack
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
.setProcessor(transformer as any);
|
||||
}).then(() => {
|
||||
this.logger.info("[NoiseGate] setProcessor resolved");
|
||||
}).catch((e: unknown) => {
|
||||
this.logger.error("[NoiseGate] setProcessor failed", e);
|
||||
});
|
||||
} else if (!enabled && audioTrack.getProcessor()) {
|
||||
this.logger.info("[NoiseGate] removing processor");
|
||||
void audioTrack.stopProcessor();
|
||||
void audioCtx?.close();
|
||||
audioCtx = null;
|
||||
transformer = null;
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
(audioTrack as any).setAudioContext(undefined);
|
||||
} else {
|
||||
this.logger.info("[NoiseGate] tick — enabled:", enabled, "hasProcessor:", !!audioTrack.getProcessor());
|
||||
}
|
||||
});
|
||||
|
||||
// Push param changes to the live worklet without recreating the processor.
|
||||
combineLatest([
|
||||
noiseGateThreshold.value$,
|
||||
noiseGateAttack.value$,
|
||||
noiseGateHold.value$,
|
||||
noiseGateRelease.value$,
|
||||
])
|
||||
.pipe(scope.bind())
|
||||
.subscribe(([threshold, attackMs, holdMs, releaseMs]) => {
|
||||
transformer?.updateParams({ threshold, attackMs, holdMs, releaseMs });
|
||||
});
|
||||
}
|
||||
|
||||
private observeTrackProcessors(
|
||||
scope: ObservableScope,
|
||||
room: LivekitRoom,
|
||||
@@ -416,4 +505,5 @@ export class Publisher {
|
||||
);
|
||||
trackProcessorSync(scope, track$, trackerProcessorState$);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user