Skip to content

Commit 746f08e

Browse files
committed
feat: use noise suppression for voice messages
Signed-off-by: Maksim Sukharev <[email protected]>
1 parent 582a32e commit 746f08e

File tree

3 files changed

+135
-2
lines changed

3 files changed

+135
-2
lines changed

src/components/NewMessage/NewMessageAudioRecorder.vue

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,11 @@ import IconClose from 'vue-material-design-icons/Close.vue'
5555
import IconMicrophoneOutline from 'vue-material-design-icons/MicrophoneOutline.vue'
5656
import { useAudioEncoder } from '../../composables/useAudioEncoder.ts'
5757
import { useGetToken } from '../../composables/useGetToken.ts'
58+
import {
59+
destroyNoiseSuppressionWorklet,
60+
processNoiseSuppression,
61+
registerNoiseSuppressionWorklet,
62+
} from '../../utils/supressNoise.ts'
5863
import { mediaDevicesManager } from '../../utils/webrtc/index.js'
5964
6065
export default {
@@ -173,7 +178,11 @@ export default {
173178
// Create new audio stream
174179
try {
175180
this.audioStream = await mediaDevicesManager.getUserMedia({
176-
audio: true,
181+
audio: {
182+
noiseSuppression: false,
183+
echoCancellation: false,
184+
autoGainControl: false,
185+
},
177186
video: false,
178187
})
179188
} catch (exception) {
@@ -189,7 +198,9 @@ export default {
189198
190199
// Create a media recorder to capture the stream
191200
try {
192-
this.mediaRecorder = new this.MediaRecorder(this.audioStream, {
201+
await registerNoiseSuppressionWorklet()
202+
const audioStreamProcessed = processNoiseSuppression(this.audioStream, true)
203+
this.mediaRecorder = new this.MediaRecorder(audioStreamProcessed, {
193204
mimeType: 'audio/wav',
194205
})
195206
} catch (exception) {
@@ -243,6 +254,7 @@ export default {
243254
this.mediaRecorder.stop()
244255
clearInterval(this.recordTimer)
245256
this.$emit('recording', false)
257+
destroyNoiseSuppressionWorklet()
246258
},
247259
248260
/**

src/test-setup.js

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,11 @@ vi.mock('@nextcloud/capabilities', () => ({
4848
getCapabilities: vi.fn(() => mockedCapabilities),
4949
}))
5050

51+
vi.mock('@sapphi-red/web-noise-suppressor', () => ({
52+
loadRnnoise: vi.fn(),
53+
RnnoiseWorkletNode: vi.fn(),
54+
}))
55+
5156
HTMLAudioElement.prototype.setSinkId = vi.fn()
5257

5358
window._oc_webroot = '/nc-webroot' // used by getRootUrl() | since @nextcloud/router 2.2.1

src/utils/supressNoise.ts

Lines changed: 116 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,116 @@
1+
/**
2+
* SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors
3+
* SPDX-License-Identifier: AGPL-3.0-or-later
4+
*/
5+
6+
import { loadRnnoise, RnnoiseWorkletNode } from '@sapphi-red/web-noise-suppressor'
7+
8+
let audioContext: AudioContext | null = null
9+
let rnnoiseWorklet: RnnoiseWorkletNode | null = null
10+
11+
/**
12+
* Creates and registers global RnnoiseWorkletNode and AudioContext.
13+
*/
14+
export async function registerNoiseSuppressionWorklet() {
15+
if (audioContext && rnnoiseWorklet) {
16+
// Already registered
17+
return
18+
}
19+
20+
audioContext = new AudioContext()
21+
const rnnoiseWasmBinary = await loadRnnoise({
22+
url: new URL(
23+
'../../node_modules/@sapphi-red/web-noise-suppressor/dist/rnnoise.wasm',
24+
import.meta.url,
25+
).pathname,
26+
simdUrl: new URL(
27+
'../../node_modules/@sapphi-red/web-noise-suppressor/dist/rnnoise_simd.wasm',
28+
import.meta.url,
29+
).pathname,
30+
})
31+
await audioContext.audioWorklet.addModule(new URL(
32+
'../../node_modules/@sapphi-red/web-noise-suppressor/dist/rnnoise/workletProcessor.js',
33+
import.meta.url,
34+
).pathname)
35+
rnnoiseWorklet = new RnnoiseWorkletNode(audioContext, {
36+
wasmBinary: rnnoiseWasmBinary,
37+
maxChannels: 2,
38+
})
39+
}
40+
41+
/**
42+
* Destroys the global RnnoiseWorkletNode and AudioContext.
43+
*/
44+
export async function destroyNoiseSuppressionWorklet() {
45+
if (rnnoiseWorklet) {
46+
try {
47+
rnnoiseWorklet?.disconnect()
48+
} catch (error) {
49+
console.error(error)
50+
}
51+
rnnoiseWorklet = null
52+
}
53+
54+
if (audioContext) {
55+
try {
56+
await audioContext.close()
57+
} catch (error) {
58+
console.error(error)
59+
}
60+
audioContext = null
61+
}
62+
}
63+
64+
/**
65+
* Processes the given MediaStream with noise suppression if enabled.
66+
* Requires that RnnoiseWorklet has been asynchronously registered beforehand.
67+
*
68+
* @param stream - MediaStream to process
69+
* @param enabled - Whether noise suppression is enabled
70+
*/
71+
export function processNoiseSuppression(stream: MediaStream, enabled = false): MediaStream {
72+
if (!enabled) {
73+
// No noise suppression requested; return the original stream
74+
return stream
75+
}
76+
77+
if (!stream.getAudioTracks().length) {
78+
// No audio tracks to process; return the original stream
79+
return stream
80+
}
81+
82+
if (!audioContext || !rnnoiseWorklet) {
83+
return stream
84+
}
85+
86+
return processRnnoise(stream)
87+
}
88+
89+
/**
90+
* Connects the RnnoiseWorklet to the given MediaStream and returns a new MediaStream with noise suppression applied.
91+
*
92+
* @param stream - MediaStream to process
93+
*/
94+
export function processRnnoise(stream: MediaStream): MediaStream {
95+
try {
96+
const mediaStreamAudioSource = audioContext!.createMediaStreamSource(stream)
97+
const mediaStreamAudioDestinationNode = audioContext!.createMediaStreamDestination()
98+
mediaStreamAudioSource.connect(rnnoiseWorklet!)
99+
rnnoiseWorklet!.connect(mediaStreamAudioDestinationNode)
100+
101+
const processedAudioTrack = mediaStreamAudioDestinationNode.stream.getAudioTracks()[0]
102+
if (!processedAudioTrack) {
103+
return stream
104+
}
105+
106+
// Remove existing audio tracks from the original stream and add only the processed track
107+
for (const track of stream.getAudioTracks()) {
108+
stream.removeTrack(track)
109+
}
110+
stream.addTrack(processedAudioTrack)
111+
} catch (error) {
112+
console.error('Error processing noise suppression:', error)
113+
}
114+
115+
return stream
116+
}

0 commit comments

Comments
 (0)