pulse-zax/scripts/rnnoise-direct.sh

338 lines
12 KiB
Bash
Executable File

#!/usr/bin/env bash
set -euo pipefail
log() { echo "[pulse-noise] $*"; }
die() { echo "[pulse-noise] ERROR: $*" >&2; exit 1; }
if [ "${EUID:-$(id -u)}" -ne 0 ]; then
die "Run as root (or with sudo)."
fi
PULSE_DIR="/opt/pulse"
REPO_URL="https://github.com/plsechat/pulse-chat.git"
log "Preparing source in ${PULSE_DIR}..."
if [ -d "${PULSE_DIR}/.git" ]; then
git -C "${PULSE_DIR}" fetch --all --prune
git -C "${PULSE_DIR}" checkout .
git -C "${PULSE_DIR}" pull --ff-only
else
rm -rf "${PULSE_DIR}"
git clone "${REPO_URL}" "${PULSE_DIR}"
fi
cd "${PULSE_DIR}"
# Ensure .env exists
if [ ! -f "${PULSE_DIR}/.env" ]; then
log ".env missing ? creating from .env.supabase.example"
cp -f .env.supabase.example .env
if command -v bun >/dev/null 2>&1; then
log "Generating Supabase keys..."
KEY_OUTPUT="$(bun docker/generate-keys.ts)"
JWT_SECRET="$(echo "${KEY_OUTPUT}" | grep -E '^JWT_SECRET=' | head -n1 | cut -d'=' -f2-)"
SUPABASE_ANON_KEY="$(echo "${KEY_OUTPUT}" | grep -E '^SUPABASE_ANON_KEY=' | head -n1 | cut -d'=' -f2-)"
SUPABASE_SERVICE_ROLE_KEY="$(echo "${KEY_OUTPUT}" | grep -E '^SUPABASE_SERVICE_ROLE_KEY=' | head -n1 | cut -d'=' -f2-)"
sed -i \
-e "s|^POSTGRES_PASSWORD=.*|POSTGRES_PASSWORD=$(openssl rand -hex 16)|" \
-e "s|^JWT_SECRET=.*|JWT_SECRET=${JWT_SECRET}|" \
-e "s|^SUPABASE_ANON_KEY=.*|SUPABASE_ANON_KEY=${SUPABASE_ANON_KEY}|" \
-e "s|^SUPABASE_SERVICE_ROLE_KEY=.*|SUPABASE_SERVICE_ROLE_KEY=${SUPABASE_SERVICE_ROLE_KEY}|" \
.env
else
log "bun not available; left .env defaults. Edit /opt/pulse/.env before running compose."
fi
fi
log "Editing files directly..."
python3 <<'PY'
import json, re, pathlib
root = pathlib.Path("/opt/pulse")
def read(p): return p.read_text()
def write(p, s): p.write_text(s)
# package.json
pkg = root / "apps/client/package.json"
data = json.loads(read(pkg))
deps = data.setdefault("dependencies", {})
deps.setdefault("@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:
t = t.replace(
" noiseSuppression: boolean;\n",
" noiseSuppression: boolean;\n"
" noiseSuppressionEnhanced: boolean;\n"
" noiseSuppressionRnnoise: boolean;\n"
)
write(types, t)
# devices-provider defaults
devprov = root / "apps/client/src/components/devices-provider/index.tsx"
d = read(devprov)
if "noiseSuppressionRnnoise" not in d:
d = d.replace(
" noiseSuppression: false,\n",
" noiseSuppression: false,\n"
" noiseSuppressionEnhanced: false,\n"
" noiseSuppressionRnnoise: false,\n"
)
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:
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>
"""
marker = """ <Group label=\"Noise suppression\">
<Switch
checked={!!values.noiseSuppression}
onCheckedChange={(checked) =>
onChange('noiseSuppression', checked)
}
/>
</Group>
"""
if marker not in u:
raise SystemExit("Could not find Noise suppression group.")
u = u.replace(marker, marker + insert)
write(ui, u)
# voice-provider
vp = root / "apps/client/src/components/voice-provider/index.tsx"
v = read(vp)
if "rnnoise-wasm" not in v:
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"
)
if "buildMicConstraints" not in v:
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 = {
"""
)
if "applyRnnoise" not in v:
v = v.replace(
"} = useTransportStats();\n\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]);
"""
)
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", "")
v = v.replace("if (audioTrack) {\n logVoice('Obtained audio track', { audioTrack });",
"if (track) {\n logVoice('Obtained audio track', { audioTrack: track });")
v = v.replace("track: audioTrack,\n", "track,\n")
v = v.replace("audioTrack.onended = () => {", "track.onended = () => {")
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")
if "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);")
v = v.replace(
"devices.microphoneId,\n devices.autoGainControl,\n devices.echoCancellation,\n devices.noiseSuppression",
"acquireMicStream,\n stopRawMic,\n cleanupRnnoise"
)
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 "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');")
v = v.replace(
"devices.microphoneId, devices.autoGainControl, devices.echoCancellation, devices.noiseSuppression",
"acquireMicStream,\n stopRawMic"
)
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 "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');")
v = v.replace(
"devices.microphoneId,\n devices.autoGainControl,\n devices.echoCancellation,\n devices.noiseSuppression",
"acquireMicStream,\n stopRawMic"
)
if "cleanupRnnoise();" not in v:
v = v.replace("setConnectionStatus(ConnectionStatus.DISCONNECTED);\n }, [",
"setConnectionStatus(ConnectionStatus.DISCONNECTED);\n stopRawMic();\n cleanupRnnoise();\n }, [")
v = v.replace("cleanupTransports", "cleanupTransports,\n stopRawMic,\n cleanupRnnoise")
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;"
)
write(vp, v)
PY
log "Building image pulse-chat:noise..."
docker build -t pulse-chat:noise .
log "Writing compose override..."
cat > docker-compose.noise.yml <<'YML'
services:
pulse:
image: pulse-chat:noise
YML
COMPOSE_FILES="-f docker-compose-supabase.yml"
if [ -f docker-compose.override.yml ]; then
COMPOSE_FILES="$COMPOSE_FILES -f docker-compose.override.yml"
fi
COMPOSE_FILES="$COMPOSE_FILES -f docker-compose.noise.yml"
log "Restarting with noise build..."
docker compose $COMPOSE_FILES up -d --build
log "Done. UI now has 'Enhanced noise suppression' and 'RNNoise (WASM)' toggles."