338 lines
12 KiB
Bash
Executable File
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."
|