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
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:
@@ -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!(
|
||||
"pairing not armed (arm it in the console, or start with --allow-pairing)"
|
||||
),
|
||||
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!(
|
||||
"pairing is armed for a different device — this attempt does not consume the window"
|
||||
),
|
||||
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,
|
||||
"wire version mismatch: client {} host {}",
|
||||
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!(
|
||||
"pairing request not approved within {PENDING_APPROVAL_WAIT:?} \
|
||||
— the device can knock again"
|
||||
),
|
||||
PairingDecision::Superseded => anyhow::bail!(
|
||||
"parked knock superseded by a newer connection from the same device — \
|
||||
only the newest is admitted on approval"
|
||||
),
|
||||
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 => {
|
||||
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,
|
||||
"wire version mismatch: client {} host {}",
|
||||
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.
|
||||
|
||||
Reference in New Issue
Block a user