feat: quality profile selection in desktop settings
Some checks failed
Mirror to GitHub / mirror (push) Failing after 35s
Build Release Binaries / build-amd64 (push) Failing after 1m58s

Adds a Quality dropdown (Auto / Opus 24k / Opus 6k / Codec2 3.2k /
Codec2 1.2k) to both the connect screen and settings panel. The
selected profile is passed through to the engine which configures
the encoder and decoder accordingly.

The desktop engine recv path now auto-switches the decoder codec
when incoming packets use a different codec than expected, enabling
cross-codec interop between clients on different quality settings.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Siavash Sameni
2026-04-07 17:44:17 +04:00
parent 96ccb4f333
commit 85c2146760
4 changed files with 91 additions and 20 deletions

View File

@@ -26,6 +26,15 @@
<label>Alias <label>Alias
<input id="alias" type="text" placeholder="your name" /> <input id="alias" type="text" placeholder="your name" />
</label> </label>
<label>Quality
<select id="quality">
<option value="auto">Auto</option>
<option value="good">Opus 24k</option>
<option value="degraded">Opus 6k</option>
<option value="codec2-3200">Codec2 3.2k</option>
<option value="catastrophic">Codec2 1.2k</option>
</select>
</label>
<div class="form-row"> <div class="form-row">
<label class="checkbox"> <label class="checkbox">
<input id="os-aec" type="checkbox" checked /> <input id="os-aec" type="checkbox" checked />
@@ -91,6 +100,15 @@
</div> </div>
<div class="settings-section"> <div class="settings-section">
<h3>Audio</h3> <h3>Audio</h3>
<label>Quality
<select id="s-quality">
<option value="auto">Auto (adaptive)</option>
<option value="good">Good — Opus 24kbps</option>
<option value="degraded">Degraded — Opus 6kbps</option>
<option value="codec2-3200">Codec2 3200bps</option>
<option value="catastrophic">Catastrophic — Codec2 1200bps</option>
</select>
</label>
<label class="checkbox"> <label class="checkbox">
<input id="s-os-aec" type="checkbox" /> <input id="s-os-aec" type="checkbox" />
OS Echo Cancellation (macOS VoiceProcessingIO) OS Echo Cancellation (macOS VoiceProcessingIO)

View File

@@ -11,9 +11,27 @@ use tracing::{error, info};
use wzp_client::audio_io::{AudioCapture, AudioPlayback}; use wzp_client::audio_io::{AudioCapture, AudioPlayback};
use wzp_client::call::{CallConfig, CallEncoder}; use wzp_client::call::{CallConfig, CallEncoder};
use wzp_proto::MediaTransport; use wzp_proto::{CodecId, MediaTransport, QualityProfile};
const FRAME_SAMPLES: usize = 960; const FRAME_SAMPLES_20MS: usize = 960;
const FRAME_SAMPLES_40MS: usize = 1920;
/// Resolve a quality string from the UI to a QualityProfile.
/// Returns None for "auto" (use default adaptive behavior).
fn resolve_quality(quality: &str) -> Option<QualityProfile> {
match quality {
"good" | "opus" => Some(QualityProfile::GOOD),
"degraded" | "opus6k" => Some(QualityProfile::DEGRADED),
"catastrophic" | "codec2-1200" => Some(QualityProfile::CATASTROPHIC),
"codec2-3200" => Some(QualityProfile {
codec: CodecId::Codec2_3200,
fec_ratio: 0.5,
frame_duration_ms: 20,
frames_per_block: 5,
}),
_ => None, // "auto" or unknown
}
}
/// Wrapper to make non-Sync audio handles safe to store in shared state. /// Wrapper to make non-Sync audio handles safe to store in shared state.
/// The audio handle is only accessed from the thread that created it (drop), /// The audio handle is only accessed from the thread that created it (drop),
@@ -60,6 +78,7 @@ impl CallEngine {
room: String, room: String,
alias: String, alias: String,
_os_aec: bool, _os_aec: bool,
quality: String,
event_cb: F, event_cb: F,
) -> Result<Self, anyhow::Error> ) -> Result<Self, anyhow::Error>
where where
@@ -173,21 +192,32 @@ impl CallEngine {
let send_fs = frames_sent.clone(); let send_fs = frames_sent.clone();
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();
tokio::spawn(async move { tokio::spawn(async move {
let config = CallConfig { let profile = resolve_quality(&send_quality);
let config = match profile {
Some(p) => CallConfig {
noise_suppression: false,
suppression_enabled: false,
..CallConfig::from_profile(p)
},
None => CallConfig {
noise_suppression: false, noise_suppression: false,
suppression_enabled: false, suppression_enabled: false,
..CallConfig::default() ..CallConfig::default()
},
}; };
let frame_samples = (config.profile.frame_duration_ms as usize) * 48;
info!(codec = ?config.profile.codec, frame_samples, "send task starting");
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];
loop { loop {
if !send_r.load(Ordering::Relaxed) { if !send_r.load(Ordering::Relaxed) {
break; break;
} }
if capture_ring.available() < FRAME_SAMPLES { if capture_ring.available() < frame_samples {
tokio::time::sleep(std::time::Duration::from_millis(5)).await; tokio::time::sleep(std::time::Duration::from_millis(5)).await;
continue; continue;
} }
@@ -221,15 +251,17 @@ impl CallEngine {
} }
}); });
// Recv task (direct playout) // Recv task (direct playout with auto codec switch)
let recv_t = transport.clone(); let recv_t = transport.clone();
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();
tokio::spawn(async move { tokio::spawn(async move {
let mut opus_dec = wzp_codec::create_decoder(wzp_proto::QualityProfile::GOOD); let initial_profile = resolve_quality(&quality).unwrap_or(QualityProfile::GOOD);
let mut decoder = wzp_codec::create_decoder(initial_profile);
let mut current_codec = initial_profile.codec;
let mut agc = wzp_codec::AutoGainControl::new(); let mut agc = wzp_codec::AutoGainControl::new();
let mut pcm = vec![0i16; FRAME_SAMPLES]; let mut pcm = vec![0i16; FRAME_SAMPLES_40MS]; // big enough for any codec
loop { loop {
if !recv_r.load(Ordering::Relaxed) { if !recv_r.load(Ordering::Relaxed) {
@@ -242,8 +274,24 @@ impl CallEngine {
.await .await
{ {
Ok(Ok(Some(pkt))) => { Ok(Ok(Some(pkt))) => {
if !pkt.header.is_repair { if !pkt.header.is_repair && pkt.header.codec_id != CodecId::ComfortNoise {
if let Ok(n) = opus_dec.decode(&pkt.payload, &mut pcm) { // Auto-switch decoder if incoming codec differs
if pkt.header.codec_id != current_codec {
let new_profile = match pkt.header.codec_id {
CodecId::Opus24k => QualityProfile::GOOD,
CodecId::Opus6k => QualityProfile::DEGRADED,
CodecId::Codec2_1200 => QualityProfile::CATASTROPHIC,
CodecId::Codec2_3200 => QualityProfile {
codec: CodecId::Codec2_3200,
fec_ratio: 0.5, frame_duration_ms: 20, frames_per_block: 5,
},
other => QualityProfile { codec: other, ..QualityProfile::GOOD },
};
info!(from = ?current_codec, to = ?pkt.header.codec_id, "recv: switching decoder");
let _ = decoder.set_profile(new_profile);
current_codec = pkt.header.codec_id;
}
if let Ok(n) = decoder.decode(&pkt.payload, &mut pcm) {
agc.process_frame(&mut pcm[..n]); agc.process_frame(&mut pcm[..n]);
if !recv_spk.load(Ordering::Relaxed) { if !recv_spk.load(Ordering::Relaxed) {
playout_ring.write(&pcm[..n]); playout_ring.write(&pcm[..n]);
@@ -259,7 +307,6 @@ impl CallEngine {
error!("recv fatal: {e}"); error!("recv fatal: {e}");
break; break;
} }
// Transient error — continue
} }
Err(_) => {} Err(_) => {}
} }

View File

@@ -122,6 +122,7 @@ async fn connect(
room: String, room: String,
alias: String, alias: String,
os_aec: bool, os_aec: bool,
quality: String,
) -> Result<String, String> { ) -> Result<String, String> {
let mut engine_lock = state.engine.lock().await; let mut engine_lock = state.engine.lock().await;
if engine_lock.is_some() { if engine_lock.is_some() {
@@ -129,7 +130,7 @@ async fn connect(
} }
let app_clone = app.clone(); let app_clone = app.clone();
match CallEngine::start(relay, room, alias, os_aec, move |event_kind, message| { match CallEngine::start(relay, room, alias, os_aec, quality, move |event_kind, message| {
let _ = app_clone.emit( let _ = app_clone.emit(
"call-event", "call-event",
CallEvent { CallEvent {

View File

@@ -8,6 +8,7 @@ const callScreen = document.getElementById("call-screen")!;
const roomInput = document.getElementById("room") as HTMLInputElement; const roomInput = document.getElementById("room") as HTMLInputElement;
const aliasInput = document.getElementById("alias") as HTMLInputElement; const aliasInput = document.getElementById("alias") as HTMLInputElement;
const osAecCheckbox = document.getElementById("os-aec") as HTMLInputElement; const osAecCheckbox = document.getElementById("os-aec") as HTMLInputElement;
const qualitySelect = document.getElementById("quality") as HTMLSelectElement;
const connectBtn = document.getElementById("connect-btn") as HTMLButtonElement; const connectBtn = document.getElementById("connect-btn") as HTMLButtonElement;
const connectError = document.getElementById("connect-error")!; const connectError = document.getElementById("connect-error")!;
const roomName = document.getElementById("room-name")!; const roomName = document.getElementById("room-name")!;
@@ -48,6 +49,7 @@ const sRoom = document.getElementById("s-room") as HTMLInputElement;
const sAlias = document.getElementById("s-alias") as HTMLInputElement; const sAlias = document.getElementById("s-alias") as HTMLInputElement;
const sOsAec = document.getElementById("s-os-aec") as HTMLInputElement; const sOsAec = document.getElementById("s-os-aec") as HTMLInputElement;
const sAgc = document.getElementById("s-agc") as HTMLInputElement; const sAgc = document.getElementById("s-agc") as HTMLInputElement;
const sQuality = document.getElementById("s-quality") as HTMLSelectElement;
const sFingerprint = document.getElementById("s-fingerprint")!; const sFingerprint = document.getElementById("s-fingerprint")!;
const sRecentRooms = document.getElementById("s-recent-rooms")!; const sRecentRooms = document.getElementById("s-recent-rooms")!;
const sClearRecent = document.getElementById("s-clear-recent")!; const sClearRecent = document.getElementById("s-clear-recent")!;
@@ -74,6 +76,7 @@ interface Settings {
alias: string; alias: string;
osAec: boolean; osAec: boolean;
agc: boolean; agc: boolean;
quality: string;
recentRooms: RecentRoom[]; recentRooms: RecentRoom[];
} }
@@ -81,7 +84,7 @@ function loadSettings(): Settings {
const defaults: Settings = { const defaults: Settings = {
relays: [{ name: "Default", address: "193.180.213.68:4433" }], relays: [{ name: "Default", address: "193.180.213.68:4433" }],
selectedRelay: 0, room: "android", alias: "", selectedRelay: 0, room: "android", alias: "",
osAec: true, agc: true, recentRooms: [], osAec: true, agc: true, quality: "auto", recentRooms: [],
}; };
try { try {
const raw = localStorage.getItem("wzp-settings"); const raw = localStorage.getItem("wzp-settings");
@@ -156,6 +159,7 @@ function applySettings() {
roomInput.value = s.room; roomInput.value = s.room;
aliasInput.value = s.alias; aliasInput.value = s.alias;
osAecCheckbox.checked = s.osAec; osAecCheckbox.checked = s.osAec;
qualitySelect.value = s.quality || "auto";
renderRecentRooms(s.recentRooms); renderRecentRooms(s.recentRooms);
renderRelayButton(); renderRelayButton();
} }
@@ -376,7 +380,7 @@ async function doConnect() {
userDisconnected = false; userDisconnected = false;
const s = loadSettings(); const s = loadSettings();
s.room = roomInput.value; s.alias = aliasInput.value; s.osAec = osAecCheckbox.checked; s.room = roomInput.value; s.alias = aliasInput.value; s.osAec = osAecCheckbox.checked; s.quality = qualitySelect.value;
const room = roomInput.value.trim(); const room = roomInput.value.trim();
if (room) { if (room) {
const entry: RecentRoom = { relay: relay.address, room }; const entry: RecentRoom = { relay: relay.address, room };
@@ -388,6 +392,7 @@ async function doConnect() {
await invoke("connect", { await invoke("connect", {
relay: relay.address, room: roomInput.value, relay: relay.address, room: roomInput.value,
alias: aliasInput.value, osAec: osAecCheckbox.checked, alias: aliasInput.value, osAec: osAecCheckbox.checked,
quality: qualitySelect.value,
}); });
showCallScreen(); showCallScreen();
} catch (e: any) { } catch (e: any) {
@@ -529,7 +534,7 @@ listen("call-event", (event: any) => {
// ── Settings ── // ── Settings ──
function openSettings() { function openSettings() {
const s = loadSettings(); const s = loadSettings();
sRoom.value = s.room; sAlias.value = s.alias; sOsAec.checked = s.osAec; sRoom.value = s.room; sAlias.value = s.alias; sOsAec.checked = s.osAec; sQuality.value = s.quality || "auto";
sFingerprint.textContent = myFingerprint || "(loading...)"; sFingerprint.textContent = myFingerprint || "(loading...)";
renderSettingsRecentRooms(s.recentRooms); renderSettingsRecentRooms(s.recentRooms);
settingsPanel.classList.remove("hidden"); settingsPanel.classList.remove("hidden");
@@ -564,9 +569,9 @@ settingsPanel.addEventListener("click", (e) => { if (e.target === settingsPanel)
settingsSave.addEventListener("click", () => { settingsSave.addEventListener("click", () => {
const s = loadSettings(); const s = loadSettings();
s.room = sRoom.value; s.alias = sAlias.value; s.osAec = sOsAec.checked; s.room = sRoom.value; s.alias = sAlias.value; s.osAec = sOsAec.checked; s.quality = sQuality.value;
saveSettingsObj(s); saveSettingsObj(s);
roomInput.value = s.room; aliasInput.value = s.alias; osAecCheckbox.checked = s.osAec; roomInput.value = s.room; aliasInput.value = s.alias; osAecCheckbox.checked = s.osAec; qualitySelect.value = s.quality;
renderRecentRooms(s.recentRooms); renderRecentRooms(s.recentRooms);
closeSettings(); closeSettings();
}); });