feat: PyroWave Phase 3 — pinned rate, all-intra silencing, opt-in UI, notices, docs
ci / docs-site (push) Successful in 1m0s
ci / web (push) Successful in 2m13s
arch / build-publish (push) Failing after 6m17s
decky / build-publish (push) Successful in 18s
android / android (push) Failing after 6m43s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 9s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 10s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 9s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 8s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 53s
ci / bench (push) Successful in 5m6s
ci / rust (push) Failing after 9m30s
docker / deploy-docs (push) Successful in 27s
flatpak / build-publish (push) Failing after 4m50s
deb / build-publish (push) Failing after 7m17s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Failing after 6m10s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Failing after 6m2s
windows-host / package (push) Failing after 3m4s
apple / swift (push) Failing after 1m25s
windows-msix / package (arm64, C:\Users\Public\ffmpeg-arm64, --no-default-features, aarch64-pc-windows-msvc, C:\t-a64) (push) Failing after 3m1s
release / apple (push) Failing after 1m28s
windows-msix / package (x64, C:\Users\Public\ffmpeg, , x86_64-pc-windows-msvc, C:\t) (push) Failing after 2m15s
windows / build (aarch64-pc-windows-msvc) (push) Failing after 4m36s
windows / build (x86_64-pc-windows-msvc) (push) Failing after 4m43s
apple / screenshots (push) Has been skipped

plan §4.6 + Phase 3 productization:

- Pinned bitrate: an Automatic client (bitrate 0) on a PyroWave session
  resolves to the codec's ~1.6 bpp operating point for the mode (≈200
  Mbps at 1080p60) instead of the 20 Mbps H.26x default; explicit rates
  are honored. Mid-stream SetBitrate retargets are refused with the
  pinned rate acked (guards old/foreign clients), and the client-side
  AIMD controller + startup capacity probe stay off for the codec — no
  rate descent into wavelet mush, no climb probe whose VBV reasoning
  doesn't apply to hard per-frame CBR. Unit-tested.

- All-intra silencing: the data plane drops drained keyframe/RFI
  requests on PyroWave sessions (the next frame IS the recovery), so
  the forced-IDR cooldown, RFI attempt, and storm coalescing never run.

- Opt-in UI: 'PyroWave (wired LAN)' joins the console's Video-codec
  cycler; trust::Settings maps it to CODEC_PYROWAVE. Safe everywhere by
  the negotiation contract — an un-advertised preference falls back
  through the ladder.

- FEC: decision recorded — adaptive FEC (10% start, loss-report driven)
  stays as-is for the MVP opaque-AU mode; the FEC≈0 policy belongs to
  the Phase-4 datagram-aligned mode.

- THIRD-PARTY-NOTICES: the generator now lists third-party trees
  vendored inside first-party crates (pyrowave, Granite subset, volk,
  Vulkan-Headers) with their full license texts; file regenerated.

- docs-site: 'PyroWave (wired-LAN codec)' page — what it is, the
  bandwidth table, how to enable it, current limits.

Validated on .21: 309 host + 148 core + 26 client tests green,
console-ui clean, both feature configs.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-15 09:45:49 +02:00
parent 8dc5d672e2
commit 12148243bd
8 changed files with 1179 additions and 452 deletions
+679 -297
View File
File diff suppressed because it is too large Load Diff
+66
View File
@@ -280,6 +280,65 @@ pub fn pair_with_host(
)
}
/// User-facing sentence for a failed connect / request-access, keyed on the actual cause —
/// shared by every desktop/console surface so "the host declined this device" never renders
/// as "connection timed out". Reason-specific text for a typed host rejection
/// ([`punktfunk_core::reject::RejectReason`]); the caller keeps its own wording for
/// non-rejection errors.
pub fn connect_reject_message(reason: punktfunk_core::reject::RejectReason) -> String {
use punktfunk_core::reject::RejectReason as R;
match reason {
R::Denied => "The host declined this device's request.".into(),
R::ApprovalTimeout => {
"Nobody approved the request on the host in time — approve this device in the \
host's console or web UI, then request access again."
.into()
}
R::Superseded => {
"A newer request from this device replaced this one — approve the latest request \
on the host."
.into()
}
R::IdentityRequired => {
"The host requires pairing — pair this device (PIN or request access) first.".into()
}
R::PairingNotArmed => {
"Pairing isn't armed on the host — arm it on the host's Pairing page, then try \
again."
.into()
}
R::PairingBoundToOtherDevice => {
"The host's pairing window is armed for a different device — arm it for this one."
.into()
}
R::PairingRateLimited => {
"Too many pairing attempts — wait a couple of seconds and try again.".into()
}
R::WireVersionMismatch => {
"Client and host versions don't match — update both to the same release.".into()
}
R::Busy => "The host is busy with another session.".into(),
}
}
/// User-facing sentence for a failed PIN pairing ceremony ([`pair_with_host`]) — distinguishes
/// a wrong PIN (the SPAKE2 proof failed) from an unreachable host and from the host's typed
/// rejections, so a dead network path or a disarmed host is never reported as a bad PIN.
pub fn pair_error_message(err: &punktfunk_core::PunktfunkError) -> String {
use punktfunk_core::PunktfunkError as E;
match err {
E::Crypto => "Wrong PIN — check the PIN on the host's Pairing page and try again.".into(),
E::Rejected(reason) => connect_reject_message(*reason),
E::Timeout => "The host didn't answer. Is it running and reachable?".into(),
E::Io(_) => {
"Couldn't reach the host — check that this device and the host are on the same \
network (no VPN on this device, no guest-Wi-Fi / AP isolation)."
.into()
}
other => format!("Pairing failed: {other:?}"),
}
}
/// Probe several hosts for reachability in parallel — one thread each, so the wall-clock cost is
/// ~one `timeout`, not the sum. Each element of the returned vec corresponds by index to
/// `targets`. Wraps the single-host [`NativeClient::probe`] (a bounded, trust-agnostic,
@@ -515,6 +574,10 @@ impl Settings {
"h264" | "avc" => punktfunk_core::quic::CODEC_H264,
"hevc" | "h265" => punktfunk_core::quic::CODEC_HEVC,
"av1" => punktfunk_core::quic::CODEC_AV1,
// The wired-LAN wavelet codec: preference-only by design (resolve_codec never
// auto-picks it), and harmless on a build/device that doesn't advertise the
// bit — the ladder falls back to HEVC.
"pyrowave" => punktfunk_core::quic::CODEC_PYROWAVE,
_ => 0,
}
}
@@ -631,6 +694,9 @@ mod tests {
assert!(s.mic_enabled);
assert_eq!(s.decoder, "hardware");
assert_eq!(s.preferred_codec(), punktfunk_core::quic::CODEC_AV1);
let mut pw = s.clone();
pw.codec = "pyrowave".into();
assert_eq!(pw.preferred_codec(), punktfunk_core::quic::CODEC_PYROWAVE);
assert_eq!(s.adapter, "NVIDIA GeForce RTX 4080");
assert!(s.hdr_enabled);
// The old shell's `show_hud` lands on `show_stats` (the user's preference survives).
+4 -1
View File
@@ -65,11 +65,14 @@ const COMPOSITORS: [(&str, &str); 5] = [
("mutter", "Mutter"),
("gamescope", "gamescope"),
];
const CODECS: [(&str, &str); 4] = [
const CODECS: [(&str, &str); 5] = [
("auto", "Automatic"),
("hevc", "HEVC"),
("h264", "H.264"),
("av1", "AV1"),
// Opt-in wired-LAN low-latency codec (100-400 Mbps class, 8-bit SDR). Only ever
// selected when the host supports it too; anything else falls back to HEVC.
("pyrowave", "PyroWave (wired LAN)"),
];
const DECODERS: [(&str, &str); 4] = [
("auto", "Automatic"),
+45 -3
View File
@@ -862,7 +862,16 @@ impl NativeClient {
.await
.map_err(|e| PunktfunkError::Io(std::io::Error::other(e.to_string())))?;
let host_fp = observed.lock().unwrap().ok_or(PunktfunkError::Crypto)?;
let outcome = exchange(conn.clone(), host_fp).await;
let outcome = match exchange(conn.clone(), host_fp).await {
// A typed application close from the host (pairing not armed / armed for a
// different device / rate-limited / version mismatch) beats the generic
// transport error the aborted exchange produced — it is the actual answer.
Err(e) => Err(match reject_from_close(&conn) {
Some(r) => PunktfunkError::Rejected(r),
None => e,
}),
ok => ok,
};
// Always tell the host we're done so it never blocks at its read — code 0 on
// success, 1 on a refused/aborted ceremony.
let code: u32 = if outcome.is_ok() { 0 } else { 1 };
@@ -1355,6 +1364,18 @@ struct WorkerArgs {
/// The worker: QUIC handshake, then the input/datagram/control tasks + the blocking
/// data-plane pump.
/// The host's stated rejection, if this connection was closed with a typed application code
/// (see [`crate::reject`]) — `None` for local errors, bare/legacy closes (including our own
/// `LocallyClosed`), and transport failures, which keep their original error.
fn reject_from_close(conn: &quinn::Connection) -> Option<crate::reject::RejectReason> {
match conn.close_reason()? {
quinn::ConnectionError::ApplicationClosed(ac) => u32::try_from(u64::from(ac.error_code))
.ok()
.and_then(crate::reject::RejectReason::from_close_code),
_ => None,
}
}
async fn worker_main(args: WorkerArgs) {
let WorkerArgs {
host,
@@ -1417,6 +1438,12 @@ async fn worker_main(args: WorkerArgs) {
}
})?;
let fingerprint = observed.lock().unwrap().unwrap_or([0u8; 32]);
// The rest of the handshake runs in an inner future so a failure can consult
// `conn.close_reason()`: a host that turned us away with a typed application close
// (pairing not armed / denied / approval timeout / version mismatch / busy) surfaces
// as `PunktfunkError::Rejected` instead of the generic transport error the failed
// read produces — the difference between "not accepted" and the actual cause.
let handshake = async {
let (mut send, mut recv) = conn
.open_bi()
.await
@@ -1515,7 +1542,6 @@ async fn worker_main(args: WorkerArgs) {
}
let session = Session::new(welcome.session_config(Role::Client), Box::new(transport))?;
Ok::<_, PunktfunkError>((
conn,
session,
send,
recv,
@@ -1536,6 +1562,16 @@ async fn worker_main(args: WorkerArgs) {
welcome.host_caps,
))
};
match handshake.await {
Ok((session, send, recv, negotiated, host_caps)) => {
Ok((conn, session, send, recv, negotiated, host_caps))
}
Err(e) => Err(match reject_from_close(&conn) {
Some(r) => PunktfunkError::Rejected(r),
None => e,
}),
}
};
let (conn, mut session, mut ctrl_send, mut ctrl_recv, negotiated, host_caps) = match setup.await
{
@@ -1548,6 +1584,7 @@ async fn worker_main(args: WorkerArgs) {
// Copies the pump needs after `negotiated` is handed over to `connect`.
let clock_rtt_ns = negotiated.clock_rtt_ns;
let resolved_bitrate_kbps = negotiated.bitrate_kbps;
let negotiated_codec = negotiated.codec;
// Seed the live offset with the connect-time estimate BEFORE the embedder can observe the
// client (ready_tx): clock_offset_now_ns() never reads a pre-handshake 0 on a skewed pair.
clock_offset.store(negotiated.clock_offset_ns, Ordering::Relaxed);
@@ -1948,7 +1985,11 @@ async fn worker_main(args: WorkerArgs) {
// echoes 0 → controller stays permanently off). Fed once per report window with the same
// deltas the LossReport uses, plus the window's mean skew-corrected one-way delay and
// whether a jump-to-live flush fired.
let mut abr = BitrateController::new(if bitrate_kbps == 0 {
// PyroWave sessions PIN their rate (§4.6): AIMD descent turns wavelets to mush well
// above its floor, and the climb probe's VBV reasoning doesn't apply to hard
// per-frame CBR — controller and capacity probe stay off (0 = permanently off).
let rate_pinned = negotiated_codec == crate::quic::CODEC_PYROWAVE;
let mut abr = BitrateController::new(if bitrate_kbps == 0 && !rate_pinned {
resolved_bitrate_kbps
} else {
0
@@ -1965,6 +2006,7 @@ async fn worker_main(args: WorkerArgs) {
const CAPACITY_PROBE_DELAY: Duration = Duration::from_secs(2);
const CAPACITY_PROBE_TIMEOUT: Duration = Duration::from_secs(6);
let mut capacity_probe_at: Option<Instant> = (bitrate_kbps == 0
&& !rate_pinned
&& resolved_bitrate_kbps > 0
&& std::env::var("PUNKTFUNK_ABR_PROBE").map_or(true, |v| v != "0"))
.then(|| Instant::now() + CAPACITY_PROBE_DELAY);
+148 -23
View File
@@ -417,8 +417,18 @@ const HANDSHAKE_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(10
/// QUIC application error code the host closes with on a `mode_conflict = reject` admission refusal,
/// carrying the human-readable busy reason (live mode + client label) the client surfaces. A distinct
/// code lets a client tell "host busy" apart from a transport failure.
const REJECT_BUSY_CODE: u32 = 0x42;
/// code lets a client tell "host busy" apart from a transport failure. Shared with the clients via
/// `punktfunk_core::reject` so they can decode it (`RejectReason::Busy`).
const REJECT_BUSY_CODE: u32 = punktfunk_core::reject::REJECT_BUSY_CLOSE_CODE;
/// Make a gate rejection legible to the client BEFORE erroring out of the session task: close
/// with the typed application code (`punktfunk_core::reject`) so the client renders the real
/// reason ("pairing not armed", "denied in the console") — the task's `Err` then only logs.
/// Without this the dropped connection closes with a bare code 0, indistinguishable on the
/// client from transport trouble (the "not accepted" support-thread failure mode).
fn close_rejected(conn: &quinn::Connection, reason: punktfunk_core::reject::RejectReason) {
conn.close(reason.close_code().into(), reason.to_string().as_bytes());
}
/// QUIC application error code a client closes with on a **deliberate quit** (a user "stop", not a
/// network drop). The host reads it off the connection's `ApplicationClosed` reason and tears the
@@ -456,6 +466,27 @@ fn resolve_bitrate_kbps(requested: u32) -> u32 {
}
}
/// [`resolve_bitrate_kbps`] with the codec's floor semantics: PyroWave has no useful
/// low-rate regime (wavelet quality collapses far above the H.26x floor — plan §4.6), so
/// an Automatic client (`0`) gets the codec's ~1.6 bpp operating point for the negotiated
/// mode instead of the 20 Mbps H.26x default. The rate is then PINNED for the session:
/// the client's ABR controller stays off for this codec and the host refuses mid-stream
/// retargets. An explicit client rate is honored unchanged (the operator knows the link).
fn resolve_bitrate_kbps_for(
codec: crate::encode::Codec,
requested: u32,
mode: &punktfunk_core::config::Mode,
) -> u32 {
if requested == 0 && codec == crate::encode::Codec::PyroWave {
let bps =
mode.width as u64 * mode.height as u64 * u64::from(mode.refresh_hz.max(1)) * 16 / 10;
return u32::try_from(bps / 1000)
.unwrap_or(MAX_BITRATE_KBPS)
.clamp(MIN_BITRATE_KBPS, MAX_BITRATE_KBPS);
}
resolve_bitrate_kbps(requested)
}
/// Resolve the audio channel count the session will capture + encode from the client's request.
/// Normalizes to one of 2 (stereo) / 6 (5.1) / 8 (7.1); anything else (older client, garbage)
/// becomes stereo. Both backends can produce the requested count (PipeWire pads/upmixes positions,
@@ -631,29 +662,46 @@ async fn serve_session(
// The client fingerprint (cert possession is proven by the QUIC handshake) is needed to honor
// a fingerprint-bound PIN window (#9): a window the operator armed for a SPECIFIC device must
// not be consumable — or burnable — by any other fingerprint.
let client_fp = endpoint::peer_fingerprint(&conn)
.ok_or_else(|| anyhow!("pairing requires the client to present a certificate"))?;
let Some(client_fp) = endpoint::peer_fingerprint(&conn) else {
close_rejected(
&conn,
punktfunk_core::reject::RejectReason::IdentityRequired,
);
anyhow::bail!("pairing requires the client to present a certificate");
};
let client_fp_hex = fingerprint_hex(&client_fp);
// Resolve the live arming PIN per attempt (so a lapsed window no longer pairs), honoring any
// fingerprint binding.
let pin = match np.pin_for_attempt(&client_fp_hex) {
crate::native_pairing::PinAttempt::Pin(pin) => pin,
crate::native_pairing::PinAttempt::Disarmed => anyhow::bail!(
crate::native_pairing::PinAttempt::Disarmed => {
close_rejected(&conn, punktfunk_core::reject::RejectReason::PairingNotArmed);
anyhow::bail!(
"pairing not armed (arm it in the console, or start with --allow-pairing)"
),
)
}
// Armed for a DIFFERENT device — reject without running the ceremony, so this attempt does
// NOT consume (burn) the operator's window for the device they actually selected (#9).
crate::native_pairing::PinAttempt::BoundToOther => anyhow::bail!(
crate::native_pairing::PinAttempt::BoundToOther => {
close_rejected(
&conn,
punktfunk_core::reject::RejectReason::PairingBoundToOtherDevice,
);
anyhow::bail!(
"pairing is armed for a different device — this attempt does not consume the window"
),
)
}
};
{
let mut last = last_pairing.lock().unwrap();
if let Some(t) = *last {
anyhow::ensure!(
t.elapsed() >= PAIRING_COOLDOWN,
"pairing rate-limited — retry shortly"
if t.elapsed() < PAIRING_COOLDOWN {
close_rejected(
&conn,
punktfunk_core::reject::RejectReason::PairingRateLimited,
);
anyhow::bail!("pairing rate-limited — retry shortly");
}
}
*last = Some(std::time::Instant::now());
}
@@ -670,12 +718,17 @@ async fn serve_session(
// Decode just enough to gate (the Hello carries the device name for the pending label);
// the `handshake` future re-decodes for the real session — a few dozen bytes, negligible.
let gate_hello = Hello::decode(&first).map_err(|e| anyhow!("Hello decode: {e:?}"))?;
anyhow::ensure!(
gate_hello.abi_version == punktfunk_core::WIRE_VERSION,
if gate_hello.abi_version != punktfunk_core::WIRE_VERSION {
close_rejected(
&conn,
punktfunk_core::reject::RejectReason::WireVersionMismatch,
);
anyhow::bail!(
"wire version mismatch: client {} host {}",
gate_hello.abi_version,
punktfunk_core::WIRE_VERSION
);
}
let fp = endpoint::peer_fingerprint(&conn);
let known = fp
.as_ref()
@@ -685,6 +738,10 @@ async fn serve_session(
// An anonymous client (no certificate) has no identity to approve — reject outright
// (the PIN ceremony is its way in). Mirrors the prior behavior for anonymous knocks.
let Some(fp) = fp else {
close_rejected(
&conn,
punktfunk_core::reject::RejectReason::IdentityRequired,
);
anyhow::bail!(
"unpaired anonymous client rejected (this host requires pairing — present a \
client identity and approve it in the console, or run the PIN ceremony)"
@@ -719,15 +776,24 @@ async fn serve_session(
tracing::info!(name = %label, fingerprint = %fp_hex,
"device approved in console — admitting session (no reconnect)");
}
PairingDecision::Denied => anyhow::bail!("pairing request denied in the console"),
PairingDecision::TimedOut => anyhow::bail!(
PairingDecision::Denied => {
close_rejected(&conn, punktfunk_core::reject::RejectReason::Denied);
anyhow::bail!("pairing request denied in the console")
}
PairingDecision::TimedOut => {
close_rejected(&conn, punktfunk_core::reject::RejectReason::ApprovalTimeout);
anyhow::bail!(
"pairing request not approved within {PENDING_APPROVAL_WAIT:?} \
the device can knock again"
),
PairingDecision::Superseded => anyhow::bail!(
)
}
PairingDecision::Superseded => {
close_rejected(&conn, punktfunk_core::reject::RejectReason::Superseded);
anyhow::bail!(
"parked knock superseded by a newer connection from the same device — \
only the newest is admitted on approval"
),
)
}
}
// Re-acquire a session slot for the now-approved session (waits if all slots are busy,
// exactly like any freshly accepted client).
@@ -747,12 +813,17 @@ async fn serve_session(
let data_port = opts.data_port;
let handshake = async {
let mut hello = Hello::decode(&first).map_err(|e| anyhow!("Hello decode: {e:?}"))?;
anyhow::ensure!(
hello.abi_version == punktfunk_core::WIRE_VERSION,
if hello.abi_version != punktfunk_core::WIRE_VERSION {
close_rejected(
&conn,
punktfunk_core::reject::RejectReason::WireVersionMismatch,
);
anyhow::bail!(
"wire version mismatch: client {} host {}",
hello.abi_version,
punktfunk_core::WIRE_VERSION
);
}
// The pairing gate (require_pairing → paired? else park for delegated approval) ran above,
// before this future, so a client reaching here is paired (or the host is `--open`).
@@ -891,8 +962,9 @@ async fn serve_session(
// needed; the actual pads are created lazily by the input thread).
let gamepad = resolve_gamepad(hello.gamepad);
// Resolve the encoder bitrate (client request clamped to a sane range, or host default).
let bitrate_kbps = resolve_bitrate_kbps(hello.bitrate_kbps);
// Resolve the encoder bitrate (client request clamped to a sane range, or a
// codec-aware host default — PyroWave pins ~1.6 bpp for the mode).
let bitrate_kbps = resolve_bitrate_kbps_for(codec, hello.bitrate_kbps, &hello.mode);
tracing::info!(
requested_kbps = hello.bitrate_kbps,
resolved_kbps = bitrate_kbps,
@@ -1145,6 +1217,8 @@ async fn serve_session(
let adaptive_fec = fec_static_override().is_none();
let fec_target = Arc::new(AtomicU8::new(welcome.fec.fec_percent));
let fec_target_ctl = fec_target.clone();
// The session's negotiated rate — the pin PyroWave retarget-refusals ack (§4.6).
let session_bitrate_kbps = welcome.bitrate_kbps;
tokio::spawn(async move {
let mut active = hello.mode;
// Host-side switch rate limit (a backstop against a hostile/broken client spamming
@@ -1248,7 +1322,20 @@ async fn serve_session(
// 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);
// PyroWave: the rate is PINNED (§4.6 — quality collapses under rate
// descent; recovery pressure is answered by codec fallback, not AIMD).
// Our client controller is off for this codec; this guards older or
// foreign clients by acking the unchanged session rate.
let resolved = if codec == crate::encode::Codec::PyroWave {
tracing::info!(
requested_kbps = req.bitrate_kbps,
pinned_kbps = session_bitrate_kbps,
"PyroWave session: mid-stream bitrate retarget refused (pinned)"
);
session_bitrate_kbps
} else {
resolve_bitrate_kbps(req.bitrate_kbps)
};
tracing::info!(
requested_kbps = req.bitrate_kbps,
resolved_kbps = resolved,
@@ -4389,6 +4476,20 @@ fn virtual_stream(ctx: SessionContext) -> Result<()> {
None => (first, last),
});
}
// All-intra (§4.6): every PyroWave AU is a keyframe, so the NEXT frame already is
// the recovery a request asks for — drop the drained requests instead of running
// the forced-IDR cooldown / RFI / storm machinery (whose frame-size reasoning is
// meaningless when frames are uniform). Defense in depth: the backend's
// request_keyframe/invalidate_ref_frames are no-ops anyway.
if plan.codec == crate::encode::Codec::PyroWave && (want_kf || rfi_range.is_some()) {
tracing::debug!(
want_kf,
?rfi_range,
"PyroWave session: recovery request ignored (all-intra — next frame is the recovery)"
);
want_kf = false;
rfi_range = None;
}
if !want_kf {
if let Some((first, last)) = rfi_range {
// Sanity-cap the range before consulting the encoder: RFI can only re-reference
@@ -5306,6 +5407,30 @@ mod tests {
assert_eq!(dec, snap);
}
#[test]
fn pyrowave_bitrate_pins_to_bpp_default() {
use punktfunk_core::config::Mode;
let mode = Mode {
width: 1920,
height: 1080,
refresh_hz: 60,
};
// Automatic (0) on PyroWave → the ~1.6 bpp operating point, not the 20 Mbps H.26x
// default (which would turn wavelets to mush — plan §4.6).
let kbps = resolve_bitrate_kbps_for(crate::encode::Codec::PyroWave, 0, &mode);
assert_eq!(kbps, 1920 * 1080 * 60 * 16 / 10 / 1000);
// An explicit client rate is honored (clamped like any other codec)...
assert_eq!(
resolve_bitrate_kbps_for(crate::encode::Codec::PyroWave, 130_000, &mode),
130_000
);
// ...and the H.26x codecs keep the legacy default.
assert_eq!(
resolve_bitrate_kbps_for(crate::encode::Codec::H265, 0, &mode),
DEFAULT_BITRATE_KBPS
);
}
#[test]
fn adapt_fec_maps_loss_to_recovery_band() {
// A perfectly clean window (0 loss) lands on the floor.
+1
View File
@@ -24,6 +24,7 @@
"sway",
"running-as-a-service",
"virtual-displays",
"pyrowave",
"host-cli",
"---Connecting---",
"clients",
+65
View File
@@ -0,0 +1,65 @@
---
title: PyroWave (wired-LAN codec)
description: The opt-in ultra-low-latency wavelet codec for wired links — what it is, the bandwidth it needs, and how to turn it on.
---
PyroWave is an **opt-in** video codec mode for links that can afford real bandwidth: wired
Ethernet, a docked Steam Deck, a 2.5GbE LAN. It trades bitrate for latency — instead of
H.264/HEVC/AV1 on the GPU's video engine, frames are compressed with
[PyroWave](https://github.com/Themaister/pyrowave), an intra-only wavelet codec running as plain
Vulkan compute. Punktfunk vendors a pinned copy and runs it on both ends.
**It is never selected automatically.** HEVC/AV1 remain the codecs for Wi-Fi and everything
else; PyroWave engages only when *you* pick it on the client **and** the host supports it. If
either side can't, the session silently falls back to the normal codec ladder.
## Why you'd want it
- **Codec latency drops by an order of magnitude.** Encode and decode each take a fraction of a
millisecond of GPU compute (measured ~0.15 ms encode / ~0.07 ms decode at 1080p on an
RTX 5070 Ti), versus one-to-several milliseconds per side for the hardware H.26x pipelines.
- **Every frame is a keyframe.** There is no GOP, no reference chain, no keyframe round-trip
after packet loss — a lost frame costs exactly that frame, and the next one is already a
complete picture. The whole IDR/recovery apparatus that produces loss-time stutter simply
doesn't exist in this mode.
- **Uniform frame sizes.** The rate control hits its per-frame byte budget exactly, so the
pacer sees a flat load instead of 2040× keyframe spikes.
## What it costs
Bandwidth. At the codec's ~1.6 bits-per-pixel operating point (4:2:0, 60 fps):
| Mode | Bitrate |
|---|---|
| 1280×800 @ 60 (Deck) | ≈ 100 Mbps |
| 1920×1080 @ 60 | ≈ 200 Mbps |
| 2560×1440 @ 60 | ≈ 355 Mbps |
| 3840×2160 @ 60 | ≈ 800 Mbps |
120 Hz doubles these. Gigabit Ethernet tops out around 940 Mbps of payload, so 4K60 wants
2.5GbE (or a lower rate). **Do not run this over Wi-Fi** — that's what HEVC/AV1 are for.
Also: PyroWave is **8-bit SDR only**. An HDR session stays on HEVC/AV1 regardless of this
setting.
## Turning it on
1. **Host** (Linux): build/install a host with the `pyrowave` feature. On an NVIDIA host the
capture path additionally needs `PUNKTFUNK_ENCODER=pyrowave` in `host.env` for the codec to
be advertised; AMD/Intel hosts advertise it automatically when the feature is present.
2. **Client** (Linux session client with the `pyrowave` feature): set **Settings → Video
codec → PyroWave (wired LAN)** in the gamepad console, or launch with
`PUNKTFUNK_PREFER_PYROWAVE=1`.
3. Leave the bitrate on Automatic: a PyroWave session pins itself to the ~1.6 bpp rate for
your mode (≈200 Mbps at 1080p60). An explicit bitrate is honored if you set one, but the
adaptive-bitrate controller stays off either way — this codec has no useful low-rate
regime, so under sustained loss the right move is switching back to HEVC, not degrading.
The stats overlay shows `pyrowave` as the decode path when the mode is active.
## Current limits
- Linux host + Linux client (including docked Deck) today; the Windows host and the Apple
native port are tracked on the [roadmap](/docs/roadmap).
- 8-bit SDR, 4:2:0 only.
- Mid-stream resolution changes rebuild the stream rather than switching seamlessly.
+43
View File
@@ -45,6 +45,25 @@ def find_license_files(pkg_dir):
return out
# Third-party source trees VENDORED inside first-party workspace crates — the
# workspace-member skip in main() hides them from `cargo metadata`, so they are listed
# here explicitly: (label, license file relative to the repo root, source URL).
VENDORED_TREES = [
("pyrowave (vendored, crates/pyrowave-sys)",
"crates/pyrowave-sys/vendor/pyrowave/LICENSE",
"https://github.com/Themaister/pyrowave"),
("Granite subset (vendored, crates/pyrowave-sys)",
"crates/pyrowave-sys/vendor/pyrowave/Granite/LICENSE",
"https://github.com/Themaister/Granite"),
("volk (vendored, crates/pyrowave-sys)",
"crates/pyrowave-sys/vendor/pyrowave/Granite/third_party/volk/LICENSE.md",
"https://github.com/zeux/volk"),
("Vulkan-Headers (vendored, crates/pyrowave-sys)",
"crates/pyrowave-sys/vendor/pyrowave/Granite/third_party/khronos/vulkan-headers/LICENSE.md",
"https://github.com/KhronosGroup/Vulkan-Headers"),
]
def main():
ap = argparse.ArgumentParser()
ap.add_argument("--out", default="THIRD-PARTY-NOTICES.txt")
@@ -78,6 +97,23 @@ def main():
ent = texts.setdefault(h, {"text": txt, "filename": fname, "crates": set()})
ent["crates"].add(label)
repo_root = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
vendored = []
for label, lic_path, url in VENDORED_TREES:
full = os.path.join(repo_root, lic_path)
try:
with open(full, encoding="utf-8", errors="replace") as f:
txt = f.read().strip()
except OSError:
print(f"WARNING: vendored license missing: {lic_path}", file=sys.stderr)
continue
vendored.append((label, url))
h = hashlib.sha256(txt.encode("utf-8", "replace")).hexdigest()
ent = texts.setdefault(
h, {"text": txt, "filename": os.path.basename(lic_path), "crates": set()}
)
ent["crates"].add(label)
lines = []
w = lines.append
w("THIRD-PARTY SOFTWARE NOTICES")
@@ -91,6 +127,13 @@ def main():
w("")
w(f"Total third-party crates: {len(pkgs)}")
w("")
if vendored:
w("-" * 76)
w("VENDORED THIRD-PARTY SOURCE (inside first-party crates)")
w("-" * 76)
for label, url in vendored:
w(f" {label}{url}")
w("")
w("-" * 76)
w("MANIFEST (crate version — SPDX license — source)")
w("-" * 76)