This commit is contained in:
parent
e7c60e7c47
commit
be1451ce45
3 changed files with 109 additions and 7 deletions
38
src/voice.rs
38
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<ServerEvent>,
|
||||
}
|
||||
|
||||
|
|
@ -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::<Vec<_>>();
|
||||
|
||||
|
|
@ -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}"),
|
||||
|
|
|
|||
|
|
@ -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(() => { });
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue