#!/usr/bin/env bash set -euo pipefail log() { echo "[pulse-noise] $*"; } die() { echo "[pulse-noise] ERROR: $*" >&2; exit 1; } if [[ "${EUID:-$(id -u)}" -ne 0 ]]; then die "Run as root (or with sudo)." fi PULSE_DIR="/opt/pulse" REPO_URL="https://github.com/plsechat/pulse-chat.git" IMAGE_TAG="pulse-chat:noise" log "Preparing source in ${PULSE_DIR}..." if [[ -d "${PULSE_DIR}/.git" ]]; then git -C "${PULSE_DIR}" fetch --all --prune git -C "${PULSE_DIR}" checkout . git -C "${PULSE_DIR}" pull --ff-only else rm -rf "${PULSE_DIR}" git clone "${REPO_URL}" "${PULSE_DIR}" fi cd "${PULSE_DIR}" log "Applying noise suppression patch..." cat > /tmp/pulse-noise.patch <<'PATCH' diff --git a/apps/client/package.json b/apps/client/package.json index dca0dec..e3cba92 100644 --- a/apps/client/package.json +++ b/apps/client/package.json @@ -45,6 +45,7 @@ "@tiptap/react": "^3.7.2", "@tiptap/starter-kit": "^3.7.2", "@tiptap/suggestion": "^3.7.2", + "@timephy/rnnoise-wasm": "^1.0.0", "@trpc/client": "^11.6.0", "@types/lodash-es": "^4.17.12", "class-variance-authority": "^0.7.1", diff --git a/apps/client/src/components/devices-provider/index.tsx b/apps/client/src/components/devices-provider/index.tsx index 459d90d..cd3daa3 100644 --- a/apps/client/src/components/devices-provider/index.tsx +++ b/apps/client/src/components/devices-provider/index.tsx @@ -22,6 +22,8 @@ const DEFAULT_DEVICE_SETTINGS: TDeviceSettings = { webcamFramerate: 30, echoCancellation: false, noiseSuppression: false, + noiseSuppressionEnhanced: false, + noiseSuppressionRnnoise: false, autoGainControl: true, shareSystemAudio: false, screenResolution: Resolution['720p'], diff --git a/apps/client/src/components/server-screens/user-settings/devices/index.tsx b/apps/client/src/components/server-screens/user-settings/devices/index.tsx index 516352e..1c07479 100644 --- a/apps/client/src/components/server-screens/user-settings/devices/index.tsx +++ b/apps/client/src/components/server-screens/user-settings/devices/index.tsx @@ -85,6 +85,24 @@ const Devices = memo(() => { /> + + + onChange('noiseSuppressionEnhanced', checked) + } + /> + + + + + onChange('noiseSuppressionRnnoise', checked) + } + /> + + { + const enhanced = !!devices.noiseSuppressionEnhanced; + const rnnoise = !!devices.noiseSuppressionRnnoise; + + const constraints: MediaTrackConstraints = { + deviceId: devices.microphoneId + ? { exact: devices.microphoneId } + : undefined, + autoGainControl: (enhanced || rnnoise) ? true : devices.autoGainControl, + echoCancellation: (enhanced || rnnoise) ? true : devices.echoCancellation, + noiseSuppression: rnnoise ? false : (enhanced ? true : devices.noiseSuppression), + sampleRate: 48000, + channelCount: (enhanced || rnnoise) ? 1 : 2 + }; + + if (enhanced) { + const legacy = constraints as unknown as Record; + legacy.googHighpassFilter = true; + legacy.googNoiseSuppression = true; + legacy.googEchoCancellation = true; + legacy.googAutoGainControl = true; + legacy.googTypingNoiseDetection = true; + } + + return constraints; +}; type AudioVideoRefs = { videoRef: React.RefObject; @@ -194,35 +223,96 @@ const VoiceProvider = memo(({ children }: TVoiceProviderProps) => { resetStats } = useTransportStats(); + type RNNoiseState = { + ctx: AudioContext; + source: MediaStreamAudioSourceNode; + node: AudioWorkletNode; + dest: MediaStreamAudioDestinationNode; + input: MediaStream; + }; + + const rnnoiseRef = useRef(null); + const rawMicStreamRef = useRef(null); + + const cleanupRnnoise = useCallback(() => { + if (!rnnoiseRef.current) return; + try { + rnnoiseRef.current.node.disconnect(); + rnnoiseRef.current.source.disconnect(); + rnnoiseRef.current.dest.disconnect(); + rnnoiseRef.current.ctx.close(); + } catch { + // ignore + } + rnnoiseRef.current = null; + }, []); + + const stopRawMic = useCallback(() => { + rawMicStreamRef.current?.getTracks().forEach((t) => t.stop()); + rawMicStreamRef.current = null; + }, []); + + const applyRnnoise = useCallback(async (input: MediaStream): Promise => { + if (typeof AudioWorkletNode === 'undefined') { + logVoice('RNNoise not supported: AudioWorklet unavailable'); + return input; + } + + cleanupRnnoise(); + + const ctx = new AudioContext({ sampleRate: 48000 }); + await ctx.audioWorklet.addModule(NoiseSuppressorWorklet); + + const source = ctx.createMediaStreamSource(input); + const node = new AudioWorkletNode(ctx, NoiseSuppressorWorklet_Name); + const dest = ctx.createMediaStreamDestination(); + + source.connect(node); + node.connect(dest); + + rnnoiseRef.current = { ctx, source, node, dest, input }; + + return dest.stream; + }, [cleanupRnnoise]); + + const acquireMicStream = useCallback(async (): Promise<{ stream: MediaStream; track: MediaStreamTrack; raw: MediaStream }> => { + const raw = await navigator.mediaDevices.getUserMedia({ + audio: buildMicConstraints(devices), + video: false + }); + + let stream = raw; + if (devices.noiseSuppressionRnnoise) { + try { + stream = await applyRnnoise(raw); + } catch (err) { + logVoice('RNNoise failed, falling back to raw mic', { error: err }); + stream = raw; + } + } + + const track = stream.getAudioTracks()[0]; + return { stream, track, raw }; + }, [devices, applyRnnoise]); + const startMicStream = useCallback(async () => { try { logVoice('Starting microphone stream'); - const stream = await navigator.mediaDevices.getUserMedia({ - audio: { - deviceId: devices.microphoneId - ? { exact: devices.microphoneId } - : undefined, - autoGainControl: devices.autoGainControl, - echoCancellation: devices.echoCancellation, - noiseSuppression: devices.noiseSuppression, - sampleRate: 48000, - channelCount: 2 - }, - video: false - }); + const { stream, track, raw } = await acquireMicStream(); logVoice('Microphone stream obtained', { stream }); setLocalAudioStream(stream); - const audioTrack = stream.getAudioTracks()[0]; + stopRawMic(); + rawMicStreamRef.current = raw; - if (audioTrack) { - logVoice('Obtained audio track', { audioTrack }); + if (track) { + logVoice('Obtained audio track', { audioTrack: track }); localAudioProducer.current = await producerTransport.current?.produce({ - track: audioTrack, + track, appData: { kind: StreamKind.AUDIO } }); @@ -244,7 +334,7 @@ const VoiceProvider = memo(({ children }: TVoiceProviderProps) => { } }); - audioTrack.onended = () => { + track.onended = () => { logVoice('Audio track ended, cleaning up microphone'); localAudioStream?.getAudioTracks().forEach((track) => { @@ -252,6 +342,8 @@ const VoiceProvider = memo(({ children }: TVoiceProviderProps) => { }); localAudioProducer.current?.close(); + stopRawMic(); + cleanupRnnoise(); setLocalAudioStream(undefined); }; } else { @@ -265,10 +357,9 @@ const VoiceProvider = memo(({ children }: TVoiceProviderProps) => { setLocalAudioStream, localAudioProducer, localAudioStream, - devices.microphoneId, - devices.autoGainControl, - devices.echoCancellation, - devices.noiseSuppression + acquireMicStream, + stopRawMic, + cleanupRnnoise ]); const startWebcamStream = useCallback(async () => { @@ -383,25 +474,15 @@ const VoiceProvider = memo(({ children }: TVoiceProviderProps) => { if (sharingSystemAudio && localAudioProducer.current && !localAudioProducer.current.closed) { try { logVoice('macOS: Restoring mic to original settings'); - const newMicStream = await navigator.mediaDevices.getUserMedia({ - audio: { - deviceId: devices.microphoneId - ? { exact: devices.microphoneId } - : undefined, - autoGainControl: devices.autoGainControl, - echoCancellation: devices.echoCancellation, - noiseSuppression: devices.noiseSuppression, - sampleRate: 48000, - channelCount: 2 - }, - video: false - }); + const { stream: newMicStream, raw: newRaw } = await acquireMicStream(); const newMicTrack = newMicStream.getAudioTracks()[0]; if (newMicTrack) { await localAudioProducer.current.replaceTrack({ track: newMicTrack }); localAudioStream?.getAudioTracks().forEach((t) => t.stop()); setLocalAudioStream(newMicStream); + stopRawMic(); + rawMicStreamRef.current = newRaw; logVoice('macOS: Mic restored to original settings'); } } catch (err) { @@ -412,7 +493,18 @@ const VoiceProvider = memo(({ children }: TVoiceProviderProps) => { setLocalScreenShare(undefined); setSharingSystemAudio(false); setRealOutputSinkId(undefined); - }, [localScreenShareStream, setLocalScreenShare, localScreenShareProducer, localScreenShareAudioProducer, sharingSystemAudio, localAudioProducer, localAudioStream, setLocalAudioStream, devices.microphoneId, devices.autoGainControl, devices.echoCancellation, devices.noiseSuppression]); + }, [ + localScreenShareStream, + setLocalScreenShare, + localScreenShareProducer, + localScreenShareAudioProducer, + sharingSystemAudio, + localAudioProducer, + localAudioStream, + setLocalAudioStream, + acquireMicStream, + stopRawMic + ]); const startScreenShareStream = useCallback(async () => { try { @@ -671,21 +763,8 @@ const VoiceProvider = memo(({ children }: TVoiceProviderProps) => { try { logVoice('Reapplying mic settings mid-call'); - const newStream = await navigator.mediaDevices.getUserMedia({ - audio: { - deviceId: devices.microphoneId - ? { exact: devices.microphoneId } - : undefined, - autoGainControl: devices.autoGainControl, - echoCancellation: devices.echoCancellation, - noiseSuppression: devices.noiseSuppression, - sampleRate: 48000, - channelCount: 2 - }, - video: false - }); - - const newTrack = newStream.getAudioTracks()[0]; + const { stream: newStream, track: newTrack, raw: newRaw } = + await acquireMicStream(); if (!newTrack) return; // Stop old tracks @@ -698,6 +777,8 @@ const VoiceProvider = memo(({ children }: TVoiceProviderProps) => { await localAudioProducer.current!.replaceTrack({ track: newTrack }); } + stopRawMic(); + rawMicStreamRef.current = newRaw; setLocalAudioStream(newStream); logVoice('Mic settings reapplied successfully'); } catch (error) { @@ -707,10 +788,8 @@ const VoiceProvider = memo(({ children }: TVoiceProviderProps) => { localAudioProducer, localAudioStream, setLocalAudioStream, - devices.microphoneId, - devices.autoGainControl, - devices.echoCancellation, - devices.noiseSuppression + acquireMicStream, + stopRawMic ]); // Hot-swap webcam track on the existing producer when device settings change @@ -762,13 +841,17 @@ const VoiceProvider = memo(({ children }: TVoiceProviderProps) => { cleanupTransports(); setConnectionStatus(ConnectionStatus.DISCONNECTED); + stopRawMic(); + cleanupRnnoise(); }, [ stopMonitoring, resetStats, clearLocalStreams, clearRemoteUserStreams, clearExternalStreams, - cleanupTransports + cleanupTransports, + stopRawMic, + cleanupRnnoise ]); const init = useCallback( @@ -893,7 +976,9 @@ const VoiceProvider = memo(({ children }: TVoiceProviderProps) => { prev.microphoneId !== devices.microphoneId || prev.echoCancellation !== devices.echoCancellation || prev.noiseSuppression !== devices.noiseSuppression || - prev.autoGainControl !== devices.autoGainControl; + prev.autoGainControl !== devices.autoGainControl || + prev.noiseSuppressionEnhanced !== devices.noiseSuppressionEnhanced || + prev.noiseSuppressionRnnoise !== devices.noiseSuppressionRnnoise; const webcamChanged = prev.webcamId !== devices.webcamId || diff --git a/apps/client/src/types.ts b/apps/client/src/types.ts index 17e2e17..5f6b23b 100644 --- a/apps/client/src/types.ts +++ b/apps/client/src/types.ts @@ -41,6 +41,8 @@ export type TDeviceSettings = { webcamFramerate: number; echoCancellation: boolean; noiseSuppression: boolean; + noiseSuppressionEnhanced: boolean; + noiseSuppressionRnnoise: boolean; autoGainControl: boolean; shareSystemAudio: boolean; screenResolution: Resolution; PATCH if [[ -d .git ]]; then git apply /tmp/pulse-noise.patch else patch -p1 < /tmp/pulse-noise.patch fi log "Building image ${IMAGE_TAG}..." docker build -t "${IMAGE_TAG}" . log "Writing compose override..." cat > docker-compose.noise.yml <<'EOF' services: pulse: image: pulse-chat:noise EOF log "Restarting with noise-enhanced build..." docker compose -f docker-compose-supabase.yml -f docker-compose.override.yml -f docker-compose.noise.yml up -d --build log "Done. UI now has 'Enhanced noise suppression' and 'RNNoise (WASM)' toggles."