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

@ -18,6 +18,7 @@ struct ClientHandle {
display_name: String, display_name: String,
is_sharing_video: bool, is_sharing_video: bool,
is_sharing_screen: bool, is_sharing_screen: bool,
is_speaking: bool,
tx: mpsc::UnboundedSender<ServerEvent>, tx: mpsc::UnboundedSender<ServerEvent>,
} }
@ -27,6 +28,7 @@ pub struct VoiceParticipant {
pub display_name: String, pub display_name: String,
pub is_sharing_video: bool, pub is_sharing_video: bool,
pub is_sharing_screen: bool, pub is_sharing_screen: bool,
pub is_speaking: bool,
} }
#[derive(Serialize, Clone)] #[derive(Serialize, Clone)]
@ -50,6 +52,10 @@ enum ServerEvent {
user_id: Uuid, user_id: Uuid,
is_sharing_screen: bool, is_sharing_screen: bool,
}, },
SpeakingStatusChanged {
user_id: Uuid,
is_speaking: bool,
},
Signal { Signal {
from_user_id: Uuid, from_user_id: Uuid,
kind: String, kind: String,
@ -74,6 +80,9 @@ enum ClientEvent {
SetScreenStatus { SetScreenStatus {
is_sharing_screen: bool, is_sharing_screen: bool,
}, },
SetSpeakingStatus {
is_speaking: bool,
},
} }
impl VoiceHub { impl VoiceHub {
@ -89,6 +98,7 @@ impl VoiceHub {
display_name: handle.display_name.clone(), display_name: handle.display_name.clone(),
is_sharing_video: handle.is_sharing_video, is_sharing_video: handle.is_sharing_video,
is_sharing_screen: handle.is_sharing_screen, is_sharing_screen: handle.is_sharing_screen,
is_speaking: handle.is_speaking,
}) })
.collect() .collect()
} }
@ -110,6 +120,7 @@ impl VoiceHub {
display_name: peer.display_name.clone(), display_name: peer.display_name.clone(),
is_sharing_video: peer.is_sharing_video, is_sharing_video: peer.is_sharing_video,
is_sharing_screen: peer.is_sharing_screen, is_sharing_screen: peer.is_sharing_screen,
is_speaking: peer.is_speaking,
}) })
.collect::<Vec<_>>(); .collect::<Vec<_>>();
@ -119,6 +130,7 @@ impl VoiceHub {
display_name: display_name.clone(), display_name: display_name.clone(),
is_sharing_video: false, is_sharing_video: false,
is_sharing_screen: false, is_sharing_screen: false,
is_speaking: false,
tx, 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) { 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) .set_screen_status(room_id, user_id, is_sharing_screen)
.await; .await;
} }
Ok(ClientEvent::SetSpeakingStatus { is_speaking }) => {
state
.voice
.set_speaking_status(room_id, user_id, is_speaking)
.await;
}
Err(err) => { Err(err) => {
let _ = tx.send(ServerEvent::Error { let _ = tx.send(ServerEvent::Error {
message: format!("invalid voice message: {err}"), message: format!("invalid voice message: {err}"),

View file

@ -281,7 +281,7 @@ function renderChannels() {
pList.style.paddingLeft = "24px"; pList.style.paddingLeft = "24px";
for (const p of participants) { for (const p of participants) {
const pRow = document.createElement("div"); const pRow = document.createElement("div");
pRow.className = "channel-row"; pRow.className = `channel-row ${p.is_speaking ? 'voice-speaking' : ''}`;
pRow.style.padding = "2px 8px"; 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>`; 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); pList.appendChild(pRow);
@ -629,15 +629,57 @@ function renderVideo(peerId, displayName, stream, source) {
} }
async function buildAudioPipeline(rawStream) { async function buildAudioPipeline(rawStream) {
const audioContext = new AudioContext(); const ctx = new AudioContext();
const source = audioContext.createMediaStreamSource(rawStream); state.voice.audioContext = ctx;
const destination = audioContext.createMediaStreamDestination(); const source = ctx.createMediaStreamSource(rawStream);
state.voice.audioContext = audioContext;
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; return destination.stream;
} }
@ -839,6 +881,15 @@ async function joinVoice() {
if (!msg.is_sharing_screen) { if (!msg.is_sharing_screen) {
document.getElementById(`video-${msg.user_id}-screen`)?.parentElement?.remove(); 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(() => { }); refreshVoicePresence().catch(() => { });
}; };

View file

@ -566,6 +566,19 @@ select {
font-size: 12px; 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 */
.chat-pane { .chat-pane {
display: flex; display: flex;