Compare commits
4
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8216f1d92d | ||
|
|
1677d1c0c2 | ||
|
|
3f738a9989 | ||
|
|
7df321f459 |
@@ -185,8 +185,12 @@ pub enum MaxLevelIdc {
|
||||
H265(hh::StdVideoH265LevelIdc),
|
||||
/// `VkVideoDecodeAV1CapabilitiesKHR::maxLevel`. Unlike the other two this code
|
||||
/// space is the BITSTREAM's own: `StdVideoAV1Level` is index-coded exactly like
|
||||
/// AV1's `seq_level_idx` (2.0 = 0, 2.1 = 1, … 7.3 = 23), so the decoder's gate
|
||||
/// compares the sequence header's value against it directly.
|
||||
/// AV1's `seq_level_idx` (2.0 = 0, 2.1 = 1, … 7.3 = 23).
|
||||
///
|
||||
/// ⚠ Only over 0…23. `seq_level_idx` is 5 bits, and 31 is Annex A's "maximum
|
||||
/// parameters" sentinel — no level constraint — which outranks even a device
|
||||
/// reporting the enum's top value. The AV1 gate therefore treats a stream above
|
||||
/// this ceiling as advisory instead of refusing it (`VkAv1Decoder::ensure_state`).
|
||||
Av1(hh::StdVideoAV1Level),
|
||||
}
|
||||
|
||||
|
||||
@@ -211,9 +211,16 @@ pub struct RawAv1Caps {
|
||||
pub max_coded_extent: vk::Extent2D,
|
||||
pub max_dpb_slots: u32,
|
||||
pub max_active_reference_pictures: u32,
|
||||
/// `VkVideoDecodeAV1CapabilitiesKHR::maxLevel` (index-coded Std level — the
|
||||
/// SAME numbering as the bitstream's `seq_level_idx`, which is what makes the
|
||||
/// decoder's level gate a plain comparison).
|
||||
/// `VkVideoDecodeAV1CapabilitiesKHR::maxLevel` (index-coded Std level — the same
|
||||
/// numbering as the bitstream's `seq_level_idx` OVER 0…23, which is the whole
|
||||
/// range `StdVideoAV1Level` enumerates).
|
||||
///
|
||||
/// ⚠ That correspondence does not extend to the rest of the bitstream field.
|
||||
/// `seq_level_idx` is 5 bits: 24…30 are reserved and 31 is Annex A's "maximum
|
||||
/// parameters" sentinel — "not constrained to a level" — which has no Std code
|
||||
/// point and is NOT an ordering above 7.3. The decoder's gate therefore treats
|
||||
/// a stream above this ceiling as advisory rather than comparing it as a level
|
||||
/// (`VkAv1Decoder::ensure_state`).
|
||||
pub max_level: hh::StdVideoAV1Level,
|
||||
/// `VkVideoCapabilitiesKHR::stdHeaderVersion` — session creation echoes it back.
|
||||
pub std_header_version: vk::ExtensionProperties,
|
||||
|
||||
@@ -100,6 +100,7 @@ use pf_bitstream::av1::NUM_REF_SLOTS;
|
||||
use pf_bitstream::h264::DisplayCrop;
|
||||
use tracing::debug;
|
||||
use tracing::trace;
|
||||
use tracing::warn;
|
||||
|
||||
use crate::caps::DecodeCaps;
|
||||
use crate::caps::DecodeProfile;
|
||||
@@ -688,6 +689,10 @@ pub struct VkAv1Decoder {
|
||||
/// through a temporal unit, which is why the skip is per FRAME while the error
|
||||
/// is per ACCESS UNIT.
|
||||
awaiting_key: bool,
|
||||
/// One-shot latch for the over-declared-level warning, so a stream whose
|
||||
/// sequence header sits above the device ceiling says so once per decoder
|
||||
/// rather than once per access unit (`ensure_state` runs per AU).
|
||||
level_advisory_warned: bool,
|
||||
}
|
||||
|
||||
impl VkAv1Decoder {
|
||||
@@ -728,6 +733,7 @@ impl VkAv1Decoder {
|
||||
device_lost: false,
|
||||
recovery: RecoveryLatch::default(),
|
||||
awaiting_key: false,
|
||||
level_advisory_warned: false,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -745,8 +751,10 @@ impl VkAv1Decoder {
|
||||
///
|
||||
/// The negotiated facts are a HINT (the in-band sequence header is
|
||||
/// authoritative), so this is deliberately not a promise that decode will
|
||||
/// succeed: the level ceiling and a sequence header that disagrees with the
|
||||
/// Welcome still surface at the first AU.
|
||||
/// succeed: a coded extent outside the caps, a DPB deeper than the device
|
||||
/// allows, and a sequence header that disagrees with the Welcome all still
|
||||
/// surface at the first AU. The declared LEVEL is not among them — it is
|
||||
/// advisory, and `ensure_state` only warns on it.
|
||||
pub fn probe_stream_support(
|
||||
&self,
|
||||
chroma_format_idc: u8,
|
||||
@@ -1478,8 +1486,9 @@ impl VkAv1Decoder {
|
||||
self.flush();
|
||||
}
|
||||
|
||||
/// Session/caps for THIS plan exist and match its extent + profile, and the
|
||||
/// stream sits inside the device's level ceiling.
|
||||
/// Session/caps for THIS plan exist and match its extent + profile. A declared
|
||||
/// level above the device ceiling warns once and proceeds — see the gate below
|
||||
/// for why an AV1 `seq_level_idx` is advisory and 31 is not even a level.
|
||||
fn ensure_state(&mut self, plan: &AuPlan) -> Result<(), VkDecodeError> {
|
||||
let key = profile_key_for(plan)?;
|
||||
if self.caps.as_ref().map(|(k, _)| *k) != Some(key) {
|
||||
@@ -1491,17 +1500,39 @@ impl VkAv1Decoder {
|
||||
unsafe { query_av1_caps(&self.dev, key) }.map_err(|r| caps_query_error(r, key))?;
|
||||
self.caps = Some((key, derive_caps_av1(&raw, wanted)?));
|
||||
}
|
||||
// The level gate. AV1's `StdVideoAV1Level` is index-coded exactly like the
|
||||
// bitstream's `seq_level_idx` (2.0 = 0 … 7.3 = 23) and ascends with the
|
||||
// level, so this is a plain comparison — of AV1 code points against an AV1
|
||||
// ceiling, the pairing `MaxLevelIdc`'s tag exists to keep honest.
|
||||
// The declared level vs the device ceiling: a DECLARED level above `maxLevel`
|
||||
// is NOT a refusal, for the reason `VkH265Decoder::ensure_state` spells out —
|
||||
// the level is a CLAIM, and the stream's real demands are enforced where they
|
||||
// are physical facts (coded extent and DPB depth, checked in `rebuild_state`).
|
||||
//
|
||||
// AV1 makes the point sharper than H.265 did. `seq_level_idx` is a 5-bit
|
||||
// field; Annex A defines 0…23 (levels 2.0…7.3) and reserves 24…30, but **31 is
|
||||
// the "maximum parameters" level — the spec's own way of saying the bitstream
|
||||
// is not constrained to any level at all**. `StdVideoAV1Level` has no code
|
||||
// point for it (it stops at 7.3 = 23), so the index-coded comparison that
|
||||
// holds across 0…23 is meaningless against 31: the sentinel is not a level
|
||||
// and 31 > 23 is not "too demanding". Real-time encoders emit it as a matter
|
||||
// of course — a 2026-08-13 field report (RTX 5060 client, 4K120) had EVERY
|
||||
// AV1 session demote to D3D11VA on "stream level (seq_level_idx 31) above the
|
||||
// device's maxLevel (AV1 Std level 23)" while the same hardware decoded the
|
||||
// stream trivially. We never write an AV1 level on any host encode path, so
|
||||
// whatever the vendor defaults to is what the client must accept.
|
||||
//
|
||||
// Unlike H.265 there is nothing to clamp: `StdVideoAV1SequenceHeader` carries
|
||||
// no level field (see `params_av1`), so the declaration never reaches the
|
||||
// driver and cannot be invalid usage. Warn once, proceed.
|
||||
let caps_max_level = self.caps.as_ref().expect("queried above").1.max_level_idc;
|
||||
let stream_level = u32::from(stream_level_idx(plan));
|
||||
if stream_level > caps_max_level.code_point() {
|
||||
return Err(VkDecodeError::Unsupported(format!(
|
||||
"stream level (seq_level_idx {stream_level}) above the device's \
|
||||
maxLevel ({caps_max_level})"
|
||||
)));
|
||||
if stream_level > caps_max_level.code_point() && !self.level_advisory_warned {
|
||||
self.level_advisory_warned = true;
|
||||
warn!(
|
||||
stream_level,
|
||||
ceiling = %caps_max_level,
|
||||
"stream declares an AV1 level above the device ceiling — the declared \
|
||||
level is advisory (seq_level_idx 31 means \"maximum parameters\", and \
|
||||
encoders over-declare); proceeding, since the level never reaches the \
|
||||
driver"
|
||||
);
|
||||
}
|
||||
let coded = coded_extent(plan);
|
||||
match &self.state {
|
||||
@@ -2907,10 +2938,45 @@ mod tests {
|
||||
assert_eq!(key.output_format(), Some(crate::caps::NV12));
|
||||
assert!(!key.film_grain);
|
||||
|
||||
// The level gate reads operating point 0 and stays inside the Std range.
|
||||
// The level gate reads operating point 0. This vector declares a real level,
|
||||
// inside the Std range — the sentinel case is pinned separately below.
|
||||
assert!(stream_level_idx(&plan) <= 23);
|
||||
}
|
||||
|
||||
/// `seq_level_idx` 31 is Annex A's "maximum parameters" — "not constrained to a
|
||||
/// level" — not a level above 7.3, and `StdVideoAV1Level` has no code point for
|
||||
/// it. Comparing it as an ordinary level is what demoted every AV1 session on a
|
||||
/// 2026-08-13 field report (RTX 5060, 4K120): `maxLevel` came back 23 (7.3, the
|
||||
/// device's own maximum) and 31 > 23 refused a stream the hardware decodes fine.
|
||||
///
|
||||
/// This pins the ARITHMETIC that made the refusal look reasonable, so nobody
|
||||
/// restores the gate by reading `31 > 23` as "too demanding":
|
||||
#[test]
|
||||
fn the_av1_max_parameters_sentinel_is_not_a_level_above_the_ceiling() {
|
||||
// The ceiling as the gate reads it, on a device that decodes everything the
|
||||
// Std enum can name — 7.3, the top code point there is.
|
||||
let ceiling = crate::caps::MaxLevelIdc::Av1(hh::StdVideoAV1Level_STD_VIDEO_AV1_LEVEL_7_3);
|
||||
assert_eq!(ceiling.code_point(), 23, "the Std enum's top code point");
|
||||
|
||||
// Every `seq_level_idx` the Std enum names compares sanely against it…
|
||||
for idx in 0..=ceiling.code_point() {
|
||||
assert!(idx <= ceiling.code_point());
|
||||
}
|
||||
// …and everything above is OUTSIDE that code space, not above the ceiling:
|
||||
// 24…30 are reserved and 31 is "maximum parameters". A maxed-out device
|
||||
// cannot satisfy the comparison, which is why it is not a capability test.
|
||||
for idx in (ceiling.code_point() + 1)..=31 {
|
||||
assert!(
|
||||
idx > ceiling.code_point(),
|
||||
"seq_level_idx {idx} is outside the Std range, not a more demanding level"
|
||||
);
|
||||
}
|
||||
|
||||
// The field report's exact pairing, kept legible: 31 against a ceiling of 23.
|
||||
assert!(31 > ceiling.code_point());
|
||||
assert_eq!(format!("{ceiling}"), "AV1 Std level 23");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn only_a_decoded_key_frame_ends_the_wait_for_one() {
|
||||
let mut planner = Av1Planner::new();
|
||||
@@ -2951,7 +3017,7 @@ mod tests {
|
||||
/// `PlanError::AwaitingIdr`, and the reason [`VkAv1Decoder::awaiting_key`]'s
|
||||
/// docs carry: a clean `Ok(None)` resets the consumer's demotion streak once
|
||||
/// per frame, so a rung whose every key frame fails (film grain on a device
|
||||
/// without the grain profile; a level above `maxLevelIdc`; a sequence header
|
||||
/// without the grain profile; a coded extent outside the caps; a sequence header
|
||||
/// disagreeing with the negotiation) would never demote and the session would
|
||||
/// hold a frozen screen with a clean bill of health.
|
||||
///
|
||||
|
||||
@@ -47,7 +47,15 @@ pub(crate) const FLUSH_AFTER: Duration = Duration::from_millis(250);
|
||||
/// Minimum spacing between jump-to-live events, so a bottleneck that instantly rebuilds the queue (a
|
||||
/// link/consumer that can't sustain the bitrate at all) degrades into a periodic skip + a logged
|
||||
/// warning instead of a continuous flush/keyframe storm.
|
||||
pub(crate) const FLUSH_COOLDOWN: Duration = Duration::from_secs(2);
|
||||
///
|
||||
/// **Public because the HOST needs it to read its own logs.** Each jump-to-live sends a keyframe
|
||||
/// request, so a client that cannot sustain the rate asks for one at exactly this spacing,
|
||||
/// forever — and the host's recovery-cadence detector saw that perfect periodicity and blamed a
|
||||
/// periodic *display* disturbance (2026-08-13 field log: `period_s=2.0`, three subsystems named,
|
||||
/// none of them the cause). Perfect periodicity is the signature of a fixed software cooldown,
|
||||
/// not of a physical disturbance. The host compares against this constant rather than a copy of
|
||||
/// the number, so the two can never drift apart.
|
||||
pub const FLUSH_COOLDOWN: Duration = Duration::from_secs(2);
|
||||
|
||||
/// A clock-triggered jump-to-live that discarded fewer datagrams than this (and no queued AUs)
|
||||
/// found NO local backlog: the frames read as late, but nothing here was actually behind. Two
|
||||
|
||||
@@ -42,6 +42,7 @@ mod recovery;
|
||||
mod rumble;
|
||||
mod worker;
|
||||
|
||||
pub use self::frame_channel::FLUSH_COOLDOWN;
|
||||
pub use self::planes::AudioPacket;
|
||||
pub use self::probe::ProbeOutcome;
|
||||
pub use self::rumble::{ActuatorQuirks, RumbleCommand};
|
||||
|
||||
@@ -62,6 +62,17 @@ pub struct PwAudioCapturer {
|
||||
/// active). Toggled by open/[`drain`](AudioCapturer::drain) (claim) and
|
||||
/// [`idle`](AudioCapturer::idle)/Drop (release).
|
||||
claimed: bool,
|
||||
/// Whether a session is currently CONSUMING this capturer, shared with the PipeWire
|
||||
/// thread so the drop counter can tell "the encode thread fell behind" from "nobody is
|
||||
/// reading". The capturer is host-lifetime and merely PARKED between sessions
|
||||
/// ([`idle`](AudioCapturer::idle)), so without this the producer keeps filling the bounded
|
||||
/// hand-off channel, every `try_send` fails once it is full, and the plane reports a 100 %
|
||||
/// drop rate — warning that "the stream will click" when there is no stream. A 2026-08-13
|
||||
/// field host log carried ten such warnings, up to `dropped_chunks=11251` (= 30 s × 375
|
||||
/// chunks/s, i.e. every single chunk), each one straddling a session boundary and each one
|
||||
/// meaningless. Distinct from `claimed`, which tracks the sink-routing claim and only
|
||||
/// exists when the stream sink is enabled at all.
|
||||
active: Arc<AtomicBool>,
|
||||
}
|
||||
|
||||
impl PwAudioCapturer {
|
||||
@@ -90,10 +101,21 @@ impl PwAudioCapturer {
|
||||
// mode the sink node must exist before we claim the default to its name.
|
||||
let (ready_tx, ready_rx) = sync_channel::<Result<()>>(1);
|
||||
let thread_sink_name = sink_name.clone();
|
||||
// Opens at session start (see the routing claim below), so the consumer is live from
|
||||
// the first chunk.
|
||||
let active = Arc::new(AtomicBool::new(true));
|
||||
let thread_active = Arc::clone(&active);
|
||||
thread::Builder::new()
|
||||
.name("punktfunk-pw-audio".into())
|
||||
.spawn(move || {
|
||||
if let Err(e) = pw_thread(tx, quit_rx, channels, thread_sink_name, ready_tx) {
|
||||
if let Err(e) = pw_thread(
|
||||
tx,
|
||||
quit_rx,
|
||||
channels,
|
||||
thread_sink_name,
|
||||
ready_tx,
|
||||
thread_active,
|
||||
) {
|
||||
tracing::error!(error = %format!("{e:#}"), "pipewire audio thread failed");
|
||||
}
|
||||
})
|
||||
@@ -118,12 +140,16 @@ impl PwAudioCapturer {
|
||||
quit: quit_tx,
|
||||
sink_name,
|
||||
claimed,
|
||||
active,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for PwAudioCapturer {
|
||||
fn drop(&mut self) {
|
||||
// The receiver dies with us; anything the producer still pushes is unwanted by
|
||||
// definition, and it must not be reported as the encode thread falling behind.
|
||||
self.active.store(false, Ordering::Relaxed);
|
||||
if self.claimed {
|
||||
self.claimed = false;
|
||||
stream_sink::release();
|
||||
@@ -157,9 +183,15 @@ impl AudioCapturer for PwAudioCapturer {
|
||||
stream_sink::claim(name);
|
||||
self.claimed = true;
|
||||
}
|
||||
// Ordered AFTER the backlog drain, so the producer never counts a drop against a
|
||||
// channel this call is still emptying.
|
||||
self.active.store(true, Ordering::Relaxed);
|
||||
}
|
||||
|
||||
fn idle(&mut self) {
|
||||
// Parked: from here the channel fills and stays full, and those drops are nobody's
|
||||
// fault. See `PwAudioCapturer::active`.
|
||||
self.active.store(false, Ordering::Relaxed);
|
||||
if self.claimed {
|
||||
self.claimed = false;
|
||||
stream_sink::release();
|
||||
@@ -644,6 +676,7 @@ fn pw_thread(
|
||||
channels: u32,
|
||||
sink_name: Option<String>,
|
||||
ready: std::sync::mpsc::SyncSender<Result<()>>,
|
||||
active: Arc<AtomicBool>,
|
||||
) -> Result<()> {
|
||||
use pipewire as pw;
|
||||
use pw::{properties::properties, spa};
|
||||
@@ -735,6 +768,9 @@ fn pw_thread(
|
||||
/// never again — the one number that identifies a clamped quantum, invisible on every
|
||||
/// subsequent open (including every reopen after a device change).
|
||||
reported_quantum: bool,
|
||||
/// Shared with the capturer — see [`PwAudioCapturer::active`]. Read on every
|
||||
/// failed hand-off to keep parked-capturer backpressure out of the drop count.
|
||||
active: Arc<AtomicBool>,
|
||||
}
|
||||
let ud = CapUd {
|
||||
tx,
|
||||
@@ -742,6 +778,7 @@ fn pw_thread(
|
||||
stats: Default::default(),
|
||||
last_stats: std::time::Instant::now(),
|
||||
reported_quantum: false,
|
||||
active,
|
||||
};
|
||||
let _listener = stream
|
||||
.add_local_listener_with_user_data(ud)
|
||||
@@ -844,11 +881,15 @@ fn pw_thread(
|
||||
samples.push(f32::from_le_bytes(b));
|
||||
}
|
||||
ud.stats.observe(&samples, ud.channels);
|
||||
// Non-blocking and lossy, as before — but COUNTED. A full channel means the
|
||||
// encode thread is not keeping up, and because the encoder simply
|
||||
// concatenates across the hole every dropped chunk is a click AND a
|
||||
// permanent shift of everything after it.
|
||||
if ud.tx.try_send(samples).is_err() {
|
||||
// Non-blocking and lossy, as before — but COUNTED, and only while a session
|
||||
// is actually reading. A full channel under a LIVE consumer means the encode
|
||||
// thread is not keeping up, and because the encoder simply concatenates
|
||||
// across the hole every dropped chunk is a click AND a permanent shift of
|
||||
// everything after it. A full channel under a PARKED capturer means nothing
|
||||
// at all: the capturer is host-lifetime, so between sessions the channel
|
||||
// fills once and then refuses everything, which counted as a 100 % drop rate
|
||||
// and warned about a stream that did not exist (`PwAudioCapturer::active`).
|
||||
if ud.tx.try_send(samples).is_err() && ud.active.load(Ordering::Relaxed) {
|
||||
ud.stats.dropped_chunks += 1;
|
||||
}
|
||||
if ud.last_stats.elapsed() >= crate::audio::capture_policy::STATS_EVERY {
|
||||
|
||||
@@ -43,6 +43,15 @@ pub struct WasapiLoopbackCapturer {
|
||||
channels: u32,
|
||||
stop: Arc<AtomicBool>,
|
||||
join: Option<JoinHandle<()>>,
|
||||
/// Whether a session is currently CONSUMING this capturer, shared with the capture thread
|
||||
/// so the drop counter can tell "the encode thread fell behind" from "nobody is reading".
|
||||
/// The native/gamestream planes park a capturer between sessions
|
||||
/// ([`idle`](AudioCapturer::idle)) instead of dropping it, and the hand-off channel is
|
||||
/// bounded — so without this the thread fills it once, then counts every subsequent chunk
|
||||
/// as a drop and warns that "the stream will click" with no stream to click. Proven on the
|
||||
/// Linux twin by a 2026-08-13 field log (100 % drop rate across session gaps); the parking
|
||||
/// call sites are platform-independent, so this half had the same defect.
|
||||
active: Arc<AtomicBool>,
|
||||
}
|
||||
|
||||
impl WasapiLoopbackCapturer {
|
||||
@@ -58,10 +67,13 @@ impl WasapiLoopbackCapturer {
|
||||
// rather than a silent dead thread.
|
||||
let (ready_tx, ready_rx) = sync_channel::<Result<()>>(1);
|
||||
let stop_t = stop.clone();
|
||||
// Opens at session start, so the consumer is live from the first chunk.
|
||||
let active = Arc::new(AtomicBool::new(true));
|
||||
let active_t = active.clone();
|
||||
let join = thread::Builder::new()
|
||||
.name("punktfunk-wasapi-audio".into())
|
||||
.spawn(move || {
|
||||
if let Err(e) = capture_thread(tx, stop_t, ready_tx, channels) {
|
||||
if let Err(e) = capture_thread(tx, stop_t, ready_tx, channels, active_t) {
|
||||
tracing::error!(error = %format!("{e:#}"), "wasapi loopback thread failed");
|
||||
}
|
||||
})
|
||||
@@ -76,6 +88,7 @@ impl WasapiLoopbackCapturer {
|
||||
channels,
|
||||
stop,
|
||||
join: Some(join),
|
||||
active,
|
||||
})
|
||||
}
|
||||
Ok(Err(e)) => Err(e),
|
||||
@@ -92,6 +105,9 @@ impl WasapiLoopbackCapturer {
|
||||
|
||||
impl Drop for WasapiLoopbackCapturer {
|
||||
fn drop(&mut self) {
|
||||
// The receiver dies with us; anything the thread still pushes is unwanted by
|
||||
// definition, and must not be reported as the encode thread falling behind.
|
||||
self.active.store(false, Ordering::Relaxed);
|
||||
self.stop.store(true, Ordering::SeqCst);
|
||||
if let Some(j) = self.join.take() {
|
||||
let _ = j.join();
|
||||
@@ -114,6 +130,14 @@ impl AudioCapturer for WasapiLoopbackCapturer {
|
||||
}
|
||||
fn drain(&mut self) {
|
||||
while self.chunks.try_recv().is_ok() {}
|
||||
// Ordered AFTER the backlog drain, so the capture thread never counts a drop against a
|
||||
// channel this call is still emptying.
|
||||
self.active.store(true, Ordering::Relaxed);
|
||||
}
|
||||
fn idle(&mut self) {
|
||||
// Parked: from here the channel fills and stays full, and those drops are nobody's
|
||||
// fault. See [`WasapiLoopbackCapturer::active`].
|
||||
self.active.store(false, Ordering::Relaxed);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -167,6 +191,7 @@ fn capture_thread(
|
||||
stop: Arc<AtomicBool>,
|
||||
ready: SyncSender<Result<()>>,
|
||||
channels: u32,
|
||||
active: Arc<AtomicBool>,
|
||||
) -> Result<()> {
|
||||
// COM must be initialized on THIS thread (MTA), before any device call.
|
||||
if let Err(e) = wasapi::initialize_mta()
|
||||
@@ -192,7 +217,7 @@ fn capture_thread(
|
||||
// is said once per topology — the field log drowned in 256+ copies of the same line.
|
||||
let mut unsat_logged: Option<u64> = None;
|
||||
while !stop.load(Ordering::Relaxed) {
|
||||
match capture_once(&tx, &stop, &mut ready, channels, mode) {
|
||||
match capture_once(&tx, &stop, &mut ready, channels, mode, &active) {
|
||||
Ok(Next::Stopped) => break,
|
||||
Ok(Next::Reopen(m)) => {
|
||||
mode = m;
|
||||
@@ -357,6 +382,7 @@ fn capture_once(
|
||||
ready: &mut Option<SyncSender<Result<()>>>,
|
||||
channels: u32,
|
||||
mode: TargetMode,
|
||||
active: &AtomicBool,
|
||||
) -> Result<Next> {
|
||||
// Interleaved f32: channels * 4 bytes per frame.
|
||||
let block_align = channels as usize * 4;
|
||||
@@ -611,10 +637,14 @@ fn capture_once(
|
||||
samples.push(f32::from_le_bytes([c[0], c[1], c[2], c[3]]));
|
||||
}
|
||||
stats.observe(&samples, channels);
|
||||
// Non-blocking, lossy — same discipline as PipeWire. Now COUNTED: a full channel
|
||||
// means the encode thread is not keeping up, and every dropped chunk is a click plus
|
||||
// a permanent shift of everything after it.
|
||||
if tx.try_send(samples).is_err() {
|
||||
// Non-blocking, lossy — same discipline as PipeWire. COUNTED, and only while a
|
||||
// session is actually reading: a full channel under a LIVE consumer means the encode
|
||||
// thread is not keeping up, and every dropped chunk is a click plus a permanent
|
||||
// shift of everything after it. A full channel under a PARKED capturer means nothing
|
||||
// — the planes park capturers between sessions rather than dropping them, so the
|
||||
// channel fills once and then refuses everything
|
||||
// ([`WasapiLoopbackCapturer::active`]).
|
||||
if tx.try_send(samples).is_err() && active.load(Ordering::Relaxed) {
|
||||
stats.dropped_chunks += 1;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2722,14 +2722,35 @@ pub(super) fn virtual_stream(ctx: SessionContext, prepared: Option<PreparedDispl
|
||||
last_forced_idr = Some(now);
|
||||
rfi_echo_swallowed = 0; // the IDR resets the episode — echoes of IT coalesce via the cooldown
|
||||
if let Some(period) = recovery_cadence.note(now) {
|
||||
tracing::warn!(
|
||||
period_s = format!("{:.1}", period.as_secs_f64()),
|
||||
"client keyframe recoveries are METRONOMIC — a periodic host/display \
|
||||
disturbance (display-topology churn, display-poller software, \
|
||||
virtual-display timing) is the likely cause, not random network loss; \
|
||||
correlate with 'slow display-descriptor poll' / 'display descriptor \
|
||||
changed' / 'IDD-push capture stall' lines"
|
||||
);
|
||||
// A period that lands on the CLIENT's jump-to-live cooldown is not evidence
|
||||
// of a periodic disturbance here at all — it is the client shedding a
|
||||
// standing receive queue, which it is rate-limited to do exactly this often
|
||||
// (`punktfunk_core::client::FLUSH_COOLDOWN`), so the cadence is a property of
|
||||
// our own backpressure code rather than of anything physical. Naming display
|
||||
// churn there sent a 2026-08-13 field investigation at three innocent
|
||||
// subsystems while the real chain was: client refused the codec → demoted to
|
||||
// a slower decode rung → could not sustain the rate → standing queue.
|
||||
// Perfect periodicity argues FOR a software cooldown, not against it.
|
||||
if matches_client_flush_cadence(period) {
|
||||
tracing::warn!(
|
||||
period_s = format!("{:.1}", period.as_secs_f64()),
|
||||
"client keyframe recoveries match the client's jump-to-live cooldown \
|
||||
— the CLIENT cannot sustain the stream and is shedding a standing \
|
||||
receive queue (check its log for 'receive backlog stopped draining' \
|
||||
with queue_depth, and for a decode rung that demoted); a slower \
|
||||
decode path or a link below the bitrate does this, and it is NOT a \
|
||||
host display disturbance"
|
||||
);
|
||||
} else {
|
||||
tracing::warn!(
|
||||
period_s = format!("{:.1}", period.as_secs_f64()),
|
||||
"client keyframe recoveries are METRONOMIC — a periodic host/display \
|
||||
disturbance (display-topology churn, display-poller software, \
|
||||
virtual-display timing) is the likely cause, not random network \
|
||||
loss; correlate with 'slow display-descriptor poll' / 'display \
|
||||
descriptor changed' / 'IDD-push capture stall' lines"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -3785,6 +3806,23 @@ pub(super) fn virtual_stream(ctx: SessionContext, prepared: Option<PreparedDispl
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Whether a measured keyframe-recovery period is the CLIENT's jump-to-live cooldown rather
|
||||
/// than anything happening on this host.
|
||||
///
|
||||
/// Every jump-to-live sends a keyframe request and is rate-limited to one per
|
||||
/// [`punktfunk_core::client::FLUSH_COOLDOWN`], so a client that simply cannot sustain the
|
||||
/// stream asks at exactly that spacing for as long as it stays behind. The recovery-cadence
|
||||
/// detector reads perfect periodicity as evidence of a periodic *disturbance*, which is
|
||||
/// backwards here: a fixed software cooldown is the most periodic thing in the system.
|
||||
///
|
||||
/// ±10 % — wide enough for scheduling jitter and the request's network trip, narrow enough that
|
||||
/// it cannot swallow the disturbance cadences the other branch exists to report (display-mode
|
||||
/// churn and descriptor polls run at their own, unrelated periods).
|
||||
fn matches_client_flush_cadence(period: std::time::Duration) -> bool {
|
||||
let flush = punktfunk_core::client::FLUSH_COOLDOWN;
|
||||
period.abs_diff(flush) < flush / 10
|
||||
}
|
||||
|
||||
/// One mode's capture/encode pipeline: (capturer, encoder, first frame, frame interval).
|
||||
/// Dropping the capturer tears down the PipeWire stream and the virtual output with it.
|
||||
type Pipeline = (
|
||||
@@ -4597,6 +4635,26 @@ fn build_pipeline(
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// The 2026-08-13 field log's exact reading — `period_s=2.0` — must be attributed to the
|
||||
/// client's backlog shedding, not to a host display disturbance. The whole point of routing
|
||||
/// on the shared constant is that this stays true if the cooldown is ever retuned, so the
|
||||
/// test derives its cases from `FLUSH_COOLDOWN` instead of hardcoding two seconds.
|
||||
#[test]
|
||||
fn a_recovery_cadence_on_the_clients_cooldown_is_not_blamed_on_the_display() {
|
||||
let flush = punktfunk_core::client::FLUSH_COOLDOWN;
|
||||
assert!(matches_client_flush_cadence(flush), "the field reading");
|
||||
// Scheduling jitter and the request's trip across the link stay inside the band.
|
||||
assert!(matches_client_flush_cadence(flush + flush / 20));
|
||||
assert!(matches_client_flush_cadence(flush - flush / 20));
|
||||
|
||||
// Cadences that are NOT the cooldown still reach the display-disturbance branch — the
|
||||
// band must not be so wide that it swallows them.
|
||||
assert!(!matches_client_flush_cadence(flush / 2));
|
||||
assert!(!matches_client_flush_cadence(flush * 2));
|
||||
assert!(!matches_client_flush_cadence(flush + flush / 5));
|
||||
assert!(!matches_client_flush_cadence(std::time::Duration::ZERO));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_escalated_but_caught_up_encoder_stops_refusing_climbs() {
|
||||
const DEGRADE: u32 = 10;
|
||||
|
||||
Reference in New Issue
Block a user