fiiix
All checks were successful
/ upload (release) Successful in 3m16s

This commit is contained in:
pavel 2026-02-26 00:13:15 +01:00
commit 270785a21f
4 changed files with 254 additions and 115 deletions

View file

@ -23,8 +23,6 @@ const state = {
muted: false, muted: false,
sharingVideo: false, sharingVideo: false,
sharingScreen: false, sharingScreen: false,
noiseFilterMode: 'off',
activeNoiseFilter: 'off',
deepFilterProcessor: null, deepFilterProcessor: null,
deepFilterModule: null, deepFilterModule: null,
viewMode: 'chat', // 'chat' or 'video' viewMode: 'chat', // 'chat' or 'video'
@ -388,7 +386,12 @@ function renderChannels() {
renderDMs(); renderDMs();
updateView(); updateView();
updateHeaderLabels(); 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(); if (window.innerWidth <= 768) closeMobileMenus();
}; };
@ -723,7 +726,6 @@ function stopAndClearAudioPipeline() {
state.voice.localStream = null; state.voice.localStream = null;
state.voice.rawStream = null; state.voice.rawStream = null;
state.voice.audioContext = null; state.voice.audioContext = null;
state.voice.activeNoiseFilter = 'off';
} }
function stopAndClearVideoPipeline() { function stopAndClearVideoPipeline() {
@ -796,34 +798,25 @@ async function buildAudioPipeline(rawStream) {
state.voice.audioContext = ctx; state.voice.audioContext = ctx;
const source = ctx.createMediaStreamSource(rawStream); const source = ctx.createMediaStreamSource(rawStream);
let processedSource = source; let processedSource = source;
state.voice.activeNoiseFilter = 'off';
if (state.voice.noiseFilterMode === 'deepfilter') {
try { try {
const deepFilter = await getDeepFilterModule(); const deepFilter = await getDeepFilterModule();
const processor = new deepFilter.DeepFilterNet3Core({ const processor = new deepFilter.DeepFilterNet3Core({
sampleRate: 48000, sampleRate: 48000,
noiseReductionLevel: 70, noiseReductionLevel: 70,
}); });
const timeoutMs = 2500; await processor.initialize();
const withTimeout = (promise, label) => const workletNode = await processor.createAudioWorkletNode(ctx);
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); processor.setNoiseSuppressionEnabled(true);
state.voice.deepFilterProcessor = processor;
source.connect(workletNode); source.connect(workletNode);
processedSource = workletNode; processedSource = workletNode;
state.voice.activeNoiseFilter = 'deepfilter'; state.voice.deepFilterProcessor = processor;
console.info('DeepFilterNet3 enabled');
} catch (err) { } catch (err) {
console.warn("DeepFilterNet3 unavailable, falling back to raw mic audio", err); console.warn('DeepFilterNet3 unavailable, falling back to raw mic audio', err);
state.voice.activeNoiseFilter = 'off'; state.voice.deepFilterProcessor = null;
}
} }
// Metering/Speaking detection with adaptive threshold and hysteresis. // Metering/Speaking detection with adaptive threshold and hysteresis.
const analyser = ctx.createAnalyser(); const analyser = ctx.createAnalyser();
analyser.fftSize = 1024; analyser.fftSize = 1024;
@ -913,7 +906,7 @@ async function buildAudioPipeline(rawStream) {
function resolveDeepFilterModuleUrl() { function resolveDeepFilterModuleUrl() {
if (location.protocol === 'file:') { 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'; return '/static/vendor/deepfilternet3-noise-filter.esm.js';
} }
@ -926,13 +919,12 @@ async function getDeepFilterModule() {
} }
async function createLocalVoiceStream() { async function createLocalVoiceStream() {
const useDeepFilter = state.voice.noiseFilterMode === 'deepfilter';
const constraints = { const constraints = {
audio: { audio: {
channelCount: 1, channelCount: 1,
sampleRate: 48000, sampleRate: 48000,
echoCancellation: true, echoCancellation: true,
noiseSuppression: !useDeepFilter, noiseSuppression: false,
autoGainControl: false, autoGainControl: false,
}, },
video: false, video: false,
@ -943,7 +935,6 @@ async function createLocalVoiceStream() {
localStream = await buildAudioPipeline(rawStream); localStream = await buildAudioPipeline(rawStream);
} catch (err) { } catch (err) {
console.warn('voice audio pipeline failed, using raw mic stream', err); console.warn('voice audio pipeline failed, using raw mic stream', err);
state.voice.activeNoiseFilter = 'off';
} }
state.voice.rawStream = rawStream; state.voice.rawStream = rawStream;
state.voice.localStream = localStream; state.voice.localStream = localStream;
@ -1049,6 +1040,26 @@ function ensurePeerConnection(peerId) {
return pc; 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) { async function handleSignal(fromPeerId, kind, data) {
const pc = ensurePeerConnection(fromPeerId); const pc = ensurePeerConnection(fromPeerId);
@ -1084,25 +1095,71 @@ async function handleSignal(fromPeerId, kind, data) {
} }
async function joinVoice() { async function joinVoice() {
if (!state.selectedVoiceChannelId) return; if (!state.selectedVoiceChannelId) {
if (state.voice.joinedChannelId === state.selectedVoiceChannelId) return; alert('No voice channel selected.');
return;
await leaveVoice(); }
if (state.voice.joinedChannelId === state.selectedVoiceChannelId &&
try { state.voice.ws &&
await createLocalVoiceStream(); state.voice.ws.readyState === WebSocket.OPEN) {
} catch (err) {
console.error('failed to initialize local voice stream', err);
return; return;
} }
await leaveVoice();
el.voiceConnection.classList.remove('hidden');
el.vcChannelName.textContent = 'Connecting...';
const ws = new WebSocket(getVoiceWsUrl(state.selectedVoiceChannelId)); const ws = new WebSocket(getVoiceWsUrl(state.selectedVoiceChannelId));
const currentWs = ws;
let wsOpened = false;
let micUnavailable = false;
state.voice.ws = ws; state.voice.ws = ws;
state.voice.joinedChannelId = state.selectedVoiceChannelId; 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 = () => { ws.onopen = () => {
if (state.voice.ws !== currentWs) return;
wsOpened = true;
clearTimeout(connectTimer);
const channel = state.channels.find(c => c.id === state.selectedVoiceChannelId); const channel = state.channels.find(c => c.id === state.selectedVoiceChannelId);
el.vcChannelName.textContent = channel ? channel.name : "Voice"; 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.voiceConnection.classList.remove("hidden");
el.soundboard.classList.remove("hidden"); el.soundboard.classList.remove("hidden");
playSound('join'); playSound('join');
@ -1111,6 +1168,7 @@ async function joinVoice() {
}; };
ws.onmessage = async (event) => { ws.onmessage = async (event) => {
if (state.voice.ws !== currentWs) return;
const msg = JSON.parse(event.data); const msg = JSON.parse(event.data);
if (msg.type === "peers") { if (msg.type === "peers") {
for (const peer of msg.peers) { for (const peer of msg.peers) {
@ -1153,8 +1211,24 @@ async function joinVoice() {
refreshVoicePresence().catch(() => { }); refreshVoicePresence().catch(() => { });
}; };
ws.onclose = () => { 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.voiceConnection.classList.add("hidden");
}
el.soundboard.classList.add("hidden"); el.soundboard.classList.add("hidden");
for (const pc of state.voice.peerConnections.values()) pc.close(); for (const pc of state.voice.peerConnections.values()) pc.close();
state.voice.peerConnections.clear(); state.voice.peerConnections.clear();

View file

@ -260,7 +260,7 @@
</div> </div>
</div> </div>
<script src="app.js" defer></script> <script src="app.js?v=20260225-voice-debug-1" defer></script>
</body> </body>
</html> </html>

View file

@ -23,8 +23,6 @@ const state = {
muted: false, muted: false,
sharingVideo: false, sharingVideo: false,
sharingScreen: false, sharingScreen: false,
noiseFilterMode: 'off',
activeNoiseFilter: 'off',
deepFilterProcessor: null, deepFilterProcessor: null,
deepFilterModule: null, deepFilterModule: null,
viewMode: 'chat', // 'chat' or 'video' viewMode: 'chat', // 'chat' or 'video'
@ -341,7 +339,12 @@ function renderChannels() {
renderDMs(); renderDMs();
updateView(); updateView();
updateHeaderLabels(); 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(); if (window.innerWidth <= 768) closeMobileMenus();
}; };
@ -671,7 +674,6 @@ function stopAndClearAudioPipeline() {
state.voice.localStream = null; state.voice.localStream = null;
state.voice.rawStream = null; state.voice.rawStream = null;
state.voice.audioContext = null; state.voice.audioContext = null;
state.voice.activeNoiseFilter = 'off';
} }
function stopAndClearVideoPipeline() { function stopAndClearVideoPipeline() {
@ -739,52 +741,28 @@ function renderVideo(peerId, displayName, stream, source) {
videoEl.srcObject = stream; 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) { async function buildAudioPipeline(rawStream) {
const ctx = new AudioContext(); const ctx = new AudioContext();
state.voice.audioContext = ctx; state.voice.audioContext = ctx;
const source = ctx.createMediaStreamSource(rawStream); const source = ctx.createMediaStreamSource(rawStream);
let processedSource = source; let processedSource = source;
state.voice.activeNoiseFilter = 'off';
if (state.voice.noiseFilterMode === 'deepfilter') {
try { try {
const deepFilter = await getDeepFilterModule(); const deepFilter = await getDeepFilterModule();
const processor = new deepFilter.DeepFilterNet3Core({ const processor = new deepFilter.DeepFilterNet3Core({
sampleRate: 48000, sampleRate: 48000,
noiseReductionLevel: 70, noiseReductionLevel: 70,
}); });
const timeoutMs = 2500; await processor.initialize();
const withTimeout = (promise, label) => const workletNode = await processor.createAudioWorkletNode(ctx);
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); processor.setNoiseSuppressionEnabled(true);
state.voice.deepFilterProcessor = processor;
source.connect(workletNode); source.connect(workletNode);
processedSource = workletNode; processedSource = workletNode;
state.voice.activeNoiseFilter = 'deepfilter'; state.voice.deepFilterProcessor = processor;
console.info('DeepFilterNet3 enabled');
} catch (err) { } catch (err) {
console.warn("DeepFilterNet3 unavailable, falling back to raw mic audio", err); console.warn('DeepFilterNet3 unavailable, falling back to raw mic audio', err);
state.voice.activeNoiseFilter = 'off'; state.voice.deepFilterProcessor = null;
}
} }
// Metering/Speaking detection with adaptive threshold and hysteresis. // Metering/Speaking detection with adaptive threshold and hysteresis.
@ -817,9 +795,7 @@ async function buildAudioPipeline(rawStream) {
let sumSquares = 0; let sumSquares = 0;
let peak = 0; let peak = 0;
for (let i = 0; i < timeData.length; i++) { for (let i = 0; i < timeData.length; i++) {
const v = supportsFloatTimeDomain const v = supportsFloatTimeDomain ? timeData[i] : (timeData[i] - 128) / 128;
? timeData[i]
: (timeData[i] - 128) / 128;
sumSquares += v * v; sumSquares += v * v;
const abs = Math.abs(v); const abs = Math.abs(v);
if (abs > peak) peak = abs; if (abs > peak) peak = abs;
@ -830,10 +806,8 @@ async function buildAudioPipeline(rawStream) {
levelEma = levelEma * 0.75 + level * 0.25; levelEma = levelEma * 0.75 + level * 0.25;
if (!localIsSpeaking) { if (!localIsSpeaking) {
// Learn room noise slowly while idle.
noiseFloor = noiseFloor * 0.98 + levelEma * 0.02; noiseFloor = noiseFloor * 0.98 + levelEma * 0.02;
} else { } else {
// Do not let noise floor jump up while speaking.
noiseFloor = Math.min(noiseFloor, levelEma); noiseFloor = Math.min(noiseFloor, levelEma);
} }
@ -849,21 +823,17 @@ async function buildAudioPipeline(rawStream) {
} }
const newSpeakingState = localIsSpeaking ? silenceFrames < 3 : speechFrames >= 2; const newSpeakingState = localIsSpeaking ? silenceFrames < 3 : speechFrames >= 2;
if (newSpeakingState !== localIsSpeaking) { if (newSpeakingState !== localIsSpeaking) {
localIsSpeaking = newSpeakingState; localIsSpeaking = newSpeakingState;
if (state.voice.ws && state.voice.ws.readyState === WebSocket.OPEN) { if (state.voice.ws && state.voice.ws.readyState === WebSocket.OPEN) {
state.voice.ws.send(JSON.stringify({ type: 'set_speaking_status', is_speaking: localIsSpeaking })); state.voice.ws.send(JSON.stringify({ type: 'set_speaking_status', is_speaking: localIsSpeaking }));
} }
// Local UI update
state.voicePresence.get(state.selectedVoiceChannelId)?.forEach(p => { state.voicePresence.get(state.selectedVoiceChannelId)?.forEach(p => {
if (p.user_id === state.me.id) p.is_speaking = localIsSpeaking; if (p.user_id === state.me.id) p.is_speaking = localIsSpeaking;
}); });
renderChannels(); renderChannels();
const myVideo = document.getElementById(`video-${state.me.id}-camera`) || document.getElementById(`video-${state.me.id}-screen`); const myVideo = document.getElementById(`video-${state.me.id}-camera`) || document.getElementById(`video-${state.me.id}-screen`);
if (myVideo) { if (myVideo) myVideo.parentElement.classList.toggle('speaking', localIsSpeaking);
myVideo.parentElement.classList.toggle('speaking', localIsSpeaking);
}
} }
setTimeout(checkVolume, 60); setTimeout(checkVolume, 60);
}; };
@ -874,14 +844,27 @@ async function buildAudioPipeline(rawStream) {
return destination.stream; 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() { async function createLocalVoiceStream() {
const useDeepFilter = state.voice.noiseFilterMode === 'deepfilter';
const constraints = { const constraints = {
audio: { audio: {
channelCount: 1, channelCount: 1,
sampleRate: 48000, sampleRate: 48000,
echoCancellation: true, echoCancellation: true,
noiseSuppression: !useDeepFilter, noiseSuppression: false,
autoGainControl: false, autoGainControl: false,
}, },
video: false, video: false,
@ -892,7 +875,6 @@ async function createLocalVoiceStream() {
localStream = await buildAudioPipeline(rawStream); localStream = await buildAudioPipeline(rawStream);
} catch (err) { } catch (err) {
console.warn('voice audio pipeline failed, using raw mic stream', err); console.warn('voice audio pipeline failed, using raw mic stream', err);
state.voice.activeNoiseFilter = 'off';
} }
state.voice.rawStream = rawStream; state.voice.rawStream = rawStream;
state.voice.localStream = localStream; state.voice.localStream = localStream;
@ -998,6 +980,26 @@ function ensurePeerConnection(peerId) {
return pc; 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) { async function handleSignal(fromPeerId, kind, data) {
const pc = ensurePeerConnection(fromPeerId); const pc = ensurePeerConnection(fromPeerId);
@ -1033,25 +1035,71 @@ async function handleSignal(fromPeerId, kind, data) {
} }
async function joinVoice() { async function joinVoice() {
if (!state.selectedVoiceChannelId) return; if (!state.selectedVoiceChannelId) {
if (state.voice.joinedChannelId === state.selectedVoiceChannelId) return; alert('No voice channel selected.');
return;
await leaveVoice(); }
if (state.voice.joinedChannelId === state.selectedVoiceChannelId &&
try { state.voice.ws &&
await createLocalVoiceStream(); state.voice.ws.readyState === WebSocket.OPEN) {
} catch (err) {
console.error('failed to initialize local voice stream', err);
return; return;
} }
await leaveVoice();
el.voiceConnection.classList.remove('hidden');
el.vcChannelName.textContent = 'Connecting...';
const ws = new WebSocket(getVoiceWsUrl(state.selectedVoiceChannelId)); const ws = new WebSocket(getVoiceWsUrl(state.selectedVoiceChannelId));
const currentWs = ws;
let wsOpened = false;
let micUnavailable = false;
state.voice.ws = ws; state.voice.ws = ws;
state.voice.joinedChannelId = state.selectedVoiceChannelId; 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 = () => { ws.onopen = () => {
if (state.voice.ws !== currentWs) return;
wsOpened = true;
clearTimeout(connectTimer);
const channel = state.channels.find(c => c.id === state.selectedVoiceChannelId); const channel = state.channels.find(c => c.id === state.selectedVoiceChannelId);
el.vcChannelName.textContent = channel ? channel.name : "Voice"; 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.voiceConnection.classList.remove("hidden");
el.soundboard.classList.remove("hidden"); el.soundboard.classList.remove("hidden");
playSound('join'); playSound('join');
@ -1060,6 +1108,7 @@ async function joinVoice() {
}; };
ws.onmessage = async (event) => { ws.onmessage = async (event) => {
if (state.voice.ws !== currentWs) return;
const msg = JSON.parse(event.data); const msg = JSON.parse(event.data);
if (msg.type === "peers") { if (msg.type === "peers") {
for (const peer of msg.peers) { for (const peer of msg.peers) {
@ -1102,8 +1151,24 @@ async function joinVoice() {
refreshVoicePresence().catch(() => { }); refreshVoicePresence().catch(() => { });
}; };
ws.onclose = () => { 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.voiceConnection.classList.add("hidden");
}
el.soundboard.classList.add("hidden"); el.soundboard.classList.add("hidden");
for (const pc of state.voice.peerConnections.values()) pc.close(); for (const pc of state.voice.peerConnections.values()) pc.close();
state.voice.peerConnections.clear(); state.voice.peerConnections.clear();

View file

@ -265,7 +265,7 @@
</div> </div>
</div> </div>
<script src="/static/app.js" defer></script> <script src="/static/app.js?v=20260225-voice-dfn-1" defer></script>
</body> </body>
</html> </html>