#!/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 </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" < "${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(() => { /> + + + 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 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 = """ onChange('noiseSuppression', checked) } /> """ insert = """ onChange('noiseSuppressionEnhanced', checked) } /> onChange('noiseSuppressionRnnoise', checked) } /> """ 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; 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(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]); """, 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 "$@"