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
+85 -8
View File
@@ -32,14 +32,14 @@ use punktfunk_core::config::{
use punktfunk_core::input::{InputEvent, InputKind};
use punktfunk_core::packet::{FLAG_PIC, FLAG_PROBE, FLAG_SOF};
use punktfunk_core::quic::{
endpoint, io, ClockEcho, ClockProbe, ColorInfo, Hello, LossReport, PairChallenge, PairProof,
PairRequest, PairResult, ProbeRequest, ProbeResult, Reconfigure, Reconfigured, RequestKeyframe,
Start, Welcome,
endpoint, io, BitrateChanged, ClockEcho, ClockProbe, ColorInfo, Hello, LossReport,
PairChallenge, PairProof, PairRequest, PairResult, ProbeRequest, ProbeResult, Reconfigure,
Reconfigured, RequestKeyframe, SetBitrate, Start, Welcome,
};
use punktfunk_core::transport::UdpTransport;
use punktfunk_core::Session;
use rand::RngCore;
use std::sync::atomic::{AtomicBool, AtomicU8, Ordering};
use std::sync::atomic::{AtomicBool, AtomicU32, AtomicU8, Ordering};
use std::sync::Arc;
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
@@ -1054,6 +1054,7 @@ async fn serve_session(
// (inbound requests, outbound probe results) are multiplexed with `select!`.
let (reconfig_tx, reconfig_rx) = std::sync::mpsc::channel::<punktfunk_core::Mode>();
let (keyframe_tx, keyframe_rx) = std::sync::mpsc::channel::<()>();
let (bitrate_tx, bitrate_rx) = std::sync::mpsc::channel::<u32>();
let (probe_tx, probe_rx) = std::sync::mpsc::channel::<ProbeRequest>();
let (probe_result_tx, mut probe_result_rx) =
tokio::sync::mpsc::unbounded_channel::<ProbeResult>();
@@ -1124,6 +1125,27 @@ async fn serve_session(
);
}
}
} else if let Ok(req) = SetBitrate::decode(&msg) {
// Mid-stream bitrate renegotiation (adaptive bitrate): clamp exactly like
// the Hello request, ack the resolved value, then hand it to the data-plane
// thread, which rebuilds the encoder in place at the same mode — the fresh
// encoder's first frame is an IDR with in-band parameter sets, so the
// client's decoder follows without a reconnect.
let resolved = resolve_bitrate_kbps(req.bitrate_kbps);
tracing::info!(
requested_kbps = req.bitrate_kbps,
resolved_kbps = resolved,
"mid-stream bitrate change requested"
);
let ack = BitrateChanged {
bitrate_kbps: resolved,
};
if io::write_msg(&mut ctrl_send, &ack.encode()).await.is_err() {
break;
}
if bitrate_tx.send(resolved).is_err() {
break; // data plane gone
}
} else if let Ok(req) = ProbeRequest::decode(&msg) {
tracing::info!(
target_kbps = req.target_kbps,
@@ -1445,6 +1467,7 @@ async fn serve_session(
quit: quit_stream,
reconfig: reconfig_rx,
keyframe: keyframe_rx,
bitrate_rx,
compositor,
bitrate_kbps,
bit_depth,
@@ -2738,7 +2761,9 @@ struct SendStats {
fps: u32,
codec: &'static str,
client: String,
bitrate_kbps: u32,
/// Live encoder bitrate (kbps) — the capture thread updates it on a mid-stream adaptive
/// bitrate change, so the web-console sample reports what the encoder is ACTUALLY targeting.
bitrate_kbps: Arc<AtomicU32>,
}
#[allow(clippy::too_many_arguments)]
@@ -2921,7 +2946,7 @@ fn send_loop(
fps: (new_frames as f64 / secs) as f32,
repeat_fps: (repeat_frames as f64 / secs) as f32,
mbps: tx_mbps as f32,
bitrate_kbps: stats.bitrate_kbps,
bitrate_kbps: stats.bitrate_kbps.load(Ordering::Relaxed),
frames_dropped: s.frames_dropped.saturating_sub(last_frames_dropped) as u32,
packets_dropped: s.packets_dropped.saturating_sub(last_packets_dropped) as u32,
send_dropped: s.packets_send_dropped.saturating_sub(last_send_dropped) as u32,
@@ -3078,6 +3103,9 @@ struct SessionContext {
reconfig: std::sync::mpsc::Receiver<punktfunk_core::Mode>,
/// Client decode-recovery keyframe requests.
keyframe: std::sync::mpsc::Receiver<()>,
/// Accepted mid-stream bitrate changes (adaptive bitrate, already clamped) — the encoder
/// alone is rebuilt in place at the new rate; capture + virtual output are untouched.
bitrate_rx: std::sync::mpsc::Receiver<u32>,
/// The resolved compositor backend (moot on Windows — `vdisplay::open` ignores it there).
compositor: crate::vdisplay::Compositor,
/// Negotiated encoder bitrate (kbps).
@@ -3137,8 +3165,9 @@ fn virtual_stream(ctx: SessionContext) -> Result<()> {
quit,
reconfig,
keyframe,
bitrate_rx,
compositor,
bitrate_kbps,
mut bitrate_kbps,
bit_depth,
// The resolved chroma is already captured in `plan` (above); ignore the duplicate here.
chroma: _,
@@ -3236,6 +3265,9 @@ fn virtual_stream(ctx: SessionContext) -> Result<()> {
// The bounded channel applies backpressure (the encode thread blocks if the send falls behind,
// so frames slow down rather than a dropped frame freezing the infinite-GOP stream).
let (frame_tx, frame_rx) = std::sync::mpsc::sync_channel::<FrameMsg>(3);
// Live encoder bitrate, shared with the send thread's stats sample: a mid-stream adaptive
// bitrate change (bitrate_rx below) updates it so the console shows the actual target.
let live_bitrate = Arc::new(AtomicU32::new(bitrate_kbps));
// The send thread emits the web-console stats sample (it owns `session.stats()`); clone the
// recorder so the capture loop keeps its own handle for the per-frame `is_armed()` gate.
let send_stats = SendStats {
@@ -3245,7 +3277,7 @@ fn virtual_stream(ctx: SessionContext) -> Result<()> {
fps: mode.refresh_hz,
codec: plan.codec.label(),
client: client_label,
bitrate_kbps,
bitrate_kbps: live_bitrate.clone(),
};
let send_thread = std::thread::Builder::new()
.name("punktfunk-send".into())
@@ -3447,6 +3479,51 @@ fn virtual_stream(ctx: SessionContext) -> Result<()> {
}
}
}
// Adaptive bitrate: drain to the NEWEST requested rate (the client's controller may step
// several times while we stream) and rebuild the ENCODER ONLY in place — the mode didn't
// change, so capture and the virtual output are untouched and the switch costs exactly the
// IDR the fresh encoder opens with (the same resync discipline as a mode switch, minus the
// pipeline churn). Rates arrive pre-clamped by the control task (`resolve_bitrate_kbps`).
let mut want_kbps = None;
while let Ok(k) = bitrate_rx.try_recv() {
want_kbps = Some(k);
}
if let Some(new_kbps) = want_kbps.filter(|&k| k != bitrate_kbps) {
// `interval` was built as 1/effective_hz, so the round-trip recovers the integer rate.
let hz = (1.0 / interval.as_secs_f64()).round() as u32;
match crate::encode::open_video(
plan.codec,
frame.format,
frame.width,
frame.height,
hz,
new_kbps as u64 * 1000,
frame.is_cuda(),
bit_depth,
plan.chroma,
) {
Ok(new_enc) => {
tracing::info!(
from_kbps = bitrate_kbps,
to_kbps = new_kbps,
"encoder rebuilt at new bitrate (adaptive bitrate)"
);
enc = new_enc;
bitrate_kbps = new_kbps;
live_bitrate.store(new_kbps, Ordering::Relaxed);
// The owed AUs died with the old encoder — same bookkeeping as a mode-switch
// rebuild; the fresh encoder opens on an IDR, so anchor the IDR cooldown too.
inflight.clear();
last_au_at = std::time::Instant::now();
encoder_resets = 0;
last_forced_idr = Some(std::time::Instant::now());
}
Err(e) => {
tracing::error!(error = %format!("{e:#}"), to_kbps = new_kbps,
"bitrate-change encoder rebuild failed — keeping the current rate");
}
}
}
// Client recovery: it asked for a fresh IDR (its decoder wedged on the cold opening
// GOP). Coalesce the backlog — several requests fire before the IDR lands — and force
// the next encoded frame to be a keyframe. (A reconfig rebuild above already opens with