Compare commits

...

8 Commits

Author SHA1 Message Date
lebaudantoine d1bb414cb4 🔖(minor) bump release to 0.1.26
Add noise reduction with RNNoise.
New feature needs to be battle tested,
it's protected with a feature flag.
2025-06-25 22:40:01 +02:00
lebaudantoine 513f3c588a 🚩(frontend) add noise reduction availability hook with feature flag
Encapsulate noise reduction availability logic in hook and add feature flag
for quick production disable if issues arise. Gives product owners emergency
control over the feature.
2025-06-25 22:21:23 +02:00
lebaudantoine b407bfda07 🐛(frontend) default noise reduction to disabled for existing users
Prevent React warnings about uncontrolled/controlled components by ensuring
lk-user-choice store initializes with default value when noise reduction
setting is missing from existing localStorage.
2025-06-25 22:21:23 +02:00
lebaudantoine 3de4cc01dc (frontend) disable noise reduction on mobile devices
Prevent enabling noise reduction on mobile to avoid performance issues
from resource-intensive audio processing.
2025-06-25 22:21:23 +02:00
lebaudantoine 1fe1a557ad (frontend) add hook to manage noise reduction processor toggling
Create concise hook that listens to audio track status and user noise
reduction preference to automatically handle processor state changes.

Note: Logging could be improved in future iterations.
2025-06-25 22:21:23 +02:00
lebaudantoine fb92e5b79f (frontend) add noise reduction toggle setting with beta label
Implement settings option to enable/disable noise suppression with clear
beta indicator. Label will be removed after production battle-testing.

Note: Settings styling needs polish, will address in future commits.
2025-06-25 22:21:23 +02:00
lebaudantoine e71bc093bd (frontend) add RNNoise processor for meeting noise suppression
Implement noise reduction copying Jitsi's approach. RNNoise isn't optimal
but chosen for first draft. Needs production battle-testing for CPU/RAM.

Use global audio context with pause/resume instead of deletion to avoid
WASM resource leak issues in @timephy/rnnoise-wasm dependency. Audio context
deletion may not properly release WASM resources.

Requires discussion with senior devs on resource management approach.
2025-06-25 22:21:23 +02:00
lebaudantoine 43df855461 (frontend) add @timephy/rnnoise-wasm for noise suppression
Install wrapper around Jitsi's RNNoise implementation for easier reuse.
Note: Library may not properly release WebAssembly resources based on
code review.
2025-06-25 22:21:23 +02:00
20 changed files with 266 additions and 16 deletions
+1 -1
View File
@@ -7,7 +7,7 @@ build-backend = "setuptools.build_meta"
[project]
name = "meet"
version = "0.1.25"
version = "0.1.26"
authors = [{ "name" = "DINUM", "email" = "dev@mail.numerique.gouv.fr" }]
classifiers = [
"Development Status :: 5 - Production/Stable",
+8 -2
View File
@@ -1,12 +1,12 @@
{
"name": "meet",
"version": "0.1.25",
"version": "0.1.26",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "meet",
"version": "0.1.25",
"version": "0.1.26",
"dependencies": {
"@livekit/components-react": "2.9.12",
"@livekit/components-styles": "1.1.6",
@@ -15,6 +15,7 @@
"@react-aria/toast": "3.0.2",
"@remixicon/react": "4.6.0",
"@tanstack/react-query": "5.76.0",
"@timephy/rnnoise-wasm": "1.0.0",
"crisp-sdk-web": "1.0.25",
"hoofd": "1.7.3",
"i18next": "25.1.2",
@@ -3888,6 +3889,11 @@
"react": "^18 || ^19"
}
},
"node_modules/@timephy/rnnoise-wasm": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/@timephy/rnnoise-wasm/-/rnnoise-wasm-1.0.0.tgz",
"integrity": "sha512-zzRdFyALbhaNIuEo3LKazPWxabatbPxkaBrHzRjGDlVHX2dFklh68HUENHlvHkKsq+OOQSC1ag5sGPDMDgg2CA=="
},
"node_modules/@ts-morph/common": {
"version": "0.25.0",
"resolved": "https://registry.npmjs.org/@ts-morph/common/-/common-0.25.0.tgz",
+2 -1
View File
@@ -1,7 +1,7 @@
{
"name": "meet",
"private": true,
"version": "0.1.25",
"version": "0.1.26",
"type": "module",
"scripts": {
"dev": "panda codegen && vite",
@@ -20,6 +20,7 @@
"@react-aria/toast": "3.0.2",
"@remixicon/react": "4.6.0",
"@tanstack/react-query": "5.76.0",
"@timephy/rnnoise-wasm": "1.0.0",
"crisp-sdk-web": "1.0.25",
"hoofd": "1.7.3",
"i18next": "25.1.2",
@@ -2,4 +2,5 @@ export enum FeatureFlags {
Transcript = 'transcription-summary',
ScreenRecording = 'screen-recording',
faceLandmarks = 'face-landmarks',
noiseReduction = 'noise-reduction',
}
@@ -0,0 +1,32 @@
import { useEffect } from 'react'
import { Track } from 'livekit-client'
import { useRoomContext } from '@livekit/components-react'
import { RnnNoiseProcessor } from '../processors/RnnNoiseProcessor'
import { usePersistentUserChoices } from './usePersistentUserChoices'
import { useNoiseReductionAvailable } from '@/features/rooms/livekit/hooks/useNoiseReductionAvailable'
export const useNoiseReduction = () => {
const room = useRoomContext()
const noiseReductionAvailable = useNoiseReductionAvailable()
const {
userChoices: { noiseReductionEnabled },
} = usePersistentUserChoices()
const audioTrack = room.localParticipant.getTrackPublication(
Track.Source.Microphone
)?.audioTrack
useEffect(() => {
if (!audioTrack || !noiseReductionAvailable) return
const processor = audioTrack?.getProcessor()
if (noiseReductionEnabled && !processor) {
const rnnNoiseProcessor = new RnnNoiseProcessor()
audioTrack.setProcessor(rnnNoiseProcessor)
} else if (!noiseReductionEnabled && processor) {
audioTrack.stopProcessor()
}
}, [audioTrack, noiseReductionEnabled, noiseReductionAvailable])
}
@@ -0,0 +1,13 @@
import { useIsMobile } from '@/utils/useIsMobile'
import { useFeatureFlagEnabled } from 'posthog-js/react'
import { FeatureFlags } from '@/features/analytics/enums'
import { useIsAnalyticsEnabled } from '@/features/analytics/hooks/useIsAnalyticsEnabled'
export const useNoiseReductionAvailable = () => {
const featureEnabled = useFeatureFlagEnabled(FeatureFlags.faceLandmarks)
const isAnalyticsEnabled = useIsAnalyticsEnabled()
const isMobile = useIsMobile()
return !isMobile && (!isAnalyticsEnabled || featureEnabled)
}
@@ -22,6 +22,9 @@ export function usePersistentUserChoices() {
saveUsername: (username: string) => {
userChoicesStore.username = username
},
saveNoiseReductionEnabled: (enabled: boolean) => {
userChoicesStore.noiseReductionEnabled = enabled
},
saveProcessorSerialized: (
processorSerialized: ProcessorSerialized | undefined
) => {
@@ -31,6 +31,7 @@ import { useSidePanel } from '../hooks/useSidePanel'
import { RecordingStateToast } from '@/features/recording'
import { ScreenShareErrorModal } from '../components/ScreenShareErrorModal'
import { useConnectionObserver } from '../hooks/useConnectionObserver'
import { useNoiseReduction } from '../hooks/useNoiseReduction'
const LayoutWrapper = styled(
'div',
@@ -87,6 +88,8 @@ export function VideoConference({ ...props }: VideoConferenceProps) {
const layoutContext = useCreateLayoutContext()
useNoiseReduction()
const screenShareTracks = tracks
.filter(isTrackReference)
.filter((track) => track.publication.source === Track.Source.ScreenShare)
@@ -0,0 +1,102 @@
import { Track, TrackProcessor, ProcessorOptions } from 'livekit-client'
import { NoiseSuppressorWorklet_Name } from '@timephy/rnnoise-wasm'
// This is an example how to get the script path using Vite, may be different when using other build tools
// NOTE: `?worker&url` is important (`worker` to generate a working script, `url` to get its url to load it)
import NoiseSuppressorWorklet from '@timephy/rnnoise-wasm/NoiseSuppressorWorklet?worker&url'
// Use Jitsi's approach: maintain a global AudioContext variable
// and suspend/resume it as needed to manage audio state
let audioContext: AudioContext
export interface AudioProcessorInterface
extends TrackProcessor<Track.Kind.Audio> {
name: string
}
export class RnnNoiseProcessor implements AudioProcessorInterface {
name: string = 'noise-reduction'
processedTrack?: MediaStreamTrack
private source?: MediaStreamTrack
private sourceNode?: MediaStreamAudioSourceNode
private destinationNode?: MediaStreamAudioDestinationNode
private noiseSuppressionNode?: AudioWorkletNode
constructor() {}
async init(opts: ProcessorOptions<Track.Kind.Audio>) {
if (!opts.track) {
throw new Error('Track is required for audio processing')
}
this.source = opts.track as MediaStreamTrack
if (!audioContext) {
audioContext = new AudioContext()
} else {
await audioContext.resume()
}
await audioContext.audioWorklet.addModule(NoiseSuppressorWorklet)
this.sourceNode = audioContext.createMediaStreamSource(
new MediaStream([this.source])
)
this.noiseSuppressionNode = new AudioWorkletNode(
audioContext,
NoiseSuppressorWorklet_Name
)
this.destinationNode = audioContext.createMediaStreamDestination()
// Connect the audio processing chain
this.sourceNode
.connect(this.noiseSuppressionNode)
.connect(this.destinationNode)
// Get the processed track
const tracks = this.destinationNode.stream.getAudioTracks()
if (tracks.length === 0) {
throw new Error('No audio tracks found for processing')
}
this.processedTrack = tracks[0]
}
async restart(opts: ProcessorOptions<Track.Kind.Audio>) {
await this.destroy()
return this.init(opts)
}
async destroy() {
// Clean up audio nodes and context
this.sourceNode?.disconnect()
this.noiseSuppressionNode?.disconnect()
this.destinationNode?.disconnect()
/**
* Audio Context Lifecycle Management
*
* We prefer suspending the audio context rather than destroying and recreating it
* to avoid memory leaks in WebAssembly-based audio processing.
*
* Issue: When an AudioContext containing WebAssembly modules is destroyed,
* the WASM resources are not properly garbage collected. This causes:
* - Retained JavaScript VM instances
* - Growing memory consumption over multiple create/destroy cycles
* - Potential performance degradation
*
* Solution: Use suspend() and resume() methods instead of close() to maintain
* the same context instance while controlling audio processing state.
*/
await audioContext.suspend()
this.sourceNode = undefined
this.destinationNode = undefined
this.source = undefined
this.processedTrack = undefined
this.noiseSuppressionNode = undefined
}
}
@@ -1,4 +1,4 @@
import { DialogProps, Field, H } from '@/primitives'
import { DialogProps, Field, H, Switch } from '@/primitives'
import { TabPanel, TabPanelProps } from '@/primitives/Tabs'
import {
@@ -13,16 +13,46 @@ import { HStack } from '@/styled-system/jsx'
import { ActiveSpeaker } from '@/features/rooms/components/ActiveSpeaker'
import { usePersistentUserChoices } from '@/features/rooms/livekit/hooks/usePersistentUserChoices'
import { ReactNode } from 'react'
import { css } from '@/styled-system/css'
import posthog from 'posthog-js'
import { useNoiseReductionAvailable } from '@/features/rooms/livekit/hooks/useNoiseReductionAvailable'
type RowWrapperProps = {
heading: string
children: ReactNode[]
beta?: boolean
}
const RowWrapper = ({ heading, children }: RowWrapperProps) => {
const BetaBadge = () => (
<span
className={css({
content: '"Beta"',
display: 'block',
letterSpacing: '-0.02rem',
padding: '0 0.25rem',
backgroundColor: '#E8EDFF',
color: '#0063CB',
fontSize: '12px',
fontWeight: 500,
margin: '0 0 0.9375rem 0.3125rem',
lineHeight: '1rem',
borderRadius: '4px',
width: 'fit-content',
height: 'fit-content',
marginTop: { base: '10px', sm: '5px' },
})}
>
Beta
</span>
)
const RowWrapper = ({ heading, children, beta }: RowWrapperProps) => {
return (
<>
<H lvl={2}>{heading}</H>
<HStack>
<H lvl={2}>{heading}</H>
{beta && <BetaBadge />}
</HStack>
<HStack
gap={0}
style={{
@@ -61,7 +91,11 @@ export const AudioTab = ({ id }: AudioTabProps) => {
const { t } = useTranslation('settings')
const { localParticipant } = useRoomContext()
const { saveAudioInputDeviceId } = usePersistentUserChoices()
const {
userChoices: { noiseReductionEnabled },
saveAudioInputDeviceId,
saveNoiseReductionEnabled,
} = usePersistentUserChoices()
const isSpeaking = useIsSpeaking(localParticipant)
@@ -109,6 +143,8 @@ export const AudioTab = ({ id }: AudioTabProps) => {
return defaultItem.value
}
const noiseReductionAvailable = useNoiseReductionAvailable()
return (
<TabPanel padding={'md'} flex id={id}>
<RowWrapper heading={t('audio.microphone.heading')}>
@@ -158,6 +194,23 @@ export const AudioTab = ({ id }: AudioTabProps) => {
<SoundTester />
</RowWrapper>
)}
{noiseReductionAvailable && (
<RowWrapper heading={t('audio.noiseReduction.heading')} beta>
<Switch
aria-label={t(
`audio.noiseReduction.ariaLabel.${noiseReductionEnabled ? 'disable' : 'enable'}`
)}
isSelected={noiseReductionEnabled}
onChange={(v) => {
saveNoiseReductionEnabled(v)
if (v) posthog.capture('noise-reduction-init')
}}
>
{t('audio.noiseReduction.label')}
</Switch>
<div />
</RowWrapper>
)}
</TabPanel>
)
}
@@ -12,6 +12,14 @@
"label": "Wählen Sie Ihre Audioeingabe",
"disabled": "Mikrofon deaktiviert"
},
"noiseReduction": {
"label": "Rauschunterdrückung",
"heading": "Rauschunterdrückung",
"ariaLabel": {
"enable": "Rauschunterdrückung aktivieren",
"disable": "Rauschunterdrückung deaktivieren"
}
},
"speakers": {
"heading": "Lautsprecher",
"label": "Wählen Sie Ihre Audioausgabe",
@@ -12,6 +12,14 @@
"label": "Select your audio input",
"disabled": "Microphone disabled"
},
"noiseReduction": {
"label": "Noise reduction",
"heading": "Noise reduction",
"ariaLabel": {
"enable": "Enable noise reduction",
"disable": "Disable noise reduction"
}
},
"speakers": {
"heading": "Speakers",
"label": "Select your audio output",
@@ -12,6 +12,14 @@
"label": "Sélectionner votre entrée audio",
"disabled": "Micro désactivé"
},
"noiseReduction": {
"label": "Réduction de bruit",
"heading": "Réduction de bruit",
"ariaLabel": {
"enable": "Activer la réduction du bruit",
"disable": "Désactiver la réduction du bruit"
}
},
"speakers": {
"heading": "Haut-parleurs",
"label": "Sélectionner votre sortie audio",
@@ -12,6 +12,14 @@
"label": "Selecteer uw audioinvoer",
"disabled": "Microfoon uitgeschakeld"
},
"noiseReduction": {
"label": "Ruisonderdrukking",
"heading": "Ruisonderdrukking",
"ariaLabel": {
"enable": "Ruisonderdrukking inschakelen",
"disable": "Ruisonderdrukking uitschakelen"
}
},
"speakers": {
"heading": "Luidsprekers",
"label": "Selecteer uw audio-uitvoer",
+5 -1
View File
@@ -8,10 +8,14 @@ import {
export type LocalUserChoices = LocalUserChoicesLK & {
processorSerialized?: ProcessorSerialized
noiseReductionEnabled?: boolean
}
function getUserChoicesState(): LocalUserChoices {
return loadUserChoices()
return {
noiseReductionEnabled: false,
...loadUserChoices(),
}
}
export const userChoicesStore = proxy<LocalUserChoices>(getUserChoicesState())
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "mail_mjml",
"version": "0.1.25",
"version": "0.1.26",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "mail_mjml",
"version": "0.1.25",
"version": "0.1.26",
"license": "MIT",
"dependencies": {
"@html-to/text-cli": "0.5.4",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "mail_mjml",
"version": "0.1.25",
"version": "0.1.26",
"description": "An util to generate html and text django's templates from mjml templates",
"type": "module",
"dependencies": {
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "sdk",
"version": "0.1.25",
"version": "0.1.26",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "sdk",
"version": "0.1.25",
"version": "0.1.26",
"license": "ISC",
"workspaces": [
"./library",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "sdk",
"version": "0.1.25",
"version": "0.1.26",
"author": "",
"license": "ISC",
"description": "",
+1 -1
View File
@@ -1,7 +1,7 @@
[project]
name = "summary"
version = "0.1.25"
version = "0.1.26"
dependencies = [
"fastapi[standard]>=0.105.0",
"uvicorn>=0.24.0",