diff --git a/src/Slider.tsx b/src/Slider.tsx index c6520e42..fe0bac72 100644 --- a/src/Slider.tsx +++ b/src/Slider.tsx @@ -31,6 +31,7 @@ interface Props { max: number; step: number; disabled?: boolean; + tooltip?: boolean; } /** @@ -46,6 +47,7 @@ export const Slider: FC = ({ max, step, disabled, + tooltip = true, }) => { const onValueChange = useCallback( ([v]: number[]) => onValueChangeProp(v), @@ -71,9 +73,13 @@ export const Slider: FC = ({ {/* Note: This is expected not to be visible on mobile.*/} - + {tooltip ? ( + + + + ) : ( - + )} ); }; diff --git a/src/livekit/MatrixAudioRenderer.tsx b/src/livekit/MatrixAudioRenderer.tsx index 10579c1b..c929ddba 100644 --- a/src/livekit/MatrixAudioRenderer.tsx +++ b/src/livekit/MatrixAudioRenderer.tsx @@ -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); diff --git a/src/livekit/NoiseGateProcessor.worklet.ts b/src/livekit/NoiseGateProcessor.worklet.ts new file mode 100644 index 00000000..b26f6321 --- /dev/null +++ b/src/livekit/NoiseGateProcessor.worklet.ts @@ -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, + ): 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): 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); diff --git a/src/livekit/NoiseGateTransformer.ts b/src/livekit/NoiseGateTransformer.ts new file mode 100644 index 00000000..765e2e2b --- /dev/null +++ b/src/livekit/NoiseGateTransformer.ts @@ -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 interface. + */ +export interface AudioTrackProcessor { + name: string; + processedTrack?: MediaStreamTrack; + init(opts: AudioProcessorOptions): Promise; + restart(opts: AudioProcessorOptions): Promise; + destroy(): Promise; +} + +/** + * 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 { + 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 { + await this.destroy(); + await this.init(opts); + } + + // eslint-disable-next-line @typescript-eslint/require-await + public async destroy(): Promise { + 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); + } +} diff --git a/src/settings/NoiseLevelSlider.module.css b/src/settings/NoiseLevelSlider.module.css new file mode 100644 index 00000000..8f81845e --- /dev/null +++ b/src/settings/NoiseLevelSlider.module.css @@ -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; +} diff --git a/src/settings/NoiseLevelSlider.tsx b/src/settings/NoiseLevelSlider.tsx new file mode 100644 index 00000000..3f616897 --- /dev/null +++ b/src/settings/NoiseLevelSlider.tsx @@ -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 = ({ + label, + value, + onValueChange: onValueChangeProp, + onValueCommit: onValueCommitProp, + min, + max, + step, +}) => { + const trackRef = useRef(null); + const animFrameRef = useRef(0); + const analyserRef = useRef(null); + const streamRef = useRef(null); + const dataRef = useRef | null>(null); + const thresholdPctRef = useRef(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; + }) + .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 ( + + + + + + + ); +}; diff --git a/src/settings/SettingsModal.module.css b/src/settings/SettingsModal.module.css index b07cb4c8..f7efedca 100644 --- a/src/settings/SettingsModal.module.css +++ b/src/settings/SettingsModal.module.css @@ -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); +} diff --git a/src/settings/SettingsModal.tsx b/src/settings/SettingsModal.tsx index 30ac3618..f7ac1f39 100644 --- a/src/settings/SettingsModal.tsx +++ b/src/settings/SettingsModal.tsx @@ -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 = ({ 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 = ({ /> +
+ + Noise Gate + + + + ): void => + setNoiseGateEnabled(e.target.checked) + } + /> + + {noiseGateEnabled && ( + <> +
+ Threshold +

Gate opens above this level, closes below it.

+ +
+
+ + {showAdvancedGate && ( + <> +
+ +

How quickly the gate opens when signal exceeds threshold.

+ +
+
+ +

How long the gate stays open after signal drops below threshold.

+ +
+
+ +

How quickly the gate closes after hold expires.

+ +
+
+ +
+ + )} +
+ + )} +
), }; diff --git a/src/settings/settings.ts b/src/settings/settings.ts index 917c79f1..8eab8000 100644 --- a/src/settings/settings.ts +++ b/src/settings/settings.ts @@ -129,6 +129,22 @@ export const alwaysShowIphoneEarpiece = new Setting( false, ); +export const noiseGateEnabled = new Setting( + "noise-gate-enabled", + false, +); +// Threshold in dBFS — gate opens above this, closes below it +export const noiseGateThreshold = new Setting( + "noise-gate-threshold", + -60, +); +// Time in ms for the gate to fully open after signal exceeds threshold +export const noiseGateAttack = new Setting("noise-gate-attack", 25); +// Time in ms the gate stays open after signal drops below threshold +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 enum MatrixRTCMode { Legacy = "legacy", Compatibility = "compatibility", diff --git a/src/state/CallViewModel/localMember/Publisher.ts b/src/state/CallViewModel/localMember/Publisher.ts index b7841c49..885593a0 100644 --- a/src/state/CallViewModel/localMember/Publisher.ts +++ b/src/state/CallViewModel/localMember/Publisher.ts @@ -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$); } + }