pub mod sfx;
pub mod aec;
pub mod options;
use std::
{
collections::{ BTreeMap, VecDeque },
time::
{
Instant,
SystemTime,
Duration,
UNIX_EPOCH,
},
sync::
{
Arc,
Mutex,
atomic::{ AtomicBool, AtomicUsize, Ordering },
},
};
use tokio::
{
time,
net::UdpSocket,
net::tcp::OwnedWriteHalf,
sync::
{
Mutex as MutexAsync,
mpsc::{ self, Sender },
},
};
use cpal::
{
Host,
Stream,
Device,
StreamConfig,
SupportedStreamConfig,
SupportedStreamConfigRange,
traits::
{
DeviceTrait,
HostTrait,
StreamTrait,
},
};
use audiopus::
{
Channels,
SampleRate,
Application,
TryFrom,
coder::{ Encoder, Decoder },
};
use ringbuf::
{
HeapRb,
HeapCons,
HeapProd,
traits::
{
Split,
Producer,
Consumer,
},
};
use nnnoiseless::DenoiseState;
use gag::Gag;
use crate::
{
config,
command::{ self, Command },
options as chat_options,
network::
{
client::{ ClientEvent, VoiceUser },
voice::
{
self,
consts,
VoicePacketCode,
client::sfx::SoundEffect,
},
},
};
#[cfg(target_os = "linux")]
use cpal::HostId;
struct LocalStream
{
_input: Stream,
_output: Stream,
input_id: String,
output_id: String,
}
#[derive(Clone)]
pub struct AudioDevice
{
pub id: String,
pub label: String,
}
struct StreamGuard
{
generation: usize,
}
struct RemoteStream
{
consumer: HeapCons<f32>, resample_pos: f32, current_sample: f32, next_sample: f32, activity_hold: usize, display_hold: usize, username: String, latencies: VecDeque<u128>, avg_latency: u128, }
pub struct PeerData
{
decoder: Decoder, producer: HeapProd<f32>, }
static LOCAL_STREAMS: Mutex<Option<LocalStream>> = Mutex::new(None);
static CONSUMERS: Mutex<BTreeMap<usize, (RemoteStream, PeerData)>> = Mutex::new(BTreeMap::new());
static LOCAL_DISPLAY_HOLD: AtomicUsize = AtomicUsize::new(0);
static AUDIO_GENERATION: AtomicUsize = AtomicUsize::new(0);
impl Drop for StreamGuard
{
fn drop(&mut self)
{
if AUDIO_GENERATION.load(Ordering::Relaxed) == self.generation
{
if let Ok(mut streams) = LOCAL_STREAMS.lock()
{
*streams = None;
}
aec::set_rate(0);
}
}
}
fn device_id(device: &Device) -> String {
device.id().map(|id| id.to_string()).unwrap_or_default()
}
fn is_usable(id: &str, cards: bool) -> bool
{
#[cfg(target_os = "linux")]
{
let Some(pcm) = id.strip_prefix("alsa:") else { return true };
if pcm == "null" { return false; }
if !pcm.contains("CARD=") { return true; }
cards && pcm.starts_with("plughw:")
}
#[cfg(not(target_os = "linux"))]
{
let (_, _) = (id, cards);
true
}
}
fn audio_hosts() -> Vec<Host>
{
let primary = audio_host();
let mut hosts = vec![primary];
let fallback = cpal::default_host();
if fallback.id() != hosts[0].id() { hosts.push(fallback); }
hosts
}
pub fn list_devices() -> (Vec<AudioDevice>, Vec<AudioDevice>) {
let _stderr_gag = Gag::stderr().ok();
let hosts = audio_hosts();
let cards = hosts.len() == 1;
let collect = |input: bool|
{
let mut out: Vec<AudioDevice> = Vec::new();
for host in hosts.iter() { collect_devices(host, input, cards, &mut out); }
out
};
(collect(true), collect(false))
}
fn collect_devices(host: &Host, input: bool, cards: bool, out: &mut Vec<AudioDevice>)
{
let Ok(devices) = (if input { host.input_devices() } else { host.output_devices() }) else { return };
let start = out.len();
for device in devices
{
let id = device_id(&device);
if id.is_empty() || !is_usable(&id, cards) || out.iter().any(|entry| entry.id == id) { continue; }
let Ok(label) = device.description().map(|d| d.to_string()) else { continue };
out.push(AudioDevice { id, label });
}
out[start..].sort_by(|a, b| a.label.cmp(&b.label).then_with(|| a.id.cmp(&b.id)));
}
fn pick_device(wanted: &str, input: bool) -> Option<Device>
{
let hosts = audio_hosts();
if wanted.is_empty()
{
let host = hosts.first()?;
return if input { host.default_input_device() } else { host.default_output_device() };
}
let devices: Vec<Device> = hosts.iter()
.filter_map(|host| if input { host.input_devices().ok() } else { host.output_devices().ok() })
.flatten()
.collect();
devices.iter().find(|device| device_id(device) == wanted)
.or_else(|| devices.iter().find(|device| device.description().is_ok_and(|desc| desc.to_string() == wanted)))
.cloned()
}
fn configured_ids() -> (String, String) {
(config::read_config::<String>("input_device"), config::read_config::<String>("output_device"))
}
pub fn configure_device(device: &cpal::Device, supported_configs: impl Iterator<Item = SupportedStreamConfigRange>, default_config: SupportedStreamConfig, is_input_stream: bool) -> StreamConfig
{
let mut config: StreamConfig = supported_configs
.filter(|c| c.min_sample_rate() <= consts::SAMPLE_RATE && c.max_sample_rate() >= consts::SAMPLE_RATE)
.next()
.map(|c| c.with_sample_rate(consts::SAMPLE_RATE))
.unwrap_or(default_config.clone())
.into();
if is_input_stream
{
if let Ok(test_stream) = device.build_input_stream(config.clone(), |_: &[f32], _| {}, |_| {}, None)
{
drop(test_stream); } else
{
config = default_config.into(); }
} else
{
if let Ok(test_stream) = device.build_output_stream(config.clone(), |_: &mut [f32], _| {}, |_| {}, None)
{
drop(test_stream); } else
{
config = default_config.into(); }
}
config
}
fn transmit_audio(encoder: &Encoder, frame: &mut [f32], buffer: &mut [u8], tx: &Sender<Vec<u8>>)
{
let gain = options::get_input_gain();
if gain != 1.
{
for sample in frame.iter_mut()
{
*sample = (*sample * gain).clamp(-1., 1.);
}
}
if let Ok(len) = encoder.encode_float(&frame, buffer)
{
tx.try_send(buffer[..len].to_vec()).ok();
}
}
fn apply_agc(frame: &mut [f32], rms: f32, is_speech: bool, envelope: &mut f32, gain: &mut f32)
{
if !options::automatic_gain() { return; }
if is_speech
{
let smoothing = if rms > *envelope { consts::AGC_ATTACK } else { consts::AGC_RELEASE };
*envelope += (rms - *envelope) * smoothing;
}
let target_gain = (consts::AGC_TARGET_RMS / envelope.max(1e-6)).clamp(consts::AGC_MIN_GAIN, consts::AGC_MAX_GAIN);
let slew = if target_gain < *gain { consts::AGC_GAIN_DOWN } else { consts::AGC_GAIN_UP };
*gain += (target_gain - *gain) * slew;
for sample in frame.iter_mut()
{
let amplified = *sample * *gain;
let magnitude = amplified.abs();
*sample = if magnitude > consts::LIMITER_KNEE
{
let headroom = 1. - consts::LIMITER_KNEE;
let over = (magnitude - consts::LIMITER_KNEE) / headroom;
amplified.signum() * (consts::LIMITER_KNEE + headroom * over.tanh())
} else
{
amplified
};
}
}
fn audio_host() -> Host {
#[cfg(target_os = "linux")]
{
cpal::host_from_id(HostId::Alsa).unwrap_or_else(|_| cpal::default_host())
}
#[cfg(not(target_os = "linux"))]
{
cpal::default_host()
}
}
fn build_input_stream(device: &Device, config: StreamConfig, current_generation: usize, packet_tx: Sender<Vec<u8>>) -> Option<Stream>
{
let opus_encoder = Encoder::new
(
<SampleRate as TryFrom<i32>>::try_from(consts::SAMPLE_RATE as i32).unwrap(),
Channels::Mono,
Application::Voip
).unwrap();
let mut input_accum: Vec<f32> = Vec::with_capacity(consts::FRAME_SIZE * 2);
let mut encoded_buffer = [0u8; 1500];
let input_channels = config.channels as usize;
let input_source_rate = config.sample_rate as f32;
let input_target_rate = consts::SAMPLE_RATE as f32;
let input_resample_step = input_source_rate / input_target_rate;
let mut input_resample_pos = 0.;
let gate_open = Arc::new(Mutex::new(false)); let preroll_buffer = Arc::new(Mutex::new(VecDeque::<Vec<f32>>::with_capacity(3))); let hold_frames_remaining = Arc::new(Mutex::new(0usize)); let noise_floor = Arc::new(Mutex::new(consts::INITIAL_NOISE_FLOOR)); let agc_envelope = Arc::new(Mutex::new(consts::AGC_TARGET_RMS)); let agc_gain = Arc::new(Mutex::new(1.0f32));
let mut denoiser = DenoiseState::new();
let mut denoise_buffer = [0.0f32; consts::SAMPLE_RATE as usize / 100];
let noise_floor_cb = noise_floor.clone();
let agc_envelope_cb = agc_envelope.clone();
let agc_gain_cb = agc_gain.clone();
device.build_input_stream(config, move |data: &[f32], _: &_|
{
if chat_options::is_muted(None) || options::get_input_volume() == 0
{
LOCAL_DISPLAY_HOLD.store(0, Ordering::Relaxed); input_accum.clear();
if let Ok(mut gate) = gate_open.lock()
{
*gate = false;
}
if let Ok(mut preroll) = preroll_buffer.lock()
{
preroll.clear();
}
return;
}
if AUDIO_GENERATION.load(Ordering::Relaxed) != current_generation { return; }
let frames_in_buffer = data.len() / input_channels;
let current_hold = LOCAL_DISPLAY_HOLD.load(Ordering::Relaxed);
if current_hold > 0
{
LOCAL_DISPLAY_HOLD.store(current_hold.saturating_sub(frames_in_buffer), Ordering::Relaxed);
}
let get_mono_sample = |index: usize| -> f32
{
if index >= frames_in_buffer { return 0. }
let mut sum = 0.;
for c in 0..input_channels
{
sum += data[index * input_channels + c];
}
sum / input_channels as f32
};
while input_resample_pos < (frames_in_buffer as f32) - 1.
{
let idx = input_resample_pos.floor() as usize;
let frac = input_resample_pos - idx as f32;
let s0 = get_mono_sample(idx);
let s1 = get_mono_sample(idx + 1);
let interpolated = s0 + (s1 - s0) * frac;
input_accum.push(interpolated);
input_resample_pos += input_resample_step;
}
input_resample_pos -= frames_in_buffer as f32;
while input_accum.len() >= consts::FRAME_SIZE
{
let mut frame: Vec<f32> = input_accum.drain(0..consts::FRAME_SIZE).collect();
for chunk in frame.chunks_mut(consts::SAMPLE_RATE as usize / 100)
{
if options::noise_suppression() && chunk.len() == consts::SAMPLE_RATE as usize / 100
{
for sample in chunk.iter_mut()
{
*sample *= 32767.;
}
denoiser.process_frame(&mut denoise_buffer, chunk);
for (i, sample) in denoise_buffer.iter().enumerate()
{
chunk[i] = sample / 32767.;
}
}
}
let rms = (frame.iter().map(|&x| x * x).sum::<f32>() / frame.len() as f32 + 1e-10).sqrt(); let mut gate = gate_open.lock().unwrap();
let mut preroll = preroll_buffer.lock().unwrap();
let mut hold_frames = hold_frames_remaining.lock().unwrap();
let mut nf = noise_floor_cb.lock().unwrap();
let mut envelope = agc_envelope_cb.lock().unwrap();
let mut gain = agc_gain_cb.lock().unwrap();
if !*gate
{
*nf += (rms - *nf) * consts::NOISE_FLOOR_ALPHA;
}
let treshold_open = (*nf * consts::NOISE_OPEN_MULT).max(consts::MIN_TRESHOLD_OPEN);
let treshold_close = (*nf * consts::NOISE_CLOSE_MULT).max(consts::MIN_TRESHOLD_CLOSE);
drop(nf);
if !*gate && rms > treshold_open
{
*gate = true;
for mut old_frame in preroll.drain(..)
{
apply_agc(&mut old_frame, rms, false, &mut envelope, &mut gain);
transmit_audio(&opus_encoder, &mut old_frame, &mut encoded_buffer, &packet_tx);
}
*hold_frames = consts::HOLD_FRAMES;
} else if *gate && rms < treshold_close
{
if *hold_frames > 0 {
*hold_frames -= 1;
} else {
*gate = false;
}
} else if *gate && rms >= treshold_close {
*hold_frames = consts::HOLD_FRAMES;
}
if !*gate
{
preroll.push_back(frame.clone());
if preroll.len() > 3
{
preroll.pop_front();
}
}
if *gate
{
LOCAL_DISPLAY_HOLD.store((consts::SAMPLE_RATE * consts::DISPLAY_HOLD as u32 / 1000) as usize, Ordering::Relaxed);
apply_agc(&mut frame, rms, rms >= treshold_close, &mut envelope, &mut gain);
transmit_audio(&opus_encoder, &mut frame, &mut encoded_buffer, &packet_tx);
}
}
}, |_| {}, None).ok()
}
fn build_output_stream(device: &Device, config: StreamConfig, current_generation: usize) -> Option<Stream>
{
let output_channels = config.channels as usize;
let output_source_rate = consts::SAMPLE_RATE as f32;
let output_target_rate = config.sample_rate as f32;
let output_resample_step = output_source_rate / output_target_rate;
aec::set_rate(config.sample_rate);
let mut reference = Vec::with_capacity(consts::FRAME_SIZE);
device.build_output_stream(config, move |data: &mut [f32], _: &_|
{
if AUDIO_GENERATION.load(Ordering::Relaxed) != current_generation { return; }
data.fill(0.);
let output_gain = options::get_output_gain(); let frames_to_write = data.len() / output_channels;
let mut consumers_guard = CONSUMERS.lock().unwrap();
reference.clear();
for i in 0..frames_to_write
{
let mut mixed_sample = 0.;
let mut active_speakers = 0;
for (stream, _) in consumers_guard.values_mut()
{
while stream.resample_pos >= 1.
{
stream.current_sample = stream.next_sample;
stream.next_sample = stream.consumer.try_pop().unwrap_or(0.); stream.resample_pos -= 1.;
}
let interpolated = stream.current_sample + (stream.next_sample - stream.current_sample) * stream.resample_pos;
stream.resample_pos += output_resample_step;
if interpolated.abs() > consts::MIXING_TRESHOLD
{
stream.activity_hold = consts::ACTIVITY_HOLD; stream.display_hold = (consts::SAMPLE_RATE * consts::DISPLAY_HOLD as u32 / 1000) as usize; }
if stream.activity_hold > 0
{
mixed_sample += interpolated;
active_speakers += 1;
stream.activity_hold -= 1;
}
if stream.display_hold > 0
{
stream.display_hold -= 1;
}
}
if active_speakers > 1
{
mixed_sample /= (active_speakers as f32).sqrt();
}
sfx::play_effects(&mut mixed_sample);
mixed_sample *= output_gain;
mixed_sample = mixed_sample.tanh();
reference.push(mixed_sample);
for channel in 0..output_channels
{
data[i * output_channels + channel] = mixed_sample;
}
}
drop(consumers_guard);
aec::push_reference(&reference);
}, |_| {}, None).ok()
}
fn build_streams(current_generation: usize, packet_tx: &Sender<Vec<u8>>, wanted: Option<(String, String)>) -> Option<LocalStream>
{
let (wanted_input, wanted_output) = wanted.unwrap_or_else(configured_ids);
let (input_device, output_device) =
{
let _stderr_gag = Gag::stderr().ok();
(pick_device(&wanted_input, true)?, pick_device(&wanted_output, false)?)
};
let input_config = configure_device(&input_device, input_device.supported_input_configs().ok()?,
input_device.default_input_config().ok()?, true);
let output_config = configure_device(&output_device, output_device.supported_output_configs().ok()?,
output_device.default_output_config().ok()?, false);
let input_stream = build_input_stream(&input_device, input_config, current_generation, packet_tx.clone())?;
let output_stream = build_output_stream(&output_device, output_config, current_generation)?;
input_stream.play().ok()?; output_stream.play().ok()?;
Some(LocalStream
{
_input: input_stream,
_output: output_stream,
input_id: wanted_input,
output_id: wanted_output,
})
}
fn replace_streams(current_generation: usize, packet_tx: &Sender<Vec<u8>>) -> bool
{
let previous = LOCAL_STREAMS.lock().unwrap().take();
let restore = previous.as_ref().map(|streams| (streams.input_id.clone(), streams.output_id.clone()));
drop(previous);
if let Some(streams) = build_streams(current_generation, packet_tx, None)
{
*LOCAL_STREAMS.lock().unwrap() = Some(streams);
return true;
}
if let Some((input_id, output_id)) = restore
{
config::client_write("input_device", &input_id);
config::client_write("output_device", &output_id);
if let Some(streams) = build_streams(current_generation, packet_tx, Some((input_id, output_id)))
{
*LOCAL_STREAMS.lock().unwrap() = Some(streams);
}
}
false
}
pub async fn listen_server_voice (
id: usize,
username: String,
tx: Sender<ClientEvent>,
write_stream: Arc<MutexAsync<OwnedWriteHalf>>,
token: [u8; 32]
)
{
options::set_seq(0);
options::set_server_seq(0);
options::init_audio();
let current_generation = AUDIO_GENERATION.fetch_add(1, Ordering::Relaxed) + 1;
let _guard = StreamGuard { generation: current_generation };
let socket = Arc::new(UdpSocket::bind("0.0.0.0:0").await.expect("Binding UDP failed"));
socket.connect(chat_options::get_server_address()).await.expect("Connecting to server UDP failed");
let handshake = Arc::new(AtomicBool::new(false));
let (packet_tx, mut packet_rx) = mpsc::channel::<Vec<u8>>(consts::SEND_CHANNEL_BOUND);
let send_socket = socket.clone();
let send_handshake = handshake.clone();
tokio::spawn(async move
{
while let Some(data) = packet_rx.recv().await
{
if AUDIO_GENERATION.load(Ordering::Relaxed) != current_generation { return; }
if !send_handshake.load(Ordering::Relaxed) { continue; }
voice::send(&send_socket, id, VoicePacketCode::Audio
{
data,
username: None,
}, &chat_options::get_keys().unwrap()).await.ok();
}
});
let streams = match build_streams(current_generation, &packet_tx, None)
{
Some(streams) => streams,
None => {
command::send_command_code(&mut *write_stream.lock().await, &Command::Voice, &None).await;
return;
}
};
*LOCAL_STREAMS.lock().unwrap() = Some(streams);
{
let socket = socket.clone();
let handshake = handshake.clone();
let tx = tx.clone();
let write_stream = write_stream.clone();
tokio::spawn(async move
{
let deadline = Instant::now() + Duration::from_millis(consts::HELLO_TIMEOUT);
while !handshake.load(Ordering::Relaxed)
{
if AUDIO_GENERATION.load(Ordering::Relaxed) != current_generation || !options::get_use_voice() { return; }
if Instant::now() >= deadline
{
tx.send(ClientEvent::VoiceHandshakeFailed).await.ok();
command::send_command_code(&mut *write_stream.lock().await, &Command::Voice, &None).await;
return;
}
let keys = match chat_options::get_keys()
{
Some(keys) => keys,
None => return };
voice::send(&socket, id, VoicePacketCode::Hello { token }, &keys).await.ok();
time::sleep(Duration::from_millis(consts::HELLO_INTERVAL)).await;
}
});
}
sfx::clear_effects();
sfx::queue_effect(SoundEffect::Join);
let vad_socket = socket.clone();
tokio::spawn(async move
{
let mut iteration_counter = 0u8;
let mut devices = options::device_generation();
loop
{
if AUDIO_GENERATION.load(Ordering::Relaxed) != current_generation { return; }
if !options::get_use_voice()
{
tx.send(ClientEvent::VoiceActivity(Vec::new())).await.unwrap(); return;
}
let generation = options::device_generation();
if generation != devices
{
devices = generation;
if !replace_streams(current_generation, &packet_tx)
{
tx.send(ClientEvent::VoiceDeviceFailed).await.unwrap();
}
}
iteration_counter += 1;
display_active_speakers(&username, &tx).await;
if iteration_counter == 10
{
voice::send(&vad_socket, id, VoicePacketCode::Ping
{
timestamp: SystemTime::now().duration_since(UNIX_EPOCH).unwrap().as_millis(),
}, &chat_options::get_keys().unwrap()).await.ok();
iteration_counter = 0;
}
time::sleep(Duration::from_millis(100)).await;
}
});
let mut decoded_buffer = [0.0f32; consts::FRAME_SIZE];
loop
{
let (network_buffer, _) = match voice::receive(&socket).await
{
Some(r) => r,
None => {
if AUDIO_GENERATION.load(Ordering::Relaxed) != current_generation { return; }
sfx::queue_effect(SoundEffect::Leave);
while sfx::is_playing()
{
if AUDIO_GENERATION.load(Ordering::Relaxed) != current_generation { return; }
time::sleep(Duration::from_millis(50)).await;
}
return;
}
};
if network_buffer.seq <= options::get_server_seq() { continue; } options::set_server_seq(network_buffer.seq);
if let VoicePacketCode::HelloAck = network_buffer.code
{
handshake.store(true, Ordering::Relaxed);
continue;
}
let mut pong: Option<u128> = None;
if let Some((stream, peer)) = CONSUMERS.lock().unwrap().get_mut(&network_buffer.id)
{
match network_buffer.code
{
VoicePacketCode::Audio { data, .. } =>
{
if chat_options::is_muted(Some(network_buffer.id)) { continue; }
if let Ok(decoded_len) = peer.decoder.decode_float(Some(&data), &mut decoded_buffer[..], false)
{
peer.producer.push_slice(&decoded_buffer[..decoded_len]);
}
},
VoicePacketCode::Ping { timestamp } =>
{
pong = Some(timestamp);
},
VoicePacketCode::Pong { timestamp, .. } =>
{
let latency = SystemTime::now().duration_since(UNIX_EPOCH).unwrap().as_millis().saturating_sub(timestamp);
stream.latencies.push_back(latency);
if stream.latencies.len() > 20 {
stream.latencies.pop_front();
}
let sum: u128 = stream.latencies.iter().sum();
if !stream.latencies.is_empty()
{
stream.avg_latency = sum / stream.latencies.len() as u128;
}
},
_ => {}, }
}
if let Some(timestamp) = pong
{
voice::send(&socket, id, VoicePacketCode::Pong
{
target_id: network_buffer.id,
timestamp: timestamp,
}, &chat_options::get_keys().unwrap()).await.ok();
}
}
}
pub fn remove_consumer(id: &usize)
{
if CONSUMERS.lock().unwrap().remove(id).is_some()
{
sfx::queue_effect(SoundEffect::Leave);
}
}
pub fn remove_all_consumers()
{
CONSUMERS.lock().unwrap().clear();
sfx::queue_effect(SoundEffect::Join);
}
pub fn add_consumer(id: usize, username: String)
{
let decoder = Decoder::new
(
<SampleRate as TryFrom<i32>>::try_from(consts::SAMPLE_RATE as i32).unwrap(),
Channels::Mono,
).unwrap();
let rb = HeapRb::<f32>::new(consts::FRAME_SIZE * consts::JITTER_BUFFER_SIZE);
let (producer, mut consumer) = rb.split();
let first_sample = consumer.try_pop().unwrap_or(0.0);
CONSUMERS.lock().unwrap().insert(id, (RemoteStream
{
consumer: consumer,
resample_pos: 0.,
current_sample: 0.,
next_sample: first_sample,
activity_hold: 0,
display_hold: 0,
username: username,
latencies: VecDeque::with_capacity(20),
avg_latency: 0,
}, PeerData
{
decoder: decoder,
producer: producer,
}));
sfx::queue_effect(SoundEffect::Join);
}
async fn display_active_speakers(local_username: &str, tx: &Sender<ClientEvent>)
{
let mut users_to_display = Vec::new();
let local_speaking = LOCAL_DISPLAY_HOLD.load(Ordering::Relaxed) > 0;
users_to_display.push(VoiceUser
{
id: 0,
username: local_username.to_string(),
is_speaking: local_speaking,
latency: None,
is_local: true,
});
if let Ok(consumers) = CONSUMERS.lock()
{
for (id, (stream, _)) in consumers.iter()
{
users_to_display.push(VoiceUser
{
id: *id,
username: stream.username.clone(),
is_speaking: stream.display_hold > 0, latency: Some(stream.avg_latency),
is_local: false,
});
}
}
if users_to_display.len() > 1
{
users_to_display[1..].sort_by_key(|u| u.id);
}
tx.send(ClientEvent::VoiceActivity(users_to_display)).await.unwrap();
}