#!/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(() => {
               />
             </Group>
 
+            <Group label="Enhanced noise suppression">
+              <Switch
+                checked={!!values.noiseSuppressionEnhanced}
+                onCheckedChange={(checked) =>
+                  onChange('noiseSuppressionEnhanced', checked)
+                }
+              />
+            </Group>
+
+            <Group label="RNNoise (WASM)">
+              <Switch
+                checked={!!values.noiseSuppressionRnnoise}
+                onCheckedChange={(checked) =>
+                  onChange('noiseSuppressionRnnoise', checked)
+                }
+              />
+            </Group>
+
             <Group label="Automatic gain control">
               <Switch
                 checked={!!values.autoGainControl}
diff --git a/apps/client/src/components/voice-provider/index.tsx b/apps/client/src/components/voice-provider/index.tsx
index 1bfbdaa..3f3dfed 100644
--- a/apps/client/src/components/voice-provider/index.tsx
+++ b/apps/client/src/components/voice-provider/index.tsx
@@ -29,6 +29,35 @@ import { useTransports } from './hooks/use-transports';
 import { useVoiceControls } from './hooks/use-voice-controls';
 import { useVoiceEvents } from './hooks/use-voice-events';
 import { VolumeControlProvider } from './volume-control-context';
+import { NoiseSuppressorWorklet_Name } from '@timephy/rnnoise-wasm';
+import NoiseSuppressorWorklet from '@timephy/rnnoise-wasm/NoiseSuppressorWorklet?worker&url';
+
+const buildMicConstraints = (devices: TDeviceSettings): MediaTrackConstraints => {
+  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<string, unknown>;
+    legacy.googHighpassFilter = true;
+    legacy.googNoiseSuppression = true;
+    legacy.googEchoCancellation = true;
+    legacy.googAutoGainControl = true;
+    legacy.googTypingNoiseDetection = true;
+  }
+
+  return constraints;
+};
 
 type AudioVideoRefs = {
   videoRef: React.RefObject<HTMLVideoElement | null>;
@@ -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<RNNoiseState | null>(null);
+  const rawMicStreamRef = useRef<MediaStream | null>(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<MediaStream> => {
+    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."
