diff --git a/src/app/components/CallEmbedProvider.tsx b/src/app/components/CallEmbedProvider.tsx index de91075bf..6fa7b5b7d 100644 --- a/src/app/components/CallEmbedProvider.tsx +++ b/src/app/components/CallEmbedProvider.tsx @@ -86,6 +86,7 @@ import { CallAvatarAnimation } from '../styles/Animations.css'; import { webRTCSupported } from '../utils/rtc'; import { CallsUnavailableMessage } from '../features/call/CallsUnavailable'; import { zIndices } from '../styles/zIndex'; +import { useDenoiseNotices } from '../hooks/useDenoiseNotices'; const PIP_MIN_W = 200; const PIP_MIN_H = 112; @@ -813,6 +814,7 @@ function CallUtils({ embed, joined }: { embed: CallEmbed; joined: boolean }) { useCallPolicyRevokedToast(embed, joined); useCallEndedToast(embed); useScreenshareNotices(embed); + useDenoiseNotices(embed); // [Gitea #43] The in-frame screenshare button follows the same room policy // as the host bar's (hidden where the server would refuse the share). const { allowScreenshare } = useRoomCallPolicy(embed.room); diff --git a/src/app/hooks/useDenoiseNotices.ts b/src/app/hooks/useDenoiseNotices.ts new file mode 100644 index 000000000..1b66a9757 --- /dev/null +++ b/src/app/hooks/useDenoiseNotices.ts @@ -0,0 +1,38 @@ +import { useEffect, useRef } from 'react'; +import { useSetAtom } from 'jotai'; +import { Icons } from 'folds'; +import { CallEmbed } from '../plugins/call'; +import { toastQueueAtom } from '../state/toast'; +import { useSetting } from '../state/hooks/settings'; +import { settingsAtom } from '../state/settings'; +import { denoiseNotice, DenoiseStateReport } from '../utils/denoiseNotice'; + +/** + * [Gitea #260] Tell the user when ML noise suppression didn't attach (raw mic) + * or fell back to another model. The fork's `io.lotus.denoise_state` used to + * go unanswered ("unsupported action"), so failures were silent. + */ +export function useDenoiseNotices(embed: CallEmbed): void { + const setToast = useSetAtom(toastQueueAtom); + const [mode] = useSetting(settingsAtom, 'callNoiseSuppression'); + const [model] = useSetting(settingsAtom, 'callDenoiseModel'); + const latest = useRef({ mode, model }); + latest.current = { mode, model }; + useEffect( + () => + embed.listenAction<{ data?: DenoiseStateReport }>('io.lotus.denoise_state', (evt) => { + if (latest.current.mode !== 'ml') return; + const body = denoiseNotice(latest.current.model, evt.detail?.data); + if (!body) return; + setToast({ + id: `denoise-state-${Date.now()}`, + iconSrc: Icons.Mic, + displayName: 'Noise suppression', + body, + roomName: embed.room.name ?? 'Voice call', + roomId: embed.roomId, + }); + }), + [embed, setToast], + ); +} diff --git a/src/app/pages/client/ClientNonUIFeatures.tsx b/src/app/pages/client/ClientNonUIFeatures.tsx index 03420ed10..eab713f32 100644 --- a/src/app/pages/client/ClientNonUIFeatures.tsx +++ b/src/app/pages/client/ClientNonUIFeatures.tsx @@ -1075,32 +1075,6 @@ function TauriUpdateFeature() { return null; } -function LotusDenoiseFeature() { - const setToast = useSetAtom(toastQueueAtom); - - useEffect(() => { - const handleMessage = (event: MessageEvent) => { - if (event.data?.type === 'lotus-denoise-status') { - const { active, error } = event.data; - if (!active) { - setToast({ - id: `denoise-fail-${Date.now()}`, - displayName: 'Audio Quality', - body: `ML Noise Suppression failed: ${error || 'Unknown error'}. Falling back to raw mic.`, - roomName: 'System', - roomId: '', - }); - } - } - }; - - window.addEventListener('message', handleMessage); - return () => window.removeEventListener('message', handleMessage); - }, [setToast]); - - return null; -} - // Registers the global `?` shortcut (ignored while typing) and renders the // keyboard-shortcuts help dialog. Headless — the dialog self-gates on its atom. function KeyboardShortcutsFeature() { @@ -1183,7 +1157,6 @@ export function ClientNonUIFeatures({ children }: ClientNonUIFeaturesProps) { - {children} diff --git a/src/app/utils/denoiseNotice.test.ts b/src/app/utils/denoiseNotice.test.ts new file mode 100644 index 000000000..779be6879 --- /dev/null +++ b/src/app/utils/denoiseNotice.test.ts @@ -0,0 +1,17 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { denoiseNotice } from './denoiseNotice'; + +test('denoiseNotice: says when ML is off or fell back (#260)', () => { + assert.equal(denoiseNotice('deepfilternet', { active: true, model: 'deepfilternet' }), undefined); + assert.match( + denoiseNotice('deepfilternet', { active: true, model: 'rnnoise' }) ?? '', + /^DeepFilterNet 3 couldn't start, so noise suppression is using RNNoise instead\.$/, + ); + assert.match( + denoiseNotice('rnnoise', { active: false, error: 'boom' }) ?? '', + /unfiltered audio/, + ); + assert.equal(denoiseNotice('rnnoise', undefined), undefined); + assert.equal(denoiseNotice('rnnoise', { model: 'x' }), undefined); +}); diff --git a/src/app/utils/denoiseNotice.ts b/src/app/utils/denoiseNotice.ts new file mode 100644 index 000000000..8d16645bc --- /dev/null +++ b/src/app/utils/denoiseNotice.ts @@ -0,0 +1,29 @@ +/** + * [Gitea #260] The fork reports what noise suppression actually runs as + * `io.lotus.denoise_state { active, model, error? }`. Pure: the toast text for + * a report, or undefined when what runs is what the user picked. + */ +export type DenoiseStateReport = { active?: unknown; model?: unknown; error?: unknown }; + +const MODEL_NAMES: Record = { + rnnoise: 'RNNoise', + speex: 'Speex', + dtln: 'DTLN', + deepfilternet: 'DeepFilterNet 3', +}; +const modelName = (m: unknown): string => + typeof m === 'string' ? (MODEL_NAMES[m] ?? m) : 'the selected model'; + +export function denoiseNotice( + requestedModel: string, + report: DenoiseStateReport | undefined, +): string | undefined { + if (!report || typeof report.active !== 'boolean') return undefined; + if (!report.active) { + return `ML noise suppression couldn't start, so your microphone is sending unfiltered audio. You can pick another model in Settings → Calls.`; + } + if (typeof report.model === 'string' && report.model !== requestedModel) { + return `${modelName(requestedModel)} couldn't start, so noise suppression is using ${modelName(report.model)} instead.`; + } + return undefined; +}