Compare commits
10
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
00a9d16201 | ||
|
|
7246f0fe60 | ||
|
|
46d9e0d20f | ||
|
|
8e8451ca0c | ||
|
|
0e1bab019c | ||
|
|
7951d12b06 | ||
|
|
4690a166ca | ||
|
|
f60b6e30e2 | ||
|
|
4d155f4985 | ||
|
|
4b5f0dac6b |
@@ -1479,39 +1479,28 @@ public final class SessionAudio {
|
||||
"mic capture: \(Int(inFormat.sampleRate)) Hz, \(inChannels) ch, \(channelPlan)")
|
||||
#endif
|
||||
|
||||
// Encode a single mono bus (folded from `inFormat` in the tap): the resampler goes
|
||||
// Encode a single mono bus (folded from the tap's own buffer format): the resampler goes
|
||||
// mono@inputSR → the encoder's 48 kHz mono, so it handles the rate change and the
|
||||
// wrong-channel downmix never happens. Mono end to end — the host's decoder upmixes,
|
||||
// so the old duplicate-into-stereo step only cost bits and cycles.
|
||||
//
|
||||
// `mono`/`staging` are the per-callback scratch buffers, preallocated HERE (grown only
|
||||
// if a larger-than-expected device quantum ever arrives) — the steady-state tap path
|
||||
// allocates nothing.
|
||||
// `chain` carries the rate-dependent pieces INCLUDING the per-callback scratch buffers,
|
||||
// preallocated HERE for the rate the input currently reports — the steady-state tap path
|
||||
// allocates nothing. The tap rebuilds it if the device's real rate or quantum differ,
|
||||
// which is the price of installing the tap with the bus's own format (see below).
|
||||
let scratchFrames: AVAudioFrameCount = 8192
|
||||
let stagingCapacity = { (frames: AVAudioFrameCount) -> AVAudioFrameCount in
|
||||
AVAudioFrameCount(
|
||||
(Double(frames) * 48_000 / inFormat.sampleRate).rounded(.up)) + 64
|
||||
}
|
||||
guard let monoFormat = AVAudioFormat(
|
||||
commonFormat: .pcmFormatFloat32, sampleRate: inFormat.sampleRate,
|
||||
channels: 1, interleaved: false),
|
||||
let encoder = try? OpusEncoder(),
|
||||
let resampler = AVAudioConverter(from: monoFormat, to: encoder.pcmFormat),
|
||||
guard let encoder = try? OpusEncoder(),
|
||||
var chain = Self.micChain(
|
||||
rate: inFormat.sampleRate, frames: scratchFrames, to: encoder.pcmFormat),
|
||||
let chunk = AVAudioPCMBuffer(
|
||||
pcmFormat: encoder.pcmFormat, frameCapacity: encoder.framesPerPacket),
|
||||
let monoScratch = AVAudioPCMBuffer(
|
||||
pcmFormat: monoFormat, frameCapacity: scratchFrames),
|
||||
let stagingScratch = AVAudioPCMBuffer(
|
||||
pcmFormat: encoder.pcmFormat, frameCapacity: stagingCapacity(scratchFrames))
|
||||
pcmFormat: encoder.pcmFormat, frameCapacity: encoder.framesPerPacket)
|
||||
else {
|
||||
log.error("Opus encoder unavailable — mic uplink disabled")
|
||||
return false
|
||||
}
|
||||
|
||||
// Tap-thread-confined state: fold into `mono`, resample into `staging`, accumulate in
|
||||
// `fifo`, slice `framesPerPacket` (10 ms) chunks for the encoder.
|
||||
var mono = monoScratch
|
||||
var staging = stagingScratch
|
||||
// Tap-thread-confined state: fold into `chain.mono`, resample into `chain.staging`,
|
||||
// accumulate in `fifo`, slice `framesPerPacket` (10 ms) chunks for the encoder.
|
||||
var fifo: [Float] = []
|
||||
fifo.reserveCapacity(48_000)
|
||||
var seq: UInt32 = 0
|
||||
@@ -1533,22 +1522,32 @@ public final class SessionAudio {
|
||||
// 480 frames = 10 ms, matching the packet duration. Advisory — CoreAudio delivers the
|
||||
// device quantum whatever we ask (the old 2048 request came back as 42.7 ms bursts, most
|
||||
// of the uplink's latency) — but where the system honors it, the tap fires per-packet.
|
||||
input.installTap(onBus: 0, bufferSize: 480, format: inFormat) { buffer, _ in
|
||||
// `format: nil` — NOT the format read above. `installTap` validates a non-nil format
|
||||
// against the bus and raises an Objective-C exception on any mismatch; Swift cannot catch
|
||||
// that, so it aborts the process (SIGABRT in `AVAudioEngineGraph::InstallTapOnNode`). The
|
||||
// format was necessarily read a moment EARLIER, and on macOS the input can move underneath
|
||||
// it — a device switch, a clock/rate change, or the `setDevice` swap `startCapture` itself
|
||||
// performs two lines before this. `nil` means "whatever the bus emits", which is what the
|
||||
// chain wants anyway, and the mismatch cannot arise by construction. The tap then follows
|
||||
// the real format below.
|
||||
input.installTap(onBus: 0, bufferSize: 480, format: nil) { buffer, _ in
|
||||
if flag.isStopped { return }
|
||||
let frames = Int(buffer.frameLength)
|
||||
guard frames > 0, let src = buffer.floatChannelData else { return }
|
||||
if frames > Int(mono.frameCapacity) {
|
||||
// A quantum larger than the scratch (bufferSize is advisory both ways) — regrow
|
||||
// once to the new high-water mark; the steady state stays allocation-free.
|
||||
guard let biggerMono = AVAudioPCMBuffer(
|
||||
pcmFormat: monoFormat, frameCapacity: buffer.frameLength),
|
||||
let biggerStaging = AVAudioPCMBuffer(
|
||||
pcmFormat: encoder.pcmFormat,
|
||||
frameCapacity: stagingCapacity(buffer.frameLength))
|
||||
// Rebuild the rate-dependent chain when the device changes rate under a live tap
|
||||
// (resampling by the old ratio would pitch-shift the mic), and when a quantum larger
|
||||
// than the scratch arrives (`bufferSize` is advisory both ways) — regrown once to the
|
||||
// new high-water mark, so the steady state stays allocation-free.
|
||||
if buffer.format.sampleRate != chain.monoFormat.sampleRate
|
||||
|| buffer.frameLength > chain.mono.frameCapacity {
|
||||
guard let rebuilt = Self.micChain(
|
||||
rate: buffer.format.sampleRate,
|
||||
frames: max(buffer.frameLength, scratchFrames),
|
||||
to: encoder.pcmFormat)
|
||||
else { return }
|
||||
mono = biggerMono
|
||||
staging = biggerStaging
|
||||
chain = rebuilt
|
||||
}
|
||||
let mono = chain.mono, staging = chain.staging, resampler = chain.resampler
|
||||
guard let dst = mono.floatChannelData?[0] else { return }
|
||||
mono.frameLength = buffer.frameLength
|
||||
|
||||
@@ -1620,6 +1619,41 @@ public final class SessionAudio {
|
||||
return true
|
||||
}
|
||||
|
||||
/// The rate-dependent half of the mic chain: a mono bus at `rate`, the resampler from it onto
|
||||
/// the encoder's 48 kHz mono, and the two scratch buffers sized for `frames`. Grouped so the
|
||||
/// tap can swap all four together — they are only ever valid as a set.
|
||||
struct MicChain {
|
||||
let monoFormat: AVAudioFormat
|
||||
let resampler: AVAudioConverter
|
||||
let mono: AVAudioPCMBuffer
|
||||
let staging: AVAudioPCMBuffer
|
||||
}
|
||||
|
||||
/// Build a `MicChain` for `rate`, or nil if the rate is unusable or an allocation fails.
|
||||
/// Built once up front for the format the input reports, and again from the tap whenever the
|
||||
/// device's real rate differs — a macOS input can change rate under a live tap, and a chain
|
||||
/// pinned to the old rate resamples by the wrong ratio (a pitch-shifted mic).
|
||||
/// `internal` for unit testing: it needs no engine, device or permission.
|
||||
static func micChain(
|
||||
rate: Double, frames: AVAudioFrameCount, to pcmFormat: AVAudioFormat
|
||||
) -> MicChain? {
|
||||
// `staging` holds the resampled 48 kHz mono, so it must fit the UPWARD ratio from `rate`
|
||||
// (a 44.1 kHz quantum grows by ~1.088); +64 covers the converter's own slack.
|
||||
guard rate > 0, frames > 0,
|
||||
let monoFormat = AVAudioFormat(
|
||||
commonFormat: .pcmFormatFloat32, sampleRate: rate, channels: 1,
|
||||
interleaved: false),
|
||||
let resampler = AVAudioConverter(from: monoFormat, to: pcmFormat),
|
||||
let mono = AVAudioPCMBuffer(pcmFormat: monoFormat, frameCapacity: frames),
|
||||
let staging = AVAudioPCMBuffer(
|
||||
pcmFormat: pcmFormat,
|
||||
frameCapacity: AVAudioFrameCount(
|
||||
(Double(frames) * 48_000 / rate).rounded(.up)) + 64)
|
||||
else { return nil }
|
||||
return MicChain(
|
||||
monoFormat: monoFormat, resampler: resampler, mono: mono, staging: staging)
|
||||
}
|
||||
|
||||
/// Fold `channels` of input (`floatChannelData` layout: `interleaved` → one buffer strided by
|
||||
/// channel count; else one buffer per channel) down to a single mono bus in `out` (`frames`
|
||||
/// long). `pinned` (0-based, must be `< channels`) copies exactly that channel — the fix for a
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
// The rate-dependent half of the mic chain (SessionAudio.micChain). The tap now installs with
|
||||
// `format: nil` — a non-nil format is validated against the bus and raises an Objective-C
|
||||
// exception on mismatch, which Swift cannot catch, so it aborted the whole app (SIGABRT in
|
||||
// AVAudioEngineGraph::InstallTapOnNode, reported against 0.31.0). With nil the tap follows
|
||||
// whatever the bus emits, which means the chain has to be rebuildable at the device's real rate.
|
||||
// This pins the sizing arithmetic that rebuild depends on, without an engine, device or mic grant.
|
||||
|
||||
#if !os(tvOS)
|
||||
import AVFoundation
|
||||
import XCTest
|
||||
|
||||
@testable import PunktfunkKit
|
||||
|
||||
final class AudioMicChainTests: XCTestCase {
|
||||
/// The encoder's target: 48 kHz mono float — what every chain resamples ONTO.
|
||||
private let target = AVAudioFormat(
|
||||
commonFormat: .pcmFormatFloat32, sampleRate: 48_000, channels: 1, interleaved: false)!
|
||||
|
||||
/// A chain is built at the device's rate, mono, and resamples onto the 48 kHz encoder format.
|
||||
func testBuildsMonoChainAtDeviceRate() throws {
|
||||
let chain = try XCTUnwrap(
|
||||
SessionAudio.micChain(rate: 44_100, frames: 8192, to: target))
|
||||
XCTAssertEqual(chain.monoFormat.sampleRate, 44_100)
|
||||
XCTAssertEqual(chain.monoFormat.channelCount, 1)
|
||||
XCTAssertEqual(chain.mono.frameCapacity, 8192)
|
||||
XCTAssertEqual(chain.resampler.outputFormat.sampleRate, 48_000)
|
||||
}
|
||||
|
||||
/// `staging` holds the resampled 48 kHz mono, so it must fit the UPWARD ratio — the bug this
|
||||
/// guards is a staging buffer sized for the input rate, which silently truncates every packet
|
||||
/// when the device runs below 48 kHz.
|
||||
func testStagingFitsUpwardResampleRatio() throws {
|
||||
for rate in [8_000.0, 16_000, 44_100, 48_000, 96_000] {
|
||||
let chain = try XCTUnwrap(
|
||||
SessionAudio.micChain(rate: rate, frames: 1024, to: target))
|
||||
let needed = (1024.0 * 48_000 / rate).rounded(.up)
|
||||
XCTAssertGreaterThanOrEqual(
|
||||
Double(chain.staging.frameCapacity), needed,
|
||||
"staging too small to hold 1024 frames resampled from \(rate) Hz")
|
||||
}
|
||||
}
|
||||
|
||||
/// A rate the device cannot report is refused rather than producing a chain that would
|
||||
/// divide by zero in the staging arithmetic. The tap treats nil as "skip this buffer".
|
||||
func testRejectsUnusableRateAndEmptyQuantum() {
|
||||
XCTAssertNil(SessionAudio.micChain(rate: 0, frames: 8192, to: target))
|
||||
XCTAssertNil(SessionAudio.micChain(rate: -48_000, frames: 8192, to: target))
|
||||
XCTAssertNil(SessionAudio.micChain(rate: 48_000, frames: 0, to: target))
|
||||
}
|
||||
|
||||
/// The rebuild path: a device that switches 48 kHz → 44.1 kHz under a live tap yields a chain
|
||||
/// at the NEW rate. Resampling by the stale ratio is what pitch-shifts the mic.
|
||||
func testRebuildFollowsNewRate() throws {
|
||||
let first = try XCTUnwrap(SessionAudio.micChain(rate: 48_000, frames: 512, to: target))
|
||||
let second = try XCTUnwrap(SessionAudio.micChain(rate: 44_100, frames: 512, to: target))
|
||||
XCTAssertEqual(first.monoFormat.sampleRate, 48_000)
|
||||
XCTAssertEqual(second.monoFormat.sampleRate, 44_100)
|
||||
XCTAssertGreaterThan(second.staging.frameCapacity, first.staging.frameCapacity)
|
||||
}
|
||||
}
|
||||
#endif
|
||||
@@ -148,6 +148,11 @@ pub enum AppMsg {
|
||||
ended: Option<String>,
|
||||
tofu: bool,
|
||||
},
|
||||
/// Hand over to the gamepad console (`punktfunk-session --browse`) — the couch UI's
|
||||
/// door from the desktop shell.
|
||||
OpenConsole,
|
||||
/// The console child exited; `Some` carries why it ended badly.
|
||||
ConsoleExited(Option<String>),
|
||||
/// Request-access Cancel: the child was killed; release busy quietly.
|
||||
CancelPending,
|
||||
/// The speed-test dialog resolved (either way) — release `busy`.
|
||||
@@ -519,6 +524,51 @@ impl SimpleComponent for AppModel {
|
||||
))),
|
||||
}
|
||||
}
|
||||
AppMsg::OpenConsole => {
|
||||
if std::mem::replace(&mut self.busy, true) {
|
||||
return;
|
||||
}
|
||||
// The console owns the screen and the pads while it runs, so it takes `busy`
|
||||
// like a stream does. `gio::Subprocess` is the GLib-native child: its
|
||||
// `wait_check_async` lands the exit on this very main loop — no thread, no
|
||||
// channel — and reports a non-zero exit as an error. That is also how a
|
||||
// build without the session's `ui` feature (Nix) surfaces: the child prints
|
||||
// "--browse needs the console UI" and exits non-zero, and we banner it.
|
||||
let mut argv = vec![
|
||||
std::ffi::OsString::from(crate::spawn::session_binary()),
|
||||
"--browse".into(),
|
||||
];
|
||||
// Same knob a stream uses — the session also fullscreens itself on the Deck
|
||||
// and under gamescope regardless.
|
||||
if self.settings.borrow().fullscreen_on_stream {
|
||||
argv.push("--fullscreen".into());
|
||||
}
|
||||
let argv: Vec<&std::ffi::OsStr> =
|
||||
argv.iter().map(std::ffi::OsString::as_os_str).collect();
|
||||
match gio::Subprocess::newv(&argv, gio::SubprocessFlags::NONE) {
|
||||
Ok(child) => {
|
||||
let sender = sender.clone();
|
||||
child.wait_check_async(gio::Cancellable::NONE, move |res| {
|
||||
sender.input(AppMsg::ConsoleExited(res.err().map(|e| e.to_string())));
|
||||
});
|
||||
}
|
||||
Err(e) => {
|
||||
self.busy = false;
|
||||
self.hosts.emit(HostsMsg::ShowError(format!(
|
||||
"Couldn't start the console UI — {e}"
|
||||
)));
|
||||
}
|
||||
}
|
||||
}
|
||||
AppMsg::ConsoleExited(err) => {
|
||||
self.busy = false;
|
||||
// Quitting the console (B at its root) exits 0 and returns here silently.
|
||||
if let Some(e) = err {
|
||||
self.hosts
|
||||
.emit(HostsMsg::ShowError(format!("Console UI ended — {e}")));
|
||||
}
|
||||
self.hosts.emit(HostsMsg::Refresh);
|
||||
}
|
||||
AppMsg::CancelPending => {
|
||||
self.close_waiting();
|
||||
self.busy = false;
|
||||
@@ -1007,6 +1057,7 @@ fn install_actions(window: &adw::ApplicationWindow, sender: &ComponentSender<App
|
||||
window.add_action(&add("shortcuts", || AppMsg::ShowShortcuts));
|
||||
window.add_action(&add("about", || AppMsg::ShowAbout));
|
||||
window.add_action(&add("add-host", || AppMsg::ShowAddHost));
|
||||
window.add_action(&add("console", || AppMsg::OpenConsole));
|
||||
}
|
||||
|
||||
/// The Keyboard Shortcuts window — the SESSION window's keys (the shell itself has
|
||||
|
||||
@@ -859,7 +859,14 @@ impl SimpleComponent for HostsPage {
|
||||
rescan_btn.connect_clicked(move |_| sender.input(HostsMsg::Rescan));
|
||||
}
|
||||
header.pack_start(&rescan_btn);
|
||||
// The couch UI's front door, beside the page's other actions (same placement the
|
||||
// WinUI shell gives it). It was previously reachable only as `--browse` on the
|
||||
// command line, which is no way to find a mode.
|
||||
let console_btn = gtk::Button::from_icon_name("input-gaming-symbolic");
|
||||
console_btn.set_tooltip_text(Some("Console UI — the controller-driven couch interface"));
|
||||
console_btn.set_action_name(Some("win.console"));
|
||||
let menu = gio::Menu::new();
|
||||
menu.append(Some("Console UI"), Some("win.console"));
|
||||
menu.append(Some("Preferences"), Some("win.preferences"));
|
||||
menu.append(Some("Keyboard Shortcuts"), Some("win.shortcuts"));
|
||||
menu.append(Some("About Punktfunk"), Some("win.about"));
|
||||
@@ -869,7 +876,9 @@ impl SimpleComponent for HostsPage {
|
||||
.primary(true)
|
||||
.tooltip_text("Main menu")
|
||||
.build();
|
||||
// Packed after the menu so the hamburger stays rightmost (pack_end fills inward).
|
||||
header.pack_end(&menu_btn);
|
||||
header.pack_end(&console_btn);
|
||||
|
||||
let toolbar = adw::ToolbarView::new();
|
||||
toolbar.add_top_bar(&header);
|
||||
|
||||
@@ -19,7 +19,10 @@
|
||||
//! fat LAN never surfaces as loss/OWD/decode. Paired with the host's own climb refusal (a
|
||||
//! behind-cadence host acks climbs at the current rate) and short-ack cap learning
|
||||
//! ([`BitrateController::on_ack`]), this is what stops an Automatic session from driving the
|
||||
//! encoder off a cliff the network could carry.
|
||||
//! encoder off a cliff the network could carry. It is also the one signal that can fire for a
|
||||
//! reason the rate cannot fix (contention on the host's GPU), so it stands itself down when
|
||||
//! backing off stops helping, and a later clean run re-probes it — see
|
||||
//! [`ENCODE_NOOP_BACKOFFS_TO_DISARM`].
|
||||
//!
|
||||
//! AIMD shape: a SEVERE window (an unrecoverable frame, a flush, ≥6 % loss, or a decode-latency
|
||||
//! excursion far past baseline) backs off ×0.7 immediately; ordinary congestion
|
||||
@@ -126,10 +129,49 @@ const PROVEN_HEADROOM_DEN: u32 = 2;
|
||||
/// encode_us inflated by its retrieve-queue depth (~a frame), so an absolute budget threshold
|
||||
/// would read permanently-red and drive the rate to the floor; a rise above the session's own
|
||||
/// baseline survives that offset. ~half a 120 Hz frame budget of standing rise is real.
|
||||
///
|
||||
/// A FRAME BUDGET, not a fixed duration — the two constants here are the 120 Hz values, used
|
||||
/// only until [`set_frame_budget`](BitrateController::set_frame_budget) supplies the session's
|
||||
/// own (see [`BitrateController::encode_thresholds`]). Left absolute they encode a 120 Hz
|
||||
/// assumption into every session: at 60 Hz one frame is 16.7 ms, so an ordinary one-frame encode
|
||||
/// hiccup clears the SEVERE tier and takes the immediate ×0.7 where the same hiccup at 120 Hz
|
||||
/// (8.3 ms) does not even reach it. That asymmetry is a field report — a 1440p60 session ratcheted
|
||||
/// to the floor while 1440p120 sessions on the same host and client climbed to their shape ceiling.
|
||||
const ENCODE_RISE_US: i64 = 4_000;
|
||||
/// Host-encode latency this far above baseline (≈1.5 × a 120 Hz budget) is SEVERE — the encode
|
||||
/// queue is growing past the knee; skip the two-window confirmation.
|
||||
/// queue is growing past the knee; skip the two-window confirmation. Frame-budget-scaled like
|
||||
/// [`ENCODE_RISE_US`].
|
||||
const ENCODE_SEVERE_US: i64 = 12_000;
|
||||
/// Consecutive encode-attributed backoffs that did NOT bring host encode time down before the
|
||||
/// encode down-driver is disarmed for the session.
|
||||
///
|
||||
/// The signal's whole premise is that encode time is a function of the rate the controller can
|
||||
/// actuate: it exists to find the encoder's compute knee, where cutting the rate cuts the work.
|
||||
/// When the rise comes from something else on the GPU — a game saturating the card, which is
|
||||
/// exactly when the host is also behind cadence — the premise is false. The backoff changes
|
||||
/// nothing, the signal fires again, and [`on_ack`](BitrateController::on_ack)'s baseline re-seed
|
||||
/// erases the evidence that nothing improved, so the controller ratchets to the floor pulling the
|
||||
/// one lever that cannot work (the field case: 57 → 5 Mbps over ten minutes with zero packet loss,
|
||||
/// zero keyframe asks and a flat decoder).
|
||||
///
|
||||
/// So: remember the level each encode-attributed backoff fired at, and when the next one fires no
|
||||
/// lower, count it. Two in a row means the rate is not what is driving encode time here — stop
|
||||
/// letting it drive. Same shape as the clock-flush detector's
|
||||
/// [`crate::client::frame_channel::NOOP_CLOCK_FLUSHES_TO_DISARM`]: a signal whose remedy is
|
||||
/// demonstrably doing nothing should stand down rather than repeat forever.
|
||||
///
|
||||
/// Two, not one: a single pair of backoffs at a similar level is also what a real knee looks like
|
||||
/// while the rate is still above it, and the knee is the case this signal was built for.
|
||||
///
|
||||
/// And a stand-down, never a permanent disarm. Nothing this controller learns from evidence is
|
||||
/// permanent — both caps re-probe, and the clock-flush detector was itself changed from "off for
|
||||
/// the session" to re-armable for exactly this reason. GPU contention is transient by nature (the
|
||||
/// game exits to a menu, the shader storm ends), while what it silences is the only signal that
|
||||
/// can descend when the encoder is past its knee on a link that shows nothing. So a clean run
|
||||
/// re-arms it on the [`CAP_REPROBE_WINDOWS_MIN`] ladder, doubling each time the silence is
|
||||
/// immediately re-earned. The loss, OWD, decode and keyframe signals keep their full power
|
||||
/// throughout, and the host's own climb refusal stays the backstop for a genuine knee.
|
||||
const ENCODE_NOOP_BACKOFFS_TO_DISARM: u32 = 2;
|
||||
/// Clean windows parked at a learned cap before re-probing above it, and the ceiling that
|
||||
/// interval backs off to.
|
||||
///
|
||||
@@ -329,6 +371,30 @@ pub(crate) struct BitrateController {
|
||||
/// baseline like the decode signal. Cleared whenever OUR OWN rate decrease changes the
|
||||
/// encode regime (see [`on_ack`](Self::on_ack)) and on a mode switch.
|
||||
encode_means: VecDeque<i64>,
|
||||
/// This session's frame budget in µs (one refresh interval), the unit the encode thresholds
|
||||
/// are expressed in — see [`encode_thresholds`](Self::encode_thresholds). `None` = the mode
|
||||
/// was never plumbed in, and the 120 Hz constants stand exactly as before.
|
||||
frame_budget_us: Option<i64>,
|
||||
/// The window mean host-encode latency (µs) that drove the last encode-attributed backoff;
|
||||
/// `0` = none yet, or the streak was broken by a backoff something else drove.
|
||||
encode_backoff_us: i64,
|
||||
/// Consecutive encode-attributed backoffs after which encode time did NOT come down (see
|
||||
/// [`ENCODE_NOOP_BACKOFFS_TO_DISARM`]).
|
||||
encode_noop_backoffs: u32,
|
||||
/// The encode down-driver is stood down: its rises are not answering the rate, so they
|
||||
/// neither mark a window bad nor teach a baseline. Lifted by a clean run (see
|
||||
/// `encode_reprobe_after`) or a mode switch — never permanent, like every other piece of
|
||||
/// evidence-learned state here.
|
||||
encode_disarmed: bool,
|
||||
/// Clean windows since the stand-down, against `encode_reprobe_after`.
|
||||
encode_disarm_clean_windows: u32,
|
||||
/// Clean windows the stand-down must survive before the signal is re-armed. Doubles each
|
||||
/// time a re-armed signal is immediately silenced again, so a standing contention settles
|
||||
/// into a slow poll instead of thrashing ([`CAP_REPROBE_WINDOWS_MIN`]).
|
||||
encode_reprobe_after: u32,
|
||||
/// A stand-down has been lifted at least once, so the next one is re-silencing something the
|
||||
/// re-probe already tried — the trigger for backing that clock off.
|
||||
encode_rearmed: bool,
|
||||
/// The host-taught rate cap (§ABR overdrive): latched when the host acks BELOW what we
|
||||
/// asked twice consecutively at the same value — its encoder's codec-level ceiling, or a
|
||||
/// climb refusal while host encode can't hold cadence. Kept apart from `ceiling_kbps` so
|
||||
@@ -427,6 +493,13 @@ impl BitrateController {
|
||||
owd_means: VecDeque::with_capacity(BASELINE_WINDOWS),
|
||||
decode_means: VecDeque::with_capacity(BASELINE_WINDOWS),
|
||||
encode_means: VecDeque::with_capacity(BASELINE_WINDOWS),
|
||||
frame_budget_us: None,
|
||||
encode_backoff_us: 0,
|
||||
encode_noop_backoffs: 0,
|
||||
encode_disarmed: false,
|
||||
encode_disarm_clean_windows: 0,
|
||||
encode_reprobe_after: CAP_REPROBE_WINDOWS_MIN,
|
||||
encode_rearmed: false,
|
||||
host_cap_kbps: None,
|
||||
last_requested_kbps: None,
|
||||
short_ack_kbps: 0,
|
||||
@@ -485,6 +558,33 @@ impl BitrateController {
|
||||
self.stream_cap_kbps = Some(kbps);
|
||||
}
|
||||
|
||||
/// Teach the controller this session's refresh rate, so the encode thresholds can be sized in
|
||||
/// FRAME BUDGETS rather than the 120 Hz durations they were calibrated at (see
|
||||
/// [`ENCODE_RISE_US`]). Ignored for a nonsense rate — the defaults are the old behavior, which
|
||||
/// is the right answer when the mode is not known.
|
||||
pub(crate) fn set_frame_budget(&mut self, refresh_hz: u32) {
|
||||
if refresh_hz > 0 {
|
||||
self.frame_budget_us = Some(1_000_000 / refresh_hz as i64);
|
||||
}
|
||||
}
|
||||
|
||||
/// `(rise, severe)` for the host-encode signal: half a frame budget and one and a half of
|
||||
/// them, the shape [`ENCODE_RISE_US`] documents, against this session's actual budget.
|
||||
///
|
||||
/// Scales with the SESSION REFRESH, not with the rate the source actually delivers. A game
|
||||
/// rendering below refresh stretches the real budget further still (the host stretches its own
|
||||
/// cadence deadline by exactly that, `cadence_budget`), so a sub-refresh source can still
|
||||
/// present a one-frame hiccup above the severe tier — that residue is what
|
||||
/// [`ENCODE_NOOP_BACKOFFS_TO_DISARM`] is for. Deliberately not chased here: the client would
|
||||
/// have to infer the source period from arrival cadence, which is the same jitter the signal
|
||||
/// is trying to read through.
|
||||
fn encode_thresholds(&self) -> (i64, i64) {
|
||||
match self.frame_budget_us {
|
||||
Some(budget) => (budget / 2, budget * 3 / 2),
|
||||
None => (ENCODE_RISE_US, ENCODE_SEVERE_US),
|
||||
}
|
||||
}
|
||||
|
||||
/// The host's [`crate::quic::BitrateChanged`] ack: its clamp is authoritative for what the
|
||||
/// encoder now targets, and any ack proves the host renegotiates (resets the silence counter).
|
||||
///
|
||||
@@ -603,6 +703,16 @@ impl BitrateController {
|
||||
self.owd_means.clear();
|
||||
self.decode_means.clear();
|
||||
self.encode_means.clear();
|
||||
// The encode down-driver's disarm is mode-scoped like everything else here: the new mode
|
||||
// is a different amount of encode work per frame, so a rate that could not move encode
|
||||
// time under the old one says nothing about this one. Re-arm and let it prove itself
|
||||
// again. (The caller re-sizes the frame budget for the new refresh alongside this.)
|
||||
self.encode_disarmed = false;
|
||||
self.encode_backoff_us = 0;
|
||||
self.encode_noop_backoffs = 0;
|
||||
self.encode_disarm_clean_windows = 0;
|
||||
self.encode_reprobe_after = CAP_REPROBE_WINDOWS_MIN;
|
||||
self.encode_rearmed = false;
|
||||
self.proven_kbps = 0;
|
||||
}
|
||||
|
||||
@@ -684,11 +794,17 @@ impl BitrateController {
|
||||
// frame describe what reached the CLIENT, and they mean the same thing however little
|
||||
// flowed — the periodic-capture-stall case (see [`STARVED_DELIVERY_DIV`]) still backs off
|
||||
// on one window, as its tests require.
|
||||
//
|
||||
// Withheld the same way once the signal has DISARMED itself (see
|
||||
// [`ENCODE_NOOP_BACKOFFS_TO_DISARM`]): a rise the rate has twice failed to answer is not
|
||||
// evidence about the rate, so it must neither mark a window bad nor teach a baseline.
|
||||
let (encode_rise_us, encode_severe_us) = self.encode_thresholds();
|
||||
let encode_usable = !starved && !self.encode_disarmed;
|
||||
let (encode_bad, encode_severe) = score_baseline(
|
||||
&mut self.encode_means,
|
||||
encode_mean_us.filter(|_| !starved),
|
||||
ENCODE_RISE_US,
|
||||
ENCODE_SEVERE_US,
|
||||
encode_mean_us.filter(|_| encode_usable),
|
||||
encode_rise_us,
|
||||
encode_severe_us,
|
||||
);
|
||||
// SEVERE = the user already saw damage (an unrecoverable frame, a jump-to-live flush, a
|
||||
// deep decode-latency excursion, a window spent begging for keyframes) or loss far past
|
||||
@@ -782,6 +898,42 @@ impl BitrateController {
|
||||
}
|
||||
}
|
||||
}
|
||||
// The encode down-driver's stand-down re-probes on the same clock, for the same reason
|
||||
// the two caps do: it is EVIDENCE, not a spec limit. What silenced it — a game
|
||||
// saturating the GPU, a shader-compile storm, another app on the card — is exactly the
|
||||
// sort of thing that ENDS mid-session, and what it silences is the only signal that can
|
||||
// descend when the encoder is genuinely past its compute knee on a link that shows
|
||||
// nothing. Left permanent, one contended stretch would strip that protection from every
|
||||
// later minute of the session, including the calm ones where a climb can reach a rate
|
||||
// the ASIC cannot hold.
|
||||
//
|
||||
// A clean run is the cheapest moment to ask again: nothing else is unhappy, so if the
|
||||
// rate still cannot move encode time, two more no-op backoffs stand it down again at a
|
||||
// bounded cost — while the doubling interval keeps a genuinely standing contention from
|
||||
// thrashing. The asymmetry decides it: a too-eager re-arm costs one ×0.7, a too-permanent
|
||||
// silence costs the knee protection outright.
|
||||
if self.encode_disarmed {
|
||||
if bad {
|
||||
self.encode_disarm_clean_windows = 0;
|
||||
} else {
|
||||
self.encode_disarm_clean_windows += 1;
|
||||
if self.encode_disarm_clean_windows >= self.encode_reprobe_after {
|
||||
self.encode_disarmed = false;
|
||||
self.encode_rearmed = true;
|
||||
self.encode_disarm_clean_windows = 0;
|
||||
// Re-arm on a FRESH baseline and with no streak carried over: the level the
|
||||
// old backoffs fired at describes a regime that has since been clean for
|
||||
// seconds, so it is not the reference the next one should be judged against.
|
||||
self.encode_backoff_us = 0;
|
||||
self.encode_noop_backoffs = 0;
|
||||
self.encode_means.clear();
|
||||
tracing::debug!(
|
||||
after_windows = self.encode_reprobe_after,
|
||||
"adaptive bitrate: re-arming the encode down-driver after a clean run"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
let cooled = self
|
||||
.last_change
|
||||
.is_none_or(|t| now.duration_since(t) >= CHANGE_COOLDOWN);
|
||||
@@ -875,6 +1027,56 @@ impl BitrateController {
|
||||
} else {
|
||||
self.decode_backoff_kbps = 0;
|
||||
}
|
||||
// Encode attribution (see [`ENCODE_NOOP_BACKOFFS_TO_DISARM`]): did the LAST
|
||||
// encode-driven backoff buy anything? Judged from the level this one fires at, not
|
||||
// from the baseline — `on_ack` re-seeded that after the last decrease, so the firing
|
||||
// level is the only surviving record of what encode time did in between. Network
|
||||
// distress disqualifies the attribution: loss, a flush or a dropped frame explain the
|
||||
// backoff without the encoder, and cutting the rate genuinely is the remedy for those.
|
||||
let encode_attributed = (encode_severe || encode_bad)
|
||||
&& dropped == 0
|
||||
&& !flushed
|
||||
&& loss_ppm < HEAVY_LOSS_PPM;
|
||||
if let Some(mean) = encode_mean_us.filter(|_| encode_attributed) {
|
||||
if self.encode_backoff_us > 0
|
||||
&& mean >= self.encode_backoff_us.saturating_sub(encode_rise_us)
|
||||
{
|
||||
// Fired again no lower than last time: the ×0.7 in between did nothing.
|
||||
self.encode_noop_backoffs += 1;
|
||||
if self.encode_noop_backoffs >= ENCODE_NOOP_BACKOFFS_TO_DISARM {
|
||||
// Re-silencing something the re-probe had already lifted means the
|
||||
// contention is STANDING, not the transient the re-probe exists to ride
|
||||
// out — back its clock off, exactly as both learned caps do.
|
||||
self.encode_reprobe_after = if self.encode_rearmed {
|
||||
self.encode_reprobe_after
|
||||
.saturating_mul(2)
|
||||
.min(CAP_REPROBE_WINDOWS_MAX)
|
||||
} else {
|
||||
CAP_REPROBE_WINDOWS_MIN
|
||||
};
|
||||
self.encode_disarmed = true;
|
||||
self.encode_disarm_clean_windows = 0;
|
||||
self.encode_means.clear();
|
||||
tracing::info!(
|
||||
at_kbps = self.current_kbps,
|
||||
encode_mean_us = mean,
|
||||
noop_backoffs = self.encode_noop_backoffs,
|
||||
rearm_after_windows = self.encode_reprobe_after,
|
||||
"adaptive bitrate: host encode time is not answering the rate — \
|
||||
standing the encode down-driver down until a clean run re-probes it \
|
||||
(loss, OWD, decode and keyframe signals keep driving)"
|
||||
);
|
||||
}
|
||||
} else {
|
||||
self.encode_noop_backoffs = 0;
|
||||
}
|
||||
self.encode_backoff_us = mean;
|
||||
} else {
|
||||
// Something else drove this one: the encode streak is broken, and the level the
|
||||
// next encode-driven backoff would have to beat no longer means anything.
|
||||
self.encode_backoff_us = 0;
|
||||
self.encode_noop_backoffs = 0;
|
||||
}
|
||||
self.climb_since_backoff = false;
|
||||
let next = ((self.current_kbps as u64 * 7 / 10) as u32).max(self.floor_kbps);
|
||||
self.bad_windows = 0;
|
||||
@@ -2178,6 +2380,283 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
/// One encode-attributed choke: re-seed the baseline `on_ack` cleared, then present `level`
|
||||
/// again — the shape of an encoder held up by something the last ×0.7 did nothing about.
|
||||
/// Four seed windows is under [`CLEAN_WINDOWS_TO_INCREASE`], so no cycle can climb its way
|
||||
/// out from under the test.
|
||||
fn encode_choke(
|
||||
c: &mut BitrateController,
|
||||
start: Instant,
|
||||
tick: &mut u32,
|
||||
level: i64,
|
||||
) -> Option<u32> {
|
||||
for _ in 0..BASELINE_MIN_WINDOWS {
|
||||
let at = ticks(start, *tick);
|
||||
*tick += 1;
|
||||
// Seed windows are clean by construction; ack a climb if the controller takes one, so
|
||||
// the helper stays usable in tests that leave climb headroom below the ceiling.
|
||||
if let Some(k) = c.on_window(
|
||||
at,
|
||||
0,
|
||||
0,
|
||||
Some(10_000),
|
||||
None,
|
||||
Some(7_000),
|
||||
1_000_000,
|
||||
false,
|
||||
0,
|
||||
) {
|
||||
c.on_ack(k);
|
||||
}
|
||||
}
|
||||
let at = ticks(start, *tick);
|
||||
*tick += 1;
|
||||
c.on_window(
|
||||
at,
|
||||
0,
|
||||
0,
|
||||
Some(10_000),
|
||||
None,
|
||||
Some(level),
|
||||
1_000_000,
|
||||
false,
|
||||
0,
|
||||
)
|
||||
}
|
||||
|
||||
/// `n` clean windows carrying no encode sample, acking any climb the controller takes.
|
||||
fn clean_run(c: &mut BitrateController, start: Instant, tick: &mut u32, n: u32) {
|
||||
for _ in 0..n {
|
||||
let at = ticks(start, *tick);
|
||||
*tick += 1;
|
||||
if let Some(k) = c.on_window(at, 0, 0, Some(10_000), None, None, 1_000_000, false, 0) {
|
||||
c.on_ack(k);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Drive the field ratchet: encode-attributed backoffs at a level the ×0.7s never move, until
|
||||
/// the signal stands down.
|
||||
fn disarm_encode(c: &mut BitrateController, start: Instant, tick: &mut u32) {
|
||||
for _ in 0..=ENCODE_NOOP_BACKOFFS_TO_DISARM {
|
||||
let verdict = encode_choke(c, start, tick, 20_000);
|
||||
c.on_ack(verdict.expect("an unanswered encode rise must back off"));
|
||||
}
|
||||
assert!(c.encode_disarmed);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_stood_down_encode_signal_re_arms_after_a_clean_run() {
|
||||
// The stand-down is EVIDENCE, not a spec limit, and what it answers — contention on the
|
||||
// host's GPU — is exactly the sort of thing that ends mid-session. Left permanent, one
|
||||
// contended stretch would strip the knee down-driver from every calm minute that follows,
|
||||
// including the ones where a climb can reach a rate the ASIC cannot hold.
|
||||
let mut c = BitrateController::new(20_000);
|
||||
let start = Instant::now();
|
||||
let mut tick = 0;
|
||||
disarm_encode(&mut c, start, &mut tick);
|
||||
assert_eq!(c.encode_reprobe_after, CAP_REPROBE_WINDOWS_MIN);
|
||||
|
||||
// A short clean spell is not enough — the re-probe is a run, not a blip.
|
||||
clean_run(&mut c, start, &mut tick, CAP_REPROBE_WINDOWS_MIN - 1);
|
||||
assert!(c.encode_disarmed);
|
||||
clean_run(&mut c, start, &mut tick, 1);
|
||||
assert!(!c.encode_disarmed);
|
||||
|
||||
// And it really drives again: a fresh excursion backs the rate off.
|
||||
assert!(encode_choke(&mut c, start, &mut tick, 40_000).is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_standing_contention_backs_the_re_arm_clock_off() {
|
||||
// A re-armed signal silenced again means the contention is STANDING, not the transient
|
||||
// the re-probe rides out. Same answer both caps give: poll it slowly rather than either
|
||||
// giving up forever or thrashing every twelve seconds.
|
||||
//
|
||||
// Started high enough that two full ratchets stay clear of the floor — a rate pinned at
|
||||
// `FLOOR_KBPS` stops backing off at all, which would starve the second stand-down of the
|
||||
// backoffs it is counted from.
|
||||
let mut c = BitrateController::new(200_000);
|
||||
let start = Instant::now();
|
||||
let mut tick = 0;
|
||||
disarm_encode(&mut c, start, &mut tick);
|
||||
clean_run(&mut c, start, &mut tick, CAP_REPROBE_WINDOWS_MIN);
|
||||
assert!(!c.encode_disarmed);
|
||||
// Re-armed, and the contention is still there.
|
||||
disarm_encode(&mut c, start, &mut tick);
|
||||
assert_eq!(c.encode_reprobe_after, CAP_REPROBE_WINDOWS_MIN * 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_bad_window_restarts_the_re_arm_run() {
|
||||
// The re-probe wants a genuinely quiet stretch: a window the network spoiled says nothing
|
||||
// about whether the encoder would answer the rate now, so the run starts over.
|
||||
let mut c = BitrateController::new(20_000);
|
||||
let start = Instant::now();
|
||||
let mut tick = 0;
|
||||
disarm_encode(&mut c, start, &mut tick);
|
||||
clean_run(&mut c, start, &mut tick, CAP_REPROBE_WINDOWS_MIN - 1);
|
||||
let at = ticks(start, tick);
|
||||
tick += 1;
|
||||
// A flush: severe, so it also costs a ×0.7 — and it resets the clean run behind it.
|
||||
assert!(c
|
||||
.on_window(at, 0, 0, Some(10_000), None, None, 1_000_000, true, 0)
|
||||
.is_some());
|
||||
clean_run(&mut c, start, &mut tick, CAP_REPROBE_WINDOWS_MIN - 1);
|
||||
assert!(c.encode_disarmed, "the spoiled window must restart the run");
|
||||
clean_run(&mut c, start, &mut tick, 1);
|
||||
assert!(!c.encode_disarmed);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_encode_thresholds_follow_the_session_frame_budget() {
|
||||
// The 1440p60-vs-1440p120 field asymmetry. One frame of encode delay is 8.3 ms at 120 Hz
|
||||
// and 16.7 ms at 60 Hz, so against FIXED thresholds the 60 Hz session takes the immediate
|
||||
// ×0.7 for the same physical hiccup the 120 Hz one shrugs off. Sized in frame budgets,
|
||||
// both treat it the same way: ordinary, and confirmed by a second window.
|
||||
let excursion = 23_700; // 7 ms baseline + ~one 60 Hz frame
|
||||
let mut hz120 = BitrateController::new(20_000);
|
||||
hz120.set_frame_budget(120);
|
||||
let mut tick = 0;
|
||||
let start = Instant::now();
|
||||
assert_eq!(
|
||||
encode_choke(&mut hz120, start, &mut tick, excursion),
|
||||
Some(14_000),
|
||||
"at 120 Hz that is ~2.8 frame budgets over baseline — severe, one window"
|
||||
);
|
||||
|
||||
let mut hz60 = BitrateController::new(20_000);
|
||||
hz60.set_frame_budget(60);
|
||||
let mut tick = 0;
|
||||
assert_eq!(
|
||||
encode_choke(&mut hz60, start, &mut tick, excursion),
|
||||
None,
|
||||
"the same excursion is ~1 frame budget at 60 Hz — bad, but not severe"
|
||||
);
|
||||
// Confirmed by a second window, it still backs off — the signal is not weakened, only
|
||||
// re-scaled.
|
||||
let at = ticks(start, tick + 1);
|
||||
assert_eq!(
|
||||
hz60.on_window(
|
||||
at,
|
||||
0,
|
||||
0,
|
||||
Some(10_000),
|
||||
None,
|
||||
Some(excursion),
|
||||
1_000_000,
|
||||
false,
|
||||
0
|
||||
),
|
||||
Some(14_000)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unactuatable_encode_rises_disarm_the_down_driver() {
|
||||
// The field ratchet (2026-08-22): a game saturating the GPU holds host encode time up,
|
||||
// the client reads it as the compute knee, and every ×0.7 changes nothing — 57 Mbps to
|
||||
// the floor over ten minutes with zero loss, zero keyframe asks and a flat decoder.
|
||||
// `on_ack` re-seeds the encode baseline after each decrease, so nothing in the signal
|
||||
// itself ever notices that the backoffs are not working. The firing LEVEL does.
|
||||
let mut c = BitrateController::new(20_000);
|
||||
let start = Instant::now();
|
||||
let mut tick = 0;
|
||||
|
||||
// First one is a legitimate knee sample — nothing has been learned yet.
|
||||
assert_eq!(encode_choke(&mut c, start, &mut tick, 20_000), Some(14_000));
|
||||
c.on_ack(14_000);
|
||||
// Fires again no lower: the first ×0.7 bought nothing. One no-op is not a verdict — a
|
||||
// real knee still above the current rate looks exactly like this.
|
||||
assert_eq!(encode_choke(&mut c, start, &mut tick, 20_000), Some(9_800));
|
||||
c.on_ack(9_800);
|
||||
assert_eq!(c.encode_noop_backoffs, 1);
|
||||
assert!(!c.encode_disarmed);
|
||||
// Twice in a row ⇒ the rate is not the lever. This backoff still lands (the window was
|
||||
// judged before the verdict), and it is the last one this signal drives until a clean run
|
||||
// re-probes it (`a_stood_down_encode_signal_re_arms_after_a_clean_run`).
|
||||
assert_eq!(encode_choke(&mut c, start, &mut tick, 20_000), Some(6_860));
|
||||
c.on_ack(6_860);
|
||||
assert!(c.encode_disarmed);
|
||||
|
||||
// The ratchet stops: the same excursion no longer moves the rate…
|
||||
assert_eq!(encode_choke(&mut c, start, &mut tick, 20_000), None);
|
||||
// …and the session climbs back out instead of parking at the floor.
|
||||
c.set_ceiling(200_000);
|
||||
assert!(
|
||||
run_clean(&mut c, start, tick, 8).is_some_and(|k| k > 6_860),
|
||||
"a disarmed encode signal must not keep the session pinned"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_encode_backoff_that_helps_keeps_the_down_driver_armed() {
|
||||
// The knee this signal was built for: the ×0.7 lands nearer it and encode time genuinely
|
||||
// comes down, so the next excursion is a fresh event rather than evidence that the rate
|
||||
// is the wrong lever. Nothing here may disarm.
|
||||
let mut c = BitrateController::new(20_000);
|
||||
let start = Instant::now();
|
||||
let mut tick = 0;
|
||||
assert_eq!(encode_choke(&mut c, start, &mut tick, 40_000), Some(14_000));
|
||||
c.on_ack(14_000);
|
||||
assert_eq!(encode_choke(&mut c, start, &mut tick, 22_000), Some(9_800));
|
||||
c.on_ack(9_800);
|
||||
assert_eq!(c.encode_noop_backoffs, 0);
|
||||
assert!(!c.encode_disarmed);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_network_driven_backoff_breaks_the_encode_streak() {
|
||||
// Loss, a flush or a dropped frame explain a backoff without the encoder — and cutting
|
||||
// the rate genuinely IS the remedy for those. Such a window must not count toward the
|
||||
// disarm, even when encode time happens to be elevated in it too.
|
||||
let mut c = BitrateController::new(20_000);
|
||||
let start = Instant::now();
|
||||
let mut tick = 0;
|
||||
assert_eq!(encode_choke(&mut c, start, &mut tick, 20_000), Some(14_000));
|
||||
c.on_ack(14_000);
|
||||
assert_eq!(c.encode_backoff_us, 20_000);
|
||||
// Re-seed so the encode signal is live again…
|
||||
for _ in 0..BASELINE_MIN_WINDOWS {
|
||||
let at = ticks(start, tick);
|
||||
tick += 1;
|
||||
assert_eq!(
|
||||
c.on_window(
|
||||
at,
|
||||
0,
|
||||
0,
|
||||
Some(10_000),
|
||||
None,
|
||||
Some(7_000),
|
||||
1_000_000,
|
||||
false,
|
||||
0
|
||||
),
|
||||
None
|
||||
);
|
||||
}
|
||||
// …then a window carrying BOTH an encode excursion and a jump-to-live flush. The flush is
|
||||
// the explanation, so the encode streak resets rather than advancing toward a disarm.
|
||||
let at = ticks(start, tick);
|
||||
assert_eq!(
|
||||
c.on_window(
|
||||
at,
|
||||
0,
|
||||
0,
|
||||
Some(10_000),
|
||||
None,
|
||||
Some(20_000),
|
||||
1_000_000,
|
||||
true,
|
||||
0
|
||||
),
|
||||
Some(9_800)
|
||||
);
|
||||
assert_eq!(c.encode_backoff_us, 0);
|
||||
assert_eq!(c.encode_noop_backoffs, 0);
|
||||
assert!(!c.encode_disarmed);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn env_max_mbps_caps_every_learned_ceiling() {
|
||||
// PUNKTFUNK_ABR_MAX_MBPS=50 (injected — `new` reads the env exactly once, at
|
||||
|
||||
@@ -97,6 +97,12 @@ pub(super) async fn run_pump(args: WorkerArgs) {
|
||||
negotiated.bit_depth,
|
||||
negotiated.chroma_format,
|
||||
);
|
||||
// This session's frame budget, the unit the ABR's host-encode thresholds are expressed in
|
||||
// (see [`crate::abr::BitrateController::encode_thresholds`]). The NEGOTIATED refresh, not the
|
||||
// requested one — `mode_slot` still holds the request until the connect handshake seeds it,
|
||||
// and a host that answered 60 to a 120 ask is exactly the session that must not be scored
|
||||
// against a 120 Hz budget.
|
||||
let refresh_hz = negotiated.mode.refresh_hz;
|
||||
// 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);
|
||||
@@ -179,6 +185,9 @@ pub(super) async fn run_pump(args: WorkerArgs) {
|
||||
|
||||
// Control task (see [`control_task`]): the handshake stream stays open for mid-stream
|
||||
// renegotiation, speed tests, clock re-sync, and clipboard metadata.
|
||||
// The data pump re-reads the accepted mode when `mode_gen` moves, to re-size the ABR's
|
||||
// frame-budget-scaled encode thresholds for the new refresh.
|
||||
let mode_slot_pump = mode_slot.clone();
|
||||
tokio::spawn(
|
||||
control_task::ControlTask {
|
||||
ctrl_rx,
|
||||
@@ -271,6 +280,8 @@ pub(super) async fn run_pump(args: WorkerArgs) {
|
||||
resolved_bitrate_kbps,
|
||||
negotiated_codec,
|
||||
stream_cap_kbps,
|
||||
refresh_hz,
|
||||
mode_slot: mode_slot_pump,
|
||||
};
|
||||
let _ = tokio::task::spawn_blocking(move || pump.run()).await;
|
||||
|
||||
|
||||
@@ -44,6 +44,12 @@ pub(super) struct DataPump {
|
||||
/// [`crate::abr::stream_ceiling_kbps`]) — the bound the probe-measured link ceiling is held
|
||||
/// 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`]).
|
||||
pub(super) refresh_hz: u32,
|
||||
/// The accepted mode, written by the control task on a mode switch — read when `mode_gen`
|
||||
/// moves so the frame budget follows the new refresh.
|
||||
pub(super) mode_slot: Arc<Mutex<crate::config::Mode>>,
|
||||
}
|
||||
|
||||
impl DataPump {
|
||||
@@ -69,6 +75,8 @@ impl DataPump {
|
||||
resolved_bitrate_kbps,
|
||||
negotiated_codec,
|
||||
stream_cap_kbps,
|
||||
refresh_hz,
|
||||
mode_slot: pump_mode_slot,
|
||||
} = self;
|
||||
pin_thread_user_interactive(); // feeds the frame channel → the user-interactive video pump
|
||||
register_hot_tid(&pump_hot_tids); // this thread does UDP receive + FEC reassembly — hint it
|
||||
@@ -121,6 +129,10 @@ impl DataPump {
|
||||
// no inter-coded stream benefits from — the field session walked to 657 Mbps for 1440p120
|
||||
// and drove the client's decode latency from 0.8 ms to 10 ms getting there.
|
||||
abr.set_stream_cap(stream_cap_kbps);
|
||||
// Size the host-encode thresholds in this session's frame budgets rather than the 120 Hz
|
||||
// durations they were calibrated at — a 60 Hz session otherwise takes the SEVERE
|
||||
// one-window ×0.7 on an ordinary one-frame encode hiccup.
|
||||
abr.set_frame_budget(refresh_hz);
|
||||
// Startup link-capacity probe (Automatic sessions): the controller's ceiling is the
|
||||
// negotiated start rate — the conservative 20 Mbps default, historically a box Automatic
|
||||
// could NEVER climb out of. One speed-test burst shortly after the stream settles
|
||||
@@ -538,6 +550,10 @@ impl DataPump {
|
||||
if mg != seen_mode_gen {
|
||||
seen_mode_gen = mg;
|
||||
abr.on_mode_switch();
|
||||
// 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(pump_mode_slot.lock().unwrap().refresh_hz);
|
||||
}
|
||||
if let Some(acked) = bitrate_ack.lock().unwrap().take() {
|
||||
abr.on_ack(acked);
|
||||
@@ -1055,6 +1071,12 @@ mod tests {
|
||||
resolved_bitrate_kbps: 20_000,
|
||||
negotiated_codec: crate::quic::CODEC_HEVC,
|
||||
stream_cap_kbps: 100_000,
|
||||
refresh_hz: 60,
|
||||
mode_slot: Arc::new(Mutex::new(crate::config::Mode {
|
||||
width: 1920,
|
||||
height: 1080,
|
||||
refresh_hz: 60,
|
||||
})),
|
||||
};
|
||||
let started = Instant::now();
|
||||
let pump_thread = std::thread::spawn(move || pump.run());
|
||||
|
||||
@@ -821,6 +821,7 @@ fn on_receive(
|
||||
// session without the GAMEPAD grant never creates a uinput node or a pad-audio streamer,
|
||||
// because the creating event never arrives.
|
||||
if let Some(gp) = super::gamepad::decode(&pt) {
|
||||
crate::sleep_inhibit::note_input();
|
||||
if permitted(grants, GrantClass::Gamepad, drops) {
|
||||
pads.handle(&gp);
|
||||
}
|
||||
@@ -831,6 +832,7 @@ fn on_receive(
|
||||
// pen drives this session's virtual tablet; touch forwards as ordinary wire touches.
|
||||
// Pointer-class by construction (the plane tag decides, like the native pen plane).
|
||||
if let Some(p) = super::input::decode_pointer(&pt) {
|
||||
crate::sleep_inhibit::note_input();
|
||||
if permitted(grants, GrantClass::Pointer, drops) {
|
||||
pointer.apply(&p, |ev| {
|
||||
let _ = inj_tx.send(ev);
|
||||
@@ -862,6 +864,10 @@ fn on_receive(
|
||||
if events.is_empty() {
|
||||
return; // keepalive / QoS / unhandled input kind
|
||||
}
|
||||
// A Moonlight guest is driving the box — drop any standing suspend veto so their own "Sleep"
|
||||
// reaches logind (see `sleep_inhibit`). Past the `is_empty` gate on purpose: a keepalive is
|
||||
// the one thing a passive viewer DOES send, and it must not read as someone being there.
|
||||
crate::sleep_inhibit::note_input();
|
||||
|
||||
// Forward to the dedicated injector thread (it opens the backend on the first event and
|
||||
// coalesces redundant motion) — each event past one mask test against the exhaustive
|
||||
|
||||
@@ -962,7 +962,15 @@ pub(super) fn input_thread(
|
||||
} else {
|
||||
pads.feedback_poll_interval()
|
||||
};
|
||||
match rx.recv_timeout(poll) {
|
||||
let arrived = rx.recv_timeout(poll);
|
||||
// Every plane's input funnels through here, so this is where the box learns someone is
|
||||
// driving it: any arrival drops a standing suspend veto, so the next press being "Sleep"
|
||||
// in Steam's power menu reaches logind instead of being refused (see `sleep_inhibit`).
|
||||
// Stamped before the grant tests below — a denied event still means a person is there.
|
||||
if arrived.is_ok() {
|
||||
crate::sleep_inhibit::note_input();
|
||||
}
|
||||
match arrived {
|
||||
// Rich input (touchpad / motion) is applied the moment it arrives; the single channel
|
||||
// wakes for gyro samples instead of making them wait out the feedback poll interval.
|
||||
// Guarded on the pad grant like every gamepad arm below — see the `grants` parameter.
|
||||
|
||||
@@ -1,19 +1,56 @@
|
||||
//! Session-scoped suspend/idle inhibition: while at least one client is streaming, the host
|
||||
//! holds a logind `sleep:idle` BLOCK inhibitor so the box doesn't auto-suspend out from under a
|
||||
//! passive viewer. Remote INPUT resets the compositor's idle timers, but a video-only viewer
|
||||
//! sends none — observed live on a SteamOS Game-Mode host, which s2idled mid-stream-day and
|
||||
//! dropped off the network (and, in a VM with GPU passthrough, never woke again). Refcounted
|
||||
//! across planes (native sessions + GameStream media): the first hold acquires, the last drop
|
||||
//! releases. Best-effort — no logind (containers, non-systemd boxes) logs once and streams on.
|
||||
//! Off Linux this is a no-op: macOS/Windows hosts manage their own power assertions.
|
||||
//! Session-scoped suspend/idle inhibition: while at least one client is streaming **and is not
|
||||
//! sending input**, the host holds a logind `sleep:idle` BLOCK inhibitor so the box doesn't
|
||||
//! auto-suspend out from under a passive viewer. Remote INPUT resets the compositor's idle timers,
|
||||
//! but a video-only viewer sends none — observed live on a SteamOS Game-Mode host, which s2idled
|
||||
//! mid-stream-day and dropped off the network (and, in a VM with GPU passthrough, never woke
|
||||
//! again). Refcounted across planes (native sessions + GameStream media): the first hold acquires,
|
||||
//! the last drop releases. Best-effort — no logind (containers, non-systemd boxes) logs once and
|
||||
//! streams on. Off Linux this is a no-op: macOS/Windows hosts manage their own power assertions.
|
||||
//!
|
||||
//! **The quiet gate is the point, and it is not an optimisation.** A `block` lock on `sleep`
|
||||
//! refuses EVERY suspend, not just the idle timer's: "Sleep" in Steam's Big Picture power menu
|
||||
//! reaches logind as exactly the same `Suspend()` call, and logind answers the person who pressed
|
||||
//! it with `Operation inhibited by "Punktfunk" (…), reason is "a client is streaming"` — silently,
|
||||
//! because nothing in that UI surfaces a D-Bus error. Held unconditionally for the length of a
|
||||
//! stream (as it was from 2026-07-22 to this commit), the lock made a host impossible to put to
|
||||
//! sleep from the machine's own screen for as long as anyone was watching it. Reproduced verbatim
|
||||
//! on a Bazzite box, 2026-08-24.
|
||||
//!
|
||||
//! So the veto is held only while the stream is QUIET. Any client input ([`note_input`]) drops it
|
||||
//! **synchronously** — releasing is a `close(2)` on the inhibitor fd, no round trip, so a Sleep
|
||||
//! press cannot race it — and it is re-taken only after [`QUIET_BEFORE_VETO`] of silence. That is
|
||||
//! the same line the original justification already drew ("a video-only viewer sends none"): a
|
||||
//! person choosing Sleep is, by definition, sending input, and a passive viewer never does.
|
||||
//!
|
||||
//! What this deliberately does NOT cover is a local suspend request typed at a box that a passive
|
||||
//! viewer is streaming from — the veto is still standing, so it is still refused. That case wants
|
||||
//! a person-vs-timer signal we do not have, and the remote viewer's claim on the box is at least
|
||||
//! arguable. `ponytail:` if it turns up in the field, the lever is a config knob, not a heuristic.
|
||||
|
||||
use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
|
||||
use std::sync::{Mutex, OnceLock};
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
/// How long a stream must go without client input before the host vetoes suspend on its behalf.
|
||||
/// Comfortably under every idle-suspend timer worth catching (Steam's shortest offer is 5 min,
|
||||
/// KDE's default 10), and long enough that a menu press followed by a slow "are you sure?" cannot
|
||||
/// re-arm the veto mid-decision.
|
||||
const QUIET_BEFORE_VETO: Duration = Duration::from_secs(30);
|
||||
|
||||
/// How often [`watch`] re-checks the quiet time. Only the RE-ARM edge waits for a tick — the
|
||||
/// release edge is synchronous in [`note_input`] — so this bounds nothing a user can feel.
|
||||
#[cfg(target_os = "linux")]
|
||||
const WATCH_TICK: Duration = Duration::from_secs(5);
|
||||
|
||||
/// RAII share of the host-wide inhibitor — hold one per live session/stream.
|
||||
pub struct StreamHold(());
|
||||
|
||||
struct State {
|
||||
count: u32,
|
||||
/// Whether [`watch`] is running. Its exit is the 1→0 edge, so without this flag a session that
|
||||
/// ends and restarts inside one tick would leave two watchers racing for the same fd slot.
|
||||
#[cfg(target_os = "linux")]
|
||||
watching: bool,
|
||||
/// The logind inhibitor pipe fd — inhibition lasts exactly as long as it stays open.
|
||||
#[cfg(target_os = "linux")]
|
||||
fd: Option<ashpd::zbus::zvariant::OwnedFd>,
|
||||
@@ -25,18 +62,65 @@ fn state() -> &'static Mutex<State> {
|
||||
Mutex::new(State {
|
||||
count: 0,
|
||||
#[cfg(target_os = "linux")]
|
||||
watching: false,
|
||||
#[cfg(target_os = "linux")]
|
||||
fd: None,
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
/// Take a share; the underlying inhibitor is acquired on the 0→1 edge.
|
||||
/// Monotonic ms since first use — a plain `AtomicU64` clock the input path can stamp with one
|
||||
/// relaxed store, which `Instant` itself is too fat to be.
|
||||
fn now_ms() -> u64 {
|
||||
static EPOCH: OnceLock<Instant> = OnceLock::new();
|
||||
EPOCH.get_or_init(Instant::now).elapsed().as_millis() as u64
|
||||
}
|
||||
|
||||
static LAST_INPUT_MS: AtomicU64 = AtomicU64::new(0);
|
||||
/// Whether a veto is standing right now. Read once per input event, so it is what keeps
|
||||
/// [`note_input`] off the mutex on the hot path.
|
||||
static VETOING: AtomicBool = AtomicBool::new(false);
|
||||
|
||||
/// Whether the stream has been quiet long enough to veto suspend on the viewer's behalf.
|
||||
fn quiet_for(last_input_ms: u64, now_ms: u64) -> bool {
|
||||
now_ms.saturating_sub(last_input_ms) >= QUIET_BEFORE_VETO.as_millis() as u64
|
||||
}
|
||||
|
||||
/// Client input arrived on any plane — the person at the other end is driving this box, so no
|
||||
/// suspend veto may be standing when their next button press is "Sleep".
|
||||
///
|
||||
/// Called per decoded input event (keyboard, pointer, pad, pen, motion): one relaxed store, plus a
|
||||
/// relaxed load that only ever takes the lock on the rare edge where a veto is actually standing.
|
||||
pub fn note_input() {
|
||||
LAST_INPUT_MS.store(now_ms(), Ordering::Relaxed);
|
||||
if VETOING.load(Ordering::Relaxed) {
|
||||
release("the client is sending input again — a deliberate suspend now reaches logind");
|
||||
}
|
||||
}
|
||||
|
||||
/// Take a share. The underlying inhibitor is NOT acquired here: the `watch` thread takes it once
|
||||
/// the stream has been quiet for [`QUIET_BEFORE_VETO`], and never while someone is driving the box.
|
||||
pub fn hold() -> StreamHold {
|
||||
// A fresh stream gets the full quiet window before anything is vetoed, so an ordinary connect
|
||||
// costs zero D-Bus round trips.
|
||||
LAST_INPUT_MS.store(now_ms(), Ordering::Relaxed);
|
||||
let mut st = state().lock().unwrap_or_else(|e| e.into_inner());
|
||||
st.count += 1;
|
||||
#[cfg(target_os = "linux")]
|
||||
if st.count == 1 && st.fd.is_none() {
|
||||
st.fd = acquire();
|
||||
if !st.watching {
|
||||
st.watching = true;
|
||||
drop(st);
|
||||
if let Err(e) = std::thread::Builder::new()
|
||||
.name("punktfunk-sleep-veto".into())
|
||||
.spawn(watch)
|
||||
{
|
||||
state().lock().unwrap_or_else(|e| e.into_inner()).watching = false;
|
||||
tracing::warn!(
|
||||
error = %e,
|
||||
"could not start the sleep-veto watcher — the box may auto-suspend under a \
|
||||
passive (video-only) viewer"
|
||||
);
|
||||
}
|
||||
}
|
||||
StreamHold(())
|
||||
}
|
||||
@@ -46,12 +130,72 @@ impl Drop for StreamHold {
|
||||
let mut st = state().lock().unwrap_or_else(|e| e.into_inner());
|
||||
st.count = st.count.saturating_sub(1);
|
||||
#[cfg(target_os = "linux")]
|
||||
if st.count == 0 && st.fd.take().is_some() {
|
||||
tracing::info!("released the sleep/idle inhibitor (no live sessions)");
|
||||
if st.count == 0 {
|
||||
release_locked(&mut st, "no live sessions");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Drop any standing veto. Closing the fd is all it takes — no D-Bus, so this is safe to call from
|
||||
/// the input path.
|
||||
#[cfg(target_os = "linux")]
|
||||
fn release(why: &str) {
|
||||
let mut st = state().lock().unwrap_or_else(|e| e.into_inner());
|
||||
release_locked(&mut st, why);
|
||||
}
|
||||
|
||||
#[cfg(not(target_os = "linux"))]
|
||||
fn release(_why: &str) {}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
fn release_locked(st: &mut State, why: &str) {
|
||||
if st.fd.take().is_some() {
|
||||
VETOING.store(false, Ordering::Relaxed);
|
||||
tracing::info!(why, "released the sleep/idle inhibitor");
|
||||
}
|
||||
}
|
||||
|
||||
/// Own the veto's arm/disarm edges for as long as any session lives.
|
||||
///
|
||||
/// Acquiring is the only expensive edge (a thread spawn + a D-Bus round trip), so it happens here
|
||||
/// rather than on the input path, and outside the lock — a `note_input` on a hot input stream must
|
||||
/// never queue behind a logind call.
|
||||
#[cfg(target_os = "linux")]
|
||||
fn watch() {
|
||||
loop {
|
||||
std::thread::sleep(WATCH_TICK);
|
||||
{
|
||||
let mut st = state().lock().unwrap_or_else(|e| e.into_inner());
|
||||
if st.count == 0 {
|
||||
st.watching = false; // the last `Drop` already released the fd
|
||||
return;
|
||||
}
|
||||
if st.fd.is_some() {
|
||||
// Belt for the nanosecond window in which input lands after the re-check below but
|
||||
// before `VETOING` is published: that press releases nothing, so catch it here
|
||||
// rather than leave a veto standing over a live viewer.
|
||||
if !quiet_for(LAST_INPUT_MS.load(Ordering::Relaxed), now_ms()) {
|
||||
release_locked(&mut st, "the client is sending input again");
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if !quiet_for(LAST_INPUT_MS.load(Ordering::Relaxed), now_ms()) {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
let Some(fd) = acquire() else {
|
||||
continue; // no logind / refused — `acquire` said so once, don't spin on it
|
||||
};
|
||||
let mut st = state().lock().unwrap_or_else(|e| e.into_inner());
|
||||
if st.count == 0 || !quiet_for(LAST_INPUT_MS.load(Ordering::Relaxed), now_ms()) {
|
||||
drop(fd); // raced: the stream ended, or the viewer came back — never veto for those
|
||||
continue;
|
||||
}
|
||||
st.fd = Some(fd);
|
||||
VETOING.store(true, Ordering::Relaxed);
|
||||
}
|
||||
}
|
||||
|
||||
/// One logind `Inhibit` call on a dedicated plain thread — zbus's blocking API must not run on
|
||||
/// a tokio worker (its internal `block_on` panics there), and callers of [`hold`] may be either.
|
||||
/// The join blocks the caller for the D-Bus round-trip (~ms), which every call site tolerates.
|
||||
@@ -94,7 +238,44 @@ fn acquire() -> Option<ashpd::zbus::zvariant::OwnedFd> {
|
||||
.ok()
|
||||
.flatten();
|
||||
if fd.is_some() {
|
||||
tracing::info!("holding a logind sleep/idle inhibitor while clients stream");
|
||||
tracing::info!(
|
||||
quiet_s = QUIET_BEFORE_VETO.as_secs(),
|
||||
"holding a logind sleep/idle inhibitor — this stream has gone quiet"
|
||||
);
|
||||
}
|
||||
fd
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// The whole fix in one assertion: a stream that is being driven must never be vetoing, or the
|
||||
/// "Sleep" the viewer just picked in Steam's power menu is refused with no visible reason.
|
||||
#[test]
|
||||
fn a_driven_stream_is_never_vetoed_but_a_quiet_one_is() {
|
||||
let quiet_ms = QUIET_BEFORE_VETO.as_millis() as u64;
|
||||
assert!(!quiet_for(1_000, 1_000), "input this instant is not quiet");
|
||||
assert!(
|
||||
!quiet_for(1_000, 1_000 + quiet_ms - 1),
|
||||
"one ms short of the window still counts as driven"
|
||||
);
|
||||
assert!(
|
||||
quiet_for(1_000, 1_000 + quiet_ms),
|
||||
"the window elapsed — a passive viewer gets the veto"
|
||||
);
|
||||
// The clock starts at zero, so an un-stamped stream would look infinitely quiet: `hold`
|
||||
// seeds it precisely so a connect never vetoes before anyone could have pressed anything.
|
||||
assert!(quiet_for(0, quiet_ms), "an unseeded clock reads as quiet");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn note_input_stamps_the_clock() {
|
||||
note_input();
|
||||
let stamped = LAST_INPUT_MS.load(Ordering::Relaxed);
|
||||
assert!(
|
||||
!quiet_for(stamped, now_ms()),
|
||||
"input just arrived — the veto must not be armable"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -79,6 +79,15 @@ punktfunk launch <host-ref> # start a session, waking the host firs
|
||||
punktfunk-client --connect <host>:9777 # the older flag — still supported for existing scripts
|
||||
```
|
||||
|
||||
**Couch mode.** The same client also has a **gamepad console UI**: a full-screen, controller-driven
|
||||
front end with the host list, PIN pairing, settings, Wake-on-LAN and the game library, streaming in
|
||||
its own window. Three ways in — the **gamepad button** in the shell's header bar (also *Main menu →
|
||||
Console UI*), the separate **Punktfunk Console** launcher the packages install alongside the app, or:
|
||||
|
||||
```sh
|
||||
punktfunk-client --browse --fullscreen
|
||||
```
|
||||
|
||||
The client also updates itself (`punktfunk-client --check-update` / `--apply-update`) — see
|
||||
[Keeping a client up to date](/docs/install-client#keeping-a-client-up-to-date).
|
||||
|
||||
|
||||
@@ -235,6 +235,11 @@ package_punktfunk-host() {
|
||||
"$pkgdir/usr/share/polkit-1/actions/io.unom.punktfunk.dm-helper.policy"
|
||||
sed -i 's#/usr/libexec/punktfunk/pf-dm-helper#/usr/lib/punktfunk/pf-dm-helper#' \
|
||||
"$pkgdir/usr/share/polkit-1/actions/io.unom.punktfunk.dm-helper.policy"
|
||||
# ...and the other half of stopping a display manager: with it stopped the box has no active
|
||||
# local session, so logind's power actions fall to auth_admin_keep and Steam's power menu goes
|
||||
# quiet mid-stream. No path annotation to rewrite — a .rules file names a group, not a binary.
|
||||
install -Dm0644 "$R/packaging/linux/49-punktfunk-power.rules" \
|
||||
"$pkgdir/usr/share/polkit-1/rules.d/49-punktfunk-power.rules"
|
||||
# Web-console-triggered updates (host-update-from-web-console.md §7): root helper + oneshot
|
||||
# unit + group-scoped polkit rule. Same no-libexec relocation as pf-dm-helper, with the
|
||||
# unit's ExecStart rewritten to match. On pacman the helper additionally requires the
|
||||
@@ -360,6 +365,9 @@ package_punktfunk-client() {
|
||||
"$pkgdir/usr/share/icons/hicolor/scalable/apps/io.unom.Punktfunk.svg"
|
||||
install -Dm0644 "$R/packaging/linux/io.unom.Punktfunk.desktop" \
|
||||
"$pkgdir/usr/share/applications/io.unom.Punktfunk.desktop"
|
||||
# Second launcher, straight into the gamepad console (`--browse`) — the couch entry point.
|
||||
install -Dm0644 "$R/packaging/linux/io.unom.Punktfunk.Console.desktop" \
|
||||
"$pkgdir/usr/share/applications/io.unom.Punktfunk.Console.desktop"
|
||||
# DualSense hidraw access (full pad fidelity through SDL's HIDAPI driver).
|
||||
install -Dm0644 "$R/scripts/70-punktfunk-client.rules" \
|
||||
"$pkgdir/usr/lib/udev/rules.d/70-punktfunk-client.rules"
|
||||
|
||||
@@ -75,6 +75,10 @@ install -Dm0755 "$SESSION_BIN" "$STAGE/usr/bin/punktfu
|
||||
install -Dm0755 "$CLI_BIN" "$STAGE/usr/bin/punktfunk"
|
||||
install -Dm0644 packaging/linux/io.unom.Punktfunk.desktop \
|
||||
"$STAGE/usr/share/applications/io.unom.Punktfunk.desktop"
|
||||
# Second launcher, straight into the gamepad console (`--browse`): the couch entry point a
|
||||
# TV/HTPC user picks from the app grid, and what gets added to Steam as a non-Steam game.
|
||||
install -Dm0644 packaging/linux/io.unom.Punktfunk.Console.desktop \
|
||||
"$STAGE/usr/share/applications/io.unom.Punktfunk.Console.desktop"
|
||||
# The app icon the desktop entry (and the About dialog) name. Without it the launcher falls
|
||||
# back to a generic monitor glyph, which is what shipped until now.
|
||||
install -Dm0644 packaging/linux/icons/hicolor/scalable/apps/io.unom.Punktfunk.svg \
|
||||
|
||||
@@ -100,6 +100,10 @@ done
|
||||
install -Dm0755 scripts/pf-dm-helper "$STAGE/usr/libexec/punktfunk/pf-dm-helper"
|
||||
install -Dm0644 scripts/io.unom.punktfunk.dm-helper.policy \
|
||||
"$STAGE/usr/share/polkit-1/actions/io.unom.punktfunk.dm-helper.policy"
|
||||
# ...and the other half of stopping one: with the DM stopped the box has no active local session,
|
||||
# so logind's power actions fall to auth_admin_keep and Steam's power menu goes quiet mid-stream.
|
||||
install -Dm0644 packaging/linux/49-punktfunk-power.rules \
|
||||
"$STAGE/usr/share/polkit-1/rules.d/49-punktfunk-power.rules"
|
||||
# vhci-hcd autoload — usbip transport for the virtual Steam Deck pad (Steam only adopts USB pads).
|
||||
install -Dm0644 scripts/punktfunk-modules.conf "$STAGE/usr/lib/modules-load.d/punktfunk.conf"
|
||||
# UDP socket-buffer tuning (32 MB) — without it the kernel clamps the host's SO_SNDBUF to ~416 KB
|
||||
|
||||
@@ -350,6 +350,11 @@ modules:
|
||||
# Desktop entry (renamed to the app id; Exec is the in-sandbox binary).
|
||||
- install -Dm0644 packaging/flatpak/io.unom.Punktfunk.desktop
|
||||
${FLATPAK_DEST}/share/applications/io.unom.Punktfunk.desktop
|
||||
# Second launcher, straight into the gamepad console (`--browse`) — the couch entry
|
||||
# point. Shared with the deb/rpm/arch (there is nothing sandbox-specific about it);
|
||||
# flatpak's export rewrites Exec into `flatpak run --command=punktfunk-client …`.
|
||||
- install -Dm0644 packaging/linux/io.unom.Punktfunk.Console.desktop
|
||||
${FLATPAK_DEST}/share/applications/io.unom.Punktfunk.Console.desktop
|
||||
# AppStream metainfo (required for a well-formed flatpak / Software listings).
|
||||
- install -Dm0644 packaging/flatpak/io.unom.Punktfunk.metainfo.xml
|
||||
${FLATPAK_DEST}/share/metainfo/io.unom.Punktfunk.metainfo.xml
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
// Members of the `punktfunk` group may power the box off, restart it, or put it to sleep even when
|
||||
// the box has no ACTIVE LOCAL SESSION — which is exactly the state a display-manager takeover
|
||||
// leaves it in for the length of a stream.
|
||||
//
|
||||
// Why this is needed at all: logind ships `power-off`/`reboot`/`suspend` as `allow_active: yes`,
|
||||
// and polkit decides "active" from the caller's own logind session — falling back to the user's
|
||||
// elected DISPLAY session when the caller has none, which is every `systemd --user` unit including
|
||||
// the managed gamescope session. A takeover that STOPS the display manager removes that session
|
||||
// (logind elects a display session only from `user`/`greeter` class ones, never from the user
|
||||
// manager's), the fallback then finds nothing, and all three actions become `auth_admin_keep`: an
|
||||
// interactive password prompt, asked of a non-interactive caller, on a screen that is switched off.
|
||||
// Nothing surfaces the refusal, so the symptom is a power menu that does nothing at all.
|
||||
//
|
||||
// That menu is the reason this file exists. On SteamOS-like boxes Steam does not call logind for
|
||||
// "Shut Down" — it writes `$STEAMOS_STEAM_SHUTDOWN_SENTINEL` and exits, and `gamescope-session-plus`
|
||||
// runs a plain `poweroff` once Steam is gone. During a stream that wrapper is OURS, running in the
|
||||
// session-less transient unit, so its `poweroff` is the call polkit refuses. Measured on Bazzite,
|
||||
// 2026-08-24: the identical `pkcheck --action-id org.freedesktop.login1.power-off` from a
|
||||
// `systemd --user` unit answers authorized with the display manager up, and `auth_admin_keep` with
|
||||
// it stopped.
|
||||
//
|
||||
// The group ships EMPTY and joining it is a deliberate act. It is the same group the takeover's own
|
||||
// root helper (`io.unom.punktfunk.dm-helper`) authorizes on, and a takeover that stops a display
|
||||
// manager cannot work without that helper — so this grants to exactly the population the fault
|
||||
// reaches, and to nobody else.
|
||||
//
|
||||
// Scope notes: the three actions are the three entries in Steam's power menu, so a grant that stops
|
||||
// there authorizes "use the power menu on the box you are streaming from" and nothing else. The
|
||||
// `-multiple-sessions` variants are deliberately NOT granted — a box with a second USER logged in
|
||||
// still asks before it powers off under them — and neither are the `-ignore-inhibit` ones, which
|
||||
// would let this override somebody else's block inhibitor rather than just our absent session.
|
||||
polkit.addRule(function (action, subject) {
|
||||
if (
|
||||
(action.id == "org.freedesktop.login1.power-off" ||
|
||||
action.id == "org.freedesktop.login1.reboot" ||
|
||||
action.id == "org.freedesktop.login1.suspend") &&
|
||||
subject.isInGroup("punktfunk")
|
||||
) {
|
||||
return polkit.Result.YES;
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,14 @@
|
||||
[Desktop Entry]
|
||||
Type=Application
|
||||
Name=Punktfunk Console
|
||||
Comment=Controller-driven couch interface — browse hosts and stream with a gamepad
|
||||
# The shell already execs `punktfunk-session --browse` for this argv, so the shortcut goes
|
||||
# through the same binary the main entry uses (which is also what flatpak's Exec rewrite
|
||||
# expects). A couch UI is fullscreen; the session fullscreens itself on the Deck and under
|
||||
# gamescope anyway.
|
||||
Exec=punktfunk-client --browse --fullscreen
|
||||
Icon=io.unom.Punktfunk
|
||||
Terminal=false
|
||||
Categories=Network;Game;
|
||||
Keywords=streaming;remote;game;gamepad;controller;couch;console;bigpicture;
|
||||
StartupNotify=true
|
||||
@@ -410,7 +410,10 @@ The shell exports an
|
||||
session binary's *optional* on-glass stats overlay is absent, and the **GTK shell
|
||||
(`punktfunk-client`) is skia-free and fully featured.** Re-adding it means teaching skia-bindings
|
||||
to consume a prebuilt Skia offline (a fixed-output derivation of the rust-skia tarball) or a
|
||||
vendored from-source Skia build — a tracked follow-up.
|
||||
vendored from-source Skia build — a tracked follow-up. For the same reason this build does **not** install
|
||||
`io.unom.Punktfunk.Console.desktop` (the deb/rpm/arch/flatpak couch launcher): it runs
|
||||
`--browse`, which needs that feature, so the entry would be a launcher that only prints an
|
||||
error.
|
||||
|
||||
- **⚠ `nix flake check` does NOT check the NixOS module — that is why `module-check.nix` exists.**
|
||||
For `nixosModules`, nix forces the value and asserts it is a lambda taking an open attribute set,
|
||||
|
||||
@@ -350,6 +350,10 @@ install -Dm0644 scripts/alsa-ucm2/USB-Audio/Punktfunk/DualSense-PS5-Haptic-HiFi.
|
||||
# polkit rule. The helper derives the DM unit itself — callers can't name arbitrary units.
|
||||
install -Dm0755 scripts/pf-dm-helper %{buildroot}%{_libexecdir}/punktfunk/pf-dm-helper
|
||||
install -Dm0644 scripts/io.unom.punktfunk.dm-helper.policy %{buildroot}%{_datadir}/polkit-1/actions/io.unom.punktfunk.dm-helper.policy
|
||||
# ...and the other half of stopping a display manager: with it stopped the box has no active local
|
||||
# session, so logind's own power actions fall to auth_admin_keep and Steam's power menu goes quiet
|
||||
# mid-stream. Scoped to the same (shipped-empty) punktfunk group the helper above gates on.
|
||||
install -Dm0644 packaging/linux/49-punktfunk-power.rules %{buildroot}%{_datadir}/polkit-1/rules.d/49-punktfunk-power.rules
|
||||
|
||||
# vhci-hcd autoload — the usbip transport that makes the virtual Steam Deck controller a
|
||||
# real USB device (Steam Input only adopts those; the UHID fallback is invisible to Steam).
|
||||
@@ -423,6 +427,9 @@ install -Dm0755 target/release/punktfunk-session %{buildroot}%{_bindir}/punktfun
|
||||
install -Dm0755 target/release/punktfunk %{buildroot}%{_bindir}/punktfunk
|
||||
install -Dm0644 packaging/linux/io.unom.Punktfunk.desktop \
|
||||
%{buildroot}%{_datadir}/applications/io.unom.Punktfunk.desktop
|
||||
# Second launcher, straight into the gamepad console (`--browse`) — the couch entry point.
|
||||
install -Dm0644 packaging/linux/io.unom.Punktfunk.Console.desktop \
|
||||
%{buildroot}%{_datadir}/applications/io.unom.Punktfunk.Console.desktop
|
||||
# The app icon the desktop entry (and the About dialog) name. Without it the launcher falls
|
||||
# back to a generic monitor glyph, which is what shipped until now.
|
||||
install -Dm0644 packaging/linux/icons/hicolor/scalable/apps/io.unom.Punktfunk.svg \
|
||||
@@ -612,6 +619,7 @@ install -Dm0644 scripts/punktfunk-scripting.service %{buildroot}%{_userunitdir}/
|
||||
%{_libexecdir}/punktfunk/pf-update
|
||||
%{_unitdir}/punktfunk-update.service
|
||||
%{_datadir}/polkit-1/rules.d/49-punktfunk-update.rules
|
||||
%{_datadir}/polkit-1/rules.d/49-punktfunk-power.rules
|
||||
%{_datadir}/polkit-1/actions/io.unom.punktfunk.dm-helper.policy
|
||||
%{_prefix}/lib/modules-load.d/punktfunk.conf
|
||||
%{_prefix}/lib/sysctl.d/99-punktfunk-net.conf
|
||||
@@ -640,6 +648,7 @@ install -Dm0644 scripts/punktfunk-scripting.service %{buildroot}%{_userunitdir}/
|
||||
%{_bindir}/punktfunk-session
|
||||
%{_bindir}/punktfunk
|
||||
%{_datadir}/applications/io.unom.Punktfunk.desktop
|
||||
%{_datadir}/applications/io.unom.Punktfunk.Console.desktop
|
||||
%{_datadir}/icons/hicolor/scalable/apps/io.unom.Punktfunk.svg
|
||||
%{_udevrulesdir}/70-punktfunk-client.rules
|
||||
%{_prefix}/lib/sysctl.d/99-punktfunk-client-net.conf
|
||||
|
||||
Reference in New Issue
Block a user