This commit is contained in:
2026-08-27 14:42:35 +02:00
parent f28c9c0bdf
commit 23e6a2199f
4 changed files with 186 additions and 40 deletions
+150 -38
View File
@@ -11,14 +11,14 @@ use std::{
use cpal::traits::{DeviceTrait, HostTrait, StreamTrait}; use cpal::traits::{DeviceTrait, HostTrait, StreamTrait};
use ringbuf::{ use ringbuf::{
storage::Heap,
traits::{Consumer, Observer, Producer, Split}, traits::{Consumer, Observer, Producer, Split},
HeapRb, CachingCons, CachingProd, HeapRb, SharedRb,
}; };
use tauri::State; use tauri::State;
use crate::commands::config::ConfigState; use crate::commands::config::ConfigState;
const SAMPLE_RATE: u32 = 48000;
const PACKET_SAMPLES: usize = 960; // 20ms @ 48kHz const PACKET_SAMPLES: usize = 960; // 20ms @ 48kHz
const HEADER_SIZE: usize = 4; const HEADER_SIZE: usize = 4;
const MAX_PACKET_SIZE: usize = 4096; const MAX_PACKET_SIZE: usize = 4096;
@@ -26,9 +26,12 @@ const MAX_PACKET_SIZE: usize = 4096;
const INITIAL_PACKET_CUSHION: usize = 3; // ~60ms cushion const INITIAL_PACKET_CUSHION: usize = 3; // ~60ms cushion
// ============================================================================ // ============================================================================
// STATE // STATE & TYPES
// ============================================================================ // ============================================================================
type AudioProducer = Arc<Mutex<CachingProd<Arc<SharedRb<Heap<f32>>>>>>;
type AudioConsumer = Arc<Mutex<CachingCons<Arc<SharedRb<Heap<f32>>>>>>;
pub struct VoiceState { pub struct VoiceState {
pub session: Mutex<Option<VoiceSession>>, pub session: Mutex<Option<VoiceSession>>,
} }
@@ -39,6 +42,102 @@ pub struct VoiceSession {
pub socket: Arc<UdpSocket>, pub socket: Arc<UdpSocket>,
pub pin: u64, pub pin: u64,
pub shutdown: Arc<AtomicBool>, pub shutdown: Arc<AtomicBool>,
// Tracked settings for diffing config changes
pub current_input_device: Option<String>,
pub current_output_device: Option<String>,
// Shared ringbuffer handles for runtime hot-swapping
pub producer_in: AudioProducer,
pub consumer_out: AudioConsumer,
}
impl VoiceSession {
/// Re-binds the input stream (microphone) without disconnecting the UDP thread
pub fn update_input_device(&mut self, device_name: Option<String>) -> Result<(), String> {
let host = cpal::default_host();
let device = match &device_name {
Some(name) => host
.input_devices()
.map_err(|e| e.to_string())?
.find(|d| {
d.description()
.ok()
.map(|x| x.name() == *name)
.unwrap_or(false)
})
.ok_or_else(|| format!("Input device '{name}' not found"))?,
None => host
.default_input_device()
.ok_or("No default input device")?,
};
let input_config = device
.default_input_config()
.map_err(|e| format!("Failed to get default input config: {e}"))?
.config();
let producer = Arc::clone(&self.producer_in);
let new_stream = device
.build_input_stream(
input_config,
move |data: &[f32], _| {
if let Ok(mut prod) = producer.lock() {
let _ = prod.push_slice(data);
}
},
|err| eprintln!("[vc] input error: {err}"),
None,
)
.map_err(|e| e.to_string())?;
new_stream
.play()
.map_err(|e| format!("Failed to play input stream: {e}"))?;
// Dropping old stream stops capturing audio hardware
self.input_stream = new_stream;
self.current_input_device = device_name;
Ok(())
}
/// Re-binds the output stream (speakers/headphones) without interrupting UDP reception
pub fn update_output_device(&mut self, device_name: Option<String>) -> Result<(), String> {
let host = cpal::default_host();
let device = match &device_name {
Some(name) => host
.output_devices()
.map_err(|e| e.to_string())?
.find(|d| {
d.description()
.ok()
.map(|x| x.name() == *name)
.unwrap_or(false)
})
.ok_or_else(|| format!("Output device '{name}' not found"))?,
None => host
.default_output_device()
.ok_or("No default output device")?,
};
let output_config = device
.default_output_config()
.map_err(|e| format!("Failed to get default output config: {e}"))?
.config();
let consumer = Arc::clone(&self.consumer_out);
let new_stream = build_output_stream(&device, output_config, consumer)?;
new_stream
.play()
.map_err(|e| format!("Failed to play output stream: {e}"))?;
if let Ok(mut active_stream) = self.output_stream.lock() {
*active_stream = new_stream;
}
self.current_output_device = device_name;
Ok(())
}
} }
impl Default for VoiceState { impl Default for VoiceState {
@@ -74,13 +173,20 @@ pub fn list_output_devices() -> Result<Vec<String>, String> {
} }
// ============================================================================ // ============================================================================
// DISCONNECT // CONFIG UPDATES & DISCONNECT
// ============================================================================ // ============================================================================
#[tauri::command] #[tauri::command]
pub fn disconnect_from_vc(voice_state: State<VoiceState>) -> Result<(), String> { pub fn disconnect_from_vc(voice_state: State<VoiceState>) -> Result<(), String> {
if let Some(session) = voice_state.session.lock().unwrap().take() { if let Some(session) = voice_state.session.lock().unwrap().take() {
// Signal shutdown atomic to terminate sender and receiver loops
session.shutdown.store(true, Ordering::SeqCst); session.shutdown.store(true, Ordering::SeqCst);
// Pause CPAL hardware streams explicitly to flush playback drivers immediately
let _ = session.input_stream.pause();
if let Ok(output) = session.output_stream.lock() {
let _ = output.pause();
}
} }
Ok(()) Ok(())
} }
@@ -149,44 +255,53 @@ pub fn connect_to_vc(
let shutdown = Arc::new(AtomicBool::new(false)); let shutdown = Arc::new(AtomicBool::new(false));
// Lock-Free Ring Buffer setup (19.2k samples ~ 400ms buffer capacity at 48kHz) // Output Ring Buffer setup
let rb_out = HeapRb::<f32>::new(19200); let rb_out = HeapRb::<f32>::new(19200);
let (mut producer_out, consumer_out) = rb_out.split(); let (producer_out, consumer_out) = rb_out.split();
let mut producer_out = producer_out; // Handed to receiver thread
let shared_consumer_out = Arc::new(Mutex::new(consumer_out));
let output_config = cpal::StreamConfig { // Replace hardcoded output_config:
channels: 2, let output_config = output_device
sample_rate: SAMPLE_RATE, .default_output_config()
buffer_size: cpal::BufferSize::Default, .map_err(|e| format!("Failed to get default output config: {e}"))?
}; .config();
let output_stream = build_output_stream(&output_device, output_config, consumer_out)?; let output_stream = build_output_stream(
&output_device,
output_config,
Arc::clone(&shared_consumer_out),
)?;
output_stream output_stream
.play() .play()
.map_err(|e| format!("Failed to start output: {e}"))?; .map_err(|e| format!("Failed to start output: {e}"))?;
let output_stream = Arc::new(Mutex::new(output_stream)); let output_stream = Arc::new(Mutex::new(output_stream));
// Configure Input Stream with dedicated thread ring-buffer // Input Ring Buffer setup
let input_config = cpal::StreamConfig {
channels: 1,
sample_rate: SAMPLE_RATE,
buffer_size: cpal::BufferSize::Default,
};
let rb_in = HeapRb::<f32>::new(19200); let rb_in = HeapRb::<f32>::new(19200);
let (mut producer_in, mut consumer_in) = rb_in.split(); let (producer_in, mut consumer_in) = rb_in.split();
let shared_producer_in = Arc::new(Mutex::new(producer_in));
let input_config = input_device
.default_input_config()
.map_err(|e| format!("Failed to get default input config: {e}"))?
.config();
let cb_producer = Arc::clone(&shared_producer_in);
let input_stream = input_device let input_stream = input_device
.build_input_stream( .build_input_stream(
input_config, input_config,
move |data: &[f32], _| { move |data: &[f32], _| {
let _ = producer_in.push_slice(data); if let Ok(mut prod) = cb_producer.lock() {
let _ = prod.push_slice(data);
}
}, },
|err| eprintln!("[vc] input error: {err}"), |err| eprintln!("[vc] input error: {err}"),
None, None,
) )
.map_err(|e| e.to_string())?; .map_err(|e| e.to_string())?;
// UDP Sender Thread - Pumps exact 20ms frames smoothly // UDP Sender Thread
{ {
let input_socket = socket.clone(); let input_socket = socket.clone();
let shutdown = shutdown.clone(); let shutdown = shutdown.clone();
@@ -216,7 +331,7 @@ pub fn connect_to_vc(
}); });
} }
// UDP Receiver Thread - Paced Jitter Buffer // UDP Receiver Thread
{ {
let socket = socket.clone(); let socket = socket.clone();
let shutdown = shutdown.clone(); let shutdown = shutdown.clone();
@@ -230,7 +345,6 @@ pub fn connect_to_vc(
let mut next_frame_time = Instant::now(); let mut next_frame_time = Instant::now();
while !shutdown.load(Ordering::Relaxed) { while !shutdown.load(Ordering::Relaxed) {
// Non-blocking UDP receive
if let Ok(len) = socket.recv(&mut udp_buffer) { if let Ok(len) = socket.recv(&mut udp_buffer) {
if len > HEADER_SIZE { if len > HEADER_SIZE {
let seq = u32::from_be_bytes(udp_buffer[0..4].try_into().unwrap()); let seq = u32::from_be_bytes(udp_buffer[0..4].try_into().unwrap());
@@ -255,7 +369,6 @@ pub fn connect_to_vc(
} }
} }
// Paced playback frame-by-frame (20ms target interval)
if Instant::now() >= next_frame_time { if Instant::now() >= next_frame_time {
if let Some(seq) = expected { if let Some(seq) = expected {
if let Some(samples) = packets.remove(&seq) { if let Some(samples) = packets.remove(&seq) {
@@ -273,19 +386,15 @@ pub fn connect_to_vc(
let _ = producer_out.push_slice(&last_good_frame); let _ = producer_out.push_slice(&last_good_frame);
expected = Some(seq.wrapping_add(1)); expected = Some(seq.wrapping_add(1));
} else if packets.keys().any(|&x| x > seq) { } else if packets.keys().any(|&x| x > seq) {
// PLC (Packet Loss Concealment)
for sample in last_good_frame.iter_mut() { for sample in last_good_frame.iter_mut() {
*sample *= 0.65; *sample *= 0.65;
} }
let _ = producer_out.push_slice(&last_good_frame); let _ = producer_out.push_slice(&last_good_frame);
expected = Some(seq.wrapping_add(1)); expected = Some(seq.wrapping_add(1));
} else { } else if packets.is_empty() {
// Re-buffer if packet stream dropped
if packets.is_empty() {
is_prebuffering = true; is_prebuffering = true;
} }
} }
}
next_frame_time += Duration::from_millis(20); next_frame_time += Duration::from_millis(20);
} }
@@ -304,6 +413,10 @@ pub fn connect_to_vc(
socket, socket,
pin, pin,
shutdown, shutdown,
current_input_device: config.input_device_name,
current_output_device: config.output_device_name,
producer_in: shared_producer_in,
consumer_out: shared_consumer_out,
}); });
Ok(()) Ok(())
@@ -313,14 +426,11 @@ pub fn connect_to_vc(
// AUDIO CALLBACK // AUDIO CALLBACK
// ============================================================================ // ============================================================================
fn build_output_stream<C>( fn build_output_stream(
device: &cpal::Device, device: &cpal::Device,
config: cpal::StreamConfig, config: cpal::StreamConfig,
mut consumer: C, consumer: AudioConsumer,
) -> Result<cpal::Stream, String> ) -> Result<cpal::Stream, String> {
where
C: Consumer<Item = f32> + Send + 'static,
{
let channels = config.channels as usize; let channels = config.channels as usize;
let mut last_sample = 0.0f32; let mut last_sample = 0.0f32;
@@ -329,15 +439,17 @@ where
config, config,
move |data: &mut [f32], _| { move |data: &mut [f32], _| {
let mut idx = 0; let mut idx = 0;
let mut cons_guard = consumer.lock().ok();
while idx < data.len() { while idx < data.len() {
if let Some(mono_sample) = consumer.try_pop() { let sample = cons_guard.as_mut().and_then(|c| c.try_pop());
if let Some(mono_sample) = sample {
last_sample = mono_sample; last_sample = mono_sample;
for ch in 0..channels { for ch in 0..channels {
data[idx + ch] = mono_sample; data[idx + ch] = mono_sample;
} }
idx += channels; idx += channels;
} else { } else {
// Smooth fade out to avoid clicks on underruns
while idx < data.len() { while idx < data.len() {
last_sample *= 0.92; last_sample *= 0.92;
for ch in 0..channels { for ch in 0..channels {
+11
View File
@@ -210,6 +210,17 @@ export default class Enclave<P = Page> {
case "JoinVoice": case "JoinVoice":
server.voiceJoin && server.voiceJoin(msg.pin, msg.channel_id); server.voiceJoin && server.voiceJoin(msg.pin, msg.channel_id);
return; return;
case "UserJoinedVoice":
if (!server.voiceChatUsers[msg.channel_id])
server.voiceChatUsers[msg.channel_id] = [];
server.voiceChatUsers[msg.channel_id].push(msg.pubkey);
return;
case "Speaking":
server.voiceChatSpeaker(msg.pubkey);
return;
} }
} }
} }
+10 -1
View File
@@ -33,7 +33,16 @@ export type ClientMethod =
| { | {
method: "JoinVoice"; method: "JoinVoice";
channel_id: string; channel_id: string;
pin: number; pin: bigint;
}
| {
method: "UserJoinedVoice";
channel_id: string;
pubkey: string;
}
| {
method: "Speaking";
pubkey: string;
}; };
export type ServerMethod = export type ServerMethod =
+14
View File
@@ -29,15 +29,21 @@ export default class EnclaveServer {
public isSecure: boolean; public isSecure: boolean;
public websocket?: EnclaveWebSocket; public websocket?: EnclaveWebSocket;
public meta?: ServerMeta; public meta?: ServerMeta;
public messages: Record<string, Record<string, StoredMessage>>; public messages: Record<string, Record<string, StoredMessage>>;
public users: Record<string, ClientMeta | null>; public users: Record<string, ClientMeta | null>;
public voiceJoin?: (pin: number, channelId: string) => void; public voiceJoin?: (pin: number, channelId: string) => void;
public voiceChatUsers: Record<string, string[]>;
public voiceChatSpeakers: Record<string, NodeJS.Timeout>;
public constructor(hostname: string, isSecure: boolean) { public constructor(hostname: string, isSecure: boolean) {
this.hostname = hostname; this.hostname = hostname;
this.isSecure = isSecure; this.isSecure = isSecure;
this.messages = {}; this.messages = {};
this.users = {}; this.users = {};
this.voiceChatUsers = {};
this.voiceChatSpeakers = {};
} }
public disconnect() { public disconnect() {
@@ -107,4 +113,12 @@ export default class EnclaveServer {
this.websocket?.send({ method: "GetUsers", pubkeys: filteredPubkeys }); this.websocket?.send({ method: "GetUsers", pubkeys: filteredPubkeys });
} }
public voiceChatSpeaker(pubkey: string) {
clearTimeout(this.voiceChatSpeakers[pubkey]);
this.voiceChatSpeakers[pubkey] = setTimeout(() => {
delete this.voiceChatSpeakers[pubkey];
}, 1000);
}
} }