feat(core,host): adaptive bitrate — mid-stream encoder re-targeting when set to Automatic

New SetBitrate (0x05) / BitrateChanged (0x06) control messages: the host clamps like the
Hello request, acks the resolved rate, and rebuilds the ENCODER ONLY in place (same mode,
first new-rate frame is an IDR — the proven mode-switch resync, minus the pipeline churn).
The client side is an AIMD controller (core abr.rs) in the data-plane pump, armed only when
the user's bitrate is Automatic (Hello bitrate_kbps == 0): ×0.7 after two bad 750 ms windows
(FEC-unrecoverable frames, ≥2% loss, one-way-delay rise above its rolling baseline — the
pre-loss bufferbloat signal off the clock-skew handshake — or a jump-to-live flush), ~+6%
after ~10 s clean, ceiling = the session's starting rate, 3 s cooldown, self-disables against
a host that never acks (older build). Division of labour: adaptive FEC keeps answering fast
random loss; bitrate now answers persistent congestion, closing the FEC death-spiral gap.
The web-console sample reports the live rate. Also: join_host_port() brackets bare IPv6
literals before SocketAddr parsing (parse-side IPv6 groundwork, pairs with the next commit).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-09 10:56:37 +02:00
parent 180ac3aa61
commit 8e6e8bb25c
6 changed files with 580 additions and 13 deletions
+87
View File
@@ -369,6 +369,32 @@ pub struct LossReport {
pub loss_ppm: u32,
}
/// `client → host`, any time after [`Start`]: reconfigure the encoder to a new target bitrate
/// without reconnecting — the mid-stream lever of adaptive bitrate. The host clamps the request
/// exactly like [`Hello::bitrate_kbps`] (its `[MIN, MAX]` band; `0` → host default), answers with
/// [`BitrateChanged`] carrying the value it actually configured, and rebuilds the encoder in
/// place at the same mode — the first new-rate frame is an IDR with in-band parameter sets, which
/// every client decoder already follows (same discipline as a [`Reconfigure`] mode switch).
///
/// Sent by the client's automatic-bitrate controller (active when the user's bitrate setting is
/// "Automatic", i.e. `Hello::bitrate_kbps == 0`) when the link can't sustain the current rate —
/// or can sustain more again. A host that predates this ignores it (unknown control message) and
/// never answers; the client's controller detects the silence and disables itself.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct SetBitrate {
/// Requested encoder bitrate in kilobits per second (`0` = host default, like Hello's field).
pub bitrate_kbps: u32,
}
/// `host → client`: answer to [`SetBitrate`] — the bitrate the host actually configured (the
/// request clamped to its supported band). The encoder switches on the next frame (an IDR); the
/// stream never pauses. Also the controller's liveness signal: no answer ⇒ an old host that
/// doesn't renegotiate bitrate.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct BitrateChanged {
pub bitrate_kbps: u32,
}
/// `client → host`, any time after [`Start`]: run a bandwidth speed test. The host bursts
/// filler access units (flagged [`crate::packet::FLAG_PROBE`]) over the data plane at
/// `target_kbps` of application goodput for `duration_ms`, *pausing video for the duration*, then
@@ -455,6 +481,10 @@ pub const MSG_RECONFIGURED: u8 = 0x02;
pub const MSG_REQUEST_KEYFRAME: u8 = 0x03;
/// Type byte of [`LossReport`].
pub const MSG_LOSS_REPORT: u8 = 0x04;
/// Type byte of [`SetBitrate`].
pub const MSG_SET_BITRATE: u8 = 0x05;
/// Type byte of [`BitrateChanged`].
pub const MSG_BITRATE_CHANGED: u8 = 0x06;
/// Type byte of [`ProbeRequest`].
pub const MSG_PROBE_REQUEST: u8 = 0x20;
/// Type byte of [`ProbeResult`].
@@ -1128,6 +1158,46 @@ impl LossReport {
}
}
impl SetBitrate {
pub fn encode(&self) -> Vec<u8> {
// magic[0..4] type[4] bitrate_kbps[5..9]
let mut b = Vec::with_capacity(9);
b.extend_from_slice(CTL_MAGIC);
b.push(MSG_SET_BITRATE);
b.extend_from_slice(&self.bitrate_kbps.to_le_bytes());
b
}
pub fn decode(b: &[u8]) -> Result<SetBitrate> {
if b.len() != 9 || &b[0..4] != CTL_MAGIC || b[4] != MSG_SET_BITRATE {
return Err(PunktfunkError::InvalidArg("bad SetBitrate"));
}
Ok(SetBitrate {
bitrate_kbps: u32::from_le_bytes(b[5..9].try_into().unwrap()),
})
}
}
impl BitrateChanged {
pub fn encode(&self) -> Vec<u8> {
// magic[0..4] type[4] bitrate_kbps[5..9]
let mut b = Vec::with_capacity(9);
b.extend_from_slice(CTL_MAGIC);
b.push(MSG_BITRATE_CHANGED);
b.extend_from_slice(&self.bitrate_kbps.to_le_bytes());
b
}
pub fn decode(b: &[u8]) -> Result<BitrateChanged> {
if b.len() != 9 || &b[0..4] != CTL_MAGIC || b[4] != MSG_BITRATE_CHANGED {
return Err(PunktfunkError::InvalidArg("bad BitrateChanged"));
}
Ok(BitrateChanged {
bitrate_kbps: u32::from_le_bytes(b[5..9].try_into().unwrap()),
})
}
}
/// Compute a [`LossReport`] `loss_ppm` from one window's session-stat deltas: shards FEC recovered
/// (the loss it absorbed), shards received, and frames that went unrecoverable. Loss ≈ recovered /
/// (received + recovered) — the fraction of shards that arrived missing. A frame drop means loss
@@ -2684,6 +2754,23 @@ mod tests {
assert!(window_loss_ppm(u64::MAX, 1, 9) <= 1_000_000);
}
#[test]
fn bitrate_messages_roundtrip() {
let req = SetBitrate {
bitrate_kbps: 14_000,
};
assert_eq!(SetBitrate::decode(&req.encode()).unwrap(), req);
let ack = BitrateChanged {
bitrate_kbps: 14_000,
};
assert_eq!(BitrateChanged::decode(&ack.encode()).unwrap(), ack);
// Same payload shape as LossReport — the type byte alone must keep them disjoint.
assert!(LossReport::decode(&req.encode()).is_err());
assert!(SetBitrate::decode(&ack.encode()).is_err());
assert!(BitrateChanged::decode(&req.encode()).is_err());
assert!(SetBitrate::decode(&LossReport { loss_ppm: 7 }.encode()).is_err());
}
#[test]
fn probe_messages_roundtrip() {
let req = ProbeRequest {