428 lines
15 KiB
Bash
Executable File
428 lines
15 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"
|
|
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(() => {
|
|
/>
|
|
</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 [[ -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."
|