feat: RNNoise WASM AudioWorklet for ML-based noise suppression (Phase 3)

Inject @sapphi-red/web-noise-suppressor into AudioManager's input pipeline
as a toggleable AudioWorkletNode. The worklet is loaded lazily on first
enable, then kept alive — toggling bypasses by rewiring the graph without
destroying the WASM instance. Browser NS is forced off when RNNoise is
active to avoid double-processing. InputGain forced to stereo up-mix to
prevent mono-left-only output from the worklet. Also wires Phase 2 output
device routing (setSinkId) into AppLayout/ChannelSidebar.
This commit is contained in:
Jannis Braun
2026-02-23 00:28:29 +01:00
parent dbebd38576
commit ccf2001047
15 changed files with 345 additions and 49 deletions
+8 -5
View File
@@ -119,10 +119,7 @@ export const useVoiceStore = create()(persist((set, get) => ({
AudioManager.getInstance().setInputVolume(volume);
},
setOutputVolume: (volume) => set({ outputVolume: volume }),
setInputDevice: async (deviceId) => {
set({ inputDeviceId: deviceId });
await AudioManager.getInstance().setInputDevice(deviceId);
},
setInputDevice: (deviceId) => set({ inputDeviceId: deviceId }),
setOutputDevice: (deviceId) => set({ outputDeviceId: deviceId }),
toggleMic: () => set((state) => ({ isMuted: !state.isMuted })),
toggleDeafen: () => set((state) => ({ isDeafened: !state.isDeafened })),
@@ -133,9 +130,11 @@ export const useVoiceStore = create()(persist((set, get) => ({
noiseSuppression: true,
echoCancellation: true,
autoGainControl: false,
rnnoiseEnabled: false,
toggleNoiseSuppression: () => set((state) => ({ noiseSuppression: !state.noiseSuppression })),
setEchoCancellation: (enabled) => set({ echoCancellation: enabled }),
setAutoGainControl: (enabled) => set({ autoGainControl: enabled }),
toggleRnnoise: () => set((state) => ({ rnnoiseEnabled: !state.rnnoiseEnabled })),
deafenedUserIds: new Set(),
setUserDeafened: (userId, deafened) => {
set((state) => {
@@ -207,7 +206,7 @@ export const useVoiceStore = create()(persist((set, get) => ({
}),
}), {
name: 'opencord-voice-settings',
version: 2,
version: 3,
migrate: (persistedState, version) => {
if (version === 0) {
persistedState.streamAttenuationEnabled = false;
@@ -216,6 +215,9 @@ export const useVoiceStore = create()(persist((set, get) => ({
persistedState.echoCancellation = true;
persistedState.autoGainControl = false;
}
if (version < 3) {
persistedState.rnnoiseEnabled = false;
}
return persistedState;
},
storage: createJSONStorage(() => localStorage),
@@ -232,6 +234,7 @@ export const useVoiceStore = create()(persist((set, get) => ({
noiseSuppression: state.noiseSuppression,
echoCancellation: state.echoCancellation,
autoGainControl: state.autoGainControl,
rnnoiseEnabled: state.rnnoiseEnabled,
streamAttenuationEnabled: state.streamAttenuationEnabled,
streamAttenuationStrength: state.streamAttenuationStrength,
}),
+11 -6
View File
@@ -53,7 +53,7 @@ interface VoiceState {
setIsLiveKitConnected: (connected: boolean) => void;
setInputVolume: (volume: number) => void;
setOutputVolume: (volume: number) => void;
setInputDevice: (deviceId: string) => Promise<void>;
setInputDevice: (deviceId: string) => void;
setOutputDevice: (deviceId: string) => void;
toggleMic: () => void;
toggleCamera: () => void;
@@ -64,9 +64,11 @@ interface VoiceState {
noiseSuppression: boolean;
echoCancellation: boolean;
autoGainControl: boolean;
rnnoiseEnabled: boolean;
toggleNoiseSuppression: () => void;
setEchoCancellation: (enabled: boolean) => void;
setAutoGainControl: (enabled: boolean) => void;
toggleRnnoise: () => void;
deafenedUserIds: Set<string>;
setUserDeafened: (userId: string, deafened: boolean) => void;
// WebSocket-based voice user status (visible without joining LiveKit)
@@ -210,10 +212,7 @@ export const useVoiceStore = create<VoiceState>()(
},
setOutputVolume: (volume) => set({ outputVolume: volume }),
setInputDevice: async (deviceId) => {
set({ inputDeviceId: deviceId });
await AudioManager.getInstance().setInputDevice(deviceId);
},
setInputDevice: (deviceId) => set({ inputDeviceId: deviceId }),
setOutputDevice: (deviceId) => set({ outputDeviceId: deviceId }),
@@ -227,9 +226,11 @@ export const useVoiceStore = create<VoiceState>()(
noiseSuppression: true,
echoCancellation: true,
autoGainControl: false,
rnnoiseEnabled: false,
toggleNoiseSuppression: () => set((state) => ({ noiseSuppression: !state.noiseSuppression })),
setEchoCancellation: (enabled) => set({ echoCancellation: enabled }),
setAutoGainControl: (enabled) => set({ autoGainControl: enabled }),
toggleRnnoise: () => set((state) => ({ rnnoiseEnabled: !state.rnnoiseEnabled })),
deafenedUserIds: new Set(),
setUserDeafened: (userId, deafened) => {
set((state) => {
@@ -304,7 +305,7 @@ export const useVoiceStore = create<VoiceState>()(
}),
{
name: 'opencord-voice-settings',
version: 2,
version: 3,
migrate: (persistedState: any, version: number) => {
if (version === 0) {
persistedState.streamAttenuationEnabled = false;
@@ -313,6 +314,9 @@ export const useVoiceStore = create<VoiceState>()(
persistedState.echoCancellation = true;
persistedState.autoGainControl = false;
}
if (version < 3) {
persistedState.rnnoiseEnabled = false;
}
return persistedState;
},
storage: createJSONStorage(() => localStorage),
@@ -329,6 +333,7 @@ export const useVoiceStore = create<VoiceState>()(
noiseSuppression: state.noiseSuppression,
echoCancellation: state.echoCancellation,
autoGainControl: state.autoGainControl,
rnnoiseEnabled: state.rnnoiseEnabled,
streamAttenuationEnabled: state.streamAttenuationEnabled,
streamAttenuationStrength: state.streamAttenuationStrength,
}),