fix(core): the cheapest lossless rung was the one format nobody could ask for

Three follow-ups from the lossless plane.

The hi-res capability bit was derived from "the caller asked for something other than
48 kHz/16-bit". That rule is wrong at exactly one point, and it is a point that
matters: 48/16 is both the default AND the cheapest lossless rung (1.5 Mbps against
Opus's 256 kbps), so it was the one format on the ladder no caller could request. The
bit now means "the caller SPECIFIED a format", with 0 as the unspecified value. No wire
byte and no ABI change — the wire already encodes an explicit 48000/16 identically to
absent, and the capability bit is what carries the intent.

That inverts a default, so every legacy entry point had to move with it: `connect` and
the four pre-v24 `punktfunk_connect_ex*` variants passed an explicit 48000/16 and would
otherwise have started advertising hi-res on behalf of embedders that never asked. They
now pass 0/0, and the comment at each site says why the distinction is load-bearing.

`JitterPolicy` had two decisions denominated in FRAMES rather than milliseconds — the
floor under the effective target, and the smooth shed's one-frame drop — both written
when 5 ms was the only frame this protocol had. The lossless plane negotiates 4 ms at
48/24 and 2 ms at 96/24, where the old constants shed 2.5 frames at a time and faded
across an entire frame. `set_frame_us` fixes both and caps the seam crossfade at half a
frame, because a fade as long as the material it is fading is not a crossfade. It is a
setter rather than a constructor argument for two reasons: the default keeps every Opus
session and all seventeen policy tests bit-identical, and `audio_frame_us` is resolved
by the host and only known after the Welcome — later than the ring is built. Frame
length is computed in microseconds so 2 500 µs does not truncate through integer
milliseconds.

`audio_kbps()` reports what the plane costs, for the stats line §4.6 asks for. It
answers only for PCM, and the answer is exact rather than measured: PCM is
constant-bitrate by construction, so a byte counter would add sampling noise to a
number already known precisely. Opus is VBR with a host-side ladder position, so the
client has no honest figure and says None instead of inventing one.
This commit is contained in:
2026-08-16 09:18:08 +02:00
parent cef4b849e7
commit d09b4668c6
3 changed files with 176 additions and 31 deletions
+28 -16
View File
@@ -2064,10 +2064,13 @@ pub unsafe extern "C" fn punktfunk_connect_ex7(
client_cert_pem,
client_key_pem,
std::ptr::null(), // pre-v21 variant: no device name, so the OS default stands
// pre-v24 variant: the legacy audio request (Opus, 48 kHz, 16-bit), which is also
// what makes the Hello byte-identical to what every earlier variant sent.
crate::audio::SAMPLE_RATE_HZ,
crate::audio::pcm::BITS_16,
// pre-v24 variant: the audio format is UNSPECIFIED (0/0), not "48 kHz/16-bit". The
// distinction is load-bearing — `advertised_client_caps` reads any non-zero value as
// "the caller asked for the lossless plane", so passing an explicit 48 000/16 here
// would make every legacy C embedder start requesting it. 0/0 is what keeps this
// variant's Hello byte-identical to what it has always sent.
0,
0,
timeout_ms,
std::ptr::null_mut(),
)
@@ -2131,10 +2134,13 @@ pub unsafe extern "C" fn punktfunk_connect_ex8(
client_cert_pem,
client_key_pem,
std::ptr::null(), // pre-v21 variant: no device name, so the OS default stands
// pre-v24 variant: the legacy audio request (Opus, 48 kHz, 16-bit), which is also
// what makes the Hello byte-identical to what every earlier variant sent.
crate::audio::SAMPLE_RATE_HZ,
crate::audio::pcm::BITS_16,
// pre-v24 variant: the audio format is UNSPECIFIED (0/0), not "48 kHz/16-bit". The
// distinction is load-bearing — `advertised_client_caps` reads any non-zero value as
// "the caller asked for the lossless plane", so passing an explicit 48 000/16 here
// would make every legacy C embedder start requesting it. 0/0 is what keeps this
// variant's Hello byte-identical to what it has always sent.
0,
0,
timeout_ms,
status_out,
)
@@ -2198,10 +2204,13 @@ pub unsafe extern "C" fn punktfunk_connect_ex9(
client_cert_pem,
client_key_pem,
std::ptr::null(), // pre-v21 variant: no device name, so the OS default stands
// pre-v24 variant: the legacy audio request (Opus, 48 kHz, 16-bit), which is also
// what makes the Hello byte-identical to what every earlier variant sent.
crate::audio::SAMPLE_RATE_HZ,
crate::audio::pcm::BITS_16,
// pre-v24 variant: the audio format is UNSPECIFIED (0/0), not "48 kHz/16-bit". The
// distinction is load-bearing — `advertised_client_caps` reads any non-zero value as
// "the caller asked for the lossless plane", so passing an explicit 48 000/16 here
// would make every legacy C embedder start requesting it. 0/0 is what keeps this
// variant's Hello byte-identical to what it has always sent.
0,
0,
timeout_ms,
status_out,
)
@@ -2274,10 +2283,13 @@ pub unsafe extern "C" fn punktfunk_connect_ex10(
client_cert_pem,
client_key_pem,
device_name,
// pre-v24 variant: the legacy audio request (Opus, 48 kHz, 16-bit), which is also
// what makes the Hello byte-identical to what every earlier variant sent.
crate::audio::SAMPLE_RATE_HZ,
crate::audio::pcm::BITS_16,
// pre-v24 variant: the audio format is UNSPECIFIED (0/0), not "48 kHz/16-bit". The
// distinction is load-bearing — `advertised_client_caps` reads any non-zero value as
// "the caller asked for the lossless plane", so passing an explicit 48 000/16 here
// would make every legacy C embedder start requesting it. 0/0 is what keeps this
// variant's Hello byte-identical to what it has always sent.
0,
0,
timeout_ms,
status_out,
)
+91 -4
View File
@@ -603,6 +603,11 @@ const EWMA_TAU_MS: u32 = 1_000;
/// thing here a listener could ever notice, so it must never fire on a transient.
const SHED_SUSTAIN_MS: u32 = 2_000;
/// Linear crossfade applied across a drift shed's seam.
///
/// Sized against the protocol's 5 ms Opus frame, where 2 ms is a comfortable fraction of what a
/// shed drops. The lossless plane negotiates SHORTER frames — 2 ms at 96 kHz/24-bit under the
/// default MTU — where a flat 2 ms would be the entire dropped frame. See
/// [`JitterPolicy::set_frame_us`], which caps the fade at half a frame for exactly that reason.
const SHED_CROSSFADE_MS: u32 = 2;
/// Underruns inside [`GROW_WINDOW_MS`] before the live target grows.
const GROW_UNDERRUNS: u32 = 3;
@@ -672,6 +677,10 @@ pub struct JitterPolicy {
/// Interleaved samples per millisecond at the negotiated layout (`rate_hz / 1000 × channels`
/// — 48 × channels at the default rate, 96 × channels for a 96 kHz hi-res session).
per_ms: usize,
/// One protocol audio frame, in microseconds. [`FRAME_MS`] for the Opus plane; the lossless
/// plane negotiates shorter frames ([`pcm::frame_us_for`]). Set via
/// [`set_frame_us`](Self::set_frame_us); the default keeps every Opus session bit-identical.
frame_us: u32,
/// The live target, in interleaved samples — `base_target_ms` grown by underrun pressure.
target: usize,
primed: bool,
@@ -750,6 +759,7 @@ impl JitterPolicy {
JitterPolicy {
tuning,
per_ms,
frame_us: FRAME_MS * 1000,
target: tuning.base_target_ms as usize * per_ms,
primed: false,
empties: 0,
@@ -771,6 +781,36 @@ impl JitterPolicy {
}
}
/// Tell the policy how long one audio frame actually is, in microseconds.
///
/// Two of its decisions are denominated in *frames* rather than milliseconds — the floor under
/// the effective target (a device quantum plus one frame) and the smooth shed (drop exactly one
/// frame) — and both were written when [`FRAME_MS`] was the only frame this protocol had. The
/// lossless plane negotiates shorter ones: 4 ms at 48 kHz/24-bit, 2 ms at 96 kHz/24-bit under
/// the default MTU. Left unset, a 96 kHz session would shed 2.5 frames at a time and fade
/// across an entire frame.
///
/// Defaulting to [`FRAME_MS`] rather than taking this in the constructor is deliberate: every
/// Opus session, and all seventeen policy tests, stay bit-identical, and the value that matters
/// here (`audio_frame_us`) is resolved by the host and only known to the client after the
/// `Welcome` — later than the ring is built.
pub fn set_frame_us(&mut self, frame_us: u32) {
self.frame_us = frame_us.max(1);
}
/// One frame in interleaved samples. Computed in µs so a sub-millisecond frame does not
/// truncate: 2 500 µs at 48 kHz stereo is 240 samples, not 192.
fn frame_samples(&self) -> usize {
(self.per_ms * self.frame_us as usize / 1000).max(1)
}
/// The seam crossfade, capped at half a frame. [`SHED_CROSSFADE_MS`]'s flat 2 ms is a
/// comfortable slice of a 5 ms Opus frame and the whole of a 2 ms lossless one, and a fade
/// as long as the material it is fading is not a crossfade.
fn crossfade_samples(&self) -> usize {
(SHED_CROSSFADE_MS as usize * self.per_ms).min(self.frame_samples() / 2)
}
/// Hand the ring the depth the A/V sync loop wants ([`AvSync::desired_depth`]), or `None` to
/// run unsynchronised.
///
@@ -813,7 +853,7 @@ impl JitterPolicy {
/// quantum, a legacy AAudio path) lifts it to `want` plus one protocol frame rather than
/// oscillating prime → dropout → re-prime forever.
fn effective_target(&self, want: usize) -> usize {
let floor = self.target.max(want + FRAME_MS as usize * self.per_ms);
let floor = self.target.max(want + self.frame_samples());
match self.sync_target {
// Continuity outranks sync — see `set_sync_target`. The loop may pull the ring
// shallower to catch the picture up, or push it deeper when audio runs early, but
@@ -859,7 +899,8 @@ impl JitterPolicy {
// that was backwards.
out.drop_front = depth - cap;
out.hard_trim = true;
out.crossfade = (SHED_CROSSFADE_MS as usize * self.per_ms)
out.crossfade = self
.crossfade_samples()
.min(depth.saturating_sub(out.drop_front));
self.over_run = 0;
} else if self.depth_avg
@@ -867,8 +908,9 @@ impl JitterPolicy {
{
self.over_run += want;
if self.over_run >= SHED_SUSTAIN_MS as usize * self.per_ms {
out.drop_front = (FRAME_MS as usize * self.per_ms).min(depth);
out.crossfade = (SHED_CROSSFADE_MS as usize * self.per_ms)
out.drop_front = self.frame_samples().min(depth);
out.crossfade = self
.crossfade_samples()
.min(depth.saturating_sub(out.drop_front));
self.over_run = 0;
}
@@ -2112,6 +2154,51 @@ mod tests {
}
}
/// The shed drops exactly ONE frame and fades across part of it. Both were written when 5 ms
/// was the only frame this protocol had; the lossless plane negotiates shorter ones. The
/// default must stay bit-identical (every Opus session depends on it), and a short frame must
/// shed a short frame rather than 2.5 of them.
#[test]
fn the_shed_follows_the_negotiated_frame_length() {
let pm = per_ms(2);
// Default: one 5 ms frame dropped, a 2 ms fade — exactly the pre-hi-res numbers.
let p = JitterPolicy::new(JitterTuning::PIPEWIRE, 2);
assert_eq!(p.frame_samples(), FRAME_MS as usize * pm);
assert_eq!(p.crossfade_samples(), SHED_CROSSFADE_MS as usize * pm);
// A 2 ms lossless frame sheds 2 ms, and the fade is capped at half of it rather than
// consuming the whole dropped frame.
let mut q = JitterPolicy::new(JitterTuning::PIPEWIRE, 2);
q.set_frame_us(2_000);
assert_eq!(q.frame_samples(), 2 * pm);
assert_eq!(q.crossfade_samples(), pm, "fade must be half a 2 ms frame");
assert!(
q.crossfade_samples() < q.frame_samples(),
"a fade as long as the frame is not a crossfade"
);
// Sub-millisecond precision: 2 500 µs at 48 kHz stereo is 240 interleaved samples, and
// must not truncate to 192 by going through integer milliseconds first.
let mut r = JitterPolicy::new(JitterTuning::PIPEWIRE, 2);
r.set_frame_us(2_500);
assert_eq!(r.frame_samples(), 240);
// At 96 kHz the same 2 ms frame is twice the samples for the same duration.
let mut h = JitterPolicy::new_at_rate(JitterTuning::PIPEWIRE, 2, 96_000);
h.set_frame_us(2_000);
assert_eq!(h.frame_samples(), 2 * per_ms_at(96_000, 2));
// A degenerate value must not panic or produce a zero-length frame.
let mut z = JitterPolicy::new(JitterTuning::PIPEWIRE, 2);
z.set_frame_us(0);
assert!(z.frame_samples() >= 1);
}
fn per_ms_at(rate: u32, channels: u8) -> usize {
(rate / 1000) as usize * channels as usize
}
/// `new` is exactly `new_at_rate` at the protocol default — the property that let every
/// existing caller and all 17 policy tests keep their behaviour when the rate became a
/// parameter.
+57 -11
View File
@@ -463,6 +463,30 @@ pub struct NativeClient {
pub codec: u8,
}
impl NativeClient {
/// What the audio plane costs, in kbps — the figure a stats line or HUD should show so a user
/// who turned the lossless plane on can see what it took (`design/hi-res-audio.md` §4.6).
///
/// `Some` only for the lossless plane, where the answer is **exact rather than measured**:
/// PCM is constant-bitrate by construction, so `rate × depth × channels` IS the wire rate and
/// a byte counter would only add sampling noise to a number already known precisely. `None`
/// for Opus, which is VBR and whose ladder position is chosen host-side by
/// [`crate::audio::plan_audio_budget`] — the client has no honest figure to report, and
/// inventing one from a short window would read as jitter.
///
/// Payload only: the 13-byte per-datagram header and QUIC's own framing are not counted, on
/// the grounds that the same is true of every other bitrate this project quotes.
pub fn audio_kbps(&self) -> Option<u32> {
(self.audio_codec == crate::quic::AUDIO_CODEC_PCM).then(|| {
crate::audio::pcm::bitrate_kbps(
self.audio_sample_rate_hz,
self.audio_bits,
self.audio_channels,
)
})
}
}
/// Pin the calling thread to the user-interactive QoS class on Apple targets.
///
/// The Apple client drains every plane on `.userInteractive` Thread s (video pump, audio,
@@ -616,8 +640,15 @@ fn os_hostname() -> Option<String> {
/// the legacy ones, so an embedder that genuinely wants that (rare — 24-bit is where the plane
/// earns its bandwidth) sets the bit itself and is not overridden.
fn advertised_client_caps(client_caps: u8, audio_rate_hz: u32, audio_bits: u8) -> u8 {
let hires =
audio_rate_hz != crate::audio::SAMPLE_RATE_HZ || audio_bits != crate::audio::pcm::BITS_16;
// The bit means "the caller SPECIFIED a format", not "the format differs from the default".
//
// Those two rules agree everywhere except one place, and that place matters: 48 kHz/16-bit is
// the cheapest lossless rung (1.5 Mbps against Opus's 256 kbps) and is also the default, so a
// "differs from the default" rule makes it the one format on the ladder that cannot be asked
// for. `0` is the unspecified value — [`NativeClient::connect`] passes it, and the wire encodes
// an explicit 48 000/16 identically to absent — so keying on "non-zero" separates *asking for
// 48/16 lossless* from *not asking at all* without costing a wire byte.
let hires = audio_rate_hz != 0 || audio_bits != 0;
client_caps
| crate::quic::CLIENT_CAP_AUDIO_RED
| if hires {
@@ -700,8 +731,11 @@ impl NativeClient {
bitrate_kbps,
video_caps,
audio_channels,
crate::audio::SAMPLE_RATE_HZ,
crate::audio::pcm::BITS_16,
// 0/0 = UNSPECIFIED, which is what keeps this path's `Hello` byte-identical to the
// pre-hi-res one. Passing an explicit 48 000/16 here would read as "asked for the
// cheapest lossless rung" under the rule in `advertised_client_caps`.
0,
0,
video_codecs,
preferred_codec,
display_hdr,
@@ -721,7 +755,10 @@ impl NativeClient {
/// Everything else is identical. What the pair actually does is decide whether the `Hello`
/// carries [`quic::CLIENT_CAP_AUDIO_HIRES`], and the rule is deliberately narrow:
///
/// **The bit is set exactly when the caller asks for something other than 48 kHz / 16-bit.**
/// **The bit is set exactly when the caller SPECIFIES a format at all** (either argument
/// non-zero; `0` means unspecified, which is what [`connect`](Self::connect) passes).
/// Deliberately not "differs from 48 kHz/16-bit": that rule would make the cheapest lossless
/// rung — 48 kHz/16-bit, 1.5 Mbps — the one format on the ladder nobody could request.
/// It is NOT set unconditionally, and that is the whole difference between it and
/// [`quic::CLIENT_CAP_AUDIO_RED`] — which core ORs in for every session below, because
/// redundancy is a pure "I can decode it" that costs ~1 % and is recovered inside core where
@@ -1893,23 +1930,32 @@ mod client_caps_tests {
/// that. A regression here is silent in every test that does not look for it: the session
/// still works, it just costs several megabits nobody asked for.
#[test]
fn hires_is_advertised_only_when_the_caller_asked_for_a_non_default_format() {
// The legacy request: redundancy on, hi-res off, the embedder's own bits untouched.
let legacy = advertised_client_caps(CLIENT_CAP_CURSOR, SAMPLE_RATE_HZ, BITS_16);
fn hires_is_advertised_only_when_the_caller_specified_a_format() {
// The legacy request is UNSPECIFIED (0/0): redundancy on, hi-res off, the embedder's own
// bits untouched. This is what `connect` and every pre-v24 C entry point pass.
let legacy = advertised_client_caps(CLIENT_CAP_CURSOR, 0, 0);
assert_eq!(legacy & CLIENT_CAP_AUDIO_RED, CLIENT_CAP_AUDIO_RED);
assert_eq!(legacy & CLIENT_CAP_AUDIO_HIRES, 0);
assert_eq!(legacy & CLIENT_CAP_CURSOR, CLIENT_CAP_CURSOR);
// …and with no embedder bits at all, which is what every `connect` caller produces.
assert_eq!(advertised_client_caps(0, 0, 0), CLIENT_CAP_AUDIO_RED);
// The rung this rule exists for: 48 kHz/16-bit is the DEFAULT and also the cheapest
// lossless format. Asking for it explicitly must be a request, or it is the one point on
// the ladder no caller can reach.
assert_eq!(
advertised_client_caps(0, SAMPLE_RATE_HZ, BITS_16),
CLIENT_CAP_AUDIO_RED
advertised_client_caps(0, SAMPLE_RATE_HZ, BITS_16) & CLIENT_CAP_AUDIO_HIRES,
CLIENT_CAP_AUDIO_HIRES,
"explicit 48 kHz/16-bit is a lossless request, not a legacy one"
);
// Either half of the format being non-default is a request.
// Specifying either half alone is still a request.
for (rate, bits) in [
(SAMPLE_RATE_HZ, BITS_24),
(96_000, BITS_16),
(96_000, BITS_24),
(0, BITS_24),
(96_000, 0),
] {
let caps = advertised_client_caps(0, rate, bits);
assert_eq!(