1005 lines
34 KiB
Bash
Executable File
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 "$@"
|