From 504899aa1478384611e71a55d0af7c606b307282 Mon Sep 17 00:00:00 2001 From: Lotus CI Date: Fri, 30 Oct 2026 21:19:56 -0400 Subject: [PATCH] feat(calls): change noise suppression mid-call without rejoining Changing Noise suppression (off / browser-native / ML), the ML model, the gate or "stack browser NS" in Settings now applies to the call you're in. useCallDenoise sends the fork's io.lotus.set_denoise whenever those settings change; CallControl join-gates it like set_quality and re-sends it after an EC reconnect (the remounted fork handler starts again from the join URL). The "requires rejoining the call" note is replaced. Bumps Element Call to 0.28.0-lotus.4, which adds io.lotus.set_denoise and fixes mic re-captures silently keeping the old noiseSuppression (the mic level meter's clone held the old track open), the meter following the old device after a mic switch, and voiceIsolation staying on in off/ML modes (element-call#66). Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_017PCb59uY3UKqDqt7B6hzd5 --- package-lock.json | 8 ++-- package.json | 2 +- src/app/components/CallEmbedProvider.tsx | 2 + src/app/features/settings/general/General.tsx | 2 +- src/app/hooks/useCallDenoise.ts | 41 +++++++++++++++++++ src/app/plugins/call/CallControl.ts | 23 +++++++++++ src/app/utils/callDenoise.test.ts | 27 ++++++++++++ src/app/utils/callDenoise.ts | 31 ++++++++++++++ 8 files changed, 130 insertions(+), 6 deletions(-) create mode 100644 src/app/hooks/useCallDenoise.ts create mode 100644 src/app/utils/callDenoise.test.ts create mode 100644 src/app/utils/callDenoise.ts diff --git a/package-lock.json b/package-lock.json index bd74f40a4..64c2568fe 100644 --- a/package-lock.json +++ b/package-lock.json @@ -81,7 +81,7 @@ }, "devDependencies": { "@axe-core/playwright": "4.13.0", - "@lotusguild/element-call-embedded": "0.28.0-lotus.3", + "@lotusguild/element-call-embedded": "0.28.0-lotus.4", "@playwright/test": "1.63.0", "@rollup/plugin-inject": "5.0.5", "@rollup/plugin-wasm": "6.2.2", @@ -2695,9 +2695,9 @@ "integrity": "sha512-dfLbk+PwWvFzSxwk3n5ySL0hfBog779o8h68wK/7/APo/7cgyWp5jcXockbxdk5kFRkbeXWm4Fbi9FrdN381sA==" }, "node_modules/@lotusguild/element-call-embedded": { - "version": "0.28.0-lotus.3", - "resolved": "https://code.lotusguild.org/api/packages/LotusGuild/npm/%40lotusguild%2Felement-call-embedded/-/0.28.0-lotus.3/element-call-embedded-0.28.0-lotus.3.tgz", - "integrity": "sha512-CPXWXLCZkGMD8MFWgmerTb19UC1aj9QCD1PtSG8AO+IZvT1t1Md2hiCCu/EPDwRyYHBV54n5yWxQGg0hgLSteg==", + "version": "0.28.0-lotus.4", + "resolved": "https://code.lotusguild.org/api/packages/LotusGuild/npm/%40lotusguild%2Felement-call-embedded/-/0.28.0-lotus.4/element-call-embedded-0.28.0-lotus.4.tgz", + "integrity": "sha512-g+1mkdpGqUkE7JJo3b2G0XBiwDFwEedWul20hwD0iBpDyW67OafjbhrzRwfROY8Q9bx6BnulJHiR6Ll98KjguA==", "dev": true }, "node_modules/@matrix-org/matrix-sdk-crypto-wasm": { diff --git a/package.json b/package.json index c171a0ad9..66f9a1c32 100644 --- a/package.json +++ b/package.json @@ -108,7 +108,7 @@ }, "devDependencies": { "@axe-core/playwright": "4.13.0", - "@lotusguild/element-call-embedded": "0.28.0-lotus.3", + "@lotusguild/element-call-embedded": "0.28.0-lotus.4", "@playwright/test": "1.63.0", "@rollup/plugin-inject": "5.0.5", "@rollup/plugin-wasm": "6.2.2", diff --git a/src/app/components/CallEmbedProvider.tsx b/src/app/components/CallEmbedProvider.tsx index ee75fcb5f..529aa2831 100644 --- a/src/app/components/CallEmbedProvider.tsx +++ b/src/app/components/CallEmbedProvider.tsx @@ -60,6 +60,7 @@ import { SrOnly } from '../features/call-status/styles.css'; import { useCallHotkeys } from '../hooks/useCallHotkeys'; import { useAfkAutoMute } from '../hooks/useAfkAutoMute'; import { useCallQuality } from '../hooks/useCallQuality'; +import { useCallDenoise } from '../hooks/useCallDenoise'; import { useRemoteAllMuted } from '../hooks/useCallSpeakers'; import { useRoomAvatar, useRoomName } from '../hooks/useRoomMeta'; import { mDirectAtom } from '../state/mDirectList'; @@ -836,6 +837,7 @@ function CallUtils({ embed, joined }: { embed: CallEmbed; joined: boolean }) { useMutedTalkWarning(embed, joined); useCallThemeSync(embed); useCallQuality(embed); + useCallDenoise(embed); useCallHangupEvent( embed, useCallback(() => { diff --git a/src/app/features/settings/general/General.tsx b/src/app/features/settings/general/General.tsx index ca2242204..5c55984d0 100644 --- a/src/app/features/settings/general/General.tsx +++ b/src/app/features/settings/general/General.tsx @@ -2137,7 +2137,7 @@ export function Calls() { - Note: Applying changes requires rejoining the call. + Changes apply straight away, even during a call. diff --git a/src/app/hooks/useCallDenoise.ts b/src/app/hooks/useCallDenoise.ts new file mode 100644 index 000000000..70e74aa7b --- /dev/null +++ b/src/app/hooks/useCallDenoise.ts @@ -0,0 +1,41 @@ +import { useEffect } from 'react'; +import { useAtomValue } from 'jotai'; +import { CallEmbed } from '../plugins/call'; +import { settingsAtom } from '../state/settings'; +import { buildDenoisePayload } from '../utils/callDenoise'; + +/** + * Apply noise-suppression setting changes to a call that's already running + * (the fork's `io.lotus.set_denoise`): switching between off, browser-native + * and an ML model, or between models, takes effect without rejoining. The join + * itself still carries the settings in the call URL, so the first send is a + * no-op fork-side. + */ +export function useCallDenoise(embed: CallEmbed): void { + const { + callNoiseSuppression, + callDenoiseModel, + callDenoiseNativeNS, + callDenoiseGate, + callDenoiseGateThreshold, + } = useAtomValue(settingsAtom); + + useEffect(() => { + embed.control.setDenoise( + buildDenoisePayload({ + callNoiseSuppression, + callDenoiseModel, + callDenoiseNativeNS, + callDenoiseGate, + callDenoiseGateThreshold, + }), + ); + }, [ + embed, + callNoiseSuppression, + callDenoiseModel, + callDenoiseNativeNS, + callDenoiseGate, + callDenoiseGateThreshold, + ]); +} diff --git a/src/app/plugins/call/CallControl.ts b/src/app/plugins/call/CallControl.ts index b151a76f9..5f2d2b75d 100644 --- a/src/app/plugins/call/CallControl.ts +++ b/src/app/plugins/call/CallControl.ts @@ -4,6 +4,7 @@ import { CallControlState } from './CallControlState'; import { ElementMediaStateDetail, ElementMediaStatePayload, ElementWidgetActions } from './types'; import { canDelegateCapability } from './utils'; import { CallErrorState, parseCallError } from './callError'; +import type { LotusDenoisePayload } from '../../utils/callDenoise'; export enum CallControlEvent { StateUpdate = 'state_update', @@ -106,6 +107,11 @@ export class CallControl extends EventEmitter implements CallControlState { // pre-join send pends to a 10s widget timeout, mirroring the deafen gate). private lastQuality: LotusQualityPayload | null = null; + // Last noise-suppression choice (io.lotus.set_denoise), held like + // lastQuality: sent once joined and re-sent after an EC reconnect, whose + // remounted handler starts again from the settings in the join URL. + private lastDenoise: LotusDenoisePayload | null = null; + // C-M5: set true by CallControls while a push-to-talk key is held. A PTT hold // unmutes the mic transiently, and onMediaState() must NOT treat that as a // user-initiated unmute that auto-undeafens the user. @@ -191,6 +197,7 @@ export class CallControl extends EventEmitter implements CallControlState { this.joined = true; this.sendDeafenState(); this.sendQuality(); + this.sendDenoise(); this.sendHotkeyCodes(); this.sendFrameScreenshareAllowed(); } @@ -204,6 +211,7 @@ export class CallControl extends EventEmitter implements CallControlState { public resendForkState(): void { this.sendDeafenState(); this.sendQuality(); + this.sendDenoise(); if (this._audioOutputId !== undefined) this.sendAudioOutput(this._audioOutputId); // [Gitea #17] The pin lives fork-side and is dropped on a handler remount. if (this._focusedUserId !== null) this.sendFocus(this._focusedUserId, this._focusedMediaId); @@ -762,6 +770,21 @@ export class CallControl extends EventEmitter implements CallControlState { this.call.transport.send('io.lotus.set_quality', this.lastQuality).catch(() => undefined); } + /** + * Switch noise suppression on the live mic (the fork's `io.lotus.set_denoise`): + * browser-native on/off and/or the ML model, without rejoining. Join-gated + * like setQuality(). + */ + public setDenoise(payload: LotusDenoisePayload): void { + this.lastDenoise = payload; + this.sendDenoise(); + } + + private sendDenoise(): void { + if (!this.joined || !this.lastDenoise) return; + this.call.transport.send('io.lotus.set_denoise', this.lastDenoise).catch(() => undefined); + } + public dispose() { // [Gitea #56] Don't let a manual focus pin outlive the call. this.clearFocusParticipant(); diff --git a/src/app/utils/callDenoise.test.ts b/src/app/utils/callDenoise.test.ts new file mode 100644 index 000000000..2190ade2c --- /dev/null +++ b/src/app/utils/callDenoise.test.ts @@ -0,0 +1,27 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; + +import { buildDenoisePayload } from './callDenoise'; + +test('buildDenoisePayload passes the settings through', () => { + assert.deepEqual( + buildDenoisePayload({ + callNoiseSuppression: 'ml', + callDenoiseModel: 'dtln', + callDenoiseNativeNS: false, + callDenoiseGate: true, + callDenoiseGateThreshold: -50, + }), + { mode: 'ml', model: 'dtln', nativeNS: false, gate: true, gateThreshold: -50 }, + ); +}); + +test('buildDenoisePayload uses the same defaults as the join URL', () => { + assert.deepEqual(buildDenoisePayload({}), { + mode: 'browser', + model: 'rnnoise', + nativeNS: true, + gate: false, + gateThreshold: -45, + }); +}); diff --git a/src/app/utils/callDenoise.ts b/src/app/utils/callDenoise.ts new file mode 100644 index 000000000..73da212f1 --- /dev/null +++ b/src/app/utils/callDenoise.ts @@ -0,0 +1,31 @@ +import { DenoiseModelId, NoiseSuppressionMode } from '../state/settings'; + +/** + * Payload for the fork's `io.lotus.set_denoise` action, which switches noise + * suppression on the live mic mid-call (browser suppressor on/off and/or the + * ML model) instead of needing a rejoin. + */ +export type LotusDenoisePayload = { + mode: NoiseSuppressionMode; + model: DenoiseModelId; + nativeNS: boolean; + gate: boolean; + gateThreshold: number; +}; + +export type DenoiseSettings = { + callNoiseSuppression?: NoiseSuppressionMode; + callDenoiseModel?: DenoiseModelId; + callDenoiseNativeNS?: boolean; + callDenoiseGate?: boolean; + callDenoiseGateThreshold?: number; +}; + +/** Same defaults as the call URL built at join (useCallStart/createCallEmbed). */ +export const buildDenoisePayload = (s: DenoiseSettings): LotusDenoisePayload => ({ + mode: s.callNoiseSuppression ?? 'browser', + model: s.callDenoiseModel ?? 'rnnoise', + nativeNS: s.callDenoiseNativeNS ?? true, + gate: s.callDenoiseGate ?? false, + gateThreshold: s.callDenoiseGateThreshold ?? -45, +});