signaling
All checks were successful
/ upload (release) Successful in 20s

This commit is contained in:
pavel 2026-02-24 01:25:27 +01:00
commit be1451ce45
3 changed files with 109 additions and 7 deletions

View file

@ -281,7 +281,7 @@ function renderChannels() {
pList.style.paddingLeft = "24px";
for (const p of participants) {
const pRow = document.createElement("div");
pRow.className = "channel-row";
pRow.className = `channel-row ${p.is_speaking ? 'voice-speaking' : ''}`;
pRow.style.padding = "2px 8px";
pRow.innerHTML = `<div class="avatar" style="width:20px;height:20px;font-size:10px">${shortName(p.display_name)}</div> <span>${escapeHtml(p.display_name)}</span>`;
pList.appendChild(pRow);
@ -629,15 +629,57 @@ function renderVideo(peerId, displayName, stream, source) {
}
async function buildAudioPipeline(rawStream) {
const audioContext = new AudioContext();
const source = audioContext.createMediaStreamSource(rawStream);
const destination = audioContext.createMediaStreamDestination();
state.voice.audioContext = audioContext;
const ctx = new AudioContext();
state.voice.audioContext = ctx;
const source = ctx.createMediaStreamSource(rawStream);
let head = source;
// Metering/Speaking detection
const analyser = ctx.createAnalyser();
analyser.fftSize = 512;
source.connect(analyser);
head.connect(destination);
const dataArray = new Uint8Array(analyser.frequencyBinCount);
let localIsSpeaking = false;
let speakCounter = 0;
const checkVolume = () => {
if (!state.voice.audioContext || state.voice.audioContext.state === 'closed') return;
analyser.getByteFrequencyData(dataArray);
let sum = 0;
for (let i = 0; i < dataArray.length; i++) sum += dataArray[i];
const avg = sum / dataArray.length;
const isCurrentlySpeaking = avg > 30; // Calibrated for normal speech
if (isCurrentlySpeaking) {
speakCounter = Math.min(speakCounter + 1, 5);
} else {
speakCounter = Math.max(speakCounter - 1, 0);
}
const newSpeakingState = speakCounter >= 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);
}
}
setTimeout(checkVolume, 100);
};
checkVolume();
const destination = ctx.createMediaStreamDestination();
source.connect(destination);
return destination.stream;
}
@ -839,6 +881,15 @@ async function joinVoice() {
if (!msg.is_sharing_screen) {
document.getElementById(`video-${msg.user_id}-screen`)?.parentElement?.remove();
}
} else if (msg.type === "speaking_status_changed") {
state.voicePresence.get(state.selectedVoiceChannelId)?.forEach(p => {
if (p.user_id === msg.user_id) p.is_speaking = msg.is_speaking;
});
renderChannels();
const videoEl = document.getElementById(`video-${msg.user_id}-camera`) || document.getElementById(`video-${msg.user_id}-screen`);
if (videoEl) {
videoEl.parentElement.classList.toggle('speaking', msg.is_speaking);
}
}
refreshVoicePresence().catch(() => { });
};

View file

@ -566,6 +566,19 @@ select {
font-size: 12px;
}
/* Voice Activity */
.voice-speaking .avatar {
box-shadow: 0 0 0 2px var(--green);
}
.video-item.speaking {
box-shadow: 0 0 0 4px var(--green);
}
.video-item.speaking .video-label {
background: var(--green);
}
/* Chat Pane */
.chat-pane {
display: flex;