pulse-zax/scripts/install-pulse-rnnoise.sh

1005 lines
34 KiB
Bash
Executable File

#!/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 "$@"