diff --git a/src/voice.rs b/src/voice.rs index e327d7a..4ea9825 100644 --- a/src/voice.rs +++ b/src/voice.rs @@ -18,6 +18,7 @@ struct ClientHandle { display_name: String, is_sharing_video: bool, is_sharing_screen: bool, + is_speaking: bool, tx: mpsc::UnboundedSender, } @@ -27,6 +28,7 @@ pub struct VoiceParticipant { pub display_name: String, pub is_sharing_video: bool, pub is_sharing_screen: bool, + pub is_speaking: bool, } #[derive(Serialize, Clone)] @@ -50,6 +52,10 @@ enum ServerEvent { user_id: Uuid, is_sharing_screen: bool, }, + SpeakingStatusChanged { + user_id: Uuid, + is_speaking: bool, + }, Signal { from_user_id: Uuid, kind: String, @@ -74,6 +80,9 @@ enum ClientEvent { SetScreenStatus { is_sharing_screen: bool, }, + SetSpeakingStatus { + is_speaking: bool, + }, } impl VoiceHub { @@ -89,6 +98,7 @@ impl VoiceHub { display_name: handle.display_name.clone(), is_sharing_video: handle.is_sharing_video, is_sharing_screen: handle.is_sharing_screen, + is_speaking: handle.is_speaking, }) .collect() } @@ -110,6 +120,7 @@ impl VoiceHub { display_name: peer.display_name.clone(), is_sharing_video: peer.is_sharing_video, is_sharing_screen: peer.is_sharing_screen, + is_speaking: peer.is_speaking, }) .collect::>(); @@ -119,6 +130,7 @@ impl VoiceHub { display_name: display_name.clone(), is_sharing_video: false, is_sharing_screen: false, + is_speaking: false, tx, }, ); @@ -213,6 +225,26 @@ impl VoiceHub { } } } + + pub async fn set_speaking_status(&self, room_id: Uuid, user_id: Uuid, is_speaking: bool) { + let mut rooms = self.rooms.write().await; + let Some(room) = rooms.get_mut(&room_id) else { + return; + }; + + if let Some(handle) = room.get_mut(&user_id) { + handle.is_speaking = is_speaking; + + for (peer_id, peer) in room.iter() { + if *peer_id != user_id { + let _ = peer.tx.send(ServerEvent::SpeakingStatusChanged { + user_id, + is_speaking, + }); + } + } + } + } } pub async fn handle_socket(state: AppState, socket: WebSocket, room_id: Uuid, user_id: Uuid) { @@ -267,6 +299,12 @@ pub async fn handle_socket(state: AppState, socket: WebSocket, room_id: Uuid, us .set_screen_status(room_id, user_id, is_sharing_screen) .await; } + Ok(ClientEvent::SetSpeakingStatus { is_speaking }) => { + state + .voice + .set_speaking_status(room_id, user_id, is_speaking) + .await; + } Err(err) => { let _ = tx.send(ServerEvent::Error { message: format!("invalid voice message: {err}"), diff --git a/static/app.js b/static/app.js index f6eaa85..8f01157 100644 --- a/static/app.js +++ b/static/app.js @@ -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 = `
${shortName(p.display_name)}
${escapeHtml(p.display_name)}`; 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(() => { }); }; diff --git a/static/styles.css b/static/styles.css index 3077a78..c0b681a 100644 --- a/static/styles.css +++ b/static/styles.css @@ -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;