This commit is contained in:
parent
6f9c24ac60
commit
270785a21f
4 changed files with 254 additions and 115 deletions
166
desktop/app.js
166
desktop/app.js
|
|
@ -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,
|
});
|
||||||
});
|
await processor.initialize();
|
||||||
const timeoutMs = 2500;
|
const workletNode = await processor.createAudioWorkletNode(ctx);
|
||||||
const withTimeout = (promise, label) =>
|
processor.setNoiseSuppressionEnabled(true);
|
||||||
Promise.race([
|
source.connect(workletNode);
|
||||||
promise,
|
processedSource = workletNode;
|
||||||
new Promise((_, reject) => setTimeout(() => reject(new Error(`${label} timed out`)), timeoutMs)),
|
state.voice.deepFilterProcessor = processor;
|
||||||
]);
|
console.info('DeepFilterNet3 enabled');
|
||||||
|
} catch (err) {
|
||||||
await withTimeout(processor.initialize(), "DeepFilter initialize");
|
console.warn('DeepFilterNet3 unavailable, falling back to raw mic audio', err);
|
||||||
const workletNode = await withTimeout(processor.createAudioWorkletNode(ctx), "DeepFilter worklet");
|
state.voice.deepFilterProcessor = null;
|
||||||
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';
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 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,8 +935,7 @@ 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;
|
||||||
if (state.voice.muted) {
|
if (state.voice.muted) {
|
||||||
|
|
@ -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) => {
|
||||||
el.voiceConnection.classList.add("hidden");
|
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");
|
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();
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
203
static/app.js
203
static/app.js
|
|
@ -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,
|
});
|
||||||
});
|
await processor.initialize();
|
||||||
const timeoutMs = 2500;
|
const workletNode = await processor.createAudioWorkletNode(ctx);
|
||||||
const withTimeout = (promise, label) =>
|
processor.setNoiseSuppressionEnabled(true);
|
||||||
Promise.race([
|
source.connect(workletNode);
|
||||||
promise,
|
processedSource = workletNode;
|
||||||
new Promise((_, reject) => setTimeout(() => reject(new Error(`${label} timed out`)), timeoutMs)),
|
state.voice.deepFilterProcessor = processor;
|
||||||
]);
|
console.info('DeepFilterNet3 enabled');
|
||||||
|
} catch (err) {
|
||||||
await withTimeout(processor.initialize(), "DeepFilter initialize");
|
console.warn('DeepFilterNet3 unavailable, falling back to raw mic audio', err);
|
||||||
const workletNode = await withTimeout(processor.createAudioWorkletNode(ctx), "DeepFilter worklet");
|
state.voice.deepFilterProcessor = null;
|
||||||
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';
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Metering/Speaking detection with adaptive threshold and hysteresis.
|
// Metering/Speaking detection with adaptive threshold and hysteresis.
|
||||||
|
|
@ -803,7 +781,7 @@ async function buildAudioPipeline(rawStream) {
|
||||||
const checkVolume = () => {
|
const checkVolume = () => {
|
||||||
if (!state.voice.audioContext || state.voice.audioContext.state === 'closed') return;
|
if (!state.voice.audioContext || state.voice.audioContext.state === 'closed') return;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
if (supportsFloatTimeDomain) {
|
if (supportsFloatTimeDomain) {
|
||||||
analyser.getFloatTimeDomainData(timeData);
|
analyser.getFloatTimeDomainData(timeData);
|
||||||
} else {
|
} else {
|
||||||
|
|
@ -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) => {
|
||||||
el.voiceConnection.classList.add("hidden");
|
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");
|
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();
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue