feat: add Opus 32k/48k/64k studio quality tiers
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>
This commit is contained in:
@@ -532,6 +532,9 @@ impl CallDecoder {
|
|||||||
frames_per_block: 5,
|
frames_per_block: 5,
|
||||||
},
|
},
|
||||||
CodecId::Opus6k => QualityProfile::DEGRADED,
|
CodecId::Opus6k => QualityProfile::DEGRADED,
|
||||||
|
CodecId::Opus32k => QualityProfile::STUDIO_32K,
|
||||||
|
CodecId::Opus48k => QualityProfile::STUDIO_48K,
|
||||||
|
CodecId::Opus64k => QualityProfile::STUDIO_64K,
|
||||||
CodecId::Codec2_3200 => QualityProfile {
|
CodecId::Codec2_3200 => QualityProfile {
|
||||||
codec: CodecId::Codec2_3200,
|
codec: CodecId::Codec2_3200,
|
||||||
fec_ratio: 0.5,
|
fec_ratio: 0.5,
|
||||||
|
|||||||
@@ -133,9 +133,12 @@ fn resolve_profile(name: &str) -> wzp_proto::QualityProfile {
|
|||||||
frame_duration_ms: 20,
|
frame_duration_ms: 20,
|
||||||
frames_per_block: 5,
|
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 => {
|
other => {
|
||||||
eprintln!("unknown profile: {other}");
|
eprintln!("unknown profile: {other}");
|
||||||
eprintln!("valid: good, degraded, catastrophic, codec2-3200, codec2-1200");
|
eprintln!("valid: good, degraded, catastrophic, codec2-3200, codec2-1200, studio-32k, studio-48k, studio-64k");
|
||||||
std::process::exit(1);
|
std::process::exit(1);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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(())
|
||||||
|
|||||||
@@ -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)?;
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -96,13 +96,16 @@
|
|||||||
<span class="setting-label">QUALITY</span>
|
<span class="setting-label">QUALITY</span>
|
||||||
<span id="s-quality-label" class="quality-label">Auto</span>
|
<span id="s-quality-label" class="quality-label">Auto</span>
|
||||||
</div>
|
</div>
|
||||||
<input id="s-quality" type="range" min="0" max="4" step="1" value="0" class="quality-slider" />
|
<input id="s-quality" type="range" min="0" max="7" step="1" value="3" class="quality-slider" />
|
||||||
<div class="quality-ticks">
|
<div class="quality-ticks">
|
||||||
|
<span>64k</span>
|
||||||
|
<span>48k</span>
|
||||||
|
<span>32k</span>
|
||||||
<span>Auto</span>
|
<span>Auto</span>
|
||||||
<span>Opus 24k</span>
|
<span>24k</span>
|
||||||
<span>Opus 6k</span>
|
<span>6k</span>
|
||||||
<span>C2 3.2k</span>
|
<span>C2</span>
|
||||||
<span>C2 1.2k</span>
|
<span>1.2k</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<label class="checkbox">
|
<label class="checkbox">
|
||||||
|
|||||||
@@ -28,6 +28,9 @@ fn resolve_quality(quality: &str) -> Option<QualityProfile> {
|
|||||||
frame_duration_ms: 20,
|
frame_duration_ms: 20,
|
||||||
frames_per_block: 5,
|
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
|
_ => None, // "auto" or unknown
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -279,6 +282,9 @@ impl CallEngine {
|
|||||||
let new_profile = match pkt.header.codec_id {
|
let new_profile = match pkt.header.codec_id {
|
||||||
CodecId::Opus24k => QualityProfile::GOOD,
|
CodecId::Opus24k => QualityProfile::GOOD,
|
||||||
CodecId::Opus6k => QualityProfile::DEGRADED,
|
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_1200 => QualityProfile::CATASTROPHIC,
|
||||||
CodecId::Codec2_3200 => QualityProfile {
|
CodecId::Codec2_3200 => QualityProfile {
|
||||||
codec: CodecId::Codec2_3200,
|
codec: CodecId::Codec2_3200,
|
||||||
|
|||||||
@@ -51,22 +51,20 @@ const sAgc = document.getElementById("s-agc") as HTMLInputElement;
|
|||||||
const sQuality = document.getElementById("s-quality") as HTMLInputElement;
|
const sQuality = document.getElementById("s-quality") as HTMLInputElement;
|
||||||
const sQualityLabel = document.getElementById("s-quality-label")!;
|
const sQualityLabel = document.getElementById("s-quality-label")!;
|
||||||
|
|
||||||
// Quality slider config
|
// Quality slider config — best (left/green) to worst (right/red)
|
||||||
const QUALITY_STEPS = ["auto", "good", "degraded", "codec2-3200", "catastrophic"];
|
const QUALITY_STEPS = ["studio-64k", "studio-48k", "studio-32k", "auto", "good", "degraded", "codec2-3200", "catastrophic"];
|
||||||
const QUALITY_LABELS = ["Auto", "Opus 24k", "Opus 6k", "Codec2 3.2k", "Codec2 1.2k"];
|
const QUALITY_LABELS = ["Studio 64k", "Studio 48k", "Studio 32k", "Auto", "Opus 24k", "Opus 6k", "Codec2 3.2k", "Codec2 1.2k"];
|
||||||
const QUALITY_COLORS = ["#4ade80", "#4ade80", "#facc15", "#e97320", "#991b1b"];
|
const QUALITY_COLORS = ["#22c55e", "#4ade80", "#86efac", "#a3e635", "#facc15", "#f59e0b", "#e97320", "#991b1b"];
|
||||||
|
|
||||||
function qualityToIndex(q: string): number {
|
function qualityToIndex(q: string): number {
|
||||||
const idx = QUALITY_STEPS.indexOf(q);
|
const idx = QUALITY_STEPS.indexOf(q);
|
||||||
return idx >= 0 ? idx : 0;
|
return idx >= 0 ? idx : 3; // default to "auto" (index 3)
|
||||||
}
|
}
|
||||||
|
|
||||||
function updateQualityUI(index: number) {
|
function updateQualityUI(index: number) {
|
||||||
sQualityLabel.textContent = QUALITY_LABELS[index];
|
sQualityLabel.textContent = QUALITY_LABELS[index];
|
||||||
sQualityLabel.style.color = QUALITY_COLORS[index];
|
sQualityLabel.style.color = QUALITY_COLORS[index];
|
||||||
const pct = index / (QUALITY_STEPS.length - 1);
|
sQuality.style.background = `linear-gradient(90deg, #22c55e 0%, #86efac 25%, #facc15 50%, #e97320 75%, #991b1b 100%)`;
|
||||||
// Gradient: green at left → yellow middle → dark red at right
|
|
||||||
sQuality.style.background = `linear-gradient(90deg, #4ade80 0%, #facc15 40%, #e97320 70%, #991b1b 100%)`;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
sQuality.addEventListener("input", () => {
|
sQuality.addEventListener("input", () => {
|
||||||
|
|||||||
Reference in New Issue
Block a user