diff --git a/desktop/app.js b/desktop/app.js index 530bc53..7dc4d8f 100644 --- a/desktop/app.js +++ b/desktop/app.js @@ -23,8 +23,6 @@ const state = { muted: false, sharingVideo: false, sharingScreen: false, - noiseFilterMode: 'off', - activeNoiseFilter: 'off', deepFilterProcessor: null, deepFilterModule: null, viewMode: 'chat', // 'chat' or 'video' @@ -388,7 +386,12 @@ function renderChannels() { renderDMs(); updateView(); updateHeaderLabels(); - joinVoice(); + try { + await joinVoice(); + } catch (err) { + console.error("joinVoice failed from channel click", err); + alert(`Voice join failed: ${err?.message || err}`); + } } if (window.innerWidth <= 768) closeMobileMenus(); }; @@ -723,7 +726,6 @@ function stopAndClearAudioPipeline() { state.voice.localStream = null; state.voice.rawStream = null; state.voice.audioContext = null; - state.voice.activeNoiseFilter = 'off'; } function stopAndClearVideoPipeline() { @@ -796,34 +798,25 @@ async function buildAudioPipeline(rawStream) { state.voice.audioContext = ctx; const source = ctx.createMediaStreamSource(rawStream); let processedSource = source; - state.voice.activeNoiseFilter = 'off'; - if (state.voice.noiseFilterMode === 'deepfilter') { - try { - const deepFilter = await getDeepFilterModule(); - const processor = new deepFilter.DeepFilterNet3Core({ - sampleRate: 48000, - noiseReductionLevel: 70, - }); - const timeoutMs = 2500; - const withTimeout = (promise, label) => - Promise.race([ - promise, - new Promise((_, reject) => setTimeout(() => reject(new Error(`${label} timed out`)), timeoutMs)), - ]); - - await withTimeout(processor.initialize(), "DeepFilter initialize"); - const workletNode = await withTimeout(processor.createAudioWorkletNode(ctx), "DeepFilter worklet"); - processor.setNoiseSuppressionEnabled(true); - state.voice.deepFilterProcessor = processor; - source.connect(workletNode); - processedSource = workletNode; - state.voice.activeNoiseFilter = 'deepfilter'; - } catch (err) { - console.warn("DeepFilterNet3 unavailable, falling back to raw mic audio", err); - state.voice.activeNoiseFilter = 'off'; - } + try { + const deepFilter = await getDeepFilterModule(); + const processor = new deepFilter.DeepFilterNet3Core({ + sampleRate: 48000, + noiseReductionLevel: 70, + }); + await processor.initialize(); + const workletNode = await processor.createAudioWorkletNode(ctx); + processor.setNoiseSuppressionEnabled(true); + source.connect(workletNode); + processedSource = workletNode; + state.voice.deepFilterProcessor = processor; + console.info('DeepFilterNet3 enabled'); + } catch (err) { + console.warn('DeepFilterNet3 unavailable, falling back to raw mic audio', err); + state.voice.deepFilterProcessor = null; } + // Metering/Speaking detection with adaptive threshold and hysteresis. const analyser = ctx.createAnalyser(); analyser.fftSize = 1024; @@ -913,7 +906,7 @@ async function buildAudioPipeline(rawStream) { function resolveDeepFilterModuleUrl() { if (location.protocol === 'file:') { - return new URL('vendor/deepfilternet3-noise-filter.esm.js', location.href).toString(); + return new URL('./vendor/deepfilternet3-noise-filter.esm.js', location.href).toString(); } return '/static/vendor/deepfilternet3-noise-filter.esm.js'; } @@ -926,13 +919,12 @@ async function getDeepFilterModule() { } async function createLocalVoiceStream() { - const useDeepFilter = state.voice.noiseFilterMode === 'deepfilter'; const constraints = { audio: { channelCount: 1, sampleRate: 48000, echoCancellation: true, - noiseSuppression: !useDeepFilter, + noiseSuppression: false, autoGainControl: false, }, video: false, @@ -943,8 +935,7 @@ async function createLocalVoiceStream() { localStream = await buildAudioPipeline(rawStream); } catch (err) { console.warn('voice audio pipeline failed, using raw mic stream', err); - state.voice.activeNoiseFilter = 'off'; - } + } state.voice.rawStream = rawStream; state.voice.localStream = localStream; if (state.voice.muted) { @@ -1049,6 +1040,26 @@ function ensurePeerConnection(peerId) { return pc; } +async function attachLocalAudioToPeerConnections() { + if (!state.voice.localStream) return; + const localTrack = state.voice.localStream.getAudioTracks()[0]; + if (!localTrack) return; + + await Promise.all( + Array.from(state.voice.peerConnections.values()).map(async (pc) => { + const audioTransceiver = pc.getTransceivers().find((t) => t.receiver?.track?.kind === 'audio'); + if (audioTransceiver?.sender) { + await audioTransceiver.sender.replaceTrack(localTrack); + if (audioTransceiver.direction === 'recvonly') { + audioTransceiver.direction = 'sendrecv'; + } + } else { + pc.addTrack(localTrack, state.voice.localStream); + } + }) + ); +} + async function handleSignal(fromPeerId, kind, data) { const pc = ensurePeerConnection(fromPeerId); @@ -1084,25 +1095,71 @@ async function handleSignal(fromPeerId, kind, data) { } async function joinVoice() { - if (!state.selectedVoiceChannelId) return; - if (state.voice.joinedChannelId === state.selectedVoiceChannelId) return; - - await leaveVoice(); - - try { - await createLocalVoiceStream(); - } catch (err) { - console.error('failed to initialize local voice stream', err); + if (!state.selectedVoiceChannelId) { + alert('No voice channel selected.'); + return; + } + if (state.voice.joinedChannelId === state.selectedVoiceChannelId && + state.voice.ws && + state.voice.ws.readyState === WebSocket.OPEN) { return; } + await leaveVoice(); + + el.voiceConnection.classList.remove('hidden'); + el.vcChannelName.textContent = 'Connecting...'; + const ws = new WebSocket(getVoiceWsUrl(state.selectedVoiceChannelId)); + const currentWs = ws; + let wsOpened = false; + let micUnavailable = false; + state.voice.ws = ws; state.voice.joinedChannelId = state.selectedVoiceChannelId; + // Start mic setup in parallel so channel join is not blocked by device init. + createLocalVoiceStream() + .then(async () => { + if (state.voice.ws !== currentWs) { + stopAndClearAudioPipeline(); + return; + } + try { + await attachLocalAudioToPeerConnections(); + } catch (err) { + console.warn('failed to attach local audio to existing peer connections', err); + } + }) + .catch((err) => { + micUnavailable = true; + console.warn('failed to initialize local voice stream, joining as listen-only', err); + state.voice.localStream = null; + state.voice.rawStream = null; + if (state.voice.audioContext) { + state.voice.audioContext.close().catch(() => { }); + state.voice.audioContext = null; + } + }); + + const connectTimer = setTimeout(() => { + if (!wsOpened && state.voice.ws === currentWs) { + console.error('voice websocket connect timeout'); + try { currentWs.close(); } catch (_) { } + el.vcChannelName.textContent = 'Connection timeout'; + alert('Could not connect to voice channel (timeout). Please try again.'); + } + }, 5000); + ws.onopen = () => { + if (state.voice.ws !== currentWs) return; + wsOpened = true; + clearTimeout(connectTimer); const channel = state.channels.find(c => c.id === state.selectedVoiceChannelId); el.vcChannelName.textContent = channel ? channel.name : "Voice"; + if (micUnavailable) { + console.warn('Voice joined in listen-only mode because microphone is unavailable.'); + } el.voiceConnection.classList.remove("hidden"); el.soundboard.classList.remove("hidden"); playSound('join'); @@ -1111,6 +1168,7 @@ async function joinVoice() { }; ws.onmessage = async (event) => { + if (state.voice.ws !== currentWs) return; const msg = JSON.parse(event.data); if (msg.type === "peers") { for (const peer of msg.peers) { @@ -1153,8 +1211,24 @@ async function joinVoice() { refreshVoicePresence().catch(() => { }); }; - ws.onclose = () => { - el.voiceConnection.classList.add("hidden"); + ws.onerror = (event) => { + if (state.voice.ws !== currentWs) return; + console.error('voice websocket error', event); + el.vcChannelName.textContent = 'Connection failed'; + }; + + ws.onclose = (event) => { + if (state.voice.ws !== currentWs) return; + clearTimeout(connectTimer); + if (!wsOpened) { + console.error('voice websocket closed before open', event.code, event.reason); + const code = event && typeof event.code === 'number' ? event.code : 'unknown'; + el.vcChannelName.textContent = `Connection failed (${code})`; + alert(`Voice connection failed (code ${code}).`); + } + if (wsOpened) { + el.voiceConnection.classList.add("hidden"); + } el.soundboard.classList.add("hidden"); for (const pc of state.voice.peerConnections.values()) pc.close(); state.voice.peerConnections.clear(); diff --git a/desktop/index.html b/desktop/index.html index 2bd8957..28521c9 100644 --- a/desktop/index.html +++ b/desktop/index.html @@ -260,7 +260,7 @@ - + diff --git a/static/app.js b/static/app.js index bad7f22..0996318 100644 --- a/static/app.js +++ b/static/app.js @@ -23,8 +23,6 @@ const state = { muted: false, sharingVideo: false, sharingScreen: false, - noiseFilterMode: 'off', - activeNoiseFilter: 'off', deepFilterProcessor: null, deepFilterModule: null, viewMode: 'chat', // 'chat' or 'video' @@ -341,7 +339,12 @@ function renderChannels() { renderDMs(); updateView(); updateHeaderLabels(); - joinVoice(); + try { + await joinVoice(); + } catch (err) { + console.error("joinVoice failed from channel click", err); + alert(`Voice join failed: ${err?.message || err}`); + } } if (window.innerWidth <= 768) closeMobileMenus(); }; @@ -671,7 +674,6 @@ function stopAndClearAudioPipeline() { state.voice.localStream = null; state.voice.rawStream = null; state.voice.audioContext = null; - state.voice.activeNoiseFilter = 'off'; } function stopAndClearVideoPipeline() { @@ -739,52 +741,28 @@ function renderVideo(peerId, displayName, stream, source) { videoEl.srcObject = stream; } -function resolveDeepFilterModuleUrl() { - if (location.protocol === 'file:') { - return new URL('vendor/deepfilternet3-noise-filter.esm.js', location.href).toString(); - } - return '/static/vendor/deepfilternet3-noise-filter.esm.js'; -} - -async function getDeepFilterModule() { - if (state.voice.deepFilterModule) return state.voice.deepFilterModule; - const moduleUrl = resolveDeepFilterModuleUrl(); - state.voice.deepFilterModule = await import(moduleUrl); - return state.voice.deepFilterModule; -} - async function buildAudioPipeline(rawStream) { const ctx = new AudioContext(); state.voice.audioContext = ctx; const source = ctx.createMediaStreamSource(rawStream); let processedSource = source; - state.voice.activeNoiseFilter = 'off'; - if (state.voice.noiseFilterMode === 'deepfilter') { - try { - const deepFilter = await getDeepFilterModule(); - const processor = new deepFilter.DeepFilterNet3Core({ - sampleRate: 48000, - noiseReductionLevel: 70, - }); - const timeoutMs = 2500; - const withTimeout = (promise, label) => - Promise.race([ - promise, - new Promise((_, reject) => setTimeout(() => reject(new Error(`${label} timed out`)), timeoutMs)), - ]); - - await withTimeout(processor.initialize(), "DeepFilter initialize"); - const workletNode = await withTimeout(processor.createAudioWorkletNode(ctx), "DeepFilter worklet"); - processor.setNoiseSuppressionEnabled(true); - state.voice.deepFilterProcessor = processor; - source.connect(workletNode); - processedSource = workletNode; - state.voice.activeNoiseFilter = 'deepfilter'; - } catch (err) { - console.warn("DeepFilterNet3 unavailable, falling back to raw mic audio", err); - state.voice.activeNoiseFilter = 'off'; - } + try { + const deepFilter = await getDeepFilterModule(); + const processor = new deepFilter.DeepFilterNet3Core({ + sampleRate: 48000, + noiseReductionLevel: 70, + }); + await processor.initialize(); + const workletNode = await processor.createAudioWorkletNode(ctx); + processor.setNoiseSuppressionEnabled(true); + source.connect(workletNode); + processedSource = workletNode; + state.voice.deepFilterProcessor = processor; + console.info('DeepFilterNet3 enabled'); + } catch (err) { + console.warn('DeepFilterNet3 unavailable, falling back to raw mic audio', err); + state.voice.deepFilterProcessor = null; } // Metering/Speaking detection with adaptive threshold and hysteresis. @@ -803,7 +781,7 @@ async function buildAudioPipeline(rawStream) { const checkVolume = () => { if (!state.voice.audioContext || state.voice.audioContext.state === 'closed') return; - try { + try { if (supportsFloatTimeDomain) { analyser.getFloatTimeDomainData(timeData); } else { @@ -817,9 +795,7 @@ async function buildAudioPipeline(rawStream) { let sumSquares = 0; let peak = 0; for (let i = 0; i < timeData.length; i++) { - const v = supportsFloatTimeDomain - ? timeData[i] - : (timeData[i] - 128) / 128; + const v = supportsFloatTimeDomain ? timeData[i] : (timeData[i] - 128) / 128; sumSquares += v * v; const abs = Math.abs(v); if (abs > peak) peak = abs; @@ -830,10 +806,8 @@ async function buildAudioPipeline(rawStream) { levelEma = levelEma * 0.75 + level * 0.25; if (!localIsSpeaking) { - // Learn room noise slowly while idle. noiseFloor = noiseFloor * 0.98 + levelEma * 0.02; } else { - // Do not let noise floor jump up while speaking. noiseFloor = Math.min(noiseFloor, levelEma); } @@ -849,21 +823,17 @@ async function buildAudioPipeline(rawStream) { } const newSpeakingState = localIsSpeaking ? silenceFrames < 3 : speechFrames >= 2; - if (newSpeakingState !== localIsSpeaking) { localIsSpeaking = newSpeakingState; if (state.voice.ws && state.voice.ws.readyState === WebSocket.OPEN) { state.voice.ws.send(JSON.stringify({ type: 'set_speaking_status', is_speaking: localIsSpeaking })); } - // Local UI update state.voicePresence.get(state.selectedVoiceChannelId)?.forEach(p => { if (p.user_id === state.me.id) p.is_speaking = localIsSpeaking; }); renderChannels(); const myVideo = document.getElementById(`video-${state.me.id}-camera`) || document.getElementById(`video-${state.me.id}-screen`); - if (myVideo) { - myVideo.parentElement.classList.toggle('speaking', localIsSpeaking); - } + if (myVideo) myVideo.parentElement.classList.toggle('speaking', localIsSpeaking); } setTimeout(checkVolume, 60); }; @@ -874,14 +844,27 @@ async function buildAudioPipeline(rawStream) { return destination.stream; } +function resolveDeepFilterModuleUrl() { + if (location.protocol === 'file:') { + return new URL('./vendor/deepfilternet3-noise-filter.esm.js', location.href).toString(); + } + return '/static/vendor/deepfilternet3-noise-filter.esm.js'; +} + +async function getDeepFilterModule() { + if (state.voice.deepFilterModule) return state.voice.deepFilterModule; + const moduleUrl = resolveDeepFilterModuleUrl(); + state.voice.deepFilterModule = await import(moduleUrl); + return state.voice.deepFilterModule; +} + async function createLocalVoiceStream() { - const useDeepFilter = state.voice.noiseFilterMode === 'deepfilter'; const constraints = { audio: { channelCount: 1, sampleRate: 48000, echoCancellation: true, - noiseSuppression: !useDeepFilter, + noiseSuppression: false, autoGainControl: false, }, video: false, @@ -892,7 +875,6 @@ async function createLocalVoiceStream() { localStream = await buildAudioPipeline(rawStream); } catch (err) { console.warn('voice audio pipeline failed, using raw mic stream', err); - state.voice.activeNoiseFilter = 'off'; } state.voice.rawStream = rawStream; state.voice.localStream = localStream; @@ -998,6 +980,26 @@ function ensurePeerConnection(peerId) { return pc; } +async function attachLocalAudioToPeerConnections() { + if (!state.voice.localStream) return; + const localTrack = state.voice.localStream.getAudioTracks()[0]; + if (!localTrack) return; + + await Promise.all( + Array.from(state.voice.peerConnections.values()).map(async (pc) => { + const audioTransceiver = pc.getTransceivers().find((t) => t.receiver?.track?.kind === 'audio'); + if (audioTransceiver?.sender) { + await audioTransceiver.sender.replaceTrack(localTrack); + if (audioTransceiver.direction === 'recvonly') { + audioTransceiver.direction = 'sendrecv'; + } + } else { + pc.addTrack(localTrack, state.voice.localStream); + } + }) + ); +} + async function handleSignal(fromPeerId, kind, data) { const pc = ensurePeerConnection(fromPeerId); @@ -1033,25 +1035,71 @@ async function handleSignal(fromPeerId, kind, data) { } async function joinVoice() { - if (!state.selectedVoiceChannelId) return; - if (state.voice.joinedChannelId === state.selectedVoiceChannelId) return; - - await leaveVoice(); - - try { - await createLocalVoiceStream(); - } catch (err) { - console.error('failed to initialize local voice stream', err); + if (!state.selectedVoiceChannelId) { + alert('No voice channel selected.'); + return; + } + if (state.voice.joinedChannelId === state.selectedVoiceChannelId && + state.voice.ws && + state.voice.ws.readyState === WebSocket.OPEN) { return; } + await leaveVoice(); + + el.voiceConnection.classList.remove('hidden'); + el.vcChannelName.textContent = 'Connecting...'; + const ws = new WebSocket(getVoiceWsUrl(state.selectedVoiceChannelId)); + const currentWs = ws; + let wsOpened = false; + let micUnavailable = false; + state.voice.ws = ws; state.voice.joinedChannelId = state.selectedVoiceChannelId; + // Start mic setup in parallel so channel join is not blocked by device init. + createLocalVoiceStream() + .then(async () => { + if (state.voice.ws !== currentWs) { + stopAndClearAudioPipeline(); + return; + } + try { + await attachLocalAudioToPeerConnections(); + } catch (err) { + console.warn('failed to attach local audio to existing peer connections', err); + } + }) + .catch((err) => { + micUnavailable = true; + console.warn('failed to initialize local voice stream, joining as listen-only', err); + state.voice.localStream = null; + state.voice.rawStream = null; + if (state.voice.audioContext) { + state.voice.audioContext.close().catch(() => { }); + state.voice.audioContext = null; + } + }); + + const connectTimer = setTimeout(() => { + if (!wsOpened && state.voice.ws === currentWs) { + console.error('voice websocket connect timeout'); + try { currentWs.close(); } catch (_) { } + el.vcChannelName.textContent = 'Connection timeout'; + alert('Could not connect to voice channel (timeout). Please try again.'); + } + }, 5000); + ws.onopen = () => { + if (state.voice.ws !== currentWs) return; + wsOpened = true; + clearTimeout(connectTimer); const channel = state.channels.find(c => c.id === state.selectedVoiceChannelId); el.vcChannelName.textContent = channel ? channel.name : "Voice"; + if (micUnavailable) { + console.warn('Voice joined in listen-only mode because microphone is unavailable.'); + } el.voiceConnection.classList.remove("hidden"); el.soundboard.classList.remove("hidden"); playSound('join'); @@ -1060,6 +1108,7 @@ async function joinVoice() { }; ws.onmessage = async (event) => { + if (state.voice.ws !== currentWs) return; const msg = JSON.parse(event.data); if (msg.type === "peers") { for (const peer of msg.peers) { @@ -1102,8 +1151,24 @@ async function joinVoice() { refreshVoicePresence().catch(() => { }); }; - ws.onclose = () => { - el.voiceConnection.classList.add("hidden"); + ws.onerror = (event) => { + if (state.voice.ws !== currentWs) return; + console.error('voice websocket error', event); + el.vcChannelName.textContent = 'Connection failed'; + }; + + ws.onclose = (event) => { + if (state.voice.ws !== currentWs) return; + clearTimeout(connectTimer); + if (!wsOpened) { + console.error('voice websocket closed before open', event.code, event.reason); + const code = event && typeof event.code === 'number' ? event.code : 'unknown'; + el.vcChannelName.textContent = `Connection failed (${code})`; + alert(`Voice connection failed (code ${code}).`); + } + if (wsOpened) { + el.voiceConnection.classList.add("hidden"); + } el.soundboard.classList.add("hidden"); for (const pc of state.voice.peerConnections.values()) pc.close(); state.voice.peerConnections.clear(); diff --git a/static/index.html b/static/index.html index de07abc..939922b 100644 --- a/static/index.html +++ b/static/index.html @@ -265,7 +265,7 @@ - +