#!/usr/bin/env bash
set -euo pipefail
IFS=$'\n\t'

log() { echo "[pulse-setup] $*"; }
warn() { echo "[pulse-setup] WARN: $*" >&2; }
die() { echo "[pulse-setup] ERROR: $*" >&2; exit 1; }

need_root() {
  if [[ "${EUID:-$(id -u)}" -ne 0 ]]; then
    die "Run as root (or with sudo)."
  fi
}

need_cmd() {
  command -v "$1" >/dev/null 2>&1 || die "Missing command: $1"
}

ask_yes_no() {
  local prompt="$1"
  local default="${2:-N}"
  local answer
  read -r -p "${prompt} [${default}]: " answer
  answer="${answer:-$default}"
  [[ "$answer" =~ ^[Yy]$ ]]
}

escape_sed() {
  printf '%s' "$1" | sed -e 's/[&|]/\\&/g'
}

upsert_env() {
  local file="$1"
  local key="$2"
  local value="$3"
  local escaped
  escaped="$(escape_sed "$value")"
  if grep -qE "^${key}=" "$file"; then
    sed -i "s|^${key}=.*|${key}=${escaped}|" "$file"
  else
    printf '%s=%s\n' "$key" "$value" >> "$file"
  fi
}

install_docker_if_missing() {
  if command -v docker >/dev/null 2>&1; then
    return
  fi

  log "Installing Docker..."
  install -m 0755 -d /etc/apt/keyrings
  curl -fsSL https://download.docker.com/linux/debian/gpg | gpg --dearmor -o /etc/apt/keyrings/docker.gpg
  chmod a+r /etc/apt/keyrings/docker.gpg

  source /etc/os-release
  cat > /etc/apt/sources.list.d/docker.list <<EOF
deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.gpg] https://download.docker.com/linux/debian ${VERSION_CODENAME} stable
EOF

  apt-get update -y
  apt-get install -y docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin
}

install_bun_if_missing() {
  if command -v bun >/dev/null 2>&1; then
    return
  fi
  log "Installing Bun..."
  curl -fsSL https://bun.sh/install | bash
}

write_caddy_files() {
  local domain="$1"
  local tls_email="$2"
  local pulse_dir="$3"

  cat > "${pulse_dir}/Caddyfile" <<EOF
${domain} {
  encode gzip
  reverse_proxy pulse:4991
  tls ${tls_email}
}
EOF

  cat > "${pulse_dir}/docker-compose.override.yml" <<'EOF'
services:
  caddy:
    image: caddy:2
    restart: unless-stopped
    ports:
      - "80:80"
      - "443:443"
      - "443:443/udp"
    volumes:
      - ./Caddyfile:/etc/caddy/Caddyfile:ro
      - caddy_data:/data
      - caddy_config:/config
volumes:
  caddy_data:
  caddy_config:
EOF
}

write_noise_override() {
  local pulse_dir="$1"
  cat > "${pulse_dir}/docker-compose.noise.yml" <<'EOF'
services:
  pulse:
    image: pulse-chat:noise
EOF
}

apply_db_entrypoint_fix() {
  local pulse_dir="$1"
  local entrypoint="${pulse_dir}/docker/db-entrypoint.sh"
  [[ -f "${entrypoint}" ]] || return 0

  # Upstream script can mistakenly treat failed ALTER ROLE as success.
  # Force SQL to fail hard so the loop only exits on real password sync.
  sed -i 's/ON_ERROR_STOP=0/ON_ERROR_STOP=1/g' "${entrypoint}" || true
}

apply_rnnoise_patch() {
  local pulse_dir="$1"
  local patch_file
  patch_file="$(mktemp)"

  if grep -q "@timephy/rnnoise-wasm" "${pulse_dir}/apps/client/package.json" \
    && grep -q "noiseSuppressionRnnoise" "${pulse_dir}/apps/client/src/types.ts"; then
    log "RNNoise patch already present, skipping patch step."
    return
  fi

  cat > "${patch_file}" <<'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 git -C "${pulse_dir}" apply --check "${patch_file}" >/dev/null 2>&1; then
    git -C "${pulse_dir}" apply "${patch_file}"
    rm -f "${patch_file}"
    return
  fi

  if patch -d "${pulse_dir}" -p1 --dry-run < "${patch_file}" >/dev/null 2>&1; then
    patch -d "${pulse_dir}" -p1 < "${patch_file}"
    rm -f "${patch_file}"
    return
  fi

  rm -f "${patch_file}"
  warn "Static patch did not apply cleanly. Falling back to adaptive editor."
  apply_rnnoise_fallback "${pulse_dir}"
}

apply_rnnoise_fallback() {
  local pulse_dir="$1"
  command -v python3 >/dev/null 2>&1 || die "python3 is required for RNNoise fallback editing."

  python3 - "${pulse_dir}" <<'PY'
import json
import pathlib
import re
import sys

root = pathlib.Path(sys.argv[1])

def read(path: pathlib.Path) -> str:
    return path.read_text()

def write(path: pathlib.Path, content: str) -> None:
    path.write_text(content)

def require_contains(text: str, marker: str, file_hint: str) -> None:
    if marker not in text:
        raise SystemExit(f"Fallback patch marker not found in {file_hint}: {marker}")

# package.json
pkg = root / "apps/client/package.json"
data = json.loads(read(pkg))
deps = data.setdefault("dependencies", {})
if "@timephy/rnnoise-wasm" not in deps:
    deps["@timephy/rnnoise-wasm"] = "^1.0.0"
    write(pkg, json.dumps(data, indent=2) + "\n")

# types.ts
types = root / "apps/client/src/types.ts"
t = read(types)
if "noiseSuppressionRnnoise" not in t:
    require_contains(t, "  noiseSuppression: boolean;\n", "types.ts")
    t = t.replace(
        "  noiseSuppression: boolean;\n",
        "  noiseSuppression: boolean;\n"
        "  noiseSuppressionEnhanced: boolean;\n"
        "  noiseSuppressionRnnoise: boolean;\n",
        1
    )
    write(types, t)

# devices-provider defaults
devprov = root / "apps/client/src/components/devices-provider/index.tsx"
d = read(devprov)
if "noiseSuppressionRnnoise" not in d:
    require_contains(d, "  noiseSuppression: false,\n", "devices-provider/index.tsx")
    d = d.replace(
        "  noiseSuppression: false,\n",
        "  noiseSuppression: false,\n"
        "  noiseSuppressionEnhanced: false,\n"
        "  noiseSuppressionRnnoise: false,\n",
        1
    )
    write(devprov, d)

# settings UI
ui = root / "apps/client/src/components/server-screens/user-settings/devices/index.tsx"
u = read(ui)
if "RNNoise (WASM)" not in u:
    marker = """            <Group label="Noise suppression">
              <Switch
                checked={!!values.noiseSuppression}
                onCheckedChange={(checked) =>
                  onChange('noiseSuppression', checked)
                }
              />
            </Group>
"""
    insert = """
            <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>
"""
    require_contains(u, marker, "user-settings/devices/index.tsx")
    u = u.replace(marker, marker + insert, 1)
    write(ui, u)

# voice-provider
vp = root / "apps/client/src/components/voice-provider/index.tsx"
v = read(vp)

if "import { NoiseSuppressorWorklet_Name } from '@timephy/rnnoise-wasm';" not in v:
    require_contains(v, "import { VolumeControlProvider } from './volume-control-context';\n", "voice-provider/index.tsx")
    v = v.replace(
        "import { VolumeControlProvider } from './volume-control-context';\n",
        "import { VolumeControlProvider } from './volume-control-context';\n"
        "import { NoiseSuppressorWorklet_Name } from '@timephy/rnnoise-wasm';\n"
        "import NoiseSuppressorWorklet from '@timephy/rnnoise-wasm/NoiseSuppressorWorklet?worker&url';\n",
        1
    )

if "const buildMicConstraints = (devices: TDeviceSettings): MediaTrackConstraints => {" not in v:
    require_contains(v, "type AudioVideoRefs = {\n", "voice-provider/index.tsx")
    v = v.replace(
        "type AudioVideoRefs = {\n",
        """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 = {
""",
        1
    )

if "const acquireMicStream = useCallback(async (): Promise<{ stream: MediaStream; track: MediaStreamTrack; raw: MediaStream }> => {" not in v:
    require_contains(v, "} = useTransportStats();\n", "voice-provider/index.tsx")
    v = v.replace(
        "} = useTransportStats();\n",
        """} = 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]);
""",
        1
    )

v = re.sub(
    r"const stream = await navigator\.mediaDevices\.getUserMedia\(\{[\s\S]*?\}\);\n\n\s*logVoice\('Microphone stream obtained', \{ stream \}\);",
    "const { stream, track, raw } = await acquireMicStream();\n\n      logVoice('Microphone stream obtained', { stream });",
    v, count=1
)

v = v.replace("const audioTrack = stream.getAudioTracks()[0];\n\n", "", 1)
v = v.replace("if (audioTrack) {\n        logVoice('Obtained audio track', { audioTrack });",
              "if (track) {\n        logVoice('Obtained audio track', { audioTrack: track });", 1)
v = v.replace("track: audioTrack,\n", "track,\n", 1)
v = v.replace("audioTrack.onended = () => {", "track.onended = () => {", 1)

if "rawMicStreamRef.current = raw;" not in v:
    v = v.replace("setLocalAudioStream(stream);\n\n", "setLocalAudioStream(stream);\n\n      stopRawMic();\n      rawMicStreamRef.current = raw;\n\n", 1)

if "stopRawMic();\n          cleanupRnnoise();" not in v:
    v = v.replace("localAudioProducer.current?.close();\n\n          setLocalAudioStream(undefined);",
                  "localAudioProducer.current?.close();\n\n          stopRawMic();\n          cleanupRnnoise();\n          setLocalAudioStream(undefined);", 1)

v = v.replace(
    "devices.microphoneId,\n    devices.autoGainControl,\n    devices.echoCancellation,\n    devices.noiseSuppression",
    "acquireMicStream,\n    stopRawMic,\n    cleanupRnnoise",
    1
)

v = re.sub(
    r"const newMicStream = await navigator\.mediaDevices\.getUserMedia\(\{[\s\S]*?\}\);\n\n\s*const newMicTrack",
    "const { stream: newMicStream, raw: newRaw } = await acquireMicStream();\n\n        const newMicTrack",
    v, count=1
)

if "setLocalAudioStream(newMicStream);\n          stopRawMic();\n          rawMicStreamRef.current = newRaw;" not in v:
    v = v.replace("setLocalAudioStream(newMicStream);\n          logVoice('macOS: Mic restored to original settings');",
                  "setLocalAudioStream(newMicStream);\n          stopRawMic();\n          rawMicStreamRef.current = newRaw;\n          logVoice('macOS: Mic restored to original settings');", 1)

v = v.replace(
    "devices.microphoneId, devices.autoGainControl, devices.echoCancellation, devices.noiseSuppression",
    "acquireMicStream,\n    stopRawMic",
    1
)

v = re.sub(
    r"const newStream = await navigator\.mediaDevices\.getUserMedia\(\{[\s\S]*?\}\);\n\n\s*const newTrack = newStream\.getAudioTracks\(\)\[0\];",
    "const { stream: newStream, track: newTrack, raw: newRaw } =\n        await acquireMicStream();",
    v, count=1
)

if "setLocalAudioStream(newStream);\n      stopRawMic();\n      rawMicStreamRef.current = newRaw;" not in v:
    v = v.replace("setLocalAudioStream(newStream);\n      logVoice('Mic settings reapplied successfully');",
                  "setLocalAudioStream(newStream);\n      stopRawMic();\n      rawMicStreamRef.current = newRaw;\n      logVoice('Mic settings reapplied successfully');", 1)

v = v.replace(
    "devices.microphoneId,\n    devices.autoGainControl,\n    devices.echoCancellation,\n    devices.noiseSuppression",
    "acquireMicStream,\n    stopRawMic",
    1
)

if "setConnectionStatus(ConnectionStatus.DISCONNECTED);\n    stopRawMic();\n    cleanupRnnoise();" not in v:
    v = v.replace("setConnectionStatus(ConnectionStatus.DISCONNECTED);",
                  "setConnectionStatus(ConnectionStatus.DISCONNECTED);\n    stopRawMic();\n    cleanupRnnoise();", 1)
    v = v.replace("cleanupTransports\n  ]);",
                  "cleanupTransports,\n    stopRawMic,\n    cleanupRnnoise\n  ]);", 1)

v = v.replace(
    "prev.noiseSuppression !== devices.noiseSuppression ||\n      prev.autoGainControl !== devices.autoGainControl;",
    "prev.noiseSuppression !== devices.noiseSuppression ||\n      prev.autoGainControl !== devices.autoGainControl ||\n      prev.noiseSuppressionEnhanced !== devices.noiseSuppressionEnhanced ||\n      prev.noiseSuppressionRnnoise !== devices.noiseSuppressionRnnoise;",
    1
)

write(vp, v)
PY
}

main() {
  need_root

  local PULSE_DIR="/opt/pulse"
  local REPO_URL="https://github.com/plsechat/pulse-chat.git"
  local WIPE="N"
  local RESET_REPO="Y"
  local DOMAIN=""
  local ENABLE_CADDY="N"
  local TLS_EMAIL=""
  local POSTGRES_PASSWORD=""
  local PULSE_PORT="4991"
  local PUBLIC_IP=""
  local ENABLE_RNNOISE="Y"

  if ask_yes_no "Reinstall from scratch (remove ${PULSE_DIR} and Docker volumes)?" "N"; then
    WIPE="Y"
  fi

  if ask_yes_no "Reset repository to origin/main before install?" "Y"; then
    RESET_REPO="Y"
  else
    RESET_REPO="N"
  fi

  read -r -p "Domain for Pulse (e.g. chat.example.com): " DOMAIN
  [[ -n "${DOMAIN}" ]] || die "Domain is required."

  if ask_yes_no "Enable Caddy HTTPS proxy?" "N"; then
    ENABLE_CADDY="Y"
    read -r -p "TLS email (for Let's Encrypt): " TLS_EMAIL
    [[ -n "${TLS_EMAIL}" ]] || die "TLS email is required when Caddy is enabled."
  fi

  read -r -p "Postgres password (leave blank to generate): " POSTGRES_PASSWORD
  if [[ -z "${POSTGRES_PASSWORD}" ]]; then
    POSTGRES_PASSWORD="$(openssl rand -hex 16)"
    log "Generated Postgres password."
  fi

  read -r -p "Pulse port [4991]: " PULSE_PORT
  PULSE_PORT="${PULSE_PORT:-4991}"

  read -r -p "Public IP (optional; leave blank to auto-detect): " PUBLIC_IP
  if [[ -z "${PUBLIC_IP}" ]]; then
    PUBLIC_IP="$(curl -fsSL https://api.ipify.org || true)"
    [[ -n "${PUBLIC_IP}" ]] && log "Detected public IP: ${PUBLIC_IP}"
  fi

  if ask_yes_no "Enable RNNoise patch/build?" "Y"; then
    ENABLE_RNNOISE="Y"
  else
    ENABLE_RNNOISE="N"
  fi

  log "Installing base packages..."
  apt-get update -y
  apt-get install -y ca-certificates curl gnupg git ufw openssl patch

  install_docker_if_missing
  install_bun_if_missing
  need_cmd docker
  need_cmd git

  export BUN_INSTALL="${BUN_INSTALL:-/root/.bun}"
  export PATH="${BUN_INSTALL}/bin:${PATH}"
  need_cmd bun

  if [[ "${WIPE}" == "Y" && -d "${PULSE_DIR}" ]]; then
    log "Stopping existing stack and removing ${PULSE_DIR}..."
    local down_files=("-f" "docker-compose-supabase.yml")
    [[ -f "${PULSE_DIR}/docker-compose.override.yml" ]] && down_files+=("-f" "docker-compose.override.yml")
    [[ -f "${PULSE_DIR}/docker-compose.noise.yml" ]] && down_files+=("-f" "docker-compose.noise.yml")
    (cd "${PULSE_DIR}" && docker compose "${down_files[@]}" down -v --remove-orphans) || true
    rm -rf "${PULSE_DIR}"
  fi

  log "Preparing repository..."
  if [[ -d "${PULSE_DIR}/.git" ]]; then
    git -C "${PULSE_DIR}" fetch --all --prune
    if [[ "${RESET_REPO}" == "Y" ]]; then
      local target_ref
      if git -C "${PULSE_DIR}" show-ref --verify --quiet refs/remotes/origin/main; then
        target_ref="origin/main"
      else
        target_ref="$(git -C "${PULSE_DIR}" symbolic-ref -q --short refs/remotes/origin/HEAD || true)"
        [[ -n "${target_ref}" ]] || die "Could not determine default remote branch."
      fi
      git -C "${PULSE_DIR}" checkout -B main "${target_ref}"
      git -C "${PULSE_DIR}" reset --hard "${target_ref}"
    else
      git -C "${PULSE_DIR}" checkout main || true
      git -C "${PULSE_DIR}" pull --ff-only
    fi
  else
    rm -rf "${PULSE_DIR}"
    git clone "${REPO_URL}" "${PULSE_DIR}"
    git -C "${PULSE_DIR}" checkout main || true
  fi

  cd "${PULSE_DIR}"

  log "Configuring environment..."
  [[ -f .env ]] || cp -f .env.supabase.example .env

  local KEY_OUTPUT JWT_SECRET SUPABASE_ANON_KEY SUPABASE_SERVICE_ROLE_KEY SITE_URL
  KEY_OUTPUT="$(bun docker/generate-keys.ts)"
  JWT_SECRET="$(echo "${KEY_OUTPUT}" | awk -F= '/^JWT_SECRET=/{print substr($0, index($0,$2)); exit}')"
  SUPABASE_ANON_KEY="$(echo "${KEY_OUTPUT}" | awk -F= '/^SUPABASE_ANON_KEY=/{print substr($0, index($0,$2)); exit}')"
  SUPABASE_SERVICE_ROLE_KEY="$(echo "${KEY_OUTPUT}" | awk -F= '/^SUPABASE_SERVICE_ROLE_KEY=/{print substr($0, index($0,$2)); exit}')"
  [[ -n "${JWT_SECRET}" && -n "${SUPABASE_ANON_KEY}" && -n "${SUPABASE_SERVICE_ROLE_KEY}" ]] || die "Failed to generate Supabase/JWT keys."

  if [[ "${ENABLE_CADDY}" == "Y" ]]; then
    SITE_URL="https://${DOMAIN}"
  else
    SITE_URL="http://${DOMAIN}:${PULSE_PORT}"
  fi

  upsert_env .env POSTGRES_PASSWORD "${POSTGRES_PASSWORD}"
  upsert_env .env JWT_SECRET "${JWT_SECRET}"
  upsert_env .env SUPABASE_ANON_KEY "${SUPABASE_ANON_KEY}"
  upsert_env .env SUPABASE_SERVICE_ROLE_KEY "${SUPABASE_SERVICE_ROLE_KEY}"
  upsert_env .env SITE_URL "${SITE_URL}"
  upsert_env .env PULSE_PORT "${PULSE_PORT}"
  [[ -n "${PUBLIC_IP}" ]] && upsert_env .env PUBLIC_IP "${PUBLIC_IP}"

  if [[ "${ENABLE_CADDY}" == "Y" ]]; then
    write_caddy_files "${DOMAIN}" "${TLS_EMAIL}" "${PULSE_DIR}"
  else
    rm -f "${PULSE_DIR}/docker-compose.override.yml" "${PULSE_DIR}/Caddyfile"
  fi

  if [[ "${ENABLE_RNNOISE}" == "Y" ]]; then
    log "Applying RNNoise patch..."
    apply_rnnoise_patch "${PULSE_DIR}"
    log "Building RNNoise image..."
    docker build -t pulse-chat:noise .
    write_noise_override "${PULSE_DIR}"
  else
    rm -f "${PULSE_DIR}/docker-compose.noise.yml"
  fi

  apply_db_entrypoint_fix "${PULSE_DIR}"

  log "Configuring firewall..."
  ufw allow OpenSSH >/dev/null 2>&1 || true
  if [[ "${ENABLE_CADDY}" == "Y" ]]; then
    ufw allow 80/tcp >/dev/null 2>&1 || true
    ufw allow 443/tcp >/dev/null 2>&1 || true
    ufw allow 443/udp >/dev/null 2>&1 || true
  else
    ufw allow "${PULSE_PORT}/tcp" >/dev/null 2>&1 || true
  fi
  ufw allow 40000:40020/tcp >/dev/null 2>&1 || true
  ufw allow 40000:40020/udp >/dev/null 2>&1 || true
  ufw --force enable >/dev/null 2>&1 || true

  log "Starting stack..."
  local compose_files=("-f" "docker-compose-supabase.yml")
  [[ "${ENABLE_CADDY}" == "Y" ]] && compose_files+=("-f" "docker-compose.override.yml")
  [[ "${ENABLE_RNNOISE}" == "Y" ]] && compose_files+=("-f" "docker-compose.noise.yml")

  docker compose "${compose_files[@]}" up -d --build
  docker compose "${compose_files[@]}" ps

  log "Done."
  if [[ "${ENABLE_CADDY}" == "Y" ]]; then
    log "Pulse URL: https://${DOMAIN}"
  else
    log "Pulse URL: http://${DOMAIN}:${PULSE_PORT}"
  fi
  log "Check startup logs with:"
  log "  docker logs -f pulse | head -n 200"
}

main "$@"
