Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1511374959 |
@@ -399,7 +399,7 @@ final class SessionModel: ObservableObject {
|
||||
let hz = UInt32(clamping: effective.refreshHz)
|
||||
let compositor = PunktfunkConnection.Compositor(
|
||||
rawValue: UInt32(clamping: effective.compositor)) ?? .auto
|
||||
let bitrateKbps = UInt32(clamping: effective.bitrateKbps)
|
||||
var bitrateKbps = UInt32(clamping: effective.bitrateKbps)
|
||||
let audioChannels = UInt8(clamping: effective.audioChannels)
|
||||
// The audio format this session ASKS for — the user's choice, at every channel count.
|
||||
//
|
||||
@@ -419,6 +419,15 @@ final class SessionModel: ObservableObject {
|
||||
let (audioRateHz, audioBits) = audioFormat.wire
|
||||
let hdrEnabled = effective.hdrEnabled
|
||||
let preferredCodec = PunktfunkConnection.codecByte(effective.codec)
|
||||
// PyroWave is always Automatic bitrate (ABR overhaul RFC §5.2): a fixed kbps is
|
||||
// ill-defined for the all-intra codec (bpp is the operating point) and used to bypass
|
||||
// the host's operator ceiling — send 0 and let the host pin its per-mode rate. Gated
|
||||
// like the advertisement below: a device that failed the Metal probe never offers the
|
||||
// codec, falls back to H.26x, and the user's rate must survive there. The stored
|
||||
// setting is untouched, so switching codecs back restores it.
|
||||
if preferredCodec == PunktfunkConnection.codecPyroWave, MetalWaveletDecoder.supported {
|
||||
bitrateKbps = 0
|
||||
}
|
||||
let pin = host.pinnedSHA256
|
||||
// Capability gate (main-actor — screen APIs): only advertise HDR when this display can
|
||||
// actually present it, so the host sends a proper SDR stream to an SDR display rather than
|
||||
|
||||
@@ -256,11 +256,25 @@ extension SettingsView {
|
||||
|
||||
/// The automatic-bitrate toggle + manual slider (and the >1 Gbps warning) rows.
|
||||
@ViewBuilder private var bitrateRows: some View {
|
||||
described("Uses the host's default, 20 Mbps. Off to set it yourself.",
|
||||
field: "bitrate_kbps") {
|
||||
Toggle("Automatic bitrate", isOn: automaticBitrate)
|
||||
// PyroWave is always Automatic (ABR overhaul RFC §5.2): the session sends 0 and the
|
||||
// host pins a per-mode rate, so a live rate control here would change nothing. Same
|
||||
// support gate as the codec picker offering the option; the stored rate is untouched,
|
||||
// so switching the codec back restores it.
|
||||
if effective.codec == "pyrowave", MetalWaveletDecoder.supported {
|
||||
described("PyroWave sets its own rate from the stream mode — a fixed bitrate "
|
||||
+ "doesn't apply.",
|
||||
field: "bitrate_kbps") {
|
||||
Toggle("Automatic bitrate", isOn: .constant(true))
|
||||
.disabled(true)
|
||||
}
|
||||
} else {
|
||||
described("Uses the host's default, 20 Mbps. Off to set it yourself.",
|
||||
field: "bitrate_kbps") {
|
||||
Toggle("Automatic bitrate", isOn: automaticBitrate)
|
||||
}
|
||||
}
|
||||
if effective.bitrateKbps != 0 {
|
||||
if effective.codec != "pyrowave" || !MetalWaveletDecoder.supported,
|
||||
effective.bitrateKbps != 0 {
|
||||
HStack(spacing: 12) {
|
||||
Slider(value: bitrateSlider, in: 0...1) {
|
||||
Text("Bitrate")
|
||||
|
||||
@@ -450,15 +450,24 @@ struct SettingsView: View {
|
||||
title: "Render scale",
|
||||
options: RenderScale.presets.map { (label: RenderScale.label($0), tag: $0) },
|
||||
selection: $renderScale)
|
||||
TVSelectionRow(
|
||||
title: "Bitrate",
|
||||
options: SettingsOptions.bitrateOptions(current: bitrateKbps),
|
||||
selection: $bitrateKbps)
|
||||
if bitrateKbps > 1_000_000 {
|
||||
Label(Self.gigabitWarning, systemImage: "exclamationmark.triangle.fill")
|
||||
.font(.geist(20, relativeTo: .caption)) // TV-legible caption size
|
||||
.foregroundStyle(.orange)
|
||||
.multilineTextAlignment(.center)
|
||||
// PyroWave is always Automatic (ABR overhaul RFC §5.2): the session sends 0
|
||||
// and the host pins a per-mode rate. tvOS has no codec picker, so this only
|
||||
// fires on a codec synced from another device — but the row must not offer a
|
||||
// rate the session ignores. The stored value is kept.
|
||||
if codec == "pyrowave", MetalWaveletDecoder.supported {
|
||||
tvCaption("PyroWave sets its own rate from the stream mode — the bitrate "
|
||||
+ "setting doesn't apply.")
|
||||
} else {
|
||||
TVSelectionRow(
|
||||
title: "Bitrate",
|
||||
options: SettingsOptions.bitrateOptions(current: bitrateKbps),
|
||||
selection: $bitrateKbps)
|
||||
if bitrateKbps > 1_000_000 {
|
||||
Label(Self.gigabitWarning, systemImage: "exclamationmark.triangle.fill")
|
||||
.font(.geist(20, relativeTo: .caption)) // TV-legible caption size
|
||||
.foregroundStyle(.orange)
|
||||
.multilineTextAlignment(.center)
|
||||
}
|
||||
}
|
||||
TVSelectionRow(
|
||||
title: "10-bit HDR",
|
||||
|
||||
@@ -795,6 +795,25 @@ fn pump(
|
||||
// rung at all, so advertising HEVC would promise what this build cannot keep.
|
||||
¶ms.decoder,
|
||||
) & !params.exclude_codecs;
|
||||
// PyroWave is always Automatic bitrate (ABR overhaul RFC §5.2): a fixed kbps is
|
||||
// ill-defined for the all-intra codec (bpp is the operating point) and used to bypass
|
||||
// the host's `PUNKTFUNK_PYROWAVE_MAX_MBPS` ceiling. Send 0 and let the host pin; the
|
||||
// stored profile value is untouched, so switching codecs back restores it. Gated on the
|
||||
// codec actually being ADVERTISED: a pyrowave preference on a device that failed the
|
||||
// decode probe falls back to H.26x, where the user's explicit rate must survive.
|
||||
let bitrate_kbps = if preferred == punktfunk_core::quic::CODEC_PYROWAVE
|
||||
&& advertised_codecs & punktfunk_core::quic::CODEC_PYROWAVE != 0
|
||||
{
|
||||
if params.bitrate_kbps != 0 {
|
||||
tracing::info!(
|
||||
stored_kbps = params.bitrate_kbps,
|
||||
"PyroWave forces Automatic bitrate — asking the host for its per-mode pin"
|
||||
);
|
||||
}
|
||||
0
|
||||
} else {
|
||||
params.bitrate_kbps
|
||||
};
|
||||
if params.exclude_codecs != 0 {
|
||||
tracing::info!(
|
||||
excluded = params.exclude_codecs,
|
||||
@@ -840,7 +859,7 @@ fn pump(
|
||||
params.mode,
|
||||
params.compositor,
|
||||
params.gamepad,
|
||||
params.bitrate_kbps,
|
||||
bitrate_kbps,
|
||||
params.video_caps,
|
||||
params.audio_channels,
|
||||
audio_rate_hz,
|
||||
|
||||
@@ -677,9 +677,12 @@ impl SettingsScreen {
|
||||
let ids = self.row_ids(ctx);
|
||||
self.clamp_cursor(ids.len());
|
||||
// Y on the Bitrate row opens the typed rate; on every other row it means nothing,
|
||||
// and the hint bar only offers it where it does.
|
||||
// and the hint bar only offers it where it does. Not under PyroWave — the row is
|
||||
// dimmed (see `row_spec`) and a typed rate would be as inert as the ladder.
|
||||
if ev == MenuEvent::Secondary {
|
||||
return if ids.get(self.list.cursor) == Some(&RowId::Bitrate) {
|
||||
return if ids.get(self.list.cursor) == Some(&RowId::Bitrate)
|
||||
&& ctx.settings.codec != "pyrowave"
|
||||
{
|
||||
self.custom_bitrate = Some(String::new());
|
||||
Some(MenuPulse::Confirm)
|
||||
} else {
|
||||
@@ -824,6 +827,11 @@ impl SettingsScreen {
|
||||
Hint::new(HintKey::Confirm, "Open"),
|
||||
Hint::new(HintKey::Back, "Done"),
|
||||
],
|
||||
// Dimmed under PyroWave (row_spec): offering "Adjust" on an inert row would
|
||||
// teach a control that answers with a thud.
|
||||
Some(RowId::Bitrate) if ctx.settings.codec == "pyrowave" => {
|
||||
vec![Hint::new(HintKey::Back, "Done")]
|
||||
}
|
||||
// The one row with a value the ladder cannot name every version of.
|
||||
Some(RowId::Bitrate) => vec![
|
||||
Hint::new(HintKey::Adjust, "Adjust"),
|
||||
@@ -910,7 +918,7 @@ impl SettingsScreen {
|
||||
let detail = ids
|
||||
.get(self.list.cursor)
|
||||
.copied()
|
||||
.map_or("", |id| detail(id, ctx.platform));
|
||||
.map_or("", |id| detail(id, ctx));
|
||||
fonts.centered(
|
||||
canvas,
|
||||
detail,
|
||||
@@ -1042,6 +1050,11 @@ fn row_spec(id: RowId, ctx: &Ctx, profiles: &[(String, String)]) -> RowSpec {
|
||||
// that one is different.
|
||||
let enabled = match id {
|
||||
RowId::EchoCancel => s.mic_enabled,
|
||||
// PyroWave is always Automatic bitrate (ABR overhaul RFC §5.2): the session sends 0
|
||||
// whatever this row stores and the host pins a per-mode bpp rate. Dimmed, not live —
|
||||
// a control that changes nothing must say so. The stored rate is kept: switching the
|
||||
// codec back restores it.
|
||||
RowId::Bitrate => s.codec != "pyrowave",
|
||||
// ⚠ Lossless follows the channel count for a reason that has MOVED, and the old reason
|
||||
// is still written down in several places that are now wrong (`hi-res-audio.md` §4.2's
|
||||
// blanket "surround does not fit a datagram", and `trust::Settings::audio_format`'s doc
|
||||
@@ -1301,8 +1314,9 @@ fn row_spec(id: RowId, ctx: &Ctx, profiles: &[(String, String)]) -> RowSpec {
|
||||
/// The focused row's one-line explainer. Takes the platform because two desktop rows
|
||||
/// advertise desktop-only live chords (Ctrl+Alt+Shift+…) that no Android build has — a
|
||||
/// shortcut the device cannot press must not be taught.
|
||||
fn detail(id: RowId, platform: crate::platform::Platform) -> &'static str {
|
||||
fn detail(id: RowId, ctx: &Ctx) -> &'static str {
|
||||
use crate::platform::Platform;
|
||||
let platform = ctx.platform;
|
||||
match id {
|
||||
RowId::Resolution => {
|
||||
"The host creates a virtual display at exactly this size — no scaling. \
|
||||
@@ -1313,6 +1327,10 @@ fn detail(id: RowId, platform: crate::platform::Platform) -> &'static str {
|
||||
"The host renders larger or smaller than the stream mode and this window \
|
||||
resamples — above 1× supersamples, below saves bandwidth."
|
||||
}
|
||||
RowId::Bitrate if ctx.settings.codec == "pyrowave" => {
|
||||
"PyroWave sets its own rate from the stream mode (all-intra) — a fixed bitrate \
|
||||
doesn't apply. Pick another codec to use this setting."
|
||||
}
|
||||
RowId::Bitrate => {
|
||||
"Automatic uses the host's default (20 Mbps). Y types an exact rate, up to 2 Gbps."
|
||||
}
|
||||
@@ -1573,6 +1591,11 @@ fn adjust(id: RowId, delta: i32, wrap: bool, ctx: &mut Ctx) -> bool {
|
||||
.map(|i| s.render_scale = RENDER_SCALES[i])
|
||||
}
|
||||
RowId::Bitrate => {
|
||||
// Inert under PyroWave — a boundary thud, matching what the dimmed row shows
|
||||
// (the host pins the rate; see `row_spec`).
|
||||
if s.codec == "pyrowave" {
|
||||
return false;
|
||||
}
|
||||
// A typed rate (or one a desktop shell's spinner stored) sits BETWEEN rungs, and
|
||||
// the generic step snaps a value it cannot find to the first option — which here
|
||||
// is Automatic, i.e. one nudge throws the custom rate away. Step to the rung the
|
||||
@@ -2145,6 +2168,40 @@ pub(super) mod tests {
|
||||
assert!(ctx.settings.echo_cancel);
|
||||
}
|
||||
|
||||
/// Bitrate follows the codec: dimmed and inert under PyroWave (the host pins a per-mode
|
||||
/// rate and the session sends 0 — ABR overhaul RFC §5.2), live for every other codec,
|
||||
/// and the stored rate survives the dim so switching back restores it.
|
||||
#[test]
|
||||
fn bitrate_dims_under_pyrowave() {
|
||||
let (mut settings, pads) = ctx_parts();
|
||||
settings.codec = "pyrowave".into();
|
||||
settings.bitrate_kbps = 80_000;
|
||||
let library = crate::library::LibraryShared::default();
|
||||
let mut ctx = Ctx {
|
||||
hosts: &[],
|
||||
library: &library,
|
||||
settings: &mut settings,
|
||||
store: crate::store::file_store(),
|
||||
platform: crate::platform::Platform::Desktop,
|
||||
pads: &pads,
|
||||
deck: false,
|
||||
fallback_ui: false,
|
||||
device_name: "t",
|
||||
t: 0.0,
|
||||
};
|
||||
assert!(!row_spec(RowId::Bitrate, &ctx, &[]).enabled);
|
||||
assert!(
|
||||
!adjust(RowId::Bitrate, 1, false, &mut ctx),
|
||||
"pyrowave = thud"
|
||||
);
|
||||
assert!(!adjust(RowId::Bitrate, 1, true, &mut ctx), "A too");
|
||||
assert_eq!(ctx.settings.bitrate_kbps, 80_000, "the stored rate is kept");
|
||||
|
||||
ctx.settings.codec = "hevc".into();
|
||||
assert!(row_spec(RowId::Bitrate, &ctx, &[]).enabled);
|
||||
assert!(adjust(RowId::Bitrate, 1, false, &mut ctx));
|
||||
}
|
||||
|
||||
/// The smoothness buffer is OFFERED only under Smoothness — under Lowest latency it names
|
||||
/// a quantity that doesn't exist, so the row is gone from the Video tab rather than sitting
|
||||
/// there dimmed. This is what the GTK and WinUI shells and the Apple/Android screens have
|
||||
|
||||
@@ -552,27 +552,10 @@ impl BitrateController {
|
||||
}
|
||||
|
||||
/// Teach the controller what this session's mode and codec could plausibly use (see
|
||||
/// [`stream_ceiling_kbps`]). Bounds future LEARNED ceilings at the same funnel as the
|
||||
/// [`stream_ceiling_kbps`]). Applied to LEARNED ceilings only, at the same funnel as the
|
||||
/// operator's env cap.
|
||||
///
|
||||
/// The FIRST set is the session's negotiated shape and keeps the founding semantics — a
|
||||
/// negotiated start rate above it stands, the host resolved that number (pinned by
|
||||
/// `the_stream_bound_clamps_a_learned_ceiling_only`). A RE-set is a mode switch, and
|
||||
/// there a DROP in pixel rate also rebinds the already-standing ceiling: `set_ceiling`
|
||||
/// clamps only at learn time and deliberately never lowers, so a 4K-learned ceiling
|
||||
/// would otherwise stand over a 720p stream with only the reactive loss/decode signals
|
||||
/// to bound the climb (review §2.1).
|
||||
pub(crate) fn set_stream_cap(&mut self, kbps: u32) {
|
||||
let mode_switch = self.stream_cap_kbps.is_some();
|
||||
self.stream_cap_kbps = Some(kbps);
|
||||
if mode_switch && self.enabled && self.ceiling_kbps > kbps {
|
||||
tracing::info!(
|
||||
ceiling_kbps = self.ceiling_kbps,
|
||||
stream_cap_kbps = kbps,
|
||||
"adaptive bitrate: ceiling rebound to the switched mode's stream shape"
|
||||
);
|
||||
self.ceiling_kbps = kbps;
|
||||
}
|
||||
}
|
||||
|
||||
/// Teach the controller this session's refresh rate, so the encode thresholds can be sized in
|
||||
@@ -1568,48 +1551,6 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
/// Review §2.1: the stream-shape cap was computed once from the Welcome mode and never
|
||||
/// again — 1080p→4K kept a 1080p-sized climb ceiling, 4K→720p left an oversized one
|
||||
/// standing. A mode switch now re-teaches the cap: an upswitch opens room for the probe's
|
||||
/// measurement to authorize more, a downswitch rebinds the already-learned ceiling.
|
||||
#[test]
|
||||
fn a_mode_switch_reteaches_the_stream_cap_both_ways() {
|
||||
// 1080p session, probe measured a fat link: ceiling bound at the 1080p shape.
|
||||
let mut c = BitrateController::new(20_000);
|
||||
c.set_stream_cap(100_000);
|
||||
c.set_ceiling(657_000);
|
||||
assert_eq!(c.ceiling_kbps, 100_000);
|
||||
|
||||
// Switch UP to 4K: the new shape allows more, and the probe's measurement (already
|
||||
// taken this session) may re-authorize up to it.
|
||||
c.on_mode_switch();
|
||||
c.set_stream_cap(400_000);
|
||||
assert_eq!(
|
||||
c.ceiling_kbps, 100_000,
|
||||
"an upswitch alone raises nothing — authority still needs a measurement"
|
||||
);
|
||||
c.set_ceiling(657_000);
|
||||
assert_eq!(
|
||||
c.ceiling_kbps, 400_000,
|
||||
"the 4K shape no longer pins the session to the 1080p bound"
|
||||
);
|
||||
|
||||
// Switch DOWN to 720p: the learned 4K ceiling must not stand over the small stream —
|
||||
// `set_ceiling` never lowers, so the re-taught cap is what rebinds it.
|
||||
c.on_mode_switch();
|
||||
c.set_stream_cap(42_000);
|
||||
assert_eq!(
|
||||
c.ceiling_kbps, 42_000,
|
||||
"a downswitch rebinds the already-learned ceiling"
|
||||
);
|
||||
|
||||
// A disabled controller (explicit bitrate) is untouched by all of it.
|
||||
let mut d = BitrateController::new(0);
|
||||
d.set_stream_cap(100_000);
|
||||
d.set_stream_cap(42_000);
|
||||
assert_eq!(d.ceiling_kbps, 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn owd_rise_alone_is_a_congestion_signal() {
|
||||
let mut c = BitrateController::new(20_000);
|
||||
|
||||
@@ -86,10 +86,6 @@ pub(super) async fn run_pump(args: WorkerArgs) {
|
||||
let clock_rtt_ns = negotiated.clock_rtt_ns;
|
||||
let resolved_bitrate_kbps = negotiated.bitrate_kbps;
|
||||
let negotiated_codec = negotiated.codec;
|
||||
// Session constants a mode switch does not change — the pump recomputes the stream-shape
|
||||
// cap from them for the switched geometry (review §2.1).
|
||||
let bit_depth = negotiated.bit_depth;
|
||||
let chroma_format = negotiated.chroma_format;
|
||||
// What this session's mode + codec could plausibly use — the bound the ABR holds its
|
||||
// probe-measured link ceiling to. Computed here because this is where the Welcome-resolved
|
||||
// geometry lives; the data pump stays codec-agnostic.
|
||||
@@ -168,14 +164,9 @@ pub(super) async fn run_pump(args: WorkerArgs) {
|
||||
}
|
||||
});
|
||||
|
||||
// Adaptive bitrate ack queue: the control task pushes every BitrateChanged; the pump's
|
||||
// controller drains them in arrival order on its report tick. A QUEUE, not a latest-wins
|
||||
// slot (review §2.4): a full resolve ack plus a corrective short retarget in the same
|
||||
// 750 ms window used to collapse to whichever arrived last, and host-cap learning needs
|
||||
// two CONSECUTIVE short acks — losing one delayed or prevented the cap and could
|
||||
// reintroduce the encoder-overdrive sawtooth.
|
||||
let bitrate_ack: Arc<Mutex<std::collections::VecDeque<u32>>> =
|
||||
Arc::new(Mutex::new(std::collections::VecDeque::new()));
|
||||
// Adaptive bitrate ack slot: the control task parks the latest BitrateChanged here; the
|
||||
// pump's controller drains it on its report tick (`take()` — an ack is consumed once).
|
||||
let bitrate_ack: Arc<Mutex<Option<u32>>> = Arc::new(Mutex::new(None));
|
||||
// Decode-recovery keyframe asks (the ABR recovery signal): the control task counts every
|
||||
// outbound `CtrlRequest::Keyframe` — the one choke point all emitters funnel through — and
|
||||
// the pump drains the count per report window.
|
||||
@@ -288,8 +279,6 @@ pub(super) async fn run_pump(args: WorkerArgs) {
|
||||
bitrate_kbps,
|
||||
resolved_bitrate_kbps,
|
||||
negotiated_codec,
|
||||
bit_depth,
|
||||
chroma_format,
|
||||
stream_cap_kbps,
|
||||
refresh_hz,
|
||||
mode_slot: mode_slot_pump,
|
||||
|
||||
@@ -15,7 +15,7 @@ pub(super) struct ControlTask {
|
||||
pub(super) mode_slot: Arc<Mutex<Mode>>,
|
||||
pub(super) probe: Arc<Mutex<ProbeState>>,
|
||||
/// The latest host `BitrateChanged` ack, drained by the pump's ABR on its report tick.
|
||||
pub(super) bitrate_ack: Arc<Mutex<std::collections::VecDeque<u32>>>,
|
||||
pub(super) bitrate_ack: Arc<Mutex<Option<u32>>>,
|
||||
/// The live encoder-target mirror ([`NativeClient::current_bitrate_kbps`]): unlike the
|
||||
/// drain-once ack slot above, this one always holds the latest acked rate for stats HUDs.
|
||||
pub(super) live_bitrate: Arc<AtomicU32>,
|
||||
@@ -200,7 +200,7 @@ impl ControlTask {
|
||||
if ack.bitrate_kbps > 0 {
|
||||
live_bitrate.store(ack.bitrate_kbps, Ordering::Relaxed);
|
||||
}
|
||||
bitrate_ack.lock().unwrap().push_back(ack.bitrate_kbps);
|
||||
*bitrate_ack.lock().unwrap() = Some(ack.bitrate_kbps);
|
||||
} else if let Ok(gap) = crate::quic::PipelineGap::decode(&msg) {
|
||||
// The host rebuilt its capture ring + encoder in place and nothing flowed
|
||||
// while it did. Park it for the pump, which discards the report window in
|
||||
|
||||
@@ -27,10 +27,7 @@ pub(super) struct DataPump {
|
||||
pub(super) mode_gen: Arc<AtomicU32>,
|
||||
pub(super) frames_dropped: Arc<std::sync::atomic::AtomicU64>,
|
||||
pub(super) fec_recovered: Arc<std::sync::atomic::AtomicU64>,
|
||||
/// Host `BitrateChanged` acks since the last report tick, drained in arrival order — a
|
||||
/// queue so a corrective short retarget can't be clobbered by a full resolve ack in the
|
||||
/// same window (review §2.4; host-cap learning needs two CONSECUTIVE short acks).
|
||||
pub(super) bitrate_ack: Arc<Mutex<std::collections::VecDeque<u32>>>,
|
||||
pub(super) bitrate_ack: Arc<Mutex<Option<u32>>>,
|
||||
/// Outbound decode-recovery keyframe asks, counted by the control task at its send choke
|
||||
/// point; drained per report window as the ABR's recovery signal.
|
||||
pub(super) recovery_kf: Arc<AtomicU32>,
|
||||
@@ -43,15 +40,9 @@ pub(super) struct DataPump {
|
||||
/// The rate the host actually configured (echoed in Welcome).
|
||||
pub(super) resolved_bitrate_kbps: u32,
|
||||
pub(super) negotiated_codec: u8,
|
||||
/// The negotiated encode bit depth and chroma wire byte — session constants a mode switch
|
||||
/// does NOT change, carried so the stream-shape cap can be recomputed for a new geometry
|
||||
/// (review §2.1).
|
||||
pub(super) bit_depth: u8,
|
||||
pub(super) chroma_format: u8,
|
||||
/// What this session's mode + codec could plausibly use (see
|
||||
/// [`crate::abr::stream_ceiling_kbps`]) — the bound the probe-measured link ceiling is held
|
||||
/// to. Computed where the negotiated geometry lives; recomputed here on an accepted mode
|
||||
/// switch (review §2.1).
|
||||
/// to. Computed where the negotiated geometry lives, so this module stays codec-agnostic.
|
||||
pub(super) stream_cap_kbps: u32,
|
||||
/// The negotiated refresh, which sets the frame budget the ABR sizes its host-encode
|
||||
/// thresholds against (see [`crate::abr::BitrateController::set_frame_budget`]).
|
||||
@@ -83,8 +74,6 @@ impl DataPump {
|
||||
bitrate_kbps,
|
||||
resolved_bitrate_kbps,
|
||||
negotiated_codec,
|
||||
bit_depth,
|
||||
chroma_format,
|
||||
stream_cap_kbps,
|
||||
refresh_hz,
|
||||
mode_slot: pump_mode_slot,
|
||||
@@ -561,26 +550,12 @@ impl DataPump {
|
||||
if mg != seen_mode_gen {
|
||||
seen_mode_gen = mg;
|
||||
abr.on_mode_switch();
|
||||
let m = *pump_mode_slot.lock().unwrap();
|
||||
// The frame budget is a property of the MODE: a switch that changes the
|
||||
// refresh changes what one frame of encode time costs, and the encode
|
||||
// thresholds are sized in those.
|
||||
abr.set_frame_budget(m.refresh_hz);
|
||||
// So is the stream-shape cap (review §2.1): computed once from the
|
||||
// Welcome mode, 1080p→4K kept a 1080p-sized climb ceiling (under-running
|
||||
// quality on a fat link) and 4K→720p left an oversized cap standing with
|
||||
// only the reactive loss/decode signals to bound the climb.
|
||||
// `set_stream_cap` also rebinds an already-learned ceiling downward.
|
||||
abr.set_stream_cap(crate::abr::stream_ceiling_kbps(
|
||||
m.width,
|
||||
m.height,
|
||||
m.refresh_hz,
|
||||
negotiated_codec,
|
||||
bit_depth,
|
||||
chroma_format,
|
||||
));
|
||||
abr.set_frame_budget(pump_mode_slot.lock().unwrap().refresh_hz);
|
||||
}
|
||||
for acked in bitrate_ack.lock().unwrap().drain(..) {
|
||||
if let Some(acked) = bitrate_ack.lock().unwrap().take() {
|
||||
abr.on_ack(acked);
|
||||
}
|
||||
let owd_mean_us =
|
||||
@@ -1053,7 +1028,7 @@ mod tests {
|
||||
refresh_hz: 60,
|
||||
})),
|
||||
probe: Arc::new(Mutex::new(ProbeState::default())),
|
||||
bitrate_ack: Arc::new(Mutex::new(std::collections::VecDeque::new())),
|
||||
bitrate_ack: Arc::new(Mutex::new(None)),
|
||||
live_bitrate: Arc::new(AtomicU32::new(0)),
|
||||
recovery_kf: Arc::new(AtomicU32::new(0)),
|
||||
pipeline_gap: pipeline_gap.clone(),
|
||||
@@ -1089,14 +1064,12 @@ mod tests {
|
||||
mode_gen: Arc::new(AtomicU32::new(0)),
|
||||
frames_dropped: Arc::new(std::sync::atomic::AtomicU64::new(0)),
|
||||
fec_recovered: Arc::new(std::sync::atomic::AtomicU64::new(0)),
|
||||
bitrate_ack: Arc::new(Mutex::new(std::collections::VecDeque::new())),
|
||||
bitrate_ack: Arc::new(Mutex::new(None)),
|
||||
recovery_kf: Arc::new(AtomicU32::new(0)),
|
||||
pipeline_gap: pipeline_gap.clone(),
|
||||
bitrate_kbps: 20_000,
|
||||
resolved_bitrate_kbps: 20_000,
|
||||
negotiated_codec: crate::quic::CODEC_HEVC,
|
||||
bit_depth: 8,
|
||||
chroma_format: 0,
|
||||
stream_cap_kbps: 100_000,
|
||||
refresh_hz: 60,
|
||||
mode_slot: Arc::new(Mutex::new(crate::config::Mode {
|
||||
|
||||
@@ -931,7 +931,13 @@ fn resolve_bitrate_kbps(requested: u32) -> u32 {
|
||||
/// 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).
|
||||
/// retargets.
|
||||
///
|
||||
/// PyroWave ignores an explicit client rate too (ABR overhaul RFC §5.2): a fixed rate is
|
||||
/// ill-defined for an all-intra codec (bpp is the operating point, not kbps) and it used to
|
||||
/// bypass the `PUNKTFUNK_PYROWAVE_MAX_MBPS` operator ceiling. Clients grey the control out;
|
||||
/// this arm is the belt-and-braces for embedders that never update their UI. H.26x/AV1
|
||||
/// explicit rates are honored unchanged (the operator knows the link).
|
||||
fn resolve_bitrate_kbps_for(
|
||||
codec: crate::encode::Codec,
|
||||
requested: u32,
|
||||
@@ -939,7 +945,14 @@ fn resolve_bitrate_kbps_for(
|
||||
chroma: crate::encode::ChromaFormat,
|
||||
bit_depth: u8,
|
||||
) -> u32 {
|
||||
if requested == 0 && codec == crate::encode::Codec::PyroWave {
|
||||
if codec == crate::encode::Codec::PyroWave {
|
||||
if requested != 0 {
|
||||
tracing::warn!(
|
||||
requested_kbps = requested,
|
||||
"an explicit bitrate is ill-defined under PyroWave (all-intra bpp semantics) — \
|
||||
treating it as Automatic and resolving the per-mode pin"
|
||||
);
|
||||
}
|
||||
// ~1.6 bpp for 4:2:0. 4:4:4 doubles the samples per pixel (3 vs 1.5) but chroma
|
||||
// compresses better than luma → ×1.625 ≈ 2.6 bpp; 16-bit planes add ~15 % (both
|
||||
// factors measured against the Phase-0 fixture matrix, design/pyrowave-444-hdr.md).
|
||||
@@ -976,7 +989,8 @@ fn resolve_bitrate_kbps_for(
|
||||
|
||||
/// Operator ceiling for PyroWave's open-loop Automatic bitrate pin: `PUNKTFUNK_PYROWAVE_MAX_MBPS`
|
||||
/// (megabits/s) → kbps, or `None` when unset/zero/invalid (no cap — the raw bpp pin stands).
|
||||
/// Only consulted for `requested == 0` PyroWave sessions; an explicit client bitrate bypasses it.
|
||||
/// Consulted for every PyroWave session — an explicit client bitrate resolves through the
|
||||
/// pin too (RFC §5.2), so nothing bypasses the ceiling.
|
||||
fn pyrowave_auto_pin_ceiling_kbps() -> Option<u32> {
|
||||
std::env::var("PUNKTFUNK_PYROWAVE_MAX_MBPS")
|
||||
.ok()
|
||||
@@ -2035,8 +2049,9 @@ async fn serve_session(
|
||||
});
|
||||
let bitrate_kbps = welcome.bitrate_kbps; // resolved encoder bitrate (Hello clamped, or default)
|
||||
// "Automatic" request: the resolved rate is a host default — for PyroWave a per-mode
|
||||
// bpp pin the data plane re-resolves on a mid-stream mode switch.
|
||||
let bitrate_auto = hello.bitrate_kbps == 0;
|
||||
// bpp pin the data plane re-resolves on a mid-stream mode switch. PyroWave is Automatic
|
||||
// unconditionally (`resolve_bitrate_kbps_for` overrode any explicit rate — RFC §5.2).
|
||||
let bitrate_auto = hello.bitrate_kbps == 0 || codec == crate::encode::Codec::PyroWave;
|
||||
let bit_depth = welcome.bit_depth; // resolved encode bit depth (8, or 10 when negotiated)
|
||||
// Resolved chroma — derive the typed value back from the wire byte the Welcome carried (so the
|
||||
// session uses exactly what the client was told). `Yuv444` only when the handshake gate passed.
|
||||
@@ -2501,7 +2516,8 @@ mod tests {
|
||||
),
|
||||
(1920u64 * 1080 * 60 * 26 / 10 * 115 / 100 / 1000) as u32
|
||||
);
|
||||
// An explicit client rate is honored (clamped like any other codec)...
|
||||
// An explicit client rate is overridden to the same pin — a fixed kbps is ill-defined
|
||||
// for the all-intra codec, and it used to skip the operator ceiling (RFC §5.2)...
|
||||
assert_eq!(
|
||||
resolve_bitrate_kbps_for(
|
||||
crate::encode::Codec::PyroWave,
|
||||
@@ -2510,7 +2526,7 @@ mod tests {
|
||||
ChromaFormat::Yuv420,
|
||||
8
|
||||
),
|
||||
130_000
|
||||
1920 * 1080 * 60 * 16 / 10 / 1000
|
||||
);
|
||||
// ...and the H.26x codecs keep the legacy default.
|
||||
assert_eq!(
|
||||
@@ -2559,10 +2575,11 @@ mod tests {
|
||||
resolve_bitrate_kbps_for(Codec::PyroWave, 0, &small, ChromaFormat::Yuv420, 8),
|
||||
1920 * 1080 * 60 * 16 / 10 / 1000
|
||||
);
|
||||
// ...and an explicit client rate bypasses the ceiling entirely.
|
||||
// ...and an explicit client rate no longer bypasses it: PyroWave resolves through the
|
||||
// pin + ceiling whatever the Hello carried (RFC §5.2 — this bypass was the bug).
|
||||
assert_eq!(
|
||||
resolve_bitrate_kbps_for(Codec::PyroWave, 6_000_000, &mode, ChromaFormat::Yuv444, 10),
|
||||
6_000_000
|
||||
4_500_000
|
||||
);
|
||||
// SAFETY: as the set above — single writer, and the readers run on this thread.
|
||||
unsafe { std::env::remove_var("PUNKTFUNK_PYROWAVE_MAX_MBPS") };
|
||||
|
||||
@@ -1106,7 +1106,9 @@ pub(super) async fn negotiate(
|
||||
// "Automatic" — `bitrate_kbps` above is the host's own answer for `mode`, so the build
|
||||
// may re-resolve it if the source turns out to deliver a different size. Sampled here
|
||||
// rather than in the thread body so the closure doesn't have to capture `hello`.
|
||||
let bitrate_auto = hello.bitrate_kbps == 0;
|
||||
// PyroWave is Automatic unconditionally (`resolve_bitrate_kbps_for` overrode any
|
||||
// explicit rate — RFC §5.2).
|
||||
let bitrate_auto = hello.bitrate_kbps == 0 || codec == crate::encode::Codec::PyroWave;
|
||||
let trace = bringup.clone();
|
||||
std::thread::Builder::new()
|
||||
.name("punktfunk1-stream".into())
|
||||
|
||||
@@ -1762,8 +1762,8 @@ pub(super) fn virtual_stream(ctx: SessionContext, prepared: Option<PreparedDispl
|
||||
instead of building twice"
|
||||
);
|
||||
mode = m;
|
||||
// Mirror the loop's rebuild: PyroWave's Automatic bitrate is a per-mode ~1.6 bpp pin, so
|
||||
// a resolution change moves the operating point. Explicit client rates stay put.
|
||||
// Mirror the loop's rebuild: PyroWave's bitrate is a per-mode ~1.6 bpp pin, so a
|
||||
// resolution change moves the operating point (PyroWave is always Automatic — RFC §5.2).
|
||||
if bitrate_auto && plan.codec == crate::encode::Codec::PyroWave {
|
||||
bitrate_kbps =
|
||||
resolve_bitrate_kbps_for(plan.codec, 0, &mode, plan.chroma, plan.bit_depth);
|
||||
@@ -2512,10 +2512,10 @@ pub(super) fn virtual_stream(ctx: SessionContext, prepared: Option<PreparedDispl
|
||||
// new-mode frame — `build_pipeline` waits for it). Total lands in the shared
|
||||
// `resize_ms` slot (→ `session_status`); a failed rebuild abandons it silently.
|
||||
let resize_trace = crate::bringup::Trace::start("resize", resize_ms.clone());
|
||||
// PyroWave's Automatic bitrate is a per-mode ~1.6 bpp pin (resolve_bitrate_kbps_for) —
|
||||
// a resolution change moves the operating point (1080p→4K quadruples the pixel rate),
|
||||
// so re-resolve it for the new mode. Explicit client rates stay put (the operator knows
|
||||
// the link), and the H.26x codecs keep their mode-independent rate (ABR owns it).
|
||||
// PyroWave's bitrate is a per-mode ~1.6 bpp pin (resolve_bitrate_kbps_for) — a
|
||||
// resolution change moves the operating point (1080p→4K quadruples the pixel rate),
|
||||
// so re-resolve it for the new mode (PyroWave is always Automatic — RFC §5.2). The
|
||||
// H.26x codecs keep their mode-independent rate (ABR owns it).
|
||||
let mode_bitrate = if bitrate_auto && plan.codec == crate::encode::Codec::PyroWave {
|
||||
resolve_bitrate_kbps_for(plan.codec, 0, &new_mode, plan.chroma, plan.bit_depth)
|
||||
} else {
|
||||
@@ -2798,11 +2798,6 @@ pub(super) fn virtual_stream(ctx: SessionContext, prepared: Option<PreparedDispl
|
||||
// `interval` was built as 1/effective_hz, so the round-trip recovers the integer
|
||||
// rate.
|
||||
let hz = interval_hz(interval);
|
||||
// Timed for the `PipelineGap` below: the rebuild stalls capture for ~0.6 s,
|
||||
// and a client that isn't told discards its starved windows as congestion
|
||||
// (the 401 ms field case: slow start killed, minutes at ~15 Mbps on a clean
|
||||
// link — review §2.2). The mode-switch and topology rebuilds already announce.
|
||||
let rebuild_t0 = std::time::Instant::now();
|
||||
match crate::encode::open_video(
|
||||
plan.codec,
|
||||
frame.format,
|
||||
@@ -2858,24 +2853,10 @@ pub(super) fn virtual_stream(ctx: SessionContext, prepared: Option<PreparedDispl
|
||||
behind_score = 0;
|
||||
depth_frames = 0;
|
||||
ahead_run = 0;
|
||||
// …and it must not feed the CLIENT's controller either: announce the
|
||||
// host-local gap so the starved window is discarded, exactly as a
|
||||
// mode-switch rebuild does (review §2.2 — this arm was the one rebuild
|
||||
// that never told the client).
|
||||
announce_pipeline_gap(
|
||||
&gap_tx,
|
||||
rebuild_t0.elapsed().as_millis().min(u32::MAX as u128) as u32,
|
||||
);
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::warn!(error = %format!("{e:#}"), to_kbps = new_kbps,
|
||||
"bitrate-change encoder rebuild failed — keeping the current rate");
|
||||
// The control task acked the resolved rate BEFORE this apply — with
|
||||
// the rebuild failed, the client's controller now tracks a rate the
|
||||
// encoder never ran: its climb base, utilization and proven math all
|
||||
// drift from a phantom number (review §2.3). Snap it back, same
|
||||
// channel as the short-apply correction above.
|
||||
let _ = retarget_tx.send(bitrate_kbps);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -50,8 +50,10 @@ probe about two seconds in that lets the rate climb past 20 Mbps. An explicit ra
|
||||
session, clamped to **500 kbps – 8 Gbps**. A host card's menu has **Test network speed…** to suggest
|
||||
a value.
|
||||
|
||||
PyroWave has no useful low-rate regime: its Automatic is a fixed per-pixel budget for the negotiated
|
||||
mode (hundreds of Mbps), with adaptive bitrate and the probe off for the whole session.
|
||||
PyroWave is **always Automatic**: the rate is a fixed per-pixel budget for the negotiated mode
|
||||
(hundreds of Mbps), with adaptive bitrate and the probe off for the whole session. A fixed kbps
|
||||
is meaningless for the all-intra codec, so the bitrate setting is disabled while PyroWave is
|
||||
selected — your stored value is kept, and picking another codec restores it.
|
||||
|
||||
**Render scale** — *default: Native (1×).* The host renders and encodes at your mode times this;
|
||||
your device resamples to its window. Above 1× supersamples at more bandwidth and decode work; below
|
||||
@@ -298,7 +300,7 @@ exactly [what a profile can't change](/docs/profiles-and-links#what-a-profile-ca
|
||||
| You ask for | What the host does |
|
||||
|---|---|
|
||||
| Resolution and refresh | Builds a display at exactly that mode. A host pinned to a real monitor keeps that monitor's resolution and you scale locally. A size the encoder can't take — odd, or past the codec's per-axis limit — fails the connect rather than being quietly changed. |
|
||||
| A bitrate | Clamps it to 500 kbps – 8 Gbps, or uses its 20 Mbps default for Automatic (a per-pixel budget for Automatic PyroWave). |
|
||||
| A bitrate | Clamps it to 500 kbps – 8 Gbps, or uses its 20 Mbps default for Automatic. PyroWave ignores the number entirely — every PyroWave session gets the per-pixel budget. |
|
||||
| A codec | Honors it when it can encode it, else the best shared codec in the order HEVC → AV1 → H.264. |
|
||||
| 10-bit HDR | Upgrades only for HDR content on an encoder that can do 10-bit; otherwise 8-bit SDR. |
|
||||
| 4:4:4 chroma | Sends it only when every gate passes; otherwise 4:2:0. |
|
||||
|
||||
@@ -133,7 +133,7 @@ See your desktop page ([KDE](/docs/kde), [GNOME](/docs/gnome)) for when to set t
|
||||
| `PUNKTFUNK_10BIT` | `1` · `0` *(default on)* | Allow 10-bit (HEVC Main10 / AV1 10-bit) sessions at all; `0` forces every session to 8-bit SDR. Which hosts can actually deliver it, and the client half of the switch, are on [HDR](/docs/hdr). |
|
||||
| `PUNKTFUNK_444` | `1` · `0` *(default on)* | Host **policy gate** for full chroma 4:4:4 — sharper text and thin lines, no chroma loss. **On by default**; `0` forces every session to 4:2:0. It only ever *allows*: the client's own 4:4:4 setting (default off) is the real per-session switch, and the codec, capture-path and GPU gates behind it are on [Client settings → Full chroma](/docs/client-settings#video). Which GPUs and which clients can actually do it is in the [support matrix](/docs/support-matrix#encoders); how it interacts with HDR is on [HDR](/docs/hdr). **punktfunk/1 native only** — Moonlight stays 4:2:0. |
|
||||
| `PUNKTFUNK_CHACHA20` | `1` · `0` *(default on)* | ChaCha20-Poly1305 session encryption for clients without hardware AES (old ARM TVs, e.g. webOS), lifting their ~100 Mbps software-AES decrypt ceiling. **On by default** on the host; a session uses it only when the client requests it — everyone else stays on AES-GCM. Purely a performance choice (both ciphers are full-strength); set `0` to force AES-GCM for all sessions. |
|
||||
| `PUNKTFUNK_PYROWAVE_MAX_MBPS` | `N` (Mbps) | Cap the [PyroWave](/docs/pyrowave) Automatic bitrate pin, for a host on a link that the open-loop pin can outrun (e.g. 4:4:4 + HDR at 5120×1440@240 pins ~5.3 Gbps, over a 5GbE link). Unset = no cap. Only affects Automatic (bitrate `0`) PyroWave sessions; an explicit client bitrate bypasses it. |
|
||||
| `PUNKTFUNK_PYROWAVE_MAX_MBPS` | `N` (Mbps) | Cap the [PyroWave](/docs/pyrowave) Automatic bitrate pin, for a host on a link that the open-loop pin can outrun (e.g. 4:4:4 + HDR at 5120×1440@240 pins ~5.3 Gbps, over a 5GbE link). Unset = no cap. Applies to every PyroWave session — a client-requested bitrate is treated as Automatic under PyroWave, so nothing bypasses the ceiling. |
|
||||
| `PUNKTFUNK_DSCP` | `1` | Opt-in DSCP / `SO_PRIORITY` QoS tagging on the media sockets. No-op on the wire on Windows without a qWAVE policy. |
|
||||
| `PUNKTFUNK_OH264_THREADS` / `PUNKTFUNK_OH264_GOP` | `N` | Software (openh264) encoder tuning: encode threads (default 2 — latency over throughput) and GOP length in frames (unset = about ten minutes' worth, `fps × 600`; set `0` for encoder-auto). Only relevant with `PUNKTFUNK_ENCODER=software`. |
|
||||
| `PUNKTFUNK_MAX_FPS` | `N` (fps) *(default: no limit)* | **Frame limiter for the game** — how fast the compositor lets it render. It does *not* cap the stream: the client still negotiates and receives its full rate, because the encode loop re-encodes the held frame whenever the compositor produced no new one (an almost-empty P-frame). A 60-capped game on a 120 Hz session still sends 120 frames a second, and the GPU time the game gives up goes to capture and encode instead — and to heat and battery on a laptop or handheld. **gamescope only today**: it takes this as `--nested-refresh`, the rate it clamps the game to; that is the nested output's rate, so everything gamescope composites moves at it. Other compositors have no equivalent lever and ignore it. ⚠️ On gamescope that one number is also the refresh the session **reports**: Steam's in-session display settings and every game will read the display as `N` Hz, and a game that paces itself to the display will hold itself there. If you want a quieter box without games believing the panel changed, cap the client's requested refresh instead. |
|
||||
|
||||
Reference in New Issue
Block a user