11 Commits

Author SHA1 Message Date
Siavash Sameni
395a0c557e 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>
2026-04-08 12:03:20 +04:00
Siavash Sameni
da593f9510 feat: relay-grouped participant rendering + relay_label in protocol
Some checks failed
Mirror to GitHub / mirror (push) Failing after 36s
Build Release Binaries / build-amd64 (push) Failing after 1m47s
RoomParticipant now has optional relay_label field. Desktop client
groups participants by relay: "This Relay" (green dot) for local,
peer label (blue dot) for federated. Shows all relays in the chain
including intermediate ones.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-08 11:22:05 +04:00
Siavash Sameni
7bddc6b5a6 fix: advertise studio profiles in desktop handshake supported_profiles
Some checks failed
Mirror to GitHub / mirror (push) Failing after 35s
Build Release Binaries / build-amd64 (push) Failing after 1m55s
Same fix as Android — the CallOffer now includes STUDIO_64K/48K/32K
so the relay can negotiate studio quality levels.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-07 21:06:48 +04:00
Siavash Sameni
3b85604b41 docs: PRDs for local recording + mixer and studio quality tiers
Some checks failed
Mirror to GitHub / mirror (push) Failing after 37s
Build Release Binaries / build-amd64 (push) Failing after 1m56s
PRD-local-recording.md: Dual-path architecture for podcast-quality
interviews — local lossless WAV recording alongside live call, with
sync markers for post-session alignment, resumable upload to a
self-hosted mixer service that produces normalized multi-track output.

PRD-studio-quality.md: Documents the Opus 32k/48k/64k studio tiers,
when to use them, cross-codec interop, and backward compatibility.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-07 18:32:24 +04:00
Siavash Sameni
a8c2011445 feat: add Opus 32k/48k/64k studio quality tiers
Some checks failed
Mirror to GitHub / mirror (push) Failing after 36s
Build Release Binaries / build-amd64 (push) Has been cancelled
Adds three new codec IDs (Opus32k=6, Opus48k=7, Opus64k=8) and
corresponding STUDIO_32K, STUDIO_48K, STUDIO_64K quality profiles.
All use 20ms frames with minimal FEC (10%) for maximum quality on
good networks.

Updated across: wire protocol (codec_id.rs), encoder/decoder
(opus_enc/dec.rs), adaptive codec switch (call.rs), CLI
(--profile studio-64k), desktop engine + UI slider (8 quality
levels from Studio 64k green to Codec2 1.2k red).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-07 18:31:05 +04:00
Siavash Sameni
ded49bdb7b feat: replace browser confirm with proper key-change warning dialog
Some checks failed
Mirror to GitHub / mirror (push) Failing after 35s
Build Release Binaries / build-amd64 (push) Failing after 1m57s
When the relay's server key changes (e.g. after restart), show a
styled in-app warning dialog instead of the ugly browser confirm().
The dialog shows old vs new fingerprints and lets the user accept
the new key or cancel. Accepting updates the saved fingerprint and
refreshes the relay button state.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-07 18:19:53 +04:00
Siavash Sameni
369347ce54 fix: remove unused FRAME_SAMPLES_20MS constant in desktop engine
Some checks failed
Mirror to GitHub / mirror (push) Failing after 35s
Build Release Binaries / build-amd64 (push) Failing after 1m53s
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-07 17:54:13 +04:00
Siavash Sameni
44f04b55e8 feat: quality slider in settings with color gradient
Some checks failed
Mirror to GitHub / mirror (push) Failing after 36s
Build Release Binaries / build-amd64 (push) Failing after 1m56s
Replace the quality dropdown with a range slider in the settings
panel. The slider goes from Auto (green) through Opus 24k, Opus 6k
(yellow), Codec2 3.2k (orange) to Codec2 1.2k (dark red). The
track uses a green-to-red gradient and the label color updates
to match the selected level. Removed the quality dropdown from
the connect screen — quality is now settings-only.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-07 17:50:46 +04:00
Siavash Sameni
85c2146760 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>
2026-04-07 17:44:17 +04:00
Siavash Sameni
96ccb4f333 fix: auto-switch decoder codec to match incoming packets
Some checks failed
Mirror to GitHub / mirror (push) Failing after 35s
Build Release Binaries / build-amd64 (push) Failing after 3m41s
The CallDecoder now inspects each incoming packet's codec_id and
automatically switches the audio decoder if it differs from the
current profile. This enables cross-codec interop where one client
sends Opus and the other sends Codec2 — previously the receiver
would try to decode with the wrong codec, producing garbled audio.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-07 15:35:31 +04:00
Siavash Sameni
95a905e1b5 feat: add --profile/--codec flag to CLI for forcing codec selection
Enables debugging Codec2 by allowing forced codec selection from CLI.
Supports: good, degraded, catastrophic, codec2-3200, codec2-1200.
Frame size, timing, and jitter buffer are all adjusted dynamically
based on the selected profile.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-07 15:35:31 +04:00
14 changed files with 873 additions and 77 deletions

View File

@@ -500,6 +500,52 @@ impl CallDecoder {
} }
} }
/// Switch the decoder to match an incoming packet's codec if it differs
/// from the current profile. This enables cross-codec interop (e.g. one
/// client sends Opus, the other sends Codec2).
fn switch_decoder_if_needed(&mut self, incoming_codec: CodecId) {
if incoming_codec == self.profile.codec || incoming_codec == CodecId::ComfortNoise {
return;
}
let new_profile = Self::profile_for_codec(incoming_codec);
info!(
from = ?self.profile.codec,
to = ?incoming_codec,
"decoder switching codec to match incoming packet"
);
if let Err(e) = self.audio_dec.set_profile(new_profile) {
warn!("failed to switch decoder profile: {e}");
return;
}
self.fec_dec = wzp_fec::create_decoder(&new_profile);
self.profile = new_profile;
}
/// Map a `CodecId` to a reasonable `QualityProfile` for decoding.
fn profile_for_codec(codec: CodecId) -> QualityProfile {
match codec {
CodecId::Opus24k => QualityProfile::GOOD,
CodecId::Opus16k => QualityProfile {
codec: CodecId::Opus16k,
fec_ratio: 0.3,
frame_duration_ms: 20,
frames_per_block: 5,
},
CodecId::Opus6k => QualityProfile::DEGRADED,
CodecId::Opus32k => QualityProfile::STUDIO_32K,
CodecId::Opus48k => QualityProfile::STUDIO_48K,
CodecId::Opus64k => QualityProfile::STUDIO_64K,
CodecId::Codec2_3200 => QualityProfile {
codec: CodecId::Codec2_3200,
fec_ratio: 0.5,
frame_duration_ms: 20,
frames_per_block: 5,
},
CodecId::Codec2_1200 => QualityProfile::CATASTROPHIC,
CodecId::ComfortNoise => QualityProfile::GOOD,
}
}
/// Decode the next audio frame from the jitter buffer. /// Decode the next audio frame from the jitter buffer.
/// ///
/// Returns PCM samples (48kHz mono) or None if not ready. /// Returns PCM samples (48kHz mono) or None if not ready.
@@ -514,6 +560,9 @@ impl CallDecoder {
return Some(pcm.len()); return Some(pcm.len());
} }
// Auto-switch decoder if incoming codec differs from current.
self.switch_decoder_if_needed(pkt.header.codec_id);
self.last_was_cn = false; self.last_was_cn = false;
let result = match self.audio_dec.decode(&pkt.payload, pcm) { let result = match self.audio_dec.decode(&pkt.payload, pcm) {
Ok(n) => Some(n), Ok(n) => Some(n),

View File

@@ -19,12 +19,18 @@ use tracing::{error, info, warn};
use wzp_client::call::{CallConfig, CallDecoder, CallEncoder}; use wzp_client::call::{CallConfig, CallDecoder, CallEncoder};
use wzp_proto::MediaTransport; use wzp_proto::MediaTransport;
const FRAME_SAMPLES: usize = 960; // 20ms @ 48kHz const FRAME_SAMPLES_20MS: usize = 960; // 20ms @ 48kHz
const FRAME_SAMPLES_40MS: usize = 1920; // 40ms @ 48kHz
/// Compute frame samples at 48kHz for a given profile.
fn frame_samples_for(profile: &wzp_proto::QualityProfile) -> usize {
(profile.frame_duration_ms as usize) * 48 // 48000 / 1000
}
/// Generate a sine wave tone. /// Generate a sine wave tone.
fn generate_sine_frame(freq_hz: f32, sample_rate: u32, frame_offset: u64) -> Vec<i16> { fn generate_sine_frame(freq_hz: f32, sample_rate: u32, frame_offset: u64, frame_samples: usize) -> Vec<i16> {
let start_sample = frame_offset * FRAME_SAMPLES as u64; let start_sample = frame_offset * frame_samples as u64;
(0..FRAME_SAMPLES) (0..frame_samples)
.map(|i| { .map(|i| {
let t = (start_sample + i as u64) as f32 / sample_rate as f32; let t = (start_sample + i as u64) as f32 / sample_rate as f32;
(f32::sin(2.0 * std::f32::consts::PI * freq_hz * t) * 16000.0) as i16 (f32::sin(2.0 * std::f32::consts::PI * freq_hz * t) * 16000.0) as i16
@@ -57,6 +63,8 @@ struct CliArgs {
os_aec: bool, os_aec: bool,
token: Option<String>, token: Option<String>,
_metrics_file: Option<String>, _metrics_file: Option<String>,
/// Force a quality profile: "good", "degraded", "catastrophic", "codec2-3200"
profile_override: Option<String>,
} }
/// Default identity file path: ~/.wzp/identity /// Default identity file path: ~/.wzp/identity
@@ -112,6 +120,30 @@ impl CliArgs {
} }
} }
/// Resolve a profile name to a QualityProfile.
fn resolve_profile(name: &str) -> wzp_proto::QualityProfile {
use wzp_proto::{CodecId, QualityProfile};
match name.to_lowercase().as_str() {
"good" | "opus" | "opus24k" => QualityProfile::GOOD,
"degraded" | "opus6k" => QualityProfile::DEGRADED,
"catastrophic" | "codec2-1200" | "c2-1200" | "1200" => QualityProfile::CATASTROPHIC,
"codec2-3200" | "c2-3200" | "3200" => QualityProfile {
codec: CodecId::Codec2_3200,
fec_ratio: 0.5,
frame_duration_ms: 20,
frames_per_block: 5,
},
"studio-32k" | "opus32k" | "32k" => QualityProfile::STUDIO_32K,
"studio-48k" | "opus48k" | "48k" | "studio" => QualityProfile::STUDIO_48K,
"studio-64k" | "opus64k" | "64k" | "studio-high" => QualityProfile::STUDIO_64K,
other => {
eprintln!("unknown profile: {other}");
eprintln!("valid: good, degraded, catastrophic, codec2-3200, codec2-1200, studio-32k, studio-48k, studio-64k");
std::process::exit(1);
}
}
}
fn parse_args() -> CliArgs { fn parse_args() -> CliArgs {
let args: Vec<String> = std::env::args().collect(); let args: Vec<String> = std::env::args().collect();
let mut live = false; let mut live = false;
@@ -136,6 +168,7 @@ fn parse_args() -> CliArgs {
let mut os_aec = false; let mut os_aec = false;
let mut token = None; let mut token = None;
let mut metrics_file = None; let mut metrics_file = None;
let mut profile_override = None;
let mut relay_str = None; let mut relay_str = None;
let mut i = 1; let mut i = 1;
@@ -237,6 +270,14 @@ fn parse_args() -> CliArgs {
.expect("--drift-test value must be a number"), .expect("--drift-test value must be a number"),
); );
} }
"--profile" | "--codec" => {
i += 1;
profile_override = Some(
args.get(i)
.expect("--profile requires a value (good, degraded, catastrophic, codec2-3200)")
.to_string(),
);
}
"--sweep" => sweep = true, "--sweep" => sweep = true,
"--help" | "-h" => { "--help" | "-h" => {
eprintln!("Usage: wzp-client [options] [relay-addr]"); eprintln!("Usage: wzp-client [options] [relay-addr]");
@@ -248,6 +289,8 @@ fn parse_args() -> CliArgs {
eprintln!(" --record <file.raw> Record received audio to raw PCM file"); eprintln!(" --record <file.raw> Record received audio to raw PCM file");
eprintln!(" --echo-test <secs> Run automated echo quality test"); eprintln!(" --echo-test <secs> Run automated echo quality test");
eprintln!(" --drift-test <secs> Run automated clock-drift measurement"); eprintln!(" --drift-test <secs> Run automated clock-drift measurement");
eprintln!(" --profile <name> Force quality profile: good, degraded, catastrophic, codec2-3200");
eprintln!(" --codec <name> Alias for --profile");
eprintln!(" --sweep Run jitter buffer parameter sweep (local, no network)"); eprintln!(" --sweep Run jitter buffer parameter sweep (local, no network)");
eprintln!(" --seed <hex> Identity seed (64 hex chars, featherChat compatible)"); eprintln!(" --seed <hex> Identity seed (64 hex chars, featherChat compatible)");
eprintln!(" --mnemonic <words...> Identity seed as BIP39 mnemonic (24 words)"); eprintln!(" --mnemonic <words...> Identity seed as BIP39 mnemonic (24 words)");
@@ -312,6 +355,7 @@ fn parse_args() -> CliArgs {
os_aec, os_aec,
token, token,
_metrics_file: metrics_file, _metrics_file: metrics_file,
profile_override,
} }
} }
@@ -332,12 +376,19 @@ async fn main() -> anyhow::Result<()> {
let seed = cli.resolve_seed(); let seed = cli.resolve_seed();
// Resolve profile override
let profile = cli.profile_override.as_deref().map(resolve_profile);
if let Some(ref p) = profile {
info!(codec = ?p.codec, frame_ms = p.frame_duration_ms, fec = p.fec_ratio, "forced profile");
}
info!( info!(
relay = %cli.relay_addr, relay = %cli.relay_addr,
live = cli.live, live = cli.live,
send_tone = ?cli.send_tone_secs, send_tone = ?cli.send_tone_secs,
record = ?cli.record_file, record = ?cli.record_file,
room = ?cli.room, room = ?cli.room,
profile = ?cli.profile_override,
"WarzonePhone client" "WarzonePhone client"
); );
@@ -400,6 +451,7 @@ async fn main() -> anyhow::Result<()> {
direct_playout: cli.direct_playout, direct_playout: cli.direct_playout,
aec_delay_ms: cli.aec_delay_ms, aec_delay_ms: cli.aec_delay_ms,
os_aec: cli.os_aec, os_aec: cli.os_aec,
profile_override: profile,
}; };
return run_live(transport, audio_opts).await; return run_live(transport, audio_opts).await;
} }
@@ -422,19 +474,23 @@ async fn main() -> anyhow::Result<()> {
transport.close().await?; transport.close().await?;
Ok(()) Ok(())
} else if cli.send_tone_secs.is_some() || cli.send_file.is_some() || cli.record_file.is_some() { } else if cli.send_tone_secs.is_some() || cli.send_file.is_some() || cli.record_file.is_some() {
run_file_mode(transport, cli.send_tone_secs, cli.send_file, cli.record_file).await run_file_mode(transport, cli.send_tone_secs, cli.send_file, cli.record_file, profile).await
} else { } else {
run_silence(transport).await run_silence(transport, profile).await
} }
} }
/// Send silence frames (connectivity test). /// Send silence frames (connectivity test).
async fn run_silence(transport: Arc<wzp_transport::QuinnTransport>) -> anyhow::Result<()> { async fn run_silence(transport: Arc<wzp_transport::QuinnTransport>, profile: Option<wzp_proto::QualityProfile>) -> anyhow::Result<()> {
let config = CallConfig::default(); let config = match profile {
Some(p) => CallConfig::from_profile(p),
None => CallConfig::default(),
};
let frame_samples = frame_samples_for(&config.profile);
let mut encoder = CallEncoder::new(&config); let mut encoder = CallEncoder::new(&config);
let frame_duration = tokio::time::Duration::from_millis(20); let frame_duration = tokio::time::Duration::from_millis(config.profile.frame_duration_ms as u64);
let pcm = vec![0i16; FRAME_SAMPLES]; let pcm = vec![0i16; frame_samples];
let mut total_source = 0u64; let mut total_source = 0u64;
let mut total_repair = 0u64; let mut total_repair = 0u64;
@@ -480,13 +536,20 @@ async fn run_file_mode(
send_tone_secs: Option<u32>, send_tone_secs: Option<u32>,
send_file: Option<String>, send_file: Option<String>,
record_file: Option<String>, record_file: Option<String>,
profile: Option<wzp_proto::QualityProfile>,
) -> anyhow::Result<()> { ) -> anyhow::Result<()> {
let config = CallConfig::default(); let config = match profile {
Some(p) => CallConfig::from_profile(p),
None => CallConfig::default(),
};
let frame_samples = frame_samples_for(&config.profile);
let frame_duration_ms = config.profile.frame_duration_ms as u64;
// --- Send task: generate tone or play file --- // --- Send task: generate tone or play file ---
let send_transport = transport.clone(); let send_transport = transport.clone();
let send_handle = tokio::spawn(async move { let send_handle = tokio::spawn(async move {
// Load PCM frames from file or generate tone // Load PCM frames from file or generate tone
let frames_per_sec = 1000 / frame_duration_ms;
let pcm_frames: Vec<Vec<i16>> = if let Some(ref path) = send_file { let pcm_frames: Vec<Vec<i16>> = if let Some(ref path) = send_file {
// Read raw PCM file (48kHz mono s16le) // Read raw PCM file (48kHz mono s16le)
let bytes = match std::fs::read(path) { let bytes = match std::fs::read(path) {
@@ -498,14 +561,14 @@ async fn run_file_mode(
.collect(); .collect();
let duration = samples.len() as f64 / 48_000.0; let duration = samples.len() as f64 / 48_000.0;
info!(file = %path, duration = format!("{:.1}s", duration), "sending audio file"); info!(file = %path, duration = format!("{:.1}s", duration), "sending audio file");
samples.chunks(FRAME_SAMPLES) samples.chunks(frame_samples)
.filter(|c| c.len() == FRAME_SAMPLES) .filter(|c| c.len() == frame_samples)
.map(|c| c.to_vec()) .map(|c| c.to_vec())
.collect() .collect()
} else if let Some(secs) = send_tone_secs { } else if let Some(secs) = send_tone_secs {
let total = (secs as u64) * 50; let total = (secs as u64) * frames_per_sec;
info!(seconds = secs, frames = total, "sending 440Hz tone"); info!(seconds = secs, frames = total, frame_samples, frame_ms = frame_duration_ms, "sending 440Hz tone");
(0..total).map(|i| generate_sine_frame(440.0, 48_000, i)).collect() (0..total).map(|i| generate_sine_frame(440.0, 48_000, i, frame_samples)).collect()
} else { } else {
// No sending, just wait // No sending, just wait
tokio::signal::ctrl_c().await.ok(); tokio::signal::ctrl_c().await.ok();
@@ -514,7 +577,7 @@ async fn run_file_mode(
let mut encoder = CallEncoder::new(&config); let mut encoder = CallEncoder::new(&config);
let _total_frames = pcm_frames.len() as u64; let _total_frames = pcm_frames.len() as u64;
let frame_duration = tokio::time::Duration::from_millis(20); let frame_duration = tokio::time::Duration::from_millis(frame_duration_ms);
let mut total_source = 0u64; let mut total_source = 0u64;
let mut total_repair = 0u64; let mut total_repair = 0u64;
@@ -564,8 +627,13 @@ async fn run_file_mode(
} }
}; };
let mut decoder = CallDecoder::new(&CallConfig::default()); let recv_config = match profile {
let mut pcm_buf = vec![0i16; FRAME_SAMPLES]; Some(p) => CallConfig::from_profile(p),
None => CallConfig::default(),
};
let recv_frame_samples = frame_samples_for(&recv_config.profile);
let mut decoder = CallDecoder::new(&recv_config);
let mut pcm_buf = vec![0i16; recv_frame_samples.max(FRAME_SAMPLES_40MS)];
let mut all_pcm: Vec<i16> = Vec::new(); let mut all_pcm: Vec<i16> = Vec::new();
let mut frames_received = 0u64; let mut frames_received = 0u64;
@@ -704,6 +772,7 @@ struct AudioOpts {
direct_playout: bool, direct_playout: bool,
aec_delay_ms: Option<u32>, aec_delay_ms: Option<u32>,
os_aec: bool, os_aec: bool,
profile_override: Option<wzp_proto::QualityProfile>,
} }
#[cfg(feature = "audio")] #[cfg(feature = "audio")]
@@ -788,12 +857,18 @@ async fn run_live(
std::process::exit(1); std::process::exit(1);
}); });
let base_config = match opts.profile_override {
Some(p) => CallConfig::from_profile(p),
None => CallConfig::default(),
};
let config = CallConfig { let config = CallConfig {
noise_suppression: !opts.no_denoise, noise_suppression: !opts.no_denoise,
suppression_enabled: !opts.no_silence, suppression_enabled: !opts.no_silence,
aec_delay_ms: opts.aec_delay_ms.unwrap_or(40), aec_delay_ms: opts.aec_delay_ms.unwrap_or(40),
..CallConfig::default() ..base_config
}; };
let frame_samples = frame_samples_for(&config.profile);
info!(codec = ?config.profile.codec, frame_samples, frame_ms = config.profile.frame_duration_ms, "call config");
{ {
let mut flags = Vec::new(); let mut flags = Vec::new();
if opts.no_denoise { flags.push("denoise"); } if opts.no_denoise { flags.push("denoise"); }
@@ -819,8 +894,8 @@ async fn run_live(
let mut encoder = CallEncoder::new(&config); let mut encoder = CallEncoder::new(&config);
if no_aec { encoder.set_aec_enabled(false); } if no_aec { encoder.set_aec_enabled(false); }
if no_agc { encoder.set_agc_enabled(false); } if no_agc { encoder.set_agc_enabled(false); }
let mut capture_buf = vec![0i16; FRAME_SAMPLES]; let mut capture_buf = vec![0i16; frame_samples];
let mut farend_buf = vec![0i16; FRAME_SAMPLES]; let mut farend_buf = vec![0i16; frame_samples];
let mut frames_sent: u64 = 0; let mut frames_sent: u64 = 0;
let mut frames_dropped: u64 = 0; let mut frames_dropped: u64 = 0;
let mut send_errors: u64 = 0; let mut send_errors: u64 = 0;
@@ -834,19 +909,19 @@ async fn run_live(
} }
let avail = capture_ring.available(); let avail = capture_ring.available();
if avail < FRAME_SAMPLES { if avail < frame_samples {
tokio::time::sleep(std::time::Duration::from_millis(5)).await; tokio::time::sleep(std::time::Duration::from_millis(5)).await;
polls += 1; polls += 1;
// Diagnostic every 2 seconds // Diagnostic every 2 seconds
if last_diag.elapsed().as_secs() >= 2 { if last_diag.elapsed().as_secs() >= 2 {
info!(avail, polls, frames_sent, "send: ring starved (avail < {FRAME_SAMPLES})"); info!(avail, polls, frames_sent, frame_samples, "send: ring starved");
last_diag = std::time::Instant::now(); last_diag = std::time::Instant::now();
} }
continue; continue;
} }
let read = capture_ring.read(&mut capture_buf); let read = capture_ring.read(&mut capture_buf);
if read < FRAME_SAMPLES { if read < frame_samples {
continue; continue;
} }
@@ -858,7 +933,7 @@ async fn run_live(
// Feed AEC far-end reference: what was played through the speaker. // Feed AEC far-end reference: what was played through the speaker.
// Must be called BEFORE encode_frame processes the mic signal. // Must be called BEFORE encode_frame processes the mic signal.
if !no_aec { if !no_aec {
while send_farend.available() >= FRAME_SAMPLES { while send_farend.available() >= frame_samples {
send_farend.read(&mut farend_buf); send_farend.read(&mut farend_buf);
encoder.feed_aec_farend(&farend_buf); encoder.feed_aec_farend(&farend_buf);
} }
@@ -903,6 +978,8 @@ async fn run_live(
let recv_running = running.clone(); let recv_running = running.clone();
let recv_spk_muted = spk_muted.clone(); let recv_spk_muted = spk_muted.clone();
let direct_playout = opts.direct_playout; let direct_playout = opts.direct_playout;
let recv_profile = opts.profile_override;
let playout_profile = recv_profile; // Copy for playout_task
// Direct playout: decode on recv, write straight to playout ring (like Android). // Direct playout: decode on recv, write straight to playout ring (like Android).
// Jitter buffer mode: ingest into jitter buffer, decode on 20ms tick. // Jitter buffer mode: ingest into jitter buffer, decode on 20ms tick.
@@ -917,14 +994,15 @@ async fn run_live(
let mut packets_received: u64 = 0; let mut packets_received: u64 = 0;
let mut recv_errors: u64 = 0; let mut recv_errors: u64 = 0;
let mut timeouts: u64 = 0; let mut timeouts: u64 = 0;
// For direct playout: raw Opus decoder + AGC // For direct playout: raw codec decoder + AGC
let direct_profile = recv_profile.unwrap_or(wzp_proto::QualityProfile::GOOD);
let mut opus_dec = if direct_playout { let mut opus_dec = if direct_playout {
Some(wzp_codec::create_decoder(wzp_proto::QualityProfile::GOOD)) Some(wzp_codec::create_decoder(direct_profile))
} else { } else {
None None
}; };
let mut playout_agc = wzp_codec::AutoGainControl::new(); let mut playout_agc = wzp_codec::AutoGainControl::new();
let mut pcm_buf = vec![0i16; FRAME_SAMPLES]; let mut pcm_buf = vec![0i16; frame_samples.max(FRAME_SAMPLES_40MS)];
loop { loop {
if !recv_running.load(Ordering::Relaxed) { if !recv_running.load(Ordering::Relaxed) {
@@ -1019,10 +1097,15 @@ async fn run_live(
return; return;
} }
let config = CallConfig::default(); let playout_config = match playout_profile {
let mut decoder = CallDecoder::new(&config); Some(p) => CallConfig::from_profile(p),
let mut pcm_buf = vec![0i16; FRAME_SAMPLES]; None => CallConfig::default(),
let mut interval = tokio::time::interval(std::time::Duration::from_millis(20)); };
let playout_frame_ms = playout_config.profile.frame_duration_ms as u64;
let playout_frame_samples = frame_samples_for(&playout_config.profile);
let mut decoder = CallDecoder::new(&playout_config);
let mut pcm_buf = vec![0i16; playout_frame_samples.max(FRAME_SAMPLES_40MS)];
let mut interval = tokio::time::interval(std::time::Duration::from_millis(playout_frame_ms));
interval.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip); interval.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip);
let mut telemetry = JitterTelemetry::new(5); let mut telemetry = JitterTelemetry::new(5);
loop { loop {

View File

@@ -38,6 +38,9 @@ pub async fn perform_handshake(
ephemeral_pub, ephemeral_pub,
signature, signature,
supported_profiles: vec![ supported_profiles: vec![
QualityProfile::STUDIO_64K,
QualityProfile::STUDIO_48K,
QualityProfile::STUDIO_32K,
QualityProfile::GOOD, QualityProfile::GOOD,
QualityProfile::DEGRADED, QualityProfile::DEGRADED,
QualityProfile::CATASTROPHIC, QualityProfile::CATASTROPHIC,

View File

@@ -79,7 +79,7 @@ impl AudioDecoder for OpusDecoder {
fn set_profile(&mut self, profile: QualityProfile) -> Result<(), CodecError> { fn set_profile(&mut self, profile: QualityProfile) -> Result<(), CodecError> {
match profile.codec { match profile.codec {
CodecId::Opus24k | CodecId::Opus16k | CodecId::Opus6k => { c if c.is_opus() => {
self.codec_id = profile.codec; self.codec_id = profile.codec;
self.frame_duration_ms = profile.frame_duration_ms; self.frame_duration_ms = profile.frame_duration_ms;
Ok(()) Ok(())

View File

@@ -100,7 +100,7 @@ impl AudioEncoder for OpusEncoder {
fn set_profile(&mut self, profile: QualityProfile) -> Result<(), CodecError> { fn set_profile(&mut self, profile: QualityProfile) -> Result<(), CodecError> {
match profile.codec { match profile.codec {
CodecId::Opus24k | CodecId::Opus16k | CodecId::Opus6k => { c if c.is_opus() => {
self.codec_id = profile.codec; self.codec_id = profile.codec;
self.frame_duration_ms = profile.frame_duration_ms; self.frame_duration_ms = profile.frame_duration_ms;
self.apply_bitrate(profile.codec)?; self.apply_bitrate(profile.codec)?;

View File

@@ -18,6 +18,12 @@ pub enum CodecId {
Codec2_1200 = 4, Codec2_1200 = 4,
/// Comfort noise descriptor (silence suppression) /// Comfort noise descriptor (silence suppression)
ComfortNoise = 5, ComfortNoise = 5,
/// Opus at 32kbps (studio low)
Opus32k = 6,
/// Opus at 48kbps (studio)
Opus48k = 7,
/// Opus at 64kbps (studio high)
Opus64k = 8,
} }
impl CodecId { impl CodecId {
@@ -27,6 +33,9 @@ impl CodecId {
Self::Opus24k => 24_000, Self::Opus24k => 24_000,
Self::Opus16k => 16_000, Self::Opus16k => 16_000,
Self::Opus6k => 6_000, Self::Opus6k => 6_000,
Self::Opus32k => 32_000,
Self::Opus48k => 48_000,
Self::Opus64k => 64_000,
Self::Codec2_3200 => 3_200, Self::Codec2_3200 => 3_200,
Self::Codec2_1200 => 1_200, Self::Codec2_1200 => 1_200,
Self::ComfortNoise => 0, Self::ComfortNoise => 0,
@@ -36,8 +45,7 @@ impl CodecId {
/// Preferred frame duration in milliseconds. /// Preferred frame duration in milliseconds.
pub const fn frame_duration_ms(self) -> u8 { pub const fn frame_duration_ms(self) -> u8 {
match self { match self {
Self::Opus24k => 20, Self::Opus24k | Self::Opus16k | Self::Opus32k | Self::Opus48k | Self::Opus64k => 20,
Self::Opus16k => 20,
Self::Opus6k => 40, Self::Opus6k => 40,
Self::Codec2_3200 => 20, Self::Codec2_3200 => 20,
Self::Codec2_1200 => 40, Self::Codec2_1200 => 40,
@@ -48,7 +56,8 @@ impl CodecId {
/// Sample rate expected by this codec. /// Sample rate expected by this codec.
pub const fn sample_rate_hz(self) -> u32 { pub const fn sample_rate_hz(self) -> u32 {
match self { match self {
Self::Opus24k | Self::Opus16k | Self::Opus6k => 48_000, Self::Opus24k | Self::Opus16k | Self::Opus6k
| Self::Opus32k | Self::Opus48k | Self::Opus64k => 48_000,
Self::Codec2_3200 | Self::Codec2_1200 => 8_000, Self::Codec2_3200 | Self::Codec2_1200 => 8_000,
Self::ComfortNoise => 48_000, Self::ComfortNoise => 48_000,
} }
@@ -63,6 +72,9 @@ impl CodecId {
3 => Some(Self::Codec2_3200), 3 => Some(Self::Codec2_3200),
4 => Some(Self::Codec2_1200), 4 => Some(Self::Codec2_1200),
5 => Some(Self::ComfortNoise), 5 => Some(Self::ComfortNoise),
6 => Some(Self::Opus32k),
7 => Some(Self::Opus48k),
8 => Some(Self::Opus64k),
_ => None, _ => None,
} }
} }
@@ -71,6 +83,12 @@ impl CodecId {
pub const fn to_wire(self) -> u8 { pub const fn to_wire(self) -> u8 {
self as u8 self as u8
} }
/// Returns true if this is an Opus variant.
pub const fn is_opus(self) -> bool {
matches!(self, Self::Opus6k | Self::Opus16k | Self::Opus24k
| Self::Opus32k | Self::Opus48k | Self::Opus64k)
}
} }
/// Describes the complete quality configuration for a call session. /// Describes the complete quality configuration for a call session.
@@ -111,6 +129,30 @@ impl QualityProfile {
frames_per_block: 8, frames_per_block: 8,
}; };
/// Studio low: Opus 32kbps, minimal FEC.
pub const STUDIO_32K: Self = Self {
codec: CodecId::Opus32k,
fec_ratio: 0.1,
frame_duration_ms: 20,
frames_per_block: 5,
};
/// Studio: Opus 48kbps, minimal FEC.
pub const STUDIO_48K: Self = Self {
codec: CodecId::Opus48k,
fec_ratio: 0.1,
frame_duration_ms: 20,
frames_per_block: 5,
};
/// Studio high: Opus 64kbps, minimal FEC.
pub const STUDIO_64K: Self = Self {
codec: CodecId::Opus64k,
fec_ratio: 0.1,
frame_duration_ms: 20,
frames_per_block: 5,
};
/// Estimated total bandwidth in kbps including FEC overhead. /// Estimated total bandwidth in kbps including FEC overhead.
pub fn total_bitrate_kbps(&self) -> f32 { pub fn total_bitrate_kbps(&self) -> f32 {
let base = self.codec.bitrate_bps() as f32 / 1000.0; let base = self.codec.bitrate_bps() as f32 / 1000.0;

View File

@@ -670,6 +670,10 @@ pub struct RoomParticipant {
pub fingerprint: String, pub fingerprint: String,
/// Optional display name set by the client. /// Optional display name set by the client.
pub alias: Option<String>, pub alias: Option<String>,
/// Relay label — identifies which relay this participant is connected to.
/// None for local participants, Some("Relay B") for federated.
#[serde(default)]
pub relay_label: Option<String>,
} }
/// Reasons for ending a call. /// Reasons for ending a call.

View File

@@ -91,6 +91,23 @@
</div> </div>
<div class="settings-section"> <div class="settings-section">
<h3>Audio</h3> <h3>Audio</h3>
<div class="quality-control">
<div class="quality-header">
<span class="setting-label">QUALITY</span>
<span id="s-quality-label" class="quality-label">Auto</span>
</div>
<input id="s-quality" type="range" min="0" max="7" step="1" value="3" class="quality-slider" />
<div class="quality-ticks">
<span>64k</span>
<span>48k</span>
<span>32k</span>
<span>Auto</span>
<span>24k</span>
<span>6k</span>
<span>C2</span>
<span>1.2k</span>
</div>
</div>
<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)
@@ -137,6 +154,28 @@
</div> </div>
</div> </div>
</div> </div>
<!-- Key changed warning dialog -->
<div id="key-warning" class="hidden">
<div class="settings-card key-warning-card">
<div class="key-warning-icon">&#9888;</div>
<h2>Server Key Changed</h2>
<p class="key-warning-text">The relay's identity has changed since you last connected. This usually happens when the server was restarted, but could also indicate a security issue.</p>
<div class="key-warning-fps">
<div class="key-fp-row">
<span class="key-fp-label">Previously known</span>
<code id="kw-old-fp" class="key-fp"></code>
</div>
<div class="key-fp-row">
<span class="key-fp-label">New key</span>
<code id="kw-new-fp" class="key-fp"></code>
</div>
</div>
<div class="key-warning-actions">
<button id="kw-accept" class="primary">Accept New Key</button>
<button id="kw-cancel" class="secondary-btn">Cancel</button>
</div>
</div>
</div>
</div> </div>
<script type="module" src="/src/main.ts"></script> <script type="module" src="/src/main.ts"></script>
</body> </body>

View File

@@ -11,9 +11,29 @@ 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_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,
}),
"studio-32k" => Some(QualityProfile::STUDIO_32K),
"studio-48k" => Some(QualityProfile::STUDIO_48K),
"studio-64k" => Some(QualityProfile::STUDIO_64K),
_ => 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),
@@ -25,6 +45,7 @@ unsafe impl Sync for SyncWrapper {}
pub struct ParticipantInfo { pub struct ParticipantInfo {
pub fingerprint: String, pub fingerprint: String,
pub alias: Option<String>, pub alias: Option<String>,
pub relay_label: Option<String>,
} }
pub struct EngineStatus { pub struct EngineStatus {
@@ -36,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 {
@@ -46,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,
@@ -60,6 +85,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
@@ -165,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();
@@ -173,21 +201,34 @@ 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();
let send_tx_codec = tx_codec.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");
*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];
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 +262,18 @@ 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();
let recv_rx_codec = rx_codec.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 +286,33 @@ 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) { // 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
if pkt.header.codec_id != current_codec {
let new_profile = match pkt.header.codec_id {
CodecId::Opus24k => QualityProfile::GOOD,
CodecId::Opus6k => QualityProfile::DEGRADED,
CodecId::Opus32k => QualityProfile::STUDIO_32K,
CodecId::Opus48k => QualityProfile::STUDIO_48K,
CodecId::Opus64k => QualityProfile::STUDIO_64K,
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 +328,6 @@ impl CallEngine {
error!("recv fatal: {e}"); error!("recv fatal: {e}");
break; break;
} }
// Transient error — continue
} }
Err(_) => {} Err(_) => {}
} }
@@ -294,6 +362,7 @@ impl CallEngine {
.map(|p| ParticipantInfo { .map(|p| ParticipantInfo {
fingerprint: p.fingerprint, fingerprint: p.fingerprint,
alias: p.alias, alias: p.alias,
relay_label: p.relay_label,
}) })
.collect(); .collect();
let count = unique.len(); let count = unique.len();
@@ -319,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),
}) })
} }
@@ -343,6 +414,7 @@ impl CallEngine {
.map(|p| ParticipantInfo { .map(|p| ParticipantInfo {
fingerprint: p.fingerprint.clone(), fingerprint: p.fingerprint.clone(),
alias: p.alias.clone(), alias: p.alias.clone(),
relay_label: p.relay_label.clone(),
}) })
.collect() .collect()
}; // lock dropped here }; // lock dropped here
@@ -355,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

@@ -18,6 +18,7 @@ struct CallEvent {
struct Participant { struct Participant {
fingerprint: String, fingerprint: String,
alias: Option<String>, alias: Option<String>,
relay_label: Option<String>,
} }
#[derive(Clone, Serialize)] #[derive(Clone, Serialize)]
@@ -31,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 {
@@ -122,6 +125,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 +133,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 {
@@ -194,6 +198,7 @@ async fn get_status(state: tauri::State<'_, Arc<AppState>>) -> Result<CallStatus
.map(|p| Participant { .map(|p| Participant {
fingerprint: p.fingerprint, fingerprint: p.fingerprint,
alias: p.alias, alias: p.alias,
relay_label: p.relay_label,
}) })
.collect(), .collect(),
encode_fps: status.frames_sent, encode_fps: status.frames_sent,
@@ -201,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 {
@@ -213,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

@@ -48,10 +48,39 @@ 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 HTMLInputElement;
const sQualityLabel = document.getElementById("s-quality-label")!;
// Quality slider config — best (left/green) to worst (right/red)
const QUALITY_STEPS = ["studio-64k", "studio-48k", "studio-32k", "auto", "good", "degraded", "codec2-3200", "catastrophic"];
const QUALITY_LABELS = ["Studio 64k", "Studio 48k", "Studio 32k", "Auto", "Opus 24k", "Opus 6k", "Codec2 3.2k", "Codec2 1.2k"];
const QUALITY_COLORS = ["#22c55e", "#4ade80", "#86efac", "#a3e635", "#facc15", "#f59e0b", "#e97320", "#991b1b"];
function qualityToIndex(q: string): number {
const idx = QUALITY_STEPS.indexOf(q);
return idx >= 0 ? idx : 3; // default to "auto" (index 3)
}
function updateQualityUI(index: number) {
sQualityLabel.textContent = QUALITY_LABELS[index];
sQualityLabel.style.color = QUALITY_COLORS[index];
sQuality.style.background = `linear-gradient(90deg, #22c55e 0%, #86efac 25%, #facc15 50%, #e97320 75%, #991b1b 100%)`;
}
sQuality.addEventListener("input", () => {
updateQualityUI(parseInt(sQuality.value));
});
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")!;
// Key warning dialog
const keyWarning = document.getElementById("key-warning")!;
const kwOldFp = document.getElementById("kw-old-fp")!;
const kwNewFp = document.getElementById("kw-new-fp")!;
const kwAccept = document.getElementById("kw-accept")!;
const kwCancel = document.getElementById("kw-cancel")!;
let statusInterval: number | null = null; let statusInterval: number | null = null;
let myFingerprint = ""; let myFingerprint = "";
let userDisconnected = false; let userDisconnected = false;
@@ -74,6 +103,7 @@ interface Settings {
alias: string; alias: string;
osAec: boolean; osAec: boolean;
agc: boolean; agc: boolean;
quality: string;
recentRooms: RecentRoom[]; recentRooms: RecentRoom[];
} }
@@ -81,7 +111,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");
@@ -352,6 +382,28 @@ connectBtn.addEventListener("click", doConnect);
el.addEventListener("keydown", (e) => { if (e.key === "Enter") doConnect(); }) el.addEventListener("keydown", (e) => { if (e.key === "Enter") doConnect(); })
); );
function showKeyWarning(oldFp: string, newFp: string): Promise<boolean> {
return new Promise((resolve) => {
kwOldFp.textContent = oldFp;
kwNewFp.textContent = newFp;
keyWarning.classList.remove("hidden");
const cleanup = () => {
keyWarning.classList.add("hidden");
kwAccept.removeEventListener("click", onAccept);
kwCancel.removeEventListener("click", onCancel);
keyWarning.removeEventListener("click", onBackdrop);
};
const onAccept = () => { cleanup(); resolve(true); };
const onCancel = () => { cleanup(); resolve(false); };
const onBackdrop = (e: Event) => { if (e.target === keyWarning) { cleanup(); resolve(false); } };
kwAccept.addEventListener("click", onAccept);
kwCancel.addEventListener("click", onCancel);
keyWarning.addEventListener("click", onBackdrop);
});
}
async function doConnect() { async function doConnect() {
const relay = getSelectedRelay(); const relay = getSelectedRelay();
if (!relay) { connectError.textContent = "No relay selected"; return; } if (!relay) { connectError.textContent = "No relay selected"; return; }
@@ -359,13 +411,13 @@ async function doConnect() {
// Warn on fingerprint mismatch // Warn on fingerprint mismatch
const ls = lockStatus(relay); const ls = lockStatus(relay);
if (ls === "changed") { if (ls === "changed") {
if (!confirm(`Server fingerprint has changed!\n\nKnown: ${relay.knownFingerprint}\nNew: ${relay.serverFingerprint}\n\nThis could indicate a man-in-the-middle attack. Continue?`)) { const accepted = await showKeyWarning(relay.knownFingerprint || "", relay.serverFingerprint || "");
return; if (!accepted) return;
}
// User accepted — update known fingerprint // User accepted — update known fingerprint
const s = loadSettings(); const s = loadSettings();
s.relays[s.selectedRelay].knownFingerprint = relay.serverFingerprint; s.relays[s.selectedRelay].knownFingerprint = relay.serverFingerprint;
saveSettingsObj(s); saveSettingsObj(s);
renderRelayButton();
} }
// Don't block connect on offline — ping may have failed transiently // Don't block connect on offline — ping may have failed transiently
@@ -388,6 +440,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: s.quality || "auto",
}); });
showCallScreen(); showCallScreen();
} catch (e: any) { } catch (e: any) {
@@ -487,12 +540,29 @@ async function pollStatus() {
const pct = rms > 0 ? Math.min(100, (Math.log(rms) / Math.log(32767)) * 100) : 0; const pct = rms > 0 ? Math.min(100, (Math.log(rms) / Math.log(32767)) * 100) : 0;
levelBar.style.width = `${pct}%`; levelBar.style.width = `${pct}%`;
// Participants with identicons // Participants grouped by relay
if (st.participants.length === 0) { if (st.participants.length === 0) {
participantsDiv.innerHTML = '<div class="participants-empty">Waiting for participants...</div>'; participantsDiv.innerHTML = '<div class="participants-empty">Waiting for participants...</div>';
} else { } else {
participantsDiv.innerHTML = ""; participantsDiv.innerHTML = "";
st.participants.forEach((p) => { // Group by relay_label (null = this relay)
const groups: Record<string, typeof st.participants> = {};
st.participants.forEach((p: any) => {
const relay = p.relay_label || "This Relay";
if (!groups[relay]) groups[relay] = [];
groups[relay].push(p);
});
Object.entries(groups).forEach(([relay, members]) => {
// Relay header
const header = document.createElement("div");
header.className = "relay-group-header";
const isLocal = relay === "This Relay";
header.innerHTML = `<span class="relay-dot-small ${isLocal ? "green" : "blue"}"></span> ${escapeHtml(relay)}`;
participantsDiv.appendChild(header);
// Participants under this relay
(members as any[]).forEach((p) => {
const name = p.alias || "Anonymous"; const name = p.alias || "Anonymous";
const fp = p.fingerprint || ""; const fp = p.fingerprint || "";
const isMe = fp && myFingerprint.includes(fp); const isMe = fp && myFingerprint.includes(fp);
@@ -500,7 +570,6 @@ async function pollStatus() {
const row = document.createElement("div"); const row = document.createElement("div");
row.className = "participant"; row.className = "participant";
// Identicon avatar
const icon = createIdenticonEl(fp || name, 36, true); const icon = createIdenticonEl(fp || name, 36, true);
if (isMe) icon.style.outline = "2px solid var(--accent)"; if (isMe) icon.style.outline = "2px solid var(--accent)";
row.appendChild(icon); row.appendChild(icon);
@@ -514,9 +583,13 @@ async function pollStatus() {
row.appendChild(info); row.appendChild(info);
participantsDiv.appendChild(row); participantsDiv.appendChild(row);
}); });
});
} }
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 {}
} }
@@ -530,6 +603,9 @@ listen("call-event", (event: any) => {
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;
const qi = qualityToIndex(s.quality || "auto");
sQuality.value = String(qi);
updateQualityUI(qi);
sFingerprint.textContent = myFingerprint || "(loading...)"; sFingerprint.textContent = myFingerprint || "(loading...)";
renderSettingsRecentRooms(s.recentRooms); renderSettingsRecentRooms(s.recentRooms);
settingsPanel.classList.remove("hidden"); settingsPanel.classList.remove("hidden");
@@ -565,6 +641,7 @@ 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 = QUALITY_STEPS[parseInt(sQuality.value)] || "auto";
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;
renderRecentRooms(s.recentRooms); renderRecentRooms(s.recentRooms);

View File

@@ -441,6 +441,56 @@ button.primary:disabled { opacity: 0.5; cursor: not-allowed; }
border-radius: 8px; border-radius: 8px;
} }
/* ── Relay group headers ── */
.relay-group-header {
display: flex;
align-items: center;
gap: 6px;
font-size: 11px;
text-transform: uppercase;
letter-spacing: 0.5px;
color: var(--text-dim);
padding: 6px 0 2px;
border-top: 1px solid #ffffff08;
margin-top: 4px;
}
.relay-group-header:first-child {
border-top: none;
margin-top: 0;
}
.relay-dot-small {
width: 6px;
height: 6px;
border-radius: 50%;
display: inline-block;
}
.relay-dot-small.green { background: var(--green); }
.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;
@@ -651,3 +701,172 @@ button.primary:disabled { opacity: 0.5; cursor: not-allowed; }
} }
.secondary-btn:hover { border-color: var(--accent); color: var(--text); } .secondary-btn:hover { border-color: var(--accent); color: var(--text); }
/* ── Key warning dialog ── */
#key-warning {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.7);
backdrop-filter: blur(6px);
display: flex;
align-items: center;
justify-content: center;
z-index: 300;
padding: 20px;
}
.key-warning-card {
max-width: 360px;
text-align: center;
gap: 16px;
}
.key-warning-icon {
font-size: 48px;
color: var(--yellow);
line-height: 1;
}
.key-warning-card h2 {
font-size: 18px;
font-weight: 600;
}
.key-warning-text {
font-size: 13px;
color: var(--text-dim);
line-height: 1.5;
}
.key-warning-fps {
display: flex;
flex-direction: column;
gap: 8px;
background: var(--surface);
border-radius: 8px;
padding: 12px;
}
.key-fp-row {
display: flex;
flex-direction: column;
gap: 2px;
text-align: left;
}
.key-fp-label {
font-size: 10px;
text-transform: uppercase;
letter-spacing: 0.5px;
color: var(--text-dim);
}
.key-fp {
font-family: monospace;
font-size: 11px;
word-break: break-all;
color: var(--text);
}
.key-warning-actions {
display: flex;
gap: 10px;
}
.key-warning-actions .primary {
flex: 1;
background: var(--yellow);
color: #000;
font-weight: 600;
}
.key-warning-actions .secondary-btn {
flex: 1;
}
/* ── Quality slider ── */
.quality-control {
display: flex;
flex-direction: column;
gap: 6px;
padding: 4px 0;
}
.quality-header {
display: flex;
justify-content: space-between;
align-items: center;
}
.quality-label {
font-size: 13px;
font-weight: 600;
padding: 2px 8px;
border-radius: 6px;
transition: all 0.2s;
}
.quality-slider {
-webkit-appearance: none;
appearance: none;
width: 100%;
height: 6px;
border-radius: 3px;
outline: none;
cursor: pointer;
transition: background 0.2s;
}
.quality-slider::-webkit-slider-thumb {
-webkit-appearance: none;
appearance: none;
width: 18px;
height: 18px;
border-radius: 50%;
background: var(--text);
border: 2px solid var(--bg);
box-shadow: 0 1px 4px rgba(0,0,0,0.4);
cursor: pointer;
transition: transform 0.1s;
}
.quality-slider::-webkit-slider-thumb:hover {
transform: scale(1.15);
}
.quality-ticks {
display: flex;
justify-content: space-between;
font-size: 9px;
color: var(--text-dim);
padding: 0 2px;
}
.form select {
background: var(--surface);
border: 1px solid #333;
border-radius: 8px;
padding: 10px 12px;
color: var(--text);
font-size: 15px;
outline: none;
transition: border-color 0.2s;
}
.form select:focus {
border-color: var(--accent);
}
.settings-section select {
background: var(--surface);
border: 1px solid #333;
border-radius: 8px;
padding: 8px 10px;
color: var(--text);
font-size: 14px;
outline: none;
}
.settings-section select:focus {
border-color: var(--accent);
}

141
docs/PRD-local-recording.md Normal file
View File

@@ -0,0 +1,141 @@
# PRD: Local Recording + Cloud Mixer for Podcast-Quality Interviews
## Problem
WarzonePhone delivers real-time encrypted voice, but the audio quality is limited by network conditions (codec compression, packet loss, jitter). Podcasters and interviewers need pristine, studio-grade recordings of each participant — independent of what the network delivers.
## Solution
**Dual-path architecture**: each client simultaneously (1) participates in the live call at whatever codec quality the network supports, and (2) records their own microphone locally as lossless PCM. After the session, all local recordings are uploaded to a self-hosted mixer service that aligns, normalizes, and outputs a final multi-track or mixed file.
## Architecture
```
┌──────────────────┐
Mic ──┬── Opus/Codec2 ──► Network (live) │ ← real-time call
│ └──────────────────┘
└── WAV 48kHz ────► Local File │ ← pristine recording
(timestamped)
▼ (after hangup)
┌──────────────────┐
│ Mixer Service │ ← self-hosted
│ (align + mix) │
└──────────────────┘
Final MP3/WAV/FLAC
```
## Requirements
### Phase 1: Local Recording (MVP)
**All clients (Desktop, Android, Web):**
1. **Record toggle**: User can enable "Record this call" before or during a call
2. **Recording pipeline**: Tap raw PCM from the microphone capture path *before* it enters the codec encoder
3. **File format**: WAV (48kHz, 16-bit, mono) — simple, universally supported, lossless
4. **Sync markers**: Embed a monotonic timestamp (ms since call start) at the beginning of the recording, and periodically (every 10s) write a sync marker packet into a sidecar JSON file:
```json
{"ts_ms": 30000, "seq": 1500, "wall_clock_utc": "2026-04-07T12:00:30Z"}
```
This allows the mixer to align recordings from different participants even if they join at different times.
5. **Storage**:
- Desktop: `~/.wzp/recordings/{room}_{timestamp}.wav`
- Android: `Documents/WarzonePhone/{room}_{timestamp}.wav`
- Web: IndexedDB blob or File System Access API
6. **File size estimate**: 48kHz * 16-bit * mono = 96 KB/s = ~5.6 MB/min = ~345 MB/hour
7. **UI indicator**: Red dot + timer showing recording is active and file size growing
8. **On hangup**: Close the WAV file, show "Recording saved" with file path/size
### Phase 2: Upload to Mixer
1. **Upload endpoint**: Self-hosted HTTP service (Rust or Go) that accepts WAV uploads with metadata
2. **Chunked/resumable upload**: Large files need resumable uploads (tus protocol or simple chunked POST)
3. **Upload metadata**:
```json
{
"session_id": "uuid",
"participant_fingerprint": "xxxx:xxxx:...",
"alias": "Alice",
"room": "podcast-ep-42",
"duration_secs": 3600,
"sync_markers": [...],
"sample_rate": 48000,
"channels": 1,
"bit_depth": 16
}
```
4. **Upload UI**: Progress bar after hangup, option to upload now or later
5. **Retry on failure**: Queue uploads for retry if network is unavailable
### Phase 3: Mixer Service
1. **Alignment**: Use sync markers (wall clock + sequence numbers) to align recordings from all participants to a common timeline
2. **Silence trimming**: Detect and optionally trim leading/trailing silence
3. **Normalization**: Per-track loudness normalization (LUFS-based)
4. **Noise reduction**: Optional per-track noise gate or RNNoise pass
5. **Output formats**:
- Multi-track: ZIP of individual WAVs (aligned, normalized)
- Mixed: Single stereo or mono WAV/MP3/FLAC with all participants
- Podcast-ready: Loudness-normalized to -16 LUFS (podcast standard)
6. **Web UI**: Simple dashboard to see sessions, download outputs, preview waveforms
7. **Self-hosted**: Docker image, single binary, SQLite for metadata
## Implementation Notes
### Recording tap point
The recording must tap *after* AGC (so levels are normalized) but *before* the codec encoder (to avoid compression artifacts). In the current architecture:
```
Mic → Ring Buffer → AGC → [TAP HERE for recording] → Opus/Codec2 → Network
```
**Desktop** (`engine.rs`): After `capture_agc.process_frame()`, before `encoder.encode()`
**Android** (`engine.rs`): Same location — after AGC, before encode
**CLI** (`call.rs`): After `self.agc.process_frame()` in `CallEncoder::encode_frame()`
### WAV writer
Use a simple streaming WAV writer that:
- Writes the WAV header with placeholder data length
- Appends PCM samples as they come
- On close, seeks back to update the data length in the header
### Sync mechanism
Wall-clock UTC alone is insufficient (clocks drift). The sync strategy:
1. Each participant records their local monotonic time + wall clock at call start
2. Periodically (every 10s), each participant writes: `{local_mono_ms, seq_number, utc_iso}`
3. The mixer uses sequence numbers (which are shared via the wire protocol) as ground truth for alignment, with wall clock as a fallback
### Privacy
- Local recordings never leave the device without explicit user action
- Upload is manual, not automatic
- The mixer service processes files and can delete originals after mixing
- No recording data flows through the relay — only the user's own mic
## Non-Goals (v1)
- Live transcription (future)
- Video recording (audio only)
- Automatic upload without user consent
- Recording other participants' audio (only your own mic)
- Real-time mixing (post-session only)
## Milestones
| Phase | Scope | Effort |
|-------|-------|--------|
| 1a | Local WAV recording on Desktop | 1-2 days |
| 1b | Local WAV recording on Android | 1-2 days |
| 1c | Sync markers + metadata sidecar | 1 day |
| 2a | Upload service (HTTP + storage) | 2-3 days |
| 2b | Upload UI in clients | 1-2 days |
| 3a | Mixer: alignment + normalization | 2-3 days |
| 3b | Mixer: web dashboard | 2-3 days |
| 3c | Docker packaging | 1 day |

View File

@@ -0,0 +1,56 @@
# PRD: Studio Quality Tiers (Opus 32k/48k/64k)
## Status: Implemented
Studio quality tiers have been added to the wire protocol and all clients.
## What Was Added
### Wire Protocol (codec_id.rs)
Three new `CodecId` variants using the 4-bit header space (values 6-8):
| CodecId | Wire Value | Bitrate | Frame | Use Case |
|---------|-----------|---------|-------|----------|
| Opus32k | 6 | 32 kbps | 20ms | Studio low — noticeable improvement over 24k for voice |
| Opus48k | 7 | 48 kbps | 20ms | Studio — excellent voice, captures nuance |
| Opus64k | 8 | 64 kbps | 20ms | Studio high — near-transparent quality |
### Quality Profiles
| Profile | Codec | FEC | Bandwidth (with FEC) |
|---------|-------|-----|---------------------|
| STUDIO_32K | Opus 32k | 10% | ~35 kbps |
| STUDIO_48K | Opus 48k | 10% | ~53 kbps |
| STUDIO_64K | Opus 64k | 10% | ~70 kbps |
FEC is set to 10% (vs 20% for GOOD) — studio assumes a good network.
### Client Support
| Client | Selection | Status |
|--------|-----------|--------|
| Desktop (Tauri) | Quality slider in Settings (8 levels) | Done |
| CLI | `--profile studio-64k` / `studio-48k` / `studio-32k` | Done |
| Android | Needs codec picker update in SettingsScreen.kt | TODO |
| Web | Needs UI | TODO |
### Cross-Codec Interop
All decoder auto-switch paths (call.rs, desktop engine.rs) handle the new codec IDs. A studio-64k client can talk to a codec2-1200 client — the receiver auto-switches.
## When to Use Studio Tiers
- **Podcast recording sessions**: Use studio-64k for best quality (combined with local WAV recording for pristine output)
- **Music collaboration**: Opus at 48-64k captures instrument harmonics much better than 24k
- **Good network conditions**: Only useful when bandwidth isn't constrained; the extra bits are wasted on lossy networks
## When NOT to Use
- **Mobile data**: Stick with Auto/GOOD — studio tiers use 2-3x the bandwidth
- **High packet loss**: Studio profiles use minimal FEC (10%); degraded networks need DEGRADED or CATASTROPHIC profiles with 50-100% FEC
- **Large group calls**: Each participant's stream multiplies bandwidth; 64k * 10 participants = 640 kbps incoming
## Backward Compatibility
Old clients (before this change) will receive packets with CodecId 6/7/8 which they don't recognize. The `from_wire()` returns `None` for unknown values, causing the packet to be dropped. Old clients can still *send* to new clients fine (they use CodecId 0-5). This is acceptable for a pre-release protocol.