diff --git a/electron/electron-env.d.ts b/electron/electron-env.d.ts
index 8a4eedd63..a3ba8ccfc 100644
--- a/electron/electron-env.d.ts
+++ b/electron/electron-env.d.ts
@@ -880,6 +880,7 @@ interface Window {
success: boolean;
microphoneEnabled: boolean;
microphoneDeviceId?: string;
+ noiseSuppressionMode?: string;
systemAudioEnabled: boolean;
}>;
getRecordingAudioLabConfig: () => Promise<{
@@ -889,6 +890,7 @@ interface Window {
setRecordingPreferences: (prefs: {
microphoneEnabled?: boolean;
microphoneDeviceId?: string;
+ noiseSuppressionMode?: string;
systemAudioEnabled?: boolean;
}) => Promise<{ success: boolean; error?: string }>;
/** Countdown timer before recording */
diff --git a/electron/ipc/register/settings.ts b/electron/ipc/register/settings.ts
index e84f63171..af7a34eb0 100644
--- a/electron/ipc/register/settings.ts
+++ b/electron/ipc/register/settings.ts
@@ -153,6 +153,10 @@ export function registerSettingsHandlers() {
typeof parsed.microphoneDeviceId === "string"
? parsed.microphoneDeviceId
: undefined,
+ noiseSuppressionMode:
+ typeof parsed.noiseSuppressionMode === "string"
+ ? parsed.noiseSuppressionMode
+ : "rnnoise",
systemAudioEnabled: parsed.systemAudioEnabled === true,
};
} catch {
@@ -160,6 +164,7 @@ export function registerSettingsHandlers() {
success: true,
microphoneEnabled: false,
microphoneDeviceId: undefined,
+ noiseSuppressionMode: "rnnoise",
systemAudioEnabled: false,
};
}
@@ -176,6 +181,7 @@ export function registerSettingsHandlers() {
prefs: {
microphoneEnabled?: boolean;
microphoneDeviceId?: string;
+ noiseSuppressionMode?: string;
systemAudioEnabled?: boolean;
},
) => {
diff --git a/electron/native/bin/darwin-arm64/recordly-native-cursor-monitor b/electron/native/bin/darwin-arm64/recordly-native-cursor-monitor
index e0e478f11..34a4da9e3 100755
Binary files a/electron/native/bin/darwin-arm64/recordly-native-cursor-monitor and b/electron/native/bin/darwin-arm64/recordly-native-cursor-monitor differ
diff --git a/electron/native/bin/darwin-arm64/recordly-screencapturekit-helper b/electron/native/bin/darwin-arm64/recordly-screencapturekit-helper
index 259f18848..e8a29cdf7 100755
Binary files a/electron/native/bin/darwin-arm64/recordly-screencapturekit-helper and b/electron/native/bin/darwin-arm64/recordly-screencapturekit-helper differ
diff --git a/electron/native/bin/darwin-arm64/recordly-system-cursors b/electron/native/bin/darwin-arm64/recordly-system-cursors
index f4b41ab66..034c201f1 100755
Binary files a/electron/native/bin/darwin-arm64/recordly-system-cursors and b/electron/native/bin/darwin-arm64/recordly-system-cursors differ
diff --git a/electron/native/bin/darwin-arm64/recordly-window-list b/electron/native/bin/darwin-arm64/recordly-window-list
index 76a7dab4a..e4170eadf 100755
Binary files a/electron/native/bin/darwin-arm64/recordly-window-list and b/electron/native/bin/darwin-arm64/recordly-window-list differ
diff --git a/electron/native/bin/darwin-x64/recordly-native-cursor-monitor b/electron/native/bin/darwin-x64/recordly-native-cursor-monitor
index d577b1a6f..3c0cfa872 100755
Binary files a/electron/native/bin/darwin-x64/recordly-native-cursor-monitor and b/electron/native/bin/darwin-x64/recordly-native-cursor-monitor differ
diff --git a/electron/native/bin/darwin-x64/recordly-screencapturekit-helper b/electron/native/bin/darwin-x64/recordly-screencapturekit-helper
index 09e0ed5d6..a42997665 100755
Binary files a/electron/native/bin/darwin-x64/recordly-screencapturekit-helper and b/electron/native/bin/darwin-x64/recordly-screencapturekit-helper differ
diff --git a/electron/native/bin/darwin-x64/recordly-system-cursors b/electron/native/bin/darwin-x64/recordly-system-cursors
index 545613624..0dc0ff974 100755
Binary files a/electron/native/bin/darwin-x64/recordly-system-cursors and b/electron/native/bin/darwin-x64/recordly-system-cursors differ
diff --git a/electron/native/bin/darwin-x64/recordly-window-list b/electron/native/bin/darwin-x64/recordly-window-list
index e165257ae..affc5b010 100755
Binary files a/electron/native/bin/darwin-x64/recordly-window-list and b/electron/native/bin/darwin-x64/recordly-window-list differ
diff --git a/electron/preload.ts b/electron/preload.ts
index e55d42cbd..f12d04835 100644
--- a/electron/preload.ts
+++ b/electron/preload.ts
@@ -971,6 +971,7 @@ contextBridge.exposeInMainWorld("electronAPI", {
setRecordingPreferences: (prefs: {
microphoneEnabled?: boolean;
microphoneDeviceId?: string;
+ noiseSuppressionMode?: string;
systemAudioEnabled?: boolean;
}) => ipcRenderer.invoke("set-recording-preferences", prefs),
getCountdownDelay: () => ipcRenderer.invoke("get-countdown-delay"),
diff --git a/package-lock.json b/package-lock.json
index 71432b2b1..b41ab200b 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -10,6 +10,8 @@
"hasInstallScript": true,
"dependencies": {
"@phosphor-icons/react": "^2.1.10",
+ "@sapphi-red/speex-preprocess-wasm": "^0.4.0",
+ "@shiguredo/rnnoise-wasm": "^2025.1.5",
"capturekit": "^1.0.13",
"electron-updater": "^6.8.3",
"ffmpeg-static": "^5.3.0",
@@ -3477,6 +3479,22 @@
"win32"
]
},
+ "node_modules/@sapphi-red/speex-preprocess-wasm": {
+ "version": "0.4.0",
+ "resolved": "https://registry.npmjs.org/@sapphi-red/speex-preprocess-wasm/-/speex-preprocess-wasm-0.4.0.tgz",
+ "integrity": "sha512-VAybOqfE1m/3q+3BzpDiHuP/F5O1q0r6BosAkb2FYrBbMTiP1kWs/grELZqcIeSYa1tF5uVQzHi0hQtZUYRJVQ==",
+ "license": "MIT"
+ },
+ "node_modules/@shiguredo/rnnoise-wasm": {
+ "version": "2025.1.5",
+ "resolved": "https://registry.npmjs.org/@shiguredo/rnnoise-wasm/-/rnnoise-wasm-2025.1.5.tgz",
+ "integrity": "sha512-9YJxzzHftlW936E5z9aEKK2CrPfUgc3HgZ2keyQ5e2Pb5bx+6tykYQ2Gyudvxd7Sxmlnar7SemDMFfx0OuVuGA==",
+ "license": "Apache-2.0",
+ "engines": {
+ "node": ">=20",
+ "pnpm": ">=10"
+ }
+ },
"node_modules/@sindresorhus/is": {
"version": "4.6.0",
"resolved": "https://registry.npmjs.org/@sindresorhus/is/-/is-4.6.0.tgz",
diff --git a/package.json b/package.json
index 6fd53cd35..6691b3a54 100644
--- a/package.json
+++ b/package.json
@@ -46,6 +46,8 @@
},
"dependencies": {
"@phosphor-icons/react": "^2.1.10",
+ "@sapphi-red/speex-preprocess-wasm": "^0.4.0",
+ "@shiguredo/rnnoise-wasm": "^2025.1.5",
"capturekit": "^1.0.13",
"electron-updater": "^6.8.3",
"ffmpeg-static": "^5.3.0",
diff --git a/src/components/launch/LaunchWindow.tsx b/src/components/launch/LaunchWindow.tsx
index 09cca63ef..3872468a2 100644
--- a/src/components/launch/LaunchWindow.tsx
+++ b/src/components/launch/LaunchWindow.tsx
@@ -69,6 +69,8 @@ function LaunchWindowContent() {
setMicrophoneEnabled,
microphoneDeviceId,
setMicrophoneDeviceId,
+ noiseSuppressionMode,
+ setNoiseSuppressionMode,
systemAudioEnabled,
setSystemAudioEnabled,
webcamEnabled,
@@ -262,6 +264,8 @@ function LaunchWindowContent() {
devices={devices}
microphoneDeviceId={microphoneDeviceId}
selectedDeviceId={selectedDeviceId}
+ noiseSuppressionMode={noiseSuppressionMode}
+ onSelectNoiseSuppressionMode={setNoiseSuppressionMode}
onSelectDevice={(deviceId) => {
setMicrophoneEnabled(true);
setSelectedDeviceId(deviceId);
diff --git a/src/components/launch/popovers/MicPopover.tsx b/src/components/launch/popovers/MicPopover.tsx
index 02247d662..fe4ed3fef 100644
--- a/src/components/launch/popovers/MicPopover.tsx
+++ b/src/components/launch/popovers/MicPopover.tsx
@@ -1,10 +1,17 @@
-import { MicrophoneSlashIcon, SpeakerHighIcon, SpeakerXIcon } from "@phosphor-icons/react";
+import {
+ CheckCircleIcon,
+ MicrophoneIcon,
+ MicrophoneSlashIcon,
+ SpeakerHighIcon,
+ SpeakerXIcon,
+} from "@phosphor-icons/react";
+import type { ReactElement } from "react";
import { useScopedT } from "@/contexts/I18nContext";
-import { DropdownItem, HudPopover, MicDeviceRow } from "./PopoverScaffold";
+import type { NoiseSuppressionMode } from "@/lib/audio/noiseSuppression";
+import styles from "../LaunchWindow.module.css";
import { useLaunchPopoverCoordinator } from "./LaunchPopoverCoordinator";
import type { DeviceOption } from "./launchPopoverTypes";
-import type { ReactElement } from "react";
-import styles from "../LaunchWindow.module.css";
+import { DropdownItem, HudPopover, MicDeviceRow } from "./PopoverScaffold";
const POPOVER_ID = "mic";
@@ -19,6 +26,8 @@ export function MicPopover({
microphoneDeviceId,
selectedDeviceId,
onSelectDevice,
+ noiseSuppressionMode,
+ onSelectNoiseSuppressionMode,
}: {
trigger: ReactElement;
disabled?: boolean;
@@ -30,6 +39,8 @@ export function MicPopover({
microphoneDeviceId?: string;
selectedDeviceId?: string;
onSelectDevice: (deviceId: string) => void;
+ noiseSuppressionMode: NoiseSuppressionMode;
+ onSelectNoiseSuppressionMode: (mode: NoiseSuppressionMode) => void;
}) {
const t = useScopedT("launch");
const { isOpen, requestOpen, requestClose } = useLaunchPopoverCoordinator();
@@ -53,7 +64,9 @@ export function MicPopover({
>
{t("recording.microphone")}
: }
+ icon={
+ systemAudioEnabled ? :
+ }
selected={systemAudioEnabled}
onClick={onToggleSystemAudio}
>
@@ -73,7 +86,9 @@ export function MicPopover({
)}
{!microphoneEnabled && (
- {t("recording.selectMicToEnable")}
+
+ {t("recording.selectMicToEnable")}
+
)}
{devices.map((device) => (
onSelectDevice(device.deviceId)}
/>
))}
{devices.length === 0 && (
- {t("recording.noMicrophonesFound")}
+
+ {t("recording.noMicrophonesFound")}
+
)}
+ {t("recording.noiseSuppression")}
+
+
+
);
}
+
+function NoiseSuppressionOption({
+ mode,
+ selectedMode,
+ label,
+ description,
+ onSelect,
+}: {
+ mode: NoiseSuppressionMode;
+ selectedMode: NoiseSuppressionMode;
+ label: string;
+ description: string;
+ onSelect: (mode: NoiseSuppressionMode) => void;
+}) {
+ const selected = mode === selectedMode;
+ return (
+
+ );
+}
diff --git a/src/hooks/useScreenRecorder.ts b/src/hooks/useScreenRecorder.ts
index c5cd70056..071c8cfb5 100644
--- a/src/hooks/useScreenRecorder.ts
+++ b/src/hooks/useScreenRecorder.ts
@@ -1,6 +1,16 @@
import { fixWebmDuration } from "@fix-webm-duration/fix";
import { useCallback, useEffect, useRef, useState } from "react";
import { toast } from "sonner";
+import { useI18n } from "@/contexts/I18nContext";
+import {
+ DEFAULT_NOISE_SUPPRESSION_MODE,
+ normalizeNoiseSuppressionMode,
+ type NoiseSuppressionMode,
+} from "@/lib/audio/noiseSuppression";
+import {
+ createNoiseSuppressedMicrophoneStream,
+ type NoiseSuppressedMicrophoneStream,
+} from "@/lib/audio/noiseSuppressedStream";
import { getEffectiveRecordingDurationMs } from "@/lib/mediaTiming";
import {
getVideoExtensionForMimeType,
@@ -39,6 +49,7 @@ const WEBCAM_FRAME_RATE = 30;
const WEBCAM_SUFFIX = "-webcam";
const MICROPHONE_FALLBACK_ERROR_TOAST_ID = "recording-microphone-fallback-error";
const MICROPHONE_SIDECAR_ERROR_TOAST_ID = "recording-microphone-sidecar-error";
+
export type BrowserMicrophoneProfile =
| "processed"
| "no-agc"
@@ -46,6 +57,7 @@ export type BrowserMicrophoneProfile =
| "no-noise-suppression"
| "raw";
type BrowserCaptureCursorMode = "always" | "never";
+
export type BrowserCaptureCursorPolicy = {
streamCursor: BrowserCaptureCursorMode;
hideOsCursorBeforeRecording: boolean;
@@ -141,6 +153,8 @@ type UseScreenRecorderReturn = {
setMicrophoneEnabled: (enabled: boolean) => void;
microphoneDeviceId: string | undefined;
setMicrophoneDeviceId: (deviceId: string | undefined) => void;
+ noiseSuppressionMode: NoiseSuppressionMode;
+ setNoiseSuppressionMode: (mode: NoiseSuppressionMode) => void;
systemAudioEnabled: boolean;
setSystemAudioEnabled: (enabled: boolean) => void;
webcamEnabled: boolean;
@@ -213,10 +227,7 @@ export function resolveBrowserCaptureCursorPolicy({
export function shouldUseNativeWindowsCaptureForSource(
source: Pick | null | undefined,
): boolean {
- return (
- source?.id?.startsWith("screen:") === true ||
- source?.id?.startsWith("window:") === true
- );
+ return source?.id?.startsWith("screen:") === true || source?.id?.startsWith("window:") === true;
}
export function createProcessedMicrophoneConstraints(
@@ -320,6 +331,7 @@ async function createAudioInputDeviceSnapshot(): Promise<
}
export function useScreenRecorder(): UseScreenRecorderReturn {
+ const { t } = useI18n();
const [recording, setRecording] = useState(false);
const [paused, setPaused] = useState(false);
const [starting, setStarting] = useState(false);
@@ -328,6 +340,9 @@ export function useScreenRecorder(): UseScreenRecorderReturn {
const [isMacOS, setIsMacOS] = useState(false);
const [microphoneEnabled, setMicrophoneEnabled] = useState(false);
const [microphoneDeviceId, setMicrophoneDeviceId] = useState(undefined);
+ const [noiseSuppressionMode, setNoiseSuppressionModeState] = useState(
+ DEFAULT_NOISE_SUPPRESSION_MODE,
+ );
const [systemAudioEnabled, setSystemAudioEnabled] = useState(false);
const [webcamEnabled, setWebcamEnabled] = useState(false);
const [webcamDeviceId, setWebcamDeviceId] = useState(undefined);
@@ -337,6 +352,7 @@ export function useScreenRecorder(): UseScreenRecorderReturn {
const stream = useRef(null);
const screenStream = useRef(null);
const microphoneStream = useRef(null);
+ const noiseSuppressedMicrophoneStreams = useRef([]);
const webcamStream = useRef(null);
const mixingContext = useRef(null);
const chunks = useRef([]);
@@ -554,6 +570,11 @@ export function useScreenRecorder(): UseScreenRecorderReturn {
};
const cleanupCapturedMedia = useCallback(() => {
+ for (const processedStream of noiseSuppressedMicrophoneStreams.current) {
+ processedStream.destroy();
+ }
+ noiseSuppressedMicrophoneStreams.current = [];
+
if (stream.current) {
stream.current.getTracks().forEach((track) => track.stop());
stream.current = null;
@@ -598,6 +619,35 @@ export function useScreenRecorder(): UseScreenRecorderReturn {
}
}, [resetMicFallbackTimingDiagnostics]);
+ const prepareMicrophoneStream = useCallback(
+ async (rawStream: MediaStream) => {
+ try {
+ const processed = await createNoiseSuppressedMicrophoneStream({
+ sourceStream: rawStream,
+ mode: noiseSuppressionMode,
+ onWarning: (warning) => {
+ const fallback =
+ warning.key === "recording.noiseSuppressionUnavailableWarning"
+ ? "Noise suppression is unavailable on this device. Recording will continue without it."
+ : "Noise suppression fell back to {{mode}}.";
+ const params = "params" in warning ? warning.params : undefined;
+ toast.warning(t(warning.key, fallback, params), { duration: 7000 });
+ },
+ });
+ noiseSuppressedMicrophoneStreams.current.push(processed);
+ console.info("[NoiseSuppression] Selected microphone mode.", {
+ requestedMode: processed.requestedMode,
+ activeMode: processed.activeMode,
+ });
+ return processed.stream;
+ } catch (error) {
+ rawStream.getTracks().forEach((track) => track.stop());
+ throw error;
+ }
+ },
+ [noiseSuppressionMode, t],
+ );
+
const appendMicFallbackChunk = useCallback(
(event: BlobEvent) => {
if (event.data.size <= 0) {
@@ -1266,6 +1316,9 @@ export function useScreenRecorder(): UseScreenRecorderReturn {
if (result.microphoneDeviceId) {
setMicrophoneDeviceId(result.microphoneDeviceId);
}
+ setNoiseSuppressionModeState(
+ normalizeNoiseSuppressionMode(result.noiseSuppressionMode),
+ );
setSystemAudioEnabled(result.systemAudioEnabled);
}
})();
@@ -1281,6 +1334,14 @@ export function useScreenRecorder(): UseScreenRecorderReturn {
void window.electronAPI.setRecordingPreferences({ microphoneDeviceId: deviceId });
}, []);
+ const persistNoiseSuppressionMode = useCallback((mode: NoiseSuppressionMode) => {
+ const normalizedMode = normalizeNoiseSuppressionMode(mode);
+ setNoiseSuppressionModeState(normalizedMode);
+ void window.electronAPI.setRecordingPreferences({
+ noiseSuppressionMode: normalizedMode,
+ });
+ }, []);
+
const persistSystemAudioEnabled = useCallback((enabled: boolean) => {
setSystemAudioEnabled(enabled);
void window.electronAPI.setRecordingPreferences({ systemAudioEnabled: enabled });
@@ -1361,6 +1422,7 @@ export function useScreenRecorder(): UseScreenRecorderReturn {
}
let hudSourceSelectionActive = false;
+
const setHudSourceSelectionActive = (active: boolean) => {
if (hudSourceSelectionActive === active) {
return;
@@ -1457,7 +1519,8 @@ export function useScreenRecorder(): UseScreenRecorderReturn {
selectedSource,
{
capturesSystemAudio: systemAudioEnabled,
- capturesMicrophone: microphoneEnabled,
+ capturesMicrophone:
+ microphoneEnabled && noiseSuppressionMode === "disabled",
microphoneDeviceId,
microphoneLabel: micLabel,
},
@@ -1504,7 +1567,11 @@ export function useScreenRecorder(): UseScreenRecorderReturn {
// When native mic capture is unavailable or explicitly bypassed,
// record mic via browser getUserMedia as a sidecar file.
- if (nativeResult.microphoneFallbackRequired && microphoneEnabled) {
+ if (
+ (nativeResult.microphoneFallbackRequired ||
+ noiseSuppressionMode !== "disabled") &&
+ microphoneEnabled
+ ) {
void logNativeCaptureDiagnostics("start-browser-microphone-fallback");
console.info("Using browser microphone processing for this recording.");
try {
@@ -1513,10 +1580,12 @@ export function useScreenRecorder(): UseScreenRecorderReturn {
browserMicrophoneProfile.current,
);
micFallbackRequestedConstraints.current = microphoneConstraints;
- const micStream =
+ const rawMicStream =
await navigator.mediaDevices.getUserMedia(microphoneConstraints);
+ microphoneStream.current = rawMicStream;
+ const micStream = await prepareMicrophoneStream(rawMicStream);
micFallbackTrackSettings.current =
- createMicrophoneTrackSettingsSnapshot(micStream);
+ createMicrophoneTrackSettingsSnapshot(rawMicStream);
micFallbackAudioInputDevices.current =
await createAudioInputDeviceSnapshot().catch(() => null);
console.info(
@@ -1632,6 +1701,7 @@ export function useScreenRecorder(): UseScreenRecorderReturn {
if (wantsAudioCapture) {
let screenMediaStream: MediaStream;
+
const acquireLinuxPortalStream = (withAudio: boolean) =>
mediaDevices.getDisplayMedia({
audio: withAudio,
@@ -1695,12 +1765,15 @@ export function useScreenRecorder(): UseScreenRecorderReturn {
if (microphoneEnabled) {
try {
- microphoneStream.current = await navigator.mediaDevices.getUserMedia(
+ const rawMicrophoneStream = await navigator.mediaDevices.getUserMedia(
createProcessedMicrophoneConstraints(
microphoneDeviceId,
browserMicrophoneProfile.current,
),
);
+ microphoneStream.current = rawMicrophoneStream;
+ microphoneStream.current =
+ await prepareMicrophoneStream(rawMicrophoneStream);
} catch (audioError) {
console.warn("Failed to get microphone access:", audioError);
alert(
@@ -2116,6 +2189,8 @@ export function useScreenRecorder(): UseScreenRecorderReturn {
setMicrophoneEnabled: persistMicrophoneEnabled,
microphoneDeviceId,
setMicrophoneDeviceId: persistMicrophoneDeviceId,
+ noiseSuppressionMode,
+ setNoiseSuppressionMode: persistNoiseSuppressionMode,
systemAudioEnabled,
setSystemAudioEnabled: persistSystemAudioEnabled,
webcamEnabled,
diff --git a/src/i18n/locales/en/launch.json b/src/i18n/locales/en/launch.json
index 9dfcbe038..633a50eb2 100644
--- a/src/i18n/locales/en/launch.json
+++ b/src/i18n/locales/en/launch.json
@@ -28,6 +28,15 @@
"turnOffMicrophone": "Turn Off Microphone",
"selectMicToEnable": "Select a microphone to enable",
"noMicrophonesFound": "No microphones found",
+ "noiseSuppression": "Noise suppression",
+ "noiseSuppressionRnnoise": "RNNoise - Better quality",
+ "noiseSuppressionRnnoiseDescription": "Better background-noise removal, higher CPU usage.",
+ "noiseSuppressionSpeex": "Speex - Lower CPU usage",
+ "noiseSuppressionSpeexDescription": "Lower CPU usage, but reduced voice quality.",
+ "noiseSuppressionDisabled": "Disabled",
+ "noiseSuppressionDisabledDescription": "Records the original microphone audio.",
+ "noiseSuppressionUnavailableWarning": "Noise suppression is unavailable on this device. Recording will continue without it.",
+ "noiseSuppressionFallbackWarning": "Noise suppression fell back to {{mode}}.",
"webcam": "Webcam",
"turnOffWebcam": "Turn Off Webcam",
"hideFloatingWebcamPreview": "Hide Floating Preview",
diff --git a/src/i18n/locales/es/launch.json b/src/i18n/locales/es/launch.json
index a458844a2..773c51013 100644
--- a/src/i18n/locales/es/launch.json
+++ b/src/i18n/locales/es/launch.json
@@ -28,6 +28,15 @@
"turnOffMicrophone": "Desactivar micrófono",
"selectMicToEnable": "Selecciona un micrófono para activar",
"noMicrophonesFound": "No se encontraron micrófonos",
+ "noiseSuppression": "Supresión de ruido",
+ "noiseSuppressionRnnoise": "RNNoise - Mejor calidad",
+ "noiseSuppressionRnnoiseDescription": "Mejor eliminación del ruido de fondo, mayor uso de CPU.",
+ "noiseSuppressionSpeex": "Speex - Menor uso de CPU",
+ "noiseSuppressionSpeexDescription": "Menor uso de CPU, pero calidad de voz reducida.",
+ "noiseSuppressionDisabled": "Desactivado",
+ "noiseSuppressionDisabledDescription": "Graba el audio original del micrófono.",
+ "noiseSuppressionUnavailableWarning": "La supresión de ruido no está disponible en este dispositivo. La grabación continuará sin ella.",
+ "noiseSuppressionFallbackWarning": "La supresión de ruido cambió a {{mode}}.",
"webcam": "Cámara",
"turnOffWebcam": "Desactivar cámara",
"hideFloatingWebcamPreview": "Ocultar vista flotante",
diff --git a/src/i18n/locales/fr/launch.json b/src/i18n/locales/fr/launch.json
index df2e43b2b..1a0699bd7 100644
--- a/src/i18n/locales/fr/launch.json
+++ b/src/i18n/locales/fr/launch.json
@@ -28,6 +28,15 @@
"turnOffMicrophone": "Désactiver le microphone",
"selectMicToEnable": "Sélectionnez un microphone à activer",
"noMicrophonesFound": "Aucun microphone trouvé",
+ "noiseSuppression": "Réduction du bruit",
+ "noiseSuppressionRnnoise": "RNNoise - Meilleure qualité",
+ "noiseSuppressionRnnoiseDescription": "Meilleure suppression du bruit de fond, utilisation du processeur plus élevée.",
+ "noiseSuppressionSpeex": "Speex - Utilisation du processeur réduite",
+ "noiseSuppressionSpeexDescription": "Utilisation du processeur réduite, mais qualité vocale moindre.",
+ "noiseSuppressionDisabled": "Désactivé",
+ "noiseSuppressionDisabledDescription": "Enregistre l’audio original du microphone.",
+ "noiseSuppressionUnavailableWarning": "La réduction du bruit n’est pas disponible sur cet appareil. L’enregistrement continuera sans elle.",
+ "noiseSuppressionFallbackWarning": "La réduction du bruit est passée à {{mode}}.",
"webcam": "Webcam",
"turnOffWebcam": "Désactiver la webcam",
"hideFloatingWebcamPreview": "Masquer l’aperçu flottant",
diff --git a/src/i18n/locales/it/launch.json b/src/i18n/locales/it/launch.json
index c27abc3ad..78f0988bc 100644
--- a/src/i18n/locales/it/launch.json
+++ b/src/i18n/locales/it/launch.json
@@ -28,6 +28,15 @@
"turnOffMicrophone": "Spegni microfono",
"selectMicToEnable": "Seleziona un microfono da abilitare",
"noMicrophonesFound": "Nessun microfono trovato",
+ "noiseSuppression": "Riduzione del rumore",
+ "noiseSuppressionRnnoise": "RNNoise - Qualità migliore",
+ "noiseSuppressionRnnoiseDescription": "Rimozione migliore del rumore di fondo, maggiore uso della CPU.",
+ "noiseSuppressionSpeex": "Speex - Minore uso della CPU",
+ "noiseSuppressionSpeexDescription": "Minore uso della CPU, ma qualità vocale ridotta.",
+ "noiseSuppressionDisabled": "Disabilitata",
+ "noiseSuppressionDisabledDescription": "Registra l'audio originale del microfono.",
+ "noiseSuppressionUnavailableWarning": "La riduzione del rumore non è disponibile su questo dispositivo. La registrazione continuerà senza.",
+ "noiseSuppressionFallbackWarning": "La riduzione del rumore è passata a {{mode}}.",
"webcam": "Webcam",
"turnOffWebcam": "Spegni webcam",
"hideFloatingWebcamPreview": "Nascondi anteprima fluttuante",
diff --git a/src/i18n/locales/ko/launch.json b/src/i18n/locales/ko/launch.json
index 345000399..2d348d8e1 100644
--- a/src/i18n/locales/ko/launch.json
+++ b/src/i18n/locales/ko/launch.json
@@ -28,6 +28,15 @@
"turnOffMicrophone": "마이크 끄기",
"selectMicToEnable": "사용할 마이크를 선택하세요",
"noMicrophonesFound": "마이크를 찾을 수 없습니다",
+ "noiseSuppression": "소음 억제",
+ "noiseSuppressionRnnoise": "RNNoise - 더 높은 품질",
+ "noiseSuppressionRnnoiseDescription": "배경 소음을 더 잘 제거하지만 CPU 사용량이 높습니다.",
+ "noiseSuppressionSpeex": "Speex - 더 낮은 CPU 사용량",
+ "noiseSuppressionSpeexDescription": "CPU 사용량은 낮지만 음성 품질이 떨어질 수 있습니다.",
+ "noiseSuppressionDisabled": "비활성화",
+ "noiseSuppressionDisabledDescription": "원본 마이크 오디오를 녹음합니다.",
+ "noiseSuppressionUnavailableWarning": "이 기기에서는 소음 억제를 사용할 수 없습니다. 녹음은 소음 억제 없이 계속됩니다.",
+ "noiseSuppressionFallbackWarning": "소음 억제가 {{mode}}(으)로 전환되었습니다.",
"webcam": "웹캠",
"turnOffWebcam": "웹캠 끄기",
"hideFloatingWebcamPreview": "플로팅 미리보기 숨기기",
diff --git a/src/i18n/locales/nl/launch.json b/src/i18n/locales/nl/launch.json
index d3d5870c9..051ced057 100644
--- a/src/i18n/locales/nl/launch.json
+++ b/src/i18n/locales/nl/launch.json
@@ -28,6 +28,15 @@
"turnOffMicrophone": "Microfoon uitschakelen",
"selectMicToEnable": "Selecteer een microfoon om in te schakelen",
"noMicrophonesFound": "Geen microfoons gevonden",
+ "noiseSuppression": "Ruisonderdrukking",
+ "noiseSuppressionRnnoise": "RNNoise - Betere kwaliteit",
+ "noiseSuppressionRnnoiseDescription": "Betere verwijdering van achtergrondruis, hoger CPU-gebruik.",
+ "noiseSuppressionSpeex": "Speex - Lager CPU-gebruik",
+ "noiseSuppressionSpeexDescription": "Lager CPU-gebruik, maar lagere stemkwaliteit.",
+ "noiseSuppressionDisabled": "Uitgeschakeld",
+ "noiseSuppressionDisabledDescription": "Neemt de originele microfoonaudio op.",
+ "noiseSuppressionUnavailableWarning": "Ruisonderdrukking is niet beschikbaar op dit apparaat. De opname gaat zonder ruisonderdrukking door.",
+ "noiseSuppressionFallbackWarning": "Ruisonderdrukking is teruggevallen op {{mode}}.",
"webcam": "Webcam",
"turnOffWebcam": "Webcam uitschakelen",
"hideFloatingWebcamPreview": "Zwevende voorbeeldweergave verbergen",
diff --git a/src/i18n/locales/pt-BR/launch.json b/src/i18n/locales/pt-BR/launch.json
index 8d19ac7db..1c6b55aa6 100644
--- a/src/i18n/locales/pt-BR/launch.json
+++ b/src/i18n/locales/pt-BR/launch.json
@@ -28,6 +28,15 @@
"turnOffMicrophone": "Desligar microfone",
"selectMicToEnable": "Selecione um microfone para ativar",
"noMicrophonesFound": "Nenhum microfone encontrado",
+ "noiseSuppression": "Supressão de ruído",
+ "noiseSuppressionRnnoise": "RNNoise - Melhor qualidade",
+ "noiseSuppressionRnnoiseDescription": "Melhor remoção de ruído de fundo, maior uso de CPU.",
+ "noiseSuppressionSpeex": "Speex - Menor uso de CPU",
+ "noiseSuppressionSpeexDescription": "Menor uso de CPU, mas qualidade de voz reduzida.",
+ "noiseSuppressionDisabled": "Desativado",
+ "noiseSuppressionDisabledDescription": "Grava o áudio original do microfone.",
+ "noiseSuppressionUnavailableWarning": "A supressão de ruído não está disponível neste dispositivo. A gravação continuará sem ela.",
+ "noiseSuppressionFallbackWarning": "A supressão de ruído alternou para {{mode}}.",
"webcam": "Webcam",
"turnOffWebcam": "Desligar webcam",
"hideFloatingWebcamPreview": "Ocultar prévia flutuante",
diff --git a/src/i18n/locales/ru/launch.json b/src/i18n/locales/ru/launch.json
index 65bd57b8c..7e5230fe0 100644
--- a/src/i18n/locales/ru/launch.json
+++ b/src/i18n/locales/ru/launch.json
@@ -28,6 +28,15 @@
"turnOffMicrophone": "Выключить микрофон",
"selectMicToEnable": "Выберите микрофон для записи звука",
"noMicrophonesFound": "Нет доступных микрофонов",
+ "noiseSuppression": "Шумоподавление",
+ "noiseSuppressionRnnoise": "RNNoise - Лучшее качество",
+ "noiseSuppressionRnnoiseDescription": "Лучше удаляет фоновый шум, но сильнее нагружает процессор.",
+ "noiseSuppressionSpeex": "Speex - Меньше нагрузка на процессор",
+ "noiseSuppressionSpeexDescription": "Меньше нагрузка на процессор, но ниже качество голоса.",
+ "noiseSuppressionDisabled": "Отключено",
+ "noiseSuppressionDisabledDescription": "Записывает исходный звук с микрофона.",
+ "noiseSuppressionUnavailableWarning": "Шумоподавление недоступно на этом устройстве. Запись продолжится без него.",
+ "noiseSuppressionFallbackWarning": "Шумоподавление переключилось на {{mode}}.",
"webcam": "Веб-камера",
"turnOffWebcam": "Выключить веб-камеру",
"hideFloatingWebcamPreview": "Скрыть предпросмотр",
@@ -77,4 +86,4 @@
"failedToStart": "Не удалось начать запись: {{error}}",
"failedToStartGeneric": "Не удалось начать запись"
}
-}
\ No newline at end of file
+}
diff --git a/src/i18n/locales/zh-CN/launch.json b/src/i18n/locales/zh-CN/launch.json
index 164c02afe..76c0ea89d 100644
--- a/src/i18n/locales/zh-CN/launch.json
+++ b/src/i18n/locales/zh-CN/launch.json
@@ -28,6 +28,15 @@
"turnOffMicrophone": "关闭麦克风",
"selectMicToEnable": "选择一个麦克风以启用",
"noMicrophonesFound": "未找到麦克风",
+ "noiseSuppression": "噪声抑制",
+ "noiseSuppressionRnnoise": "RNNoise - 更高质量",
+ "noiseSuppressionRnnoiseDescription": "更好地去除背景噪声,但 CPU 使用率更高。",
+ "noiseSuppressionSpeex": "Speex - 更低 CPU 使用率",
+ "noiseSuppressionSpeexDescription": "CPU 使用率更低,但语音质量会降低。",
+ "noiseSuppressionDisabled": "已禁用",
+ "noiseSuppressionDisabledDescription": "录制原始麦克风音频。",
+ "noiseSuppressionUnavailableWarning": "此设备不支持噪声抑制。录制将继续,但不会使用噪声抑制。",
+ "noiseSuppressionFallbackWarning": "噪声抑制已回退到 {{mode}}。",
"webcam": "摄像头",
"turnOffWebcam": "关闭摄像头",
"hideFloatingWebcamPreview": "隐藏悬浮预览",
diff --git a/src/i18n/locales/zh-TW/launch.json b/src/i18n/locales/zh-TW/launch.json
index 12ec494d0..bf830d70e 100644
--- a/src/i18n/locales/zh-TW/launch.json
+++ b/src/i18n/locales/zh-TW/launch.json
@@ -28,6 +28,15 @@
"turnOffMicrophone": "關閉麥克風",
"selectMicToEnable": "選擇要啟用的麥克風",
"noMicrophonesFound": "找不到麥克風",
+ "noiseSuppression": "噪音抑制",
+ "noiseSuppressionRnnoise": "RNNoise - 較佳品質",
+ "noiseSuppressionRnnoiseDescription": "更有效移除背景噪音,但 CPU 使用率較高。",
+ "noiseSuppressionSpeex": "Speex - 較低 CPU 使用率",
+ "noiseSuppressionSpeexDescription": "CPU 使用率較低,但語音品質會下降。",
+ "noiseSuppressionDisabled": "已停用",
+ "noiseSuppressionDisabledDescription": "錄製原始麥克風音訊。",
+ "noiseSuppressionUnavailableWarning": "此裝置無法使用噪音抑制。錄製將繼續,但不會使用噪音抑制。",
+ "noiseSuppressionFallbackWarning": "噪音抑制已改用 {{mode}}。",
"webcam": "網路攝影機",
"turnOffWebcam": "關閉網路攝影機",
"hideFloatingWebcamPreview": "隱藏浮動預覽",
@@ -44,15 +53,15 @@
"cancel": "取消",
"more": "更多",
"update": {
- "update": "更新",
- "updated": "已更新",
- "idleTitle": "檢查更新。",
- "checkingTitle": "正在檢查更新...",
- "downloadingTitle": "正在下載更新...",
- "errorTitle": "檢查更新失敗,點一下重試。",
- "upToDateTitle": "Recordly {{version}} 已是最新版本。",
- "availableTitle": "Recordly {{version}} 已推出新版本。",
- "availableGenericTitle": "有可用更新。"
+ "update": "更新",
+ "updated": "已更新",
+ "idleTitle": "檢查更新。",
+ "checkingTitle": "正在檢查更新...",
+ "downloadingTitle": "正在下載更新...",
+ "errorTitle": "檢查更新失敗,點一下重試。",
+ "upToDateTitle": "Recordly {{version}} 已是最新版本。",
+ "availableTitle": "Recordly {{version}} 已推出新版本。",
+ "availableGenericTitle": "有可用更新。"
}
},
"sourceSelector": {
diff --git a/src/lib/audio/noiseSuppressedStream.test.ts b/src/lib/audio/noiseSuppressedStream.test.ts
new file mode 100644
index 000000000..c48ef2aa4
--- /dev/null
+++ b/src/lib/audio/noiseSuppressedStream.test.ts
@@ -0,0 +1,134 @@
+import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
+import { createNoiseSuppressedMicrophoneStream } from "./noiseSuppressedStream";
+
+vi.mock("./noiseSuppressionWorklet.ts?worker&url", () => ({
+ default: "mock-noise-suppression-worklet-url",
+}));
+
+type FakeMediaStreamTrack = MediaStreamTrack & {
+ stop: ReturnType;
+};
+
+type FakeMediaStream = MediaStream & {
+ getTracks: ReturnType FakeMediaStreamTrack[]>>;
+};
+
+type FakeAudioNode = {
+ connect: ReturnType;
+ disconnect: ReturnType;
+};
+
+const createdContexts: FakeAudioContext[] = [];
+const createdProcessors: FakeAudioWorkletNode[] = [];
+let addModule = vi.fn();
+
+class FakeAudioContext {
+ readonly audioWorklet = {
+ addModule: (...args: unknown[]) => addModule(...args),
+ };
+ readonly close = vi.fn().mockResolvedValue(undefined);
+ readonly sampleRate = 48000;
+ readonly sourceNode = createFakeAudioNode();
+ readonly destinationNode = {
+ ...createFakeAudioNode(),
+ stream: createFakeMediaStream().stream,
+ };
+
+ constructor() {
+ createdContexts.push(this);
+ }
+
+ createMediaStreamSource() {
+ return this.sourceNode;
+ }
+
+ createMediaStreamDestination() {
+ return this.destinationNode;
+ }
+}
+
+class FakeAudioWorkletNode {
+ readonly connect = vi.fn();
+ readonly disconnect = vi.fn();
+ readonly port = {
+ onmessage: null as ((event: MessageEvent) => void) | null,
+ postMessage: vi.fn(),
+ };
+
+ constructor() {
+ createdProcessors.push(this);
+ }
+}
+
+function createFakeAudioNode(): FakeAudioNode {
+ return {
+ connect: vi.fn(),
+ disconnect: vi.fn(),
+ };
+}
+
+function createFakeMediaStream(trackCount = 1) {
+ const tracks = Array.from({ length: trackCount }, () => ({
+ stop: vi.fn(),
+ })) as FakeMediaStreamTrack[];
+ const stream = {
+ getTracks: vi.fn(() => tracks),
+ } as FakeMediaStream;
+
+ return { stream, tracks };
+}
+
+beforeEach(() => {
+ addModule = vi.fn().mockResolvedValue(undefined);
+ createdContexts.length = 0;
+ createdProcessors.length = 0;
+ vi.stubGlobal("AudioContext", FakeAudioContext);
+ vi.stubGlobal("AudioWorkletNode", FakeAudioWorkletNode);
+});
+
+afterEach(() => {
+ vi.useRealTimers();
+ vi.unstubAllGlobals();
+});
+
+describe("createNoiseSuppressedMicrophoneStream", () => {
+ it("stops source tracks and closes the AudioContext when loading the worklet fails", async () => {
+ const { stream, tracks } = createFakeMediaStream(2);
+ addModule.mockRejectedValue(new Error("worklet blocked"));
+
+ await expect(
+ createNoiseSuppressedMicrophoneStream({
+ sourceStream: stream,
+ mode: "rnnoise",
+ }),
+ ).rejects.toThrow("worklet blocked");
+
+ expect(tracks[0].stop).toHaveBeenCalledTimes(1);
+ expect(tracks[1].stop).toHaveBeenCalledTimes(1);
+ expect(createdContexts[0].sourceNode.disconnect).toHaveBeenCalledTimes(1);
+ expect(createdContexts[0].close).toHaveBeenCalledTimes(1);
+ });
+
+ it("times out a nonresponsive worklet and runs setup cleanup", async () => {
+ vi.useFakeTimers();
+ const { stream, tracks } = createFakeMediaStream();
+
+ const setup = createNoiseSuppressedMicrophoneStream({
+ sourceStream: stream,
+ mode: "rnnoise",
+ });
+ const rejection = expect(setup).rejects.toThrow(
+ "Noise suppression worklet initialization timed out.",
+ );
+ await vi.advanceTimersByTimeAsync(5000);
+
+ await rejection;
+ expect(createdProcessors[0].port.postMessage).toHaveBeenCalledWith(
+ expect.objectContaining({ type: "init" }),
+ );
+ expect(createdProcessors[0].port.postMessage).toHaveBeenCalledWith({ type: "destroy" });
+ expect(tracks[0].stop).toHaveBeenCalledTimes(1);
+ expect(createdContexts[0].sourceNode.disconnect).toHaveBeenCalledTimes(1);
+ expect(createdContexts[0].close).toHaveBeenCalledTimes(1);
+ });
+});
diff --git a/src/lib/audio/noiseSuppressedStream.ts b/src/lib/audio/noiseSuppressedStream.ts
new file mode 100644
index 000000000..6f454b394
--- /dev/null
+++ b/src/lib/audio/noiseSuppressedStream.ts
@@ -0,0 +1,246 @@
+import {
+ type NoiseSuppressionMode,
+ type NoiseSuppressionSelection,
+ normalizeNoiseSuppressionMode,
+} from "./noiseSuppression";
+import noiseSuppressionWorkletUrl from "./noiseSuppressionWorklet.ts?worker&url";
+
+const PROCESSOR_BUFFER_SIZE = 1024;
+const DEFAULT_SAMPLE_RATE = 48000;
+const FRAME_BUDGET_MS = 12;
+const LONG_FRAME_LOG_INTERVAL_MS = 5000;
+const WORKLET_READY_TIMEOUT_MS = 5000;
+
+export type NoiseSuppressedMicrophoneStream = {
+ stream: MediaStream;
+ activeMode: NoiseSuppressionMode;
+ requestedMode: NoiseSuppressionMode;
+ warnings: string[];
+ destroy(): void;
+};
+
+export type NoiseSuppressionWarning =
+ | {
+ key: "recording.noiseSuppressionUnavailableWarning";
+ }
+ | {
+ key: "recording.noiseSuppressionFallbackWarning";
+ params: { mode: string };
+ };
+
+export async function createNoiseSuppressedMicrophoneStream({
+ sourceStream,
+ mode,
+ onWarning,
+}: {
+ sourceStream: MediaStream;
+ mode: NoiseSuppressionMode;
+ onWarning?: (warning: NoiseSuppressionWarning) => void;
+}): Promise {
+ const requestedMode = normalizeNoiseSuppressionMode(mode);
+ if (requestedMode === "disabled") {
+ console.info("[NoiseSuppression] Microphone stream passthrough selected.");
+ return {
+ stream: sourceStream,
+ activeMode: "disabled",
+ requestedMode,
+ warnings: [],
+ destroy: () => {
+ sourceStream.getTracks().forEach((track) => track.stop());
+ console.info("[NoiseSuppression] Passthrough stream cleanup complete.");
+ },
+ };
+ }
+
+ const context = new AudioContext({ sampleRate: DEFAULT_SAMPLE_RATE });
+ const source = context.createMediaStreamSource(sourceStream);
+ const destination = context.createMediaStreamDestination();
+ const sampleRate = context.sampleRate || DEFAULT_SAMPLE_RATE;
+ let processor: AudioWorkletNode | null = null;
+ let selection: NoiseSuppressionWorkletReadySelection;
+
+ try {
+ await context.audioWorklet.addModule(noiseSuppressionWorkletUrl);
+ processor = new AudioWorkletNode(context, "noise-suppression-processor", {
+ numberOfInputs: 1,
+ numberOfOutputs: 1,
+ outputChannelCount: [1],
+ });
+ selection = await initializeNoiseSuppressionWorklet(processor, {
+ mode: requestedMode,
+ frameSize: PROCESSOR_BUFFER_SIZE,
+ sampleRate,
+ frameBudgetMs: FRAME_BUDGET_MS,
+ longFrameLogIntervalMs: LONG_FRAME_LOG_INTERVAL_MS,
+ });
+ source.connect(processor);
+ processor.connect(destination);
+ } catch (error) {
+ cleanupFailedNoiseSuppressionSetup({
+ context,
+ destinationStream: destination.stream,
+ processor,
+ source,
+ sourceStream,
+ });
+ throw error;
+ }
+
+ if (!processor) {
+ throw new Error("Noise suppression worklet processor was not created.");
+ }
+ const activeProcessor = processor;
+
+ reportWarnings(selection, onWarning);
+
+ console.info("[NoiseSuppression] Microphone stream processing active.", {
+ requestedMode,
+ activeMode: selection.activeMode,
+ frameSize: PROCESSOR_BUFFER_SIZE,
+ sampleRate,
+ });
+
+ return {
+ stream: destination.stream,
+ activeMode: selection.activeMode,
+ requestedMode,
+ warnings: selection.warnings,
+ destroy: () => {
+ activeProcessor.port.postMessage({ type: "destroy" });
+ activeProcessor.disconnect();
+ source.disconnect();
+ sourceStream.getTracks().forEach((track) => track.stop());
+ destination.stream.getTracks().forEach((track) => track.stop());
+ context.close().catch(() => undefined);
+ console.info("[NoiseSuppression] Microphone stream cleanup complete.", {
+ requestedMode,
+ activeMode: selection.activeMode,
+ });
+ },
+ };
+}
+
+type NoiseSuppressionWorkletReadySelection = Omit;
+
+async function initializeNoiseSuppressionWorklet(
+ processor: AudioWorkletNode,
+ config: {
+ mode: NoiseSuppressionMode;
+ frameSize: number;
+ frameBudgetMs: number;
+ longFrameLogIntervalMs: number;
+ sampleRate: number;
+ },
+): Promise {
+ return new Promise((resolve, reject) => {
+ let settled = false;
+ const readyTimeout = setTimeout(() => {
+ settled = true;
+ reject(new Error("Noise suppression worklet initialization timed out."));
+ }, WORKLET_READY_TIMEOUT_MS);
+
+ const settle = (
+ callback: () => NoiseSuppressionWorkletReadySelection,
+ complete: (selection: NoiseSuppressionWorkletReadySelection) => void,
+ ) => {
+ if (settled) {
+ return;
+ }
+ settled = true;
+ clearTimeout(readyTimeout);
+ complete(callback());
+ };
+
+ processor.port.onmessage = (event) => {
+ const message = event.data;
+ if (message.type === "ready") {
+ settle(
+ () => ({
+ requestedMode: message.requestedMode,
+ activeMode: message.activeMode,
+ warnings: message.warnings,
+ }),
+ resolve,
+ );
+ return;
+ }
+ if (message.type === "error") {
+ if (!settled) {
+ settled = true;
+ clearTimeout(readyTimeout);
+ reject(new Error(message.error));
+ }
+ return;
+ }
+ if (message.type === "processing-error") {
+ console.warn("[NoiseSuppression] Processing error; passed through frame.", {
+ activeMode: message.activeMode,
+ droppedFrames: message.droppedFrames,
+ error: message.error,
+ });
+ return;
+ }
+ if (message.type === "long-frame") {
+ console.warn("[NoiseSuppression] Processing exceeded real-time budget.", {
+ activeMode: message.activeMode,
+ elapsedMs: message.elapsedMs,
+ frameSize: message.frameSize,
+ sampleRate: message.sampleRate,
+ });
+ }
+ };
+ processor.port.postMessage({ type: "init", ...config });
+ });
+}
+
+function cleanupFailedNoiseSuppressionSetup({
+ context,
+ destinationStream,
+ processor,
+ source,
+ sourceStream,
+}: {
+ context: AudioContext;
+ destinationStream: MediaStream;
+ processor: AudioWorkletNode | null;
+ source: MediaStreamAudioSourceNode;
+ sourceStream: MediaStream;
+}) {
+ try {
+ processor?.port.postMessage({ type: "destroy" });
+ } catch {
+ // Best effort: preserve the original setup error.
+ }
+ try {
+ processor?.disconnect();
+ } catch {
+ // Best effort: preserve the original setup error.
+ }
+ try {
+ source.disconnect();
+ } catch {
+ // Best effort: preserve the original setup error.
+ }
+ sourceStream.getTracks().forEach((track) => track.stop());
+ destinationStream.getTracks().forEach((track) => track.stop());
+ context.close().catch(() => undefined);
+}
+
+function reportWarnings(
+ selection: Pick,
+ onWarning?: (warning: NoiseSuppressionWarning) => void,
+): NoiseSuppressionWarning | null {
+ if (selection.warnings.length === 0) {
+ return null;
+ }
+
+ const warning: NoiseSuppressionWarning =
+ selection.activeMode === "disabled"
+ ? { key: "recording.noiseSuppressionUnavailableWarning" }
+ : {
+ key: "recording.noiseSuppressionFallbackWarning",
+ params: { mode: selection.activeMode.toUpperCase() },
+ };
+ onWarning?.(warning);
+ return warning;
+}
diff --git a/src/lib/audio/noiseSuppression.test.ts b/src/lib/audio/noiseSuppression.test.ts
new file mode 100644
index 000000000..f7d2a747d
--- /dev/null
+++ b/src/lib/audio/noiseSuppression.test.ts
@@ -0,0 +1,185 @@
+import { beforeEach, describe, expect, it, vi } from "vitest";
+import {
+ createNoiseSuppressorWithFallback,
+ DEFAULT_NOISE_SUPPRESSION_MODE,
+ type NoiseSuppressor,
+ normalizeNoiseSuppressionMode,
+ RnnoiseNoiseSuppressor,
+} from "./noiseSuppression";
+
+const rnnoiseLoad = vi.hoisted(() => vi.fn());
+
+vi.mock("@shiguredo/rnnoise-wasm", () => ({
+ Rnnoise: {
+ load: rnnoiseLoad,
+ },
+}));
+
+function createMockSuppressor({
+ initialize = vi.fn().mockResolvedValue(undefined),
+ processAudioFrame = vi.fn((frame: Float32Array) => frame),
+ destroy = vi.fn(),
+}: Partial = {}): NoiseSuppressor {
+ return {
+ initialize,
+ processAudioFrame,
+ destroy,
+ };
+}
+
+beforeEach(() => {
+ rnnoiseLoad.mockReset();
+});
+
+describe("normalizeNoiseSuppressionMode", () => {
+ it("defaults new and invalid values to RNNoise", () => {
+ expect(normalizeNoiseSuppressionMode()).toBe(DEFAULT_NOISE_SUPPRESSION_MODE);
+ expect(normalizeNoiseSuppressionMode("unknown")).toBe(DEFAULT_NOISE_SUPPRESSION_MODE);
+ });
+
+ it("normalizes supported persisted modes", () => {
+ expect(normalizeNoiseSuppressionMode(" RNNOISE ")).toBe("rnnoise");
+ expect(normalizeNoiseSuppressionMode("speex")).toBe("speex");
+ expect(normalizeNoiseSuppressionMode("disabled")).toBe("disabled");
+ });
+});
+
+describe("RnnoiseNoiseSuppressor", () => {
+ it("buffers incomplete RNNoise frames across calls without returning raw remainder samples", async () => {
+ const processFrame = vi.fn((frame: Float32Array) => {
+ for (let index = 0; index < frame.length; index += 1) {
+ frame[index] += 100;
+ }
+ });
+ const destroy = vi.fn();
+ rnnoiseLoad.mockResolvedValue({
+ frameSize: 4,
+ createDenoiseState: () => ({
+ processFrame,
+ destroy,
+ }),
+ });
+
+ const suppressor = new RnnoiseNoiseSuppressor();
+ await suppressor.initialize();
+
+ const firstOutput = suppressor.processAudioFrame(new Float32Array([1, 2, 3, 4, 5, 6]));
+ expect(Array.from(firstOutput)).toEqual([101, 102, 103, 104, 0, 0]);
+ expect(processFrame).toHaveBeenCalledTimes(1);
+
+ const secondOutput = suppressor.processAudioFrame(new Float32Array([7, 8, 9, 10, 11, 12]));
+ expect(Array.from(secondOutput)).toEqual([105, 106, 107, 108, 109, 110]);
+ expect(processFrame).toHaveBeenCalledTimes(3);
+
+ const thirdOutput = suppressor.processAudioFrame(new Float32Array([13, 14]));
+ expect(Array.from(thirdOutput)).toEqual([111, 112]);
+ expect(processFrame).toHaveBeenCalledTimes(3);
+
+ const fourthOutput = suppressor.processAudioFrame(new Float32Array([15, 16]));
+ expect(Array.from(fourthOutput)).toEqual([113, 114]);
+ expect(processFrame).toHaveBeenCalledTimes(4);
+
+ suppressor.destroy();
+ expect(destroy).toHaveBeenCalledTimes(1);
+ });
+});
+
+describe("createNoiseSuppressorWithFallback", () => {
+ it("initializes RNNoise when requested", async () => {
+ const rnnoise = createMockSuppressor();
+ const selection = await createNoiseSuppressorWithFallback({
+ mode: "rnnoise",
+ frameSize: 1024,
+ sampleRate: 48000,
+ deps: {
+ createRnnoise: () => rnnoise,
+ },
+ });
+
+ expect(selection.activeMode).toBe("rnnoise");
+ expect(rnnoise.initialize).toHaveBeenCalledTimes(1);
+ });
+
+ it("initializes Speex when requested", async () => {
+ const speex = createMockSuppressor();
+ const selection = await createNoiseSuppressorWithFallback({
+ mode: "speex",
+ frameSize: 1024,
+ sampleRate: 48000,
+ deps: {
+ createSpeex: () => speex,
+ },
+ });
+
+ expect(selection.activeMode).toBe("speex");
+ expect(speex.initialize).toHaveBeenCalledTimes(1);
+ });
+
+ it("falls back from RNNoise to Speex", async () => {
+ const rnnoise = createMockSuppressor({
+ initialize: vi.fn().mockRejectedValue(new Error("wasm unavailable")),
+ });
+ const speex = createMockSuppressor();
+
+ const selection = await createNoiseSuppressorWithFallback({
+ mode: "rnnoise",
+ frameSize: 1024,
+ sampleRate: 48000,
+ deps: {
+ createRnnoise: () => rnnoise,
+ createSpeex: () => speex,
+ },
+ });
+
+ expect(selection.activeMode).toBe("speex");
+ expect(selection.warnings).toHaveLength(1);
+ expect(rnnoise.destroy).toHaveBeenCalledTimes(1);
+ expect(speex.initialize).toHaveBeenCalledTimes(1);
+ });
+
+ it("falls back to disabled when both algorithms fail", async () => {
+ const disabled = createMockSuppressor();
+
+ const selection = await createNoiseSuppressorWithFallback({
+ mode: "rnnoise",
+ frameSize: 1024,
+ sampleRate: 48000,
+ deps: {
+ createRnnoise: () =>
+ createMockSuppressor({
+ initialize: vi.fn().mockRejectedValue(new Error("rnnoise failed")),
+ }),
+ createSpeex: () =>
+ createMockSuppressor({
+ initialize: vi.fn().mockRejectedValue(new Error("speex failed")),
+ }),
+ createDisabled: () => disabled,
+ },
+ });
+
+ expect(selection.activeMode).toBe("disabled");
+ expect(selection.warnings).toHaveLength(2);
+ expect(disabled.initialize).toHaveBeenCalledTimes(1);
+ });
+
+ it("processes frames through the active suppressor and cleans up", async () => {
+ const frame = new Float32Array([0.1, -0.1]);
+ const processed = new Float32Array([0.05, -0.05]);
+ const speex = createMockSuppressor({
+ processAudioFrame: vi.fn(() => processed),
+ });
+
+ const selection = await createNoiseSuppressorWithFallback({
+ mode: "speex",
+ frameSize: 2,
+ sampleRate: 48000,
+ deps: {
+ createSpeex: () => speex,
+ },
+ });
+
+ expect(selection.suppressor.processAudioFrame(frame)).toBe(processed);
+ selection.suppressor.destroy();
+ expect(speex.destroy).toHaveBeenCalledTimes(1);
+ });
+});
diff --git a/src/lib/audio/noiseSuppression.ts b/src/lib/audio/noiseSuppression.ts
new file mode 100644
index 000000000..be69e0d52
--- /dev/null
+++ b/src/lib/audio/noiseSuppression.ts
@@ -0,0 +1,226 @@
+import { loadSpeexModule, SpeexPreprocessor } from "@sapphi-red/speex-preprocess-wasm";
+import { Rnnoise } from "@shiguredo/rnnoise-wasm";
+
+export type NoiseSuppressionMode = "rnnoise" | "speex" | "disabled";
+
+export interface NoiseSuppressor {
+ initialize(): Promise;
+ processAudioFrame(frame: Float32Array): Float32Array;
+ destroy(): void;
+}
+
+export type NoiseSuppressionSelection = {
+ requestedMode: NoiseSuppressionMode;
+ activeMode: NoiseSuppressionMode;
+ suppressor: NoiseSuppressor;
+ warnings: string[];
+};
+
+export const DEFAULT_NOISE_SUPPRESSION_MODE: NoiseSuppressionMode = "rnnoise";
+
+export const NOISE_SUPPRESSION_MODES = new Set([
+ "rnnoise",
+ "speex",
+ "disabled",
+]);
+
+export function normalizeNoiseSuppressionMode(value?: string | null): NoiseSuppressionMode {
+ const normalized = value?.trim().toLowerCase();
+ return normalized && NOISE_SUPPRESSION_MODES.has(normalized as NoiseSuppressionMode)
+ ? (normalized as NoiseSuppressionMode)
+ : DEFAULT_NOISE_SUPPRESSION_MODE;
+}
+
+export class DisabledNoiseSuppressor implements NoiseSuppressor {
+ async initialize() {
+ console.info("[NoiseSuppression] Disabled.");
+ }
+
+ processAudioFrame(frame: Float32Array) {
+ return frame;
+ }
+
+ destroy() {
+ console.info("[NoiseSuppression] Disabled cleanup complete.");
+ }
+}
+
+export class RnnoiseNoiseSuppressor implements NoiseSuppressor {
+ private rnnoise: Awaited> | null = null;
+ private state: ReturnType<
+ Awaited>["createDenoiseState"]
+ > | null = null;
+ private remainder: Float32Array = new Float32Array(0);
+ private queuedOutput: Float32Array = new Float32Array(0);
+
+ async initialize() {
+ console.info("[NoiseSuppression] Initializing RNNoise.");
+ this.rnnoise = await Rnnoise.load();
+ this.state = this.rnnoise.createDenoiseState();
+ console.info("[NoiseSuppression] RNNoise initialized.", {
+ frameSize: this.rnnoise.frameSize,
+ });
+ }
+
+ processAudioFrame(frame: Float32Array) {
+ if (!this.rnnoise || !this.state) {
+ throw new Error("RNNoise suppressor has not been initialized.");
+ }
+
+ const frameSize = this.rnnoise.frameSize;
+ const pendingInput = new Float32Array(this.remainder.length + frame.length);
+ pendingInput.set(this.remainder);
+ pendingInput.set(frame, this.remainder.length);
+
+ const completeLength = pendingInput.length - (pendingInput.length % frameSize);
+ for (let offset = 0; offset < completeLength; offset += frameSize) {
+ this.state.processFrame(pendingInput.subarray(offset, offset + frameSize));
+ }
+
+ this.remainder = pendingInput.slice(completeLength);
+ this.queuedOutput = appendFloat32Arrays(
+ this.queuedOutput,
+ pendingInput.subarray(0, completeLength),
+ );
+
+ const output = new Float32Array(frame.length);
+ const outputLength = Math.min(output.length, this.queuedOutput.length);
+ if (outputLength > 0) {
+ output.set(this.queuedOutput.subarray(0, outputLength));
+ this.queuedOutput = this.queuedOutput.slice(outputLength);
+ }
+ return output;
+ }
+
+ destroy() {
+ this.state?.destroy();
+ this.state = null;
+ this.rnnoise = null;
+ this.remainder = new Float32Array(0);
+ this.queuedOutput = new Float32Array(0);
+ console.info("[NoiseSuppression] RNNoise cleanup complete.");
+ }
+}
+
+function appendFloat32Arrays(left: Float32Array, right: Float32Array) {
+ if (left.length === 0) {
+ return right.slice();
+ }
+ if (right.length === 0) {
+ return left;
+ }
+
+ const combined = new Float32Array(left.length + right.length);
+ combined.set(left);
+ combined.set(right, left.length);
+ return combined;
+}
+
+export class SpeexNoiseSuppressor implements NoiseSuppressor {
+ private preprocessor: SpeexPreprocessor | null = null;
+
+ constructor(
+ private readonly frameSize: number,
+ private readonly sampleRate: number,
+ ) {}
+
+ async initialize() {
+ console.info("[NoiseSuppression] Initializing Speex.", {
+ frameSize: this.frameSize,
+ sampleRate: this.sampleRate,
+ });
+ const module = await loadSpeexModule();
+ this.preprocessor = new SpeexPreprocessor(module, this.frameSize, this.sampleRate);
+ this.preprocessor.denoise = true;
+ this.preprocessor.noiseSuppress = -24;
+ this.preprocessor.agc = false;
+ this.preprocessor.vad = false;
+ console.info("[NoiseSuppression] Speex initialized.");
+ }
+
+ processAudioFrame(frame: Float32Array) {
+ if (!this.preprocessor) {
+ throw new Error("Speex suppressor has not been initialized.");
+ }
+
+ if (frame.length === this.frameSize) {
+ this.preprocessor.process(frame);
+ return frame;
+ }
+
+ for (let offset = 0; offset + this.frameSize <= frame.length; offset += this.frameSize) {
+ this.preprocessor.process(frame.subarray(offset, offset + this.frameSize));
+ }
+ return frame;
+ }
+
+ destroy() {
+ this.preprocessor?.destroy();
+ this.preprocessor = null;
+ console.info("[NoiseSuppression] Speex cleanup complete.");
+ }
+}
+
+export type NoiseSuppressorFactoryDeps = {
+ createRnnoise?: () => NoiseSuppressor;
+ createSpeex?: () => NoiseSuppressor;
+ createDisabled?: () => NoiseSuppressor;
+};
+
+export async function createNoiseSuppressorWithFallback({
+ mode,
+ frameSize,
+ sampleRate,
+ deps = {},
+}: {
+ mode: NoiseSuppressionMode;
+ frameSize: number;
+ sampleRate: number;
+ deps?: NoiseSuppressorFactoryDeps;
+}): Promise {
+ const warnings: string[] = [];
+ const createDisabled = deps.createDisabled ?? (() => new DisabledNoiseSuppressor());
+ const candidates: NoiseSuppressionMode[] =
+ mode === "disabled" ? ["disabled"] : mode === "rnnoise" ? ["rnnoise", "speex"] : ["speex"];
+
+ for (const candidate of candidates) {
+ const suppressor =
+ candidate === "rnnoise"
+ ? (deps.createRnnoise?.() ?? new RnnoiseNoiseSuppressor())
+ : candidate === "speex"
+ ? (deps.createSpeex?.() ?? new SpeexNoiseSuppressor(frameSize, sampleRate))
+ : createDisabled();
+ try {
+ await suppressor.initialize();
+ if (candidate !== mode) {
+ console.warn("[NoiseSuppression] Falling back.", {
+ requestedMode: mode,
+ activeMode: candidate,
+ });
+ }
+ return {
+ requestedMode: mode,
+ activeMode: candidate,
+ suppressor,
+ warnings,
+ };
+ } catch (error) {
+ suppressor.destroy();
+ const warning = `Failed to initialize ${candidate}: ${
+ error instanceof Error ? error.message : String(error)
+ }`;
+ warnings.push(warning);
+ console.warn("[NoiseSuppression]", warning);
+ }
+ }
+
+ const suppressor = createDisabled();
+ await suppressor.initialize();
+ console.warn("[NoiseSuppression] All algorithms failed; recording without noise suppression.");
+ return {
+ requestedMode: mode,
+ activeMode: "disabled",
+ suppressor,
+ warnings,
+ };
+}
diff --git a/src/lib/audio/noiseSuppressionWorklet.ts b/src/lib/audio/noiseSuppressionWorklet.ts
new file mode 100644
index 000000000..ea5e765a1
--- /dev/null
+++ b/src/lib/audio/noiseSuppressionWorklet.ts
@@ -0,0 +1,198 @@
+import {
+ createNoiseSuppressorWithFallback,
+ type NoiseSuppressionMode,
+ type NoiseSuppressionSelection,
+} from "./noiseSuppression";
+
+declare const AudioWorkletProcessor: {
+ new (): AudioWorkletProcessor;
+};
+declare function registerProcessor(name: string, processorCtor: typeof AudioWorkletProcessor): void;
+declare const currentTime: number;
+
+type AudioWorkletProcessor = {
+ readonly port: MessagePort;
+ process(inputs: Float32Array[][], outputs: Float32Array[][]): boolean;
+};
+
+type NoiseSuppressionWorkletMessage =
+ | {
+ type: "init";
+ mode: NoiseSuppressionMode;
+ frameSize: number;
+ frameBudgetMs: number;
+ longFrameLogIntervalMs: number;
+ sampleRate: number;
+ }
+ | { type: "destroy" };
+
+class NoiseSuppressionProcessor extends AudioWorkletProcessor {
+ private droppedFrames = 0;
+ private frameBudgetMs = 12;
+ private frameSize = 1024;
+ private inputBuffer = new Float32Array(1024);
+ private inputBufferOffset = 0;
+ private lastLongFrameLogAt = 0;
+ private longFrameLogIntervalMs = 5000;
+ private outputBuffer: Float32Array = new Float32Array(0);
+ private sampleRate = 48000;
+ private selection: NoiseSuppressionSelection | null = null;
+ private scratchFrame = new Float32Array(1024);
+
+ constructor() {
+ super();
+ this.port.onmessage = (event: MessageEvent) => {
+ if (event.data.type === "init") {
+ void this.initialize(event.data);
+ return;
+ }
+ this.destroy();
+ };
+ }
+
+ private async initialize({
+ mode,
+ frameSize,
+ frameBudgetMs,
+ longFrameLogIntervalMs,
+ sampleRate,
+ }: Extract) {
+ this.frameSize = frameSize;
+ this.frameBudgetMs = frameBudgetMs;
+ this.longFrameLogIntervalMs = longFrameLogIntervalMs;
+ this.sampleRate = sampleRate;
+ this.inputBuffer = new Float32Array(frameSize);
+ this.inputBufferOffset = 0;
+ this.outputBuffer = new Float32Array(0);
+ this.scratchFrame = new Float32Array(frameSize);
+
+ try {
+ this.selection = await createNoiseSuppressorWithFallback({
+ mode,
+ frameSize,
+ sampleRate,
+ });
+ this.port.postMessage({
+ type: "ready",
+ activeMode: this.selection.activeMode,
+ requestedMode: this.selection.requestedMode,
+ warnings: this.selection.warnings,
+ });
+ } catch (error) {
+ this.port.postMessage({
+ type: "error",
+ error: error instanceof Error ? error.message : String(error),
+ });
+ }
+ }
+
+ process(inputs: Float32Array[][], outputs: Float32Array[][]) {
+ const input = inputs[0]?.[0];
+ const output = outputs[0]?.[0];
+ if (!output) {
+ return true;
+ }
+
+ if (!input || !this.selection) {
+ output.fill(0);
+ return true;
+ }
+
+ let inputOffset = 0;
+ while (inputOffset < input.length) {
+ const writableLength = Math.min(
+ this.frameSize - this.inputBufferOffset,
+ input.length - inputOffset,
+ );
+ this.inputBuffer.set(
+ input.subarray(inputOffset, inputOffset + writableLength),
+ this.inputBufferOffset,
+ );
+ this.inputBufferOffset += writableLength;
+ inputOffset += writableLength;
+
+ if (this.inputBufferOffset === this.frameSize) {
+ this.processBufferedFrame();
+ this.inputBufferOffset = 0;
+ }
+ }
+
+ const readableLength = Math.min(output.length, this.outputBuffer.length);
+ if (readableLength > 0) {
+ output.set(this.outputBuffer.subarray(0, readableLength));
+ this.outputBuffer = this.outputBuffer.slice(readableLength);
+ }
+ if (readableLength < output.length) {
+ output.fill(0, readableLength);
+ }
+
+ return true;
+ }
+
+ private processBufferedFrame() {
+ if (!this.selection) {
+ return;
+ }
+
+ const startedAt = getNowMs();
+ try {
+ this.scratchFrame.set(this.inputBuffer);
+ this.outputBuffer = appendFloat32Arrays(
+ this.outputBuffer,
+ this.selection.suppressor.processAudioFrame(this.scratchFrame),
+ );
+ } catch (error) {
+ this.droppedFrames += 1;
+ this.outputBuffer = appendFloat32Arrays(this.outputBuffer, this.inputBuffer);
+ this.port.postMessage({
+ type: "processing-error",
+ activeMode: this.selection.activeMode,
+ droppedFrames: this.droppedFrames,
+ error: error instanceof Error ? error.message : String(error),
+ });
+ }
+
+ const now = getNowMs();
+ const elapsedMs = now - startedAt;
+ if (
+ elapsedMs > this.frameBudgetMs &&
+ now - this.lastLongFrameLogAt > this.longFrameLogIntervalMs
+ ) {
+ this.lastLongFrameLogAt = now;
+ this.port.postMessage({
+ type: "long-frame",
+ activeMode: this.selection.activeMode,
+ elapsedMs: Number(elapsedMs.toFixed(2)),
+ frameSize: this.frameSize,
+ sampleRate: this.sampleRate,
+ });
+ }
+ }
+
+ private destroy() {
+ this.selection?.suppressor.destroy();
+ this.selection = null;
+ this.outputBuffer = new Float32Array(0);
+ this.inputBufferOffset = 0;
+ }
+}
+
+function appendFloat32Arrays(left: Float32Array, right: Float32Array) {
+ if (left.length === 0) {
+ return right.slice();
+ }
+ if (right.length === 0) {
+ return left;
+ }
+
+ const combined = new Float32Array(left.length + right.length);
+ combined.set(left);
+ combined.set(right, left.length);
+ return combined;
+}
+
+function getNowMs() {
+ return globalThis.performance?.now() ?? currentTime * 1000;
+}
+
+registerProcessor("noise-suppression-processor", NoiseSuppressionProcessor);