Compare commits
1 Commits
livekit
...
call_noise
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
68d8bb1f92 |
@@ -31,6 +31,7 @@ interface Props {
|
|||||||
max: number;
|
max: number;
|
||||||
step: number;
|
step: number;
|
||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
|
tooltip?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -46,6 +47,7 @@ export const Slider: FC<Props> = ({
|
|||||||
max,
|
max,
|
||||||
step,
|
step,
|
||||||
disabled,
|
disabled,
|
||||||
|
tooltip = true,
|
||||||
}) => {
|
}) => {
|
||||||
const onValueChange = useCallback(
|
const onValueChange = useCallback(
|
||||||
([v]: number[]) => onValueChangeProp(v),
|
([v]: number[]) => onValueChangeProp(v),
|
||||||
@@ -71,9 +73,13 @@ export const Slider: FC<Props> = ({
|
|||||||
<Range className={styles.highlight} />
|
<Range className={styles.highlight} />
|
||||||
</Track>
|
</Track>
|
||||||
{/* Note: This is expected not to be visible on mobile.*/}
|
{/* Note: This is expected not to be visible on mobile.*/}
|
||||||
|
{tooltip ? (
|
||||||
<Tooltip placement="top" label={Math.round(value * 100).toString() + "%"}>
|
<Tooltip placement="top" label={Math.round(value * 100).toString() + "%"}>
|
||||||
<Thumb className={styles.handle} aria-label={label} />
|
<Thumb className={styles.handle} aria-label={label} />
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
|
) : (
|
||||||
|
<Thumb className={styles.handle} aria-label={label} />
|
||||||
|
)}
|
||||||
</Root>
|
</Root>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -74,6 +74,8 @@ export function LivekitRoomAudioRenderer({
|
|||||||
)
|
)
|
||||||
// Only keep audio tracks
|
// Only keep audio tracks
|
||||||
.filter((ref) => ref.publication.kind === Track.Kind.Audio)
|
.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
|
// Only keep tracks from participants that are in the validIdentities list
|
||||||
.filter((ref) => {
|
.filter((ref) => {
|
||||||
const isValid = validIdentities.includes(ref.participant.identity);
|
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);
|
margin-top: var(--cpd-space-2x);
|
||||||
}
|
}
|
||||||
|
|
||||||
.volumeSlider > label {
|
.volumeSlider > label,
|
||||||
|
.sliderLabel {
|
||||||
margin-bottom: var(--cpd-space-1x);
|
margin-bottom: var(--cpd-space-1x);
|
||||||
display: block;
|
display: block;
|
||||||
}
|
}
|
||||||
@@ -33,3 +34,40 @@ Please see LICENSE in the repository root for full details.
|
|||||||
.volumeSlider > p {
|
.volumeSlider > p {
|
||||||
color: var(--cpd-color-text-secondary);
|
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.
|
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 { useTranslation } from "react-i18next";
|
||||||
import { type MatrixClient } from "matrix-js-sdk";
|
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 { type Room as LivekitRoom } from "livekit-client";
|
||||||
|
|
||||||
import { Modal } from "../Modal";
|
import { Modal } from "../Modal";
|
||||||
@@ -24,9 +24,15 @@ import {
|
|||||||
soundEffectVolume as soundEffectVolumeSetting,
|
soundEffectVolume as soundEffectVolumeSetting,
|
||||||
backgroundBlur as backgroundBlurSetting,
|
backgroundBlur as backgroundBlurSetting,
|
||||||
developerMode,
|
developerMode,
|
||||||
|
noiseGateEnabled as noiseGateEnabledSetting,
|
||||||
|
noiseGateThreshold as noiseGateThresholdSetting,
|
||||||
|
noiseGateAttack as noiseGateAttackSetting,
|
||||||
|
noiseGateHold as noiseGateHoldSetting,
|
||||||
|
noiseGateRelease as noiseGateReleaseSetting,
|
||||||
} from "./settings";
|
} from "./settings";
|
||||||
import { PreferencesSettingsTab } from "./PreferencesSettingsTab";
|
import { PreferencesSettingsTab } from "./PreferencesSettingsTab";
|
||||||
import { Slider } from "../Slider";
|
import { Slider } from "../Slider";
|
||||||
|
import { NoiseLevelSlider } from "./NoiseLevelSlider";
|
||||||
import { DeviceSelection } from "./DeviceSelection";
|
import { DeviceSelection } from "./DeviceSelection";
|
||||||
import { useTrackProcessor } from "../livekit/TrackProcessorContext";
|
import { useTrackProcessor } from "../livekit/TrackProcessorContext";
|
||||||
import { DeveloperSettingsTab } from "./DeveloperSettingsTab";
|
import { DeveloperSettingsTab } from "./DeveloperSettingsTab";
|
||||||
@@ -107,6 +113,28 @@ export const SettingsModal: FC<Props> = ({
|
|||||||
const [soundVolumeRaw, setSoundVolumeRaw] = useState(soundVolume);
|
const [soundVolumeRaw, setSoundVolumeRaw] = useState(soundVolume);
|
||||||
const [showDeveloperSettingsTab] = useSetting(developerMode);
|
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();
|
const { available: isRageshakeAvailable } = useSubmitRageshake();
|
||||||
|
|
||||||
// For controlled devices, we will not show the input section:
|
// For controlled devices, we will not show the input section:
|
||||||
@@ -165,6 +193,106 @@ export const SettingsModal: FC<Props> = ({
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</Form>
|
</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,
|
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 {
|
export enum MatrixRTCMode {
|
||||||
Legacy = "legacy",
|
Legacy = "legacy",
|
||||||
Compatibility = "compatibility",
|
Compatibility = "compatibility",
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ Please see LICENSE in the repository root for full details.
|
|||||||
*/
|
*/
|
||||||
import {
|
import {
|
||||||
ConnectionState as LivekitConnectionState,
|
ConnectionState as LivekitConnectionState,
|
||||||
|
LocalAudioTrack,
|
||||||
type LocalTrackPublication,
|
type LocalTrackPublication,
|
||||||
LocalVideoTrack,
|
LocalVideoTrack,
|
||||||
ParticipantEvent,
|
ParticipantEvent,
|
||||||
@@ -14,6 +15,7 @@ import {
|
|||||||
Track,
|
Track,
|
||||||
} from "livekit-client";
|
} from "livekit-client";
|
||||||
import {
|
import {
|
||||||
|
combineLatest,
|
||||||
map,
|
map,
|
||||||
NEVER,
|
NEVER,
|
||||||
type Observable,
|
type Observable,
|
||||||
@@ -30,6 +32,17 @@ import {
|
|||||||
trackProcessorSync,
|
trackProcessorSync,
|
||||||
} from "../../../livekit/TrackProcessorContext.tsx";
|
} from "../../../livekit/TrackProcessorContext.tsx";
|
||||||
import { getUrlParams } from "../../../UrlParams.ts";
|
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 { observeTrackReference$ } from "../../observeTrackReference";
|
||||||
import { type Connection } from "../remoteMembers/Connection.ts";
|
import { type Connection } from "../remoteMembers/Connection.ts";
|
||||||
import { ObservableScope } from "../../ObservableScope.ts";
|
import { ObservableScope } from "../../ObservableScope.ts";
|
||||||
@@ -73,6 +86,8 @@ export class Publisher {
|
|||||||
|
|
||||||
// Setup track processor syncing (blur)
|
// Setup track processor syncing (blur)
|
||||||
this.observeTrackProcessors(this.scope, room, trackerProcessorState$);
|
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
|
// Observe media device changes and update LiveKit active devices accordingly
|
||||||
this.observeMediaDevices(this.scope, devices, controlledAudioDevices);
|
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(
|
private observeTrackProcessors(
|
||||||
scope: ObservableScope,
|
scope: ObservableScope,
|
||||||
room: LivekitRoom,
|
room: LivekitRoom,
|
||||||
@@ -416,4 +505,5 @@ export class Publisher {
|
|||||||
);
|
);
|
||||||
trackProcessorSync(scope, track$, trackerProcessorState$);
|
trackProcessorSync(scope, track$, trackerProcessorState$);
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user