feat: TX/RX codec badges on desktop call screen
Some checks failed
Mirror to GitHub / mirror (push) Failing after 34s
Build Release Binaries / build-amd64 (push) Failing after 2m1s

Desktop now shows codec badges like Android:
- Green TX badge: e.g. "Opus64k"
- Blue RX badge: e.g. "Opus24k"
Displayed in the stats line below the call controls.

Engine tracks tx_codec (set on encoder init) and rx_codec (updated
from incoming packet headers). Passed through EngineStatus → CallStatus
→ frontend.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Siavash Sameni
2026-04-08 12:03:20 +04:00
parent da593f9510
commit 395a0c557e
4 changed files with 50 additions and 1 deletions

View File

@@ -57,6 +57,8 @@ pub struct EngineStatus {
pub audio_level: u32, pub audio_level: u32,
pub call_duration_secs: f64, pub call_duration_secs: f64,
pub fingerprint: String, pub fingerprint: String,
pub tx_codec: String,
pub rx_codec: String,
} }
pub struct CallEngine { pub struct CallEngine {
@@ -67,6 +69,8 @@ pub struct CallEngine {
frames_sent: Arc<AtomicU64>, frames_sent: Arc<AtomicU64>,
frames_received: Arc<AtomicU64>, frames_received: Arc<AtomicU64>,
audio_level: Arc<AtomicU32>, audio_level: Arc<AtomicU32>,
tx_codec: Arc<Mutex<String>>,
rx_codec: Arc<Mutex<String>>,
transport: Arc<wzp_transport::QuinnTransport>, transport: Arc<wzp_transport::QuinnTransport>,
start_time: Instant, start_time: Instant,
fingerprint: String, fingerprint: String,
@@ -187,6 +191,8 @@ impl CallEngine {
let frames_sent = Arc::new(AtomicU64::new(0)); let frames_sent = Arc::new(AtomicU64::new(0));
let frames_received = Arc::new(AtomicU64::new(0)); let frames_received = Arc::new(AtomicU64::new(0));
let audio_level = Arc::new(AtomicU32::new(0)); let audio_level = Arc::new(AtomicU32::new(0));
let tx_codec = Arc::new(Mutex::new(String::new()));
let rx_codec = Arc::new(Mutex::new(String::new()));
// Send task // Send task
let send_t = transport.clone(); let send_t = transport.clone();
@@ -196,6 +202,7 @@ impl CallEngine {
let send_level = audio_level.clone(); let send_level = audio_level.clone();
let send_drops = Arc::new(AtomicU64::new(0)); let send_drops = Arc::new(AtomicU64::new(0));
let send_quality = quality.clone(); let send_quality = quality.clone();
let send_tx_codec = tx_codec.clone();
tokio::spawn(async move { tokio::spawn(async move {
let profile = resolve_quality(&send_quality); let profile = resolve_quality(&send_quality);
let config = match profile { let config = match profile {
@@ -212,6 +219,7 @@ impl CallEngine {
}; };
let frame_samples = (config.profile.frame_duration_ms as usize) * 48; let frame_samples = (config.profile.frame_duration_ms as usize) * 48;
info!(codec = ?config.profile.codec, frame_samples, "send task starting"); 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); let mut encoder = CallEncoder::new(&config);
encoder.set_aec_enabled(false); // OS AEC or none encoder.set_aec_enabled(false); // OS AEC or none
let mut buf = vec![0i16; frame_samples]; let mut buf = vec![0i16; frame_samples];
@@ -259,6 +267,7 @@ impl CallEngine {
let recv_r = running.clone(); let recv_r = running.clone();
let recv_spk = spk_muted.clone(); let recv_spk = spk_muted.clone();
let recv_fr = frames_received.clone(); let recv_fr = frames_received.clone();
let recv_rx_codec = rx_codec.clone();
tokio::spawn(async move { tokio::spawn(async move {
let initial_profile = resolve_quality(&quality).unwrap_or(QualityProfile::GOOD); let initial_profile = resolve_quality(&quality).unwrap_or(QualityProfile::GOOD);
let mut decoder = wzp_codec::create_decoder(initial_profile); let mut decoder = wzp_codec::create_decoder(initial_profile);
@@ -278,6 +287,12 @@ impl CallEngine {
{ {
Ok(Ok(Some(pkt))) => { Ok(Ok(Some(pkt))) => {
if !pkt.header.is_repair && pkt.header.codec_id != CodecId::ComfortNoise { if !pkt.header.is_repair && pkt.header.codec_id != CodecId::ComfortNoise {
// Track RX codec
{
let mut rx = recv_rx_codec.lock().await;
let codec_name = format!("{:?}", pkt.header.codec_id);
if *rx != codec_name { *rx = codec_name; }
}
// Auto-switch decoder if incoming codec differs // Auto-switch decoder if incoming codec differs
if pkt.header.codec_id != current_codec { if pkt.header.codec_id != current_codec {
let new_profile = match pkt.header.codec_id { let new_profile = match pkt.header.codec_id {
@@ -373,6 +388,8 @@ impl CallEngine {
transport, transport,
start_time: Instant::now(), start_time: Instant::now(),
fingerprint, fingerprint,
tx_codec,
rx_codec,
_audio_handle: SyncWrapper(audio_handle), _audio_handle: SyncWrapper(audio_handle),
}) })
} }
@@ -410,6 +427,8 @@ impl CallEngine {
audio_level: self.audio_level.load(Ordering::Relaxed), audio_level: self.audio_level.load(Ordering::Relaxed),
call_duration_secs: self.start_time.elapsed().as_secs_f64(), call_duration_secs: self.start_time.elapsed().as_secs_f64(),
fingerprint: self.fingerprint.clone(), fingerprint: self.fingerprint.clone(),
tx_codec: self.tx_codec.lock().await.clone(),
rx_codec: self.rx_codec.lock().await.clone(),
} }
} }

View File

@@ -32,6 +32,8 @@ struct CallStatus {
audio_level: u32, audio_level: u32,
call_duration_secs: f64, call_duration_secs: f64,
fingerprint: String, fingerprint: String,
tx_codec: String,
rx_codec: String,
} }
struct AppState { struct AppState {
@@ -204,6 +206,8 @@ async fn get_status(state: tauri::State<'_, Arc<AppState>>) -> Result<CallStatus
audio_level: status.audio_level, audio_level: status.audio_level,
call_duration_secs: status.call_duration_secs, call_duration_secs: status.call_duration_secs,
fingerprint: status.fingerprint, fingerprint: status.fingerprint,
tx_codec: status.tx_codec,
rx_codec: status.rx_codec,
}) })
} else { } else {
Ok(CallStatus { Ok(CallStatus {
@@ -216,6 +220,8 @@ async fn get_status(state: tauri::State<'_, Arc<AppState>>) -> Result<CallStatus
audio_level: 0, audio_level: 0,
call_duration_secs: 0.0, call_duration_secs: 0.0,
fingerprint: String::new(), fingerprint: String::new(),
tx_codec: String::new(),
rx_codec: String::new(),
}) })
} }
} }

View File

@@ -586,7 +586,10 @@ async function pollStatus() {
}); });
} }
statsDiv.textContent = `TX: ${st.encode_fps} | RX: ${st.recv_fps}`; // Stats line with codec badges
const txBadge = (st as any).tx_codec ? `<span class="codec-badge tx">${escapeHtml((st as any).tx_codec)}</span>` : "";
const rxBadge = (st as any).rx_codec ? `<span class="codec-badge rx">${escapeHtml((st as any).rx_codec)}</span>` : "";
statsDiv.innerHTML = `${txBadge} ${rxBadge} TX: ${st.encode_fps} | RX: ${st.recv_fps}`;
} catch {} } catch {}
} }

View File

@@ -470,6 +470,27 @@ button.primary:disabled { opacity: 0.5; cursor: not-allowed; }
.relay-dot-small.green { background: var(--green); } .relay-dot-small.green { background: var(--green); }
.relay-dot-small.blue { background: #60a5fa; } .relay-dot-small.blue { background: #60a5fa; }
/* ── Codec badges ── */
.codec-badge {
display: inline-block;
font-size: 10px;
font-weight: 600;
padding: 1px 6px;
border-radius: 4px;
font-family: monospace;
margin: 0 2px;
}
.codec-badge.tx {
background: #22c55e30;
color: #4ade80;
}
.codec-badge.rx {
background: #3b82f630;
color: #60a5fa;
}
/* ── Controls ── */ /* ── Controls ── */
.controls { .controls {
display: flex; display: flex;