Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
678 changes: 187 additions & 491 deletions package-lock.json

Large diffs are not rendered by default.

1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@
"@nextcloud/sharing": "^0.3.0",
"@nextcloud/upload": "^2.0.0-rc.0",
"@nextcloud/vue": "^9.3.1",
"@sapphi-red/web-noise-suppressor": "^0.3.5",
"@vue-leaflet/vue-leaflet": "^0.10.1",
"@vueuse/components": "^14.0.0",
"@vueuse/core": "^14.1.0",
Expand Down
4 changes: 4 additions & 0 deletions rspack.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -182,6 +182,10 @@ module.exports = defineConfig((env) => {
resourceQuery: /raw/,
type: 'asset/source',
},
{
resourceQuery: /url$/,
type: 'asset/resource',
},
],
},

Expand Down
18 changes: 16 additions & 2 deletions src/components/NewMessage/NewMessageAudioRecorder.vue
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,12 @@ import IconClose from 'vue-material-design-icons/Close.vue'
import IconMicrophoneOutline from 'vue-material-design-icons/MicrophoneOutline.vue'
import { useAudioEncoder } from '../../composables/useAudioEncoder.ts'
import { useGetToken } from '../../composables/useGetToken.ts'
import { useSettingsStore } from '../../stores/settings.ts'
import {
destroyNoiseSuppressionWorklet,
processNoiseSuppression,
registerNoiseSuppressionWorklet,
} from '../../utils/supressNoise.ts'
import { mediaDevicesManager } from '../../utils/webrtc/index.js'

export default {
Expand All @@ -77,6 +83,8 @@ export default {
emits: ['recording', 'audioFile'],

setup() {
const settingsStore = useSettingsStore()

const {
isMediaRecorderReady,
isMediaRecorderLoading,
Expand All @@ -85,6 +93,7 @@ export default {
} = useAudioEncoder()

return {
settingsStore,
token: useGetToken(),
isMediaRecorderReady,
isMediaRecorderLoading,
Expand Down Expand Up @@ -173,7 +182,9 @@ export default {
// Create new audio stream
try {
this.audioStream = await mediaDevicesManager.getUserMedia({
audio: true,
audio: {
noiseSuppression: !this.settingsStore.noiseSuppression,
},
video: false,
})
} catch (exception) {
Expand All @@ -189,7 +200,9 @@ export default {

// Create a media recorder to capture the stream
try {
this.mediaRecorder = new this.MediaRecorder(this.audioStream, {
await registerNoiseSuppressionWorklet()
const audioStreamProcessed = processNoiseSuppression(this.audioStream, this.settingsStore.noiseSuppression)
this.mediaRecorder = new this.MediaRecorder(audioStreamProcessed, {
mimeType: 'audio/wav',
})
} catch (exception) {
Expand Down Expand Up @@ -243,6 +256,7 @@ export default {
this.mediaRecorder.stop()
clearInterval(this.recordTimer)
this.$emit('recording', false)
destroyNoiseSuppressionWorklet()
},

/**
Expand Down
8 changes: 8 additions & 0 deletions src/components/SettingsDialog/SettingsDialog.vue
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,10 @@
:label="t('spreed', 'Skip device preview before joining a call')"
:description="t('spreed', 'Always shown if recording consent is required')"
@update:model-value="setHideMediaSettings" />
<NcFormBoxSwitch
:model-value="settingsStore.noiseSuppression"
:label="t('spreed', 'Enable noise suppression')"
@update:model-value="toggleNoiseSuppression" />
</NcFormBox>

<NcButton
Expand Down Expand Up @@ -401,6 +405,10 @@ export default {
this.settingsStore.setShowMediaSettings(!newValue)
},

toggleNoiseSuppression(newValue) {
this.settingsStore.setNoiseSuppression(newValue)
},

async setBlurVirtualBackgroundEnabled(value) {
try {
await this.settingsStore.setBlurVirtualBackgroundEnabled(value)
Expand Down
13 changes: 13 additions & 0 deletions src/stores/settings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ export const useSettingsStore = defineStore('settings', () => {
const readStatusPrivacy = ref<PRIVACY_KEYS>(loadState('spreed', 'read_status_privacy', PRIVACY.PRIVATE))
const typingStatusPrivacy = ref<PRIVACY_KEYS>(loadState('spreed', 'typing_privacy', PRIVACY.PRIVATE))
const showMediaSettings = ref<boolean>(BrowserStorage.getItem('showMediaSettings') !== 'false')
const noiseSuppression = ref<boolean>(BrowserStorage.getItem('noiseSuppression') !== 'false')
const startWithoutMedia = ref<boolean | undefined>(getTalkConfig('local', 'call', 'start-without-media'))
const blurVirtualBackgroundEnabled = ref<boolean | undefined>(getTalkConfig('local', 'call', 'blur-virtual-background'))
const conversationsListStyle = ref<LIST_STYLE_OPTIONS | undefined>(getTalkConfig('local', 'conversations', 'list-style'))
Expand Down Expand Up @@ -68,6 +69,16 @@ export const useSettingsStore = defineStore('settings', () => {
showMediaSettings.value = value
}

/**
* Update the noise suppression settings for the user
*
* @param value - new selected state
*/
function setNoiseSuppression(value: boolean) {
BrowserStorage.setItem('noiseSuppression', value.toString())
noiseSuppression.value = value
}

/**
* Update the blur virtual background setting for the user
*
Expand Down Expand Up @@ -122,6 +133,7 @@ export const useSettingsStore = defineStore('settings', () => {
readStatusPrivacy,
typingStatusPrivacy,
showMediaSettings,
noiseSuppression,
startWithoutMedia,
blurVirtualBackgroundEnabled,
conversationsListStyle,
Expand All @@ -132,6 +144,7 @@ export const useSettingsStore = defineStore('settings', () => {
updateReadStatusPrivacy,
updateTypingStatusPrivacy,
setShowMediaSettings,
setNoiseSuppression,
setBlurVirtualBackgroundEnabled,
updateStartWithoutMedia,
updateConversationsListStyle,
Expand Down
5 changes: 5 additions & 0 deletions src/test-setup.js
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,11 @@ vi.mock('@nextcloud/capabilities', () => ({
getCapabilities: vi.fn(() => mockedCapabilities),
}))

vi.mock('@sapphi-red/web-noise-suppressor', () => ({
loadRnnoise: vi.fn(),
RnnoiseWorkletNode: vi.fn(),
}))

HTMLAudioElement.prototype.setSinkId = vi.fn()

window._oc_webroot = '/nc-webroot' // used by getRootUrl() | since @nextcloud/router 2.2.1
Expand Down
8 changes: 8 additions & 0 deletions src/types/vendor/@sapphi-red_web-noise-suppressor.d.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
/**
* SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/

declare module '@sapphi-red/web-noise-suppressor/rnnoiseWorklet.js?url'
declare module '@sapphi-red/web-noise-suppressor/rnnoise.wasm?url'
declare module '@sapphi-red/web-noise-suppressor/rnnoise_simd.wasm?url'
116 changes: 116 additions & 0 deletions src/utils/supressNoise.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,116 @@
/**
* SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/

import { loadRnnoise, RnnoiseWorkletNode } from '@sapphi-red/web-noise-suppressor'

let audioContext: AudioContext | null = null
let rnnoiseWorklet: RnnoiseWorkletNode | null = null

/**
* Creates and registers global RnnoiseWorkletNode and AudioContext.
*/
export async function registerNoiseSuppressionWorklet() {
if (audioContext && rnnoiseWorklet) {
// Already registered
return
}

audioContext = new AudioContext()
const rnnoiseWasmBinary = await loadRnnoise({
url: new URL(
'../../node_modules/@sapphi-red/web-noise-suppressor/dist/rnnoise.wasm',
import.meta.url,
).pathname,
simdUrl: new URL(
'../../node_modules/@sapphi-red/web-noise-suppressor/dist/rnnoise_simd.wasm',
import.meta.url,
).pathname,
})
await audioContext.audioWorklet.addModule(new URL(
'../../node_modules/@sapphi-red/web-noise-suppressor/dist/rnnoise/workletProcessor.js',
import.meta.url,
).pathname)
rnnoiseWorklet = new RnnoiseWorkletNode(audioContext, {
wasmBinary: rnnoiseWasmBinary,
maxChannels: 2,
})
}

/**
* Destroys the global RnnoiseWorkletNode and AudioContext.
*/
export async function destroyNoiseSuppressionWorklet() {
if (rnnoiseWorklet) {
try {
rnnoiseWorklet?.disconnect()
} catch (error) {
console.error(error)
}
rnnoiseWorklet = null
}

if (audioContext) {
try {
await audioContext.close()
} catch (error) {
console.error(error)
}
audioContext = null
}
}

/**
* Processes the given MediaStream with noise suppression if enabled.
* Requires that RnnoiseWorklet has been asynchronously registered beforehand.
*
* @param stream - MediaStream to process
* @param enabled - Whether noise suppression is enabled
*/
export function processNoiseSuppression(stream: MediaStream, enabled = false): MediaStream {
if (!enabled) {
// No noise suppression requested; return the original stream
return stream
}

if (!stream.getAudioTracks().length) {
// No audio tracks to process; return the original stream
return stream
}

if (!audioContext || !rnnoiseWorklet) {
return stream
}

return processRnnoise(stream)
}

/**
* Connects the RnnoiseWorklet to the given MediaStream and returns a new MediaStream with noise suppression applied.
*
* @param stream - MediaStream to process
*/
export function processRnnoise(stream: MediaStream): MediaStream {
try {
const mediaStreamAudioSource = audioContext!.createMediaStreamSource(stream)
const mediaStreamAudioDestinationNode = audioContext!.createMediaStreamDestination()
mediaStreamAudioSource.connect(rnnoiseWorklet!)
rnnoiseWorklet!.connect(mediaStreamAudioDestinationNode)

const processedAudioTrack = mediaStreamAudioDestinationNode.stream.getAudioTracks()[0]
if (!processedAudioTrack) {
return stream
}

// Remove existing audio tracks from the original stream and add only the processed track
for (const track of stream.getAudioTracks()) {
stream.removeTrack(track)
}
stream.addTrack(processedAudioTrack)
} catch (error) {
console.error('Error processing noise suppression:', error)
}

return stream
}