AV1 decoded on the D3D11VA rung because 31 is not a level, and two host warnings that named the wrong subsystem #205
@@ -185,8 +185,12 @@ pub enum MaxLevelIdc {
|
|||||||
H265(hh::StdVideoH265LevelIdc),
|
H265(hh::StdVideoH265LevelIdc),
|
||||||
/// `VkVideoDecodeAV1CapabilitiesKHR::maxLevel`. Unlike the other two this code
|
/// `VkVideoDecodeAV1CapabilitiesKHR::maxLevel`. Unlike the other two this code
|
||||||
/// space is the BITSTREAM's own: `StdVideoAV1Level` is index-coded exactly like
|
/// 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
|
/// AV1's `seq_level_idx` (2.0 = 0, 2.1 = 1, … 7.3 = 23).
|
||||||
/// compares the sequence header's value against it directly.
|
///
|
||||||
|
/// ⚠ 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),
|
Av1(hh::StdVideoAV1Level),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -211,9 +211,16 @@ pub struct RawAv1Caps {
|
|||||||
pub max_coded_extent: vk::Extent2D,
|
pub max_coded_extent: vk::Extent2D,
|
||||||
pub max_dpb_slots: u32,
|
pub max_dpb_slots: u32,
|
||||||
pub max_active_reference_pictures: u32,
|
pub max_active_reference_pictures: u32,
|
||||||
/// `VkVideoDecodeAV1CapabilitiesKHR::maxLevel` (index-coded Std level — the
|
/// `VkVideoDecodeAV1CapabilitiesKHR::maxLevel` (index-coded Std level — the same
|
||||||
/// SAME numbering as the bitstream's `seq_level_idx`, which is what makes the
|
/// numbering as the bitstream's `seq_level_idx` OVER 0…23, which is the whole
|
||||||
/// decoder's level gate a plain comparison).
|
/// 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,
|
pub max_level: hh::StdVideoAV1Level,
|
||||||
/// `VkVideoCapabilitiesKHR::stdHeaderVersion` — session creation echoes it back.
|
/// `VkVideoCapabilitiesKHR::stdHeaderVersion` — session creation echoes it back.
|
||||||
pub std_header_version: vk::ExtensionProperties,
|
pub std_header_version: vk::ExtensionProperties,
|
||||||
|
|||||||
@@ -100,6 +100,7 @@ use pf_bitstream::av1::NUM_REF_SLOTS;
|
|||||||
use pf_bitstream::h264::DisplayCrop;
|
use pf_bitstream::h264::DisplayCrop;
|
||||||
use tracing::debug;
|
use tracing::debug;
|
||||||
use tracing::trace;
|
use tracing::trace;
|
||||||
|
use tracing::warn;
|
||||||
|
|
||||||
use crate::caps::DecodeCaps;
|
use crate::caps::DecodeCaps;
|
||||||
use crate::caps::DecodeProfile;
|
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
|
/// through a temporal unit, which is why the skip is per FRAME while the error
|
||||||
/// is per ACCESS UNIT.
|
/// is per ACCESS UNIT.
|
||||||
awaiting_key: bool,
|
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 {
|
impl VkAv1Decoder {
|
||||||
@@ -728,6 +733,7 @@ impl VkAv1Decoder {
|
|||||||
device_lost: false,
|
device_lost: false,
|
||||||
recovery: RecoveryLatch::default(),
|
recovery: RecoveryLatch::default(),
|
||||||
awaiting_key: false,
|
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
|
/// The negotiated facts are a HINT (the in-band sequence header is
|
||||||
/// authoritative), so this is deliberately not a promise that decode will
|
/// authoritative), so this is deliberately not a promise that decode will
|
||||||
/// succeed: the level ceiling and a sequence header that disagrees with the
|
/// succeed: a coded extent outside the caps, a DPB deeper than the device
|
||||||
/// Welcome still surface at the first AU.
|
/// 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(
|
pub fn probe_stream_support(
|
||||||
&self,
|
&self,
|
||||||
chroma_format_idc: u8,
|
chroma_format_idc: u8,
|
||||||
@@ -1478,8 +1486,9 @@ impl VkAv1Decoder {
|
|||||||
self.flush();
|
self.flush();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Session/caps for THIS plan exist and match its extent + profile, and the
|
/// Session/caps for THIS plan exist and match its extent + profile. A declared
|
||||||
/// stream sits inside the device's level ceiling.
|
/// 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> {
|
fn ensure_state(&mut self, plan: &AuPlan) -> Result<(), VkDecodeError> {
|
||||||
let key = profile_key_for(plan)?;
|
let key = profile_key_for(plan)?;
|
||||||
if self.caps.as_ref().map(|(k, _)| *k) != Some(key) {
|
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))?;
|
unsafe { query_av1_caps(&self.dev, key) }.map_err(|r| caps_query_error(r, key))?;
|
||||||
self.caps = Some((key, derive_caps_av1(&raw, wanted)?));
|
self.caps = Some((key, derive_caps_av1(&raw, wanted)?));
|
||||||
}
|
}
|
||||||
// The level gate. AV1's `StdVideoAV1Level` is index-coded exactly like the
|
// The declared level vs the device ceiling: a DECLARED level above `maxLevel`
|
||||||
// bitstream's `seq_level_idx` (2.0 = 0 … 7.3 = 23) and ascends with the
|
// is NOT a refusal, for the reason `VkH265Decoder::ensure_state` spells out —
|
||||||
// level, so this is a plain comparison — of AV1 code points against an AV1
|
// the level is a CLAIM, and the stream's real demands are enforced where they
|
||||||
// ceiling, the pairing `MaxLevelIdc`'s tag exists to keep honest.
|
// 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 caps_max_level = self.caps.as_ref().expect("queried above").1.max_level_idc;
|
||||||
let stream_level = u32::from(stream_level_idx(plan));
|
let stream_level = u32::from(stream_level_idx(plan));
|
||||||
if stream_level > caps_max_level.code_point() {
|
if stream_level > caps_max_level.code_point() && !self.level_advisory_warned {
|
||||||
return Err(VkDecodeError::Unsupported(format!(
|
self.level_advisory_warned = true;
|
||||||
"stream level (seq_level_idx {stream_level}) above the device's \
|
warn!(
|
||||||
maxLevel ({caps_max_level})"
|
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);
|
let coded = coded_extent(plan);
|
||||||
match &self.state {
|
match &self.state {
|
||||||
@@ -2907,10 +2938,45 @@ mod tests {
|
|||||||
assert_eq!(key.output_format(), Some(crate::caps::NV12));
|
assert_eq!(key.output_format(), Some(crate::caps::NV12));
|
||||||
assert!(!key.film_grain);
|
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);
|
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]
|
#[test]
|
||||||
fn only_a_decoded_key_frame_ends_the_wait_for_one() {
|
fn only_a_decoded_key_frame_ends_the_wait_for_one() {
|
||||||
let mut planner = Av1Planner::new();
|
let mut planner = Av1Planner::new();
|
||||||
@@ -2951,7 +3017,7 @@ mod tests {
|
|||||||
/// `PlanError::AwaitingIdr`, and the reason [`VkAv1Decoder::awaiting_key`]'s
|
/// `PlanError::AwaitingIdr`, and the reason [`VkAv1Decoder::awaiting_key`]'s
|
||||||
/// docs carry: a clean `Ok(None)` resets the consumer's demotion streak once
|
/// 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
|
/// 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
|
/// disagreeing with the negotiation) would never demote and the session would
|
||||||
/// hold a frozen screen with a clean bill of health.
|
/// 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
|
/// 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
|
/// 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.
|
/// 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)
|
/// 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
|
/// 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 rumble;
|
||||||
mod worker;
|
mod worker;
|
||||||
|
|
||||||
|
pub use self::frame_channel::FLUSH_COOLDOWN;
|
||||||
pub use self::planes::AudioPacket;
|
pub use self::planes::AudioPacket;
|
||||||
pub use self::probe::ProbeOutcome;
|
pub use self::probe::ProbeOutcome;
|
||||||
pub use self::rumble::{ActuatorQuirks, RumbleCommand};
|
pub use self::rumble::{ActuatorQuirks, RumbleCommand};
|
||||||
|
|||||||
@@ -62,6 +62,17 @@ pub struct PwAudioCapturer {
|
|||||||
/// active). Toggled by open/[`drain`](AudioCapturer::drain) (claim) and
|
/// active). Toggled by open/[`drain`](AudioCapturer::drain) (claim) and
|
||||||
/// [`idle`](AudioCapturer::idle)/Drop (release).
|
/// [`idle`](AudioCapturer::idle)/Drop (release).
|
||||||
claimed: bool,
|
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 {
|
impl PwAudioCapturer {
|
||||||
@@ -90,10 +101,21 @@ impl PwAudioCapturer {
|
|||||||
// mode the sink node must exist before we claim the default to its name.
|
// mode the sink node must exist before we claim the default to its name.
|
||||||
let (ready_tx, ready_rx) = sync_channel::<Result<()>>(1);
|
let (ready_tx, ready_rx) = sync_channel::<Result<()>>(1);
|
||||||
let thread_sink_name = sink_name.clone();
|
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()
|
thread::Builder::new()
|
||||||
.name("punktfunk-pw-audio".into())
|
.name("punktfunk-pw-audio".into())
|
||||||
.spawn(move || {
|
.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");
|
tracing::error!(error = %format!("{e:#}"), "pipewire audio thread failed");
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
@@ -118,12 +140,16 @@ impl PwAudioCapturer {
|
|||||||
quit: quit_tx,
|
quit: quit_tx,
|
||||||
sink_name,
|
sink_name,
|
||||||
claimed,
|
claimed,
|
||||||
|
active,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Drop for PwAudioCapturer {
|
impl Drop for PwAudioCapturer {
|
||||||
fn drop(&mut self) {
|
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 {
|
if self.claimed {
|
||||||
self.claimed = false;
|
self.claimed = false;
|
||||||
stream_sink::release();
|
stream_sink::release();
|
||||||
@@ -157,9 +183,15 @@ impl AudioCapturer for PwAudioCapturer {
|
|||||||
stream_sink::claim(name);
|
stream_sink::claim(name);
|
||||||
self.claimed = true;
|
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) {
|
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 {
|
if self.claimed {
|
||||||
self.claimed = false;
|
self.claimed = false;
|
||||||
stream_sink::release();
|
stream_sink::release();
|
||||||
@@ -644,6 +676,7 @@ fn pw_thread(
|
|||||||
channels: u32,
|
channels: u32,
|
||||||
sink_name: Option<String>,
|
sink_name: Option<String>,
|
||||||
ready: std::sync::mpsc::SyncSender<Result<()>>,
|
ready: std::sync::mpsc::SyncSender<Result<()>>,
|
||||||
|
active: Arc<AtomicBool>,
|
||||||
) -> Result<()> {
|
) -> Result<()> {
|
||||||
use pipewire as pw;
|
use pipewire as pw;
|
||||||
use pw::{properties::properties, spa};
|
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
|
/// never again — the one number that identifies a clamped quantum, invisible on every
|
||||||
/// subsequent open (including every reopen after a device change).
|
/// subsequent open (including every reopen after a device change).
|
||||||
reported_quantum: bool,
|
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 {
|
let ud = CapUd {
|
||||||
tx,
|
tx,
|
||||||
@@ -742,6 +778,7 @@ fn pw_thread(
|
|||||||
stats: Default::default(),
|
stats: Default::default(),
|
||||||
last_stats: std::time::Instant::now(),
|
last_stats: std::time::Instant::now(),
|
||||||
reported_quantum: false,
|
reported_quantum: false,
|
||||||
|
active,
|
||||||
};
|
};
|
||||||
let _listener = stream
|
let _listener = stream
|
||||||
.add_local_listener_with_user_data(ud)
|
.add_local_listener_with_user_data(ud)
|
||||||
@@ -844,11 +881,15 @@ fn pw_thread(
|
|||||||
samples.push(f32::from_le_bytes(b));
|
samples.push(f32::from_le_bytes(b));
|
||||||
}
|
}
|
||||||
ud.stats.observe(&samples, ud.channels);
|
ud.stats.observe(&samples, ud.channels);
|
||||||
// Non-blocking and lossy, as before — but COUNTED. A full channel means the
|
// Non-blocking and lossy, as before — but COUNTED, and only while a session
|
||||||
// encode thread is not keeping up, and because the encoder simply
|
// is actually reading. A full channel under a LIVE consumer means the encode
|
||||||
// concatenates across the hole every dropped chunk is a click AND a
|
// thread is not keeping up, and because the encoder simply concatenates
|
||||||
// permanent shift of everything after it.
|
// across the hole every dropped chunk is a click AND a permanent shift of
|
||||||
if ud.tx.try_send(samples).is_err() {
|
// 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;
|
ud.stats.dropped_chunks += 1;
|
||||||
}
|
}
|
||||||
if ud.last_stats.elapsed() >= crate::audio::capture_policy::STATS_EVERY {
|
if ud.last_stats.elapsed() >= crate::audio::capture_policy::STATS_EVERY {
|
||||||
|
|||||||
@@ -43,6 +43,15 @@ pub struct WasapiLoopbackCapturer {
|
|||||||
channels: u32,
|
channels: u32,
|
||||||
stop: Arc<AtomicBool>,
|
stop: Arc<AtomicBool>,
|
||||||
join: Option<JoinHandle<()>>,
|
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 {
|
impl WasapiLoopbackCapturer {
|
||||||
@@ -58,10 +67,13 @@ impl WasapiLoopbackCapturer {
|
|||||||
// rather than a silent dead thread.
|
// rather than a silent dead thread.
|
||||||
let (ready_tx, ready_rx) = sync_channel::<Result<()>>(1);
|
let (ready_tx, ready_rx) = sync_channel::<Result<()>>(1);
|
||||||
let stop_t = stop.clone();
|
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()
|
let join = thread::Builder::new()
|
||||||
.name("punktfunk-wasapi-audio".into())
|
.name("punktfunk-wasapi-audio".into())
|
||||||
.spawn(move || {
|
.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");
|
tracing::error!(error = %format!("{e:#}"), "wasapi loopback thread failed");
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
@@ -76,6 +88,7 @@ impl WasapiLoopbackCapturer {
|
|||||||
channels,
|
channels,
|
||||||
stop,
|
stop,
|
||||||
join: Some(join),
|
join: Some(join),
|
||||||
|
active,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
Ok(Err(e)) => Err(e),
|
Ok(Err(e)) => Err(e),
|
||||||
@@ -92,6 +105,9 @@ impl WasapiLoopbackCapturer {
|
|||||||
|
|
||||||
impl Drop for WasapiLoopbackCapturer {
|
impl Drop for WasapiLoopbackCapturer {
|
||||||
fn drop(&mut self) {
|
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);
|
self.stop.store(true, Ordering::SeqCst);
|
||||||
if let Some(j) = self.join.take() {
|
if let Some(j) = self.join.take() {
|
||||||
let _ = j.join();
|
let _ = j.join();
|
||||||
@@ -114,6 +130,14 @@ impl AudioCapturer for WasapiLoopbackCapturer {
|
|||||||
}
|
}
|
||||||
fn drain(&mut self) {
|
fn drain(&mut self) {
|
||||||
while self.chunks.try_recv().is_ok() {}
|
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>,
|
stop: Arc<AtomicBool>,
|
||||||
ready: SyncSender<Result<()>>,
|
ready: SyncSender<Result<()>>,
|
||||||
channels: u32,
|
channels: u32,
|
||||||
|
active: Arc<AtomicBool>,
|
||||||
) -> Result<()> {
|
) -> Result<()> {
|
||||||
// COM must be initialized on THIS thread (MTA), before any device call.
|
// COM must be initialized on THIS thread (MTA), before any device call.
|
||||||
if let Err(e) = wasapi::initialize_mta()
|
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.
|
// is said once per topology — the field log drowned in 256+ copies of the same line.
|
||||||
let mut unsat_logged: Option<u64> = None;
|
let mut unsat_logged: Option<u64> = None;
|
||||||
while !stop.load(Ordering::Relaxed) {
|
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::Stopped) => break,
|
||||||
Ok(Next::Reopen(m)) => {
|
Ok(Next::Reopen(m)) => {
|
||||||
mode = m;
|
mode = m;
|
||||||
@@ -357,6 +382,7 @@ fn capture_once(
|
|||||||
ready: &mut Option<SyncSender<Result<()>>>,
|
ready: &mut Option<SyncSender<Result<()>>>,
|
||||||
channels: u32,
|
channels: u32,
|
||||||
mode: TargetMode,
|
mode: TargetMode,
|
||||||
|
active: &AtomicBool,
|
||||||
) -> Result<Next> {
|
) -> Result<Next> {
|
||||||
// Interleaved f32: channels * 4 bytes per frame.
|
// Interleaved f32: channels * 4 bytes per frame.
|
||||||
let block_align = channels as usize * 4;
|
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]]));
|
samples.push(f32::from_le_bytes([c[0], c[1], c[2], c[3]]));
|
||||||
}
|
}
|
||||||
stats.observe(&samples, channels);
|
stats.observe(&samples, channels);
|
||||||
// Non-blocking, lossy — same discipline as PipeWire. Now COUNTED: a full channel
|
// Non-blocking, lossy — same discipline as PipeWire. COUNTED, and only while a
|
||||||
// means the encode thread is not keeping up, and every dropped chunk is a click plus
|
// session is actually reading: a full channel under a LIVE consumer means the encode
|
||||||
// a permanent shift of everything after it.
|
// thread is not keeping up, and every dropped chunk is a click plus a permanent
|
||||||
if tx.try_send(samples).is_err() {
|
// 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;
|
stats.dropped_chunks += 1;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2722,14 +2722,35 @@ pub(super) fn virtual_stream(ctx: SessionContext, prepared: Option<PreparedDispl
|
|||||||
last_forced_idr = Some(now);
|
last_forced_idr = Some(now);
|
||||||
rfi_echo_swallowed = 0; // the IDR resets the episode — echoes of IT coalesce via the cooldown
|
rfi_echo_swallowed = 0; // the IDR resets the episode — echoes of IT coalesce via the cooldown
|
||||||
if let Some(period) = recovery_cadence.note(now) {
|
if let Some(period) = recovery_cadence.note(now) {
|
||||||
tracing::warn!(
|
// A period that lands on the CLIENT's jump-to-live cooldown is not evidence
|
||||||
period_s = format!("{:.1}", period.as_secs_f64()),
|
// of a periodic disturbance here at all — it is the client shedding a
|
||||||
"client keyframe recoveries are METRONOMIC — a periodic host/display \
|
// standing receive queue, which it is rate-limited to do exactly this often
|
||||||
disturbance (display-topology churn, display-poller software, \
|
// (`punktfunk_core::client::FLUSH_COOLDOWN`), so the cadence is a property of
|
||||||
virtual-display timing) is the likely cause, not random network loss; \
|
// our own backpressure code rather than of anything physical. Naming display
|
||||||
correlate with 'slow display-descriptor poll' / 'display descriptor \
|
// churn there sent a 2026-08-13 field investigation at three innocent
|
||||||
changed' / 'IDD-push capture stall' lines"
|
// 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(())
|
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).
|
/// 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.
|
/// Dropping the capturer tears down the PipeWire stream and the virtual output with it.
|
||||||
type Pipeline = (
|
type Pipeline = (
|
||||||
@@ -4597,6 +4635,26 @@ fn build_pipeline(
|
|||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
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]
|
#[test]
|
||||||
fn an_escalated_but_caught_up_encoder_stops_refusing_climbs() {
|
fn an_escalated_but_caught_up_encoder_stops_refusing_climbs() {
|
||||||
const DEGRADE: u32 = 10;
|
const DEGRADE: u32 = 10;
|
||||||
|
|||||||
Reference in New Issue
Block a user