use crate::bus::{EventBus, PlaybackRegistry, StreamEvent};
use crate::{AppName, Result, StreamId, StreamKey};
use std::collections::HashMap;
use tokio::sync::broadcast;
#[derive(Debug, Clone)]
pub struct Room {
app: AppName,
}
impl Room {
pub fn new(name: impl Into<AppName>) -> Self {
Self { app: name.into() }
}
pub fn name(&self) -> &AppName {
&self.app
}
pub fn participant_key(&self, participant: &str) -> StreamKey {
StreamKey::new(self.app.as_str(), participant)
}
pub fn peers(&self, playback: &dyn PlaybackRegistry, me: &str) -> Result<Vec<StreamId>> {
let mut out: Vec<StreamId> = playback
.list_streams(&self.app)?
.into_iter()
.filter(|id| {
let s = id.as_str();
!s.contains('~') && s != me
})
.collect();
out.sort_by(|a, b| a.as_str().cmp(b.as_str()));
Ok(out)
}
pub fn events(&self, bus: &dyn EventBus) -> Result<broadcast::Receiver<StreamEvent>> {
bus.subscribe_events(&self.app)
}
}
#[derive(Debug, Clone)]
pub struct DominantSpeaker {
scores: HashMap<String, f32>,
current: Option<String>,
switch_margin: f32,
}
impl DominantSpeaker {
pub fn new(switch_margin: f32) -> Self {
Self {
scores: HashMap::new(),
current: None,
switch_margin,
}
}
pub fn observe(&mut self, participant: &str, level_dbov: u8, voice: bool) -> Option<&str> {
let loudness = if voice {
(127u8.saturating_sub(level_dbov)) as f32
} else {
0.0
};
let score = self.scores.entry(participant.to_string()).or_insert(0.0);
*score = *score * 0.9 + loudness * 0.1;
let (best, best_score) = self
.scores
.iter()
.max_by(|a, b| a.1.partial_cmp(b.1).unwrap_or(std::cmp::Ordering::Equal))
.map(|(k, v)| (k.clone(), *v))?;
let take_over = match &self.current {
Some(cur) if cur == &best => false,
Some(cur) => {
let cur_score = self.scores.get(cur).copied().unwrap_or(0.0);
best_score > cur_score + self.switch_margin
}
None => best_score > 0.0,
};
if take_over {
self.current = Some(best);
self.current.as_deref()
} else {
None
}
}
pub fn current(&self) -> Option<&str> {
self.current.as_deref()
}
}
#[cfg(test)]
mod tests {
use super::*;
#[tokio::test]
async fn peers_lists_base_participants_excluding_self_and_layers() {
use crate::inbound::IngestContext;
let engine = crate::Engine::builder()
.application(crate::AppSpec::new("standup").gop_cache(2))
.build();
let ctx = IngestContext::new(engine.clone());
let room = Room::new("standup");
for id in ["alice", "bob", "carol", "bob~h"] {
let s = ctx.open_publish(room.participant_key(id)).await.unwrap();
std::mem::forget(s); }
let peers = room.peers(engine.as_ref(), "alice").unwrap();
let ids: Vec<&str> = peers.iter().map(|i| i.as_str()).collect();
assert_eq!(ids, vec!["bob", "carol"]);
}
#[test]
fn participant_key_is_room_scoped() {
let room = Room::new("standup");
let k = room.participant_key("alice");
assert_eq!(k.app.as_str(), "standup");
assert_eq!(k.stream_id.as_str(), "alice");
}
#[test]
fn dominant_speaker_picks_loudest_and_holds_with_hysteresis() {
let mut ds = DominantSpeaker::new(8.0);
let mut became = None;
for _ in 0..20 {
if let Some(s) = ds.observe("alice", 10, true) {
became = Some(s.to_string());
}
ds.observe("bob", 90, false);
}
assert_eq!(ds.current(), Some("alice"));
assert_eq!(became.as_deref(), Some("alice"));
for _ in 0..3 {
assert_eq!(ds.observe("bob", 8, true), None);
ds.observe("alice", 12, true);
}
assert_eq!(
ds.current(),
Some("alice"),
"within-margin lead keeps alice"
);
let mut switched = false;
for _ in 0..40 {
switched |= ds.observe("bob", 0, true).is_some();
switched |= ds.observe("alice", 100, false).is_some();
}
assert!(switched && ds.current() == Some("bob"));
}
}