#!/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"
IMAGE_TAG="pulse-chat:noise"
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}"
log "Applying noise suppression patch..."
cat > /tmp/pulse-noise.patch <<'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 [[ -d .git ]]; then
git apply /tmp/pulse-noise.patch
else
patch -p1 < /tmp/pulse-noise.patch
fi
log "Building image ${IMAGE_TAG}..."
docker build -t "${IMAGE_TAG}" .
log "Writing compose override..."
cat > docker-compose.noise.yml <<'EOF'
services:
pulse:
image: pulse-chat:noise
EOF
log "Restarting with noise-enhanced build..."
docker compose -f docker-compose-supabase.yml -f docker-compose.override.yml -f docker-compose.noise.yml up -d --build
log "Done. UI now has 'Enhanced noise suppression' and 'RNNoise (WASM)' toggles."