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;
+}