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 @@
-
+