From 8ff0c548a79b54b1a33491e5a4d5e0ca97209e33 Mon Sep 17 00:00:00 2001 From: Siavash Sameni Date: Mon, 13 Apr 2026 11:33:02 +0400 Subject: [PATCH] fix(audio): update frame_samples on codec profile switch, fix buf sizing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit frame_samples was immutable — when adaptive quality switched from 20ms (Opus24k, 960 samples) to 40ms (Opus6k, 1920 samples), the send loop kept reading 960 samples and feeding half-sized frames to the encoder. This caused Opus6k to produce ~11 frames/s instead of 25, making audio choppy. Fix: - frame_samples is now mut and updated on profile switch - buf sized for max frame (1920) with frame_samples-bounded slices - RMS, mute, encode, and capture reads all use &buf[..frame_samples] - Applied to both Android and desktop send tasks Co-Authored-By: Claude Opus 4.6 (1M context) --- desktop/src-tauri/src/engine.rs | 41 +++++++++++++++++++-------------- 1 file changed, 24 insertions(+), 17 deletions(-) diff --git a/desktop/src-tauri/src/engine.rs b/desktop/src-tauri/src/engine.rs index 71ec87b..c99c756 100644 --- a/desktop/src-tauri/src/engine.rs +++ b/desktop/src-tauri/src/engine.rs @@ -538,12 +538,14 @@ impl CallEngine { ..CallConfig::default() }, }; - let frame_samples = (config.profile.frame_duration_ms as usize) * 48; + let mut frame_samples = (config.profile.frame_duration_ms as usize) * 48; info!(codec = ?config.profile.codec, frame_samples, t_ms = send_t0.elapsed().as_millis(), "first-join diag: send task spawned (android/oboe)"); *send_tx_codec.lock().await = format!("{:?}", config.profile.codec); let mut encoder = CallEncoder::new(&config); encoder.set_aec_enabled(false); - let mut buf = vec![0i16; frame_samples]; + // Sized for max frame (40ms = 1920 samples) so profile + // switches between 20ms ↔ 40ms codecs don't need realloc. + let mut buf = vec![0i16; 1920]; // Continuous DRED tuning: poll quinn path stats every 25 // frames (~500 ms at 20 ms/frame) and adjust DRED duration + @@ -572,7 +574,7 @@ impl CallEngine { // to read a full frame and sleep briefly if the ring is // short. Oboe's capture callback fills at a steady rate // so in steady state this spins once per frame. - let read = crate::wzp_native::audio_read_capture(&mut buf); + let read = crate::wzp_native::audio_read_capture(&mut buf[..frame_samples]); if read < frame_samples { short_reads += 1; tokio::time::sleep(std::time::Duration::from_millis(5)).await; @@ -589,8 +591,8 @@ impl CallEngine { } // RMS for UI meter - let sum_sq: f64 = buf.iter().map(|&s| (s as f64) * (s as f64)).sum(); - let rms = (sum_sq / buf.len() as f64).sqrt() as u32; + let sum_sq: f64 = buf[..frame_samples].iter().map(|&s| (s as f64) * (s as f64)).sum(); + let rms = (sum_sq / frame_samples as f64).sqrt() as u32; send_level.store(rms, Ordering::Relaxed); last_rms = rms; if !first_nonzero_rms_logged && rms > 0 { @@ -603,9 +605,9 @@ impl CallEngine { } if send_mic.load(Ordering::Relaxed) { - buf.fill(0); + buf[..frame_samples].fill(0); } - match encoder.encode_frame(&buf) { + match encoder.encode_frame(&buf[..frame_samples]) { Ok(pkts) => { for pkt in &pkts { last_pkt_bytes = pkt.payload.len(); @@ -646,8 +648,10 @@ impl CallEngine { let p = send_pending_profile.swap(PROFILE_NO_CHANGE, Ordering::Acquire); if p != PROFILE_NO_CHANGE { if let Some(new_profile) = index_to_profile(p) { - info!(to = ?new_profile.codec, "auto: switching encoder profile"); + let new_fs = (new_profile.frame_duration_ms as usize) * 48; + info!(to = ?new_profile.codec, frame_samples = new_fs, "auto: switching encoder profile (android)"); if encoder.set_profile(new_profile).is_ok() { + frame_samples = new_fs; dred_tuner.set_codec(new_profile.codec); *send_tx_codec.lock().await = format!("{:?}", new_profile.codec); } @@ -1353,12 +1357,12 @@ impl CallEngine { ..CallConfig::default() }, }; - let frame_samples = (config.profile.frame_duration_ms as usize) * 48; + let mut frame_samples = (config.profile.frame_duration_ms as usize) * 48; info!(codec = ?config.profile.codec, frame_samples, "send task starting"); *send_tx_codec.lock().await = format!("{:?}", config.profile.codec); let mut encoder = CallEncoder::new(&config); encoder.set_aec_enabled(false); // OS AEC or none - let mut buf = vec![0i16; frame_samples]; + let mut buf = vec![0i16; 1920]; // max frame (40ms) // Continuous DRED tuning (same as Android send task). let mut dred_tuner = wzp_proto::DredTuner::new(config.profile.codec); @@ -1373,19 +1377,20 @@ impl CallEngine { tokio::time::sleep(std::time::Duration::from_millis(5)).await; continue; } - capture_ring.read(&mut buf); + capture_ring.read(&mut buf[..frame_samples]); // Compute RMS audio level for UI meter - if !buf.is_empty() { - let sum_sq: f64 = buf.iter().map(|&s| (s as f64) * (s as f64)).sum(); - let rms = (sum_sq / buf.len() as f64).sqrt() as u32; + { + let pcm = &buf[..frame_samples]; + let sum_sq: f64 = pcm.iter().map(|&s| (s as f64) * (s as f64)).sum(); + let rms = (sum_sq / pcm.len() as f64).sqrt() as u32; send_level.store(rms, Ordering::Relaxed); } if send_mic.load(Ordering::Relaxed) { - buf.fill(0); + buf[..frame_samples].fill(0); } - match encoder.encode_frame(&buf) { + match encoder.encode_frame(&buf[..frame_samples]) { Ok(pkts) => { for pkt in &pkts { if let Err(e) = send_t.send_media(pkt).await { @@ -1406,8 +1411,10 @@ impl CallEngine { let p = send_pending_profile.swap(PROFILE_NO_CHANGE, Ordering::Acquire); if p != PROFILE_NO_CHANGE { if let Some(new_profile) = index_to_profile(p) { - info!(to = ?new_profile.codec, "auto: switching encoder profile"); + let new_fs = (new_profile.frame_duration_ms as usize) * 48; + info!(to = ?new_profile.codec, frame_samples = new_fs, "auto: switching encoder profile (desktop)"); if encoder.set_profile(new_profile).is_ok() { + frame_samples = new_fs; dred_tuner.set_codec(new_profile.codec); *send_tx_codec.lock().await = format!("{:?}", new_profile.codec); }