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
@@ -65,6 +65,10 @@ export function AppLayout() {
observer.observe(document.body, { childList: true, subtree: true });
return () => observer.disconnect();
}, []);
const outputDeviceId = useVoiceStore((s) => s.outputDeviceId);
useEffect(() => {
AudioManager.getInstance().setOutputDevice(outputDeviceId);
}, [outputDeviceId]);
const { user, isLoading } = useAuth();
const setCurrentServer = useServerStore((s) => s.setCurrentServer);
const loadServerDetail = useServerStore((s) => s.loadServerDetail);
@@ -69,6 +69,14 @@ export function AppLayout() {
return () => observer.disconnect();
}, []);
// Sync persisted output device preference to AudioManager.
// AudioManager defers setSinkId until the AudioContext is actually created,
// so this is safe to call before any user interaction.
const outputDeviceId = useVoiceStore((s) => s.outputDeviceId);
useEffect(() => {
AudioManager.getInstance().setOutputDevice(outputDeviceId);
}, [outputDeviceId]);
const { user, isLoading } = useAuth();
const setCurrentServer = useServerStore((s) => s.setCurrentServer);
const loadServerDetail = useServerStore((s) => s.loadServerDetail);
@@ -144,8 +144,16 @@ function UserAreaPanel({ user, isMuted, isDeafened, onMicToggle, onDeafenToggle,
await navigator.mediaDevices.getUserMedia({ audio: true }).then(s => s.getTracks().forEach(t => t.stop()));
}
const devices = await navigator.mediaDevices.enumerateDevices();
const inputs = devices.filter(d => d.kind === 'audioinput');
const outputs = devices.filter(d => d.kind === 'audiooutput');
const dedup = (list) => {
const seen = new Set();
return list.filter(d => {
if (seen.has(d.deviceId)) return false;
seen.add(d.deviceId);
return true;
});
};
const inputs = dedup(devices.filter(d => d.kind === 'audioinput'));
const outputs = dedup(devices.filter(d => d.kind === 'audiooutput'));
setInputDevices(inputs);
setOutputDevices(outputs);
const currentInput = inputs.find(d => d.deviceId === inputDeviceId);
@@ -220,6 +228,7 @@ function UserAreaPanel({ user, isMuted, isDeafened, onMicToggle, onDeafenToggle,
};
const selectInput = (device) => {
setInputDevice(device.deviceId);
AudioManager.getInstance().setInputDevice(device.deviceId);
setSelectedInputLabel(device.label || 'Default');
setShowInputDeviceList(false);
};
@@ -227,9 +236,7 @@ function UserAreaPanel({ user, isMuted, isDeafened, onMicToggle, onDeafenToggle,
setOutputDevice(device.deviceId);
setSelectedOutputLabel(device.label || 'Default');
setShowOutputDeviceList(false);
const room = getActiveRoom();
if (room)
room.switchActiveDevice('audiooutput', device.deviceId).catch(() => { });
AudioManager.getInstance().setOutputDevice(device.deviceId);
};
// Generate mic level bars (20 bars like Discord)
const micBars = 20;
@@ -408,8 +408,18 @@ function UserAreaPanel({
await navigator.mediaDevices.getUserMedia({ audio: true }).then(s => s.getTracks().forEach(t => t.stop()));
}
const devices = await navigator.mediaDevices.enumerateDevices();
const inputs = devices.filter(d => d.kind === 'audioinput');
const outputs = devices.filter(d => d.kind === 'audiooutput');
// Deduplicate by deviceId — USB devices sharing the same audio chipset
// (e.g. C-Media 0d8c:0134) appear as multiple entries with identical IDs.
const dedup = (list: MediaDeviceInfo[]): MediaDeviceInfo[] => {
const seen = new Set<string>();
return list.filter(d => {
if (seen.has(d.deviceId)) return false;
seen.add(d.deviceId);
return true;
});
};
const inputs = dedup(devices.filter(d => d.kind === 'audioinput'));
const outputs = dedup(devices.filter(d => d.kind === 'audiooutput'));
setInputDevices(inputs);
setOutputDevices(outputs);
@@ -483,7 +493,8 @@ function UserAreaPanel({
};
const selectInput = (device: MediaDeviceInfo) => {
setInputDevice(device.deviceId);
setInputDevice(device.deviceId); // Pure state update → triggers syncMic if in voice call
AudioManager.getInstance().setInputDevice(device.deviceId); // Immediate preview for mic level meter
setSelectedInputLabel(device.label || 'Default');
setShowInputDeviceList(false);
};
@@ -492,8 +503,7 @@ function UserAreaPanel({
setOutputDevice(device.deviceId);
setSelectedOutputLabel(device.label || 'Default');
setShowOutputDeviceList(false);
const room = getActiveRoom();
if (room) room.switchActiveDevice('audiooutput', device.deviceId).catch(() => {});
AudioManager.getInstance().setOutputDevice(device.deviceId);
};
// Generate mic level bars (20 bars like Discord)