From c399ed3ac6d4173b71a1e07bfbc6e16b0160cc7f Mon Sep 17 00:00:00 2001 From: datagutt Date: Fri, 2 Oct 2026 01:22:00 +0200 Subject: [PATCH 01/28] refactor(workspace): remove functions and fields nothing calls --- crates/ffmpeg/src/format.rs | 7 --- crates/ffmpeg/src/hw.rs | 12 +---- crates/ffmpeg/src/lib.rs | 49 +----------------- crates/ffmpeg/src/packet.rs | 5 -- crates/ffmpeg/src/swr.rs | 27 +--------- crates/irl-core/src/audio_buffer.rs | 41 --------------- crates/irl-core/src/audio_hold.rs | 79 ++++++++--------------------- crates/irl-core/src/dsp.rs | 30 ----------- crates/irl-core/src/lib.rs | 2 +- crates/irl-core/src/pts_repair.rs | 6 --- crates/irl-source/src/log.rs | 1 - crates/obs/src/panic.rs | 10 +--- 12 files changed, 26 insertions(+), 243 deletions(-) diff --git a/crates/ffmpeg/src/format.rs b/crates/ffmpeg/src/format.rs index b4b7b6a..e131acd 100644 --- a/crates/ffmpeg/src/format.rs +++ b/crates/ffmpeg/src/format.rs @@ -76,11 +76,6 @@ impl InterruptWatch { .store(crate::gettime_us() as u64, Ordering::Relaxed); } - pub fn disarm(&self) { - self.io_start_us - .store(0, core::sync::atomic::Ordering::Relaxed); - } - /// The interrupt decision. Touches only atomics, `av_gettime` and two /// plain field reads, so it is safe to run inside FFmpeg's callback /// without `catch_unwind`. @@ -389,8 +384,6 @@ mod tests { // av_gettime has microsecond resolution; give it something to measure. crate::usleep(2_000); assert!(watch.should_abort()); - watch.disarm(); - assert!(!watch.should_abort()); } /// A listener that waited longer than the timeout for its caller must diff --git a/crates/ffmpeg/src/hw.rs b/crates/ffmpeg/src/hw.rs index 2be5e56..bd3c531 100644 --- a/crates/ffmpeg/src/hw.rs +++ b/crates/ffmpeg/src/hw.rs @@ -85,7 +85,6 @@ pub struct FramePool { fmt: AVPixelFormat, width: i32, height: i32, - size: usize, } // SAFETY: an AVBufferPool is internally synchronised (it is designed to be @@ -112,7 +111,6 @@ impl FramePool { fmt, width, height, - size, }, size, )) @@ -151,8 +149,8 @@ impl FramePool { } }; - // SAFETY: `frame` is a blank frame we own; `buf->data` points at - // `self.size` bytes, which is exactly what av_image_get_buffer_size + // SAFETY: `frame` is a blank frame we own; `buf->data` points at the + // pool's buffer size, which is exactly what av_image_get_buffer_size // reported for these format/dimensions/alignment in `new`. let ret = unsafe { let raw = frame.as_mut_ptr(); @@ -180,11 +178,6 @@ impl FramePool { unsafe { (*frame.as_mut_ptr()).buf[0] = buf }; Ok(frame) } - - /// Bytes per pooled buffer. - pub fn buffer_size(&self) -> usize { - self.size - } } impl Drop for FramePool { @@ -252,7 +245,6 @@ mod tests { ); assert!(!pool.matches(AVPixelFormat::AV_PIX_FMT_YUV420P, 1920, 1080)); assert!(!pool.matches(AVPixelFormat::AV_PIX_FMT_NV12, 1280, 720)); - assert_eq!(size, pool.buffer_size()); assert!(size >= 1920 * 1088 * 3 / 2); let first = pool.acquire().unwrap(); diff --git a/crates/ffmpeg/src/lib.rs b/crates/ffmpeg/src/lib.rs index 92e6c68..6bab6f7 100644 --- a/crates/ffmpeg/src/lib.rs +++ b/crates/ffmpeg/src/lib.rs @@ -136,18 +136,6 @@ pub fn rescale_q(value: i64, from: Rational, to: Rational) -> i64 { unsafe { sys::av_rescale_q(value, from.into(), to.into()) } } -/// `av_rescale_q_rnd(..., AV_ROUND_UP)`. -pub fn rescale_q_round_up(value: i64, from: Rational, to: Rational) -> i64 { - // SAFETY: as above; AV_ROUND_UP is a valid AVRounding value. - unsafe { sys::av_rescale_q_rnd(value, from.into(), to.into(), sys::AVRounding::AV_ROUND_UP) } -} - -/// `av_rescale`. -pub fn rescale(value: i64, mul: i64, div: i64) -> i64 { - // SAFETY: pure arithmetic over scalars. - unsafe { sys::av_rescale(value, mul, div) } -} - /// The endpoint identity of a URL, split by `av_url_split`. Userinfo, path, /// query and fragment are never copied out: they can all carry credentials, /// which is the point of using this for log lines. @@ -231,12 +219,6 @@ pub fn hwdevice_type_name(kind: AVHWDeviceType) -> &'static str { static_str(unsafe { sys::av_hwdevice_get_type_name(kind) }, "none") } -/// `av_get_pix_fmt_name` (for log lines). -pub fn pix_fmt_name(fmt: AVPixelFormat) -> &'static str { - // SAFETY: accepts any AVPixelFormat; returns a static string or NULL. - static_str(unsafe { sys::av_get_pix_fmt_name(fmt) }, "unknown") -} - /// `av_image_get_buffer_size(fmt, w, h, align)`. pub fn image_buffer_size(fmt: AVPixelFormat, width: i32, height: i32, align: i32) -> Result { // SAFETY: pure computation over scalars and the static pixel format table. @@ -251,21 +233,6 @@ pub fn image_buffer_size(fmt: AVPixelFormat, width: i32, height: i32, align: i32 Ok(size as usize) } -/// Decompose an `LIBAV*_VERSION_INT`-shaped version number. -fn version_triple(v: u32) -> (u32, u32, u32) { - (v >> 16, (v >> 8) & 0xff, v & 0xff) -} - -/// The library versions the crate was built against (`LIBAVCODEC_VERSION_*`), -/// for the connection log line. -pub fn version_string() -> String { - // SAFETY: these take no arguments and only read compiled-in constants. - let (ac_maj, ac_min, ac_mic) = version_triple(unsafe { sys::avcodec_version() }); - // SAFETY: as above. - let (af_maj, af_min, af_mic) = version_triple(unsafe { sys::avformat_version() }); - format!("libavcodec {ac_maj}.{ac_min}.{ac_mic}, libavformat {af_maj}.{af_min}.{af_mic}") -} - /// `FFALIGN(x, a)` for power-of-two `a`. pub(crate) const fn ffalign(x: i32, a: i32) -> i32 { (x + a - 1) & !(a - 1) @@ -325,29 +292,15 @@ mod tests { rescale_q(1, Rational::new(1, 1000), Rational::new(1, 1_000_000_000)), 1_000_000 ); - assert_eq!(rescale(3, 1_000_000_000, 1000), 3_000_000); - } - - #[test] - fn rescale_rounds_up() { - // 1 tick of 1/3 s into milliseconds: 333.33… rounds up to 334. - let up = rescale_q_round_up(1, Rational::new(1, 3), Rational::new(1, 1000)); - assert_eq!(up, 334); - assert_eq!( - rescale_q(1, Rational::new(1, 3), Rational::new(1, 1000)), - 333 - ); } #[test] - fn names_and_versions() { + fn names() { assert_eq!(codec_name(AVCodecID::AV_CODEC_ID_H264), "h264"); - assert_eq!(pix_fmt_name(AVPixelFormat::AV_PIX_FMT_NV12), "nv12"); assert_eq!( hwdevice_type_name(AVHWDeviceType::AV_HWDEVICE_TYPE_NONE), "none" ); - assert!(version_string().starts_with("libavcodec ")); } #[test] diff --git a/crates/ffmpeg/src/packet.rs b/crates/ffmpeg/src/packet.rs index e520e4d..8e9ab79 100644 --- a/crates/ffmpeg/src/packet.rs +++ b/crates/ffmpeg/src/packet.rs @@ -35,11 +35,6 @@ impl Packet { unsafe { (*self.0).pts } } - pub fn dts(&self) -> i64 { - // SAFETY: as above. - unsafe { (*self.0).dts } - } - /// Presentation timestamp, falling back to the decode timestamp, `None` /// when neither is set. Mirrors [`crate::Frame::best_effort_pts`]: live /// demuxers leave one or both unset often enough that the caller must not diff --git a/crates/ffmpeg/src/swr.rs b/crates/ffmpeg/src/swr.rs index feffe02..4857ff4 100644 --- a/crates/ffmpeg/src/swr.rs +++ b/crates/ffmpeg/src/swr.rs @@ -15,7 +15,6 @@ pub struct Resampler { in_rate: i32, in_channels: i32, in_format: AVSampleFormat, - out_rate: i32, out_channels: i32, } @@ -77,15 +76,7 @@ impl Resampler { core::ptr::null_mut(), ) }; - let this = Self::adopt( - ptr, - ret, - in_rate, - in_channels, - in_format, - out_rate, - out_channels, - )?; + let this = Self::adopt(ptr, ret, in_rate, in_channels, in_format, out_channels)?; this.init() } @@ -115,7 +106,6 @@ impl Resampler { rate, channels, AVSampleFormat::AV_SAMPLE_FMT_FLT, - rate, channels, )?; @@ -135,14 +125,12 @@ impl Resampler { } /// Wrap the result of `swr_alloc_set_opts2`, freeing on failure. - #[allow(clippy::too_many_arguments)] fn adopt( ptr: *mut ffmpeg_sys_next::SwrContext, ret: core::ffi::c_int, in_rate: i32, in_channels: i32, in_format: AVSampleFormat, - out_rate: i32, out_channels: i32, ) -> Result { if ptr.is_null() { @@ -153,7 +141,6 @@ impl Resampler { in_rate, in_channels, in_format, - out_rate, out_channels, }; // `this` frees the context in Drop if the option set failed. @@ -178,16 +165,6 @@ impl Resampler { self.in_rate == rate && self.in_channels == channels } - /// Channels on the output side (always interleaved float). - pub fn out_channels(&self) -> i32 { - self.out_channels - } - - /// Sample rate on the output side. - pub fn out_rate(&self) -> i32 { - self.out_rate - } - /// `swr_set_compensation(delta, distance)`. pub fn set_compensation( &mut self, @@ -301,8 +278,6 @@ mod tests { let mut swr = Resampler::passthrough_f32(48_000, 2).unwrap(); assert!(swr.matches_params(48_000, 2)); assert!(!swr.matches_params(44_100, 2)); - assert_eq!(swr.out_channels(), 2); - assert_eq!(swr.out_rate(), 48_000); let input = silent_stereo(1024); let max_out = swr.out_samples(1024).max(1024) + 32; diff --git a/crates/irl-core/src/audio_buffer.rs b/crates/irl-core/src/audio_buffer.rs index 5f0056d..2e24350 100644 --- a/crates/irl-core/src/audio_buffer.rs +++ b/crates/irl-core/src/audio_buffer.rs @@ -280,30 +280,6 @@ impl AudioBuffer { }) } - /// Discard the oldest chunk. - pub fn skip_chunk(&mut self) { - if self.data.is_empty() || self.chunk_count == 0 { - return; - } - self.skip_oldest_chunk(); - } - - /// Discard whole chunks until the oldest PTS is ≥ `min_pts_ns`. Returns chunks skipped. - pub fn skip_until_pts(&mut self, min_pts_ns: i64) -> usize { - if self.data.is_empty() { - return 0; - } - let mut skipped = 0; - while self.chunk_count > 0 { - if self.oldest_pts() >= min_pts_ns { - break; - } - self.skip_oldest_chunk(); - skipped += 1; - } - skipped - } - /// Discard oldest chunks until at most `keep_ms` remain, keeping at least /// `min_chunks`. Returns chunks trimmed and the resulting state. pub fn trim_to_keep_ms( @@ -625,12 +601,6 @@ mod tests { assert_eq!(buf.chunk_count(), consts::AUDIO_PTS_MAX_CHUNKS); assert_eq!(buf.peek_pts(), 20_000_000); assert_eq!(buf.fill_bytes(), consts::AUDIO_PTS_MAX_CHUNKS * CHUNK_BYTES); - - // ... and the newest chunk is the one just written. - let skipped = buf.skip_until_pts(256 * 20_000_000); - assert_eq!(skipped, consts::AUDIO_PTS_MAX_CHUNKS - 1); - assert_eq!(buf.chunk_count(), 1); - assert_eq!(buf.peek_pts(), 256 * 20_000_000); } #[test] @@ -767,17 +737,6 @@ mod tests { assert_eq!(trimmed, 0); } - #[test] - fn skip_chunk_drops_one() { - let mut buf = buffer(120); - buf.write_pts(&pcm(1.0, 960), 0); - buf.write_pts(&pcm(1.0, 960), 20_000_000); - buf.skip_chunk(); - assert_eq!(buf.chunk_count(), 1); - assert_eq!(buf.fill_bytes(), CHUNK_BYTES); - assert_eq!(buf.peek_pts(), 20_000_000); - } - #[test] fn fade_out_ramps_one_to_zero() { let mut buf = buffer(120); diff --git a/crates/irl-core/src/audio_hold.rs b/crates/irl-core/src/audio_hold.rs index 365fa35..fa40331 100644 --- a/crates/irl-core/src/audio_hold.rs +++ b/crates/irl-core/src/audio_hold.rs @@ -33,11 +33,11 @@ //! //! What is raised and what is released are deliberately different readings. //! A raise takes the *sustained* skew, the lowest reading across -//! [`HoldTuning::raise_window_ns`]: a skew that was there the whole window. A +//! `AUDIO_HOLD_RAISE_WINDOW_MS`: a skew that was there the whole window. A //! burst of late video from a hiccup at the sender is short, the video delay //! covers it, and paying for it in latency for the rest of the connection //! would be the wrong trade. A release takes the *worst* reading across the -//! much longer [`HoldTuning::relax_window_ns`], so it never undercuts a skew +//! much longer `AUDIO_HOLD_RELAX_WINDOW_MS`, so it never undercuts a skew //! seen recently, and the hold does not pump up and down with one. use std::collections::VecDeque; @@ -57,37 +57,8 @@ pub fn hold_ms(skew_ms: i64, covered_ms: i32, margin_ms: i32, max_ms: i32) -> i3 need_ms.clamp(0, i64::from(max_ms)) as i32 } -/// The thresholds a hold is decided with. [`Default`] is the shipped tuning -/// out of [`crate::consts`]. -#[derive(Debug, Clone, Copy)] -pub struct HoldTuning { - /// How early video should be in hand once the hold is in force. - pub margin_ms: i32, - /// Ceiling on the hold. - pub max_ms: i32, - /// How long a skew must have lasted, unbroken, to raise the hold. - pub raise_window_ns: u64, - /// Raises smaller than this are not made after priming. - pub raise_min_ms: i32, - /// How long every reading must have needed less than the hold in force - /// before it is released down to what they needed. - pub relax_window_ns: u64, - /// Surplus below this is left alone. - pub relax_min_ms: i32, -} - -impl Default for HoldTuning { - fn default() -> Self { - Self { - margin_ms: consts::AUDIO_HOLD_MARGIN_MS, - max_ms: consts::AUDIO_HOLD_MAX_MS, - raise_window_ns: consts::AUDIO_HOLD_RAISE_WINDOW_MS * 1_000_000, - raise_min_ms: consts::AUDIO_HOLD_RAISE_MIN_MS, - relax_window_ns: consts::AUDIO_HOLD_RELAX_WINDOW_MS * 1_000_000, - relax_min_ms: consts::AUDIO_HOLD_RELAX_MIN_MS, - } - } -} +const RAISE_WINDOW_NS: u64 = consts::AUDIO_HOLD_RAISE_WINDOW_MS * 1_000_000; +const RELAX_WINDOW_NS: u64 = consts::AUDIO_HOLD_RELAX_WINDOW_MS * 1_000_000; /// A change of the hold, for the caller to apply and log. #[derive(Debug, Clone, Copy, PartialEq, Eq)] @@ -105,7 +76,6 @@ pub struct HoldChange { /// The skew readings of one connection and the decisions they support. #[derive(Debug)] pub struct AudioHold { - tuning: HoldTuning, /// When the first reading was taken; the windows only count once they /// are full. first_ns: Option, @@ -116,16 +86,6 @@ pub struct AudioHold { } impl AudioHold { - /// No readings yet. - pub fn new(tuning: HoldTuning) -> Self { - Self { - tuning, - first_ns: None, - lowest: WindowExtreme::new(tuning.raise_window_ns, Extreme::Lowest), - highest: WindowExtreme::new(tuning.relax_window_ns, Extreme::Highest), - } - } - /// Forget every reading: a new connection, or a timeline that broke so /// that the old readings no longer compare with the new ones. pub fn reset(&mut self) { @@ -160,22 +120,22 @@ impl AudioHold { /// Once playback runs and the buffer is regulated by playback speed: a /// raise when the skew sustained across the raise window needs at least - /// [`HoldTuning::raise_min_ms`] more than `current_ms`, or a release when + /// `AUDIO_HOLD_RAISE_MIN_MS` more than `current_ms`, or a release when /// the worst reading across the relax window needs less than it by more - /// than [`HoldTuning::relax_min_ms`]. + /// than `AUDIO_HOLD_RELAX_MIN_MS`. pub fn regulate(&self, now_ns: u64, current_ms: i32, covered_ms: i32) -> Option { let since_ns = now_ns.saturating_sub(self.first_ns?); - if since_ns >= self.tuning.raise_window_ns + if since_ns >= RAISE_WINDOW_NS && let Some(skew_ns) = self.lowest.value() && let Some(change) = self.sized(skew_ns, current_ms, covered_ms) - && change.to_ms >= current_ms + self.tuning.raise_min_ms + && change.to_ms >= current_ms + consts::AUDIO_HOLD_RAISE_MIN_MS { return Some(change); } - if since_ns >= self.tuning.relax_window_ns + if since_ns >= RELAX_WINDOW_NS && let Some(skew_ns) = self.highest.value() && let Some(change) = self.sized(skew_ns, current_ms, covered_ms) - && change.to_ms + self.tuning.relax_min_ms < current_ms + && change.to_ms + consts::AUDIO_HOLD_RELAX_MIN_MS < current_ms { return Some(change); } @@ -184,25 +144,26 @@ impl AudioHold { fn sized(&self, skew_ns: i64, current_ms: i32, covered_ms: i32) -> Option { let skew_ms = skew_ns / 1_000_000; - let to_ms = hold_ms( - skew_ms, - covered_ms, - self.tuning.margin_ms, - self.tuning.max_ms, - ); - let wanted_ms = skew_ms + i64::from(self.tuning.margin_ms) - i64::from(covered_ms); + let (margin_ms, max_ms) = (consts::AUDIO_HOLD_MARGIN_MS, consts::AUDIO_HOLD_MAX_MS); + let to_ms = hold_ms(skew_ms, covered_ms, margin_ms, max_ms); + let wanted_ms = skew_ms + i64::from(margin_ms) - i64::from(covered_ms); (to_ms != current_ms).then_some(HoldChange { from_ms: current_ms, to_ms, skew_ms, - capped: wanted_ms > i64::from(self.tuning.max_ms), + capped: wanted_ms > i64::from(max_ms), }) } } impl Default for AudioHold { + /// No readings yet. fn default() -> Self { - Self::new(HoldTuning::default()) + Self { + first_ns: None, + lowest: WindowExtreme::new(RAISE_WINDOW_NS, Extreme::Lowest), + highest: WindowExtreme::new(RELAX_WINDOW_NS, Extreme::Highest), + } } } diff --git a/crates/irl-core/src/dsp.rs b/crates/irl-core/src/dsp.rs index 92a3d99..99c6246 100644 --- a/crates/irl-core/src/dsp.rs +++ b/crates/irl-core/src/dsp.rs @@ -99,24 +99,6 @@ pub fn shape_silence_from_last(samples: &mut [f32], channels: usize, rate: i32, } } -/// Linear 1→0 gain across the whole buffer (disconnect fade-out). -pub fn apply_linear_fade_out(samples: &mut [f32], channels: usize) { - if channels == 0 { - return; - } - let total_frames = samples.len() / channels; - if total_frames == 0 { - return; - } - - for f in 0..total_frames { - let gain = 1.0 - f as f32 / total_frames as f32; - for ch in 0..channels { - samples[f * channels + ch] *= gain; - } - } -} - /// Continue a fade-in ramp of `total` frames with `remaining` frames left; /// returns the new `remaining`. /// @@ -249,18 +231,6 @@ mod tests { assert!(samples.iter().all(|&s| s == 0.0)); } - #[test] - fn fade_out_ends_at_zero() { - let mut samples = vec![1.0f32; 4 * 2]; - apply_linear_fade_out(&mut samples, 2); - assert_eq!(samples, vec![1.0, 1.0, 0.75, 0.75, 0.5, 0.5, 0.25, 0.25]); - - // A one-frame read is a single unattenuated frame, as in C. - let mut samples = vec![1.0f32; 2]; - apply_linear_fade_out(&mut samples, 2); - assert_eq!(samples, vec![1.0, 1.0]); - } - #[test] fn fade_in_ramp_spans_several_chunks() { let total = RATE * consts::FADE_DURATION_MS / 1000; // 2400 frames diff --git a/crates/irl-core/src/lib.rs b/crates/irl-core/src/lib.rs index a38ff77..b4adb60 100644 --- a/crates/irl-core/src/lib.rs +++ b/crates/irl-core/src/lib.rs @@ -29,7 +29,7 @@ pub mod video_delay; pub mod video_time; pub use audio_buffer::{AudioBuffer, BufferState}; -pub use audio_hold::{AudioHold, HoldChange, HoldTuning}; +pub use audio_hold::{AudioHold, HoldChange}; pub use config::{HwDecode, Watermarks}; pub use dsp::LastSample; pub use pacing::{DueVerdict, PacedFrame, PacingQueue}; diff --git a/crates/irl-core/src/pts_repair.rs b/crates/irl-core/src/pts_repair.rs index 9074683..2c6ac8f 100644 --- a/crates/irl-core/src/pts_repair.rs +++ b/crates/irl-core/src/pts_repair.rs @@ -230,11 +230,6 @@ impl PtsRepair { Some((self.last_pts, self.last_duration)) } - /// Whether a reference PTS has been seen (`pts_repair.initialised`). - pub fn is_initialised(&self) -> bool { - self.initialised - } - /// The stream time base the repair state was built with. pub fn time_base(&self) -> (i32, i32) { (self.tb_num, self.tb_den) @@ -542,7 +537,6 @@ mod tests { let mut r = repair(); r.evaluate(500, DUR); r.reset(); - assert!(!r.is_initialised()); assert_eq!(r.last(), None); let v = r.evaluate(9_000, DUR); assert_eq!(v.action, PtsAction::Pass); diff --git a/crates/irl-source/src/log.rs b/crates/irl-source/src/log.rs index 4e46636..a21aa84 100644 --- a/crates/irl-source/src/log.rs +++ b/crates/irl-source/src/log.rs @@ -20,7 +20,6 @@ macro_rules! irl_info { ($($arg:tt)*) => { irl_log!(::obs::log::Level::Info, $($arg)*) }; } -#[allow(unused_macros)] macro_rules! irl_debug { ($($arg:tt)*) => { irl_log!(::obs::log::Level::Debug, $($arg)*) }; } diff --git a/crates/obs/src/panic.rs b/crates/obs/src/panic.rs index a69e5c3..cdcf193 100644 --- a/crates/obs/src/panic.rs +++ b/crates/obs/src/panic.rs @@ -5,7 +5,7 @@ //! through [`guard`], which converts a panic into one `LOG_ERROR` line and a //! caller-supplied default. -use std::panic::{AssertUnwindSafe, UnwindSafe, catch_unwind}; +use std::panic::{AssertUnwindSafe, catch_unwind}; use crate::log::{Level, blog_prefixed}; @@ -43,11 +43,3 @@ pub fn payload_message(payload: &(dyn std::any::Any + Send)) -> String { "".to_string() } } - -#[doc(hidden)] -pub fn _assert_unwind_safe(t: T) -> AssertUnwindSafe { - AssertUnwindSafe(t) -} - -#[doc(hidden)] -pub fn _is_unwind_safe(_: &T) {} From ad0cfef0f7c4c60d7a75161dab68913b247ffe6c Mon Sep 17 00:00:00 2001 From: datagutt Date: Fri, 2 Oct 2026 01:23:58 +0200 Subject: [PATCH 02/28] docs(workspace): drop port-process tags and comments that describe the old decode path --- crates/irl-core/src/consts.rs | 12 +++++------ crates/irl-core/src/pacing.rs | 4 ---- crates/irl-core/src/stats.rs | 2 +- crates/irl-source/src/audio/mod.rs | 5 ++--- crates/irl-source/src/audio/pump.rs | 4 ++-- crates/irl-source/src/receiver/audio_in.rs | 3 +-- crates/irl-source/src/receiver/decode.rs | 3 ++- crates/irl-source/src/receiver/mod.rs | 20 +++++++++---------- crates/irl-source/src/receiver/stream.rs | 2 +- crates/irl-source/src/shared.rs | 23 +++++++++------------- crates/irl-source/src/source.rs | 13 ++++++------ crates/irl-source/src/video/mod.rs | 3 +-- crates/irl-source/src/video/output.rs | 12 +++++------ crates/irl-source/src/video/thread.rs | 11 ++++++----- 14 files changed, 51 insertions(+), 66 deletions(-) diff --git a/crates/irl-core/src/consts.rs b/crates/irl-core/src/consts.rs index 647e3b1..1cd3b9c 100644 --- a/crates/irl-core/src/consts.rs +++ b/crates/irl-core/src/consts.rs @@ -26,14 +26,12 @@ pub const DEFAULT_BUFFER_TARGET_MS: i64 = 120; pub const BUFFER_TARGET_MIN_MS: i32 = 20; /// Target buffer property ceiling. /// -/// Not a limit of the controller — it is where holding the cushion stops +/// Not a limit of the controller: it is where holding the cushion stops /// being free. Every millisecond of audio buffer is also a millisecond of -/// decoded video held in the pacing queue (see [`VIDEO_PACING_MAX_FRAMES`] / -/// [`VIDEO_PACING_MAX_BYTES`]), and the whole target is paid as startup delay -/// before playback primes. High-bitrate uplinks with deep sender-side -/// buffering do stall for several seconds, though, and 2s could not ride -/// those out, so the ceiling is set by what the video side can still pace -/// rather than by what the audio side needs. +/// compressed video held in the packet queue, and the whole target is paid as +/// startup delay before playback primes. High-bitrate uplinks with deep +/// sender-side buffering do stall for several seconds, though, and 2s could +/// not ride those out. pub const BUFFER_TARGET_MAX_MS: i32 = 8000; /// Target buffer property step. pub const BUFFER_TARGET_STEP_MS: i32 = 10; diff --git a/crates/irl-core/src/pacing.rs b/crates/irl-core/src/pacing.rs index 9b87d39..83573bc 100644 --- a/crates/irl-core/src/pacing.rs +++ b/crates/irl-core/src/pacing.rs @@ -54,10 +54,6 @@ impl PacingQueue { /// `max_frames` / `max_bytes` are the *hard* bound: memory that must not be /// exceeded whatever the stream does, and reaching one means pacing has /// failed and frames go out early ([`DueVerdict::EmitEarly`], counted). - /// - /// Before the delay moved into the packet queue these were the same bound, - /// so a Target Buffer that needed more decoded frames than the byte ceiling - /// allowed silently degraded into permanent early emission. pub fn new(lead_ns: i64, max_frames: usize, max_bytes: usize) -> Self { Self { entries: VecDeque::new(), diff --git a/crates/irl-core/src/stats.rs b/crates/irl-core/src/stats.rs index 7411b6d..f18de89 100644 --- a/crates/irl-core/src/stats.rs +++ b/crates/irl-core/src/stats.rs @@ -24,7 +24,7 @@ pub enum StatValue { Bool(bool), } -/// The 28 stat fields in proc-declaration order. +/// The stat fields in proc-declaration order. pub const FIELDS: &[(&str, StatKind)] = &[ ("buffer_fill_ms", StatKind::Int), ("current_speed", StatKind::Float), diff --git a/crates/irl-source/src/audio/mod.rs b/crates/irl-source/src/audio/mod.rs index 75ea39f..03096fa 100644 --- a/crates/irl-source/src/audio/mod.rs +++ b/crates/irl-source/src/audio/mod.rs @@ -1,6 +1,5 @@ -//! Audio output side (port of the audio-thread half of `receiver-audio.c`). -//! W2-B owns this module. The functions here are the ones the other threads -//! call; their signatures are frozen. +//! Audio output side: the audio thread and the functions the other threads +//! call into it. pub mod hold; pub mod pump; diff --git a/crates/irl-source/src/audio/pump.rs b/crates/irl-source/src/audio/pump.rs index e018620..d5348a1 100644 --- a/crates/irl-source/src/audio/pump.rs +++ b/crates/irl-source/src/audio/pump.rs @@ -1,7 +1,7 @@ -//! `irl_pump_audio_once` and helpers (port of `src/receiver-audio.c`). W2-B. +//! The audio pump: drains the jitter buffer into OBS. //! //! Design contract with libobs (verified against `obs-source.c` / -//! `obs-audio.c`), unchanged from the C: +//! `obs-audio.c`): //! //! 1. OBS timestamps must be contiguous: `ts[n+1] = ts[n] + frames/rate`. //! Deviations under 70 ms are smoothed; 70 ms..2 s gaps are zero-filled diff --git a/crates/irl-source/src/receiver/audio_in.rs b/crates/irl-source/src/receiver/audio_in.rs index 69929ed..8330073 100644 --- a/crates/irl-source/src/receiver/audio_in.rs +++ b/crates/irl-source/src/receiver/audio_in.rs @@ -1,5 +1,4 @@ -//! Decoded audio intake on the receiver thread (port of -//! `irl_handle_audio_frame`, `receiver-audio.c:906-1127`). W2-B. +//! Decoded audio intake on the receiver thread. use std::sync::atomic::Ordering::Relaxed; diff --git a/crates/irl-source/src/receiver/decode.rs b/crates/irl-source/src/receiver/decode.rs index d5b742e..7ac9222 100644 --- a/crates/irl-source/src/receiver/decode.rs +++ b/crates/irl-source/src/receiver/decode.rs @@ -1,4 +1,5 @@ -//! Packet → decoder plumbing (port of `src/receiver-decode.c`). W2-A. +//! Packet routing: audio packets into the audio decoder, video packets onto +//! the video channel. use std::sync::atomic::Ordering::Relaxed; diff --git a/crates/irl-source/src/receiver/mod.rs b/crates/irl-source/src/receiver/mod.rs index 8efebe9..2eeb2fa 100644 --- a/crates/irl-source/src/receiver/mod.rs +++ b/crates/irl-source/src/receiver/mod.rs @@ -1,10 +1,10 @@ -//! Receiver thread (port of `src/receiver.c`; the thread body is W2-A's). +//! Receiver thread. //! -//! The receiver thread owns demux/decode. Decoded audio goes to -//! [`audio_in::AudioIntake`], decoded video to -//! [`crate::video::intake::VideoIntake`]; both are plain structs the receiver -//! holds and calls. State that more than one of decode / audio intake / video -//! intake touches lives in [`ReceiverFlags`] and is passed as `&mut`. +//! The receiver thread owns demux and the audio decoder. Decoded audio goes +//! to [`audio_in::AudioIntake`], a plain struct the receiver holds and calls; +//! video packets go onto the video channel undecoded. State that both the +//! packet path and the audio intake touch lives in [`ReceiverFlags`] and is +//! passed as `&mut`. pub mod audio_in; pub mod decode; @@ -20,9 +20,7 @@ use crate::receiver::audio_in::AudioIntake; use crate::shared::Shared; /// Receiver-thread state shared between the packet path (`decode.rs`) and -/// the two frame intakes. Every field is receiver-thread-only; the C kept -/// them on `struct irl_source` and reset them in `irl_prepare_new_connection` -/// / `irl_reset_stream_timing_state`. +/// the audio intake. Every field is receiver-thread-only. #[derive(Debug, Default)] pub struct ReceiverFlags { /// Which streams the current connection carries (`audio_stream_idx >= 0` @@ -200,8 +198,8 @@ impl Receiver { } self.close_ffmpeg(); - // Queued frames pin decoder surfaces; the run is over, so free them - // rather than leave them behind on the shared state. + // The run is over: free the queued packets rather than leave them + // behind on the shared state. self.shared.video.drain(); } diff --git a/crates/irl-source/src/receiver/stream.rs b/crates/irl-source/src/receiver/stream.rs index 3d7ec15..9e01ddf 100644 --- a/crates/irl-source/src/receiver/stream.rs +++ b/crates/irl-source/src/receiver/stream.rs @@ -1,4 +1,4 @@ -//! Stream open/close, reconnection, stats line (port of `src/receiver-stream.c`). W2-A. +//! Stream open/close, reconnection, stats line. use std::ffi::CString; use std::sync::atomic::Ordering::Relaxed; diff --git a/crates/irl-source/src/shared.rs b/crates/irl-source/src/shared.rs index 88d2aee..727397a 100644 --- a/crates/irl-source/src/shared.rs +++ b/crates/irl-source/src/shared.rs @@ -1,24 +1,19 @@ //! State shared between the OBS thread and the three worker threads for one //! run of the receiver (`start_receiver` … `stop_receiver`). //! -//! This file is the decomposition of the C `struct irl_source` into owners. -//! It is written by the orchestrator and frozen: agents that need a change -//! request it rather than editing here. -//! -//! Ownership map (C field → Rust home): +//! Ownership map: //! - OBS thread only (`source.rs`): `fit_pending`, `media_stopped`, //! `close_when_inactive`, the authoritative `Config`, the thread handles. -//! - [`Shared`]: built fresh at every `start_receiver` (this replaces the C -//! `reset_runtime_state()`: everything it zeroed is a field here and starts -//! zeroed; everything it deliberately kept lives in [`LifetimeStats`]). -//! - [`AudioState`] under `Shared::audio_state` (the C `audio_state_lock`). -//! The audio pump locks it **once** per `pump_once` and passes `&mut` down; -//! nothing below may lock it again. +//! - [`Shared`]: built fresh at every `start_receiver`, so per-run state +//! starts zeroed; what has to survive a restart lives in [`LifetimeStats`]. +//! - [`AudioState`] under `Shared::audio_state`. The audio pump locks it +//! **once** per `pump_once` and passes `&mut` down; nothing below may lock +//! it again. //! - `Shared::audio_buf`: the jitter buffer under its own lock. Lock order: -//! `audio_state` → `audio_buf`. `video.q` is never held together with -//! either. +//! `audio_state` → `audio_buf` → `hot.watermarks`. `video.q` is never held +//! together with any of them. //! - [`ConnStats`] / [`LifetimeStats`]: counters as relaxed atomics, readable -//! from any thread without a lock (the C read them unsynchronised). +//! from any thread without a lock. //! - Receiver-thread-owned, video-thread-owned and audio-thread-owned state //! are plain structs inside those threads (`receiver/mod.rs`, //! `video/thread.rs`, `audio/pump.rs`); they are not in this file. diff --git a/crates/irl-source/src/source.rs b/crates/irl-source/src/source.rs index 9f49ef9..fd3e4bf 100644 --- a/crates/irl-source/src/source.rs +++ b/crates/irl-source/src/source.rs @@ -404,7 +404,7 @@ fn stop_receiver(state: &mut ObsState, source: SourceHandle, clear_video: bool) let _ = running.video.join(); let _ = running.audio.join(); let _ = running.receiver.join(); - // Whatever the video thread never got to; frees the pinned surfaces. + // Whatever the video thread never got to. running.shared.video.drain(); // The joins block the calling thread, which for the media controls // and show/hide is OBS's graphics thread: this is how long the whole @@ -461,12 +461,6 @@ fn fit_to_canvas(source: SourceHandle) { // ── Stats ───────────────────────────────────────────────────── -/// Snapshot every stat, consistently: the audio state (and, under it, the -/// jitter buffer) is locked once, exactly as the C did. -/// -/// With no run in progress the per-connection counters read zero — the C read -/// the same fields after `reset_runtime_state` had zeroed them — while the -/// lifetime counters and the settings-derived flags still report. /// `av_skew_ms`: video PTS minus audio PTS of what last reached the plugin, /// in ms. Both values are stamped by the receiver at arrival (audio as it /// writes the jitter buffer, video as it pushes the packet), so the @@ -483,6 +477,11 @@ pub(crate) fn av_skew_ms(shared: &Shared, audio: &AudioState) -> i64 { (video_pts_ns - audio.latest_audio_stream_pts_ns) / 1_000_000 } +/// Snapshot every stat, consistently: the audio state (and, under it, the +/// jitter buffer) is locked once. +/// +/// With no run in progress the per-connection counters read zero, while the +/// lifetime counters and the settings-derived flags still report. fn snapshot(state: &ObsState, lifetime: &LifetimeStats) -> StatsSnapshot { let mut snap = StatsSnapshot { current_speed: 1.0, diff --git a/crates/irl-source/src/video/mod.rs b/crates/irl-source/src/video/mod.rs index 8ee5596..0585a95 100644 --- a/crates/irl-source/src/video/mod.rs +++ b/crates/irl-source/src/video/mod.rs @@ -1,5 +1,4 @@ -//! Video path (port of `receiver-video.c` + `video-handler.c`). W2-C owns -//! this module; the signatures here are frozen. +//! Video path: the video thread, its decoder and the hand-over to libobs. pub mod decode; pub mod intake; diff --git a/crates/irl-source/src/video/output.rs b/crates/irl-source/src/video/output.rs index 798337d..8f31c11 100644 --- a/crates/irl-source/src/video/output.rs +++ b/crates/irl-source/src/video/output.rs @@ -1,8 +1,8 @@ -//! HW transfer, format mapping, OBS output (port of `src/video-handler.c`). W2-C. +//! HW transfer, format mapping, OBS output. //! //! Everything here belongs to the video thread, so it is written as methods on -//! [`VideoThread`] (whose state the C kept on `struct irl_source`), plus the -//! pure mapping helpers, which stay free functions so tests can table them. +//! [`VideoThread`], plus the pure mapping helpers, which stay free functions so +//! tests can table them. use std::sync::OnceLock; use std::sync::atomic::Ordering::Relaxed; @@ -194,9 +194,9 @@ impl VideoThread { /// speed changes. Without that mapping it falls back to the video-only /// wall-clock anchor. pub fn due_time(&mut self, frame: &Frame) -> u64 { - // `frame.pts()` is already in nanoseconds: the receiver thread - // rescaled it before queueing, because the video thread must not touch - // the format context (it can be freed mid-reconnect). + // `frame.pts()` is already in nanoseconds: intake rescales it with the + // time base captured at stream open, because the video thread must not + // touch the format context (it can be freed mid-reconnect). let pts_ns = frame.pts(); let now = obs::time::gettime_ns(); diff --git a/crates/irl-source/src/video/thread.rs b/crates/irl-source/src/video/thread.rs index 1091a8b..d6b3704 100644 --- a/crates/irl-source/src/video/thread.rs +++ b/crates/irl-source/src/video/thread.rs @@ -1,9 +1,10 @@ -//! Video thread loop and pacing (port of `src/receiver-video.c:13-287`). W2-C. +//! Video thread loop and pacing. //! -//! The thread pops decoded frames off [`VideoChannel`](crate::shared::VideoChannel), -//! copies them out of the hardware pool immediately (which returns the -//! decoder's surface), then holds them in a thread-private pacing queue until -//! their mapped timestamp is due — the way OBS's own media source paces in +//! The thread pops compressed packets off [`VideoChannel`](crate::shared::VideoChannel), +//! decodes each one as it approaches its due time, copies the frame out of the +//! hardware pool immediately (which returns the decoder's surface), then holds +//! it in a thread-private pacing queue until its mapped timestamp is due, the +//! way OBS's own media source paces in //! `mp_media_sleep`. Handing libobs a frame early makes libobs hold it, and //! past `MAX_ASYNC_FRAMES` (30) held frames `cache_video` silently discards the //! whole queue, so this queue is what keeps libobs's async queue about one From d71587bb7d005a8e840c4a9e52148b83b453f5ab Mon Sep 17 00:00:00 2001 From: datagutt Date: Fri, 2 Oct 2026 01:25:37 +0200 Subject: [PATCH 03/28] fix(irl-source): low-latency clock suspend reads the pump's injected clock the reset recovery hold and the packet keyframe gate timeout move into irl_core::consts with the other tuning values. --- crates/irl-core/src/consts.rs | 8 ++++++++ crates/irl-source/src/audio/pump.rs | 10 +++------- crates/irl-source/src/receiver/decode.rs | 6 +++++- crates/irl-source/src/receiver/stream.rs | 6 +++++- crates/irl-source/src/video/decode.rs | 6 +----- 5 files changed, 22 insertions(+), 14 deletions(-) diff --git a/crates/irl-core/src/consts.rs b/crates/irl-core/src/consts.rs index 1cd3b9c..e73bfa5 100644 --- a/crates/irl-core/src/consts.rs +++ b/crates/irl-core/src/consts.rs @@ -99,6 +99,12 @@ pub const BLEED_PACE_FILL_MS: i32 = 1000; pub const AUDIO_OFFSET_REANCHOR_MARGIN_MS: i64 = 400; /// Recovery hold after an underrun (microseconds). pub const AUDIO_RECOVERY_HOLD_US: u64 = 1_500_000; +/// Recovery hold after the audio decoder was flushed or the stream timeline +/// was reset (microseconds). +pub const AUDIO_RESET_RECOVERY_HOLD_US: u64 = 2_500_000; +/// How long the packet-level keyframe gate waits before giving up and feeding +/// the decoder whatever arrives (microseconds). +pub const VIDEO_PKT_GATE_TIMEOUT_US: u64 = 5_000_000; /// Hidden backlog trim trigger above target. pub const AUDIO_TRIM_TRIGGER_MS: i32 = 90; /// Fade applied when resuming from concealment. @@ -464,6 +470,8 @@ mod tests { assert_eq!(BLEED_PACE_FILL_MS, 1000); // IRL_BLEED_PACE_FILL_MS assert_eq!(AUDIO_OFFSET_REANCHOR_MARGIN_MS, 400); // AUDIO_OFFSET_REANCHOR_MARGIN_MS assert_eq!(AUDIO_RECOVERY_HOLD_US, 1_500_000); // receiver-audio.c + assert_eq!(AUDIO_RESET_RECOVERY_HOLD_US, 2_500_000); + assert_eq!(VIDEO_PKT_GATE_TIMEOUT_US, 5_000_000); assert_eq!(AUDIO_TRIM_TRIGGER_MS, 90); // receiver-audio.c assert_eq!(AUDIO_CONCEAL_FADE_MS, 8); // receiver-audio.c assert_eq!(AUDIO_OUT_LEAD_MS, 80); // receiver-audio.c diff --git a/crates/irl-source/src/audio/pump.rs b/crates/irl-source/src/audio/pump.rs index d5348a1..d73191a 100644 --- a/crates/irl-source/src/audio/pump.rs +++ b/crates/irl-source/src/audio/pump.rs @@ -213,7 +213,7 @@ impl AudioPump { // thread: that mode emits no concealment, so nothing there can // advance the sample counter. Stand the clock down instead. if low_latency && !has_audio { - suspend_low_latency_clock(shared, state, now - next_ts); + suspend_low_latency_clock(shared, state, now - next_ts, (self.now_us)()); return false; } shared.conn.audio_output_restarts.fetch_add(1, Relaxed); @@ -614,7 +614,7 @@ impl BufferFormat { /// new clock when a real chunk arrives. Counted as an underrun, which is what /// it is. Buffered mode is untouched: its concealment keeps the counter moving, /// so a late clock there really is an output-side stall. -fn suspend_low_latency_clock(shared: &Shared, state: &mut AudioState, lag_ns: u64) { +fn suspend_low_latency_clock(shared: &Shared, state: &mut AudioState, lag_ns: u64, now_us: u64) { state.primed = false; state.anchor_ns = 0; state.samples = 0; @@ -626,11 +626,7 @@ fn suspend_low_latency_clock(shared: &Shared, state: &mut AudioState, lag_ns: u6 shared.conn.audio_underruns.fetch_add(1, Relaxed); shared.conn.audio_quality_events.fetch_add(1, Relaxed); - super::mark_audio_recovery( - state, - ffmpeg::gettime_us() as u64, - consts::AUDIO_RECOVERY_HOLD_US, - ); + super::mark_audio_recovery(state, now_us, consts::AUDIO_RECOVERY_HOLD_US); irl_warn!( "Low-latency audio input empty for {}ms; suspending output clock until audio resumes", lag_ns / 1_000_000 diff --git a/crates/irl-source/src/receiver/decode.rs b/crates/irl-source/src/receiver/decode.rs index 7ac9222..cf71456 100644 --- a/crates/irl-source/src/receiver/decode.rs +++ b/crates/irl-source/src/receiver/decode.rs @@ -85,7 +85,11 @@ fn drain_audio_frames( buf.flush(); } audio::reset_audio_timing_state(shared, &mut state); - audio::mark_audio_recovery(&mut state, now_us, 2_500_000); + audio::mark_audio_recovery( + &mut state, + now_us, + consts::AUDIO_RESET_RECOVERY_HOLD_US, + ); } reinit_audio_pts_repair(audio_in, shared, audio_tb); } diff --git a/crates/irl-source/src/receiver/stream.rs b/crates/irl-source/src/receiver/stream.rs index 9e01ddf..534e624 100644 --- a/crates/irl-source/src/receiver/stream.rs +++ b/crates/irl-source/src/receiver/stream.rs @@ -491,7 +491,11 @@ impl Receiver { buf.flush(); } audio::reset_stream_timing_state(&shared, &mut state); - audio::mark_audio_recovery(&mut state, ffmpeg::gettime_us() as u64, 2_500_000); + audio::mark_audio_recovery( + &mut state, + ffmpeg::gettime_us() as u64, + consts::AUDIO_RESET_RECOVERY_HOLD_US, + ); state.fade_in_pending = true; shared.conn.video_corrupt_frames.store(0, Relaxed); shared.conn.video_corrupt_held.store(0, Relaxed); diff --git a/crates/irl-source/src/video/decode.rs b/crates/irl-source/src/video/decode.rs index e14f6ca..955eea3 100644 --- a/crates/irl-source/src/video/decode.rs +++ b/crates/irl-source/src/video/decode.rs @@ -15,10 +15,6 @@ use irl_core::consts; use crate::shared::{Shared, VideoDecoder}; use crate::video::intake::{self, DecodeState}; -/// How long the packet-level keyframe gate waits before giving up and feeding -/// the decoder whatever arrives (`receiver-decode.c`). -const VIDEO_PKT_GATE_TIMEOUT_US: u64 = 5_000_000; - fn should_log_warning(last_warning_us: &mut u64, now_us: u64) -> bool { if *last_warning_us != 0 && now_us - *last_warning_us < consts::DECODER_WARNING_INTERVAL_US { return false; @@ -63,7 +59,7 @@ fn gate_open(shared: &Shared, state: &mut DecodeState, is_key: bool) -> bool { if state.pkt_gate_start_us == 0 { state.pkt_gate_start_us = now_us; } - if now_us - state.pkt_gate_start_us < VIDEO_PKT_GATE_TIMEOUT_US { + if now_us - state.pkt_gate_start_us < consts::VIDEO_PKT_GATE_TIMEOUT_US { return false; } state.pkt_gate_open = true; From 12cf0e4dcd9bb6d230035a8d4794c9e4df7c8787 Mon Sep 17 00:00:00 2001 From: datagutt Date: Fri, 2 Oct 2026 01:29:15 +0200 Subject: [PATCH 04/28] refactor(workspace): read pts repair gaps and network buffer from consts --- crates/irl-core/src/url_opts.rs | 76 ++++++++----------- crates/irl-source/src/config.rs | 2 - crates/irl-source/src/receiver/audio_in.rs | 32 ++------ crates/irl-source/src/receiver/decode.rs | 11 +-- crates/irl-source/src/receiver/mod.rs | 2 +- crates/irl-source/src/receiver/stream.rs | 7 +- crates/irl-source/src/shared.rs | 2 - crates/irl-source/tests/audio_core.rs | 11 +-- crates/irl-source/tests/network_sim.rs | 6 +- crates/irl-source/tests/provider_seam.rs | 2 - .../irl-source/tests/receiver_stream_open.rs | 18 +---- crates/irl-source/tests/shell_config.rs | 2 - crates/irl-source/tests/video_pipeline.rs | 2 - 13 files changed, 48 insertions(+), 125 deletions(-) diff --git a/crates/irl-core/src/url_opts.rs b/crates/irl-core/src/url_opts.rs index 5289f2e..5b70151 100644 --- a/crates/irl-core/src/url_opts.rs +++ b/crates/irl-core/src/url_opts.rs @@ -31,7 +31,6 @@ fn borrowed(key: &'static str, value: &'static str) -> (Cow<'static, str>, Cow<' pub fn demuxer_options( url: &str, extra: Option<&str>, - network_buffer_mb: i64, fast_probe: bool, ) -> Vec<(Cow<'static, str>, Cow<'static, str>)> { let mut opts: Vec<(Cow<'static, str>, Cow<'static, str>)> = Vec::with_capacity(20); @@ -75,26 +74,24 @@ pub fn demuxer_options( // keeps working setups working; `extra` can turn it back on. opts.push(borrowed("tls_verify", "0")); - if network_buffer_mb > 0 { - let bytes = (network_buffer_mb * 1024 * 1024).to_string(); - // "buffer_size" is bytes for udp:// (and rtp/rtsp, which forward it), - // but librist reuses the name for its recovery window in - // milliseconds, declared with a max of 30000 — a byte count there - // fails avformat_open_input outright with ERANGE. - if !has_scheme(url, "rist") { - opts.push(owned("buffer_size", bytes.clone())); - } - // "recv_buffer_size" is bytes for tcp:// and libsrt, and is what - // gives the setting any effect on rtmp(s):// and http(s)://. - opts.push(owned("recv_buffer_size", bytes)); - - // udp:// also has a userspace ring between its receive thread and the - // demuxer, sized in 188-byte TS packets (default 7*4096 ≈ 5.3 MB). - // Grow it with the setting, never shrink it. - let fifo_pkts = network_buffer_mb * 1024 * 1024 / 188; - if fifo_pkts > consts::UDP_FIFO_DEFAULT_PACKETS { - opts.push(owned("fifo_size", fifo_pkts.to_string())); - } + let bytes = (consts::NETWORK_BUFFER_MB * 1024 * 1024).to_string(); + // "buffer_size" is bytes for udp:// (and rtp/rtsp, which forward it), + // but librist reuses the name for its recovery window in + // milliseconds, declared with a max of 30000 — a byte count there + // fails avformat_open_input outright with ERANGE. + if !has_scheme(url, "rist") { + opts.push(owned("buffer_size", bytes.clone())); + } + // "recv_buffer_size" is bytes for tcp:// and libsrt, and is what + // gives the setting any effect on rtmp(s):// and http(s)://. + opts.push(owned("recv_buffer_size", bytes)); + + // udp:// also has a userspace ring between its receive thread and the + // demuxer, sized in 188-byte TS packets (default 7*4096 ≈ 5.3 MB). + // Grow it with the buffer, never shrink it. + let fifo_pkts = consts::NETWORK_BUFFER_MB * 1024 * 1024 / 188; + if fifo_pkts > consts::UDP_FIFO_DEFAULT_PACKETS { + opts.push(owned("fifo_size", fifo_pkts.to_string())); } if has_scheme(url, "srt") { @@ -253,18 +250,18 @@ mod tests { #[test] fn fast_probe_selects_the_one_megabyte_probe() { - let opts = demuxer_options("srt://h:1", None, 2, true); + let opts = demuxer_options("srt://h:1", None, true); assert_eq!(find(&opts, "probesize"), Some("1000000")); assert_eq!(find(&opts, "analyzeduration"), Some("1000000")); - let opts = demuxer_options("srt://h:1", None, 2, false); + let opts = demuxer_options("srt://h:1", None, false); assert_eq!(find(&opts, "probesize"), Some("5000000")); assert_eq!(find(&opts, "analyzeduration"), Some("5000000")); } #[test] fn the_unconditional_options_are_always_present() { - let opts = demuxer_options("rtmp://h/live/key", None, 2, false); + let opts = demuxer_options("rtmp://h/live/key", None, false); assert_eq!(find(&opts, "fflags"), Some("+genpts")); assert_eq!(find(&opts, "merge_pmt_versions"), Some("1")); assert_eq!(find(&opts, "overrun_nonfatal"), Some("1")); @@ -280,50 +277,38 @@ mod tests { #[test] fn tls_verify_is_off_by_default() { - let opts = demuxer_options("rtmps://h/live/key", None, 2, false); + let opts = demuxer_options("rtmps://h/live/key", None, false); assert_eq!(find(&opts, "tls_verify"), Some("0")); } #[test] fn srt_gets_the_latency_window() { - let opts = demuxer_options("srt://h:1234?streamid=x", None, 2, false); + let opts = demuxer_options("srt://h:1234?streamid=x", None, false); assert_eq!(find(&opts, "latency"), Some("200000")); - let opts = demuxer_options("rtmp://h/live/key", None, 2, false); + let opts = demuxer_options("rtmp://h/live/key", None, false); assert!(!has(&opts, "latency")); } #[test] fn rist_omits_buffer_size_but_keeps_recv_buffer_size() { - let opts = demuxer_options("rist://h:1234", None, 2, false); + let opts = demuxer_options("rist://h:1234", None, false); assert!( !has(&opts, "buffer_size"), "librist reads buffer_size as milliseconds and fails on a byte count" ); assert_eq!(find(&opts, "recv_buffer_size"), Some("2097152")); - let opts = demuxer_options("udp://h:1234", None, 2, false); + let opts = demuxer_options("udp://h:1234", None, false); assert_eq!(find(&opts, "buffer_size"), Some("2097152")); assert_eq!(find(&opts, "recv_buffer_size"), Some("2097152")); } - #[test] - fn no_buffer_options_without_a_buffer_size() { - let opts = demuxer_options("udp://h:1234", None, 0, false); - assert!(!has(&opts, "buffer_size")); - assert!(!has(&opts, "recv_buffer_size")); - assert!(!has(&opts, "fifo_size")); - } - #[test] fn fifo_size_is_only_set_above_ffmpegs_own_default() { - // The 2 MB default is ~11 154 packets, below FFmpeg's 7*4096. - let opts = demuxer_options("udp://h:1234", None, consts::NETWORK_BUFFER_MB, false); + // The 2 MB buffer is ~11 154 packets, below FFmpeg's 7*4096. + let opts = demuxer_options("udp://h:1234", None, false); assert!(!has(&opts, "fifo_size")); - - // 8 MB is ~44 620 packets, above it. - let opts = demuxer_options("udp://h:1234", None, 8, false); - assert_eq!(find(&opts, "fifo_size"), Some("44620")); } #[test] @@ -331,7 +316,6 @@ mod tests { let opts = demuxer_options( "srt://h:1234", Some("probesize=32 latency=500000 tls_verify=1 ca_file=/tmp/ca.pem"), - 2, true, ); assert_eq!(find(&opts, "probesize"), Some("32")); @@ -361,13 +345,13 @@ mod tests { // A trailing '=' is an empty value, as av_dict_set would store it. assert_eq!(parse_extra("k="), vec![("k".into(), "".into())]); - let opts = demuxer_options("srt://h:1", Some("garbage more_garbage"), 2, false); + let opts = demuxer_options("srt://h:1", Some("garbage more_garbage"), false); assert_eq!(find(&opts, "probesize"), Some("5000000")); } #[test] fn option_order_matches_the_c_dictionary_writes() { - let opts = demuxer_options("srt://h:1", Some("x=1"), 2, true); + let opts = demuxer_options("srt://h:1", Some("x=1"), true); let keys: Vec<&str> = opts.iter().map(|(k, _)| k.as_ref()).collect(); assert_eq!( keys, diff --git a/crates/irl-source/src/config.rs b/crates/irl-source/src/config.rs index 86e9733..9174420 100644 --- a/crates/irl-source/src/config.rs +++ b/crates/irl-source/src/config.rs @@ -52,8 +52,6 @@ impl Config { ffmpeg_options: settings.get_str(c"ffmpeg_options"), hw_decode, low_latency_audio: settings.get_bool(c"low_latency_audio"), - small_gap_ms: consts::SMALL_GAP_MS, - large_gap_ms: consts::LARGE_GAP_MS, }, hot: HotValues { reconnect_delay_s: settings.get_i64(c"reconnect_delay") as i32, diff --git a/crates/irl-source/src/receiver/audio_in.rs b/crates/irl-source/src/receiver/audio_in.rs index 8330073..ffa3123 100644 --- a/crates/irl-source/src/receiver/audio_in.rs +++ b/crates/irl-source/src/receiver/audio_in.rs @@ -6,7 +6,7 @@ use ffmpeg::{AVSampleFormat, Rational, Resampler}; use irl_core::{LastSample, PtsAction, PtsRepair, consts, dsp, timing}; use crate::receiver::ReceiverFlags; -use crate::shared::{Shared, StreamConfig}; +use crate::shared::Shared; /// Bytes one interleaved float sample occupies (`sizeof(float)`). const BYTES_PER_SAMPLE: i32 = 4; @@ -17,13 +17,12 @@ const NS_TB: Rational = Rational::new(1, 1_000_000_000); /// Receiver-thread audio state: the input resampler, its scratch buffer, the /// PTS repair state machine and the last-sample memory used for silence /// shaping. +#[derive(Default)] pub struct AudioIntake { swr: Option, scratch: Vec, pts: Option, last_sample: LastSample, - small_gap_ms: i32, - large_gap_ms: i32, /// Byte ⇄ float view for the silence shaping and the post-silence fade; /// see the note on `audio::pump::FloatEdit` (this crate forbids the /// unsafe cast, and both edits are off the steady-state path). @@ -31,19 +30,6 @@ pub struct AudioIntake { } impl AudioIntake { - /// Fresh intake for a run; PTS-repair thresholds come from `cfg`. - pub fn new(cfg: &StreamConfig) -> Self { - Self { - swr: None, - scratch: Vec::new(), - pts: None, - last_sample: LastSample::default(), - small_gap_ms: cfg.small_gap_ms, - large_gap_ms: cfg.large_gap_ms, - float: Vec::new(), - } - } - /// Per-connection reset (`irl_prepare_new_connection` for the audio /// fields): drops the resampler, resets PTS repair. pub fn reset(&mut self) { @@ -56,23 +42,15 @@ impl AudioIntake { /// (Re)initialise PTS repair for the audio stream's time base /// (`pts_repair_init` at decoder open and after a decoder flush). - pub fn init_pts_repair(&mut self, cfg: &StreamConfig, tb: ffmpeg::Rational) { - self.small_gap_ms = cfg.small_gap_ms; - self.large_gap_ms = cfg.large_gap_ms; + pub fn init_pts_repair(&mut self, tb: ffmpeg::Rational) { self.pts = Some(PtsRepair::new( - cfg.small_gap_ms, - cfg.large_gap_ms, + consts::SMALL_GAP_MS, + consts::LARGE_GAP_MS, tb.num, tb.den, )); } - /// The PTS repair state (the decode path calls `reset` on it after a - /// decoder flush). - pub fn pts_repair(&mut self) -> Option<&mut PtsRepair> { - self.pts.as_mut() - } - /// One decoded frame: format change handling, PTS repair, warm-up /// discard, silence insertion, resample to interleaved f32, pre-keyframe /// discard, write to the jitter buffer, publish diff --git a/crates/irl-source/src/receiver/decode.rs b/crates/irl-source/src/receiver/decode.rs index cf71456..6275863 100644 --- a/crates/irl-source/src/receiver/decode.rs +++ b/crates/irl-source/src/receiver/decode.rs @@ -34,15 +34,6 @@ fn should_flush_decoder(last_flush_time_us: &mut u64, now_us: u64) -> bool { true } -/// `reinit_audio_pts_repair`: the repair state machine restarts on the same -/// time base after a decoder flush. -fn reinit_audio_pts_repair(audio_in: &mut AudioIntake, shared: &Shared, audio_tb: Rational) { - if let Some(repair) = audio_in.pts_repair() { - repair.reset(); - } - audio_in.init_pts_repair(&shared.cfg, audio_tb); -} - /// Drain everything the audio decoder has ready. fn drain_audio_frames( dec: &mut CodecContext, @@ -91,7 +82,7 @@ fn drain_audio_frames( consts::AUDIO_RESET_RECOVERY_HOLD_US, ); } - reinit_audio_pts_repair(audio_in, shared, audio_tb); + audio_in.init_pts_repair(audio_tb); } flags.audio_decode_errors = 0; } diff --git a/crates/irl-source/src/receiver/mod.rs b/crates/irl-source/src/receiver/mod.rs index 2eeb2fa..6d43191 100644 --- a/crates/irl-source/src/receiver/mod.rs +++ b/crates/irl-source/src/receiver/mod.rs @@ -86,7 +86,7 @@ impl Receiver { fn new(shared: Arc) -> Option { let pkt = ffmpeg::Packet::new().ok()?; let frame = ffmpeg::Frame::new().ok()?; - let audio_in = AudioIntake::new(&shared.cfg); + let audio_in = AudioIntake::default(); Some(Self { shared, fmt: None, diff --git a/crates/irl-source/src/receiver/stream.rs b/crates/irl-source/src/receiver/stream.rs index 534e624..a4ef5ec 100644 --- a/crates/irl-source/src/receiver/stream.rs +++ b/crates/irl-source/src/receiver/stream.rs @@ -203,7 +203,6 @@ impl Receiver { for (key, value) in irl_core::url_opts::demuxer_options( &url_str, self.shared.cfg.ffmpeg_options.as_deref(), - consts::NETWORK_BUFFER_MB, fast_probe, ) { let (Ok(key), Ok(value)) = (CString::new(key.as_ref()), CString::new(value.as_ref())) @@ -273,11 +272,7 @@ impl Receiver { ); if self.audio_stream_idx >= 0 { - let tb = self.audio_tb; - let Self { - audio_in, shared, .. - } = self; - audio_in.init_pts_repair(&shared.cfg, tb); + self.audio_in.init_pts_repair(self.audio_tb); } true diff --git a/crates/irl-source/src/shared.rs b/crates/irl-source/src/shared.rs index 727397a..a9001af 100644 --- a/crates/irl-source/src/shared.rs +++ b/crates/irl-source/src/shared.rs @@ -40,8 +40,6 @@ pub struct StreamConfig { pub ffmpeg_options: Option, pub hw_decode: HwDecode, pub low_latency_audio: bool, - pub small_gap_ms: i32, - pub large_gap_ms: i32, } /// Settings swapped in place while the workers run (`config_apply_hot`). diff --git a/crates/irl-source/tests/audio_core.rs b/crates/irl-source/tests/audio_core.rs index bfbbd5e..ed96eb2 100644 --- a/crates/irl-source/tests/audio_core.rs +++ b/crates/irl-source/tests/audio_core.rs @@ -78,8 +78,6 @@ fn stream_config(low_latency: bool) -> StreamConfig { ffmpeg_options: None, hw_decode: HwDecode::Auto, low_latency_audio: low_latency, - small_gap_ms: consts::SMALL_GAP_MS, - large_gap_ms: consts::LARGE_GAP_MS, } } @@ -170,8 +168,8 @@ fn planar_aac_samples_follow_the_video_playout_mapping() { .with_clock(Box::new(move || ns.load(Relaxed))) .with_us_clock(Box::new(move || us.load(Relaxed) / 1000)); let tb = ffmpeg::Rational::new(1, 90_000); - let mut intake = AudioIntake::new(&shared.cfg); - intake.init_pts_repair(&shared.cfg, tb); + let mut intake = AudioIntake::default(); + intake.init_pts_repair(tb); let mut flags = ReceiverFlags::default(); let mut worst_ns = 0i64; let mut checked = 0; @@ -699,11 +697,10 @@ fn the_buffer_settles_on_the_configured_target_with_aac_chunks() { #[test] fn intake_discards_warmup_then_buffers_decoded_audio() { let shared = make_shared(false, true); - let cfg = stream_config(false); let tb = ffmpeg::Rational::new(1, RATE); - let mut intake = AudioIntake::new(&cfg); - intake.init_pts_repair(&cfg, tb); + let mut intake = AudioIntake::default(); + intake.init_pts_repair(tb); let mut flags = ReceiverFlags::default(); let frames = 20; diff --git a/crates/irl-source/tests/network_sim.rs b/crates/irl-source/tests/network_sim.rs index 9e14a0f..b4acc51 100644 --- a/crates/irl-source/tests/network_sim.rs +++ b/crates/irl-source/tests/network_sim.rs @@ -156,8 +156,6 @@ impl Sim { ffmpeg_options: None, hw_decode: HwDecode::Off, low_latency_audio, - small_gap_ms: consts::SMALL_GAP_MS, - large_gap_ms: consts::LARGE_GAP_MS, }; let shared = Shared::new( source, @@ -190,8 +188,8 @@ impl Sim { // What the decode path does when the audio decoder opens; without it // the intake has no PTS-repair state and discards every frame. - let mut intake = AudioIntake::new(&cfg); - intake.init_pts_repair(&cfg, TB_NS); + let mut intake = AudioIntake::default(); + intake.init_pts_repair(TB_NS); Self { intake, diff --git a/crates/irl-source/tests/provider_seam.rs b/crates/irl-source/tests/provider_seam.rs index 0abd59f..f541f8d 100644 --- a/crates/irl-source/tests/provider_seam.rs +++ b/crates/irl-source/tests/provider_seam.rs @@ -26,8 +26,6 @@ fn url_only(url: &str) -> Config { ffmpeg_options: None, hw_decode: HwDecode::Auto, low_latency_audio: false, - small_gap_ms: consts::SMALL_GAP_MS, - large_gap_ms: consts::LARGE_GAP_MS, }, hot: HotValues { reconnect_delay_s: consts::DEFAULT_RECONNECT_DELAY_S as i32, diff --git a/crates/irl-source/tests/receiver_stream_open.rs b/crates/irl-source/tests/receiver_stream_open.rs index 2eb6744..f7df25e 100644 --- a/crates/irl-source/tests/receiver_stream_open.rs +++ b/crates/irl-source/tests/receiver_stream_open.rs @@ -62,22 +62,12 @@ fn a_stopped_run_aborts_the_open() { } /// The demuxer option table the open feeds `av_dict_set` is the same one -/// `irl-core` pins; this checks the receiver's two call-site arguments (the -/// constant network buffer and the fast/full probe switch) reach it. +/// `irl-core` pins; this checks the receiver's fast/full probe switch reaches +/// it. #[test] fn the_probe_budget_follows_the_fast_probe_flag() { - let fast = irl_core::url_opts::demuxer_options( - "srt://127.0.0.1:9000", - None, - consts::NETWORK_BUFFER_MB, - true, - ); - let full = irl_core::url_opts::demuxer_options( - "srt://127.0.0.1:9000", - None, - consts::NETWORK_BUFFER_MB, - false, - ); + let fast = irl_core::url_opts::demuxer_options("srt://127.0.0.1:9000", None, true); + let full = irl_core::url_opts::demuxer_options("srt://127.0.0.1:9000", None, false); let probesize = |opts: &[( std::borrow::Cow<'static, str>, diff --git a/crates/irl-source/tests/shell_config.rs b/crates/irl-source/tests/shell_config.rs index 05b1207..a324f05 100644 --- a/crates/irl-source/tests/shell_config.rs +++ b/crates/irl-source/tests/shell_config.rs @@ -16,8 +16,6 @@ fn config() -> Config { ffmpeg_options: None, hw_decode: HwDecode::Auto, low_latency_audio: false, - small_gap_ms: consts::SMALL_GAP_MS, - large_gap_ms: consts::LARGE_GAP_MS, }, hot: HotValues { reconnect_delay_s: consts::DEFAULT_RECONNECT_DELAY_S as i32, diff --git a/crates/irl-source/tests/video_pipeline.rs b/crates/irl-source/tests/video_pipeline.rs index fa0ac87..2ff269d 100644 --- a/crates/irl-source/tests/video_pipeline.rs +++ b/crates/irl-source/tests/video_pipeline.rs @@ -97,8 +97,6 @@ fn stream_config(low_latency_audio: bool) -> StreamConfig { ffmpeg_options: None, hw_decode: irl_core::HwDecode::Auto, low_latency_audio, - small_gap_ms: irl_core::consts::SMALL_GAP_MS, - large_gap_ms: irl_core::consts::LARGE_GAP_MS, } } From 4f7913e2bf08be300d7fa906f7c9d8dc1c523f78 Mon Sep 17 00:00:00 2001 From: datagutt Date: Fri, 2 Oct 2026 01:31:15 +0200 Subject: [PATCH 05/28] refactor(workspace): share the byte pcm helpers through irl-core dsp --- crates/irl-core/src/dsp.rs | 86 +++++++++++++++++++++- crates/irl-source/src/audio/pump.rs | 69 ++--------------- crates/irl-source/src/receiver/audio_in.rs | 58 +++------------ 3 files changed, 99 insertions(+), 114 deletions(-) diff --git a/crates/irl-core/src/dsp.rs b/crates/irl-core/src/dsp.rs index 99c6246..e68ffb0 100644 --- a/crates/irl-core/src/dsp.rs +++ b/crates/irl-core/src/dsp.rs @@ -4,10 +4,20 @@ //! `shape_silence_from_last` (`receiver-audio.c:210-265`), the fade-in ramp in //! `irl_pump_audio_once` (`receiver-audio.c:861-883`) and the fade of //! `audio_buffer_read_with_fade_out`. Everything works on interleaved `f32`, -//! which is the only format the plugin ever hands to OBS. +//! which is the only format the plugin ever hands to OBS; [`FloatEdit`] and +//! [`LastSample::remember_bytes`] bridge to the byte buffers it travels in. use crate::consts::{self, AUDIO_MAX_CHANNELS}; +/// Bytes one interleaved float sample occupies (`sizeof(float)`). +pub const SAMPLE_BYTES: usize = 4; + +fn read_f32(bytes: &[u8]) -> f32 { + let mut raw = [0u8; SAMPLE_BYTES]; + raw.copy_from_slice(bytes); + f32::from_le_bytes(raw) +} + /// The last emitted sample per channel, for silence shaping. #[derive(Debug, Clone, Copy, Default)] pub struct LastSample { @@ -35,6 +45,27 @@ impl LastSample { self.valid = true; } + /// [`Self::remember`] over an interleaved-float byte buffer: only the last + /// frame is needed, so no whole-chunk decode happens here. + pub fn remember_bytes(&mut self, samples: &[u8], channels: usize) { + let mut values = [0.0f32; AUDIO_MAX_CHANNELS]; + let take = channels.min(AUDIO_MAX_CHANNELS); + let frame_bytes = channels * SAMPLE_BYTES; + if channels == 0 || samples.len() < frame_bytes { + self.remember(&[], channels); + return; + } + + let start = samples.len() - samples.len() % frame_bytes - frame_bytes; + for (ch, value) in values[..take].iter_mut().enumerate() { + let off = start + ch * SAMPLE_BYTES; + *value = read_f32(&samples[off..off + SAMPLE_BYTES]); + } + // `channels > 8` reaches `remember` with a short slice, which is + // exactly the case the C rejected (`*dst_valid = false`). + self.remember(&values[..take], channels); + } + /// Forget the remembered frame; the next concealment chunk is pure /// silence (`ctx->audio_out_last_valid = false` after the first one). pub fn forget(&mut self) { @@ -42,6 +73,33 @@ impl LastSample { } } +/// The helpers here edit interleaved float in place, while the audio path +/// carries bytes (what swresample and libobs both take) and the plugin crates +/// forbid the unsafe cast between the two views. The rare in-place edits +/// (concealment shaping and the splice fades) therefore decode into a reusable +/// `f32` scratch, run the helper, and write the result back. A normal chunk is +/// emitted untouched, so the steady-state copy count matches the C. +#[derive(Debug, Default)] +pub struct FloatEdit { + scratch: Vec, +} + +impl FloatEdit { + /// Run `edit` over `bytes` viewed as interleaved `f32`. + pub fn edit(&mut self, bytes: &mut [u8], edit: impl FnOnce(&mut [f32])) { + self.scratch.clear(); + self.scratch + .extend(bytes.chunks_exact(SAMPLE_BYTES).map(read_f32)); + edit(&mut self.scratch); + for (dst, value) in bytes + .chunks_exact_mut(SAMPLE_BYTES) + .zip(self.scratch.iter()) + { + dst.copy_from_slice(&value.to_le_bytes()); + } + } +} + /// Frames the concealment fade covers: `AUDIO_CONCEAL_FADE_MS` worth, never /// more than the chunk (`audio_conceal_fade_frames`). fn conceal_fade_frames(rate: i32, max_frames: usize) -> usize { @@ -143,6 +201,32 @@ mod tests { assert_eq!(&last.values[..2], &[0.5, 0.6]); } + #[test] + fn remember_bytes_keeps_the_last_whole_frame() { + let bytes: Vec = [0.1f32, 0.2, 0.3, 0.4, 0.5] + .iter() + .flat_map(|v| v.to_le_bytes()) + .collect(); + let mut last = LastSample::default(); + last.remember_bytes(&bytes, 2); + assert!(last.valid); + assert_eq!(&last.values[..2], &[0.3, 0.4]); + + last.remember_bytes(&bytes[..4], 2); + assert!(!last.valid); + } + + #[test] + fn float_edit_round_trips_through_bytes() { + let mut bytes: Vec = [1.0f32, -2.0] + .iter() + .flat_map(|v| v.to_le_bytes()) + .collect(); + FloatEdit::default().edit(&mut bytes, |pcm| pcm.iter_mut().for_each(|v| *v *= 0.5)); + assert_eq!(&bytes[..4], &0.5f32.to_le_bytes()); + assert_eq!(&bytes[4..], &(-1.0f32).to_le_bytes()); + } + #[test] fn remember_rejects_more_than_eight_channels() { let mut last = LastSample::default(); diff --git a/crates/irl-source/src/audio/pump.rs b/crates/irl-source/src/audio/pump.rs index d73191a..8811310 100644 --- a/crates/irl-source/src/audio/pump.rs +++ b/crates/irl-source/src/audio/pump.rs @@ -34,9 +34,6 @@ use irl_core::{SpeedCarry, SpeedController, SpeedInputs, consts, dsp, timing}; use crate::audio::AudioSink; use crate::shared::{AudioState, LifetimeStats, Shared}; -/// Bytes one interleaved float sample occupies. -const SAMPLE_BYTES: usize = 4; - /// Audio-thread-owned state: the speed resampler, scratch buffers and the /// speed controller. pub struct AudioPump { @@ -46,8 +43,8 @@ pub struct AudioPump { speed_scratch: Vec, pump_scratch: Vec, speed: SpeedController, - /// Byte ⇄ float view for the rare in-place edits (see [`FloatEdit`]). - float: FloatEdit, + /// Byte ⇄ float view for the rare in-place edits. + float: dsp::FloatEdit, /// The OBS clock. Injectable so tests can drive the output clock without /// a running libobs; production always reads `os_gettime_ns`. now_ns: Box u64 + Send>, @@ -78,7 +75,7 @@ impl AudioPump { speed_scratch: Vec::new(), pump_scratch: Vec::new(), speed: SpeedController::new(), - float: FloatEdit::default(), + float: dsp::FloatEdit::default(), now_ns: Box::new(obs::time::gettime_ns), now_us: Box::new(|| ffmpeg::gettime_us() as u64), idle_sleep_ms: consts::AUDIO_PUMP_SLEEP_MS, @@ -445,7 +442,7 @@ Video stays in sync with it; check the sender's frame rate and clock", timestamp, )); - remember_last_sample(&mut state.out_last, emitted, channels); + state.out_last.remember_bytes(emitted, channels); super::hold::credit_build( state, timing::frames_to_ns(u64::from(frames_out), fmt.rate as u32), @@ -564,7 +561,7 @@ Video stays in sync with it; check the sender's frame rate and clock", } max_out += 32; - let need = max_out as usize * channels as usize * SAMPLE_BYTES; + let need = max_out as usize * channels as usize * dsp::SAMPLE_BYTES; ensure_scratch(scratch, need); let got = swr @@ -842,65 +839,9 @@ fn finalize_audio_output( shared.conn.total_audio_frames.fetch_add(1, Relaxed); } -/// `remember_last_sample` over an interleaved-float byte buffer: only the last -/// frame is needed, so no whole-chunk decode happens here. -fn remember_last_sample(last: &mut irl_core::LastSample, samples: &[u8], channels: usize) { - let mut values = [0.0f32; consts::AUDIO_MAX_CHANNELS]; - let take = channels.min(consts::AUDIO_MAX_CHANNELS); - let frame_bytes = channels * SAMPLE_BYTES; - if channels == 0 || frame_bytes == 0 || samples.len() < frame_bytes { - // No frame to remember (or a nonsensical layout): clear, as C did. - last.remember(&[], channels); - return; - } - - let at = samples.len() - samples.len() % frame_bytes; - let start = at - frame_bytes; - for (ch, value) in values[..take].iter_mut().enumerate() { - let off = start + ch * SAMPLE_BYTES; - *value = read_f32(&samples[off..off + SAMPLE_BYTES]); - } - // `channels > 8` reaches `remember` with a short slice, which is exactly - // the case the C rejected (`*dst_valid = false`). - last.remember(&values[..take], channels); -} - -fn read_f32(bytes: &[u8]) -> f32 { - let mut raw = [0u8; SAMPLE_BYTES]; - raw.copy_from_slice(bytes); - f32::from_le_bytes(raw) -} - /// Grow a scratch buffer to at least `need` bytes (`ensure_scratch`). fn ensure_scratch(buf: &mut Vec, need: usize) { if buf.len() < need { buf.resize(need, 0); } } - -/// `irl_core::dsp` edits interleaved float in place, while the audio path -/// carries bytes (what swresample and libobs both take) and this crate forbids -/// the unsafe cast between the two views. The rare in-place edits — -/// concealment shaping and the two splice fades — therefore decode into a -/// reusable `f32` scratch, run the ported helper, and write the result back. A -/// normal chunk is emitted untouched, so the steady-state copy count matches -/// the C. -#[derive(Default)] -struct FloatEdit { - scratch: Vec, -} - -impl FloatEdit { - fn edit(&mut self, bytes: &mut [u8], edit: impl FnOnce(&mut [f32])) { - self.scratch.clear(); - self.scratch - .extend(bytes.chunks_exact(SAMPLE_BYTES).map(read_f32)); - edit(&mut self.scratch); - for (dst, value) in bytes - .chunks_exact_mut(SAMPLE_BYTES) - .zip(self.scratch.iter()) - { - dst.copy_from_slice(&value.to_le_bytes()); - } - } -} diff --git a/crates/irl-source/src/receiver/audio_in.rs b/crates/irl-source/src/receiver/audio_in.rs index ffa3123..c4f4734 100644 --- a/crates/irl-source/src/receiver/audio_in.rs +++ b/crates/irl-source/src/receiver/audio_in.rs @@ -8,9 +8,6 @@ use irl_core::{LastSample, PtsAction, PtsRepair, consts, dsp, timing}; use crate::receiver::ReceiverFlags; use crate::shared::Shared; -/// Bytes one interleaved float sample occupies (`sizeof(float)`). -const BYTES_PER_SAMPLE: i32 = 4; - /// Nanosecond time base. const NS_TB: Rational = Rational::new(1, 1_000_000_000); @@ -23,10 +20,8 @@ pub struct AudioIntake { scratch: Vec, pts: Option, last_sample: LastSample, - /// Byte ⇄ float view for the silence shaping and the post-silence fade; - /// see the note on `audio::pump::FloatEdit` (this crate forbids the - /// unsafe cast, and both edits are off the steady-state path). - float: Vec, + /// Byte ⇄ float view for the silence shaping and the post-silence fade. + float: dsp::FloatEdit, } impl AudioIntake { @@ -166,14 +161,14 @@ impl AudioIntake { in_scratch = true; } - let data_bytes = out_samples as usize * out_channels as usize * BYTES_PER_SAMPLE as usize; + let data_bytes = out_samples as usize * out_channels as usize * dsp::SAMPLE_BYTES; if data_bytes == 0 { return; } if inserted_silence { let channels = out_channels as usize; - edit_floats(&mut self.float, &mut self.scratch[..data_bytes], |pcm| { + self.float.edit(&mut self.scratch[..data_bytes], |pcm| { dsp::apply_fade_in(pcm, channels, out_rate) }); } @@ -198,7 +193,7 @@ impl AudioIntake { if let Some(buf) = shared.audio_buf().as_mut() { buf.write_pts(data, frame_pts_ns); } - remember_last_sample(&mut self.last_sample, data, out_channels as usize); + self.last_sample.remember_bytes(data, out_channels as usize); let mut state = shared.audio_state(); state.latest_audio_stream_pts_ns = frame_pts_ns; @@ -257,12 +252,12 @@ impl AudioIntake { let reconfigured = { let mut guard = shared.audio_buf(); match guard.as_mut() { - Some(buf) => buf.reconfigure(out_rate, out_channels, BYTES_PER_SAMPLE), + Some(buf) => buf.reconfigure(out_rate, out_channels, dsp::SAMPLE_BYTES as i32), None => { let buf = irl_core::AudioBuffer::new( out_rate, out_channels, - BYTES_PER_SAMPLE, + dsp::SAMPLE_BYTES as i32, watermarks.target_ms, watermarks.min_ms, watermarks.max_ms, @@ -313,7 +308,7 @@ impl AudioIntake { self.scratch.resize(silence_bytes, 0); } let last = self.last_sample; - edit_floats(&mut self.float, &mut self.scratch[..silence_bytes], |pcm| { + self.float.edit(&mut self.scratch[..silence_bytes], |pcm| { dsp::shape_silence_from_last(pcm, channels, rate, &last) }); @@ -360,7 +355,7 @@ impl AudioIntake { } let max_out = swr.out_samples(frame.nb_samples()) + soft_comp_samples.abs() + 32; - let need = max_out as usize * out_channels as usize * BYTES_PER_SAMPLE as usize; + let need = max_out as usize * out_channels as usize * dsp::SAMPLE_BYTES; if self.scratch.len() < need { self.scratch.resize(need, 0); } @@ -384,38 +379,3 @@ fn audio_frame_duration_ms(samples: i32, sample_rate: i32) -> i32 { let ms = samples as i64 * 1000 / sample_rate as i64; if ms <= 0 { 1 } else { ms as i32 } } - -/// `remember_last_sample` over an interleaved-float byte buffer. -fn remember_last_sample(last: &mut LastSample, samples: &[u8], channels: usize) { - let mut values = [0.0f32; consts::AUDIO_MAX_CHANNELS]; - let take = channels.min(consts::AUDIO_MAX_CHANNELS); - let frame_bytes = channels * BYTES_PER_SAMPLE as usize; - if channels == 0 || samples.len() < frame_bytes { - last.remember(&[], channels); - return; - } - - let start = samples.len() - samples.len() % frame_bytes - frame_bytes; - for (ch, value) in values[..take].iter_mut().enumerate() { - let off = start + ch * BYTES_PER_SAMPLE as usize; - let mut raw = [0u8; 4]; - raw.copy_from_slice(&samples[off..off + 4]); - *value = f32::from_le_bytes(raw); - } - last.remember(&values[..take], channels); -} - -/// Run an `irl_core::dsp` edit over an interleaved-float byte buffer through a -/// reusable float scratch (this crate forbids the unsafe view cast). -fn edit_floats(scratch: &mut Vec, bytes: &mut [u8], edit: impl FnOnce(&mut [f32])) { - scratch.clear(); - scratch.extend(bytes.chunks_exact(4).map(|chunk| { - let mut raw = [0u8; 4]; - raw.copy_from_slice(chunk); - f32::from_le_bytes(raw) - })); - edit(scratch); - for (dst, value) in bytes.chunks_exact_mut(4).zip(scratch.iter()) { - dst.copy_from_slice(&value.to_le_bytes()); - } -} From 2c6e891c914517bc3a557111b431922fb41ebfed Mon Sep 17 00:00:00 2001 From: datagutt Date: Fri, 2 Oct 2026 01:36:16 +0200 Subject: [PATCH 06/28] refactor(workspace): derive the shared state constructors from defaults --- crates/irl-core/src/arrival.rs | 13 +++ crates/irl-core/src/video_delay.rs | 20 ++++- crates/irl-source/src/audio/pump.rs | 7 +- crates/irl-source/src/shared.rs | 109 ++++++-------------------- crates/irl-source/src/video/output.rs | 6 +- crates/irl-source/src/video/thread.rs | 16 +--- 6 files changed, 68 insertions(+), 103 deletions(-) diff --git a/crates/irl-core/src/arrival.rs b/crates/irl-core/src/arrival.rs index ae7cb7b..134cd97 100644 --- a/crates/irl-core/src/arrival.rs +++ b/crates/irl-core/src/arrival.rs @@ -24,6 +24,8 @@ use std::collections::VecDeque; +use crate::consts; + /// The floor of `arrival - PTS` over a connection, with enough history to say /// whether it is still falling. #[derive(Debug)] @@ -92,6 +94,17 @@ impl ArrivalFloor { } } +impl Default for ArrivalFloor { + /// The plugin's floor, from the `VIDEO_LIVE_*` constants. + fn default() -> Self { + Self::new( + consts::VIDEO_LIVE_LOOKBACK_MS * 1_000_000, + consts::VIDEO_LIVE_TOLERANCE_MS * 1_000_000, + consts::VIDEO_LIVE_MAX_WAIT_MS * 1_000_000, + ) + } +} + #[cfg(test)] mod tests { use super::*; diff --git a/crates/irl-core/src/video_delay.rs b/crates/irl-core/src/video_delay.rs index 507fc15..5f978bd 100644 --- a/crates/irl-core/src/video_delay.rs +++ b/crates/irl-core/src/video_delay.rs @@ -40,6 +40,8 @@ //! queue before its due time passes. Asking every frame for the lead would //! delay a healthy stream by the lead for nothing. +use crate::consts; + /// One change of the delay, for the caller to log and mirror into the stats. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub struct DelayRaise { @@ -132,8 +134,9 @@ impl VideoDelay { /// Let the delay ramp back down when every frame across `window_ns` /// needed less than it by more than `min_ns`. + #[cfg(test)] #[must_use] - pub fn with_relax(mut self, window_ns: u64, min_ns: u64) -> Self { + fn with_relax(mut self, window_ns: u64, min_ns: u64) -> Self { self.relax_window_ns = window_ns; self.relax_min_ns = min_ns; self @@ -348,6 +351,21 @@ impl VideoDelay { } } +impl Default for VideoDelay { + /// The plugin's delay, from the `VIDEO_DELAY_*` constants, relaxing on. + fn default() -> Self { + Self { + relax_window_ns: consts::VIDEO_DELAY_RELAX_WINDOW_MS * 1_000_000, + relax_min_ns: consts::VIDEO_DELAY_RELAX_MIN_MS * 1_000_000, + ..Self::new( + consts::VIDEO_DELAY_MAX_MS * 1_000_000, + consts::VIDEO_DELAY_WINDOW_MS * 1_000_000, + consts::VIDEO_DELAY_MIN_FRAMES, + ) + } + } +} + /// `ns` rounded up to a whole number of canvas ticks. libobs displays on its /// ticks, so a fraction of one buys nothing. fn round_up_to_tick(ns: u64, tick_ns: u64) -> u64 { diff --git a/crates/irl-source/src/audio/pump.rs b/crates/irl-source/src/audio/pump.rs index 8811310..7d7e36b 100644 --- a/crates/irl-source/src/audio/pump.rs +++ b/crates/irl-source/src/audio/pump.rs @@ -32,7 +32,7 @@ use ffmpeg::Resampler; use irl_core::{SpeedCarry, SpeedController, SpeedInputs, consts, dsp, timing}; use crate::audio::AudioSink; -use crate::shared::{AudioState, LifetimeStats, Shared}; +use crate::shared::{AudioState, Shared}; /// Audio-thread-owned state: the speed resampler, scratch buffers and the /// speed controller. @@ -173,7 +173,10 @@ impl AudioPump { let chunk_count = peek.map_or(0, |s| s.chunk_count); // The receiver thread reads this for the stats line; the audio state // lock is held for the whole pump, so the publish is covered. - LifetimeStats::note_peak_i32(&shared.lifetime.audio_fill_peak_ms, fill_ms); + shared + .lifetime + .audio_fill_peak_ms + .fetch_max(fill_ms, Relaxed); if has_audio && maybe_trim_hidden_backlog(shared, state, fill_ms, chunk_count, low_latency) { diff --git a/crates/irl-source/src/shared.rs b/crates/irl-source/src/shared.rs index a9001af..14a8052 100644 --- a/crates/irl-source/src/shared.rs +++ b/crates/irl-source/src/shared.rs @@ -101,7 +101,7 @@ pub struct RunFlags { } /// Everything the C guarded with `audio_state_lock` that is not a counter. -#[derive(Debug)] +#[derive(Debug, Default)] pub struct AudioState { // Output clock: `ts = anchor + samples / rate`, anchored once at prime. pub primed: bool, @@ -174,45 +174,6 @@ pub struct AudioState { pub hold_wait_done: bool, } -impl AudioState { - pub fn new(startup_warmup_ms: i32) -> Self { - Self { - primed: false, - anchor_ns: 0, - samples: 0, - latest_obs_end_ts_ns: 0, - latest_buffered_end_pts_ns: 0, - offset_baseline_ns: 0, - offset_baseline_set: false, - out_last: LastSample::default(), - conceal_fade_pending: false, - fade_in_pending: false, - fade_in_frames_remaining: 0, - startup_warmup_remaining_ms: startup_warmup_ms, - decoded_frame_samples: 0, - latest_audio_stream_pts_ns: 0, - latest_video_stream_pts_ns: 0, - recovery_until_us: 0, - drain: DrainWatch::default(), - speed_trim: SpeedTrim::new(), - speed_carry: SpeedCarry::new(), - align_read_pending: false, - hold: AudioHold::default(), - hold_live: ArrivalFloor::new( - irl_core::consts::VIDEO_LIVE_LOOKBACK_MS * 1_000_000, - irl_core::consts::VIDEO_LIVE_TOLERANCE_MS * 1_000_000, - irl_core::consts::VIDEO_LIVE_MAX_WAIT_MS * 1_000_000, - ), - hold_ms: 0, - hold_unbuilt_ns: 0, - hold_built_ns: 0, - offset_baseline_hold_ms: 0, - hold_wait_since_ns: 0, - hold_wait_done: false, - } - } -} - /// Per-connection counters: zeroed with every new `Shared`. #[derive(Default)] pub struct ConnStats { @@ -295,17 +256,6 @@ pub struct LifetimeStats { pub audio_pkt_dropped: AtomicU64, } -impl LifetimeStats { - /// `field = max(field, value)`. - pub fn note_peak_i32(field: &AtomicI32, value: i32) { - field.fetch_max(value, Relaxed); - } - - pub fn note_peak_i64(field: &AtomicI64, value: i64) { - field.fetch_max(value, Relaxed); - } -} - /// The receiver → video queue: **compressed packets**, not decoded frames. /// /// This is where the stream's configured latency is held. Video has to be @@ -324,6 +274,7 @@ impl LifetimeStats { /// Bounded by media duration and bytes. Overflow drops from the front, which /// costs artifacts until the next keyframe; it should not happen, because the /// receiver's own read backpressure stops ingest long before this fills. +#[derive(Default)] pub struct VideoChannel { q: Mutex, cv: Condvar, @@ -358,6 +309,7 @@ pub struct VideoDecoder { pub codec_id: ffmpeg::AVCodecID, } +#[derive(Default)] struct VideoQueue { msgs: std::collections::VecDeque, packets: usize, @@ -369,6 +321,16 @@ struct VideoQueue { } impl VideoQueue { + fn clear(&mut self) { + self.msgs.clear(); + self.packets = 0; + self.bytes = 0; + } + + fn has_work(&self, has_room: bool) -> bool { + self.clear_pending || (has_room && !self.msgs.is_empty()) + } + /// Media time between the oldest and newest queued packet. fn span_ns(&self) -> i64 { let mut first = None; @@ -387,18 +349,6 @@ impl VideoQueue { } impl VideoChannel { - pub fn new() -> Self { - Self { - q: Mutex::new(VideoQueue { - msgs: std::collections::VecDeque::new(), - packets: 0, - bytes: 0, - clear_pending: false, - }), - cv: Condvar::new(), - } - } - /// Receiver thread: hand the video thread the decoder for a new /// connection. pub fn install_decoder(&self, decoder: VideoDecoder) { @@ -431,8 +381,9 @@ impl VideoChannel { } } - let depth = q.packets as i32; - LifetimeStats::note_peak_i32(&lifetime.video_queue_peak, depth); + lifetime + .video_queue_peak + .fetch_max(q.packets as i32, Relaxed); drop(q); self.cv.notify_one(); } @@ -441,9 +392,7 @@ impl VideoChannel { /// video thread to clear the OBS frame. pub fn request_clear(&self) { let mut q = self.q.lock(); - q.msgs.clear(); - q.packets = 0; - q.bytes = 0; + q.clear(); q.clear_pending = true; drop(q); self.cv.notify_one(); @@ -499,8 +448,7 @@ impl VideoChannel { /// channel carries the whole configured latency as packets, is the *normal* /// steady state at any Target Buffer above that lead. pub fn has_work(&self, has_room: bool) -> bool { - let q = self.q.lock(); - q.clear_pending || (has_room && !q.msgs.is_empty()) + self.q.lock().has_work(has_room) } /// Video thread pacing sleep: returns as soon as there is work, or the run @@ -509,8 +457,7 @@ impl VideoChannel { /// OBS clock every cycle. pub fn wait(&self, timeout: Duration, has_room: bool, active: &AtomicBool) { let mut q = self.q.lock(); - let work = q.clear_pending || (has_room && !q.msgs.is_empty()); - if !work && active.load(Relaxed) { + if !q.has_work(has_room) && active.load(Relaxed) { self.cv.wait_for(&mut q, timeout); } } @@ -522,16 +469,7 @@ impl VideoChannel { /// Receiver stop: drain everything. pub fn drain(&self) { - let mut q = self.q.lock(); - q.msgs.clear(); - q.packets = 0; - q.bytes = 0; - } -} - -impl Default for VideoChannel { - fn default() -> Self { - Self::new() + self.q.lock().clear(); } } @@ -569,9 +507,12 @@ impl Shared { ); Arc::new(Self { source, - audio_state: Mutex::new(AudioState::new(irl_core::consts::STARTUP_AUDIO_WARMUP_MS)), + audio_state: Mutex::new(AudioState { + startup_warmup_remaining_ms: irl_core::consts::STARTUP_AUDIO_WARMUP_MS, + ..AudioState::default() + }), audio_buf: Mutex::new(None), - video: VideoChannel::new(), + video: VideoChannel::default(), video_flags: VideoFlags::default(), conn: ConnStats::default(), lifetime, diff --git a/crates/irl-source/src/video/output.rs b/crates/irl-source/src/video/output.rs index 8f31c11..4863d8d 100644 --- a/crates/irl-source/src/video/output.rs +++ b/crates/irl-source/src/video/output.rs @@ -12,7 +12,6 @@ use ffmpeg::{AVPixelFormat, Frame, FramePool, Scaler}; use irl_core::{consts, video_time}; use obs::{ColorRange, ColorSpace, VideoFormat, VideoFrame}; -use crate::shared::LifetimeStats; use crate::video::thread::VideoThread; /// `AV_NUM_DATA_POINTERS` and libobs's `MAX_AV_PLANES`; both are 8. @@ -326,7 +325,10 @@ impl VideoThread { // Keep the high-water mark too: stats are sampled every 30 s, and an // excursion that drains in ~17 s is very likely to fall between two // samples. - LifetimeStats::note_peak_i64(&self.shared.lifetime.video_lead_peak_ns, lead_ns); + self.shared + .lifetime + .video_lead_peak_ns + .fetch_max(lead_ns, Relaxed); if lead_ns > queue_safe_ns { self.shared.lifetime.video_lead_excess.fetch_add(1, Relaxed); } diff --git a/crates/irl-source/src/video/thread.rs b/crates/irl-source/src/video/thread.rs index d6b3704..f6c41d8 100644 --- a/crates/irl-source/src/video/thread.rs +++ b/crates/irl-source/src/video/thread.rs @@ -178,20 +178,8 @@ impl VideoThread { // A fresh source has libobs's `last_frame_ts` at 0 too. anchor_pending: true, anchor_wait: AnchorWait::Idle, - delay: VideoDelay::new( - consts::VIDEO_DELAY_MAX_MS * 1_000_000, - consts::VIDEO_DELAY_WINDOW_MS * 1_000_000, - consts::VIDEO_DELAY_MIN_FRAMES, - ) - .with_relax( - consts::VIDEO_DELAY_RELAX_WINDOW_MS * 1_000_000, - consts::VIDEO_DELAY_RELAX_MIN_MS * 1_000_000, - ), - arrival: ArrivalFloor::new( - consts::VIDEO_LIVE_LOOKBACK_MS * 1_000_000, - consts::VIDEO_LIVE_TOLERANCE_MS * 1_000_000, - consts::VIDEO_LIVE_MAX_WAIT_MS * 1_000_000, - ), + delay: VideoDelay::default(), + arrival: ArrivalFloor::default(), hold_built_seen_ns: None, canvas_tick_ns: Box::new(obs::time::canvas_frame_interval_ns), sink, From 1095d53c15603825324180296f97785e219fffa2 Mon Sep 17 00:00:00 2001 From: datagutt Date: Fri, 2 Oct 2026 01:38:51 +0200 Subject: [PATCH 07/28] refactor(workspace): one rate limit helper for the decoder and lead warnings --- crates/irl-core/src/timing.rs | 26 +++++ crates/irl-source/src/receiver/decode.rs | 143 +++++++++++------------ crates/irl-source/src/video/decode.rs | 16 +-- crates/irl-source/src/video/output.rs | 9 +- 4 files changed, 103 insertions(+), 91 deletions(-) diff --git a/crates/irl-core/src/timing.rs b/crates/irl-core/src/timing.rs index a1ecd83..5d2a433 100644 --- a/crates/irl-core/src/timing.rs +++ b/crates/irl-core/src/timing.rs @@ -145,6 +145,20 @@ pub fn frames_to_ns(frames: u64, rate: u32) -> u64 { (frames as u128 * 1_000_000_000 / rate as u128) as u64 } +/// Rate limit: true, and `last` moves to `now`, unless the previous pass was +/// less than `interval` ago. A zero `last` has never passed, so the first call +/// always does. Units are the caller's, as long as all three agree. +/// +/// The difference wraps: `av_gettime` is wall clock, and a step backwards must +/// not suppress a decoder flush until the clock has caught up again. +pub fn throttle(last: &mut u64, now: u64, interval: u64) -> bool { + if *last != 0 && now.wrapping_sub(*last) < interval { + return false; + } + *last = now; + true +} + #[cfg(test)] mod alignment_tests { use super::*; @@ -355,4 +369,16 @@ mod tests { assert_eq!(output_lead_ns(960, 0, true), 0); assert_eq!(output_lead_ns(0, 48_000, false), 80_000_000); } + + #[test] + fn throttle_passes_first_then_once_per_interval() { + let mut last = 0; + assert!(throttle(&mut last, 5, 10)); + assert!(!throttle(&mut last, 14, 10)); + assert_eq!(last, 5); + assert!(throttle(&mut last, 15, 10)); + assert_eq!(last, 15); + // A clock stepped back behind the last pass does not hold it shut. + assert!(throttle(&mut last, 3, 10)); + } } diff --git a/crates/irl-source/src/receiver/decode.rs b/crates/irl-source/src/receiver/decode.rs index 6275863..3538d53 100644 --- a/crates/irl-source/src/receiver/decode.rs +++ b/crates/irl-source/src/receiver/decode.rs @@ -4,7 +4,7 @@ use std::sync::atomic::Ordering::Relaxed; use ffmpeg::{CodecContext, Frame, Rational}; -use irl_core::consts; +use irl_core::{consts, timing}; use crate::audio; use crate::receiver::audio_in::AudioIntake; @@ -15,23 +15,45 @@ use crate::shared::{Shared, TimedPacket}; /// duration bound. const NS_TIME_BASE: Rational = Rational::new(1, 1_000_000_000); -fn should_log_decoder_warning(last_warning_time_us: &mut u64, now_us: u64) -> bool { - if *last_warning_time_us != 0 - && now_us - *last_warning_time_us < consts::DECODER_WARNING_INTERVAL_US - { - return false; +/// Count one audio decode error. At a burst, log it (rate limited) and flush +/// the decoder unless a flush is cooling down; returns when it flushed. +fn audio_error_burst( + dec: &mut CodecContext, + shared: &Shared, + flags: &mut ReceiverFlags, + stage: &str, + flushing: &str, + cooling: &str, +) -> Option { + flags.audio_decode_errors += 1; + if flags.audio_decode_errors < consts::DECODER_ERROR_BURST { + return None; } - *last_warning_time_us = now_us; - true -} - -fn should_flush_decoder(last_flush_time_us: &mut u64, now_us: u64) -> bool { - if *last_flush_time_us != 0 && now_us - *last_flush_time_us < consts::DECODER_FLUSH_COOLDOWN_US - { - return false; + let now_us = ffmpeg::gettime_us() as u64; + let do_flush = timing::throttle( + &mut flags.audio_last_decoder_flush_time_us, + now_us, + consts::DECODER_FLUSH_COOLDOWN_US, + ); + if timing::throttle( + &mut flags.audio_last_decoder_warning_time_us, + now_us, + consts::DECODER_WARNING_INTERVAL_US, + ) { + irl_warn!( + "Audio decoder{stage}: corruption burst ({} consecutive errors){}", + flags.audio_decode_errors, + if do_flush { flushing } else { cooling } + ); + } + flags.audio_decode_errors = 0; + if !do_flush { + return None; } - *last_flush_time_us = now_us; - true + dec.flush(); + shared.conn.audio_decoder_flushes.fetch_add(1, Relaxed); + shared.conn.audio_quality_events.fetch_add(1, Relaxed); + Some(now_us) } /// Drain everything the audio decoder has ready. @@ -47,44 +69,27 @@ fn drain_audio_frames( match dec.receive_frame(frame) { Err(err) if err.is_eagain() || err.is_eof() => return, Err(_) => { - flags.audio_decode_errors += 1; - if flags.audio_decode_errors >= consts::DECODER_ERROR_BURST { - let now_us = ffmpeg::gettime_us() as u64; - let do_flush = - should_flush_decoder(&mut flags.audio_last_decoder_flush_time_us, now_us); - if should_log_decoder_warning( - &mut flags.audio_last_decoder_warning_time_us, - now_us, - ) { - irl_warn!( - "Audio decoder receive: corruption burst ({} consecutive errors){}", - flags.audio_decode_errors, - if do_flush { - ", resetting audio state" - } else { - ", reset cooldown active" - } - ); - } - if do_flush { - dec.flush(); - shared.conn.audio_decoder_flushes.fetch_add(1, Relaxed); - shared.conn.audio_quality_events.fetch_add(1, Relaxed); - { - let mut state = shared.audio_state(); - if let Some(buf) = shared.audio_buf().as_mut() { - buf.flush(); - } - audio::reset_audio_timing_state(shared, &mut state); - audio::mark_audio_recovery( - &mut state, - now_us, - consts::AUDIO_RESET_RECOVERY_HOLD_US, - ); + if let Some(now_us) = audio_error_burst( + dec, + shared, + flags, + " receive", + ", resetting audio state", + ", reset cooldown active", + ) { + { + let mut state = shared.audio_state(); + if let Some(buf) = shared.audio_buf().as_mut() { + buf.flush(); } - audio_in.init_pts_repair(audio_tb); + audio::reset_audio_timing_state(shared, &mut state); + audio::mark_audio_recovery( + &mut state, + now_us, + consts::AUDIO_RESET_RECOVERY_HOLD_US, + ); } - flags.audio_decode_errors = 0; + audio_in.init_pts_repair(audio_tb); } return; } @@ -142,32 +147,14 @@ impl Receiver { match &result { Err(err) if !err.is_eagain() && !err.is_eof() => { - flags.audio_decode_errors += 1; - if flags.audio_decode_errors >= consts::DECODER_ERROR_BURST { - let now_us = ffmpeg::gettime_us() as u64; - let do_flush = - should_flush_decoder(&mut flags.audio_last_decoder_flush_time_us, now_us); - if should_log_decoder_warning( - &mut flags.audio_last_decoder_warning_time_us, - now_us, - ) { - irl_warn!( - "Audio decoder: corruption burst ({} consecutive errors){}", - flags.audio_decode_errors, - if do_flush { - ", flushing" - } else { - ", suppressing repeated flush" - } - ); - } - if do_flush { - dec.flush(); - shared.conn.audio_decoder_flushes.fetch_add(1, Relaxed); - shared.conn.audio_quality_events.fetch_add(1, Relaxed); - } - flags.audio_decode_errors = 0; - } + audio_error_burst( + dec, + shared, + flags, + "", + ", flushing", + ", suppressing repeated flush", + ); } _ => flags.audio_decode_errors = 0, } diff --git a/crates/irl-source/src/video/decode.rs b/crates/irl-source/src/video/decode.rs index 955eea3..ec76ba8 100644 --- a/crates/irl-source/src/video/decode.rs +++ b/crates/irl-source/src/video/decode.rs @@ -10,19 +10,11 @@ use std::sync::atomic::Ordering::Relaxed; -use irl_core::consts; +use irl_core::{consts, timing}; use crate::shared::{Shared, VideoDecoder}; use crate::video::intake::{self, DecodeState}; -fn should_log_warning(last_warning_us: &mut u64, now_us: u64) -> bool { - if *last_warning_us != 0 && now_us - *last_warning_us < consts::DECODER_WARNING_INTERVAL_US { - return false; - } - *last_warning_us = now_us; - true -} - fn note_decode_error(shared: &Shared, state: &mut DecodeState, stage: &str) { state.decode_errors += 1; shared.video_flags.corrupted.store(true, Relaxed); @@ -30,7 +22,11 @@ fn note_decode_error(shared: &Shared, state: &mut DecodeState, stage: &str) { return; } let now_us = ffmpeg::gettime_us() as u64; - if should_log_warning(&mut state.last_warning_us, now_us) { + if timing::throttle( + &mut state.last_warning_us, + now_us, + consts::DECODER_WARNING_INTERVAL_US, + ) { irl_warn!( "Video decoder {stage}: corruption burst ({} consecutive errors), waiting for the next keyframe", state.decode_errors diff --git a/crates/irl-source/src/video/output.rs b/crates/irl-source/src/video/output.rs index 4863d8d..4c32c1f 100644 --- a/crates/irl-source/src/video/output.rs +++ b/crates/irl-source/src/video/output.rs @@ -9,7 +9,7 @@ use std::sync::atomic::Ordering::Relaxed; use ffmpeg::sys::{AVColorRange, AVColorSpace, AVColorTransferCharacteristic}; use ffmpeg::{AVPixelFormat, Frame, FramePool, Scaler}; -use irl_core::{consts, video_time}; +use irl_core::{consts, timing, video_time}; use obs::{ColorRange, ColorSpace, VideoFormat, VideoFrame}; use crate::video::thread::VideoThread; @@ -339,8 +339,11 @@ impl VideoThread { // Only a risk while the lead is still climbing — a steady lead of any // size is free — so this is a "watch this" line, not a fault. - if now.saturating_sub(self.lead_warn_time_ns) >= consts::VIDEO_LEAD_WARN_INTERVAL_NS { - self.lead_warn_time_ns = now; + if timing::throttle( + &mut self.lead_warn_time_ns, + now, + consts::VIDEO_LEAD_WARN_INTERVAL_NS, + ) { irl_info!( "Video lead {}ms is beyond what OBS can queue ({}ms at {:.0}fps); harmless while it holds steady, but a rise of that size would make OBS drop queued video", lead_ns / 1_000_000, From 67b443a07d5947166a56ba7cd9668cd8bb4d7b7d Mon Sep 17 00:00:00 2001 From: datagutt Date: Fri, 2 Oct 2026 01:40:52 +0200 Subject: [PATCH 08/28] refactor(workspace): pass pts and bytes to the pacing queue instead of a trait --- crates/irl-core/src/lib.rs | 2 +- crates/irl-core/src/pacing.rs | 81 ++++++++++++--------------- crates/irl-source/src/video/thread.rs | 41 ++++---------- 3 files changed, 48 insertions(+), 76 deletions(-) diff --git a/crates/irl-core/src/lib.rs b/crates/irl-core/src/lib.rs index b4adb60..72083ce 100644 --- a/crates/irl-core/src/lib.rs +++ b/crates/irl-core/src/lib.rs @@ -32,7 +32,7 @@ pub use audio_buffer::{AudioBuffer, BufferState}; pub use audio_hold::{AudioHold, HoldChange}; pub use config::{HwDecode, Watermarks}; pub use dsp::LastSample; -pub use pacing::{DueVerdict, PacedFrame, PacingQueue}; +pub use pacing::{DueVerdict, PacingQueue}; pub use pts_repair::{PtsAction, PtsRepair, Verdict}; pub use speed::{ DrainWatch, SpeedCarry, SpeedController, SpeedInputs, SpeedTrim, StuckReport, catchup_speed_max, diff --git a/crates/irl-core/src/pacing.rs b/crates/irl-core/src/pacing.rs index 83573bc..1ea89c4 100644 --- a/crates/irl-core/src/pacing.rs +++ b/crates/irl-core/src/pacing.rs @@ -4,14 +4,6 @@ use std::collections::VecDeque; -/// What the queue needs to know about a frame. -pub trait PacedFrame { - /// Stream PTS in nanoseconds. - fn pts_ns(&self) -> i64; - /// Bytes held. - fn bytes(&self) -> usize; -} - /// Decision for the head frame. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum DueVerdict { @@ -43,7 +35,7 @@ struct Entry { bytes: usize, } -impl PacingQueue { +impl PacingQueue { /// Empty queue holding `lead_ns` of media, under hard ceilings of /// `max_frames` and `max_bytes`. /// @@ -85,13 +77,14 @@ impl PacingQueue { self.entries.len() >= self.max_frames || self.bytes >= self.max_bytes } - /// Append a frame with its current due time. - pub fn push(&mut self, frame: F, due_ns: u64) { + /// Append a frame with its stream PTS in nanoseconds, the bytes it holds + /// and its current due time. + pub fn push(&mut self, frame: F, pts_ns: i64, bytes: usize, due_ns: u64) { let entry = Entry { - pts_ns: frame.pts_ns(), - bytes: frame.bytes(), - due_ns, frame, + pts_ns, + bytes, + due_ns, }; self.bytes += entry.bytes; self.entries.push_back(entry); @@ -225,15 +218,6 @@ mod tests { bytes: usize, } - impl PacedFrame for TestFrame { - fn pts_ns(&self) -> i64 { - self.pts_ns - } - fn bytes(&self) -> usize { - self.bytes - } - } - fn frame(pts_ns: i64) -> TestFrame { TestFrame { pts_ns, @@ -241,6 +225,11 @@ mod tests { } } + fn push(q: &mut PacingQueue, frame: TestFrame, due_ns: u64) { + let (pts_ns, bytes) = (frame.pts_ns, frame.bytes); + q.push(frame, pts_ns, bytes, due_ns); + } + fn queue() -> PacingQueue { PacingQueue::new( i64::MAX, @@ -254,7 +243,7 @@ mod tests { let mut q = PacingQueue::new(i64::MAX, consts::VIDEO_PACING_MAX_FRAMES, usize::MAX); for i in 0..consts::VIDEO_PACING_MAX_FRAMES { assert!(q.has_room(), "room at {i}"); - q.push(frame(i as i64), 0); + push(&mut q, frame(i as i64), 0); } assert!(!q.has_room()); assert_eq!(q.len(), consts::VIDEO_PACING_MAX_FRAMES); @@ -265,7 +254,7 @@ mod tests { let mut q = PacingQueue::new(i64::MAX, usize::MAX, consts::VIDEO_PACING_MAX_BYTES); let mut pushed = 0; while q.has_room() { - q.push(frame(pushed), 0); + push(&mut q, frame(pushed), 0); pushed += 1; } // 1 GiB of 1080p NV12 is ~345 frames, well inside the frame ceiling. @@ -277,8 +266,8 @@ mod tests { #[test] fn bytes_track_push_and_pop() { let mut q = queue(); - q.push(frame(0), 0); - q.push(frame(1), 0); + push(&mut q, frame(0), 0); + push(&mut q, frame(1), 0); assert_eq!(q.bytes(), 2 * FRAME_BYTES); q.pop(); assert_eq!(q.bytes(), FRAME_BYTES); @@ -292,7 +281,7 @@ mod tests { fn reschedule_shifts_the_queue_and_preserves_spacing() { let mut q = queue(); for i in 0..5 { - q.push(frame(i * 16_000_000), 1_000 + i as u64 * 16_000_000); + push(&mut q, frame(i * 16_000_000), 1_000 + i as u64 * 16_000_000); } // The offset the audio side publishes moves by +250 ms. @@ -311,14 +300,16 @@ mod tests { #[test] fn shift_moves_every_due_time_and_keeps_the_head() { let mut q = queue(); - q.push( + push( + &mut q, TestFrame { pts_ns: 0, bytes: 1, }, 1_000, ); - q.push( + push( + &mut q, TestFrame { pts_ns: 40, bytes: 1, @@ -342,7 +333,7 @@ mod tests { #[test] fn a_frame_in_the_future_waits() { let mut q = queue(); - q.push(frame(0), 100_000_000); + push(&mut q, frame(0), 100_000_000); assert_eq!( q.due_now(50_000_000, consts::VIDEO_PACING_SLACK_NS, true), Some(DueVerdict::Wait(50_000_000)) @@ -353,7 +344,7 @@ mod tests { #[test] fn slack_emits_rather_than_sleeping_again() { let mut q = queue(); - q.push(frame(0), 100_000_000); + push(&mut q, frame(0), 100_000_000); // Exactly one slack unit out: emit. let now = 100_000_000 - consts::VIDEO_PACING_SLACK_NS as u64; @@ -371,7 +362,7 @@ mod tests { #[test] fn a_due_or_late_frame_emits() { let mut q = queue(); - q.push(frame(0), 100_000_000); + push(&mut q, frame(0), 100_000_000); assert_eq!( q.due_now(100_000_000, consts::VIDEO_PACING_SLACK_NS, true), Some(DueVerdict::Emit) @@ -386,8 +377,8 @@ mod tests { #[test] fn over_the_hard_ceiling_emits_early_and_counts_an_overflow() { let mut q = PacingQueue::new(i64::MAX, 2, usize::MAX); - q.push(frame(0), 1_000_000_000); - q.push(frame(1), 1_016_000_000); + push(&mut q, frame(0), 1_000_000_000); + push(&mut q, frame(1), 1_016_000_000); assert!(!q.has_room()); // Not remotely due, but a memory ceiling binds. @@ -413,8 +404,8 @@ mod tests { #[test] fn a_hard_ceiling_does_not_emit_early_while_early_is_forbidden() { let mut q = PacingQueue::new(i64::MAX, 2, usize::MAX); - q.push(frame(0), 1_000_000_000); - q.push(frame(1), 1_016_000_000); + push(&mut q, frame(0), 1_000_000_000); + push(&mut q, frame(1), 1_016_000_000); assert!(!q.has_room(), "at the hard ceiling"); // Allowed: the ceiling forces it out. @@ -443,7 +434,7 @@ mod tests { let mut q = PacingQueue::new(100_000_000, usize::MAX, usize::MAX); let mut pts = 0; while q.has_room() { - q.push(frame(pts), 1_000_000_000 + pts as u64); + push(&mut q, frame(pts), 1_000_000_000 + pts as u64); pts += 33_000_000; } assert!(q.span_ns() >= 100_000_000, "span {}", q.span_ns()); @@ -462,9 +453,9 @@ mod tests { fn the_span_is_the_media_time_held() { let mut q = PacingQueue::new(i64::MAX, usize::MAX, usize::MAX); assert_eq!(q.span_ns(), 0); - q.push(frame(1_000), 0); + push(&mut q, frame(1_000), 0); assert_eq!(q.span_ns(), 0); - q.push(frame(34_000_000), 0); + push(&mut q, frame(34_000_000), 0); assert_eq!(q.span_ns(), 33_999_000); } @@ -472,7 +463,7 @@ mod tests { fn peak_is_a_high_water_mark() { let mut q = queue(); for i in 0..7 { - q.push(frame(i), 0); + push(&mut q, frame(i), 0); } assert_eq!(q.peak(), 7); for _ in 0..7 { @@ -480,7 +471,7 @@ mod tests { } assert_eq!(q.len(), 0); assert_eq!(q.peak(), 7, "peak survives the drain"); - q.push(frame(99), 0); + push(&mut q, frame(99), 0); assert_eq!(q.peak(), 7); } @@ -488,7 +479,7 @@ mod tests { fn drain_returns_every_frame_in_order() { let mut q = queue(); for i in 0..4 { - q.push(frame(i * 1000), 0); + push(&mut q, frame(i * 1000), 0); } let frames = q.drain(); assert_eq!( @@ -504,8 +495,8 @@ mod tests { #[test] fn frames_come_back_in_push_order() { let mut q = queue(); - q.push(frame(10), 1); - q.push(frame(20), 2); + push(&mut q, frame(10), 1); + push(&mut q, frame(20), 2); assert_eq!(q.pop().unwrap().pts_ns, 10); assert_eq!(q.pop().unwrap().pts_ns, 20); } diff --git a/crates/irl-source/src/video/thread.rs b/crates/irl-source/src/video/thread.rs index f6c41d8..2e66e4d 100644 --- a/crates/irl-source/src/video/thread.rs +++ b/crates/irl-source/src/video/thread.rs @@ -17,7 +17,7 @@ use std::time::Duration; use ffmpeg::{AVPixelFormat, Frame, FramePool, Scaler}; use irl_core::arrival::ArrivalFloor; use irl_core::consts; -use irl_core::pacing::{DueVerdict, PacedFrame, PacingQueue}; +use irl_core::pacing::{DueVerdict, PacingQueue}; use irl_core::video_delay::{DelayRaise, DelayRelax, VideoDelay}; use crate::shared::{Shared, VideoDecoder, VideoMsg}; @@ -25,31 +25,15 @@ use crate::video::VideoSink; use crate::video::decode; use crate::video::intake::DecodeState; -/// A frame waiting for its due time. `pts_ns` and `bytes` are cached at -/// intake: the pacing queue re-derives due times from the PTS every cycle, and -/// the byte total bounds the queue. `received_ns` is when the packet it was +/// A frame waiting for its due time. `received_ns` is when the packet it was /// decoded from reached this thread, which is what its arrival margin is /// measured from. pub struct Paced { frame: Frame, - pts_ns: i64, - bytes: usize, received_ns: u64, } impl Paced { - fn new(frame: Frame, received_ns: u64) -> Self { - let pts_ns = frame.pts(); - // `av_image_get_buffer_size(fmt, w, h, 1)`, as `pacing_frame_bytes`. - let bytes = frame.image_buffer_size().unwrap_or(0); - Self { - frame, - pts_ns, - bytes, - received_ns, - } - } - /// The system-memory frame itself. pub fn frame(&self) -> &Frame { &self.frame @@ -61,16 +45,6 @@ impl Paced { } } -impl PacedFrame for Paced { - fn pts_ns(&self) -> i64 { - self.pts_ns - } - - fn bytes(&self) -> usize { - self.bytes - } -} - /// The video thread's wait for the audio playout mapping before it anchors /// libobs's play head; see [`VideoThread::awaiting_audio_mapping`]. #[derive(Clone, Copy, Debug, PartialEq, Eq)] @@ -341,8 +315,15 @@ impl VideoThread { // Releases the decoder's surface before the next packet is sent. drop(frame); if let Some(f) = sysmem { - self.arrival.note(received_ns, f.pts()); - self.pacing.push(Paced::new(f, received_ns), due_ns); + let pts_ns = f.pts(); + self.arrival.note(received_ns, pts_ns); + // `av_image_get_buffer_size(fmt, w, h, 1)`, as `pacing_frame_bytes`. + let bytes = f.image_buffer_size().unwrap_or(0); + let paced = Paced { + frame: f, + received_ns, + }; + self.pacing.push(paced, pts_ns, bytes, due_ns); } } From 3e5f48c9eee3e0f915d0a82a94c5da38c9d0c1ad Mon Sep 17 00:00:00 2001 From: datagutt Date: Fri, 2 Oct 2026 01:44:16 +0200 Subject: [PATCH 09/28] refactor(workspace): inline single-use video helpers and share the ns time base --- crates/ffmpeg/src/lib.rs | 3 ++ crates/irl-source/src/receiver/audio_in.rs | 9 ++-- crates/irl-source/src/receiver/decode.rs | 13 ++--- crates/irl-source/src/receiver/stream.rs | 20 ++++---- crates/irl-source/src/shared.rs | 9 ++++ crates/irl-source/src/video/intake.rs | 10 +--- crates/irl-source/src/video/output.rs | 55 +++++++--------------- crates/irl-source/src/video/thread.rs | 28 ++++++----- crates/irl-source/tests/network_sim.rs | 11 +++-- 9 files changed, 68 insertions(+), 90 deletions(-) diff --git a/crates/ffmpeg/src/lib.rs b/crates/ffmpeg/src/lib.rs index 6bab6f7..7a39a07 100644 --- a/crates/ffmpeg/src/lib.rs +++ b/crates/ffmpeg/src/lib.rs @@ -112,6 +112,9 @@ impl Rational { } } +/// The nanosecond time base every stream timestamp is rescaled into. +pub const NS_TIME_BASE: Rational = Rational::new(1, 1_000_000_000); + impl From for sys::AVRational { fn from(r: Rational) -> Self { sys::AVRational { diff --git a/crates/irl-source/src/receiver/audio_in.rs b/crates/irl-source/src/receiver/audio_in.rs index c4f4734..32ae26a 100644 --- a/crates/irl-source/src/receiver/audio_in.rs +++ b/crates/irl-source/src/receiver/audio_in.rs @@ -8,9 +8,6 @@ use irl_core::{LastSample, PtsAction, PtsRepair, consts, dsp, timing}; use crate::receiver::ReceiverFlags; use crate::shared::Shared; -/// Nanosecond time base. -const NS_TB: Rational = Rational::new(1, 1_000_000_000); - /// Receiver-thread audio state: the input resampler, its scratch buffer, the /// PTS repair state machine and the last-sample memory used for silence /// shaping. @@ -189,7 +186,7 @@ impl AudioIntake { } }; - let frame_pts_ns = ffmpeg::rescale_q(verdict.corrected_pts, pts_tb, NS_TB); + let frame_pts_ns = ffmpeg::rescale_q(verdict.corrected_pts, pts_tb, ffmpeg::NS_TIME_BASE); if let Some(buf) = shared.audio_buf().as_mut() { buf.write_pts(data, frame_pts_ns); } @@ -312,8 +309,8 @@ impl AudioIntake { dsp::shape_silence_from_last(pcm, channels, rate, &last) }); - let mut silence_pts_ns = - ffmpeg::rescale_q(corrected_pts, pts_tb, NS_TB) - silence_ms as i64 * 1_000_000; + let mut silence_pts_ns = ffmpeg::rescale_q(corrected_pts, pts_tb, ffmpeg::NS_TIME_BASE) + - silence_ms as i64 * 1_000_000; if silence_pts_ns < 0 { silence_pts_ns = 0; } diff --git a/crates/irl-source/src/receiver/decode.rs b/crates/irl-source/src/receiver/decode.rs index 3538d53..fa21b59 100644 --- a/crates/irl-source/src/receiver/decode.rs +++ b/crates/irl-source/src/receiver/decode.rs @@ -11,10 +11,6 @@ use crate::receiver::audio_in::AudioIntake; use crate::receiver::{Receiver, ReceiverFlags}; use crate::shared::{Shared, TimedPacket}; -/// Nanosecond time base packet PTS is rescaled into for the video queue's -/// duration bound. -const NS_TIME_BASE: Rational = Rational::new(1, 1_000_000_000); - /// Count one audio decode error. At a burst, log it (rate limited) and flush /// the decoder unless a flush is cooling down; returns when it flushed. fn audio_error_burst( @@ -171,10 +167,9 @@ impl Receiver { pub(super) fn push_video_packet(&mut self) { // Only used to bound the queue by media duration; output timing comes // from the decoded frame's own PTS, after repair. - let pts_ns = self - .pkt - .pts_or_dts() - .map_or(0, |pts| ffmpeg::rescale_q(pts, self.video_tb, NS_TIME_BASE)); + let pts_ns = self.pkt.pts_or_dts().map_or(0, |pts| { + ffmpeg::rescale_q(pts, self.video_tb, ffmpeg::NS_TIME_BASE) + }); let bytes = self.pkt.size().max(0) as usize; self.shared.conn.video_arrival_pts_ns.store(pts_ns, Relaxed); let received_ns = obs::time::gettime_ns(); @@ -182,7 +177,7 @@ impl Receiver { // Where the audio hold reads the sender's skew: this packet against // the newest audio decoded before it, in mux order. if let Some(dts) = self.pkt.dts_or_pts() { - let dts_ns = ffmpeg::rescale_q(dts, self.video_tb, NS_TIME_BASE); + let dts_ns = ffmpeg::rescale_q(dts, self.video_tb, ffmpeg::NS_TIME_BASE); crate::audio::hold::observe_video_packet(&self.shared, received_ns, dts_ns); } diff --git a/crates/irl-source/src/receiver/stream.rs b/crates/irl-source/src/receiver/stream.rs index a4ef5ec..0e85ea7 100644 --- a/crates/irl-source/src/receiver/stream.rs +++ b/crates/irl-source/src/receiver/stream.rs @@ -540,18 +540,16 @@ impl Receiver { let (av_drift_ms, av_skew_ms) = { let state = shared.audio_state(); let skew = crate::source::av_skew_ms(shared, &state); - let drift = if state.offset_baseline_set - && state.latest_obs_end_ts_ns != 0 - && state.latest_buffered_end_pts_ns > 0 - { + let drift = match state.playout_mapping() { // Net of the audio hold, which moves the offset on purpose. - (state.latest_obs_end_ts_ns as i64 - - state.latest_buffered_end_pts_ns - - state.offset_baseline_ns - - crate::audio::hold::moved_since_baseline_ns(&state)) - / 1_000_000 - } else { - 0 + Some((obs_end, buffered_end)) if state.offset_baseline_set => { + (obs_end as i64 + - buffered_end + - state.offset_baseline_ns + - crate::audio::hold::moved_since_baseline_ns(&state)) + / 1_000_000 + } + _ => 0, }; (drift, skew) }; diff --git a/crates/irl-source/src/shared.rs b/crates/irl-source/src/shared.rs index 14a8052..adf7cf7 100644 --- a/crates/irl-source/src/shared.rs +++ b/crates/irl-source/src/shared.rs @@ -174,6 +174,15 @@ pub struct AudioState { pub hold_wait_done: bool, } +impl AudioState { + /// The audio → OBS playout mapping, `(latest_obs_end_ts_ns, + /// latest_buffered_end_pts_ns)`, once the pump has published one. + pub fn playout_mapping(&self) -> Option<(u64, i64)> { + (self.latest_obs_end_ts_ns != 0 && self.latest_buffered_end_pts_ns > 0) + .then_some((self.latest_obs_end_ts_ns, self.latest_buffered_end_pts_ns)) + } +} + /// Per-connection counters: zeroed with every new `Shared`. #[derive(Default)] pub struct ConnStats { diff --git a/crates/irl-source/src/video/intake.rs b/crates/irl-source/src/video/intake.rs index f279b2b..76107ea 100644 --- a/crates/irl-source/src/video/intake.rs +++ b/crates/irl-source/src/video/intake.rs @@ -11,12 +11,6 @@ use std::sync::atomic::Ordering::Relaxed; use irl_core::video_time; use crate::shared::Shared; -use crate::video::output; - -/// Nanosecond time base every queued PTS is rescaled into: the video thread -/// must not touch the format context, which the receiver frees on reconnect -/// while decoded frames may still be in flight. -const NS_TIME_BASE: ffmpeg::Rational = ffmpeg::Rational::new(1, 1_000_000_000); /// Video-thread-owned decode and intake state. /// @@ -89,7 +83,7 @@ pub fn handle_frame( }; let (width, height) = (frame.width(), frame.height()); - let is_key = output::is_keyframe(frame); + let is_key = frame.is_key(); let first_keyframe = shared.video_flags.first_keyframe.load(Relaxed); // The frame-level backstop only gates when Wait For Keyframe is on @@ -197,7 +191,7 @@ pub fn handle_frame( // Convert PTS to nanoseconds against the time base the decoder was opened // with, which travels with it rather than being read off a format context // this thread does not own. - let pts_ns = ffmpeg::rescale_q(pts, tb, NS_TIME_BASE); + let pts_ns = ffmpeg::rescale_q(pts, tb, ffmpeg::NS_TIME_BASE); // Frame interval EMA, for the estimate of how many frames a given output // lead parks in the libobs async queue. Measured rather than taken from diff --git a/crates/irl-source/src/video/output.rs b/crates/irl-source/src/video/output.rs index 4c32c1f..8159ace 100644 --- a/crates/irl-source/src/video/output.rs +++ b/crates/irl-source/src/video/output.rs @@ -96,11 +96,6 @@ pub fn convert_color_range(range: AVColorRange) -> ColorRange { } } -/// `irl_video_is_keyframe` (`video-handler.c:203-206`). -pub fn is_keyframe(frame: &Frame) -> bool { - frame.is_key() -} - impl VideoThread { /* ── Transfer to system memory ────────────────────────── */ @@ -165,7 +160,7 @@ impl VideoThread { // This backend refuses a caller-allocated destination; stop // offering one. self.xfer_pool_broken = true; - self.xfer_pool_release(); + self.xfer_pool = None; irl_warn!("Pooled hw frame transfer rejected; falling back to per-frame allocation"); return None; } @@ -175,13 +170,6 @@ impl VideoThread { Some(dst) } - /// `irl_video_xfer_pool_release`. Safe with pooled buffers still alive in - /// the pacing queue: the pool lingers internally until its last buffer is - /// returned. - pub fn xfer_pool_release(&mut self) { - self.xfer_pool = None; - } - /* ── Timestamp mapping ────────────────────────────────── */ /// `irl_video_due_time` (`video-handler.c:339-411`): the OBS timestamp a @@ -268,8 +256,7 @@ impl VideoThread { /// a held offset belongs to audio that has stopped, and anchoring on it /// would be anchoring on a guess. pub fn mapping_published(&self) -> bool { - let state = self.shared.audio_state(); - state.latest_obs_end_ts_ns != 0 && state.latest_buffered_end_pts_ns > 0 + self.shared.audio_state().playout_mapping().is_some() } /// `irl_video_playout_offset` (`video-handler.c:431-453`): the current @@ -285,13 +272,10 @@ impl VideoThread { /// keeps the due times the frames arrived with. The standing video delay /// rides on the offset, so a reschedule keeps it. pub fn playout_offset(&mut self) -> Option { - let (obs_end, buffered_end) = { - let state = self.shared.audio_state(); - (state.latest_obs_end_ts_ns, state.latest_buffered_end_pts_ns) - }; + let mapping = self.shared.audio_state().playout_mapping(); let now = obs::time::gettime_ns(); - if obs_end != 0 && buffered_end > 0 { + if let Some((obs_end, buffered_end)) = mapping { self.playout_offset_ns = video_time::playout_offset_ns(obs_end, buffered_end); self.playout_offset_time_ns = now; } else if self.playout_offset_time_ns == 0 @@ -329,13 +313,10 @@ impl VideoThread { .lifetime .video_lead_peak_ns .fetch_max(lead_ns, Relaxed); - if lead_ns > queue_safe_ns { - self.shared.lifetime.video_lead_excess.fetch_add(1, Relaxed); - } - if lead_ns <= queue_safe_ns { return; } + self.shared.lifetime.video_lead_excess.fetch_add(1, Relaxed); // Only a risk while the lead is still climbing — a steady lead of any // size is free — so this is a "watch this" line, not a fault. @@ -443,25 +424,21 @@ impl VideoThread { self.nv12_scratch.resize(need, 0); } - if self.scaler.is_none() { - match Scaler::new(sws_unstable()) { - Ok(scaler) => self.scaler = Some(scaler), - Err(err) => { - irl_warn!("swscale conversion failed: {err}"); - return false; - } - } - } - + let scaler = match self + .scaler + .take() + .map_or_else(|| Scaler::new(sws_unstable()), Ok) { - let (y, uv) = self.nv12_scratch.split_at_mut(y_size); - let Some(scaler) = self.scaler.as_mut() else { - return false; - }; - if let Err(err) = scaler.scale_into_nv12(frame, y, uv, width) { + Ok(scaler) => self.scaler.insert(scaler), + Err(err) => { irl_warn!("swscale conversion failed: {err}"); return false; } + }; + let (y, uv) = self.nv12_scratch.split_at_mut(y_size); + if let Err(err) = scaler.scale_into_nv12(frame, y, uv, width) { + irl_warn!("swscale conversion failed: {err}"); + return false; } let obs_frame = VideoFrame::new(width as u32, height as u32, VideoFormat::Nv12) diff --git a/crates/irl-source/src/video/thread.rs b/crates/irl-source/src/video/thread.rs index 2e66e4d..d473c14 100644 --- a/crates/irl-source/src/video/thread.rs +++ b/crates/irl-source/src/video/thread.rs @@ -77,7 +77,9 @@ pub struct VideoThread { /// Last known stream-PTS → OBS-clock offset and when it was taken. pub(crate) playout_offset_ns: i64, pub(crate) playout_offset_time_ns: u64, - /// Recycled destinations for `av_hwframe_transfer_data`. + /// Recycled destinations for `av_hwframe_transfer_data`. Safe to drop + /// with pooled buffers still alive in the pacing queue: the pool lingers + /// internally until its last buffer is returned. pub(crate) xfer_pool: Option, /// Latched when a backend rejects a caller-allocated destination. pub(crate) xfer_pool_broken: bool, @@ -193,7 +195,7 @@ impl VideoThread { // A cleared source is showing nothing; no reason to keep a lead's // worth of recycled buffers resident while it does. The next frame // rebuilds the pool. - self.xfer_pool_release(); + self.xfer_pool = None; // The offset belongs to the connection that just ended; the next // one brings its own PTS epoch. self.playout_offset_ns = 0; @@ -236,7 +238,7 @@ impl VideoThread { fn finish(&mut self) { self.pacing.drain(); self.decoded.clear(); - self.xfer_pool_release(); + self.xfer_pool = None; self.shared.video.drain(); self.decoder = None; } @@ -261,9 +263,7 @@ impl VideoThread { // until the old connection's frames drained. while shared.video.next_is_decoder() { if let Some(VideoMsg::Decoder(decoder)) = shared.video.pop() { - self.decoder = Some(*decoder); - self.state.reset(); - self.reset_delay(); + self.adopt_decoder(*decoder); } } while self.pacing.has_room() { @@ -271,13 +271,7 @@ impl VideoThread { return; }; match msg { - VideoMsg::Decoder(decoder) => { - // A new connection. Anything the old decoder produced is - // already paced or was cleared with the disconnect. - self.decoder = Some(*decoder); - self.state.reset(); - self.reset_delay(); - } + VideoMsg::Decoder(decoder) => self.adopt_decoder(*decoder), VideoMsg::Packet(packet) => { let received_ns = packet.received_ns; let mut produced = std::mem::take(&mut self.decoded); @@ -300,6 +294,14 @@ impl VideoThread { } } + /// A new connection's decoder. Anything the old one produced is already + /// paced or was cleared with the disconnect. + fn adopt_decoder(&mut self, decoder: VideoDecoder) { + self.decoder = Some(decoder); + self.state.reset(); + self.reset_delay(); + } + /// Copy one decoded frame out of the hardware pool and schedule it. /// `received_ns` is when the packet it came from reached this thread. /// diff --git a/crates/irl-source/tests/network_sim.rs b/crates/irl-source/tests/network_sim.rs index b4acc51..191387d 100644 --- a/crates/irl-source/tests/network_sim.rs +++ b/crates/irl-source/tests/network_sim.rs @@ -41,7 +41,6 @@ const CHANNELS: i32 = 2; /// One Opus frame: 20 ms at 48 kHz. const CHUNK_FRAMES: i32 = 960; const CHUNK_NS: u64 = 20_000_000; -const TB_NS: ffmpeg::Rational = ffmpeg::Rational::new(1, 1_000_000_000); // ── Recording sink ──────────────────────────────────────────── @@ -189,7 +188,7 @@ impl Sim { // What the decode path does when the audio decoder opens; without it // the intake has no PTS-repair state and discards every frame. let mut intake = AudioIntake::default(); - intake.init_pts_repair(TB_NS); + intake.init_pts_repair(ffmpeg::NS_TIME_BASE); Self { intake, @@ -318,8 +317,12 @@ impl Sim { self.since_delivery = 0; for pts in std::mem::take(&mut self.pending) { let frame = Self::decoded_chunk(pts, 0.25); - self.intake - .handle_frame(&self.shared, &mut self.flags, &frame, TB_NS); + self.intake.handle_frame( + &self.shared, + &mut self.flags, + &frame, + ffmpeg::NS_TIME_BASE, + ); self.delivered.push(pts); if let Some(skew) = self.video_skew_ns { hold::observe_video_packet(&self.shared, self.now_ns(), pts - skew); From 14d4ffadd04788651375523b9f175b705194b004 Mon Sep 17 00:00:00 2001 From: datagutt Date: Fri, 2 Oct 2026 01:46:09 +0200 Subject: [PATCH 10/28] refactor(irl-source): store hot values in one call and expose the pacing queue to tests --- crates/irl-source/src/config.rs | 21 +---------- crates/irl-source/src/shared.rs | 11 ++++++ crates/irl-source/src/video/thread.rs | 23 +++--------- crates/irl-source/tests/video_pipeline.rs | 44 ++++++++++++----------- 4 files changed, 40 insertions(+), 59 deletions(-) diff --git a/crates/irl-source/src/config.rs b/crates/irl-source/src/config.rs index 9174420..802175a 100644 --- a/crates/irl-source/src/config.rs +++ b/crates/irl-source/src/config.rs @@ -135,26 +135,7 @@ impl Config { } drop(state); - shared - .hot - .reconnect_delay_s - .store(self.hot.reconnect_delay_s, Relaxed); - shared - .hot - .adaptive_speed - .store(self.hot.adaptive_speed, Relaxed); - shared - .hot - .catchup_percent - .store(self.hot.catchup_percent, Relaxed); - shared - .hot - .wait_for_keyframe - .store(self.hot.wait_for_keyframe, Relaxed); - shared - .hot - .clear_on_disconnect - .store(self.hot.clear_on_disconnect, Relaxed); + shared.hot.store(&self.hot); effective } diff --git a/crates/irl-source/src/shared.rs b/crates/irl-source/src/shared.rs index adf7cf7..d2ac9a4 100644 --- a/crates/irl-source/src/shared.rs +++ b/crates/irl-source/src/shared.rs @@ -56,6 +56,17 @@ pub struct HotConfig { } impl HotConfig { + /// Swap in the atomic hot values. The watermarks are not among them: they + /// publish only once the ring has grown (`config::publish_watermarks`). + pub fn store(&self, hot: &HotValues) { + self.reconnect_delay_s.store(hot.reconnect_delay_s, Relaxed); + self.adaptive_speed.store(hot.adaptive_speed, Relaxed); + self.catchup_percent.store(hot.catchup_percent, Relaxed); + self.wait_for_keyframe.store(hot.wait_for_keyframe, Relaxed); + self.clear_on_disconnect + .store(hot.clear_on_disconnect, Relaxed); + } + pub fn watermarks(&self) -> Watermarks { *self.watermarks.lock() } diff --git a/crates/irl-source/src/video/thread.rs b/crates/irl-source/src/video/thread.rs index d473c14..38bd51b 100644 --- a/crates/irl-source/src/video/thread.rs +++ b/crates/irl-source/src/video/thread.rs @@ -779,24 +779,9 @@ impl VideoThread { self } - /// Whether the pacing queue can take another frame (test seam; the thread - /// passes this to [`crate::shared::VideoChannel::wait`]). - pub fn pacing_has_room(&self) -> bool { - self.pacing.has_room() - } - - /// Frames currently paced. - pub fn paced_len(&self) -> usize { - self.pacing.len() - } - - /// Due time of the head frame. - pub fn next_due_ns(&self) -> Option { - self.pacing.next_due() - } - - /// Frames emitted early because a pacing ceiling bound. - pub fn pacing_overflows(&self) -> u64 { - self.pacing.overflows() + /// The pacing queue, read-only (test seam). + #[doc(hidden)] + pub fn pacing(&self) -> &PacingQueue { + &self.pacing } } diff --git a/crates/irl-source/tests/video_pipeline.rs b/crates/irl-source/tests/video_pipeline.rs index 2ff269d..7601b03 100644 --- a/crates/irl-source/tests/video_pipeline.rs +++ b/crates/irl-source/tests/video_pipeline.rs @@ -586,18 +586,18 @@ fn a_full_pacing_queue_does_not_keep_the_video_thread_awake() { // Fill the pacing queue past its decode lead with frames due far ahead. let now = obs::time::gettime_ns(); let mut pts = 0i64; - while thread.pacing_has_room() { + while thread.pacing().has_room() { let mut frame = sw_frame(Pix::AV_PIX_FMT_YUV420P, 64, 32); frame.set_pts(now as i64 + 60_000_000_000 + pts); thread.pace_decoded(frame, now); pts += 33_333_333; } - assert!(!thread.pacing_has_room(), "queue should be at its lead"); + assert!(!thread.pacing().has_room(), "queue should be at its lead"); // Packets queued, no room, nothing due: there is nothing to do, so the // thread must sleep rather than spin. assert!( - !shared.video.has_work(thread.pacing_has_room()), + !shared.video.has_work(thread.pacing().has_room()), "the thread would spin: packets queued but no room to decode them" ); @@ -658,14 +658,14 @@ fn a_queued_frame_is_transferred_paced_and_emitted() { let emitted = recorder.only(); assert_eq!(emitted.planes[0].0, source_plane, "still zero-copy"); - assert_eq!(thread.paced_len(), 0); + assert_eq!(thread.pacing().len(), 0); assert!( !wait.is_zero(), "nothing left to pace: sleep the full slice" ); assert_eq!(shared.lifetime.pacing_peak.load(Relaxed), 1); assert_eq!(shared.lifetime.pacing_now.load(Relaxed), 0); - assert_eq!(thread.pacing_overflows(), 0); + assert_eq!(thread.pacing().overflows(), 0); } #[test] @@ -689,7 +689,7 @@ fn a_future_frame_waits_instead_of_being_emitted() { let wait = thread.run_once(now); assert_eq!(recorder.emitted().len(), 1, "not due yet"); - assert_eq!(thread.paced_len(), 1); + assert_eq!(thread.pacing().len(), 1); assert_eq!( wait.as_millis() as u64, irl_core::consts::VIDEO_PACING_MAX_WAIT_MS, @@ -715,7 +715,7 @@ fn a_clear_request_drops_the_queue_and_blanks_the_source() { pending.set_pts(10_000_000_000); thread.pace_decoded(pending, now); thread.run_once(now); - assert_eq!(thread.paced_len(), 1, "parked until its due time"); + assert_eq!(thread.pacing().len(), 1, "parked until its due time"); shared.video.request_clear(); let wait = thread.run_once(now); @@ -725,7 +725,7 @@ fn a_clear_request_drops_the_queue_and_blanks_the_source() { wait.is_zero(), "the clear cycle goes round again immediately" ); - assert_eq!(thread.paced_len(), 0, "paced frames go with the clear"); + assert_eq!(thread.pacing().len(), 0, "paced frames go with the clear"); assert_eq!(shared.video.len(), 0); assert_eq!( recorder.emitted().len(), @@ -754,7 +754,7 @@ fn queued_frames_reschedule_onto_the_audio_playout_offset() { thread.run_once(now); assert!(recorder.emitted().is_empty(), "due a second from now"); - let due = thread.next_due_ns().expect("paced"); + let due = thread.pacing().next_due().expect("paced"); assert_eq!(due, now + 1_000_000_000); // The audio side reclaims half of that latency; the queued frame must move @@ -764,7 +764,7 @@ fn queued_frames_reschedule_onto_the_audio_playout_offset() { state.latest_obs_end_ts_ns = now + 500_000_000; } thread.run_once(now); - assert_eq!(thread.next_due_ns(), Some(now + 500_000_000)); + assert_eq!(thread.pacing().next_due(), Some(now + 500_000_000)); // Once the offset says "now", the frame has no margin left at all: a // tick's allowance is added as the standing delay, and it goes out at the @@ -775,7 +775,7 @@ fn queued_frames_reschedule_onto_the_audio_playout_offset() { } thread.run_once(now); assert!(recorder.emitted().is_empty()); - assert_eq!(thread.next_due_ns(), Some(now + TICK)); + assert_eq!(thread.pacing().next_due(), Some(now + TICK)); thread.run_once(now + TICK); assert_eq!(recorder.only().timestamp, now + TICK); } @@ -928,7 +928,7 @@ fn frames_already_past_due_when_the_mapping_arrives_do_not_anchor_the_play_head( } thread.run_once(now); assert!(recorder.emitted().is_empty()); - assert_eq!(thread.paced_len(), 4); + assert_eq!(thread.pacing().len(), 4); // Audio primes so that the first three frames map 100, 60 and 20 ms into // the past — all past a canvas tick — and the fourth lands 20 ms out. @@ -938,7 +938,7 @@ fn frames_already_past_due_when_the_mapping_arrives_do_not_anchor_the_play_head( recorder.emitted().is_empty(), "the stale frames must not go out in place of the on-time one" ); - assert_eq!(thread.paced_len(), 1, "three stale frames dropped"); + assert_eq!(thread.pacing().len(), 1, "three stale frames dropped"); assert_eq!(shared.conn.video_delay_ns.load(Relaxed), 0); thread.run_once(now + 20_000_000); @@ -991,7 +991,7 @@ fn video_stops_waiting_for_audio_that_never_primes() { thread.run_once(give_up + 1_000_000); assert!(recorder.emitted().is_empty()); assert_eq!( - thread.paced_len(), + thread.pacing().len(), 0, "the stale frame was dropped, not anchored" ); @@ -1004,7 +1004,7 @@ fn video_stops_waiting_for_audio_that_never_primes() { let mut fresh = sw_frame(Pix::AV_PIX_FMT_YUV420P, 64, 32); fresh.set_pts(10_000_000_000 + 1_400_000_000); thread.pace_decoded(fresh, obs::time::gettime_ns()); - let due = thread.next_due_ns().expect("paced on the fallback"); + let due = thread.pacing().next_due().expect("paced on the fallback"); thread.run_once(due); assert_eq!(recorder.only().timestamp, due); } @@ -1031,7 +1031,7 @@ fn video_without_audio_is_delayed_by_a_tick_and_anchors_on_the_fallback() { assert_eq!(shared.conn.video_delay_ns.load(Relaxed), TICK); // The fallback anchors on the clock as `due_time` read it, a hair after // `t0`, so the due time is a tick past that rather than past `t0`. - let due = thread.next_due_ns().expect("paced"); + let due = thread.pacing().next_due().expect("paced"); assert!( due >= t0 + TICK && due < t0 + TICK + 1_000_000, "due {due} vs t0 {t0}" @@ -1117,7 +1117,7 @@ fn video_that_trails_its_audio_is_delayed_into_pacing_not_dropped() { "canvas {canvas_fps}fps, frame {i}" ); } - assert_eq!(thread.paced_len(), 0); + assert_eq!(thread.pacing().len(), 0); } } @@ -1146,7 +1146,7 @@ fn a_stale_startup_burst_does_not_raise_the_delay() { } thread.run_once(t0); - assert_eq!(thread.paced_len(), 2, "eighteen stale frames dropped"); + assert_eq!(thread.pacing().len(), 2, "eighteen stale frames dropped"); assert_eq!( shared.conn.video_delay_ns.load(Relaxed), 0, @@ -1185,7 +1185,7 @@ fn a_late_stream_is_measured_on_its_newest_frame() { // were stale even after that and went. let delay = 10 * TICK; assert_eq!(shared.conn.video_delay_ns.load(Relaxed), delay); - assert_eq!(thread.paced_len(), 1); + assert_eq!(thread.pacing().len(), 1); thread.run_once(t0 - late + delay); assert_eq!(recorder.only().timestamp, t0 - late + delay); } @@ -1371,7 +1371,11 @@ fn video_later_than_the_delay_ceiling_anchors_and_plays_unpaced() { frame.set_pts(10_000_000_000 + (i * FRAME) as i64); thread.pace_decoded(frame, arrival); thread.run_once(arrival); - assert_eq!(thread.paced_len(), 0, "frame {i} neither held nor dropped"); + assert_eq!( + thread.pacing().len(), + 0, + "frame {i} neither held nor dropped" + ); } assert_eq!( From 120f90d2d2b0eb7ef385fe755c8c33034d9d9636 Mon Sep 17 00:00:00 2001 From: datagutt Date: Fri, 2 Oct 2026 01:57:34 +0200 Subject: [PATCH 11/28] refactor(workspace): drop obs api the plugin does not call --- crates/irl-source/src/providers.rs | 10 ++-- crates/obs/src/audio.rs | 3 +- crates/obs/src/data.rs | 13 ++--- crates/obs/src/lib.rs | 17 +++--- crates/obs/src/log.rs | 2 +- crates/obs/src/module.rs | 2 +- crates/obs/src/proc.rs | 6 +-- crates/obs/src/properties.rs | 86 ++++++------------------------ crates/obs/src/scene.rs | 29 ++-------- crates/obs/src/source.rs | 63 ++-------------------- crates/obs/src/video.rs | 12 +---- crates/obs/tests/module_macro.rs | 4 +- 12 files changed, 50 insertions(+), 197 deletions(-) diff --git a/crates/irl-source/src/providers.rs b/crates/irl-source/src/providers.rs index 3f93487..f6761e0 100644 --- a/crates/irl-source/src/providers.rs +++ b/crates/irl-source/src/providers.rs @@ -35,7 +35,7 @@ use std::sync::OnceLock; use irl_core::consts::SOURCE_ID; use irl_provider::{Catalog, Hooks, Ingest, Level}; -use obs::{ClickAction, ComboType, Data, ModifiedAction, Properties, PropertiesRef, TextType}; +use obs::{ClickAction, Data, ModifiedAction, Properties, PropertiesRef, TextType}; use parking_lot::Mutex; use crate::module_text; @@ -228,7 +228,7 @@ pub fn add_properties(props: &Properties, instance: Option<&IrlSource>) { let saved = instance.map(|i| i.handle().settings()); let saved = saved.as_ref().map(|d| d.data()); - let list = props.add_string_list(KEY_PROVIDER, module_text(c"Provider"), ComboType::List); + let list = props.add_string_list(KEY_PROVIDER, module_text(c"Provider")); list.add(module_text(c"Provider.Manual"), c""); for (i, provider) in catalog().providers().iter().enumerate() { list.add(&cstring(&provider.name), &cstring(Slot::Listed(i).value())); @@ -272,11 +272,7 @@ pub fn add_properties(props: &Properties, instance: Option<&IrlSource>) { continue; } if signed_in { - let picker = props.add_string_list( - &slot.key(PREFIX_INGEST), - module_text(c"Ingest"), - ComboType::List, - ); + let picker = props.add_string_list(&slot.key(PREFIX_INGEST), module_text(c"Ingest")); picker.add(module_text(c"Ingest.Pick"), c""); for ingest in view.iter().flat_map(|v| &v.ingests) { picker.add(&cstring(&label(ingest)), &cstring(&ingest.id)); diff --git a/crates/obs/src/audio.rs b/crates/obs/src/audio.rs index e3935e7..a372ae6 100644 --- a/crates/obs/src/audio.rs +++ b/crates/obs/src/audio.rs @@ -8,8 +8,7 @@ pub enum AudioFormat { } impl AudioFormat { - #[must_use] - pub fn to_sys(self) -> obs_sys::audio_format { + fn to_sys(self) -> obs_sys::audio_format { match self { // Interleaved 32-bit float: the only format the plugin submits, // because swresample always converts to it first. diff --git a/crates/obs/src/data.rs b/crates/obs/src/data.rs index dce81d8..165d538 100644 --- a/crates/obs/src/data.rs +++ b/crates/obs/src/data.rs @@ -11,11 +11,11 @@ pub struct Data<'a>(NonNull, PhantomData<&'a ()>); impl Data<'_> { /// # Safety /// `ptr` must be a live `obs_data_t` for `'a`. - pub unsafe fn from_raw<'a>(ptr: NonNull) -> Data<'a> { + pub(crate) unsafe fn from_raw<'a>(ptr: NonNull) -> Data<'a> { Data(ptr, PhantomData) } - pub fn as_ptr(&self) -> *mut obs_sys::obs_data_t { + pub(crate) fn as_ptr(&self) -> *mut obs_sys::obs_data_t { self.0.as_ptr() } @@ -47,11 +47,6 @@ impl Data<'_> { unsafe { obs_sys::obs_data_get_bool(self.as_ptr(), key.as_ptr()) } } - pub fn get_f64(&self, key: &CStr) -> f64 { - // SAFETY: live handle, NUL-terminated key. - unsafe { obs_sys::obs_data_get_double(self.as_ptr(), key.as_ptr()) } - } - pub fn set_str(&self, key: &CStr, value: &CStr) { // SAFETY: live handle; libobs copies the string during the call. unsafe { obs_sys::obs_data_set_string(self.as_ptr(), key.as_ptr(), value.as_ptr()) }; @@ -114,7 +109,7 @@ impl OwnedData { /// # Safety /// `ptr` must be an `obs_data_t` whose reference this value now owns. - pub unsafe fn from_raw(ptr: NonNull) -> Self { + pub(crate) unsafe fn from_raw(ptr: NonNull) -> Self { Self(ptr) } } @@ -149,7 +144,7 @@ impl DataArray { unsafe { obs_sys::obs_data_array_push_back(self.as_ptr(), item.0.as_ptr()) }; } - pub fn as_ptr(&self) -> *mut obs_sys::obs_data_array_t { + pub(crate) fn as_ptr(&self) -> *mut obs_sys::obs_data_array_t { self.0.as_ptr() } } diff --git a/crates/obs/src/lib.rs b/crates/obs/src/lib.rs index d0c044c..cb9995a 100644 --- a/crates/obs/src/lib.rs +++ b/crates/obs/src/lib.rs @@ -2,10 +2,11 @@ //! //! This crate is the unsafe boundary for everything libobs: every raw pointer, //! every `extern "C"` shim and every `catch_unwind` lives here. It knows -//! nothing about IRL streaming; the API is shaped by what an async-video + -//! audio input source needs (registration, settings, properties, output, -//! proc handlers, scene transforms, the obs-websocket vendor API), and can be -//! reused by other plugins as is. +//! nothing about IRL streaming, but it is not a general libobs binding either: +//! it carries exactly what this plugin's async-video + audio input source uses +//! (registration, settings, properties, output, proc handlers, scene +//! transforms, the obs-websocket vendor API), and an API the plugin stops +//! calling is removed rather than kept for a hypothetical other caller. //! //! Conventions: //! - Every `extern "C"` function exported or handed to libobs is wrapped in @@ -32,13 +33,11 @@ pub use audio::{AudioFormat, AudioFrame, SpeakerLayout}; pub use data::{Data, DataArray, OwnedData}; pub use proc::{CallData, ProcCallback, ProcHandler}; pub use properties::{ - ClickAction, ComboFormat, ComboType, IntList, IntProperty, ModifiedAction, Properties, - PropertiesRef, Property, StringList, TextType, + ClickAction, IntList, IntProperty, ModifiedAction, Properties, PropertiesRef, Property, + StringList, TextType, }; pub use scene::{BoundsType, Scene, SceneItem, TransformInfo, VideoInfo}; -pub use source::{ - IconType, MediaState, OwnedSource, Source, SourceHandle, SourceType, register_source, -}; +pub use source::{MediaState, OwnedSource, Source, SourceHandle, register_source}; pub use video::{ColorRange, ColorSpace, VideoFormat, VideoFrame}; /// Re-exported so plugins can name raw types in the rare place they need to diff --git a/crates/obs/src/log.rs b/crates/obs/src/log.rs index 66b5365..5b11b19 100644 --- a/crates/obs/src/log.rs +++ b/crates/obs/src/log.rs @@ -15,7 +15,7 @@ pub enum Level { /// Emit one log line through libobs. `msg` is passed as the `%s` argument, so /// it may contain anything (including `%`). -pub fn blog(level: Level, msg: &str) { +pub(crate) fn blog(level: Level, msg: &str) { // An interior NUL would truncate the line at best and, via CString's // error path, drop it entirely. Replacing keeps the message readable and // keeps this function infallible, which matters because it is the last diff --git a/crates/obs/src/module.rs b/crates/obs/src/module.rs index 9688e4e..c988ab3 100644 --- a/crates/obs/src/module.rs +++ b/crates/obs/src/module.rs @@ -30,7 +30,7 @@ pub fn set_pointer(module: *mut obs_sys::obs_module_t) { /// The pointer stored by [`set_pointer`] (the C `obs_current_module()`). #[must_use] -pub fn current_module() -> *mut obs_sys::obs_module_t { +pub(crate) fn current_module() -> *mut obs_sys::obs_module_t { MODULE.load(Ordering::Acquire) } diff --git a/crates/obs/src/proc.rs b/crates/obs/src/proc.rs index 8efaef9..c22f650 100644 --- a/crates/obs/src/proc.rs +++ b/crates/obs/src/proc.rs @@ -24,7 +24,7 @@ pub struct ProcHandler<'a>(NonNull, PhantomData<&'a ()> impl ProcHandler<'_> { /// # Safety /// `ptr` must be a live proc handler for `'a`. - pub unsafe fn from_raw<'a>(ptr: NonNull) -> ProcHandler<'a> { + pub(crate) unsafe fn from_raw<'a>(ptr: NonNull) -> ProcHandler<'a> { ProcHandler(ptr, PhantomData) } @@ -119,7 +119,7 @@ impl CallData { /// # Safety /// `ptr` must be a live `calldata_t` for the returned borrow. - pub unsafe fn from_raw_mut<'a>(ptr: *mut obs_sys::calldata_t) -> &'a mut Self { + pub(crate) unsafe fn from_raw_mut<'a>(ptr: *mut obs_sys::calldata_t) -> &'a mut Self { // CallData is #[repr(transparent)]-equivalent over calldata_t. unsafe { &mut *(ptr as *mut Self) } } @@ -207,7 +207,7 @@ impl CallData { .then_some(v) } - pub fn as_mut_ptr(&mut self) -> *mut obs_sys::calldata_t { + pub(crate) fn as_mut_ptr(&mut self) -> *mut obs_sys::calldata_t { &mut self.0 } } diff --git a/crates/obs/src/properties.rs b/crates/obs/src/properties.rs index ac4a774..2a3a8a4 100644 --- a/crates/obs/src/properties.rs +++ b/crates/obs/src/properties.rs @@ -17,8 +17,6 @@ use crate::panic::guard; #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum TextType { Default, - Password, - Multiline, Info, } @@ -27,56 +25,11 @@ impl TextType { use obs_sys::obs_text_type as T; match self { Self::Default => T::OBS_TEXT_DEFAULT, - Self::Password => T::OBS_TEXT_PASSWORD, - Self::Multiline => T::OBS_TEXT_MULTILINE, Self::Info => T::OBS_TEXT_INFO, } } } -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum ComboFormat { - Int, - String, -} - -impl ComboFormat { - fn to_sys(self) -> obs_sys::obs_combo_format { - use obs_sys::obs_combo_format as F; - match self { - Self::Int => F::OBS_COMBO_FORMAT_INT, - Self::String => F::OBS_COMBO_FORMAT_STRING, - } - } -} - -/// Which combo widget a string list is drawn as. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum ComboType { - /// A plain dropdown. Stores the selected item's *value* while displaying - /// its name, which is what lets an entry read "Relay 3" and resolve to a - /// URL. - /// - /// When the saved value matches no item and the list is non-empty, the - /// frontend writes item 0 back into settings on dialog open. A list bound - /// to a key that can legitimately hold an off-list value therefore needs an - /// item whose value is the empty string. - List, - /// A dropdown the user can also type into. Stores the *displayed text*, so - /// its item names must equal their values. - Editable, -} - -impl ComboType { - fn to_sys(self) -> obs_sys::obs_combo_type { - use obs_sys::obs_combo_type as T; - match self { - Self::List => T::OBS_COMBO_TYPE_LIST, - Self::Editable => T::OBS_COMBO_TYPE_EDITABLE, - } - } -} - /// What a button added with [`Properties::add_button`] does. Implement on a /// marker type. /// @@ -196,7 +149,7 @@ impl Property<'_> { } /// `obs_properties_t` being built. Ownership passes to libobs when the -/// `get_properties` shim returns [`Properties::into_raw`]. +/// `get_properties` shim returns `Properties::into_raw`. #[derive(Debug)] pub struct Properties(NonNull); @@ -298,7 +251,7 @@ impl Properties { id.as_ptr(), description.as_ptr(), obs_sys::obs_combo_type::OBS_COMBO_TYPE_LIST, - ComboFormat::Int.to_sys(), + obs_sys::obs_combo_format::OBS_COMBO_FORMAT_INT, ) }; IntList( @@ -307,13 +260,16 @@ impl Properties { ) } - /// `obs_properties_add_list(..., OBS_COMBO_FORMAT_STRING)`. - pub fn add_string_list( - &self, - id: &CStr, - description: &CStr, - kind: ComboType, - ) -> StringList<'_> { + /// `obs_properties_add_list(..., OBS_COMBO_TYPE_LIST, OBS_COMBO_FORMAT_STRING)`: + /// a plain dropdown. It stores the selected item's *value* while + /// displaying its name, which is what lets an entry read "Relay 3" and + /// resolve to a URL. + /// + /// When the saved value matches no item and the list is non-empty, the + /// frontend writes item 0 back into settings on dialog open. A list bound + /// to a key that can legitimately hold an off-list value therefore needs an + /// item whose value is the empty string. + pub fn add_string_list(&self, id: &CStr, description: &CStr) -> StringList<'_> { // SAFETY: as above; the property belongs to this properties object, // which the returned StringList borrows. let ptr = unsafe { @@ -321,8 +277,8 @@ impl Properties { self.0.as_ptr(), id.as_ptr(), description.as_ptr(), - kind.to_sys(), - ComboFormat::String.to_sys(), + obs_sys::obs_combo_type::OBS_COMBO_TYPE_LIST, + obs_sys::obs_combo_format::OBS_COMBO_FORMAT_STRING, ) }; StringList( @@ -331,14 +287,6 @@ impl Properties { ) } - /// `obs_properties_get`: a property added earlier in this build, for - /// setting its initial visibility. - pub fn get(&self, id: &CStr) -> Option> { - // SAFETY: live handle owned by `self`; `id` is NUL-terminated. - let ptr = unsafe { obs_sys::obs_properties_get(self.0.as_ptr(), id.as_ptr()) }; - NonNull::new(ptr).map(|p| Property(p, PhantomData)) - } - /// `obs_properties_add_button`, calling `A::clicked` on press. pub fn add_button(&self, id: &CStr, text: &CStr) { // SAFETY: as above; the trampoline is a `'static` fn item with no @@ -354,7 +302,7 @@ impl Properties { } /// Hand ownership to libobs. Every `get_properties` shim ends here. - pub fn into_raw(self) -> *mut obs_sys::obs_properties_t { + pub(crate) fn into_raw(self) -> *mut obs_sys::obs_properties_t { let this = core::mem::ManuallyDrop::new(self); this.0.as_ptr() } @@ -391,8 +339,8 @@ pub struct StringList<'p>( ); impl StringList<'_> { - /// `obs_property_list_add_string`. See [`ComboType`] for which of `name` - /// and `value` the frontend stores. + /// `obs_property_list_add_string`. The frontend stores `value` and + /// displays `name`. pub fn add(&self, name: &CStr, value: &CStr) { // SAFETY: the property is alive for `'p` (owned by the Properties this // borrows); libobs copies both strings. diff --git a/crates/obs/src/scene.rs b/crates/obs/src/scene.rs index c4cc22a..2bc26db 100644 --- a/crates/obs/src/scene.rs +++ b/crates/obs/src/scene.rs @@ -6,35 +6,22 @@ use core::ptr::NonNull; use crate::panic::guard; use crate::source::SourceHandle; -#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +/// The subset of `enum obs_bounds_type` the plugin sets. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum BoundsType { - #[default] - None, - Stretch, ScaleInner, - ScaleOuter, - ScaleToWidth, - ScaleToHeight, - MaxOnly, } impl BoundsType { fn to_sys(self) -> obs_sys::obs_bounds_type { - use obs_sys::obs_bounds_type as B; match self { - Self::None => B::OBS_BOUNDS_NONE, - Self::Stretch => B::OBS_BOUNDS_STRETCH, - Self::ScaleInner => B::OBS_BOUNDS_SCALE_INNER, - Self::ScaleOuter => B::OBS_BOUNDS_SCALE_OUTER, - Self::ScaleToWidth => B::OBS_BOUNDS_SCALE_TO_WIDTH, - Self::ScaleToHeight => B::OBS_BOUNDS_SCALE_TO_HEIGHT, - Self::MaxOnly => B::OBS_BOUNDS_MAX_ONLY, + Self::ScaleInner => obs_sys::obs_bounds_type::OBS_BOUNDS_SCALE_INNER, } } } /// `struct obs_transform_info` in Rust terms. -#[derive(Debug, Clone, Copy, Default)] +#[derive(Debug, Clone, Copy)] pub struct TransformInfo { pub pos: (f32, f32), pub rot: f32, @@ -76,10 +63,6 @@ impl TransformInfo { pub struct VideoInfo { pub base_width: u32, pub base_height: u32, - pub output_width: u32, - pub output_height: u32, - pub fps_num: u32, - pub fps_den: u32, } /// `obs_get_video_info` (through the slack wrapper). @@ -102,10 +85,6 @@ pub fn get_video_info() -> Option { Some(VideoInfo { base_width: ovi.base_width, base_height: ovi.base_height, - output_width: ovi.output_width, - output_height: ovi.output_height, - fps_num: ovi.fps_num, - fps_den: ovi.fps_den, }) } diff --git a/crates/obs/src/source.rs b/crates/obs/src/source.rs index e1c2e98..b7122cc 100644 --- a/crates/obs/src/source.rs +++ b/crates/obs/src/source.rs @@ -10,55 +10,14 @@ use crate::proc::ProcHandler; use crate::properties::Properties; use crate::video::VideoFrame; -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum SourceType { - Input, - Filter, - Transition, -} - -impl SourceType { - fn to_sys(self) -> obs_sys::obs_source_type { - use obs_sys::obs_source_type as T; - match self { - Self::Input => T::OBS_SOURCE_TYPE_INPUT, - Self::Filter => T::OBS_SOURCE_TYPE_FILTER, - Self::Transition => T::OBS_SOURCE_TYPE_TRANSITION, - } - } -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum IconType { - Unknown, - Media, - Camera, - Custom, -} - -impl IconType { - fn to_sys(self) -> obs_sys::obs_icon_type { - use obs_sys::obs_icon_type as I; - match self { - Self::Unknown => I::OBS_ICON_TYPE_UNKNOWN, - Self::Media => I::OBS_ICON_TYPE_MEDIA, - Self::Camera => I::OBS_ICON_TYPE_CAMERA, - Self::Custom => I::OBS_ICON_TYPE_CUSTOM, - } - } -} - -/// `enum obs_media_state`. +/// The subset of `enum obs_media_state` a live input reports. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum MediaState { None, Playing, Opening, Buffering, - Paused, Stopped, - Ended, - Error, } impl MediaState { @@ -69,15 +28,12 @@ impl MediaState { Self::Playing => M::OBS_MEDIA_STATE_PLAYING, Self::Opening => M::OBS_MEDIA_STATE_OPENING, Self::Buffering => M::OBS_MEDIA_STATE_BUFFERING, - Self::Paused => M::OBS_MEDIA_STATE_PAUSED, Self::Stopped => M::OBS_MEDIA_STATE_STOPPED, - Self::Ended => M::OBS_MEDIA_STATE_ENDED, - Self::Error => M::OBS_MEDIA_STATE_ERROR, } } } -/// An OBS source type. One `impl` per registered source id. +/// An OBS input source type. One `impl` per registered source id. /// /// Callbacks take `&self`: OBS-thread callbacks and proc-handler calls (which /// arrive on other threads, e.g. obs-websocket's) may overlap, so interior @@ -86,11 +42,8 @@ impl MediaState { pub trait Source: Send + Sync + Sized + 'static { /// `obs_source_info::id`. const ID: &'static CStr; - const TYPE: SourceType = SourceType::Input; /// `obs_source_info::output_flags` (`sys::OBS_SOURCE_*`). const OUTPUT_FLAGS: u32; - const ICON_TYPE: IconType = IconType::Unknown; - const VERSION: u32 = 0; /// Display name (`get_name`). The pointer is kept by OBS, hence `'static`. fn type_name() -> &'static CStr; @@ -275,7 +228,7 @@ pub fn register_source() { // few hundred bytes, not a leak that grows. let info = Box::leak(Box::new(obs_sys::obs_source_info { id: T::ID.as_ptr(), - type_: T::TYPE.to_sys(), + type_: obs_sys::obs_source_type::OBS_SOURCE_TYPE_INPUT, output_flags: T::OUTPUT_FLAGS, get_name: Some(shim_get_name::), create: Some(shim_create::), @@ -311,7 +264,7 @@ pub fn register_source() { get_defaults2: None, get_properties2: None, audio_mix: None, - icon_type: T::ICON_TYPE.to_sys(), + icon_type: obs_sys::obs_icon_type::OBS_ICON_TYPE_UNKNOWN, media_play_pause: Some(shim_media_play_pause::), media_restart: Some(shim_media_restart::), media_stop: Some(shim_media_stop::), @@ -321,7 +274,7 @@ pub fn register_source() { media_get_time: None, media_set_time: None, media_get_state: Some(shim_media_get_state::), - version: T::VERSION, + version: 0, // NULL means "the id is already unversioned", which is what libobs // assumes for a source that never renamed itself. unversioned_id: core::ptr::null(), @@ -499,12 +452,6 @@ impl OwnedSource { pub fn handle(&self) -> SourceHandle { self.0 } - - /// # Safety - /// `ptr` must be an `obs_source_t` whose reference this value now owns. - pub unsafe fn from_raw(ptr: NonNull) -> Self { - Self(SourceHandle(ptr)) - } } impl Drop for OwnedSource { diff --git a/crates/obs/src/video.rs b/crates/obs/src/video.rs index ef3ed4a..fb8851a 100644 --- a/crates/obs/src/video.rs +++ b/crates/obs/src/video.rs @@ -40,23 +40,18 @@ impl VideoFormat { #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum ColorSpace { - Default, Bt601, Bt709, - Srgb, Pq2100, Hlg2100, } impl ColorSpace { - #[must_use] - pub fn to_sys(self) -> obs_sys::video_colorspace { + fn to_sys(self) -> obs_sys::video_colorspace { use obs_sys::video_colorspace as C; match self { - Self::Default => C::VIDEO_CS_DEFAULT, Self::Bt601 => C::VIDEO_CS_601, Self::Bt709 => C::VIDEO_CS_709, - Self::Srgb => C::VIDEO_CS_SRGB, Self::Pq2100 => C::VIDEO_CS_2100_PQ, Self::Hlg2100 => C::VIDEO_CS_2100_HLG, } @@ -65,17 +60,14 @@ impl ColorSpace { #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum ColorRange { - Default, Partial, Full, } impl ColorRange { - #[must_use] - pub fn to_sys(self) -> obs_sys::video_range_type { + fn to_sys(self) -> obs_sys::video_range_type { use obs_sys::video_range_type as R; match self { - Self::Default => R::VIDEO_RANGE_DEFAULT, Self::Partial => R::VIDEO_RANGE_PARTIAL, Self::Full => R::VIDEO_RANGE_FULL, } diff --git a/crates/obs/tests/module_macro.rs b/crates/obs/tests/module_macro.rs index 0a6ab7b..e356af6 100644 --- a/crates/obs/tests/module_macro.rs +++ b/crates/obs/tests/module_macro.rs @@ -7,7 +7,7 @@ use core::ffi::CStr; -use obs::{Data, IconType, MediaState, Properties, Source, SourceHandle, SourceType}; +use obs::{Data, MediaState, Properties, Source, SourceHandle}; fn load() -> bool { // Never called here; `register_source` is exercised for its types only. @@ -34,11 +34,9 @@ struct Dummy; impl Source for Dummy { const ID: &'static CStr = c"dummy_source"; - const TYPE: SourceType = SourceType::Input; const OUTPUT_FLAGS: u32 = obs::sys::OBS_SOURCE_AUDIO | obs::sys::OBS_SOURCE_ASYNC_VIDEO | obs::sys::OBS_SOURCE_DO_NOT_DUPLICATE; - const ICON_TYPE: IconType = IconType::Media; fn type_name() -> &'static CStr { c"Dummy Source" From eacb4617b92eed5f2913adbb1a13b33b77c937b3 Mon Sep 17 00:00:00 2001 From: datagutt Date: Fri, 2 Oct 2026 02:03:01 +0200 Subject: [PATCH 12/28] refactor(workspace): drop ffmpeg api the plugin does not call --- crates/ffmpeg/src/codec.rs | 50 ++++--------------- crates/ffmpeg/src/dict.rs | 17 +------ crates/ffmpeg/src/format.rs | 20 ++------ crates/ffmpeg/src/frame.rs | 7 +-- crates/ffmpeg/src/hw.rs | 5 +- crates/ffmpeg/src/lib.rs | 12 ++--- crates/ffmpeg/src/packet.rs | 13 ++--- .../irl-source/tests/receiver_stream_open.rs | 4 +- 8 files changed, 31 insertions(+), 97 deletions(-) diff --git a/crates/ffmpeg/src/codec.rs b/crates/ffmpeg/src/codec.rs index 1fabb61..50994e3 100644 --- a/crates/ffmpeg/src/codec.rs +++ b/crates/ffmpeg/src/codec.rs @@ -1,6 +1,6 @@ //! Decoders. -use core::ffi::{CStr, c_void}; +use core::ffi::c_void; use core::mem::ManuallyDrop; use crate::format::StreamRef; @@ -57,21 +57,6 @@ impl Codec { index += 1; } } - - pub fn name(&self) -> &'static str { - // SAFETY: `self.0` is a live static descriptor with a static `name`. - let ptr = unsafe { (*self.0).name }; - if ptr.is_null() { - return "unknown"; - } - // SAFETY: `name` is a NUL-terminated string constant inside libavcodec. - unsafe { CStr::from_ptr(ptr) }.to_str().unwrap_or("unknown") - } - - #[doc(hidden)] - pub fn as_ptr(&self) -> *const ffmpeg_sys_next::AVCodec { - self.0 - } } /// The `get_format` callback type for forced-NVDEC negotiation. Receives the @@ -248,7 +233,10 @@ impl CodecBuilder { return Err(Error(ret)); } - Ok(CodecContext { ptr, codec, opaque }) + Ok(CodecContext { + ptr, + _opaque: opaque, + }) } } @@ -264,9 +252,8 @@ impl Drop for CodecBuilder { /// An open decoder. pub struct CodecContext { ptr: *mut ffmpeg_sys_next::AVCodecContext, - codec: Codec, // Box behind ctx->opaque, kept so it outlives the context. - opaque: Option>, + _opaque: Option>, } // SAFETY: an AVCodecContext has no thread affinity of its own (its internal @@ -274,22 +261,12 @@ pub struct CodecContext { // single thread and never shares one. unsafe impl Send for CodecContext {} -#[doc(hidden)] -pub struct CtxOpaque { - pub get_format: GetFormatFn, - pub codec: Codec, +struct CtxOpaque { + get_format: GetFormatFn, + codec: Codec, } impl CodecContext { - pub fn codec(&self) -> Codec { - self.codec - } - - pub fn codec_id(&self) -> AVCodecID { - // SAFETY: `self.ptr` is an open context we own. - unsafe { (*self.ptr).codec_id } - } - /// `hw_device_ctx != NULL` after open (FFmpeg may drop it on failure). pub fn has_hw_device(&self) -> bool { // SAFETY: as above. @@ -319,18 +296,12 @@ impl CodecContext { // SAFETY: `self.ptr` is an open decoder. unsafe { ffmpeg_sys_next::avcodec_flush_buffers(self.ptr) }; } - - #[doc(hidden)] - pub fn as_mut_ptr(&mut self) -> *mut ffmpeg_sys_next::AVCodecContext { - let _ = &self.opaque; - self.ptr - } } impl Drop for CodecContext { fn drop(&mut self) { // SAFETY: `&mut self.ptr` is our sole owning pointer. Rust runs this - // body before dropping `self.opaque`, so the get_format opaque stays + // body before dropping `self._opaque`, so the get_format opaque stays // alive until libavcodec can no longer call back into it. unsafe { ffmpeg_sys_next::avcodec_free_context(&mut self.ptr) }; } @@ -343,7 +314,6 @@ mod tests { #[test] fn finds_a_built_in_decoder() { let codec = Codec::find_decoder(AVCodecID::AV_CODEC_ID_H264).expect("h264 decoder"); - assert_eq!(codec.name(), "h264"); // The bundled build has hardware configs compiled in; the list must at // least be readable and terminate. let configs = codec.hw_configs(); diff --git a/crates/ffmpeg/src/dict.rs b/crates/ffmpeg/src/dict.rs index da4a3bb..f2cf9b5 100644 --- a/crates/ffmpeg/src/dict.rs +++ b/crates/ffmpeg/src/dict.rs @@ -24,23 +24,13 @@ impl Dictionary { /// Consume the dictionary for `avformat_open_input` (which takes it by /// `&mut` and leaves the unconsumed entries behind). - #[doc(hidden)] - pub fn as_mut_ptr(&mut self) -> *mut *mut ffmpeg_sys_next::AVDictionary { + pub(crate) fn as_mut_ptr(&mut self) -> *mut *mut ffmpeg_sys_next::AVDictionary { &mut self.0 } - /// Number of entries left in the dictionary. - pub fn len(&self) -> usize { - self.iter_keys().count() - } - - pub fn is_empty(&self) -> bool { - self.0.is_null() - } - /// Keys left after `avformat_open_input`, i.e. options the demuxer did not /// recognise (logged by the C plugin). - pub fn remaining_keys(&self) -> Vec { + pub(crate) fn remaining_keys(&self) -> Vec { self.iter_keys().collect() } @@ -85,7 +75,6 @@ mod tests { #[test] fn set_then_read_back_keys() { let mut dict = Dictionary::new(); - assert!(dict.is_empty()); assert!(dict.remaining_keys().is_empty()); dict.set(c"probesize", c"1000000").unwrap(); @@ -95,7 +84,5 @@ mod tests { let mut keys = dict.remaining_keys(); keys.sort(); assert_eq!(keys, vec!["fflags".to_string(), "probesize".to_string()]); - assert_eq!(dict.len(), 2); - assert!(!dict.is_empty()); } } diff --git a/crates/ffmpeg/src/format.rs b/crates/ffmpeg/src/format.rs index e131acd..4aa814f 100644 --- a/crates/ffmpeg/src/format.rs +++ b/crates/ffmpeg/src/format.rs @@ -71,7 +71,7 @@ impl InterruptWatch { } /// Record the start of a blocking call (`av_gettime()`). - pub fn arm(&self) { + pub(crate) fn arm(&self) { self.io_start_us .store(crate::gettime_us() as u64, Ordering::Relaxed); } @@ -104,7 +104,7 @@ impl InterruptWatch { /// Reported as "IRL Source fails to open an SRT stream that OBS's Media /// Source opens immediately" (irlserver/obs-irl-source#28): the media /// source's interrupt callback only checks for shutdown, so it just waits. - pub fn should_abort(&self) -> bool { + pub(crate) fn should_abort(&self) -> bool { if !self.active.load(Ordering::Relaxed) { return true; } @@ -232,8 +232,7 @@ impl StreamRef<'_> { } } - #[doc(hidden)] - pub fn as_ptr(&self) -> *const ffmpeg_sys_next::AVStream { + pub(crate) fn as_ptr(&self) -> *const ffmpeg_sys_next::AVStream { self.ptr } } @@ -313,7 +312,7 @@ impl FormatContext { (0..count).filter_map(move |i| self.stream(i)) } - pub fn stream(&self, index: usize) -> Option> { + fn stream(&self, index: usize) -> Option> { // SAFETY: `self.ptr` is an open context; `streams` is an array of // `nb_streams` non-null pointers. unsafe { @@ -336,15 +335,6 @@ impl FormatContext { // av_read_frame unrefs it before filling it in. Error::check(unsafe { ffmpeg_sys_next::av_read_frame(self.ptr, pkt.as_mut_ptr()) }) } - - pub fn watch(&self) -> &Arc { - &self.watch - } - - #[doc(hidden)] - pub fn as_mut_ptr(&mut self) -> *mut ffmpeg_sys_next::AVFormatContext { - self.ptr - } } impl Drop for FormatContext { @@ -446,6 +436,6 @@ mod tests { let err = FormatContext::open(c"irl-nonexistent://nowhere", opts, watch) .err() .expect("opening a bogus protocol must fail"); - assert!(err.code() < 0); + assert!(err.0 < 0); } } diff --git a/crates/ffmpeg/src/frame.rs b/crates/ffmpeg/src/frame.rs index c25acf3..1829dab 100644 --- a/crates/ffmpeg/src/frame.rs +++ b/crates/ffmpeg/src/frame.rs @@ -9,9 +9,7 @@ use crate::{AVPixelFormat, AVSampleFormat, Error, Result}; pub struct Colorimetry { pub colorspace: ffmpeg_sys_next::AVColorSpace, pub color_range: ffmpeg_sys_next::AVColorRange, - pub color_primaries: ffmpeg_sys_next::AVColorPrimaries, pub color_trc: ffmpeg_sys_next::AVColorTransferCharacteristic, - pub chroma_location: ffmpeg_sys_next::AVChromaLocation, } /// An owned `AVFrame*`. Refcounted buffers make it safe to move between @@ -268,9 +266,7 @@ impl Frame { Colorimetry { colorspace: (*self.0).colorspace, color_range: (*self.0).color_range, - color_primaries: (*self.0).color_primaries, color_trc: (*self.0).color_trc, - chroma_location: (*self.0).chroma_location, } } } @@ -348,8 +344,7 @@ impl Frame { unsafe { (*self.0).pts = pts }; } - #[doc(hidden)] - pub fn as_ptr(&self) -> *const ffmpeg_sys_next::AVFrame { + pub(crate) fn as_ptr(&self) -> *const ffmpeg_sys_next::AVFrame { self.0 } diff --git a/crates/ffmpeg/src/hw.rs b/crates/ffmpeg/src/hw.rs index bd3c531..c7cf054 100644 --- a/crates/ffmpeg/src/hw.rs +++ b/crates/ffmpeg/src/hw.rs @@ -6,7 +6,7 @@ use crate::{AVHWDeviceType, AVPixelFormat, Error, Result, ffalign}; /// Plane alignment for pooled transfer destinations; what /// `av_frame_get_buffer()` would pick on a modern x86 (AVX-512 stores), and /// what lets FFmpeg's uncached-copy fast path engage on D3D11VA. -pub const XFER_PLANE_ALIGN: i32 = 64; +const XFER_PLANE_ALIGN: i32 = 64; /// Surface dimensions are padded to this before allocating a transfer /// destination: hardware backends copy in aligned blocks, and every one of @@ -61,8 +61,7 @@ impl HwDeviceContext { self.kind } - #[doc(hidden)] - pub fn as_ptr(&self) -> *mut ffmpeg_sys_next::AVBufferRef { + pub(crate) fn as_ptr(&self) -> *mut ffmpeg_sys_next::AVBufferRef { self.ptr } } diff --git a/crates/ffmpeg/src/lib.rs b/crates/ffmpeg/src/lib.rs index 7a39a07..b6721af 100644 --- a/crates/ffmpeg/src/lib.rs +++ b/crates/ffmpeg/src/lib.rs @@ -57,11 +57,6 @@ impl Error { self.0 == sys::AVERROR_EOF } - /// `AVERROR(ERANGE)` and friends, for option-setting diagnostics. - pub fn code(&self) -> c_int { - self.0 - } - /// `AVERROR(ENOMEM)` — every allocation failure in this crate. pub(crate) const fn nomem() -> Self { Error(-(sys::ENOMEM as c_int)) @@ -223,7 +218,12 @@ pub fn hwdevice_type_name(kind: AVHWDeviceType) -> &'static str { } /// `av_image_get_buffer_size(fmt, w, h, align)`. -pub fn image_buffer_size(fmt: AVPixelFormat, width: i32, height: i32, align: i32) -> Result { +pub(crate) fn image_buffer_size( + fmt: AVPixelFormat, + width: i32, + height: i32, + align: i32, +) -> Result { // SAFETY: pure computation over scalars and the static pixel format table. let size = unsafe { sys::av_image_get_buffer_size(fmt, width, height, align) }; if size <= 0 { diff --git a/crates/ffmpeg/src/packet.rs b/crates/ffmpeg/src/packet.rs index 8e9ab79..1a7e127 100644 --- a/crates/ffmpeg/src/packet.rs +++ b/crates/ffmpeg/src/packet.rs @@ -30,11 +30,6 @@ impl Packet { (unsafe { (*self.0).flags } & ffmpeg_sys_next::AV_PKT_FLAG_KEY) != 0 } - pub fn pts(&self) -> i64 { - // SAFETY: as above. - unsafe { (*self.0).pts } - } - /// Presentation timestamp, falling back to the decode timestamp, `None` /// when neither is set. Mirrors [`crate::Frame::best_effort_pts`]: live /// demuxers leave one or both unset often enough that the caller must not @@ -90,13 +85,11 @@ impl Packet { unsafe { ffmpeg_sys_next::av_packet_unref(self.0) }; } - #[doc(hidden)] - pub fn as_ptr(&self) -> *const ffmpeg_sys_next::AVPacket { + pub(crate) fn as_ptr(&self) -> *const ffmpeg_sys_next::AVPacket { self.0 } - #[doc(hidden)] - pub fn as_mut_ptr(&mut self) -> *mut ffmpeg_sys_next::AVPacket { + pub(crate) fn as_mut_ptr(&mut self) -> *mut ffmpeg_sys_next::AVPacket { self.0 } } @@ -119,7 +112,7 @@ mod tests { assert_eq!(pkt.size(), 0); assert_eq!(pkt.stream_index(), 0); assert!(!pkt.is_key()); - assert_eq!(pkt.pts(), ffmpeg_sys_next::AV_NOPTS_VALUE); + assert_eq!(pkt.pts_or_dts(), None); pkt.unref(); pkt.unref(); assert_eq!(pkt.size(), 0); diff --git a/crates/irl-source/tests/receiver_stream_open.rs b/crates/irl-source/tests/receiver_stream_open.rs index f7df25e..dce5bc9 100644 --- a/crates/irl-source/tests/receiver_stream_open.rs +++ b/crates/irl-source/tests/receiver_stream_open.rs @@ -29,7 +29,7 @@ fn opening_a_missing_file_fails_with_a_hard_error() { // The receiver logs `av_strerror` text and reconnects; neither EAGAIN nor // EOF, which are the two codes the decode path treats as "not an error". - assert!(err.code() < 0); + assert!(err.0 < 0); assert!(!err.is_eagain()); assert!(!err.is_eof()); assert!(!err.to_string().is_empty()); @@ -45,7 +45,7 @@ fn an_unknown_protocol_fails_rather_than_hanging() { .err() .expect("an unknown protocol must fail"); - assert!(err.code() < 0); + assert!(err.0 < 0); assert!(!err.is_eagain()); } From 92cdf6cd3fef3f6411046c24755d3099241bfad4 Mon Sep 17 00:00:00 2001 From: datagutt Date: Fri, 2 Oct 2026 02:07:00 +0200 Subject: [PATCH 13/28] refactor(obs-sys): drop the unused obs_data_get_double declaration --- crates/obs-sys/src/lib.rs | 1 - 1 file changed, 1 deletion(-) diff --git a/crates/obs-sys/src/lib.rs b/crates/obs-sys/src/lib.rs index 10bfa6f..f88b466 100644 --- a/crates/obs-sys/src/lib.rs +++ b/crates/obs-sys/src/lib.rs @@ -639,7 +639,6 @@ unsafe extern "C" { pub fn obs_data_get_string(data: *mut obs_data_t, name: *const c_char) -> *const c_char; pub fn obs_data_get_int(data: *mut obs_data_t, name: *const c_char) -> i64; pub fn obs_data_get_bool(data: *mut obs_data_t, name: *const c_char) -> bool; - pub fn obs_data_get_double(data: *mut obs_data_t, name: *const c_char) -> f64; pub fn obs_data_set_string(data: *mut obs_data_t, name: *const c_char, val: *const c_char); pub fn obs_data_set_int(data: *mut obs_data_t, name: *const c_char, val: i64); pub fn obs_data_set_bool(data: *mut obs_data_t, name: *const c_char, val: bool); From 5f88ae73611d02a624f482257b76eb8aadd0c115 Mon Sep 17 00:00:00 2001 From: datagutt Date: Fri, 2 Oct 2026 02:46:41 +0200 Subject: [PATCH 14/28] test(irl-source): share fixtures and fold repeated setup into helpers --- crates/irl-source/tests/audio_core.rs | 212 ++++--------- crates/irl-source/tests/common/mod.rs | 138 +++++++++ crates/irl-source/tests/network_sim.rs | 210 +++---------- crates/irl-source/tests/provider_seam.rs | 33 +- crates/irl-source/tests/shell_config.rs | 21 +- crates/irl-source/tests/video_pipeline.rs | 359 +++++++--------------- 6 files changed, 369 insertions(+), 604 deletions(-) create mode 100644 crates/irl-source/tests/common/mod.rs diff --git a/crates/irl-source/tests/audio_core.rs b/crates/irl-source/tests/audio_core.rs index ed96eb2..f5aa09f 100644 --- a/crates/irl-source/tests/audio_core.rs +++ b/crates/irl-source/tests/audio_core.rs @@ -5,120 +5,54 @@ //! and reads a test-owned clock instead of `os_gettime_ns`. The `SourceHandle` //! in `Shared` is never dereferenced by these paths. -use std::ffi::CString; -use std::ptr::NonNull; +mod common; + use std::sync::Arc; use std::sync::atomic::{AtomicU64, Ordering::Relaxed}; -use parking_lot::Mutex; - -use irl_core::{AudioBuffer, HwDecode, Watermarks, consts}; -use obs_irl_source::audio::{AudioPump, AudioSink}; +use irl_core::{AudioBuffer, consts}; +use obs_irl_source::audio::AudioPump; use obs_irl_source::receiver::ReceiverFlags; use obs_irl_source::receiver::audio_in::AudioIntake; -use obs_irl_source::shared::{HotValues, LifetimeStats, Shared, StreamConfig}; +use obs_irl_source::shared::Shared; -const RATE: i32 = 48_000; -const CHANNELS: i32 = 2; -/// One Opus frame at 48 kHz: 20 ms. -const CHUNK_FRAMES: usize = 960; -const CHUNK_NS: u64 = 20_000_000; +use common::{CHANNELS, CHUNK_FRAMES, CHUNK_NS, RATE, Recorder}; // ── Harness ─────────────────────────────────────────────────── -struct Recorded { - timestamp: u64, - frames: u32, - rate: u32, - samples: Vec, -} - -/// Sink that keeps every submission instead of handing it to libobs. -#[derive(Clone)] -struct Recorder { - channels: usize, - emitted: Arc>>, -} - -impl Recorder { - fn new(channels: usize) -> Self { - Self { - channels, - emitted: Arc::new(Mutex::new(Vec::new())), - } - } - - fn len(&self) -> usize { - self.emitted.lock().len() - } -} - -impl AudioSink for Recorder { - fn output_audio(&self, audio: &obs::AudioFrame<'_>) { - let sys = audio.as_sys(); - let bytes = sys.frames as usize * self.channels * 4; - // SAFETY: the frame borrows a live interleaved-float buffer of - // `frames * channels` samples, which is what the pump built. - let raw = unsafe { std::slice::from_raw_parts(sys.data[0], bytes) }; - self.emitted.lock().push(Recorded { - timestamp: sys.timestamp, - frames: sys.frames, - rate: sys.samples_per_sec, - samples: raw - .chunks_exact(4) - .map(|c| f32::from_le_bytes([c[0], c[1], c[2], c[3]])) - .collect(), - }); - } -} - -fn stream_config(low_latency: bool) -> StreamConfig { - StreamConfig { - url: CString::new("srt://127.0.0.1:9000").unwrap(), - ffmpeg_options: None, - hw_decode: HwDecode::Auto, - low_latency_audio: low_latency, - } -} - fn make_shared(low_latency: bool, adaptive: bool) -> Arc { - // SAFETY: no code under test dereferences the handle — audio leaves - // through the recording sink, and nothing here calls into libobs. - let source = unsafe { obs::SourceHandle::from_raw(NonNull::dangling()) }; - let hot = HotValues { - reconnect_delay_s: 2, - adaptive_speed: adaptive, - catchup_percent: consts::DEFAULT_CATCHUP_PERCENT as i32, - wait_for_keyframe: true, - clear_on_disconnect: true, - watermarks: Watermarks { - target_ms: 120, - min_ms: 60, - max_ms: 320, - }, - }; - Shared::new( - source, - stream_config(low_latency), - hot, - Arc::new(LifetimeStats::default()), - ) + let mut hot = common::hot_values(120); + hot.adaptive_speed = adaptive; + common::shared(common::stream_config(low_latency), hot) } /// A configured jitter buffer and a pump wired to `clock` and `recorder`. fn make_pump(shared: &Arc, clock: &Arc, recorder: &Recorder) -> AudioPump { *shared.audio_buf() = AudioBuffer::new(RATE, CHANNELS, 4, 120, 60, 320); - shared.audio_state().decoded_frame_samples = CHUNK_FRAMES as i32; + shared.audio_state().decoded_frame_samples = CHUNK_FRAMES; let clock = Arc::clone(clock); AudioPump::with_sink(Arc::clone(shared), Box::new(recorder.clone())) .with_clock(Box::new(move || clock.load(Relaxed))) } +/// A pump over a fresh `Shared`, the sink it emits into and the virtual clock +/// it reads, starting at 1 s. +fn harness( + low_latency: bool, + adaptive: bool, +) -> (Arc, Arc, Recorder, AudioPump) { + let shared = make_shared(low_latency, adaptive); + let clock = Arc::new(AtomicU64::new(1_000_000_000)); + let recorder = Recorder::with_samples(); + let pump = make_pump(&shared, &clock, &recorder); + (shared, clock, recorder, pump) +} + /// Append one 20 ms chunk of constant-valued PCM at `pts_ns`. fn write_chunk(shared: &Shared, pts_ns: i64, value: f32) { - let mut bytes = Vec::with_capacity(CHUNK_FRAMES * CHANNELS as usize * 4); - for _ in 0..CHUNK_FRAMES * CHANNELS as usize { + let mut bytes = Vec::with_capacity((CHUNK_FRAMES * CHANNELS) as usize * 4); + for _ in 0..CHUNK_FRAMES * CHANNELS { bytes.extend_from_slice(&value.to_le_bytes()); } shared @@ -128,6 +62,15 @@ fn write_chunk(shared: &Shared, pts_ns: i64, value: f32) { .write_pts(&bytes, pts_ns); } +/// Append `count` consecutive chunks of `value`, starting at `*pts_ns` and +/// advancing it past the last one. +fn write_chunks(shared: &Shared, pts_ns: &mut i64, count: usize, value: f32) { + for _ in 0..count { + write_chunk(shared, *pts_ns, value); + *pts_ns += CHUNK_NS as i64; + } +} + fn fill_ms(shared: &Shared) -> i32 { shared.audio_buf().as_ref().map_or(0, |b| b.fill_ms()) } @@ -161,7 +104,7 @@ fn planar_aac_samples_follow_the_video_playout_mapping() { for rate in [44_100, 48_000] { let shared = make_shared(false, true); let clock = Arc::new(AtomicU64::new(10_000_000_000)); - let recorder = Recorder::new(CHANNELS as usize); + let recorder = Recorder::with_samples(); let ns = Arc::clone(&clock); let us = Arc::clone(&clock); let mut pump = AudioPump::with_sink(shared.clone(), Box::new(recorder.clone())) @@ -248,10 +191,7 @@ fn aac_frame(rate: i32, first_sample: i64, tb: ffmpeg::Rational) -> ffmpeg::Fram #[test] fn a_pump_burst_stops_when_disconnect_pauses_playback() { - let shared = make_shared(false, false); - let clock = Arc::new(AtomicU64::new(1_000_000_000)); - let recorder = Recorder::new(CHANNELS as usize); - let mut pump = make_pump(&shared, &clock, &recorder); + let (shared, clock, recorder, mut pump) = harness(false, false); for i in 0..15 { write_chunk(&shared, i * CHUNK_NS as i64, 0.25); } @@ -272,7 +212,7 @@ fn disconnect_fade_does_not_submit_stale_audio() { for rate in [44_100, 48_000] { for teardown_ns in [0, 1_000_000_000] { let shared = make_shared(false, true); - let recorder = Recorder::new(CHANNELS as usize); + let recorder = Recorder::with_samples(); let now = 10_000_000_000; *shared.audio_buf() = AudioBuffer::new(rate, CHANNELS, 4, 120, 60, 320); let data = vec![0u8; rate as usize / 10 * CHANNELS as usize * 4]; @@ -314,18 +254,12 @@ fn disconnect_fade_does_not_submit_stale_audio() { /// frames/rate` — here over 10 000 consecutive chunks of a healthy stream. #[test] fn output_timestamps_are_contiguous_over_ten_thousand_chunks() { - let shared = make_shared(false, false); - let clock = Arc::new(AtomicU64::new(1_000_000_000)); - let recorder = Recorder::new(CHANNELS as usize); - let mut pump = make_pump(&shared, &clock, &recorder); + let (shared, clock, recorder, mut pump) = harness(false, false); let mut pts_ns = 0i64; // Prime: target (120 ms) + lead (80 ms) must be queued before playback // starts, so hand it 300 ms. - for _ in 0..15 { - write_chunk(&shared, pts_ns, 0.25); - pts_ns += CHUNK_NS as i64; - } + write_chunks(&shared, &mut pts_ns, 15, 0.25); let mut guard = 0; while recorder.len() < 10_000 { @@ -371,16 +305,10 @@ fn output_timestamps_are_contiguous_over_ten_thousand_chunks() { /// real sample instead of clicking. #[test] fn underrun_conceals_with_shaped_silence() { - let shared = make_shared(false, false); - let clock = Arc::new(AtomicU64::new(1_000_000_000)); - let recorder = Recorder::new(CHANNELS as usize); - let mut pump = make_pump(&shared, &clock, &recorder); + let (shared, clock, recorder, mut pump) = harness(false, false); let mut pts_ns = 0i64; - for _ in 0..15 { - write_chunk(&shared, pts_ns, 0.5); - pts_ns += CHUNK_NS as i64; - } + write_chunks(&shared, &mut pts_ns, 15, 0.5); // Feed nothing more: the buffer drains and the pump has to conceal. The // run stops short of the re-anchor margin (400 ms of concealment), so the @@ -434,16 +362,10 @@ fn underrun_conceals_with_shaped_silence() { /// arrived, with the sample rate submitted to OBS unchanged. #[test] fn speed_compensation_emits_fewer_frames_than_it_reads() { - let shared = make_shared(false, true); - let clock = Arc::new(AtomicU64::new(1_000_000_000)); - let recorder = Recorder::new(CHANNELS as usize); - let mut pump = make_pump(&shared, &clock, &recorder); + let (shared, clock, recorder, mut pump) = harness(false, true); let mut pts_ns = 0i64; - for _ in 0..12 { - write_chunk(&shared, pts_ns, 0.1); - pts_ns += CHUNK_NS as i64; - } + write_chunks(&shared, &mut pts_ns, 12, 0.1); // Hold the buffer well above buffer_max (320 ms) so the controller runs // at full drain authority. @@ -470,7 +392,7 @@ fn speed_compensation_emits_fewer_frames_than_it_reads() { "the submitted rate must never change" ); assert!( - (last.frames as usize) < CHUNK_FRAMES, + (last.frames as i32) < CHUNK_FRAMES, "at +5% a {CHUNK_FRAMES}-frame chunk must come out shorter, got {}", last.frames ); @@ -482,21 +404,14 @@ fn speed_compensation_emits_fewer_frames_than_it_reads() { /// backlog drains at whatever the slider says, not at a compiled-in +5 %. #[test] fn the_catchup_setting_bounds_the_drain_end_to_end() { - let shared = make_shared(false, true); + let (shared, clock, recorder, mut pump) = harness(false, true); shared .hot .catchup_percent .store(consts::CATCHUP_PERCENT_MIN, Relaxed); - let clock = Arc::new(AtomicU64::new(1_000_000_000)); - let recorder = Recorder::new(CHANNELS as usize); - let mut pump = make_pump(&shared, &clock, &recorder); - let mut pts_ns = 0i64; - for _ in 0..12 { - write_chunk(&shared, pts_ns, 0.1); - pts_ns += CHUNK_NS as i64; - } + write_chunks(&shared, &mut pts_ns, 12, 0.1); // Same runaway backlog as the test above, so the only difference is the // setting. @@ -520,7 +435,7 @@ fn the_catchup_setting_bounds_the_drain_end_to_end() { let emitted = recorder.emitted.lock(); let last = emitted.last().unwrap(); assert!( - (last.frames as usize) < CHUNK_FRAMES && last.frames > 930, + (last.frames as i32) < CHUNK_FRAMES && last.frames > 930, "at +2% a {CHUNK_FRAMES}-frame chunk should come out around 941, got {}", last.frames ); @@ -531,10 +446,7 @@ fn the_catchup_setting_bounds_the_drain_end_to_end() { /// nothing is to be done for. #[test] fn an_idle_pump_reports_when_it_next_has_work() { - let shared = make_shared(false, true); - let clock = Arc::new(AtomicU64::new(1_000_000_000)); - let recorder = Recorder::new(CHANNELS as usize); - let mut pump = make_pump(&shared, &clock, &recorder); + let (shared, clock, _, mut pump) = harness(false, true); // Nothing buffered yet: the wake condition is a write from another // thread, which no deadline here can predict. @@ -542,10 +454,7 @@ fn an_idle_pump_reports_when_it_next_has_work() { assert_eq!(pump.idle_sleep_ms(), consts::AUDIO_PUMP_SLEEP_MS); let mut pts_ns = 0i64; - for _ in 0..20 { - write_chunk(&shared, pts_ns, 0.1); - pts_ns += CHUNK_NS as i64; - } + write_chunks(&shared, &mut pts_ns, 20, 0.1); while pump.pump_once() {} // Primed and queued ahead: it now knows when the lead runs down, and that @@ -576,17 +485,11 @@ fn an_idle_pump_reports_when_it_next_has_work() { /// as long as the source stayed quiet. #[test] fn a_quiet_low_latency_input_suspends_the_clock_instead_of_restart_looping() { - let shared = make_shared(true, true); // low latency - let clock = Arc::new(AtomicU64::new(1_000_000_000)); - let recorder = Recorder::new(CHANNELS as usize); - let mut pump = make_pump(&shared, &clock, &recorder); + let (shared, clock, _, mut pump) = harness(true, true); // low latency // Prime on real audio. let mut pts_ns = 0i64; - for _ in 0..6 { - write_chunk(&shared, pts_ns, 0.1); - pts_ns += CHUNK_NS as i64; - } + write_chunks(&shared, &mut pts_ns, 6, 0.1); while pump.pump_once() {} assert!(shared.audio_state().primed, "never primed"); @@ -607,10 +510,7 @@ fn a_quiet_low_latency_input_suspends_the_clock_instead_of_restart_looping() { ); // Real audio returns: the normal prime path establishes one new clock. - for _ in 0..6 { - write_chunk(&shared, pts_ns, 0.2); - pts_ns += CHUNK_NS as i64; - } + write_chunks(&shared, &mut pts_ns, 6, 0.2); while pump.pump_once() {} assert!( shared.audio_state().primed, @@ -634,7 +534,7 @@ fn the_buffer_settles_on_the_configured_target_with_aac_chunks() { let shared = make_shared(false, true); let clock = Arc::new(AtomicU64::new(1_000_000_000)); - let recorder = Recorder::new(CHANNELS as usize); + let recorder = Recorder::with_samples(); *shared.audio_buf() = AudioBuffer::new(RATE, CHANNELS, 4, 120, 60, 320); shared.audio_state().decoded_frame_samples = AAC_FRAMES as i32; @@ -718,7 +618,7 @@ fn intake_discards_warmup_then_buffers_decoded_audio() { assert_eq!(buffered_ms, (expected_chunks * 20) as i32); let state = shared.audio_state(); - assert_eq!(state.decoded_frame_samples, CHUNK_FRAMES as i32); + assert_eq!(state.decoded_frame_samples, CHUNK_FRAMES); assert_eq!( state.latest_audio_stream_pts_ns, (frames - 1) * CHUNK_NS as i64 @@ -742,7 +642,7 @@ fn audio_frame(pts: i64, value: f32) -> ffmpeg::Frame { unsafe { let raw = frame.as_mut_ptr(); (*raw).format = ffmpeg::AVSampleFormat::AV_SAMPLE_FMT_FLT as core::ffi::c_int; - (*raw).nb_samples = CHUNK_FRAMES as i32; + (*raw).nb_samples = CHUNK_FRAMES; (*raw).sample_rate = RATE; ffmpeg::sys::av_channel_layout_default(&raw mut (*raw).ch_layout, CHANNELS); assert_eq!(ffmpeg::sys::av_frame_get_buffer(raw, 0), 0); @@ -750,7 +650,7 @@ fn audio_frame(pts: i64, value: f32) -> ffmpeg::Frame { (*raw).duration = CHUNK_FRAMES as i64; let dst = (*raw).data[0]; - for i in 0..CHUNK_FRAMES * CHANNELS as usize { + for i in 0..(CHUNK_FRAMES * CHANNELS) as usize { let bytes = value.to_le_bytes(); std::ptr::copy_nonoverlapping(bytes.as_ptr(), dst.add(i * 4), 4); } diff --git a/crates/irl-source/tests/common/mod.rs b/crates/irl-source/tests/common/mod.rs new file mode 100644 index 0000000..1f6289d --- /dev/null +++ b/crates/irl-source/tests/common/mod.rs @@ -0,0 +1,138 @@ +//! Fixtures shared by the integration tests: a configuration with the +//! shipped defaults, a `Shared` that needs no libobs, and a sink that records +//! what the audio pump submits. +//! +//! Each test binary compiles this module on its own and uses a different +//! subset of it, hence the `dead_code` allowance. Nothing here calls into +//! libobs, which keeps the binaries `scripts/test-with-libobs-shim.sh` links +//! against its stand-in down to the four functions the shim provides. + +#![allow(dead_code)] + +use std::ffi::CString; +use std::ptr::NonNull; +use std::sync::Arc; + +use parking_lot::Mutex; + +use irl_core::{HwDecode, Watermarks, consts}; +use obs_irl_source::audio::AudioSink; +use obs_irl_source::config::Config; +use obs_irl_source::shared::{HotValues, LifetimeStats, Shared, StreamConfig, TimedPacket}; + +pub const RATE: i32 = 48_000; +pub const CHANNELS: i32 = 2; +/// One Opus frame at 48 kHz: 20 ms. +pub const CHUNK_FRAMES: i32 = 960; +pub const CHUNK_NS: u64 = 20_000_000; + +pub fn stream_config(low_latency_audio: bool) -> StreamConfig { + StreamConfig { + url: CString::new("srt://example.invalid:9000").unwrap(), + ffmpeg_options: None, + hw_decode: HwDecode::Auto, + low_latency_audio, + } +} + +/// The hot settings a freshly added source starts with, at `target_ms`. +pub fn hot_values(target_ms: i32) -> HotValues { + HotValues { + reconnect_delay_s: consts::DEFAULT_RECONNECT_DELAY_S as i32, + adaptive_speed: consts::DEFAULT_ADAPTIVE_SPEED, + catchup_percent: consts::DEFAULT_CATCHUP_PERCENT as i32, + wait_for_keyframe: consts::DEFAULT_WAIT_FOR_KEYFRAME, + clear_on_disconnect: consts::DEFAULT_CLEAR_ON_DISCONNECT, + watermarks: Watermarks::derive(target_ms), + } +} + +/// A freshly added source's whole configuration, streaming from `url`. +pub fn default_config(url: &str) -> Config { + Config { + stream: StreamConfig { + url: CString::new(url).unwrap(), + ..stream_config(false) + }, + hot: hot_values(consts::DEFAULT_BUFFER_TARGET_MS as i32), + close_when_inactive: consts::DEFAULT_CLOSE_WHEN_INACTIVE, + } +} + +/// Runtime state for one connection, around a source handle that dangles. +/// Every path under test sends its output to a recording sink instead, so +/// nothing dereferences it. +pub fn shared(stream: StreamConfig, hot: HotValues) -> Arc { + // SAFETY: never dereferenced, see above. + let source = unsafe { obs::SourceHandle::from_raw(NonNull::dangling()) }; + Shared::new(source, stream, hot, Arc::new(LifetimeStats::default())) +} + +/// Queue a packet with no payload, stamped `pts_ns` and accounted as `bytes`. +pub fn push_packet(shared: &Shared, pts_ns: i64, bytes: usize) { + shared.video.push_packet( + TimedPacket { + packet: ffmpeg::Packet::new().unwrap(), + pts_ns, + bytes, + received_ns: 0, + }, + &shared.lifetime, + ); +} + +/// One audio submission, flattened. +pub struct Emitted { + pub timestamp: u64, + pub frames: u32, + pub rate: u32, + /// Interleaved PCM, or empty unless the recorder keeps samples. + pub samples: Vec, +} + +/// Audio sink that keeps every submission instead of handing it to libobs. +#[derive(Clone, Default)] +pub struct Recorder { + pub emitted: Arc>>, + /// Off by default: the network simulations submit minutes of audio, and + /// copying all of it would cost hundreds of megabytes per run for tests + /// that only read the clock line. + keep_samples: bool, +} + +impl Recorder { + /// A recorder that also copies each submission's PCM. + pub fn with_samples() -> Self { + Self { + keep_samples: true, + ..Self::default() + } + } + + pub fn len(&self) -> usize { + self.emitted.lock().len() + } +} + +impl AudioSink for Recorder { + fn output_audio(&self, audio: &obs::AudioFrame<'_>) { + let sys = audio.as_sys(); + let samples = if self.keep_samples { + let bytes = sys.frames as usize * CHANNELS as usize * 4; + // SAFETY: the frame borrows a live interleaved-float buffer of + // `frames * CHANNELS` samples, which is what the pump built. + let raw = unsafe { std::slice::from_raw_parts(sys.data[0], bytes) }; + raw.chunks_exact(4) + .map(|c| f32::from_le_bytes([c[0], c[1], c[2], c[3]])) + .collect() + } else { + Vec::new() + }; + self.emitted.lock().push(Emitted { + timestamp: sys.timestamp, + frames: sys.frames, + rate: sys.samples_per_sec, + samples, + }); + } +} diff --git a/crates/irl-source/tests/network_sim.rs b/crates/irl-source/tests/network_sim.rs index 191387d..413844c 100644 --- a/crates/irl-source/tests/network_sim.rs +++ b/crates/irl-source/tests/network_sim.rs @@ -21,74 +21,20 @@ #![allow(dead_code)] -use std::ffi::CString; -use std::ptr::NonNull; +mod common; + use std::sync::Arc; use std::sync::atomic::{AtomicU64, Ordering::Relaxed}; -use parking_lot::Mutex; - -use irl_core::{HwDecode, Watermarks, consts}; +use irl_core::consts; +use obs_irl_source::audio::AudioPump; use obs_irl_source::audio::hold; -use obs_irl_source::audio::{AudioPump, AudioSink}; use obs_irl_source::config::Config; use obs_irl_source::receiver::ReceiverFlags; use obs_irl_source::receiver::audio_in::AudioIntake; -use obs_irl_source::shared::{HotValues, LifetimeStats, Shared, StreamConfig, TimedPacket}; - -const RATE: i32 = 48_000; -const CHANNELS: i32 = 2; -/// One Opus frame: 20 ms at 48 kHz. -const CHUNK_FRAMES: i32 = 960; -const CHUNK_NS: u64 = 20_000_000; - -// ── Recording sink ──────────────────────────────────────────── - -#[derive(Clone, Copy)] -struct Emitted { - timestamp: u64, - frames: u32, - rate: u32, - /// The constant this chunk's samples carry, so silence is distinguishable - /// from real audio. - value: f32, -} +use obs_irl_source::shared::Shared; -#[derive(Clone)] -struct Recorder { - channels: usize, - emitted: Arc>>, -} - -impl Recorder { - fn new() -> Self { - Self { - channels: CHANNELS as usize, - emitted: Arc::new(Mutex::new(Vec::new())), - } - } -} - -impl AudioSink for Recorder { - fn output_audio(&self, audio: &obs::AudioFrame<'_>) { - let sys = audio.as_sys(); - let bytes = sys.frames as usize * self.channels * 4; - // SAFETY: the frame borrows a live interleaved-float buffer of - // `frames * channels` samples, which is what the pump built. - let raw = unsafe { std::slice::from_raw_parts(sys.data[0], bytes) }; - let value = if raw.len() >= 4 { - f32::from_le_bytes([raw[0], raw[1], raw[2], raw[3]]) - } else { - 0.0 - }; - self.emitted.lock().push(Emitted { - timestamp: sys.timestamp, - frames: sys.frames, - rate: sys.samples_per_sec, - value, - }); - } -} +use common::{CHANNELS, CHUNK_FRAMES, CHUNK_NS, RATE, Recorder}; // ── The simulated plugin ────────────────────────────────────── @@ -146,32 +92,13 @@ impl Sim { /// A sim with Low Latency Audio set as given. fn with_mode(target_ms: i32, low_latency_audio: bool) -> Self { - // SAFETY: no code under test dereferences the handle — audio leaves - // through the recording sink and nothing here calls into libobs. - let source = unsafe { obs::SourceHandle::from_raw(NonNull::dangling()) }; - let wm = Watermarks::derive(target_ms); - let cfg = StreamConfig { - url: CString::new("srt://sim.invalid:9000").unwrap(), - ffmpeg_options: None, - hw_decode: HwDecode::Off, - low_latency_audio, - }; - let shared = Shared::new( - source, - cfg.clone(), - HotValues { - reconnect_delay_s: 2, - adaptive_speed: true, - catchup_percent: consts::DEFAULT_CATCHUP_PERCENT as i32, - wait_for_keyframe: true, - clear_on_disconnect: true, - watermarks: wm, - }, - Arc::new(LifetimeStats::default()), + let shared = common::shared( + common::stream_config(low_latency_audio), + common::hot_values(target_ms), ); let clock = Arc::new(AtomicU64::new(1_000_000_000)); - let audio_out = Recorder::new(); + let audio_out = Recorder::default(); let pump = { let ns = Arc::clone(&clock); let us = Arc::clone(&clock); @@ -292,7 +219,7 @@ impl Sim { (*raw).duration = CHUNK_NS as i64; let dst = (*raw).data[0]; - for i in 0..(CHUNK_FRAMES as usize * CHANNELS as usize) { + for i in 0..(CHUNK_FRAMES * CHANNELS) as usize { let bytes = value.to_le_bytes(); std::ptr::copy_nonoverlapping(bytes.as_ptr(), dst.add(i * 4), 4); } @@ -397,7 +324,7 @@ impl Sim { let mut jumps = 0; for pair in emitted.windows(2) { - let (a, b) = (pair[0], pair[1]); + let (a, b) = (&pair[0], &pair[1]); assert_eq!(a.rate, RATE as u32, "the submitted rate must never change"); let expected = a.timestamp + a.frames as u64 * 1_000_000_000 / a.rate as u64; if (b.timestamp as i64 - expected as i64).abs() > 1 { @@ -432,6 +359,22 @@ impl Sim { ); } + /// The two invariants every scenario owes, whatever the link did. + fn assert_healthy(&self) { + self.assert_clock_only_jumps_where_declared(); + self.assert_no_audible_audio_dropped(); + } + + /// Video stamped the sender's skew behind the newest audio is in hand + /// before it is due, by about the hold's margin: picture and sound agree. + fn assert_in_lip_sync(&self) { + let margin = self.video_margin_now_ms(); + assert!( + (40..=200).contains(&margin), + "video arriving now is {margin}ms early against its audio" + ); + } + fn underruns(&self) -> u64 { self.shared.conn.audio_underruns.load(Relaxed) } @@ -470,8 +413,7 @@ fn a_batching_upstream_does_not_modulate_playback_speed() { "a {}ms batch at a {target_ms}ms target modulated playback {swing:.2}% (speed {lo:.4}..{hi:.4})", batch_ticks * 20, ); - sim.assert_clock_only_jumps_where_declared(); - sim.assert_no_audible_audio_dropped(); + sim.assert_healthy(); } } @@ -490,8 +432,7 @@ fn a_batch_longer_than_the_buffer_underruns_but_stays_honest() { sim.underruns() > 0, "a 1s batch against a 120ms buffer has to underrun" ); - sim.assert_clock_only_jumps_where_declared(); - sim.assert_no_audible_audio_dropped(); + sim.assert_healthy(); } #[test] @@ -499,8 +440,7 @@ fn a_clean_link_holds_the_target_and_never_breaks_the_clock() { let mut sim = Sim::new(120); sim.run(60.0, Link::Up); - sim.assert_clock_only_jumps_where_declared(); - sim.assert_no_audible_audio_dropped(); + sim.assert_healthy(); assert_eq!(sim.underruns(), 0, "a clean link must not underrun"); // The centred read alignment puts the two reachable levels half a chunk @@ -537,8 +477,7 @@ fn a_three_second_stall_conceals_then_recovers_without_skipping() { // Everything the sender buffered lands at once, then the link is fine. sim.run(60.0, Link::Up); - sim.assert_clock_only_jumps_where_declared(); - sim.assert_no_audible_audio_dropped(); + sim.assert_healthy(); let fill = sim.fill_ms(); assert!( fill < 120 + 60, @@ -557,8 +496,7 @@ fn repeated_short_dropouts_do_not_ratchet_latency() { sim.run(4.0, Link::Up); } - sim.assert_clock_only_jumps_where_declared(); - sim.assert_no_audible_audio_dropped(); + sim.assert_healthy(); let fill = sim.fill_ms(); assert!( fill < 200 + 100, @@ -576,8 +514,7 @@ fn a_sender_whose_clock_is_not_wall_clock_still_holds_the_target() { sim.sender_rate = rate; sim.run(240.0, Link::Up); - sim.assert_clock_only_jumps_where_declared(); - sim.assert_no_audible_audio_dropped(); + sim.assert_healthy(); // Without the integral trim a proportional loop parks tens of // milliseconds off target here, permanently, and the latency parks // with it. @@ -598,8 +535,7 @@ fn an_unwinnably_fast_sender_is_bounded_rather_than_unbounded() { sim.sender_rate = 1.20; sim.run(120.0, Link::Up); - sim.assert_clock_only_jumps_where_declared(); - sim.assert_no_audible_audio_dropped(); + sim.assert_healthy(); let fill = sim.fill_ms(); assert!( fill <= consts::BLEED_PACE_FILL_MS * 4, @@ -619,15 +555,7 @@ fn video_keeps_flowing_while_the_receiver_is_blocked() { // Queue a second of packets, as a healthy link would have. for i in 0..30 { - sim.shared.video.push_packet( - TimedPacket { - packet: ffmpeg::Packet::new().unwrap(), - pts_ns: i * 33_333_333, - bytes: 4096, - received_ns: 0, - }, - &sim.shared.lifetime, - ); + common::push_packet(&sim.shared, i * 33_333_333, 4096); } assert_eq!(sim.shared.video.len(), 30); @@ -654,15 +582,7 @@ fn decoded_memory_does_not_grow_with_the_target() { // 8s of 1080p60 packets: what an 8s target actually holds. for i in 0..480 { - deep.shared.video.push_packet( - TimedPacket { - packet: ffmpeg::Packet::new().unwrap(), - pts_ns: i * 16_666_667, - bytes: 16 * 1024, - received_ns: 0, - }, - &deep.shared.lifetime, - ); + common::push_packet(&deep.shared, i * 16_666_667, 16 * 1024); } // Compressed, that is single-digit megabytes. Decoded it would be ~1.5GB, @@ -708,8 +628,7 @@ fn audio_that_arrives_ahead_of_its_video_starts_late_enough_to_keep_lip_sync() { (550..1_000).contains(&started_ms), "audio started {started_ms}ms in against a 250ms hold" ); - sim.assert_clock_only_jumps_where_declared(); - sim.assert_no_audible_audio_dropped(); + sim.assert_healthy(); assert_eq!(sim.underruns(), 0); let mean = sim.mean_fill_ms(20.0); assert!( @@ -717,13 +636,7 @@ fn audio_that_arrives_ahead_of_its_video_starts_late_enough_to_keep_lip_sync() { "held {mean:.1}ms on average against a 370ms effective target" ); - // Video stamped the sender's skew behind the newest audio is in hand - // before it is due, by about the margin. - let margin = sim.video_margin_now_ms(); - assert!( - (40..=200).contains(&margin), - "video arriving now is {margin}ms early against its audio" - ); + sim.assert_in_lip_sync(); } /// The stabiliser stream from #33: video 1.65 s behind its audio. @@ -733,14 +646,9 @@ fn video_seconds_behind_its_audio_is_held_for_too() { sim.run(40.0, Link::Up); assert_eq!(sim.hold_ms(), 1650 + 100 - 120 - 80); - sim.assert_clock_only_jumps_where_declared(); - sim.assert_no_audible_audio_dropped(); + sim.assert_healthy(); assert_eq!(sim.underruns(), 0); - let margin = sim.video_margin_now_ms(); - assert!( - (40..=200).contains(&margin), - "video arriving now is {margin}ms early against its audio" - ); + sim.assert_in_lip_sync(); } /// A sender whose video is within what Target Buffer already covers needs no @@ -772,8 +680,7 @@ fn audio_starts_without_a_skew_reading_once_the_wait_runs_out() { (wait_ms..wait_ms + 700).contains(&started_ms), "audio started {started_ms}ms in against a {wait_ms}ms wait" ); - sim.assert_clock_only_jumps_where_declared(); - sim.assert_no_audible_audio_dropped(); + sim.assert_healthy(); } /// The sender starts sending its video 700 ms behind its audio halfway @@ -799,19 +706,14 @@ fn video_that_falls_behind_mid_stream_is_caught_up_by_slowing_audio() { // Built at -2 %, 20 ms a second, easing off as the buffer nears its new // target: about a minute for 600 ms. sim.run(70.0, Link::Up); - let margin = sim.video_margin_now_ms(); - assert!( - (40..=200).contains(&margin), - "video arriving now is {margin}ms early against its audio" - ); + sim.assert_in_lip_sync(); let built_ms = sim.shared.audio_state().hold_built_ns / 1_000_000; assert!( (590..=600).contains(&built_ms), "credited {built_ms}ms of a 600ms raise to the video thread" ); assert_eq!(sim.reanchors(), 0, "the hold building was read as drift"); - sim.assert_clock_only_jumps_where_declared(); - sim.assert_no_audible_audio_dropped(); + sim.assert_healthy(); assert_eq!(sim.underruns(), 0); } @@ -834,8 +736,7 @@ fn recurring_bursts_of_late_video_do_not_ratchet_the_hold() { assert_eq!(sim.hold_ms(), 250); assert!(sim.video_margin_now_ms() > 0); - sim.assert_clock_only_jumps_where_declared(); - sim.assert_no_audible_audio_dropped(); + sim.assert_healthy(); assert_eq!(sim.underruns(), 0); } @@ -862,8 +763,7 @@ fn the_hold_is_released_when_the_sender_recovers() { "held {mean:.1}ms on average after the release" ); assert!(sim.video_margin_now_ms() > 0); - sim.assert_clock_only_jumps_where_declared(); - sim.assert_no_audible_audio_dropped(); + sim.assert_healthy(); } /// Low-latency mode keeps no cushion and has no speed control to build one @@ -877,11 +777,7 @@ fn low_latency_audio_holds_at_connection_start_only() { let hold = sim.hold_ms(); assert_eq!(hold, 350 + 100 - 20); assert_eq!(sim.shared.hot.watermarks().target_ms, 120, "nothing folded"); - let margin = sim.video_margin_now_ms(); - assert!( - (40..=200).contains(&margin), - "video arriving now is {margin}ms early against its audio" - ); + sim.assert_in_lip_sync(); // A later skew is the video delay's, as before. sim.video_trails_by(900); @@ -902,14 +798,7 @@ fn a_target_buffer_edit_composes_with_the_hold() { let stream = sim.shared.cfg.clone(); let edit = |target_ms| Config { stream: stream.clone(), - hot: obs_irl_source::shared::HotValues { - reconnect_delay_s: 2, - adaptive_speed: true, - catchup_percent: consts::DEFAULT_CATCHUP_PERCENT as i32, - wait_for_keyframe: true, - clear_on_disconnect: true, - watermarks: Watermarks::derive(target_ms), - }, + hot: common::hot_values(target_ms), close_when_inactive: false, }; @@ -928,8 +817,7 @@ fn a_target_buffer_edit_composes_with_the_hold() { sim.shared.hot.watermarks().target_ms, consts::BUFFER_TARGET_MAX_MS ); - sim.assert_clock_only_jumps_where_declared(); - sim.assert_no_audible_audio_dropped(); + sim.assert_healthy(); } /// A new connection may be a different sender, or the same one with its diff --git a/crates/irl-source/tests/provider_seam.rs b/crates/irl-source/tests/provider_seam.rs index f541f8d..faa2f7b 100644 --- a/crates/irl-source/tests/provider_seam.rs +++ b/crates/irl-source/tests/provider_seam.rs @@ -11,44 +11,21 @@ //! realistically be lost is someone teaching `Config::load` to read a //! provider key. -use std::ffi::CString; +mod common; + use std::fs; use std::path::Path; -use irl_core::{HwDecode, Watermarks, consts}; -use obs_irl_source::config::Config; -use obs_irl_source::shared::{HotValues, StreamConfig}; - -fn url_only(url: &str) -> Config { - Config { - stream: StreamConfig { - url: CString::new(url).unwrap(), - ffmpeg_options: None, - hw_decode: HwDecode::Auto, - low_latency_audio: false, - }, - hot: HotValues { - reconnect_delay_s: consts::DEFAULT_RECONNECT_DELAY_S as i32, - adaptive_speed: consts::DEFAULT_ADAPTIVE_SPEED, - catchup_percent: consts::DEFAULT_CATCHUP_PERCENT as i32, - wait_for_keyframe: consts::DEFAULT_WAIT_FOR_KEYFRAME, - clear_on_disconnect: consts::DEFAULT_CLEAR_ON_DISCONNECT, - watermarks: Watermarks::derive(consts::DEFAULT_BUFFER_TARGET_MS as i32), - }, - close_when_inactive: consts::DEFAULT_CLOSE_WHEN_INACTIVE, - } -} - #[test] fn a_url_alone_is_a_runnable_config() { // Nothing else is consulted: no token, no cached list, no provider. This // is the scene collection that was saved months ago and still works. - let config = url_only("srt://relay.example:4000?streamid=play/stream/abc"); + let config = common::default_config("srt://relay.example:4000?streamid=play/stream/abc"); assert!(config.url().is_some()); - assert!(!config.requires_restart(&url_only( + assert!(!config.requires_restart(&common::default_config( "srt://relay.example:4000?streamid=play/stream/abc" ))); - assert!(config.requires_restart(&url_only( + assert!(config.requires_restart(&common::default_config( "srt://relay.example:4000?streamid=play/stream/def" ))); } diff --git a/crates/irl-source/tests/shell_config.rs b/crates/irl-source/tests/shell_config.rs index a324f05..be9a51a 100644 --- a/crates/irl-source/tests/shell_config.rs +++ b/crates/irl-source/tests/shell_config.rs @@ -3,30 +3,15 @@ //! No libobs runs under `cargo test`, so nothing here calls into it: the //! configuration is built directly rather than loaded from an `obs_data_t`. +mod common; + use std::ffi::CString; use irl_core::{HwDecode, Watermarks, consts}; use obs_irl_source::config::Config; -use obs_irl_source::shared::{HotValues, StreamConfig}; fn config() -> Config { - Config { - stream: StreamConfig { - url: CString::new("srt://example.invalid:9000").unwrap(), - ffmpeg_options: None, - hw_decode: HwDecode::Auto, - low_latency_audio: false, - }, - hot: HotValues { - reconnect_delay_s: consts::DEFAULT_RECONNECT_DELAY_S as i32, - adaptive_speed: consts::DEFAULT_ADAPTIVE_SPEED, - catchup_percent: consts::DEFAULT_CATCHUP_PERCENT as i32, - wait_for_keyframe: consts::DEFAULT_WAIT_FOR_KEYFRAME, - clear_on_disconnect: consts::DEFAULT_CLEAR_ON_DISCONNECT, - watermarks: Watermarks::derive(consts::DEFAULT_BUFFER_TARGET_MS as i32), - }, - close_when_inactive: consts::DEFAULT_CLOSE_WHEN_INACTIVE, - } + common::default_config("srt://example.invalid:9000") } #[test] diff --git a/crates/irl-source/tests/video_pipeline.rs b/crates/irl-source/tests/video_pipeline.rs index 7601b03..84f2fd7 100644 --- a/crates/irl-source/tests/video_pipeline.rs +++ b/crates/irl-source/tests/video_pipeline.rs @@ -11,10 +11,10 @@ #![allow(dead_code)] +mod common; + use obs_irl_source::{shared, video}; -use std::ffi::CString; -use std::ptr::NonNull; use std::sync::Arc; use std::sync::atomic::{AtomicUsize, Ordering::Relaxed}; @@ -23,7 +23,7 @@ use ffmpeg::sys::{AVColorRange, AVColorSpace, AVColorTransferCharacteristic}; use obs::{ColorRange, ColorSpace, VideoFormat}; use parking_lot::Mutex; -use shared::{HotValues, LifetimeStats, Shared, StreamConfig}; +use shared::Shared; use video::VideoSink; use video::output; use video::thread::VideoThread; @@ -91,34 +91,10 @@ impl VideoSink for RecorderSink { } } -fn stream_config(low_latency_audio: bool) -> StreamConfig { - StreamConfig { - url: CString::new("srt://127.0.0.1:9999").unwrap(), - ffmpeg_options: None, - hw_decode: irl_core::HwDecode::Auto, - low_latency_audio, - } -} - -fn hot_values() -> HotValues { - HotValues { - reconnect_delay_s: irl_core::consts::DEFAULT_RECONNECT_DELAY_S as i32, - adaptive_speed: true, - catchup_percent: irl_core::consts::DEFAULT_CATCHUP_PERCENT as i32, - wait_for_keyframe: true, - clear_on_disconnect: true, - watermarks: irl_core::Watermarks::derive(irl_core::consts::DEFAULT_BUFFER_TARGET_MS as i32), - } -} - fn shared() -> Arc { - // Never dereferenced: every output in these tests goes to the recorder. - let source = unsafe { obs::SourceHandle::from_raw(NonNull::dangling()) }; - Shared::new( - source, - stream_config(false), - hot_values(), - Arc::new(LifetimeStats::default()), + common::shared( + common::stream_config(false), + common::hot_values(irl_core::consts::DEFAULT_BUFFER_TARGET_MS as i32), ) } @@ -136,6 +112,14 @@ fn sw_frame(fmt: Pix, width: i32, height: i32) -> ffmpeg::Frame { ffmpeg::Frame::alloc_video(fmt, width, height).expect("alloc") } +/// Hand the pacing loop a small I420 frame stamped `pts`, decoded from a +/// packet that reached the video thread at `received_ns`. +fn feed(thread: &mut VideoThread, pts: i64, received_ns: u64) { + let mut frame = sw_frame(Pix::AV_PIX_FMT_YUV420P, 64, 32); + frame.set_pts(pts); + thread.pace_decoded(frame, received_ns); +} + /// `AV_FRAME_FLAG_KEY`, which the safe API deliberately has no setter for /// (only decoders set it). fn mark_keyframe(frame: &mut ffmpeg::Frame) { @@ -189,57 +173,35 @@ fn pixel_formats_map_to_the_obs_table() { fn colour_spaces_map_the_way_the_c_did() { let sdr = AVColorTransferCharacteristic::AVCOL_TRC_BT709; let hlg = AVColorTransferCharacteristic::AVCOL_TRC_ARIB_STD_B67; + let pq = AVColorTransferCharacteristic::AVCOL_TRC_SMPTE2084; - assert_eq!( - output::convert_color_space(AVColorSpace::AVCOL_SPC_BT709, sdr), - ColorSpace::Bt709 - ); - assert_eq!( - output::convert_color_space(AVColorSpace::AVCOL_SPC_SMPTE170M, sdr), - ColorSpace::Bt601 - ); - assert_eq!( - output::convert_color_space(AVColorSpace::AVCOL_SPC_BT470BG, sdr), - ColorSpace::Bt601 - ); - // BT.2020 splits on the transfer function, not the primaries. - assert_eq!( - output::convert_color_space(AVColorSpace::AVCOL_SPC_BT2020_NCL, hlg), - ColorSpace::Hlg2100 - ); - assert_eq!( - output::convert_color_space(AVColorSpace::AVCOL_SPC_BT2020_CL, hlg), - ColorSpace::Hlg2100 - ); - assert_eq!( - output::convert_color_space( - AVColorSpace::AVCOL_SPC_BT2020_NCL, - AVColorTransferCharacteristic::AVCOL_TRC_SMPTE2084 - ), - ColorSpace::Pq2100 - ); - // Unspecified and everything unknown fall back to BT.709. - assert_eq!( - output::convert_color_space(AVColorSpace::AVCOL_SPC_UNSPECIFIED, sdr), - ColorSpace::Bt709 - ); - assert_eq!( - output::convert_color_space(AVColorSpace::AVCOL_SPC_FCC, sdr), - ColorSpace::Bt709 - ); + let table = [ + (AVColorSpace::AVCOL_SPC_BT709, sdr, ColorSpace::Bt709), + (AVColorSpace::AVCOL_SPC_SMPTE170M, sdr, ColorSpace::Bt601), + (AVColorSpace::AVCOL_SPC_BT470BG, sdr, ColorSpace::Bt601), + // BT.2020 splits on the transfer function, not the primaries. + (AVColorSpace::AVCOL_SPC_BT2020_NCL, hlg, ColorSpace::Hlg2100), + (AVColorSpace::AVCOL_SPC_BT2020_CL, hlg, ColorSpace::Hlg2100), + (AVColorSpace::AVCOL_SPC_BT2020_NCL, pq, ColorSpace::Pq2100), + // Unspecified and everything unknown fall back to BT.709. + (AVColorSpace::AVCOL_SPC_UNSPECIFIED, sdr, ColorSpace::Bt709), + (AVColorSpace::AVCOL_SPC_FCC, sdr, ColorSpace::Bt709), + ]; + for (space, trc, obs) in table { + assert_eq!( + output::convert_color_space(space, trc), + obs, + "{space:?} / {trc:?}" + ); + } - assert_eq!( - output::convert_color_range(AVColorRange::AVCOL_RANGE_JPEG), - ColorRange::Full - ); - assert_eq!( - output::convert_color_range(AVColorRange::AVCOL_RANGE_MPEG), - ColorRange::Partial - ); - assert_eq!( - output::convert_color_range(AVColorRange::AVCOL_RANGE_UNSPECIFIED), - ColorRange::Partial - ); + for (range, obs) in [ + (AVColorRange::AVCOL_RANGE_JPEG, ColorRange::Full), + (AVColorRange::AVCOL_RANGE_MPEG, ColorRange::Partial), + (AVColorRange::AVCOL_RANGE_UNSPECIFIED, ColorRange::Partial), + ] { + assert_eq!(output::convert_color_range(range), obs, "{range:?}"); + } } /* ── to_sysmem ────────────────────────────────────────────── */ @@ -391,15 +353,7 @@ fn the_packet_queue_is_bounded_by_duration_and_bytes() { // Well past the duration ceiling: the oldest packets go. for i in 0..40 { - shared.video.push_packet( - shared::TimedPacket { - packet: ffmpeg::Packet::new().unwrap(), - pts_ns: ms(i * 500), - bytes: 1024, - received_ns: 0, - }, - &shared.lifetime, - ); + common::push_packet(&shared, ms(i * 500), 1024); } assert!( shared.video.span_ns() <= ms(irl_core::consts::VIDEO_PACKET_QUEUE_MAX_MS), @@ -415,15 +369,7 @@ fn the_packet_queue_is_bounded_by_duration_and_bytes() { fn the_packet_queue_is_bounded_by_bytes_whatever_the_timestamps_say() { let big = shared(); for _ in 0..40 { - big.video.push_packet( - shared::TimedPacket { - packet: ffmpeg::Packet::new().unwrap(), - pts_ns: 0, - bytes: 4 * 1024 * 1024, - received_ns: 0, - }, - &big.lifetime, - ); + common::push_packet(&big, 0, 4 * 1024 * 1024); } assert!( big.video.bytes() <= irl_core::consts::VIDEO_PACKET_QUEUE_MAX_BYTES, @@ -437,15 +383,7 @@ fn the_packet_queue_is_bounded_by_bytes_whatever_the_timestamps_say() { #[test] fn the_channel_delivers_packets_after_the_decoder_that_owns_them() { let shared = shared(); - shared.video.push_packet( - shared::TimedPacket { - packet: ffmpeg::Packet::new().unwrap(), - pts_ns: 0, - bytes: 1, - received_ns: 0, - }, - &shared.lifetime, - ); + common::push_packet(&shared, 0, 1); assert_eq!(shared.video.len(), 1); assert!(matches!( @@ -526,9 +464,7 @@ fn a_frame_is_handed_over_a_lead_before_it_is_due() { // schedules it at its arrival, so it is delayed by a tick first (see // `video_without_audio_is_delayed_by_a_tick_and_anchors_on_the_fallback`). let t0 = obs::time::gettime_ns(); - let mut first = sw_frame(Pix::AV_PIX_FMT_YUV420P, 64, 32); - first.set_pts(t0 as i64); - thread.pace_decoded(first, t0); + feed(&mut thread, t0 as i64, t0); let now = t0 + TICK; thread.run_once(now); assert_eq!(recorder.emitted().len(), 1, "anchor frame emitted"); @@ -537,9 +473,7 @@ fn a_frame_is_handed_over_a_lead_before_it_is_due() { // it should go now even though its due time has not arrived. (Its due // time is its PTS on the fallback epoch plus the delay: `t0 + 25 ms + // 1 tick`, 25 ms from `now`.) - let mut soon = sw_frame(Pix::AV_PIX_FMT_YUV420P, 64, 32); - soon.set_pts(t0 as i64 + 25_000_000); - thread.pace_decoded(soon, t0); + feed(&mut thread, t0 as i64 + 25_000_000, t0); thread.run_once(now); assert_eq!( recorder.emitted().len(), @@ -548,9 +482,7 @@ fn a_frame_is_handed_over_a_lead_before_it_is_due() { ); // One due far out still waits. - let mut later = sw_frame(Pix::AV_PIX_FMT_YUV420P, 64, 32); - later.set_pts(t0 as i64 + 500_000_000); - thread.pace_decoded(later, now); + feed(&mut thread, t0 as i64 + 500_000_000, now); let wait = thread.run_once(now); assert_eq!(recorder.emitted().len(), 2, "not due, not delivered"); assert!(!wait.is_zero(), "should sleep toward the lead"); @@ -571,15 +503,7 @@ fn a_full_pacing_queue_does_not_keep_the_video_thread_awake() { // A backlog of packets, as any target above the decode lead produces. for i in 0..200 { - shared.video.push_packet( - shared::TimedPacket { - packet: ffmpeg::Packet::new().unwrap(), - pts_ns: i * 33_333_333, - bytes: 2048, - received_ns: 0, - }, - &shared.lifetime, - ); + common::push_packet(&shared, i * 33_333_333, 2048); } assert!(!shared.video.is_empty()); @@ -587,9 +511,7 @@ fn a_full_pacing_queue_does_not_keep_the_video_thread_awake() { let now = obs::time::gettime_ns(); let mut pts = 0i64; while thread.pacing().has_room() { - let mut frame = sw_frame(Pix::AV_PIX_FMT_YUV420P, 64, 32); - frame.set_pts(now as i64 + 60_000_000_000 + pts); - thread.pace_decoded(frame, now); + feed(&mut thread, now as i64 + 60_000_000_000 + pts, now); pts += 33_333_333; } assert!(!thread.pacing().has_room(), "queue should be at its lead"); @@ -615,27 +537,11 @@ fn a_full_pacing_queue_does_not_keep_the_video_thread_awake() { #[test] fn a_decoder_handover_is_taken_even_with_no_pacing_room() { let shared = shared(); - shared.video.push_packet( - shared::TimedPacket { - packet: ffmpeg::Packet::new().unwrap(), - pts_ns: 0, - bytes: 1, - received_ns: 0, - }, - &shared.lifetime, - ); + common::push_packet(&shared, 0, 1); assert!(!shared.video.next_is_decoder(), "a packet is at the front"); shared.video.request_clear(); - shared.video.push_packet( - shared::TimedPacket { - packet: ffmpeg::Packet::new().unwrap(), - pts_ns: 0, - bytes: 1, - received_ns: 0, - }, - &shared.lifetime, - ); + common::push_packet(&shared, 0, 1); assert!(!shared.video.next_is_decoder()); } @@ -683,9 +589,7 @@ fn a_future_frame_waits_instead_of_being_emitted() { thread.run_once(now); assert_eq!(recorder.emitted().len(), 1, "the anchor frame goes out"); - let mut later = sw_frame(Pix::AV_PIX_FMT_YUV420P, 64, 32); - later.set_pts(200_000_000); - thread.pace_decoded(later, now); + feed(&mut thread, 200_000_000, now); let wait = thread.run_once(now); assert_eq!(recorder.emitted().len(), 1, "not due yet"); @@ -704,16 +608,12 @@ fn a_clear_request_drops_the_queue_and_blanks_the_source() { let (mut thread, recorder) = thread_with(shared.clone()); let now = obs::time::gettime_ns(); - let mut queued = sw_frame(Pix::AV_PIX_FMT_YUV420P, 64, 32); - queued.set_pts(0); - thread.pace_decoded(queued, now); + feed(&mut thread, 0, now); let now = now + TICK; thread.run_once(now); assert_eq!(recorder.emitted().len(), 1); - let mut pending = sw_frame(Pix::AV_PIX_FMT_YUV420P, 64, 32); - pending.set_pts(10_000_000_000); - thread.pace_decoded(pending, now); + feed(&mut thread, 10_000_000_000, now); thread.run_once(now); assert_eq!(thread.pacing().len(), 1, "parked until its due time"); @@ -748,9 +648,7 @@ fn queued_frames_reschedule_onto_the_audio_playout_offset() { state.latest_buffered_end_pts_ns = 10_000_000_000; } - let mut queued = sw_frame(Pix::AV_PIX_FMT_YUV420P, 64, 32); - queued.set_pts(10_000_000_000); - thread.pace_decoded(queued, now); + feed(&mut thread, 10_000_000_000, now); thread.run_once(now); assert!(recorder.emitted().is_empty(), "due a second from now"); @@ -867,6 +765,16 @@ fn publish_mapping(shared: &Shared, obs_end_ts_ns: u64, buffered_end_pts_ns: i64 state.latest_buffered_end_pts_ns = buffered_end_pts_ns; } +/// Anchor the play head on a frame at stream PTS 10 s that arrives at the +/// returned OBS time and maps 100 ms out: a comfortable margin. +fn anchor_with_margin(shared: &Shared, thread: &mut VideoThread) -> u64 { + let t0 = obs::time::gettime_ns(); + publish_mapping(shared, t0 + 100_000_000, 10_000_000_000); + feed(thread, 10_000_000_000, t0); + thread.run_once(t0 + 100_000_000); + t0 +} + fn shared_with_audio() -> Arc { let shared = shared(); shared.flags.audio_present.store(true, Relaxed); @@ -887,9 +795,7 @@ fn video_waits_for_the_audio_mapping_before_anchoring_the_play_head() { let (mut thread, recorder) = thread_with(shared.clone()); let now = obs::time::gettime_ns(); - let mut first = sw_frame(Pix::AV_PIX_FMT_YUV420P, 64, 32); - first.set_pts(10_000_000_000); - thread.pace_decoded(first, now); + feed(&mut thread, 10_000_000_000, now); // Well past the fallback due time (+120 ms): still held. let wait = thread.run_once(now + 300_000_000); @@ -922,9 +828,11 @@ fn frames_already_past_due_when_the_mapping_arrives_do_not_anchor_the_play_head( // arriving in real time over the 160 ms before the mapping. let now = obs::time::gettime_ns(); for i in 0..4 { - let mut frame = sw_frame(Pix::AV_PIX_FMT_YUV420P, 64, 32); - frame.set_pts(10_000_000_000 + i * 40_000_000); - thread.pace_decoded(frame, now - 160_000_000 + i as u64 * 40_000_000); + feed( + &mut thread, + 10_000_000_000 + i * 40_000_000, + now - 160_000_000 + i as u64 * 40_000_000, + ); } thread.run_once(now); assert!(recorder.emitted().is_empty()); @@ -953,9 +861,7 @@ fn a_frame_late_by_under_a_canvas_tick_still_anchors() { let (mut thread, recorder) = thread_with(shared.clone()); let now = obs::time::gettime_ns(); - let mut frame = sw_frame(Pix::AV_PIX_FMT_YUV420P, 64, 32); - frame.set_pts(10_000_000_000); - thread.pace_decoded(frame, now - 50_000_000); + feed(&mut thread, 10_000_000_000, now - 50_000_000); publish_mapping(&shared, now, 10_000_000_000); thread.run_once(now + 10_000_000); @@ -974,9 +880,7 @@ fn video_stops_waiting_for_audio_that_never_primes() { let (mut thread, recorder) = thread_with(shared.clone()); let now = obs::time::gettime_ns(); - let mut first = sw_frame(Pix::AV_PIX_FMT_YUV420P, 64, 32); - first.set_pts(10_000_000_000); - thread.pace_decoded(first, now); + feed(&mut thread, 10_000_000_000, now); thread.run_once(now); assert!(recorder.emitted().is_empty()); @@ -1001,9 +905,11 @@ fn video_stops_waiting_for_audio_that_never_primes() { // test's 1.35 s of pretend time it would look a second late, which in // production cannot happen (a packet never arrives after a due time the // fallback capped at now + 200 ms). - let mut fresh = sw_frame(Pix::AV_PIX_FMT_YUV420P, 64, 32); - fresh.set_pts(10_000_000_000 + 1_400_000_000); - thread.pace_decoded(fresh, obs::time::gettime_ns()); + feed( + &mut thread, + 10_000_000_000 + 1_400_000_000, + obs::time::gettime_ns(), + ); let due = thread.pacing().next_due().expect("paced on the fallback"); thread.run_once(due); assert_eq!(recorder.only().timestamp, due); @@ -1020,9 +926,7 @@ fn video_without_audio_is_delayed_by_a_tick_and_anchors_on_the_fallback() { let (mut thread, recorder) = thread_with(shared.clone()); let t0 = obs::time::gettime_ns(); - let mut first = sw_frame(Pix::AV_PIX_FMT_YUV420P, 64, 32); - first.set_pts(t0 as i64); - thread.pace_decoded(first, t0); + feed(&mut thread, t0 as i64, t0); thread.run_once(t0); assert!( recorder.emitted().is_empty(), @@ -1053,9 +957,11 @@ const LIVE_FRAMES: u64 = (irl_core::consts::VIDEO_LIVE_LOOKBACK_MS * 1_000_000). /// handed over. They are queued like any others, and go as stale. fn live_history(thread: &mut VideoThread, next_arrival_ns: u64, next_pts_ns: i64) { for k in (1..=LIVE_FRAMES).rev() { - let mut frame = sw_frame(Pix::AV_PIX_FMT_YUV420P, 64, 32); - frame.set_pts(next_pts_ns - (k * FRAME) as i64); - thread.pace_decoded(frame, next_arrival_ns - k * FRAME); + feed( + thread, + next_pts_ns - (k * FRAME) as i64, + next_arrival_ns - k * FRAME, + ); } } @@ -1086,9 +992,7 @@ fn video_that_trails_its_audio_is_delayed_into_pacing_not_dropped() { for i in 0..30u64 { let arrival = t0 + i * FRAME; - let mut frame = sw_frame(Pix::AV_PIX_FMT_YUV420P, 64, 32); - frame.set_pts(10_000_000_000 + (i * FRAME) as i64); - thread.pace_decoded(frame, arrival); + feed(&mut thread, 10_000_000_000 + (i * FRAME) as i64, arrival); thread.run_once(arrival); } @@ -1140,9 +1044,7 @@ fn a_stale_startup_burst_does_not_raise_the_delay() { let last_due = t0 + 40_000_000; publish_mapping(&shared, last_due, 10_000_000_000 + 19 * FRAME as i64); for i in 0..20i64 { - let mut frame = sw_frame(Pix::AV_PIX_FMT_YUV420P, 64, 32); - frame.set_pts(10_000_000_000 + i * FRAME as i64); - thread.pace_decoded(frame, t0); + feed(&mut thread, 10_000_000_000 + i * FRAME as i64, t0); } thread.run_once(t0); @@ -1176,9 +1078,7 @@ fn a_late_stream_is_measured_on_its_newest_frame() { let late = 150_000_000; publish_mapping(&shared, t0 - late, 10_000_000_000); live_history(&mut thread, t0, 10_000_000_000); - let mut newest = sw_frame(Pix::AV_PIX_FMT_YUV420P, 64, 32); - newest.set_pts(10_000_000_000); - thread.pace_decoded(newest, t0); + feed(&mut thread, 10_000_000_000, t0); thread.run_once(t0); // 150 ms plus a tick, in ticks, from the newest frame; the older ones @@ -1202,12 +1102,7 @@ fn recurring_lateness_after_the_anchor_raises_the_delay_once() { let (mut thread, recorder) = thread_with(shared.clone()); // A frame arriving now maps 100 ms out: a comfortable margin to anchor on. - let t0 = obs::time::gettime_ns(); - publish_mapping(&shared, t0 + 100_000_000, 10_000_000_000); - let mut anchor = sw_frame(Pix::AV_PIX_FMT_YUV420P, 64, 32); - anchor.set_pts(10_000_000_000); - thread.pace_decoded(anchor, t0); - thread.run_once(t0 + 100_000_000); + let t0 = anchor_with_margin(&shared, &mut thread); assert_eq!(recorder.emitted().len(), 1, "anchored with a full margin"); assert_eq!(shared.conn.video_delay_ns.load(Relaxed), 0); @@ -1216,9 +1111,11 @@ fn recurring_lateness_after_the_anchor_raises_the_delay_once() { let arrival_of = |i: u64| t0 + 105_000_000 + i * FRAME; let mut delay_seen = Vec::new(); for i in 1..=80u64 { - let mut frame = sw_frame(Pix::AV_PIX_FMT_YUV420P, 64, 32); - frame.set_pts(10_000_000_000 + (i * FRAME) as i64); - thread.pace_decoded(frame, arrival_of(i)); + feed( + &mut thread, + 10_000_000_000 + (i * FRAME) as i64, + arrival_of(i), + ); thread.run_once(arrival_of(i)); delay_seen.push(shared.conn.video_delay_ns.load(Relaxed)); } @@ -1249,20 +1146,17 @@ fn frames_handed_over_short_of_the_lead_but_before_due_do_not_raise_the_delay() let shared = shared_with_audio(); let (mut thread, recorder) = thread_with(shared.clone()); - let t0 = obs::time::gettime_ns(); - publish_mapping(&shared, t0 + 100_000_000, 10_000_000_000); - let mut anchor = sw_frame(Pix::AV_PIX_FMT_YUV420P, 64, 32); - anchor.set_pts(10_000_000_000); - thread.pace_decoded(anchor, t0); - thread.run_once(t0 + 100_000_000); + let t0 = anchor_with_margin(&shared, &mut thread); assert_eq!(recorder.emitted().len(), 1); // Two seconds of frames with 3 ms in hand: a tenth of the lead. let arrival_of = |i: u64| t0 + 97_000_000 + i * FRAME; for i in 1..=60u64 { - let mut frame = sw_frame(Pix::AV_PIX_FMT_YUV420P, 64, 32); - frame.set_pts(10_000_000_000 + (i * FRAME) as i64); - thread.pace_decoded(frame, arrival_of(i)); + feed( + &mut thread, + 10_000_000_000 + (i * FRAME) as i64, + arrival_of(i), + ); thread.run_once(arrival_of(i)); } @@ -1277,27 +1171,26 @@ fn a_single_late_burst_after_the_anchor_does_not_raise_the_delay() { let shared = shared_with_audio(); let (mut thread, recorder) = thread_with(shared.clone()); - let t0 = obs::time::gettime_ns(); - publish_mapping(&shared, t0 + 100_000_000, 10_000_000_000); - let mut anchor = sw_frame(Pix::AV_PIX_FMT_YUV420P, 64, 32); - anchor.set_pts(10_000_000_000); - thread.pace_decoded(anchor, t0); - thread.run_once(t0 + 100_000_000); + let t0 = anchor_with_margin(&shared, &mut thread); assert_eq!(recorder.emitted().len(), 1); let due_of = |i: u64| t0 + 100_000_000 + i * FRAME; // Six frames 20 ms late, inside 200 ms ... for i in 1..=6u64 { - let mut frame = sw_frame(Pix::AV_PIX_FMT_YUV420P, 64, 32); - frame.set_pts(10_000_000_000 + (i * FRAME) as i64); - thread.pace_decoded(frame, due_of(i) + 20_000_000); + feed( + &mut thread, + 10_000_000_000 + (i * FRAME) as i64, + due_of(i) + 20_000_000, + ); thread.run_once(due_of(i) + 20_000_000); } // ... then a second and a half of frames with their full margin. for i in 7..=50u64 { - let mut frame = sw_frame(Pix::AV_PIX_FMT_YUV420P, 64, 32); - frame.set_pts(10_000_000_000 + (i * FRAME) as i64); - thread.pace_decoded(frame, due_of(i) - 100_000_000); + feed( + &mut thread, + 10_000_000_000 + (i * FRAME) as i64, + due_of(i) - 100_000_000, + ); thread.run_once(due_of(i) - 100_000_000); } thread.run_once(due_of(50)); @@ -1321,9 +1214,7 @@ fn a_clear_forgets_the_video_delay() { let t0 = obs::time::gettime_ns(); publish_mapping(&shared, t0 - 150_000_000, 10_000_000_000); live_history(&mut thread, t0, 10_000_000_000); - let mut frame = sw_frame(Pix::AV_PIX_FMT_YUV420P, 64, 32); - frame.set_pts(10_000_000_000); - thread.pace_decoded(frame, t0); + feed(&mut thread, 10_000_000_000, t0); thread.run_once(t0); assert_eq!(shared.conn.video_delay_ns.load(Relaxed), 10 * TICK); @@ -1334,9 +1225,7 @@ fn a_clear_forgets_the_video_delay() { // The next connection's audio primes with room to spare: no delay. publish_mapping(&shared, t1 + 200_000_000, 20_000_000_000); - let mut frame = sw_frame(Pix::AV_PIX_FMT_YUV420P, 64, 32); - frame.set_pts(20_000_000_000); - thread.pace_decoded(frame, t1); + feed(&mut thread, 20_000_000_000, t1); thread.run_once(t1 + 200_000_000); assert_eq!( recorder.emitted().last().map(|f| f.timestamp), @@ -1367,9 +1256,7 @@ fn video_later_than_the_delay_ceiling_anchors_and_plays_unpaced() { for i in 0..60u64 { let arrival = t0 + i * FRAME; - let mut frame = sw_frame(Pix::AV_PIX_FMT_YUV420P, 64, 32); - frame.set_pts(10_000_000_000 + (i * FRAME) as i64); - thread.pace_decoded(frame, arrival); + feed(&mut thread, 10_000_000_000 + (i * FRAME) as i64, arrival); thread.run_once(arrival); assert_eq!( thread.pacing().len(), @@ -1414,9 +1301,7 @@ fn a_delay_set_by_a_bad_first_frame_ramps_back_without_a_jump() { let t0 = obs::time::gettime_ns(); publish_mapping(&shared, t0 - 1_300_000_000, 10_000_000_000); live_history(&mut thread, t0, 10_000_000_000); - let mut first = sw_frame(Pix::AV_PIX_FMT_YUV420P, 64, 32); - first.set_pts(10_000_000_000); - thread.pace_decoded(first, t0); + feed(&mut thread, 10_000_000_000, t0); thread.run_once(t0); let set = shared.conn.video_delay_ns.load(Relaxed); assert_eq!(set, (1_300_000_000 + TICK).div_ceil(TICK) * TICK); @@ -1428,9 +1313,7 @@ fn a_delay_set_by_a_bad_first_frame_ramps_back_without_a_jump() { let mut delays = Vec::new(); for i in 1..=frames { let arrival = t0 + i * FRAME; - let mut frame = sw_frame(Pix::AV_PIX_FMT_YUV420P, 64, 32); - frame.set_pts(pts_of(i)); - thread.pace_decoded(frame, arrival); + feed(&mut thread, pts_of(i), arrival); thread.run_once(arrival); delays.push(shared.conn.video_delay_ns.load(Relaxed)); } @@ -1483,9 +1366,7 @@ fn the_video_delay_hands_itself_back_as_the_audio_hold_builds() { let late = 300_000_000; publish_mapping(&shared, t0 - late, 10_000_000_000); live_history(&mut thread, t0, 10_000_000_000); - let mut first = sw_frame(Pix::AV_PIX_FMT_YUV420P, 64, 32); - first.set_pts(10_000_000_000); - thread.pace_decoded(first, t0); + feed(&mut thread, 10_000_000_000, t0); thread.run_once(t0); let set = shared.conn.video_delay_ns.load(Relaxed); assert_eq!(set, (late + TICK).div_ceil(TICK) * TICK); @@ -1500,9 +1381,7 @@ fn the_video_delay_hands_itself_back_as_the_audio_hold_builds() { publish_mapping(&shared, t0 - late + grown, 10_000_000_000); shared.audio_state().hold_built_ns = grown; let arrival = t0 + i * FRAME; - let mut frame = sw_frame(Pix::AV_PIX_FMT_YUV420P, 64, 32); - frame.set_pts(10_000_000_000 + (i * FRAME) as i64); - thread.pace_decoded(frame, arrival); + feed(&mut thread, 10_000_000_000 + (i * FRAME) as i64, arrival); thread.run_once(arrival); delays.push(shared.conn.video_delay_ns.load(Relaxed)); } @@ -1574,9 +1453,7 @@ fn video_catching_up_to_live_at_connection_start_sets_no_delay() { ); for i in 0..caught_up + 90 { - let mut frame = sw_frame(Pix::AV_PIX_FMT_YUV420P, 64, 32); - frame.set_pts(pts_of(i)); - thread.pace_decoded(frame, arrival_of(i)); + feed(&mut thread, pts_of(i), arrival_of(i)); thread.run_once(arrival_of(i)); assert_eq!( shared.conn.video_delay_ns.load(Relaxed), From b5a8992dc94cdcab80d7ae2eb92ad796525a8c14 Mon Sep 17 00:00:00 2001 From: datagutt Date: Fri, 2 Oct 2026 02:50:38 +0200 Subject: [PATCH 15/28] test(irl-core): table-drive repeated cases and drop redundant tests --- crates/irl-core/src/pacing.rs | 13 +--- crates/irl-core/src/pts_repair.rs | 83 ++++++++------------- crates/irl-core/src/speed.rs | 9 --- crates/irl-core/src/url_opts.rs | 120 ++++++++++++++---------------- 4 files changed, 86 insertions(+), 139 deletions(-) diff --git a/crates/irl-core/src/pacing.rs b/crates/irl-core/src/pacing.rs index 1ea89c4..afd1adc 100644 --- a/crates/irl-core/src/pacing.rs +++ b/crates/irl-core/src/pacing.rs @@ -269,9 +269,9 @@ mod tests { push(&mut q, frame(0), 0); push(&mut q, frame(1), 0); assert_eq!(q.bytes(), 2 * FRAME_BYTES); - q.pop(); + assert_eq!(q.pop().unwrap().pts_ns, 0, "frames come back in push order"); assert_eq!(q.bytes(), FRAME_BYTES); - q.pop(); + assert_eq!(q.pop().unwrap().pts_ns, 1); assert_eq!(q.bytes(), 0); assert!(q.is_empty()); assert_eq!(q.pop(), None); @@ -491,13 +491,4 @@ mod tests { assert!(q.has_room()); assert_eq!(q.drain().len(), 0); } - - #[test] - fn frames_come_back_in_push_order() { - let mut q = queue(); - push(&mut q, frame(10), 1); - push(&mut q, frame(20), 2); - assert_eq!(q.pop().unwrap().pts_ns, 10); - assert_eq!(q.pop().unwrap().pts_ns, 20); - } } diff --git a/crates/irl-core/src/pts_repair.rs b/crates/irl-core/src/pts_repair.rs index 2c6ac8f..2076e5c 100644 --- a/crates/irl-core/src/pts_repair.rs +++ b/crates/irl-core/src/pts_repair.rs @@ -346,61 +346,36 @@ mod tests { assert_eq!(r.last(), Some((12_345, DUR))); } + /// The three tiers and their edges: under 70 ms is interpolated onto the + /// expected PTS, 70 ms up to 2 s gets that much silence, and 2 s or more + /// resets the timeline. #[test] - fn contiguous_frames_pass() { - let mut r = repair(); - r.evaluate(0, DUR); - let v = r.evaluate(DUR, DUR); - assert_eq!(v.action, PtsAction::Pass); - assert_eq!(v.corrected_pts, DUR); - assert_eq!(v.gap_ms, 0); - } - - #[test] - fn gap_of_69ms_interpolates() { - let mut r = repair(); - r.evaluate(0, DUR); - let v = r.evaluate(DUR + ms(69), DUR); - assert_eq!(v.action, PtsAction::Interpolate); - assert_eq!(v.gap_ms, 69); - // Interpolation snaps to the expected PTS, discarding the gap. - assert_eq!(v.corrected_pts, DUR); - assert_eq!(r.last(), Some((DUR, DUR))); - } - - #[test] - fn gap_of_70ms_inserts_silence() { - let mut r = repair(); - r.evaluate(0, DUR); - let pts = DUR + ms(70); - let v = r.evaluate(pts, DUR); - assert_eq!(v.action, PtsAction::Silence); - assert_eq!(v.gap_ms, 70); - assert_eq!(v.silence_ms, 70); - assert_eq!(v.corrected_pts, pts); - } - - #[test] - fn gap_of_1999ms_inserts_silence() { - let mut r = repair(); - r.evaluate(0, DUR); - let pts = DUR + ms(1999); - let v = r.evaluate(pts, DUR); - assert_eq!(v.action, PtsAction::Silence); - assert_eq!(v.silence_ms, 1999); - assert_eq!(v.corrected_pts, pts); - } - - #[test] - fn gap_of_2000ms_resets() { - let mut r = repair(); - r.evaluate(0, DUR); - let pts = DUR + ms(2000); - let v = r.evaluate(pts, DUR); - assert_eq!(v.action, PtsAction::Reset); - assert_eq!(v.gap_ms, 2000); - assert_eq!(v.silence_ms, 0); - assert_eq!(v.corrected_pts, pts); + fn forward_gaps_take_the_tier_their_size_falls_in() { + let table = [ + (0, PtsAction::Pass, 0), + (69, PtsAction::Interpolate, 0), + (70, PtsAction::Silence, 70), + (1999, PtsAction::Silence, 1999), + (2000, PtsAction::Reset, 0), + ]; + for (gap, action, silence_ms) in table { + let mut r = repair(); + r.evaluate(0, DUR); + let pts = DUR + ms(i64::from(gap)); + let v = r.evaluate(pts, DUR); + assert_eq!(v.action, action, "{gap}ms"); + assert_eq!(v.gap_ms, gap, "{gap}ms"); + assert_eq!(v.silence_ms, silence_ms, "{gap}ms"); + // Interpolation snaps to the expected PTS, discarding the gap; + // every other tier keeps the PTS the stream sent. + let corrected = if action == PtsAction::Interpolate { + DUR + } else { + pts + }; + assert_eq!(v.corrected_pts, corrected, "{gap}ms"); + assert_eq!(r.last(), Some((corrected, DUR)), "{gap}ms"); + } } #[test] diff --git a/crates/irl-core/src/speed.rs b/crates/irl-core/src/speed.rs index 6946608..754e985 100644 --- a/crates/irl-core/src/speed.rs +++ b/crates/irl-core/src/speed.rs @@ -843,15 +843,6 @@ mod tests { } } - #[test] - fn naive_rounding_would_fail_that_test() { - // Pin the defect the carry fixes, so a "simplification" that drops it - // does not pass silently. - let n = 1024; - let naive = (n as f32 / 1.0002 + 0.5) as i32; - assert_eq!(naive, n, "rounding alone discards +0.02 %"); - } - #[test] fn the_carry_never_asks_for_less_than_one_frame() { let mut carry = SpeedCarry::new(); diff --git a/crates/irl-core/src/url_opts.rs b/crates/irl-core/src/url_opts.rs index 5b70151..5f63b19 100644 --- a/crates/irl-core/src/url_opts.rs +++ b/crates/irl-core/src/url_opts.rs @@ -152,71 +152,6 @@ pub fn awaits_caller(url: &str, extra: Option<&str>) -> bool { in_query || in_extra } -#[cfg(test)] -mod awaits_caller_tests { - use super::awaits_caller; - - fn url_awaits_caller(url: &str) -> bool { - awaits_caller(url, None) - } - - #[test] - fn listener_and_rendezvous_urls_wait_to_be_called() { - assert!(url_awaits_caller("srt://0.0.0.0:7000?mode=listener")); - assert!(url_awaits_caller("srt://0.0.0.0:7000?mode=rendezvous")); - assert!(url_awaits_caller("rist://0.0.0.0:7000?listen=1")); - assert!(url_awaits_caller( - "srt://0.0.0.0:7000?latency=200000&mode=listener" - )); - } - - /// The field OBS shows next to the URL is where many people put it, and - /// libsrt does not care which: the two land in one dictionary. - #[test] - fn listener_mode_in_the_ffmpeg_options_counts_too() { - assert!(awaits_caller("srt://0.0.0.0:7654", Some("mode=listener"))); - assert!(awaits_caller( - "srt://0.0.0.0:7654", - Some("latency=2000000 mode=listener") - )); - assert!(awaits_caller("srt://0.0.0.0:7654", Some("mode=rendezvous"))); - assert!(awaits_caller("rist://0.0.0.0:7654", Some("listen=1"))); - assert!(!awaits_caller( - "srt://host.example:7000", - Some("mode=caller") - )); - assert!(!awaits_caller( - "srt://host.example:7000", - Some("latency=2000000") - )); - assert!(!awaits_caller("srt://host.example:7000", Some(""))); - // A value that merely contains the word is not the option. - assert!(!awaits_caller( - "srt://host.example:7000", - Some("passphrase=mode=listener") - )); - } - - #[test] - fn caller_urls_dial_out() { - assert!(!url_awaits_caller("srt://host.example:7000")); - assert!(!url_awaits_caller("srt://host.example:7000?mode=caller")); - assert!(!url_awaits_caller("rtmp://host.example/app/key")); - assert!(!url_awaits_caller("")); - } - - #[test] - fn only_the_query_decides() { - // A path or a passphrase containing the word must not flip it: a - // caller URL that never times out would hang on a dead host forever. - assert!(!url_awaits_caller("file:///media/mode=listener/clip.ts")); - assert!(!url_awaits_caller("srt://host.example:7000#mode=listener")); - assert!(url_awaits_caller( - "srt://host:7000?passphrase=mode%3Dlistener&mode=listener" - )); - } -} - #[cfg(test)] mod tests { use super::*; @@ -375,4 +310,59 @@ mod tests { ] ); } + + /// Whether the stall deadline waits for a connection: only for a URL that + /// waits to be called, which the query or the FFmpeg Options can say. + #[test] + fn awaits_caller_reads_the_query_and_the_ffmpeg_options() { + let table = [ + // Listener and rendezvous URLs wait to be called. + ("srt://0.0.0.0:7000?mode=listener", None, true), + ("srt://0.0.0.0:7000?mode=rendezvous", None, true), + ("rist://0.0.0.0:7000?listen=1", None, true), + ( + "srt://0.0.0.0:7000?latency=200000&mode=listener", + None, + true, + ), + // Caller URLs dial out. + ("srt://host.example:7000", None, false), + ("srt://host.example:7000?mode=caller", None, false), + ("rtmp://host.example/app/key", None, false), + ("", None, false), + // Only the query decides. A path or a passphrase containing the + // word must not flip it: a caller URL that never times out would + // hang on a dead host forever. + ("file:///media/mode=listener/clip.ts", None, false), + ("srt://host.example:7000#mode=listener", None, false), + ( + "srt://host:7000?passphrase=mode%3Dlistener&mode=listener", + None, + true, + ), + // The field OBS shows next to the URL is where many people put + // it, and libsrt does not care which: the two land in one + // dictionary. + ("srt://0.0.0.0:7654", Some("mode=listener"), true), + ( + "srt://0.0.0.0:7654", + Some("latency=2000000 mode=listener"), + true, + ), + ("srt://0.0.0.0:7654", Some("mode=rendezvous"), true), + ("rist://0.0.0.0:7654", Some("listen=1"), true), + ("srt://host.example:7000", Some("mode=caller"), false), + ("srt://host.example:7000", Some("latency=2000000"), false), + ("srt://host.example:7000", Some(""), false), + // A value that merely contains the word is not the option. + ( + "srt://host.example:7000", + Some("passphrase=mode=listener"), + false, + ), + ]; + for (url, extra, expected) in table { + assert_eq!(awaits_caller(url, extra), expected, "{url} with {extra:?}"); + } + } } From 159c5c361e0e03377f65ff333ca2c03ccdd6fa40 Mon Sep 17 00:00:00 2001 From: datagutt Date: Fri, 2 Oct 2026 02:51:52 +0200 Subject: [PATCH 16/28] test(irl-core): pin consts to their own values, not the c plugin's --- crates/irl-core/src/consts.rs | 190 ++++++++++++++++------------------ 1 file changed, 90 insertions(+), 100 deletions(-) diff --git a/crates/irl-core/src/consts.rs b/crates/irl-core/src/consts.rs index e73bfa5..7737a71 100644 --- a/crates/irl-core/src/consts.rs +++ b/crates/irl-core/src/consts.rs @@ -1,6 +1,5 @@ -//! Every tuning constant of the plugin, in one place. Values are the C -//! plugin's (`include/irl-source.h` and the file-local `#define`s); the -//! `consts_match_c_values` test pins them so a typo is caught once. +//! Every tuning constant of the plugin, in one place. The `consts_are_pinned` +//! test pins every value, so a typo is caught once. /// The source id registered with OBS; also what the websocket vendor matches. pub const SOURCE_ID: &str = "irl_source"; @@ -418,107 +417,100 @@ pub const AUDIO_BUFFER_FALLBACK_CAPACITY: usize = 65536; mod tests { use super::*; - /// Every constant, pinned against the value it has in the C plugin, so a - /// typo in one of the tables above is caught once rather than diagnosed - /// from a stream that sounds slightly wrong. - /// - /// The C source of each value is in the comment beside it: `irl-source.h` - /// unless a file is named. + /// Every constant, pinned to its value, so a typo in one of the tables + /// above is caught once rather than diagnosed from a stream that sounds + /// slightly wrong. A deliberate retune changes the value here too. #[test] - fn consts_match_c_values() { + fn consts_are_pinned() { // ── identity ── - assert_eq!(SOURCE_ID, "irl_source"); // IRL_SOURCE_ID - assert_eq!(VENDOR_NAME, "obs-irl-source"); // websocket-vendor.c - assert_eq!(VENDOR_API_VERSION, 1); // websocket-vendor.c + assert_eq!(SOURCE_ID, "irl_source"); + assert_eq!(VENDOR_NAME, "obs-irl-source"); + assert_eq!(VENDOR_API_VERSION, 1); // ── settings defaults ── - assert_eq!(DEFAULT_RECONNECT_DELAY_S, 2); // IRL_DEFAULT_RECONNECT_DELAY - assert_eq!(RECONNECT_DELAY_MIN_S, 1); // settings.c - assert_eq!(RECONNECT_DELAY_MAX_S, 60); // settings.c - assert_eq!(NETWORK_BUFFER_MB, 2); // IRL_DEFAULT_NETWORK_BUFFER_MB - assert_eq!(DEFAULT_BUFFER_TARGET_MS, 120); // IRL_DEFAULT_BUFFER_TARGET_MS - assert_eq!(BUFFER_TARGET_MIN_MS, 20); // IRL_BUFFER_TARGET_MIN_MS - assert_eq!(BUFFER_TARGET_MAX_MS, 8000); // IRL_BUFFER_TARGET_MAX_MS - assert_eq!(BUFFER_TARGET_STEP_MS, 10); // settings.c - const { assert!(DEFAULT_ADAPTIVE_SPEED) }; // IRL_DEFAULT_ADAPTIVE_SPEED - assert_eq!(DEFAULT_CATCHUP_PERCENT, 5); // IRL_DEFAULT_CATCHUP_PERCENT - assert_eq!(CATCHUP_PERCENT_MIN, 2); // IRL_CATCHUP_PERCENT_MIN - assert_eq!(CATCHUP_PERCENT_MAX, 15); // IRL_CATCHUP_PERCENT_MAX - const { assert!(DEFAULT_WAIT_FOR_KEYFRAME) }; // IRL_DEFAULT_WAIT_KEYFRAME - const { assert!(!DEFAULT_LOW_LATENCY_AUDIO) }; // IRL_DEFAULT_LOW_LATENCY_AUDIO - const { assert!(!DEFAULT_CLOSE_WHEN_INACTIVE) }; // IRL_DEFAULT_CLOSE_WHEN_INACTIVE - const { assert!(DEFAULT_CLEAR_ON_DISCONNECT) }; // IRL_DEFAULT_CLEAR_ON_DISCONNECT + assert_eq!(DEFAULT_RECONNECT_DELAY_S, 2); + assert_eq!(RECONNECT_DELAY_MIN_S, 1); + assert_eq!(RECONNECT_DELAY_MAX_S, 60); + assert_eq!(NETWORK_BUFFER_MB, 2); + assert_eq!(DEFAULT_BUFFER_TARGET_MS, 120); + assert_eq!(BUFFER_TARGET_MIN_MS, 20); + assert_eq!(BUFFER_TARGET_MAX_MS, 8000); + assert_eq!(BUFFER_TARGET_STEP_MS, 10); + const { assert!(DEFAULT_ADAPTIVE_SPEED) }; + assert_eq!(DEFAULT_CATCHUP_PERCENT, 5); + assert_eq!(CATCHUP_PERCENT_MIN, 2); + assert_eq!(CATCHUP_PERCENT_MAX, 15); + const { assert!(DEFAULT_WAIT_FOR_KEYFRAME) }; + const { assert!(!DEFAULT_LOW_LATENCY_AUDIO) }; + const { assert!(!DEFAULT_CLOSE_WHEN_INACTIVE) }; + const { assert!(DEFAULT_CLEAR_ON_DISCONNECT) }; // ── buffer watermarks ── - assert_eq!(BUFFER_MIN_DIVISOR, 2); // IRL_BUFFER_MIN_DIVISOR - assert_eq!(BUFFER_MIN_FLOOR_MS, 20); // IRL_BUFFER_MIN_FLOOR_MS - assert_eq!(BUFFER_MAX_EXTRA_MS, 200); // IRL_BUFFER_MAX_EXTRA_MS - assert_eq!(BUFFER_CAPACITY_MULTIPLIER, 4); // audio-buffer.c - assert_eq!(AUDIO_BUFFER_FALLBACK_CAPACITY, 65536); // audio-buffer.c + assert_eq!(BUFFER_MIN_DIVISOR, 2); + assert_eq!(BUFFER_MIN_FLOOR_MS, 20); + assert_eq!(BUFFER_MAX_EXTRA_MS, 200); + assert_eq!(BUFFER_CAPACITY_MULTIPLIER, 4); + assert_eq!(AUDIO_BUFFER_FALLBACK_CAPACITY, 65536); // ── PTS repair ── - assert_eq!(SMALL_GAP_MS, 70); // IRL_SMALL_GAP_MS - assert_eq!(LARGE_GAP_MS, 2000); // IRL_LARGE_GAP_MS - assert_eq!(PTS_SMALL_GAP_RELOCK_COUNT, 8); // pts-repair.c - assert_eq!(PTS_SMALL_GAP_TOLERANCE_MS, 2); // pts-repair.c - assert_eq!(PTS_RELOCK_STEP_MS, 2); // pts-repair.c - assert_eq!(AUDIO_PTS_MAX_CHUNKS, 256); // audio-buffer.h + assert_eq!(SMALL_GAP_MS, 70); + assert_eq!(LARGE_GAP_MS, 2000); + assert_eq!(PTS_SMALL_GAP_RELOCK_COUNT, 8); + assert_eq!(PTS_SMALL_GAP_TOLERANCE_MS, 2); + assert_eq!(PTS_RELOCK_STEP_MS, 2); + assert_eq!(AUDIO_PTS_MAX_CHUNKS, 256); // ── audio output ── - assert_eq!(FADE_DURATION_MS, 50); // IRL_FADE_DURATION_MS - assert_eq!(STARTUP_AUDIO_WARMUP_MS, 150); // IRL_STARTUP_AUDIO_WARMUP_MS - assert_eq!(BLEED_PACE_FILL_MS, 1000); // IRL_BLEED_PACE_FILL_MS - assert_eq!(AUDIO_OFFSET_REANCHOR_MARGIN_MS, 400); // AUDIO_OFFSET_REANCHOR_MARGIN_MS - assert_eq!(AUDIO_RECOVERY_HOLD_US, 1_500_000); // receiver-audio.c + assert_eq!(FADE_DURATION_MS, 50); + assert_eq!(STARTUP_AUDIO_WARMUP_MS, 150); + assert_eq!(BLEED_PACE_FILL_MS, 1000); + assert_eq!(AUDIO_OFFSET_REANCHOR_MARGIN_MS, 400); + assert_eq!(AUDIO_RECOVERY_HOLD_US, 1_500_000); assert_eq!(AUDIO_RESET_RECOVERY_HOLD_US, 2_500_000); assert_eq!(VIDEO_PKT_GATE_TIMEOUT_US, 5_000_000); - assert_eq!(AUDIO_TRIM_TRIGGER_MS, 90); // receiver-audio.c - assert_eq!(AUDIO_CONCEAL_FADE_MS, 8); // receiver-audio.c - assert_eq!(AUDIO_OUT_LEAD_MS, 80); // receiver-audio.c - assert_eq!(AUDIO_OUT_MAX_LAG_MS, 150); // receiver-audio.c - assert_eq!(AUDIO_SPEED_MIN, 0.98); // receiver-audio.c - assert_eq!(AUDIO_SPEED_DEADBAND_MS, 20); // receiver-audio.c - assert_eq!(AUDIO_SPEED_SMOOTHING, 0.05); // receiver-audio.c - // No C ancestor: the C regulated the instantaneous level. + assert_eq!(AUDIO_TRIM_TRIGGER_MS, 90); + assert_eq!(AUDIO_CONCEAL_FADE_MS, 8); + assert_eq!(AUDIO_OUT_LEAD_MS, 80); + assert_eq!(AUDIO_OUT_MAX_LAG_MS, 150); + assert_eq!(AUDIO_SPEED_MIN, 0.98); + assert_eq!(AUDIO_SPEED_DEADBAND_MS, 20); + assert_eq!(AUDIO_SPEED_SMOOTHING, 0.05); assert_eq!(AUDIO_SPEED_LEVEL_SMOOTHING, 0.008); - assert_eq!(AUDIO_SPEED_DEADBAND_SLOPE, 0.002); // receiver-audio.c - assert_eq!(AUDIO_SPEED_TRIM_GAIN, 0.0025); // receiver-audio.c - assert_eq!(AUDIO_SPEED_TRIM_MAX, 0.01); // receiver-audio.c - assert_eq!(AUDIO_SPEED_TRIM_ERR_WINDOW_MS, 60); // receiver-audio.c (3 * deadband) - assert_eq!(AUDIO_SPEED_TRIM_MAX_DT_US, 1_000_000); // receiver-audio.c - assert_eq!(AUDIO_LL_MAX_FILL_MS, 100); // receiver-audio.c - assert_eq!(AUDIO_DRAIN_STUCK_US, 20_000_000); // receiver-audio.c - assert_eq!(AUDIO_DRAIN_STUCK_PROGRESS_MS, 100); // receiver-audio.c - assert_eq!(AUDIO_SOFT_COMPENSATION_MAX_SAMPLES, 8); // receiver-audio.c - assert_eq!(AUDIO_DEFAULT_FRAME_SAMPLES, 960); // receiver-audio.c - assert_eq!(AUDIO_PUMP_BURST, 16); // receiver.c - assert_eq!(AUDIO_PUMP_SLEEP_MS, 1); // receiver.c - // No C ancestor: the C polled at AUDIO_PUMP_SLEEP_MS unconditionally. + assert_eq!(AUDIO_SPEED_DEADBAND_SLOPE, 0.002); + assert_eq!(AUDIO_SPEED_TRIM_GAIN, 0.0025); + assert_eq!(AUDIO_SPEED_TRIM_MAX, 0.01); + assert_eq!(AUDIO_SPEED_TRIM_ERR_WINDOW_MS, 60); // 3 * deadband + assert_eq!(AUDIO_SPEED_TRIM_MAX_DT_US, 1_000_000); + assert_eq!(AUDIO_LL_MAX_FILL_MS, 100); + assert_eq!(AUDIO_DRAIN_STUCK_US, 20_000_000); + assert_eq!(AUDIO_DRAIN_STUCK_PROGRESS_MS, 100); + assert_eq!(AUDIO_SOFT_COMPENSATION_MAX_SAMPLES, 8); + assert_eq!(AUDIO_DEFAULT_FRAME_SAMPLES, 960); + assert_eq!(AUDIO_PUMP_BURST, 16); + assert_eq!(AUDIO_PUMP_SLEEP_MS, 1); assert_eq!(AUDIO_PUMP_MAX_SLEEP_MS, 20); - assert_eq!(AUDIO_MAX_CHANNELS, 8); // receiver-audio.c + assert_eq!(AUDIO_MAX_CHANNELS, 8); // ── decode ── - assert_eq!(DECODER_FLUSH_COOLDOWN_US, 350_000); // receiver-decode.c - assert_eq!(DECODER_WARNING_INTERVAL_US, 1_000_000); // receiver-decode.c - assert_eq!(DECODER_ERROR_BURST, 3); // receiver-decode.c - assert_eq!(VIDEO_DECODER_THREADS, 4); // receiver-stream.c - assert_eq!(VIDEO_EXTRA_HW_FRAMES, 6); // receiver-stream.c - // No C ancestor: the C decoded eagerly on the receiver thread and held - // the whole lead as decoded frames. + assert_eq!(DECODER_FLUSH_COOLDOWN_US, 350_000); + assert_eq!(DECODER_WARNING_INTERVAL_US, 1_000_000); + assert_eq!(DECODER_ERROR_BURST, 3); + assert_eq!(VIDEO_DECODER_THREADS, 4); + assert_eq!(VIDEO_EXTRA_HW_FRAMES, 6); assert_eq!(VIDEO_DECODE_LEAD_MS, 250); assert_eq!(VIDEO_PACKET_QUEUE_MAX_MS, 12_000); assert_eq!(VIDEO_PACKET_QUEUE_MAX_BYTES, 67_108_864); // ── video timing / pacing ── - assert_eq!(OBS_ASYNC_FRAME_BUDGET, 24); // IRL_OBS_ASYNC_FRAME_BUDGET - assert_eq!(VIDEO_LEAD_WARN_INTERVAL_NS, 10_000_000_000); // IRL_VIDEO_LEAD_WARN_INTERVAL_NS - assert_eq!(VIDEO_INTERVAL_MIN_NS, 4_000_000); // IRL_VIDEO_INTERVAL_MIN_NS - assert_eq!(VIDEO_INTERVAL_MAX_NS, 100_000_000); // IRL_VIDEO_INTERVAL_MAX_NS - assert_eq!(VIDEO_INTERVAL_DEFAULT_NS, 33_333_333); // IRL_VIDEO_INTERVAL_DEFAULT_NS - assert_eq!(VIDEO_PACING_MAX_FRAMES, 1024); // IRL_VIDEO_PACING_MAX_FRAMES - assert_eq!(VIDEO_PACING_MAX_BYTES, 1_073_741_824); // IRL_VIDEO_PACING_MAX_BYTES - assert_eq!(VIDEO_PACING_SLACK_NS, 1_000_000); // IRL_VIDEO_PACING_SLACK_NS - assert_eq!(VIDEO_PACING_LEAD_TICKS, 2); // IRL_VIDEO_PACING_LEAD_TICKS + assert_eq!(OBS_ASYNC_FRAME_BUDGET, 24); + assert_eq!(VIDEO_LEAD_WARN_INTERVAL_NS, 10_000_000_000); + assert_eq!(VIDEO_INTERVAL_MIN_NS, 4_000_000); + assert_eq!(VIDEO_INTERVAL_MAX_NS, 100_000_000); + assert_eq!(VIDEO_INTERVAL_DEFAULT_NS, 33_333_333); + assert_eq!(VIDEO_PACING_MAX_FRAMES, 1024); + assert_eq!(VIDEO_PACING_MAX_BYTES, 1_073_741_824); + assert_eq!(VIDEO_PACING_SLACK_NS, 1_000_000); + assert_eq!(VIDEO_PACING_LEAD_TICKS, 2); assert_eq!(VIDEO_ANCHOR_WAIT_MARGIN_MS, 1000); assert_eq!(VIDEO_DELAY_MAX_MS, 5000); assert_eq!(VIDEO_DELAY_WINDOW_MS, 1000); @@ -528,8 +520,6 @@ mod tests { assert_eq!(VIDEO_LIVE_MAX_WAIT_MS, 2000); assert_eq!(VIDEO_DELAY_RELAX_WINDOW_MS, 10_000); assert_eq!(VIDEO_DELAY_RELAX_MIN_MS, 50); - // No C ancestor: the C played audio the moment its buffer held the - // target, whatever its video was doing. assert_eq!(AUDIO_HOLD_MARGIN_MS, 100); assert_eq!(AUDIO_HOLD_MAX_MS, 5000); assert_eq!(AUDIO_HOLD_RAISE_WINDOW_MS, 2000); @@ -537,22 +527,22 @@ mod tests { assert_eq!(AUDIO_HOLD_RELAX_WINDOW_MS, 10_000); assert_eq!(AUDIO_HOLD_RELAX_MIN_MS, 50); assert_eq!(AUDIO_HOLD_PRIME_WAIT_MS, 2000); - assert_eq!(VIDEO_PACING_MAX_LEAD_NS, 50_000_000); // IRL_VIDEO_PACING_MAX_LEAD_NS - assert_eq!(VIDEO_CANVAS_TICK_DEFAULT_NS, 16_666_667); // IRL_VIDEO_CANVAS_TICK_DEFAULT_NS - assert_eq!(VIDEO_PACING_MAX_WAIT_MS, 50); // IRL_VIDEO_PACING_MAX_WAIT_MS - assert_eq!(VIDEO_OFFSET_HOLD_NS, 500_000_000); // IRL_VIDEO_OFFSET_HOLD_NS - assert_eq!(VIDEO_TS_CLAMP_NS, 500_000_000); // video-handler.c - assert_eq!(VIDEO_TS_CAP_NS, 200_000_000); // video-handler.c - assert_eq!(XFER_PLANE_ALIGN, 64); // video-handler.c - assert_eq!(XFER_DIM_ALIGN, 16); // video-handler.c (FFALIGN(w, 16)) + assert_eq!(VIDEO_PACING_MAX_LEAD_NS, 50_000_000); + assert_eq!(VIDEO_CANVAS_TICK_DEFAULT_NS, 16_666_667); + assert_eq!(VIDEO_PACING_MAX_WAIT_MS, 50); + assert_eq!(VIDEO_OFFSET_HOLD_NS, 500_000_000); + assert_eq!(VIDEO_TS_CLAMP_NS, 500_000_000); + assert_eq!(VIDEO_TS_CAP_NS, 200_000_000); + assert_eq!(XFER_PLANE_ALIGN, 64); + assert_eq!(XFER_DIM_ALIGN, 16); // ── stream / network ── - assert_eq!(IO_STALL_TIMEOUT_US, 10_000_000); // IRL_IO_STALL_TIMEOUT_US - assert_eq!(PROBE_FAST, 1_000_000); // receiver-stream.c - assert_eq!(PROBE_FULL, 5_000_000); // receiver-stream.c - assert_eq!(SRT_LATENCY_US, 200_000); // receiver-stream.c - assert_eq!(RTMP_BUFFER_MS, 1000); // receiver-stream.c - assert_eq!(UDP_FIFO_DEFAULT_PACKETS, 28_672); // receiver-stream.c (7 * 4096) - assert_eq!(STATS_LOG_INTERVAL_NS, 30_000_000_000); // receiver-stream.c + assert_eq!(IO_STALL_TIMEOUT_US, 10_000_000); + assert_eq!(PROBE_FAST, 1_000_000); + assert_eq!(PROBE_FULL, 5_000_000); + assert_eq!(SRT_LATENCY_US, 200_000); + assert_eq!(RTMP_BUFFER_MS, 1000); + assert_eq!(UDP_FIFO_DEFAULT_PACKETS, 28_672); // 7 * 4096 + assert_eq!(STATS_LOG_INTERVAL_NS, 30_000_000_000); } } From e01668fc9bfead59a5412af4e2dd253695f5b521 Mon Sep 17 00:00:00 2001 From: datagutt Date: Fri, 2 Oct 2026 02:54:33 +0200 Subject: [PATCH 17/28] chore(workspace): drop transfer alignment consts the ffmpeg crate owns, fix safety comments and doc links --- crates/irl-core/src/consts.rs | 6 ------ crates/irl-source/tests/video_pipeline.rs | 1 + crates/obs/src/log.rs | 2 +- crates/obs/src/module.rs | 2 +- crates/obs/src/video.rs | 5 ++--- 5 files changed, 5 insertions(+), 11 deletions(-) diff --git a/crates/irl-core/src/consts.rs b/crates/irl-core/src/consts.rs index 7737a71..5b16fd9 100644 --- a/crates/irl-core/src/consts.rs +++ b/crates/irl-core/src/consts.rs @@ -387,10 +387,6 @@ pub const VIDEO_OFFSET_HOLD_NS: u64 = 500_000_000; pub const VIDEO_TS_CLAMP_NS: i64 = 500_000_000; /// Video-only fallback: forward cap. pub const VIDEO_TS_CAP_NS: u64 = 200_000_000; -/// Plane alignment of the transfer pool (FFmpeg's uncached-copy fast path). -pub const XFER_PLANE_ALIGN: i32 = 64; -/// Transfer pool dimension alignment. -pub const XFER_DIM_ALIGN: i32 = 16; // ── Stream / network ── @@ -533,8 +529,6 @@ mod tests { assert_eq!(VIDEO_OFFSET_HOLD_NS, 500_000_000); assert_eq!(VIDEO_TS_CLAMP_NS, 500_000_000); assert_eq!(VIDEO_TS_CAP_NS, 200_000_000); - assert_eq!(XFER_PLANE_ALIGN, 64); - assert_eq!(XFER_DIM_ALIGN, 16); // ── stream / network ── assert_eq!(IO_STALL_TIMEOUT_US, 10_000_000); diff --git a/crates/irl-source/tests/video_pipeline.rs b/crates/irl-source/tests/video_pipeline.rs index 84f2fd7..fbe3878 100644 --- a/crates/irl-source/tests/video_pipeline.rs +++ b/crates/irl-source/tests/video_pipeline.rs @@ -123,6 +123,7 @@ fn feed(thread: &mut VideoThread, pts: i64, received_ns: u64) { /// `AV_FRAME_FLAG_KEY`, which the safe API deliberately has no setter for /// (only decoders set it). fn mark_keyframe(frame: &mut ffmpeg::Frame) { + // SAFETY: `frame` owns a live AVFrame for the duration of the borrow. unsafe { (*frame.as_mut_ptr()).flags |= ffmpeg::sys::AV_FRAME_FLAG_KEY; } diff --git a/crates/obs/src/log.rs b/crates/obs/src/log.rs index 5b11b19..da48451 100644 --- a/crates/obs/src/log.rs +++ b/crates/obs/src/log.rs @@ -1,4 +1,4 @@ -//! `blog` bridge. Plugins bind their prefix once with [`log!`]-style macros of +//! `blog` bridge. Plugins bind their prefix once with `log!`-style macros of //! their own (see `irl-source/src/log.rs`); this module owns the single //! `blog(level, "%s", msg)` call and the level constants. diff --git a/crates/obs/src/module.rs b/crates/obs/src/module.rs index c988ab3..597a2c2 100644 --- a/crates/obs/src/module.rs +++ b/crates/obs/src/module.rs @@ -1,6 +1,6 @@ //! Module entry points. //! -//! [`declare_module!`] expands to the `obs_module_*` exports libobs looks up +//! [`declare_module!`](crate::declare_module) expands to the `obs_module_*` exports libobs looks up //! (`obs_module_load`, `obs_module_set_pointer` and `obs_module_ver` are //! required; the rest optional) and the locale helpers that //! `OBS_MODULE_USE_DEFAULT_LOCALE` provides in C. The macro bodies only call diff --git a/crates/obs/src/video.rs b/crates/obs/src/video.rs index fb8851a..633f4c5 100644 --- a/crates/obs/src/video.rs +++ b/crates/obs/src/video.rs @@ -87,10 +87,9 @@ pub struct VideoFrame<'a> { impl<'a> VideoFrame<'a> { #[must_use] pub fn new(width: u32, height: u32, format: VideoFormat) -> Self { - // `struct obs_source_frame` has no niche-carrying member (raw + // SAFETY: `struct obs_source_frame` has no niche-carrying member (raw // pointers, integers, floats, bools and one `volatile long` libobs - // resets itself), so an all-zero value is a valid one — the same - // `memset(&frame, 0, sizeof frame)` the C plugin does. + // resets itself), so an all-zero value is a valid one. let mut inner: obs_sys::obs_source_frame = unsafe { core::mem::zeroed() }; inner.width = width; inner.height = height; From 58bbae27c01eb5dbd86385103b022c655af810ec Mon Sep 17 00:00:00 2001 From: datagutt Date: Fri, 2 Oct 2026 03:06:38 +0200 Subject: [PATCH 18/28] refactor(irl-source)!: report only the stats that diagnosed real issues get_stats and GetStats keep buffer_fill_ms, current_speed, total_audio_frames, total_video_frames, pts_max_gap_ms, audio_underruns, audio_output_restarts, video_delay_ms, av_skew_ms, audio_hold_ms and reconnecting. removed: adaptive_latency_control, low_latency_audio, reconnect_count, pts_repairs, pts_normalizations, pts_interpolations, pts_resets, pts_last_gap_ms, silence_insertions, audio_resync_skipped_chunks, audio_hidden_trimmed_chunks, audio_quality_events, obs_lead_ms, audio_decoder_flushes, video_corrupt_frames, video_corrupt_held, video_lead_ms, video_lead_excess and stream_delay_ms, along with the counters and mirrors that only fed them. stream_delay_ms measured schedule drift since the first frame, not end-to-end delay. the 30 s stats line drops the pts split, trims, quality events, obs lead, chunk sizes, decoder flushes, corrupt/held, video lead, early and reanchors. it gains ll= for low latency audio and prints ctrl=off only when adaptive latency control is disabled. the readme table and sample line, irl-stats.lua and the docs follow. BREAKING CHANGE: the get_stats proc and the GetStats vendor request no longer report adaptive_latency_control, low_latency_audio, reconnect_count, pts_repairs, pts_normalizations, pts_interpolations, pts_resets, pts_last_gap_ms, silence_insertions, audio_resync_skipped_chunks, audio_hidden_trimmed_chunks, audio_quality_events, obs_lead_ms, audio_decoder_flushes, video_corrupt_frames, video_corrupt_held, video_lead_ms, video_lead_excess or stream_delay_ms; overlays reading them must be updated. --- .github/ISSUE_TEMPLATE/bug_report.yml | 2 +- CLAUDE.md | 2 +- README.md | 39 ++--- crates/irl-core/src/stats.rs | 174 +++------------------ crates/irl-core/src/video_time.rs | 5 +- crates/irl-source/src/audio/mod.rs | 24 +-- crates/irl-source/src/audio/pump.rs | 58 +------ crates/irl-source/src/receiver/audio_in.rs | 31 +--- crates/irl-source/src/receiver/decode.rs | 15 +- crates/irl-source/src/receiver/stream.rs | 60 ++----- crates/irl-source/src/shared.rs | 40 ++--- crates/irl-source/src/source.rs | 38 +---- crates/irl-source/src/video/intake.rs | 11 +- crates/irl-source/src/video/output.rs | 27 +--- crates/irl-source/src/video/thread.rs | 3 - crates/irl-source/tests/audio_core.rs | 2 - crates/irl-source/tests/network_sim.rs | 9 +- crates/irl-source/tests/shell_stats.rs | 37 ++--- crates/irl-source/tests/video_pipeline.rs | 15 +- docs/audio-pipeline.md | 2 +- docs/viewer-quality-plan.md | 34 ++-- irl-stats.lua | 20 +-- 22 files changed, 143 insertions(+), 505 deletions(-) diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml index 6764338..9ed6d4e 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.yml +++ b/.github/ISSUE_TEMPLATE/bug_report.yml @@ -111,7 +111,7 @@ body: placeholder: | [irl-source] Video stream 0: hevc 1920x1080 (d3d11va) [irl-source] First keyframe received (1920x1080 fmt=171 hardware decode) - [irl-source] Stats: video=1801 audio=2997 buf=100ms target=120ms speed=1.000 ... + [irl-source] Stats: video=1801 audio=1406 buf=118ms peak=164ms target=120ms speed=1.000 ll=off ... - type: input id: plugin-version diff --git a/CLAUDE.md b/CLAUDE.md index 2b20ad8..5233680 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -235,7 +235,7 @@ Releases are tag driven (`.github/workflows/release.yml`, see `RELEASING.md`). P The port is behaviour-identical except for these, which are intentional: 1. The dead `network_buffer_mb` setting is gone. Nothing read it; the transport buffer is `irl_core::consts::NETWORK_BUFFER_MB`. -2. The `video_decoder_flushes` stat is gone (it was always 0 after the video decoder stopped being flushed). 27 stat fields remained; deviation 16 adds `video_delay_ms` and `av_skew_ms`, and deviation 17 adds `audio_hold_ms`, making 30. +2. The `video_decoder_flushes` stat is gone (it was always 0 after the video decoder stopped being flushed). Deviations 16 and 17 later added `video_delay_ms`, `av_skew_ms` and `audio_hold_ms`, and 3.0.0 cut the surface down to the 11 stat fields that diagnosed real issues (the stats log line was trimmed with it). 3. `irl-stats.lua` finds the source by its plugin id instead of by display name, and takes source names as script properties. 4. The vestigial `hw_map_ok` flag is not ported. 5. `w32-pthreads.dll` is no longer shipped on Windows: Rust never calls `pthread_*`, so the librist shim hazard that `include/irl-threading.h` existed for is gone. The installer deletes a stale copy. diff --git a/README.md b/README.md index 54f6eb0..349c047 100644 --- a/README.md +++ b/README.md @@ -155,7 +155,7 @@ const { responseData } = await obs.call("CallVendorRequest", { requestData: { source_name: "IRL Source" }, }); -console.log(responseData.stream_delay_ms, responseData.buffer_fill_ms); +console.log(responseData.buffer_fill_ms, responseData.current_speed); ``` There is no event stream, so poll `GetStats` at whatever rate your overlay refreshes; once a second is plenty. The request reads the same snapshot the Lua path does, so both transports always report identical numbers. @@ -251,14 +251,14 @@ The 70ms and 2000ms thresholds are fixed internally and match the libobs behavio - Low-delay decode: no B-frame reorder buffering, capped decode threading. - Zero-copy for supported pixel formats, planes go straight to OBS. Native 10-bit passthrough for YUV420P10LE (I010) and P010. Unsupported formats fall back to swscale. - Mid-stream resolution changes are detected and handled without recreating the source. -- Damaged H.264 frames are passed through with their timestamps rather than dropped, which preserves cadence instead of freezing on every corrupt frame. HEVC frames predicted from a reference that never arrived are held back instead: HEVC has no error concealment, so FFmpeg synthesizes the missing reference as flat gray and everything predicted from it is gray until the next keyframe. The last good frame stays on screen for that stretch (`video_corrupt_held` counts them). +- Damaged H.264 frames are passed through with their timestamps rather than dropped, which preserves cadence instead of freezing on every corrupt frame. HEVC frames predicted from a reference that never arrived are held back instead: HEVC has no error concealment, so FFmpeg synthesizes the missing reference as flat gray and everything predicted from it is gray until the next keyframe. The last good frame stays on screen for that stretch, and the log says how many frames were held once the picture resumes. - Video PTS is mapped through the audio playout offset for lip sync. ## Decoder recovery Repeated audio decode errors trigger a throttled audio decoder flush and a reset of bad timing state. This is what stops SRT bitrate starvation from permanently breaking audio, which is the failure mode the built-in Media Source hits. -The video decoder is deliberately never flushed. Flushing empties the reference picture buffer and clears the decoder's recovery state, and neither the H.264 nor the HEVC decoder produces a real picture again until the next keyframe: H.264 paints frames gray until a recovery point, HEVC synthesizes each missing reference as flat gray. On a lossy stream that turned a few damaged frames into a whole GOP of gray. A decode error on a live stream is a property of the packet, not of the decoder, so the next intact packet decodes fine without a reset. Bursts are still counted and logged (`video_corrupt_frames`, and the `corrupt=`/`held=` fields of the stats line). +The video decoder is deliberately never flushed. Flushing empties the reference picture buffer and clears the decoder's recovery state, and neither the H.264 nor the HEVC decoder produces a real picture again until the next keyframe: H.264 paints frames gray until a recovery point, HEVC synthesizes each missing reference as flat gray. On a lossy stream that turned a few damaged frames into a whole GOP of gray. A decode error on a live stream is a property of the packet, not of the decoder, so the next intact packet decodes fine without a reset. Bursts are still logged. ## Compared with the Media Source @@ -312,44 +312,31 @@ Stats are exposed through OBS's `proc_handler` API under the `get_stats` call, a | --- | --- | --- | | `buffer_fill_ms` | int | Current audio jitter buffer fill level (ms) | | `current_speed` | float | Current audio correction factor. Buffered mode keeps this near 1.0. | -| `adaptive_latency_control` | bool | Whether buffered steady-state latency correction is enabled | -| `reconnecting` | bool | Whether the source is currently reconnecting | | `total_audio_frames` | int | Total audio frames decoded since connection | | `total_video_frames` | int | Total video frames decoded since connection | -| `pts_repairs` | int | Number of non-normal PTS discontinuities repaired | -| `pts_normalizations` | int | Number of frame-sized PTS cadence offsets normalized without treating them as damage | -| `pts_interpolations` | int | Number of non-frame-sized small PTS gaps smoothed by timestamp interpolation | -| `pts_resets` | int | Number of large PTS gaps that triggered a timing reset | -| `pts_last_gap_ms` | int | Most recent repaired PTS gap size | -| `pts_max_gap_ms` | int | Largest repaired PTS gap size since the current connection/reset | -| `silence_insertions` | int | Number of silence insertions for gap filling | +| `pts_max_gap_ms` | int | Largest repaired audio PTS gap since the current connection | | `audio_underruns` | int | Number of plugin-side underruns that emitted silence to keep OBS audio timestamps monotonic | -| `audio_resync_skipped_chunks` | int | Number of buffered audio chunks skipped by low-latency backlog capping or startup trims | -| `audio_hidden_trimmed_chunks` | int | Number of buffered chunks trimmed before playback primed (never audible) | -| `audio_quality_events` | int | Aggregate audible-risk counter for underruns, inserted silence, resyncs, PTS resets, and audio decoder flushes | | `audio_output_restarts` | int | Output clock restarts after the audio thread stalled (should stay 0) | -| `obs_lead_ms` | int | How far ahead of real time audio is queued inside OBS (healthy is roughly 60 to 100ms) | -| `audio_decoder_flushes` | int | Number of audio decoder flushes after repeated decode errors | -| `video_corrupt_frames` | int | Decoded frames the decoder flagged as damaged (concealed slice errors on H.264, missing-reference prediction on HEVC) | -| `video_corrupt_held` | int | HEVC frames held back instead of shown because they were predicted from a missing reference and would have rendered gray; the last good frame stays on screen until the next keyframe | -| `video_lead_ms` | int | How far ahead of real time the last video frame was timestamped. Tracks the audio buffer; a value climbing well past Target Buffer and staying there means concealment has inflated the A/V mapping | -| `video_lead_excess` | int | Frames whose lead exceeded what OBS's async queue can absorb. Harmless while the lead is steady; sustained growth is what makes OBS drop queued video | | `video_delay_ms` | int | Standing delay added to the video schedule because video reached the plugin too late to be paced against the audio playout (the encoder sends video later than audio by more than Target Buffer covers). It is sized at connection start and then checked against what frames actually need: when ten seconds of frames all needed less, it ramps back down with video playing at the Catch-Up Speed, so a bad first second on a poor link does not leave the picture behind the sound for the whole stream. Lip sync is off by this much while it lasts. When the sender keeps its video this far behind its audio for two seconds, `audio_hold_ms` rises to cover it, and this delay drops back to zero as the audio slows into step. In Low Latency Audio mode, or with Adaptive Latency Control off, the hold is only sized when the stream starts. A delay that stays above zero with the hold in force means video is late for a reason on this machine (decoding, a busy GPU); raising Target Buffer by at least this much takes it back to zero | | `av_skew_ms` | int | Video PTS minus audio PTS of what last reached the plugin: how the sender stamps its two streams against each other, before any buffering here. Near zero for a healthy sender; a standing negative value is video stamped behind its audio, which is what `audio_hold_ms` then covers. Measured as packets arrive, so the delay itself does not show up in it | | `audio_hold_ms` | int | How much longer than Target Buffer audio waits before it plays, so that video which reaches the plugin later than its audio is in hand when the two are due. Measured before audio starts, so a sender that always sends its video late (pocketSRT, a phone with video stabilization on) is in sync from the first second. After that it rises when the skew lasts two seconds, which the speed controller builds by playing up to 2% slow, and falls when ten seconds of video needed less. 0 for a sender whose skew Target Buffer already covers | -| `stream_delay_ms` | int | End-to-end stream delay (SRT latency + decode + buffering) | -| `low_latency_audio` | bool | Whether OBS async unbuffered low-latency mode is enabled | -| `reconnect_count` | int | Number of reconnect attempts since the source was created | +| `reconnecting` | bool | Whether the source is currently reconnecting | ### OBS log stats The plugin also logs stats to the OBS log every 30 seconds: ``` -[irl-source] Stats: video=1801 audio=2997 buf=100ms target=120ms speed=1.000 ctrl=on pts_repairs=0 norm=0 interp=0 silence=0 resets=0 last_gap=0ms max_gap=0ms underruns=0 resync_skips=0 hidden_trims=0 quality_events=0 audio_flushes=0 corrupt=0 held=0 obs_lead=99ms chunk=960@48000 stream_chunk=20ms obs_chunk=20ms restarts=0 res=1920x1080 +[irl-source] Stats: video=1801 audio=1406 buf=118ms peak=164ms target=120ms speed=1.000 ll=off max_gap=0ms underruns=0 restarts=0 av_drift=0ms av_skew=-14ms hold=0ms vdelay=0ms vfps=60.0 vq_drops=0 pktq=7/38(96KB,116ms) paced=14/16(43MB) eagain=0/0 pktdrop=0/0 res=1920x1080 ``` -A healthy stream shows `speed=1.000`, `underruns=0`, `restarts=0`, and a constant `chunk` size. `buf` plus `obs_lead` is your plugin-side latency (fill wanders inside a deadband around the target by design). +A healthy stream shows `speed` near 1.000, `underruns=0`, `restarts=0`, `av_drift` near 0, and `vdelay` and `hold` at 0. `buf` wanders inside a deadband around `target` by design, and `peak` is the highest fill since the source was created. `ll` is Low Latency Audio, and `ctrl=off` appears only when Adaptive Latency Control is turned off. The rest describe the video path: + +- `vfps`: the frame rate the sender is delivering. +- `vq_drops`: compressed video packets dropped because the decode queue overflowed (should stay 0). +- `pktq`: compressed video waiting to be decoded, as now/peak packets (size, duration). Its duration tracks Target Buffer. +- `paced`: decoded frames waiting for their due time, as now/peak (memory). +- `eagain` and `pktdrop`: packets a decoder refused at first and packets it never took, as video/audio. ## Building from source diff --git a/crates/irl-core/src/stats.rs b/crates/irl-core/src/stats.rs index f18de89..dc8a25c 100644 --- a/crates/irl-core/src/stats.rs +++ b/crates/irl-core/src/stats.rs @@ -28,34 +28,15 @@ pub enum StatValue { pub const FIELDS: &[(&str, StatKind)] = &[ ("buffer_fill_ms", StatKind::Int), ("current_speed", StatKind::Float), - ("adaptive_latency_control", StatKind::Bool), - ("reconnecting", StatKind::Bool), ("total_audio_frames", StatKind::Int), ("total_video_frames", StatKind::Int), - ("pts_repairs", StatKind::Int), - ("pts_normalizations", StatKind::Int), - ("pts_interpolations", StatKind::Int), - ("pts_resets", StatKind::Int), - ("pts_last_gap_ms", StatKind::Int), ("pts_max_gap_ms", StatKind::Int), - ("silence_insertions", StatKind::Int), ("audio_underruns", StatKind::Int), - ("audio_resync_skipped_chunks", StatKind::Int), - ("audio_hidden_trimmed_chunks", StatKind::Int), - ("audio_quality_events", StatKind::Int), ("audio_output_restarts", StatKind::Int), - ("obs_lead_ms", StatKind::Int), - ("audio_decoder_flushes", StatKind::Int), - ("video_corrupt_frames", StatKind::Int), - ("video_corrupt_held", StatKind::Int), - ("video_lead_ms", StatKind::Int), - ("video_lead_excess", StatKind::Int), ("video_delay_ms", StatKind::Int), ("av_skew_ms", StatKind::Int), ("audio_hold_ms", StatKind::Int), - ("stream_delay_ms", StatKind::Int), - ("low_latency_audio", StatKind::Bool), - ("reconnect_count", StatKind::Int), + ("reconnecting", StatKind::Bool), ]; /// A snapshot of every stat, in [`FIELDS`] order. @@ -65,50 +46,16 @@ pub struct StatsSnapshot { pub buffer_fill_ms: i64, /// Smoothed playback speed. pub current_speed: f64, - /// Adaptive latency control enabled. - pub adaptive_latency_control: bool, - /// Reconnecting. - pub reconnecting: bool, /// Audio frames decoded this connection. pub total_audio_frames: i64, /// Video frames decoded this connection. pub total_video_frames: i64, - /// PTS repairs. - pub pts_repairs: i64, - /// PTS normalizations. - pub pts_normalizations: i64, - /// PTS interpolations. - pub pts_interpolations: i64, - /// PTS resets. - pub pts_resets: i64, - /// Last PTS gap. - pub pts_last_gap_ms: i64, - /// Largest PTS gap. + /// Largest audio PTS gap this connection. pub pts_max_gap_ms: i64, - /// Silence insertions. - pub silence_insertions: i64, /// Underruns. pub audio_underruns: i64, - /// Chunks skipped on resync. - pub audio_resync_skipped_chunks: i64, - /// Chunks trimmed silently. - pub audio_hidden_trimmed_chunks: i64, - /// Quality events. - pub audio_quality_events: i64, /// Output clock restarts. pub audio_output_restarts: i64, - /// Queued audio lead ahead of wall clock. - pub obs_lead_ms: i64, - /// Audio decoder flushes. - pub audio_decoder_flushes: i64, - /// Corrupt video frames seen. - pub video_corrupt_frames: i64, - /// Corrupt video frames held back. - pub video_corrupt_held: i64, - /// Video lead. - pub video_lead_ms: i64, - /// Video lead excess events. - pub video_lead_excess: i64, /// Standing delay added to the video schedule so late-arriving video can /// still be paced; lip sync is off by this much. pub video_delay_ms: i64, @@ -120,12 +67,8 @@ pub struct StatsSnapshot { /// arrives behind it is in hand when the two are due /// (`irl_core::audio_hold`). pub audio_hold_ms: i64, - /// Estimated end-to-end delay. - pub stream_delay_ms: i64, - /// Low latency audio enabled. - pub low_latency_audio: bool, - /// Reconnects. - pub reconnect_count: i64, + /// Reconnecting. + pub reconnecting: bool, } impl StatKind { @@ -145,34 +88,15 @@ impl StatsSnapshot { [ StatValue::Int(self.buffer_fill_ms), StatValue::Float(self.current_speed), - StatValue::Bool(self.adaptive_latency_control), - StatValue::Bool(self.reconnecting), StatValue::Int(self.total_audio_frames), StatValue::Int(self.total_video_frames), - StatValue::Int(self.pts_repairs), - StatValue::Int(self.pts_normalizations), - StatValue::Int(self.pts_interpolations), - StatValue::Int(self.pts_resets), - StatValue::Int(self.pts_last_gap_ms), StatValue::Int(self.pts_max_gap_ms), - StatValue::Int(self.silence_insertions), StatValue::Int(self.audio_underruns), - StatValue::Int(self.audio_resync_skipped_chunks), - StatValue::Int(self.audio_hidden_trimmed_chunks), - StatValue::Int(self.audio_quality_events), StatValue::Int(self.audio_output_restarts), - StatValue::Int(self.obs_lead_ms), - StatValue::Int(self.audio_decoder_flushes), - StatValue::Int(self.video_corrupt_frames), - StatValue::Int(self.video_corrupt_held), - StatValue::Int(self.video_lead_ms), - StatValue::Int(self.video_lead_excess), StatValue::Int(self.video_delay_ms), StatValue::Int(self.av_skew_ms), StatValue::Int(self.audio_hold_ms), - StatValue::Int(self.stream_delay_ms), - StatValue::Bool(self.low_latency_audio), - StatValue::Int(self.reconnect_count), + StatValue::Bool(self.reconnecting), ] } @@ -205,40 +129,14 @@ pub fn proc_declaration() -> String { mod tests { use super::*; - /// The declaration `irl_source_create` passed to `proc_handler_add` - /// (`src/irl-source.c`), with `out int video_decoder_flushes` removed — - /// that stat was always zero and is not ported — and `out int - /// video_delay_ms`, `out int av_skew_ms` and `out int audio_hold_ms` added - /// for the port's standing video delay, the sender's audio/video timestamp - /// skew and the audio hold that covers it. - const C_DECLARATION: &str = "void get_stats(out int buffer_fill_ms, \ -out float current_speed, out bool adaptive_latency_control, \ -out bool reconnecting, \ + /// What scripts and obs-websocket clients bind against. + const DECLARATION: &str = "void get_stats(out int buffer_fill_ms, \ +out float current_speed, \ out int total_audio_frames, out int total_video_frames, \ -out int pts_repairs, out int pts_normalizations, \ -out int pts_interpolations, out int pts_resets, \ -out int pts_last_gap_ms, out int pts_max_gap_ms, \ -out int silence_insertions, out int audio_underruns, \ -out int audio_resync_skipped_chunks, \ -out int audio_hidden_trimmed_chunks, \ -out int audio_quality_events, \ -out int audio_output_restarts, out int obs_lead_ms, \ -out int audio_decoder_flushes, \ -out int video_corrupt_frames, out int video_corrupt_held, \ -out int video_lead_ms, out int video_lead_excess, \ -out int video_delay_ms, out int av_skew_ms, out int audio_hold_ms, out int stream_delay_ms, out bool low_latency_audio, \ -out int reconnect_count)"; - - #[test] - fn there_are_thirty_fields() { - assert_eq!(FIELDS.len(), 30); - // video_decoder_flushes was removed (it was always 0 in C). - assert!( - !FIELDS - .iter() - .any(|(name, _)| *name == "video_decoder_flushes") - ); - } +out int pts_max_gap_ms, out int audio_underruns, \ +out int audio_output_restarts, \ +out int video_delay_ms, out int av_skew_ms, out int audio_hold_ms, \ +out bool reconnecting)"; #[test] fn field_names_are_unique() { @@ -250,8 +148,8 @@ out int reconnect_count)"; } #[test] - fn declaration_matches_the_c_literal() { - assert_eq!(proc_declaration(), C_DECLARATION); + fn declaration_matches_the_published_signature() { + assert_eq!(proc_declaration(), DECLARATION); } #[test] @@ -261,34 +159,15 @@ out int reconnect_count)"; let snap = StatsSnapshot { buffer_fill_ms: 1, current_speed: 1.05, - adaptive_latency_control: true, - reconnecting: true, total_audio_frames: 2, total_video_frames: 3, - pts_repairs: 4, - pts_normalizations: 5, - pts_interpolations: 6, - pts_resets: 7, - pts_last_gap_ms: 8, - pts_max_gap_ms: 9, - silence_insertions: 10, - audio_underruns: 11, - audio_resync_skipped_chunks: 12, - audio_hidden_trimmed_chunks: 13, - audio_quality_events: 14, - audio_output_restarts: 15, - obs_lead_ms: 16, - audio_decoder_flushes: 17, - video_corrupt_frames: 18, - video_corrupt_held: 19, - video_lead_ms: 20, - video_lead_excess: 21, - video_delay_ms: 24, - av_skew_ms: 25, - audio_hold_ms: 26, - stream_delay_ms: 22, - low_latency_audio: true, - reconnect_count: 23, + pts_max_gap_ms: 4, + audio_underruns: 5, + audio_output_restarts: 6, + video_delay_ms: 7, + av_skew_ms: 8, + audio_hold_ms: 9, + reconnecting: true, }; let values = snap.values(); @@ -320,16 +199,13 @@ out int reconnect_count)"; assert!(!ints.contains(&0), "a field was not written"); // ... and the non-integer fields are what the snapshot holds. - assert_eq!(values[1], StatValue::Float(1.05)); - assert_eq!(values[2], StatValue::Bool(true)); - assert_eq!(values[3], StatValue::Bool(true)); - assert_eq!(values[28], StatValue::Bool(true)); + assert_eq!(snap.get("current_speed"), Some(StatValue::Float(1.05))); + assert_eq!(snap.get("reconnecting"), Some(StatValue::Bool(true))); // Spot-check the by-name accessor against the same snapshot. assert_eq!(snap.get("buffer_fill_ms"), Some(StatValue::Int(1))); - assert_eq!(snap.get("reconnect_count"), Some(StatValue::Int(23))); - assert_eq!(snap.get("low_latency_audio"), Some(StatValue::Bool(true))); - assert_eq!(snap.get("video_decoder_flushes"), None); + assert_eq!(snap.get("audio_hold_ms"), Some(StatValue::Int(9))); + assert_eq!(snap.get("reconnect_count"), None); } #[test] diff --git a/crates/irl-core/src/video_time.rs b/crates/irl-core/src/video_time.rs index dda407b..3ce34e3 100644 --- a/crates/irl-core/src/video_time.rs +++ b/crates/irl-core/src/video_time.rs @@ -2,9 +2,8 @@ //! //! The arithmetic of `irl_video_due_time` and `video_record_lead`, extracted //! from the locking and logging around it: what those C functions do beyond -//! this is snapshot audio-thread state under `audio_state_lock`, publish the -//! lead stats and throttle a warning line, all of which belongs to the plugin -//! crate. +//! this is snapshot audio-thread state under `audio_state_lock` and throttle a +//! warning line, both of which belong to the plugin crate. use crate::consts; diff --git a/crates/irl-source/src/audio/mod.rs b/crates/irl-source/src/audio/mod.rs index 03096fa..1fbdaad 100644 --- a/crates/irl-source/src/audio/mod.rs +++ b/crates/irl-source/src/audio/mod.rs @@ -71,9 +71,8 @@ pub fn output_claim(state: &mut AudioState, frames: u32, rate: u32) -> u64 { } /// `irl_reset_audio_timing_state`: output clock, playout mapping, fades and -/// concealment back to the not-yet-primed state, including the per-chunk -/// stats mirrored in `ConnStats`. Caller holds `audio_state`. -pub fn reset_audio_timing_state(shared: &Shared, state: &mut AudioState) { +/// concealment back to the not-yet-primed state. Caller holds `audio_state`. +pub fn reset_audio_timing_state(state: &mut AudioState) { state.primed = false; state.anchor_ns = 0; state.samples = 0; @@ -94,12 +93,6 @@ pub fn reset_audio_timing_state(shared: &Shared, state: &mut AudioState) { state.startup_warmup_remaining_ms = 0; state.drain = irl_core::DrainWatch::default(); - // The output-side stats the C's audio reset zeroed, now in `ConnStats`. - shared.conn.last_obs_lead_ns.store(0, Relaxed); - shared.conn.last_chunk_stream_ns.store(0, Relaxed); - shared.conn.last_chunk_obs_ns.store(0, Relaxed); - shared.conn.last_frames_out.store(0, Relaxed); - shared.conn.last_samples_per_sec.store(0, Relaxed); // The receiver-thread half of the C function (decode-error counters, // last-sample memory) lives in `ReceiverFlags` / `AudioIntake`, cleared // by their owners at the same call sites. @@ -109,7 +102,7 @@ pub fn reset_audio_timing_state(shared: &Shared, state: &mut AudioState) { /// mirrors in `ConnStats` and the stream PTS trackers. Caller holds /// `audio_state`. pub fn reset_stream_timing_state(shared: &Shared, state: &mut AudioState) { - reset_audio_timing_state(shared, state); + reset_audio_timing_state(state); // The trim is a property of the sender, so it deliberately survives the // audio-only reset above (a throttled decoder flush must not cost two @@ -123,17 +116,10 @@ pub fn reset_stream_timing_state(shared: &Shared, state: &mut AudioState) { // release window takes it back if the sender no longer needs it. hold::forget_readings(state); - state.latest_video_stream_pts_ns = 0; - - // State, not counters: the interval has to be re-measured for the new - // stream, and a stale lead would be reported until the first frame - // arrives. video_lead_excess is cumulative for the source, like the other - // quality counters. + // The fallback clock re-anchors, and the frame interval is re-measured + // for the new stream. shared.conn.video_ts_init.store(false, Relaxed); - shared.conn.video_sys_base.store(0, Relaxed); - shared.conn.video_pts_base.store(0, Relaxed); shared.conn.video_frame_interval_ns.store(0, Relaxed); - shared.conn.video_lead_ns.store(0, Relaxed); // Every C call site set `current_speed = 1.0f` immediately after this // call (`irl-source.c:234-235`, `receiver-stream.c:671-678`); the diff --git a/crates/irl-source/src/audio/pump.rs b/crates/irl-source/src/audio/pump.rs index 7d7e36b..c5e8e10 100644 --- a/crates/irl-source/src/audio/pump.rs +++ b/crates/irl-source/src/audio/pump.rs @@ -217,7 +217,6 @@ impl AudioPump { return false; } shared.conn.audio_output_restarts.fetch_add(1, Relaxed); - shared.conn.audio_quality_events.fetch_add(1, Relaxed); irl_warn!( "Audio output stalled {}ms; restarting output clock", (now - next_ts) / 1_000_000 @@ -277,7 +276,6 @@ impl AudioPump { irl_info!("Audio underrun: concealing with silence"); } shared.conn.audio_underruns.fetch_add(1, Relaxed); - shared.conn.audio_quality_events.fetch_add(1, Relaxed); super::mark_audio_recovery(state, now_us, consts::AUDIO_RECOVERY_HOLD_US); state.conceal_fade_pending = true; return self.emit_concealment_silence(shared, state, base_samples, &fmt); @@ -299,9 +297,8 @@ impl AudioPump { if trimmed > 0 { shared .conn - .audio_resync_skipped_chunks + .audible_skipped_chunks .fetch_add(trimmed as u64, Relaxed); - shared.conn.audio_quality_events.fetch_add(1, Relaxed); fill_ms = post.map_or(0, |s| s.fill_ms); } } @@ -460,7 +457,6 @@ Video stays in sync with it; check the sender's frame rate and clock", fmt.rate as u32, chunk_pts_ns, stream_duration_ns, - (self.now_ns)(), ); if first_mapping { // Video holds its first frame until this mapping exists and then @@ -515,7 +511,6 @@ Video stays in sync with it; check the sender's frame rate and clock", fmt.rate as u32, state.latest_buffered_end_pts_ns, 0, - (self.now_ns)(), ); true } @@ -607,8 +602,8 @@ impl BufferFormat { /// wall clock moves, and the stall check reads that as a stalled audio thread — /// which it is not. Restarting it there re-anchors, waits one lead and trips /// again, so a silent input produced a "restarting output clock" warning every -/// ~150ms for as long as it stayed silent, with `audio_output_restarts` and -/// `audio_quality_events` climbing on a source that was merely quiet. +/// ~150ms for as long as it stayed silent, with `audio_output_restarts` +/// climbing on a source that was merely quiet. /// /// Drop the stale mapping instead and let the normal prime path establish one /// new clock when a real chunk arrives. Counted as an underrun, which is what @@ -622,10 +617,8 @@ fn suspend_low_latency_clock(shared: &Shared, state: &mut AudioState, lag_ns: u6 state.latest_buffered_end_pts_ns = 0; state.offset_baseline_set = false; state.conceal_fade_pending = true; - shared.conn.last_obs_lead_ns.store(0, Relaxed); shared.conn.audio_underruns.fetch_add(1, Relaxed); - shared.conn.audio_quality_events.fetch_add(1, Relaxed); super::mark_audio_recovery(state, now_us, consts::AUDIO_RECOVERY_HOLD_US); irl_warn!( "Low-latency audio input empty for {}ms; suspending output clock until audio resumes", @@ -707,7 +700,6 @@ fn maybe_reanchor_offset( state.conceal_fade_pending = true; shared.lifetime.audio_offset_reanchors.fetch_add(1, Relaxed); - shared.conn.audio_quality_events.fetch_add(1, Relaxed); irl_warn!( "Audio latency drifted +{}ms past baseline (>{}ms) with buffer at/below target; re-anchoring output clock", excess_ns / 1_000_000, @@ -777,14 +769,6 @@ fn maybe_trim_hidden_backlog( return false; } - shared - .conn - .audio_resync_skipped_chunks - .fetch_add(trimmed as u64, Relaxed); - shared - .conn - .audio_hidden_trimmed_chunks - .fetch_add(trimmed as u64, Relaxed); irl_info!( "Audio trim: dropped {} hidden buffered chunk{} before playback (fill={}ms target={}ms)", trimmed, @@ -795,9 +779,7 @@ fn maybe_trim_hidden_backlog( true } -/// `finalize_audio_output`: publish the playout mapping and the per-chunk -/// stats the receiver's stats line and `get_stats` read. -#[allow(clippy::too_many_arguments)] +/// `finalize_audio_output`: publish the playout mapping and count the chunk. fn finalize_audio_output( shared: &Shared, state: &mut AudioState, @@ -806,39 +788,13 @@ fn finalize_audio_output( samples_per_sec: u32, chunk_pts_ns: i64, stream_duration_ns: u64, - after_output: u64, ) { state.latest_buffered_end_pts_ns = chunk_pts_ns + stream_duration_ns as i64; - - if samples_per_sec > 0 { - let audio_duration_ns = frames as u64 * 1_000_000_000 / samples_per_sec as u64; - state.latest_obs_end_ts_ns = timestamp + audio_duration_ns; - shared - .conn - .last_chunk_obs_ns - .store(audio_duration_ns, Relaxed); - } else { - state.latest_obs_end_ts_ns = timestamp; - shared.conn.last_chunk_obs_ns.store(0, Relaxed); - } - - shared - .conn - .last_chunk_stream_ns - .store(stream_duration_ns, Relaxed); - shared.conn.last_frames_out.store(frames, Relaxed); - shared - .conn - .last_samples_per_sec - .store(samples_per_sec, Relaxed); - - let lead = if state.latest_obs_end_ts_ns > after_output { - (state.latest_obs_end_ts_ns - after_output) as i64 + state.latest_obs_end_ts_ns = if samples_per_sec > 0 { + timestamp + frames as u64 * 1_000_000_000 / samples_per_sec as u64 } else { - 0 + timestamp }; - shared.conn.last_obs_lead_ns.store(lead, Relaxed); - shared.conn.total_audio_frames.fetch_add(1, Relaxed); } diff --git a/crates/irl-source/src/receiver/audio_in.rs b/crates/irl-source/src/receiver/audio_in.rs index 32ae26a..7e2f750 100644 --- a/crates/irl-source/src/receiver/audio_in.rs +++ b/crates/irl-source/src/receiver/audio_in.rs @@ -65,7 +65,6 @@ impl AudioIntake { let Some((verdict, pts_tb, duration)) = self.evaluate_pts(frame, out_rate, tb) else { return; }; - let mut inserted_silence = false; // ── Startup warm-up ── let frame_ms = audio_frame_duration_ms(frame.nb_samples(), out_rate); @@ -81,13 +80,11 @@ impl AudioIntake { } // ── PTS repair dispatch ── + let mut inserted_silence = false; match verdict.action { PtsAction::Silence if verdict.silence_ms > 0 => { - if self.insert_silence(shared, verdict.corrected_pts, verdict.silence_ms, pts_tb) { - shared.conn.silence_insertions.fetch_add(1, Relaxed); - shared.conn.audio_quality_events.fetch_add(1, Relaxed); - inserted_silence = true; - } + inserted_silence = + self.insert_silence(shared, verdict.corrected_pts, verdict.silence_ms, pts_tb); } PtsAction::Reset => { let mut state = shared.audio_state(); @@ -100,7 +97,6 @@ impl AudioIntake { ffmpeg::gettime_us() as u64, consts::AUDIO_RECOVERY_HOLD_US, ); - shared.conn.audio_quality_events.fetch_add(1, Relaxed); drop(state); // The video half of `irl_reset_stream_timing_state`. Video // decode runs on its own thread now, so this is a request @@ -115,23 +111,10 @@ impl AudioIntake { } if verdict.action != PtsAction::Pass { - let gap_ms = verdict.gap_ms; - shared.conn.pts_last_gap_ms.store(gap_ms, Relaxed); - shared.conn.pts_max_gap_ms.fetch_max(gap_ms, Relaxed); - - let frame_sized_normalization = - verdict.action == PtsAction::Interpolate && frame_ms > 0 && gap_ms <= frame_ms + 2; - if frame_sized_normalization { - shared.conn.pts_normalizations.fetch_add(1, Relaxed); - } else { - shared.conn.pts_repairs.fetch_add(1, Relaxed); - if verdict.action == PtsAction::Interpolate { - shared.conn.pts_interpolations.fetch_add(1, Relaxed); - } - } - if verdict.action == PtsAction::Reset { - shared.conn.pts_resets.fetch_add(1, Relaxed); - } + shared + .conn + .pts_max_gap_ms + .fetch_max(verdict.gap_ms, Relaxed); } // ── Convert to interleaved float ── diff --git a/crates/irl-source/src/receiver/decode.rs b/crates/irl-source/src/receiver/decode.rs index fa21b59..1d0bfb0 100644 --- a/crates/irl-source/src/receiver/decode.rs +++ b/crates/irl-source/src/receiver/decode.rs @@ -15,7 +15,6 @@ use crate::shared::{Shared, TimedPacket}; /// the decoder unless a flush is cooling down; returns when it flushed. fn audio_error_burst( dec: &mut CodecContext, - shared: &Shared, flags: &mut ReceiverFlags, stage: &str, flushing: &str, @@ -47,8 +46,6 @@ fn audio_error_burst( return None; } dec.flush(); - shared.conn.audio_decoder_flushes.fetch_add(1, Relaxed); - shared.conn.audio_quality_events.fetch_add(1, Relaxed); Some(now_us) } @@ -67,7 +64,6 @@ fn drain_audio_frames( Err(_) => { if let Some(now_us) = audio_error_burst( dec, - shared, flags, " receive", ", resetting audio state", @@ -78,7 +74,7 @@ fn drain_audio_frames( if let Some(buf) = shared.audio_buf().as_mut() { buf.flush(); } - audio::reset_audio_timing_state(shared, &mut state); + audio::reset_audio_timing_state(&mut state); audio::mark_audio_recovery( &mut state, now_us, @@ -143,14 +139,7 @@ impl Receiver { match &result { Err(err) if !err.is_eagain() && !err.is_eof() => { - audio_error_burst( - dec, - shared, - flags, - "", - ", flushing", - ", suppressing repeated flush", - ); + audio_error_burst(dec, flags, "", ", flushing", ", suppressing repeated flush"); } _ => flags.audio_decode_errors = 0, } diff --git a/crates/irl-source/src/receiver/stream.rs b/crates/irl-source/src/receiver/stream.rs index 0e85ea7..d15a0ee 100644 --- a/crates/irl-source/src/receiver/stream.rs +++ b/crates/irl-source/src/receiver/stream.rs @@ -439,7 +439,6 @@ impl Receiver { /// `irl_wait_for_reconnect`. Returns whether the run is still active. pub(super) fn wait_for_reconnect(&mut self) -> bool { self.shared.flags.reconnecting.store(true, Relaxed); - self.shared.lifetime.reconnect_count.fetch_add(1, Relaxed); // Sampled once: a delay edited mid-wait should apply to the next // attempt, not stretch or truncate the one already counting down. let delay_s = self.shared.hot.reconnect_delay_s.load(Relaxed); @@ -492,7 +491,6 @@ impl Receiver { consts::AUDIO_RESET_RECOVERY_HOLD_US, ); state.fade_in_pending = true; - shared.conn.video_corrupt_frames.store(0, Relaxed); shared.conn.video_corrupt_held.store(0, Relaxed); } @@ -500,17 +498,8 @@ impl Receiver { conn.set_current_speed(1.0); conn.audio_output_restarts.store(0, Relaxed); conn.audio_underruns.store(0, Relaxed); - conn.audio_resync_skipped_chunks.store(0, Relaxed); - conn.audio_hidden_trimmed_chunks.store(0, Relaxed); - conn.audio_quality_events.store(0, Relaxed); - conn.audio_decoder_flushes.store(0, Relaxed); - conn.pts_repairs.store(0, Relaxed); - conn.pts_normalizations.store(0, Relaxed); - conn.pts_interpolations.store(0, Relaxed); - conn.pts_resets.store(0, Relaxed); - conn.pts_last_gap_ms.store(0, Relaxed); + conn.audible_skipped_chunks.store(0, Relaxed); conn.pts_max_gap_ms.store(0, Relaxed); - conn.silence_insertions.store(0, Relaxed); conn.total_audio_frames.store(0, Relaxed); conn.total_video_frames.store(0, Relaxed); self.last_stats_time = 0; @@ -558,62 +547,40 @@ impl Receiver { let buffer_fill_ms = self.audio_fill_ms(); irl_info!( - "Stats: video={} audio={} \ - buf={}ms peak={}ms target={}ms speed={:.3} ctrl={} pts_repairs={} \ - norm={} interp={} silence={} resets={} \ - last_gap={}ms max_gap={}ms underruns={} resync_skips={} \ - hidden_trims={} quality_events={} \ - audio_flushes={} corrupt={} held={} vq_drops={} \ - obs_lead={}ms chunk={}@{} \ - stream_chunk={}ms obs_chunk={}ms \ - restarts={} av_drift={}ms av_skew={}ms hold={}ms reanchors={} \ - vlead={}ms peak={}ms excess={} vdelay={}ms vfps={:.1} \ - pktq={}/{}({}KB,{}ms) paced={}/{}({}MB) early={} eagain={}/{} pktdrop={}/{} res={}x{}", + "Stats: video={} audio={} buf={}ms peak={}ms target={}ms speed={:.3} ll={}{} \ + max_gap={}ms underruns={} restarts={} av_drift={}ms av_skew={}ms hold={}ms \ + vdelay={}ms vfps={:.1} vq_drops={} \ + pktq={}/{}({}KB,{}ms) paced={}/{}({}MB) eagain={}/{} pktdrop={}/{} res={}x{}", conn.total_video_frames.load(Relaxed), conn.total_audio_frames.load(Relaxed), buffer_fill_ms, lifetime.audio_fill_peak_ms.load(Relaxed), shared.hot.watermarks().target_ms, f64::from(conn.current_speed()), - if shared.hot.adaptive_speed.load(Relaxed) { + if shared.cfg.low_latency_audio { "on" } else { "off" }, - conn.pts_repairs.load(Relaxed), - conn.pts_normalizations.load(Relaxed), - conn.pts_interpolations.load(Relaxed), - conn.silence_insertions.load(Relaxed), - conn.pts_resets.load(Relaxed), - conn.pts_last_gap_ms.load(Relaxed), + // Only the unusual setting is spelled out. + if shared.hot.adaptive_speed.load(Relaxed) { + "" + } else { + " ctrl=off" + }, conn.pts_max_gap_ms.load(Relaxed), conn.audio_underruns.load(Relaxed), - conn.audio_resync_skipped_chunks.load(Relaxed), - conn.audio_hidden_trimmed_chunks.load(Relaxed), - conn.audio_quality_events.load(Relaxed), - conn.audio_decoder_flushes.load(Relaxed), - conn.video_corrupt_frames.load(Relaxed), - conn.video_corrupt_held.load(Relaxed), - lifetime.video_queue_drops.load(Relaxed), - conn.last_obs_lead_ns.load(Relaxed) / 1_000_000, - conn.last_frames_out.load(Relaxed), - conn.last_samples_per_sec.load(Relaxed), - conn.last_chunk_stream_ns.load(Relaxed) / 1_000_000, - conn.last_chunk_obs_ns.load(Relaxed) / 1_000_000, conn.audio_output_restarts.load(Relaxed), av_drift_ms, av_skew_ms, conn.audio_hold_ms.load(Relaxed), - lifetime.audio_offset_reanchors.load(Relaxed), - conn.video_lead_ns.load(Relaxed) / 1_000_000, - lifetime.video_lead_peak_ns.load(Relaxed) / 1_000_000, - lifetime.video_lead_excess.load(Relaxed), conn.video_delay_ns.load(Relaxed) / 1_000_000, if video_frame_interval_ns > 0 { 1_000_000_000.0 / video_frame_interval_ns as f64 } else { 0.0 }, + lifetime.video_queue_drops.load(Relaxed), // The compressed video queue: where the stream's latency is // actually held. Its duration should track the Target Buffer, and // its size is what a deep buffer costs at this bitrate — the @@ -626,7 +593,6 @@ impl Receiver { lifetime.pacing_now.load(Relaxed), lifetime.pacing_peak.load(Relaxed), lifetime.pacing_bytes.load(Relaxed) / (1024 * 1024), - lifetime.pacing_overflows.load(Relaxed), lifetime.video_pkt_eagain.load(Relaxed), lifetime.audio_pkt_eagain.load(Relaxed), lifetime.video_pkt_dropped.load(Relaxed), diff --git a/crates/irl-source/src/shared.rs b/crates/irl-source/src/shared.rs index d2ac9a4..6c699b8 100644 --- a/crates/irl-source/src/shared.rs +++ b/crates/irl-source/src/shared.rs @@ -137,9 +137,8 @@ pub struct AudioState { /// Decoded samples per frame (AAC 1024, Opus 960): the output chunk size. pub decoded_frame_samples: i32, - // Stream PTS mirrors (ns). + /// Stream PTS of the newest decoded audio (ns). pub latest_audio_stream_pts_ns: i64, - pub latest_video_stream_pts_ns: i64, /// Underrun recovery hold (FFmpeg µs domain). pub recovery_until_us: u64, @@ -199,29 +198,18 @@ impl AudioState { pub struct ConnStats { pub total_audio_frames: AtomicU64, pub total_video_frames: AtomicU64, - pub pts_repairs: AtomicU64, - pub pts_normalizations: AtomicU64, - pub pts_interpolations: AtomicU64, - pub pts_resets: AtomicU64, - pub pts_last_gap_ms: AtomicI32, pub pts_max_gap_ms: AtomicI32, - pub silence_insertions: AtomicU64, pub audio_underruns: AtomicU64, - pub audio_resync_skipped_chunks: AtomicU64, - pub audio_hidden_trimmed_chunks: AtomicU64, - pub audio_quality_events: AtomicU64, pub audio_output_restarts: AtomicU64, - pub audio_decoder_flushes: AtomicU64, - pub video_corrupt_frames: AtomicU64, + /// Chunks dropped from the jitter buffer after playback primed, which only + /// the low-latency backlog cap does. Not a stat: it is how the network + /// simulation holds buffered mode to "audible audio is never skipped". + pub audible_skipped_chunks: AtomicU64, + /// HEVC frames held back for a missing reference, for the line that + /// reports the hold ending. pub video_corrupt_held: AtomicU64, /// `f32::to_bits` of the smoothed playback speed. pub current_speed_bits: AtomicU32, - pub last_obs_lead_ns: AtomicI64, - pub last_chunk_stream_ns: AtomicU64, - pub last_chunk_obs_ns: AtomicU64, - pub last_frames_out: AtomicU32, - pub last_samples_per_sec: AtomicU32, - pub video_lead_ns: AtomicI64, /// Mirror of the video thread's standing delay, for the stats. pub video_delay_ns: AtomicU64, /// Mirror of `AudioState::hold_ms`, for the stats and the video thread. @@ -230,12 +218,11 @@ pub struct ConnStats { /// Taken at arrival: the decoded-frame PTS trails it by however long the /// packet waited for its due time, which is not the sender's doing. pub video_arrival_pts_ns: AtomicI64, - /// EMA of decoded PTS deltas; written by the receiver, read by video. + /// EMA of decoded PTS deltas, for the frame rate the stats line reports. pub video_frame_interval_ns: AtomicI64, - // Mirrors of the video thread's anchors for stats / media_get_state. + /// Mirror of the video thread's fallback anchor, for `media_get_state` + /// and for the receiver to request a re-anchor by clearing it. pub video_ts_init: AtomicBool, - pub video_sys_base: AtomicU64, - pub video_pts_base: AtomicI64, pub last_video_width: AtomicI32, pub last_video_height: AtomicI32, } @@ -255,7 +242,6 @@ impl ConnStats { /// `reset_runtime_state()` left alone. #[derive(Default)] pub struct LifetimeStats { - pub reconnect_count: AtomicU64, pub video_queue_drops: AtomicU64, /// Peak packets queued for decode. Was the peak count of *decoded* frames /// pinning hardware surfaces, which no longer exist: the receiver queues @@ -265,10 +251,10 @@ pub struct LifetimeStats { pub pacing_now: AtomicI32, pub pacing_peak: AtomicI32, pub pacing_bytes: AtomicUsize, - pub pacing_overflows: AtomicU64, pub audio_fill_peak_ms: AtomicI32, - pub video_lead_peak_ns: AtomicI64, - pub video_lead_excess: AtomicU64, + /// Declared output-clock re-anchors. Not a stat: the network simulation + /// counts them, with the output restarts, as the only places the OBS + /// clock may jump. pub audio_offset_reanchors: AtomicU64, pub video_pkt_eagain: AtomicU64, pub audio_pkt_eagain: AtomicU64, diff --git a/crates/irl-source/src/source.rs b/crates/irl-source/src/source.rs index fd3e4bf..c78d5ce 100644 --- a/crates/irl-source/src/source.rs +++ b/crates/irl-source/src/source.rs @@ -96,11 +96,10 @@ impl Source for IrlSource { let proc_cb = match CString::new(proc_declaration()) { Ok(decl) => { let state = Arc::clone(&obs_state); - let lifetime = Arc::clone(&lifetime); Some(source.proc_handler().add( &decl, Box::new(move |cd: &mut CallData| { - write_stats(cd, &snapshot(&state.lock(), &lifetime)); + write_stats(cd, &snapshot(&state.lock())); }), )) } @@ -480,15 +479,10 @@ pub(crate) fn av_skew_ms(shared: &Shared, audio: &AudioState) -> i64 { /// Snapshot every stat, consistently: the audio state (and, under it, the /// jitter buffer) is locked once. /// -/// With no run in progress the per-connection counters read zero, while the -/// lifetime counters and the settings-derived flags still report. -fn snapshot(state: &ObsState, lifetime: &LifetimeStats) -> StatsSnapshot { +/// With no run in progress every stat reads zero, and the speed reads 1.0. +fn snapshot(state: &ObsState) -> StatsSnapshot { let mut snap = StatsSnapshot { current_speed: 1.0, - adaptive_latency_control: state.config.hot.adaptive_speed, - low_latency_audio: state.config.stream.low_latency_audio, - video_lead_excess: lifetime.video_lead_excess.load(Relaxed) as i64, - reconnect_count: lifetime.reconnect_count.load(Relaxed) as i64, ..StatsSnapshot::default() }; @@ -508,38 +502,12 @@ fn snapshot(state: &ObsState, lifetime: &LifetimeStats) -> StatsSnapshot { snap.reconnecting = shared.flags.reconnecting.load(Relaxed); snap.total_audio_frames = conn.total_audio_frames.load(Relaxed) as i64; snap.total_video_frames = conn.total_video_frames.load(Relaxed) as i64; - snap.pts_repairs = conn.pts_repairs.load(Relaxed) as i64; - snap.pts_normalizations = conn.pts_normalizations.load(Relaxed) as i64; - snap.pts_interpolations = conn.pts_interpolations.load(Relaxed) as i64; - snap.pts_resets = conn.pts_resets.load(Relaxed) as i64; - snap.pts_last_gap_ms = conn.pts_last_gap_ms.load(Relaxed) as i64; snap.pts_max_gap_ms = conn.pts_max_gap_ms.load(Relaxed) as i64; - snap.silence_insertions = conn.silence_insertions.load(Relaxed) as i64; snap.audio_underruns = conn.audio_underruns.load(Relaxed) as i64; - snap.audio_resync_skipped_chunks = conn.audio_resync_skipped_chunks.load(Relaxed) as i64; - snap.audio_hidden_trimmed_chunks = conn.audio_hidden_trimmed_chunks.load(Relaxed) as i64; - snap.audio_quality_events = conn.audio_quality_events.load(Relaxed) as i64; snap.audio_output_restarts = conn.audio_output_restarts.load(Relaxed) as i64; - snap.obs_lead_ms = conn.last_obs_lead_ns.load(Relaxed) / 1_000_000; - snap.audio_decoder_flushes = conn.audio_decoder_flushes.load(Relaxed) as i64; - snap.video_corrupt_frames = conn.video_corrupt_frames.load(Relaxed) as i64; - snap.video_corrupt_held = conn.video_corrupt_held.load(Relaxed) as i64; - snap.video_lead_ms = conn.video_lead_ns.load(Relaxed) / 1_000_000; snap.video_delay_ms = (conn.video_delay_ns.load(Relaxed) / 1_000_000) as i64; snap.av_skew_ms = av_skew_ms(shared, &audio); snap.audio_hold_ms = i64::from(conn.audio_hold_ms.load(Relaxed)); - - // Stream delay: how far behind real time the video output is, computed as - // wall clock minus the anchored video PTS. Includes SRT latency, decode - // time and any buffering, which is what makes it the number to watch in a - // latency overlay. - if conn.video_ts_init.load(Relaxed) && audio.latest_video_stream_pts_ns != 0 { - let video_wall_ns = conn.video_sys_base.load(Relaxed) as i64 - + (audio.latest_video_stream_pts_ns - conn.video_pts_base.load(Relaxed)); - snap.stream_delay_ms = - ((obs::time::gettime_ns() as i64 - video_wall_ns) / 1_000_000).max(0); - } - snap } diff --git a/crates/irl-source/src/video/intake.rs b/crates/irl-source/src/video/intake.rs index 76107ea..136e1b1 100644 --- a/crates/irl-source/src/video/intake.rs +++ b/crates/irl-source/src/video/intake.rs @@ -127,9 +127,6 @@ pub fn handle_frame( // until the next IDR/CRA. let frame_corrupt = frame.is_corrupt(); let frame_damaged = frame_corrupt || frame.decode_error_flags() != 0; - if frame_damaged { - shared.conn.video_corrupt_frames.fetch_add(1, Relaxed); - } // HEVC has no error concealment: a reference that never arrived is // synthesized as a flat mid-gray picture (hevc/refs.c @@ -193,8 +190,8 @@ pub fn handle_frame( // this thread does not own. let pts_ns = ffmpeg::rescale_q(pts, tb, ffmpeg::NS_TIME_BASE); - // Frame interval EMA, for the estimate of how many frames a given output - // lead parks in the libobs async queue. Measured rather than taken from + // Frame interval EMA, for the frame rate the stats line reports and the + // lead warning's frame budget. Measured rather than taken from // avg_frame_rate, which live SRT/RTMP demuxers routinely leave unset or // wrong. Out-of-range deltas (PTS repair, discontinuities, reordering) are // skipped rather than smoothed in. @@ -202,10 +199,6 @@ pub fn handle_frame( let measurable = state.prev_pts_ns != 0; state.prev_pts_ns = pts_ns; - { - let mut audio = shared.audio_state(); - audio.latest_video_stream_pts_ns = pts_ns; - } if measurable { let prev = shared.conn.video_frame_interval_ns.load(Relaxed); shared diff --git a/crates/irl-source/src/video/output.rs b/crates/irl-source/src/video/output.rs index 8159ace..c6d43c5 100644 --- a/crates/irl-source/src/video/output.rs +++ b/crates/irl-source/src/video/output.rs @@ -221,8 +221,6 @@ impl VideoThread { self.sys_base = now; self.pts_base = pts_ns; self.ts_init = true; - self.shared.conn.video_sys_base.store(now, Relaxed); - self.shared.conn.video_pts_base.store(pts_ns, Relaxed); self.shared.conn.video_ts_init.store(true, Relaxed); } @@ -264,7 +262,7 @@ impl VideoThread { /// already queued. /// /// Deliberately free of the side effects in [`Self::due_time`] — the lead - /// stats, the warning line and the video-only fallback anchor all belong + /// warning and the video-only fallback anchor both belong /// to a frame arriving, and running them again for every queued frame on /// every pacing cycle would report the queue rather than the stream. /// `None` when there is no audio to slave to, or when the mapping has been @@ -287,14 +285,13 @@ impl VideoThread { Some(self.playout_offset_ns + self.delay.delay_ns() as i64) } - /// `video_record_lead` (`video-handler.c:285-327`): how far ahead of wall - /// clock the mapping placed this frame. + /// `video_record_lead` (`video-handler.c:285-327`): warn when the mapping + /// placed this frame further ahead of wall clock than libobs can queue. /// - /// The lead is recorded, never clamped. What libobs queues is the lead's - /// *growth* since its play head last anchored, not its size, so a large - /// but steady lead queues nothing and clamping it would only shift video - /// ahead of audio. The measurement stays because it is the signal for - /// whether pacing is doing its job. + /// The lead is never clamped. What libobs queues is the lead's *growth* + /// since its play head last anchored, not its size, so a large but steady + /// lead queues nothing and clamping it would only shift video ahead of + /// audio. pub fn record_lead(&mut self, ts: i64, now: u64, frame_interval_ns: i64) { let lead_ns = ts - now as i64; let frame_interval_ns = if frame_interval_ns <= 0 { @@ -304,19 +301,9 @@ impl VideoThread { }; let queue_safe_ns = self.shared.hot.watermarks().target_ms as i64 * 1_000_000 + video_time::queue_safe_ns(frame_interval_ns); - - self.shared.conn.video_lead_ns.store(lead_ns, Relaxed); - // Keep the high-water mark too: stats are sampled every 30 s, and an - // excursion that drains in ~17 s is very likely to fall between two - // samples. - self.shared - .lifetime - .video_lead_peak_ns - .fetch_max(lead_ns, Relaxed); if lead_ns <= queue_safe_ns { return; } - self.shared.lifetime.video_lead_excess.fetch_add(1, Relaxed); // Only a risk while the lead is still climbing — a steady lead of any // size is free — so this is a "watch this" line, not a fault. diff --git a/crates/irl-source/src/video/thread.rs b/crates/irl-source/src/video/thread.rs index 38bd51b..a9f8871 100644 --- a/crates/irl-source/src/video/thread.rs +++ b/crates/irl-source/src/video/thread.rs @@ -744,9 +744,6 @@ impl VideoThread { .pacing_peak .store(self.pacing.peak() as i32, Relaxed); lifetime.pacing_bytes.store(self.pacing.bytes(), Relaxed); - lifetime - .pacing_overflows - .store(self.pacing.overflows(), Relaxed); } /// How long to sleep: until the head frame is due, capped at diff --git a/crates/irl-source/tests/audio_core.rs b/crates/irl-source/tests/audio_core.rs index f5aa09f..12ced4e 100644 --- a/crates/irl-source/tests/audio_core.rs +++ b/crates/irl-source/tests/audio_core.rs @@ -623,8 +623,6 @@ fn intake_discards_warmup_then_buffers_decoded_audio() { state.latest_audio_stream_pts_ns, (frames - 1) * CHUNK_NS as i64 ); - assert_eq!(shared.conn.pts_repairs.load(Relaxed), 0); - assert_eq!(shared.conn.silence_insertions.load(Relaxed), 0); let buf = shared.audio_buf(); let buf = buf.as_ref().unwrap(); diff --git a/crates/irl-source/tests/network_sim.rs b/crates/irl-source/tests/network_sim.rs index 413844c..2bea71e 100644 --- a/crates/irl-source/tests/network_sim.rs +++ b/crates/irl-source/tests/network_sim.rs @@ -349,13 +349,10 @@ impl Sim { /// first batch and hits that path, which is correct. So the invariant is /// not "nothing was skipped" but "everything skipped was inaudible". fn assert_no_audible_audio_dropped(&self) { - let skipped = self.shared.conn.audio_resync_skipped_chunks.load(Relaxed); - let hidden = self.shared.conn.audio_hidden_trimmed_chunks.load(Relaxed); + let skipped = self.shared.conn.audible_skipped_chunks.load(Relaxed); assert_eq!( - skipped, - hidden, - "{} audible chunks were skipped rather than played faster", - skipped - hidden + skipped, 0, + "{skipped} audible chunks were skipped rather than played faster" ); } diff --git a/crates/irl-source/tests/shell_stats.rs b/crates/irl-source/tests/shell_stats.rs index 633d2dd..ce489ed 100644 --- a/crates/irl-source/tests/shell_stats.rs +++ b/crates/irl-source/tests/shell_stats.rs @@ -16,34 +16,15 @@ fn distinct_snapshot() -> StatsSnapshot { StatsSnapshot { buffer_fill_ms: 101, current_speed: 1.05, - adaptive_latency_control: true, - reconnecting: true, total_audio_frames: 102, total_video_frames: 103, - pts_repairs: 104, - pts_normalizations: 105, - pts_interpolations: 106, - pts_resets: 107, - pts_last_gap_ms: 108, - pts_max_gap_ms: 109, - silence_insertions: 110, - audio_underruns: 111, - audio_resync_skipped_chunks: 112, - audio_hidden_trimmed_chunks: 113, - audio_quality_events: 114, - audio_output_restarts: 115, - obs_lead_ms: 116, - audio_decoder_flushes: 117, - video_corrupt_frames: 118, - video_corrupt_held: 119, - video_lead_ms: 120, - video_lead_excess: 121, - video_delay_ms: 124, - av_skew_ms: 125, - audio_hold_ms: 126, - stream_delay_ms: 122, - low_latency_audio: true, - reconnect_count: 123, + pts_max_gap_ms: 104, + audio_underruns: 105, + audio_output_restarts: 106, + video_delay_ms: 107, + av_skew_ms: 108, + audio_hold_ms: 109, + reconnecting: true, } } @@ -101,6 +82,6 @@ fn the_declaration_names_exactly_what_is_written() { "{name} is not in the declaration" ); } - // The dropped stat is gone from every surface, not just the table. - assert!(!decl.contains("video_decoder_flushes")); + // A dropped stat is gone from every surface, not just the table. + assert!(!decl.contains("reconnect_count")); } diff --git a/crates/irl-source/tests/video_pipeline.rs b/crates/irl-source/tests/video_pipeline.rs index fbe3878..a96e544 100644 --- a/crates/irl-source/tests/video_pipeline.rs +++ b/crates/irl-source/tests/video_pipeline.rs @@ -412,7 +412,6 @@ fn hevc_frames_from_a_missing_reference_are_held_back() { "the damaged frame is not paced" ); assert_eq!(shared.conn.video_corrupt_held.load(Relaxed), 1); - assert_eq!(shared.conn.video_corrupt_frames.load(Relaxed), 1); assert!(state.hold_logged); // H.264 damage passes through instead, to preserve cadence. @@ -707,11 +706,10 @@ fn clearing_the_ts_init_mirror_re_anchors_the_fallback_clock() { "re-anchored {re_anchored} should sit at ~now, before the capped {capped}" ); assert!(shared.conn.video_ts_init.load(Relaxed)); - assert_eq!(shared.conn.video_pts_base.load(Relaxed), 10_000_000_000); } #[test] -fn the_lead_stats_follow_the_mapping() { +fn a_frame_is_due_where_the_audio_mapping_puts_it() { let shared = shared(); let (mut thread, _recorder) = thread_with(shared.clone()); @@ -727,12 +725,11 @@ fn the_lead_stats_follow_the_mapping() { frame.set_pts(0); let due = thread.due_time(&frame); - assert!(due >= now + 1_900_000_000); - let lead = shared.conn.video_lead_ns.load(Relaxed); - assert!(lead > 1_900_000_000, "lead {lead}"); - assert_eq!(shared.lifetime.video_lead_peak_ns.load(Relaxed), lead); - // 2 s is past 120 ms of target buffer plus the 400 ms floor. - assert_eq!(shared.lifetime.video_lead_excess.load(Relaxed), 1); + assert!( + due >= now + 1_900_000_000, + "due {}ms ahead", + (due as i64 - now as i64) / 1_000_000 + ); } #[test] diff --git a/docs/audio-pipeline.md b/docs/audio-pipeline.md index 626320f..6e8c13d 100644 --- a/docs/audio-pipeline.md +++ b/docs/audio-pipeline.md @@ -101,7 +101,7 @@ The PTS repair system classifies gaps into three tiers: The thresholds are fixed constants (`IRL_SMALL_GAP_MS` 70ms, `IRL_LARGE_GAP_MS` 2000ms). They were once exposed as Small Gap and Large Gap settings, but nobody could reason about them without reading the source. Each threshold marks a real boundary: below 70ms is decoder timestamp wobble, above 2s the stream has changed underneath the plugin. -`pts_repairs` tracks non-normal PTS discontinuities. For tuning, use the split diagnostics: `pts_normalizations`, `pts_interpolations`, `silence_insertions`, `pts_resets`, `pts_last_gap_ms`, and `pts_max_gap_ms`. A high normalization count with low silence usually means frame-sized timestamp cadence smoothing, not packet-loss concealment. +`pts_max_gap_ms` (`max_gap=` in the stats line) is the largest gap repaired on the current connection, which says which tier the worst discontinuity fell into. ### 4. Fade in/out (eliminates clicks on disconnect) diff --git a/docs/viewer-quality-plan.md b/docs/viewer-quality-plan.md index 9206ba4..8c9f335 100644 --- a/docs/viewer-quality-plan.md +++ b/docs/viewer-quality-plan.md @@ -18,12 +18,12 @@ and video cadence freezes. ## Recovery is observable -PTS repair telemetry is split into separate counters (normalization, -interpolation, silence, reset, last gap, max gap) so each recovery mechanism -can be tuned independently. The aggregate `pts_repairs` counter is kept for -script compatibility. The periodic stats log line reports buffer fill, -underruns, trims, OBS lead, timing state, and the split PTS counters together, -so a single log line describes the health of the whole path. +The periodic stats log line reports buffer fill, speed, underruns, output +restarts, the largest PTS gap, the A/V skew and the delay and hold that cover +it, and the video queues together, so a single log line describes the health +of the whole path. Underruns, startup trims, output clock restarts and +re-anchors, and decoder corruption bursts also log a line each when they +happen. ## Audio behavior @@ -52,7 +52,8 @@ so a single log line describes the health of the whole path. - HEVC frames predicted from a missing reference are held back. HEVC has no concealment; FFmpeg synthesizes the missing reference as flat gray, so the choice is between a gray GOP and a freeze on the last good frame, and the - freeze wins. Resumes at the next keyframe; `video_corrupt_held` counts them. + freeze wins. Resumes at the next keyframe, and the line that says so + reports how many frames were held. - The video decoder is never flushed. A flush clears the reference buffer and the decoder's recovery state, which is a guaranteed gray GOP on both codecs; repeated errors are counted and logged instead. @@ -63,21 +64,16 @@ so a single log line describes the health of the whole path. When validating against live lossy SRT logs: -- `silence_insertions` should rise only on real medium audio gaps or underruns. -- `pts_normalizations` can be high without audible artifacts (frame-sized - cadence smoothing). -- `pts_interpolations` should be read together with gap size and audio quality. -- `pts_resets` should stay rare. +- `max_gap` is the largest audio PTS gap repaired on this connection. Below + 70 ms it was interpolated, up to 2 s it was filled with silence, and beyond + that the timeline was reset. - `speed` stays near 1.000 in buffered mode; any deviation should be smooth and correlated with high/low fill. -- `resync_skips` happens only during low-latency resync or hidden/recovery - backlog cleanup. - `Audio trim` logs are hidden/recovery cleanup only, before old chunks become audible. - Video corruption logs do not imply audio corruption unless audio decoder or PTS diagnostics also show damage. -- `corrupt=` in the stats line (`video_corrupt_frames`) is frames the decoder - flagged as damaged; `held=` (`video_corrupt_held`) is the HEVC subset that - was kept off screen. A `held` count that keeps climbing on an HEVC stream - means references are being lost faster than keyframes arrive: shorten the - encoder's keyframe interval or give SRT more latency to retransmit. +- `HEVC video resumed (N frames held this connection)` reports the HEVC frames + kept off screen. A count that keeps climbing means references are being + lost faster than keyframes arrive: shorten the encoder's keyframe interval or + give SRT more latency to retransmit. diff --git a/irl-stats.lua b/irl-stats.lua index ce1e177..4337206 100644 --- a/irl-stats.lua +++ b/irl-stats.lua @@ -71,28 +71,24 @@ function update_stats() local buf_ms = obs.calldata_int(cd, "buffer_fill_ms") local speed = obs.calldata_float(cd, "current_speed") - local ctrl = obs.calldata_bool(cd, "adaptive_latency_control") local reconnecting = obs.calldata_bool(cd, "reconnecting") local video = obs.calldata_int(cd, "total_video_frames") local audio = obs.calldata_int(cd, "total_audio_frames") - local repairs = obs.calldata_int(cd, "pts_repairs") - local silence = obs.calldata_int(cd, "silence_insertions") + local max_gap = obs.calldata_int(cd, "pts_max_gap_ms") local underruns = obs.calldata_int(cd, "audio_underruns") - local resync_skips = obs.calldata_int(cd, "audio_resync_skipped_chunks") - local hidden_trims = obs.calldata_int(cd, "audio_hidden_trimmed_chunks") - local quality_events = obs.calldata_int(cd, "audio_quality_events") - local audio_flushes = obs.calldata_int(cd, "audio_decoder_flushes") - local delay = obs.calldata_int(cd, "stream_delay_ms") + local restarts = obs.calldata_int(cd, "audio_output_restarts") + local video_delay = obs.calldata_int(cd, "video_delay_ms") + local skew = obs.calldata_int(cd, "av_skew_ms") + local hold = obs.calldata_int(cd, "audio_hold_ms") obs.calldata_destroy(cd) obs.obs_source_release(source) local status = reconnecting and "RECONNECTING" or "LIVE" local text = string.format( - "Status: %s\nDelay: %dms\nBuffer: %dms\nControl: %s\nCorrection: %.3fx\nFrames: %d/%d (v/a)\nPTS Repairs: %d\nAudio Quality: %d events\nSilence/Underruns: %d/%d\nHidden Trims: %d\nResync Skips: %d\nAudio Decoder Flushes: %d", - status, delay, buf_ms, ctrl and "on" or "off", speed, video, audio, - repairs, quality_events, silence, underruns, hidden_trims, - resync_skips, audio_flushes + "Status: %s\nBuffer: %dms\nSpeed: %.3fx\nA/V skew: %dms (hold %dms, video delay %dms)\nUnderruns/Restarts: %d/%d\nMax PTS gap: %dms\nFrames: %d/%d (v/a)", + status, buf_ms, speed, skew, hold, video_delay, underruns, restarts, + max_gap, video, audio ) -- Re-rendering the text texture is the expensive part; skip it From 2a0615d1f13767bd42a093c5ec36830ee9a8c05c Mon Sep 17 00:00:00 2001 From: datagutt Date: Fri, 2 Oct 2026 03:12:29 +0200 Subject: [PATCH 19/28] docs(obs-sys): trim comments to what the declarations cannot say --- crates/obs-sys/src/lib.rs | 26 ++++++++------------------ 1 file changed, 8 insertions(+), 18 deletions(-) diff --git a/crates/obs-sys/src/lib.rs b/crates/obs-sys/src/lib.rs index f88b466..be70253 100644 --- a/crates/obs-sys/src/lib.rs +++ b/crates/obs-sys/src/lib.rs @@ -12,9 +12,8 @@ //! resolve against the already-loaded libobs at dlopen time; on macOS the //! plugin crate passes `-undefined dynamic_lookup`. //! -//! Callers must never invoke `pthread_*` or link w32-pthreads: the reason the -//! C plugin had to (librist's pthread shim colliding with w32-pthreads on -//! MSVC) does not exist in Rust, and nothing here depends on it. +//! Nothing here calls `pthread_*` or needs w32-pthreads, which collides with +//! librist's pthread shim on MSVC. #![allow( non_camel_case_types, @@ -524,11 +523,10 @@ pub struct obs_transform_info_slack { pub _slack: [u8; 64], } -/// `struct calldata` (callback/calldata.h). `calldata_init` is a memset to -/// zero and `calldata_free` is `bfree(stack)`; both are `static inline` in the -/// header and reimplemented in the `obs` crate, as are every typed -/// `calldata_set_*`/`calldata_get_*` helper (over `calldata_set_data` / -/// `calldata_get_data`). +/// `struct calldata` (callback/calldata.h). The header's `static inline` +/// helpers (`calldata_init`, `calldata_free`, the typed getters and setters) +/// are reimplemented in the `obs` crate over `calldata_set_data` / +/// `calldata_get_data`. #[repr(C)] pub struct calldata_t { pub stack: *mut u8, @@ -558,14 +556,8 @@ pub struct obs_websocket_request_callback { // ── Functions ────────────────────────────────────────────────────────── // // Every declaration matches the prototype in the libobs header named above -// its group. `raw-dylib` on Windows means no import library is needed; on -// Linux/macOS the symbols stay undefined and resolve against the libobs the -// host process already loaded. -// -// `obs_get_video_info` and `obs_sceneitem_set_info2` are declared with the -// exact struct types, but their two call sites in the `obs` crate hand them a -// pointer into an [`obs_video_info_slack`] / [`obs_transform_info_slack`], so -// a newer libobs that appended a member cannot overrun the caller's frame. +// its group. `obs_get_video_info` and `obs_sceneitem_set_info2` are called +// with a pointer into the slack wrappers above. #[cfg_attr(windows, link(name = "obs", kind = "raw-dylib"))] unsafe extern "C" { @@ -731,8 +723,6 @@ unsafe extern "C" { ) -> *mut obs_property_t; // ── callback/calldata.h ──────────────────────────────────────────── - // The typed helpers around these three are `static inline` in the header - // and are reimplemented in the `obs` crate (`proc::CallData`). pub fn calldata_get_data( data: *const calldata_t, name: *const c_char, From 1dc79b1169c3d7d3efead9967d7a6420488b1ef0 Mon Sep 17 00:00:00 2001 From: datagutt Date: Fri, 2 Oct 2026 03:12:29 +0200 Subject: [PATCH 20/28] docs(obs): drop c plugin references and history from comments --- crates/obs/src/audio.rs | 6 +++--- crates/obs/src/data.rs | 3 +-- crates/obs/src/proc.rs | 9 ++++----- crates/obs/src/time.rs | 13 +++++-------- crates/obs/src/video.rs | 14 +++++--------- crates/obs/src/websocket.rs | 2 +- crates/obs/tests/frames.rs | 5 ++--- crates/obs/tests/sleep.rs | 11 +++++------ 8 files changed, 26 insertions(+), 37 deletions(-) diff --git a/crates/obs/src/audio.rs b/crates/obs/src/audio.rs index a372ae6..f06cb46 100644 --- a/crates/obs/src/audio.rs +++ b/crates/obs/src/audio.rs @@ -17,9 +17,9 @@ impl AudioFormat { } } -/// `enum speaker_layout`. [`SpeakerLayout::from_channels`] reproduces the C -/// plugin's `(enum speaker_layout)channels` cast for the values libobs -/// defines (1, 2, 3, 4, 5, 6, 8) and yields `Unknown` otherwise. +/// `enum speaker_layout`. [`SpeakerLayout::from_channels`] maps a channel +/// count to the layout with the same value for the counts libobs defines +/// (1, 2, 3, 4, 5, 6, 8) and yields `Unknown` otherwise. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum SpeakerLayout { Unknown, diff --git a/crates/obs/src/data.rs b/crates/obs/src/data.rs index 165d538..bd8754b 100644 --- a/crates/obs/src/data.rs +++ b/crates/obs/src/data.rs @@ -19,8 +19,7 @@ impl Data<'_> { self.0.as_ptr() } - /// `obs_data_get_string`; `None` when the value is empty, matching the C - /// idiom `if (url && *url)`. + /// `obs_data_get_string`; `None` when the value is missing or empty. pub fn get_str(&self, key: &CStr) -> Option { // SAFETY: live handle; libobs returns a NUL-terminated string owned by // the obs_data_t, valid until the item is overwritten or released — diff --git a/crates/obs/src/proc.rs b/crates/obs/src/proc.rs index c22f650..add5c2f 100644 --- a/crates/obs/src/proc.rs +++ b/crates/obs/src/proc.rs @@ -3,7 +3,7 @@ //! `calldata_init`, `calldata_free` and every typed `calldata_set_*` / //! `calldata_get_*` are `static inline` in `callback/calldata.h`; they are //! reimplemented here over the three exported functions (`calldata_set_data`, -//! `calldata_get_data`, `calldata_get_string`) with the C widths: `long long` +//! `calldata_get_data`, `calldata_get_string`) with libobs's widths: `long long` //! = 8, `double` = 8, `bool` = 1, pointer = `size_of::<*mut c_void>()`, string //! = `strlen + 1`. @@ -107,8 +107,7 @@ pub struct CallData(obs_sys::calldata_t); impl CallData { #[must_use] pub fn new() -> Self { - // `calldata_init` is a memset to zero; the C callers that build one on - // the stack write `calldata_t cd = {0, 0, 0, 0}` instead, same thing. + // `calldata_init` is a memset to zero. Self(obs_sys::calldata_t { stack: core::ptr::null_mut(), size: 0, @@ -133,14 +132,14 @@ impl CallData { } /// The shared body of every typed getter. `false` means "absent, or a - /// different type", exactly as in C. + /// different type", as with the header's own getters. fn get_data(&self, name: &CStr, out: *mut c_void, size: usize) -> bool { // SAFETY: as above; libobs writes at most `size` bytes into `out`. unsafe { obs_sys::calldata_get_data(&raw const self.0, name.as_ptr(), out, size) } } pub fn set_i64(&mut self, name: &CStr, value: i64) { - // C stores a `long long`, which is 8 bytes on every target OBS builds + // libobs stores a `long long`, which is 8 bytes on every target OBS builds // for; a getter asking for a different width reads nothing. let v: i64 = value; self.set_data(name, (&raw const v).cast(), size_of::()); diff --git a/crates/obs/src/time.rs b/crates/obs/src/time.rs index d5425ae..ae1c4b8 100644 --- a/crates/obs/src/time.rs +++ b/crates/obs/src/time.rs @@ -26,14 +26,11 @@ pub fn gettime_ns() -> u64 { /// Deliberately `std::thread::sleep` and not libobs's `os_sleep_ms`. On /// Windows 8+ that function subtracts one millisecond before calling `Sleep` /// to compensate for the scheduler's coarse timer, so `os_sleep_ms(1)` is -/// `Sleep(0)`: a yield, not a wait. The audio pump's minimum wait is 1 ms, -/// which turned every idle source into a busy loop pinning a core, and the -/// last millisecond before an audio deadline into a spin. `std::thread::sleep` -/// never returns early, and on Windows 10 1803+ it uses a high-resolution -/// waitable timer, so 1 ms means about 1 ms rather than a 15.6 ms quantum. -/// -/// Only the waiting moves off libobs; timestamps still come from -/// [`gettime_ns`]. +/// `Sleep(0)`: a yield, not a wait. With the audio pump's 1 ms minimum wait +/// that makes every idle source a busy loop pinning a core. +/// `std::thread::sleep` never returns early, and on Windows 10 1803+ it uses +/// a high-resolution waitable timer, so 1 ms means about 1 ms rather than a +/// 15.6 ms quantum. Timestamps still come from [`gettime_ns`]. pub fn sleep_ms(ms: u32) { std::thread::sleep(std::time::Duration::from_millis(u64::from(ms))); } diff --git a/crates/obs/src/video.rs b/crates/obs/src/video.rs index 633f4c5..ef12510 100644 --- a/crates/obs/src/video.rs +++ b/crates/obs/src/video.rs @@ -126,15 +126,11 @@ impl<'a> VideoFrame<'a> { } /// Fill `color_matrix`, `color_range_min/max` and `full_range` through - /// `video_format_get_parameters_for_format` (the C `setup_color_params`). - /// Falls back to BT.709 when libobs rejects the colour space, and to - /// BT.709 limited range when it rejects the range as well. - /// - /// The C version ignores the return value, which leaves an all-zero matrix - /// behind for a combination libobs does not know. That is harmless for an - /// RGB frame (no conversion shader reads the matrix) but black for a YUV - /// one, so the retries are added here; `full_range` is kept consistent - /// with whichever range actually produced the matrix. + /// `video_format_get_parameters_for_format`. Falls back to BT.709 when + /// libobs rejects the colour space, and to BT.709 limited range when it + /// rejects the range as well: a combination libobs does not know leaves + /// an all-zero matrix, which renders a YUV frame black. `full_range` + /// follows whichever range produced the matrix. #[must_use] pub fn colorimetry(mut self, cs: ColorSpace, range: ColorRange) -> Self { self.inner.full_range = range == ColorRange::Full; diff --git a/crates/obs/src/websocket.rs b/crates/obs/src/websocket.rs index e3479b1..1f479a2 100644 --- a/crates/obs/src/websocket.rs +++ b/crates/obs/src/websocket.rs @@ -106,7 +106,7 @@ impl Vendor { // Leaked on purpose: the API has no unregister that could tell us when // obs-websocket has stopped holding the record, and registrations last - // for the life of the process. This mirrors the C plugin exactly. + // for the life of the process. let boxed: Box = Box::new(callback); let priv_data = Box::into_raw(boxed).cast::(); diff --git a/crates/obs/tests/frames.rs b/crates/obs/tests/frames.rs index d751bef..6d3d8ec 100644 --- a/crates/obs/tests/frames.rs +++ b/crates/obs/tests/frames.rs @@ -87,7 +87,7 @@ fn audio_frame_is_single_plane_interleaved() { #[test] fn speaker_layout_matches_the_c_cast() { - // The C plugin casts the channel count straight to `enum speaker_layout`, + // A channel count maps to the `enum speaker_layout` with the same value, // which is only meaningful for the values libobs defines. assert_eq!(SpeakerLayout::from_channels(1), SpeakerLayout::Mono); assert_eq!(SpeakerLayout::from_channels(2), SpeakerLayout::Stereo); @@ -115,8 +115,7 @@ fn speaker_layout_matches_the_c_cast() { ); } - // Every mapped value must land on the numeric layout libobs defines, since - // that is what the C cast produced. + // Every mapped value must land on the numeric layout libobs defines. for channels in [1u32, 2, 3, 4, 5, 6, 8] { assert_eq!( SpeakerLayout::from_channels(channels).to_sys() as u32, diff --git a/crates/obs/tests/sleep.rs b/crates/obs/tests/sleep.rs index 21aa2fd..c1c610a 100644 --- a/crates/obs/tests/sleep.rs +++ b/crates/obs/tests/sleep.rs @@ -1,11 +1,10 @@ //! `sleep_ms` must block for the requested time even at the audio pump's -//! 1 ms minimum. This guards against routing it back through libobs's -//! `os_sleep_ms`, which on Windows 8+ turns a 1 ms wait into `Sleep(0)`. +//! 1 ms minimum, which libobs's `os_sleep_ms` does not: on Windows 8+ it +//! turns a 1 ms wait into `Sleep(0)`. //! -//! Windows only: that is the platform the regression exists on, and every -//! other platform's `os_sleep_ms(1)` is a real sleep, so the assertions could -//! never fail there. The binary references no libobs symbol, so unlike the -//! rest of this crate's tests it needs no `obs.dll` to load against. +//! Windows only, since every other platform's `os_sleep_ms(1)` is a real +//! sleep. The binary references no libobs symbol, so unlike the rest of this +//! crate's tests it needs no `obs.dll` to load against. #![cfg(windows)] use std::time::{Duration, Instant}; From 6212bf53aa46ddb78de5dcebd544d3cb39701514 Mon Sep 17 00:00:00 2001 From: datagutt Date: Fri, 2 Oct 2026 03:12:30 +0200 Subject: [PATCH 21/28] docs(ffmpeg): trim comments to what the code cannot say --- crates/ffmpeg/src/codec.rs | 8 +++--- crates/ffmpeg/src/dict.rs | 2 +- crates/ffmpeg/src/format.rs | 50 ++++++++++++++----------------------- crates/ffmpeg/src/frame.rs | 4 +-- crates/ffmpeg/src/hw.rs | 2 +- crates/ffmpeg/src/lib.rs | 12 ++++----- crates/ffmpeg/src/log.rs | 5 +--- crates/ffmpeg/src/sws.rs | 26 ++++++++----------- 8 files changed, 43 insertions(+), 66 deletions(-) diff --git a/crates/ffmpeg/src/codec.rs b/crates/ffmpeg/src/codec.rs index 50994e3..667c5c6 100644 --- a/crates/ffmpeg/src/codec.rs +++ b/crates/ffmpeg/src/codec.rs @@ -191,8 +191,8 @@ impl CodecBuilder { self } - /// Whether `hw_device_ctx` is currently attached (the C plugin's - /// "hardware was requested" check before the open attempt). + /// Whether `hw_device_ctx` is currently attached, i.e. hardware was + /// requested. pub fn has_hw_device(&self) -> bool { // SAFETY: `self.ptr` is an unopened context we own. !unsafe { (*self.ptr).hw_device_ctx }.is_null() @@ -282,8 +282,8 @@ impl CodecContext { /// `avcodec_receive_frame` into `frame` (unrefs it first). pub fn receive_frame(&mut self, frame: &mut Frame) -> Result<()> { - // avcodec_receive_frame unrefs internally, but the C plugin's callers - // rely on the frame being blank on every error path too. + // avcodec_receive_frame unrefs internally, but callers rely on the + // frame being blank on every error path too. frame.unref(); // SAFETY: `self.ptr` is an open decoder and `frame` a live frame we own. Error::check(unsafe { diff --git a/crates/ffmpeg/src/dict.rs b/crates/ffmpeg/src/dict.rs index f2cf9b5..d48e4b6 100644 --- a/crates/ffmpeg/src/dict.rs +++ b/crates/ffmpeg/src/dict.rs @@ -29,7 +29,7 @@ impl Dictionary { } /// Keys left after `avformat_open_input`, i.e. options the demuxer did not - /// recognise (logged by the C plugin). + /// recognise. pub(crate) fn remaining_keys(&self) -> Vec { self.iter_keys().collect() } diff --git a/crates/ffmpeg/src/format.rs b/crates/ffmpeg/src/format.rs index 4aa814f..3353dd3 100644 --- a/crates/ffmpeg/src/format.rs +++ b/crates/ffmpeg/src/format.rs @@ -8,10 +8,9 @@ use crate::dict::Dictionary; use crate::packet::Packet; use crate::{AVCodecID, Error, Rational, Result}; -/// Port of the C `interrupt_cb`: abort the blocking FFmpeg call when the -/// stream was told to stop, or when the current I/O call has been blocked -/// longer than `timeout_us` (a dead-but-open connection otherwise hangs -/// `av_read_frame` forever). +/// Aborts the blocking FFmpeg call when the stream was told to stop, or when +/// the current I/O call has been blocked longer than `timeout_us` (a +/// dead-but-open connection otherwise hangs `av_read_frame` forever). /// /// `active` is the *same* atomic as the stream's `thread_active` flag, so a /// stop request reaches a receiver blocked inside FFmpeg. `io_start_us` is @@ -53,10 +52,10 @@ impl InterruptWatch { /// /// The byte count starts at -1, which no `bytes_read` can equal, so the /// first callback that sees a connection counts it as progress and - /// restarts the clock. Starting at 0 matched a fresh connection's count, - /// and the deadline then ran from `arm()`, which for a listener was when - /// it started waiting: a sender that called in more than `timeout_us` - /// later was aborted on its first read, before it could send anything. + /// restarts the clock. Starting at 0 would match a fresh connection's + /// count and leave the deadline running from `arm()`, which for a listener + /// is when it started waiting: a sender that called in more than + /// `timeout_us` later would be aborted on its first read. fn track(&self, ptr: *mut ffmpeg_sys_next::AVFormatContext) { self.fmt.store(ptr, Ordering::Relaxed); self.io_bytes_read.store(-1, Ordering::Relaxed); @@ -80,30 +79,20 @@ impl InterruptWatch { /// plain field reads, so it is safe to run inside FFmpeg's callback /// without `catch_unwind`. /// - /// "Without progress" is meant literally, and neither half of that was - /// true before: + /// A stall means no progress, in two senses: /// /// - No `AVIOContext` yet means no connection yet, and for a listener URL - /// that is the source's normal idle state, not a fault. - /// `srt://0.0.0.0:7000?mode=listener` sits inside `srt_accept()` until - /// the sender calls in — which can be hours — and libsrt polls this - /// callback throughout. Timing that out tore the listening socket down - /// every 10s and rebound it after the reconnect delay, so the port was - /// dark for a slice of every cycle and any handshake in flight died with - /// the socket. A caller URL keeps the deadline: it is dialing a host that - /// either answers or does not, and libsrt's own `SRTO_CONNTIMEO` bounds - /// it besides. + /// that is the normal idle state. `srt://0.0.0.0:7000?mode=listener` + /// sits inside `srt_accept()` until the sender calls in, possibly for + /// hours, and libsrt polls this callback throughout. Timing that out + /// would tear the listening socket down every cycle and kill any + /// handshake in flight. A caller URL keeps the deadline: libsrt's own + /// `SRTO_CONNTIMEO` bounds it besides. /// - Once connected, the stall is measured from the connection or the - /// last byte that actually arrived, whichever is later, rather than - /// from the start of the call. - /// `avformat_open_input` accepts the connection and then probes over the - /// same deadline, so without this a sender that arrived nine seconds into - /// the accept got one second to deliver a PAT/PMT — a healthy stream - /// failing on a stopwatch. - /// - /// Reported as "IRL Source fails to open an SRT stream that OBS's Media - /// Source opens immediately" (irlserver/obs-irl-source#28): the media - /// source's interrupt callback only checks for shutdown, so it just waits. + /// last byte that arrived, whichever is later, not from the start of + /// the call. `avformat_open_input` accepts and then probes over the + /// same deadline, so a sender that arrived late into the accept would + /// otherwise get only the remainder to deliver a PAT/PMT (#28). pub(crate) fn should_abort(&self) -> bool { if !self.active.load(Ordering::Relaxed) { return true; @@ -327,8 +316,7 @@ impl FormatContext { } } - /// `av_read_frame` (arms the watch first). `Err(EAGAIN)`/`EOF` map to the - /// C plugin's read-error handling. + /// `av_read_frame` (arms the watch first). pub fn read_frame(&mut self, pkt: &mut Packet) -> Result<()> { self.watch.arm(); // SAFETY: `self.ptr` is an open context and `pkt` a live packet we own; diff --git a/crates/ffmpeg/src/frame.rs b/crates/ffmpeg/src/frame.rs index 1829dab..d5b5f7b 100644 --- a/crates/ffmpeg/src/frame.rs +++ b/crates/ffmpeg/src/frame.rs @@ -156,8 +156,8 @@ impl Frame { } /// Borrow plane `index`. `None` when the plane pointer is null **or the - /// linesize is not positive** — the negative-stride case the C plugin - /// routes to swscale — so the caller's format check lands in one place. + /// linesize is not positive** (a negative stride needs swscale), so the + /// caller's format check lands in one place. /// Length is `linesize * plane_height(index)`, with the chroma height /// derived from `av_pix_fmt_desc_get(format)->log2_chroma_h`. pub fn plane(&self, index: usize) -> Option<&[u8]> { diff --git a/crates/ffmpeg/src/hw.rs b/crates/ffmpeg/src/hw.rs index c7cf054..deb50fa 100644 --- a/crates/ffmpeg/src/hw.rs +++ b/crates/ffmpeg/src/hw.rs @@ -26,7 +26,7 @@ unsafe impl Send for HwDeviceContext {} impl HwDeviceContext { /// Try `av_hwdevice_ctx_create(type, NULL, NULL, 0)` for each type in /// order; the first success wins. `on_fail` is called for each failure so - /// the caller can log it the way the C plugin does. + /// the caller can log it. pub fn probe( types: &[AVHWDeviceType], on_fail: &mut dyn FnMut(AVHWDeviceType, Error), diff --git a/crates/ffmpeg/src/lib.rs b/crates/ffmpeg/src/lib.rs index b6721af..fd11dcf 100644 --- a/crates/ffmpeg/src/lib.rs +++ b/crates/ffmpeg/src/lib.rs @@ -4,11 +4,10 @@ //! frame, swresample (including compensation-based speed control) and //! swscale (`sws_scale_frame` on the public `SwsContext`). //! -//! Every `unsafe` FFmpeg call in the plugin lives in this crate. The API is -//! deliberately close to the C surface (the C plugin is the behavioral spec), -//! but ownership is explicit: `Frame`, `Packet`, `CodecContext`, -//! `FormatContext`, `HwDeviceContext`, `FramePool`, `Resampler` and `Scaler` -//! free their FFmpeg objects in `Drop`. +//! Every `unsafe` FFmpeg call in the plugin lives in this crate. The API stays +//! close to FFmpeg's own, but ownership is explicit: `Frame`, `Packet`, +//! `CodecContext`, `FormatContext`, `HwDeviceContext`, `FramePool`, +//! `Resampler` and `Scaler` free their FFmpeg objects in `Drop`. //! //! Time domains: [`gettime_us`] is `av_gettime()` (microseconds) and is used //! only for FFmpeg-side timers (interrupt watch, decoder cooldowns). OBS @@ -245,8 +244,7 @@ pub(crate) const fn ffalign(x: i32, a: i32) -> i32 { /// /// The bindgen enum is `#[repr(i32)]` with contiguous discriminants from /// `AV_PIX_FMT_NONE` (-1) to `AV_PIX_FMT_NB`, so a range check is enough to -/// make the transmute sound; anything outside becomes `AV_PIX_FMT_NONE` -/// (which is how the C plugin's `default:` arms treat unknown formats too). +/// make the transmute sound; anything outside becomes `AV_PIX_FMT_NONE`. pub(crate) fn pix_fmt_from_raw(raw: c_int) -> AVPixelFormat { if raw < AVPixelFormat::AV_PIX_FMT_NONE as c_int || raw > AVPixelFormat::AV_PIX_FMT_NB as c_int { diff --git a/crates/ffmpeg/src/log.rs b/crates/ffmpeg/src/log.rs index d50d853..fbf5fde 100644 --- a/crates/ffmpeg/src/log.rs +++ b/crates/ffmpeg/src/log.rs @@ -3,10 +3,7 @@ //! Without this the plugin's media stack is silent. The FFmpeg it links is //! static and hidden behind the module's symbol map, so the host OBS's own //! `av_log` callback can never see it, and FFmpeg's default callback writes to -//! a stderr a Windows OBS does not have. Every libavformat/libsrt failure — -//! the handshake error, the "no TS sync" probe warning, the reason a URL would -//! not open — went nowhere at all. Port of the C `irl_ffmpeg_log` -//! (master f06d705). +//! a stderr a Windows OBS does not have. //! //! The sink receives a *formatted* line and nothing else: this module owns the //! `va_list`, and the caller never sees FFmpeg's format string or its diff --git a/crates/ffmpeg/src/sws.rs b/crates/ffmpeg/src/sws.rs index f52f0ad..c5d40f2 100644 --- a/crates/ffmpeg/src/sws.rs +++ b/crates/ffmpeg/src/sws.rs @@ -9,21 +9,16 @@ //! isolated in `set_flags` with the `av_opt_set_int("sws_flags")` fallback //! documented there. //! -//! Why dynamic mode at all (ported from the C plugin's `video-handler.c`): -//! FFmpeg 9.0 landed the swscale rewrite, where conversions are decomposed -//! into elementary ops compiled into kernel chains. `sws_getContext()` sets -//! `is_legacy_init`, and swscale.h is blunt that "the stateful legacy API -//! always implies SWS_BACKEND_LEGACY" — `SWS_UNSTABLE` on such a context is -//! silently ignored. The new backends exist only behind `sws_alloc_context()` -//! with no `sws_init_context()`, driven by `sws_scale_frame()`. +//! Why dynamic mode: FFmpeg 9.0's swscale rewrite (conversions compiled into +//! op chains) is reachable only through `sws_alloc_context()` without +//! `sws_init_context()`, driven by `sws_scale_frame()`. A context from +//! `sws_getContext()` is legacy and silently ignores `SWS_UNSTABLE`. //! -//! As of 9.0 the op chain refuses every conversion this plugin performs: it -//! only builds a pass when no chroma resampling is needed, so `yuv420p → nv12` -//! (and every other subsampled target) fails `ff_sws_op_list_generate()` with -//! `ENOTSUP` and falls back to the legacy pass, bit-identically. The flag is -//! wired up anyway because that is where upstream's work is going, and it -//! should be one env var away rather than a refactor away. Callers gate it on -//! `IRL_SWS_UNSTABLE`; the default is `flags = 0`, i.e. the legacy backend. +//! As of 9.0 the op chain refuses every conversion this plugin performs (it +//! only builds a pass when no chroma resampling is needed) and falls back to +//! the legacy pass bit-identically. Callers gate `SWS_UNSTABLE` on +//! `IRL_SWS_UNSTABLE` so the new backend is one env var away; the default is +//! `flags = 0`, the legacy backend. use core::ffi::{c_int, c_uint}; @@ -40,8 +35,7 @@ pub struct Scaler { unsafe impl Send for Scaler {} impl Scaler { - /// `unstable` sets `SWS_UNSTABLE` (the C plugin gates it on the - /// `IRL_SWS_UNSTABLE` environment variable). + /// `unstable` sets `SWS_UNSTABLE`. pub fn new(unstable: bool) -> Result { // SAFETY: no arguments; returns an allocated, uninitialised context or // null. Deliberately no sws_init_context(): that is what would mark the From 6528da118c26a198f8d25da417b96001516e36c9 Mon Sep 17 00:00:00 2001 From: datagutt Date: Fri, 2 Oct 2026 03:14:31 +0200 Subject: [PATCH 22/28] docs(irl-core): trim comments to the reasons the code cannot carry --- .../irl-core/examples/speed-controller-sim.rs | 24 ++-- crates/irl-core/src/arrival.rs | 30 ++--- crates/irl-core/src/audio_buffer.rs | 38 +++---- crates/irl-core/src/audio_hold.rs | 52 +++------ crates/irl-core/src/config.rs | 10 +- crates/irl-core/src/consts.rs | 81 +++++-------- crates/irl-core/src/dsp.rs | 26 ++--- crates/irl-core/src/lib.rs | 3 +- crates/irl-core/src/pacing.rs | 51 ++++----- crates/irl-core/src/pts_repair.rs | 38 +++---- crates/irl-core/src/speed.rs | 106 +++++++----------- crates/irl-core/src/timing.rs | 64 +++++------ crates/irl-core/src/url_opts.rs | 25 ++--- crates/irl-core/src/video_delay.rs | 52 +++------ crates/irl-core/src/video_time.rs | 40 +++---- 15 files changed, 236 insertions(+), 404 deletions(-) diff --git a/crates/irl-core/examples/speed-controller-sim.rs b/crates/irl-core/examples/speed-controller-sim.rs index a743f59..a059d6a 100644 --- a/crates/irl-core/examples/speed-controller-sim.rs +++ b/crates/irl-core/examples/speed-controller-sim.rs @@ -9,19 +9,11 @@ //! touching `speed.rs`; it exits non-zero if the loop fails to settle at any //! buffer target, or if a requested speed is not applied faithfully. //! -//! It exists because the PI speed controller is the one piece of real -//! mathematics in the audio path and is otherwise unfalsifiable: you cannot -//! tell a converging controller from a limit-cycling one by reading it. Every -//! defect the controller had during development was found here — the limit -//! cycle the trim produced against the original flat deadband, the windup a -//! stall leaked into the trim before the error window existed, and the sample -//! quantisation that was silently discarding or doubling every correction -//! under 0.1 %. `docs/audio-timing-pitfalls.md` is the write-up. -//! -//! Unlike the C harness this replaces (`tools/speed-controller-sim.c`, which -//! copy-pasted the controller because the real one read `struct irl_source`), -//! this **links** [`irl_core::speed`]. Its constants cannot drift out of step -//! with the plugin's, because they are the same constants. +//! You cannot tell a converging controller from a limit-cycling one by reading +//! it, so this runs it: it checks for limit cycles, for a stall leaking into +//! the trim, and for sample quantisation discarding or doubling small +//! corrections (see `docs/audio-timing-pitfalls.md`). It links +//! [`irl_core::speed`], so it always simulates the controller that ships. //! //! **Caveat:** the buffer level here is CONTINUOUS. On real audio it moves in //! whole decoded chunks (21.3 ms for 1024-sample AAC), so the sub-millisecond @@ -219,9 +211,9 @@ fn main() -> std::process::ExitCode { // How faithfully the requested speed is actually applied. The resampler is // driven in whole samples per chunk, so rounding each chunk independently // quantises the applied speed to multiples of 1/in_frames (~0.1 % at - // 1024). That is the region the deadband slope and the trim both live in, - // so the request was either discarded or doubled; `SpeedCarry` carries the - // fractional remainder to fix it. + // 1024), the region the deadband slope and the trim live in; `SpeedCarry` + // carries the fractional remainder so the request is neither discarded + // nor doubled. println!("applied vs requested speed (1024-frame chunks)"); for req in [ 1.0f32, 1.0002, 1.0005, 1.001, 1.002, 1.005, 1.01, 0.9995, 0.998, 0.99, diff --git a/crates/irl-core/src/arrival.rs b/crates/irl-core/src/arrival.rs index 134cd97..2f5d26c 100644 --- a/crates/irl-core/src/arrival.rs +++ b/crates/irl-core/src/arrival.rs @@ -1,26 +1,16 @@ //! Whether video has stopped catching up to live. //! -//! The standing video delay ([`crate::video_delay`]) is sized before the play -//! head anchors from how late a frame reached the plugin against its audio. -//! That is only the sender's skew once the video is *live*. At the start of a -//! connection it often is not: a relay hands a new subscriber video from its -//! last keyframe next to live audio, and that video is behind its audio until -//! it has caught up. Measured then, a stream with no skew at all reads more -//! than a second late. +//! How late video reaches the plugin against its audio is the sender's skew +//! only once video is *live*. A relay hands a new subscriber video from its +//! last keyframe next to live audio, so at connection start a stream with no +//! skew can read more than a second late. //! -//! The network cannot produce that reading. Audio and video travel in one -//! mux, so loss, throttling and jitter delay them together. Video can only be -//! behind its audio and then not be if it arrived faster than real time in -//! between, and that is observable: a packet's arrival time minus its PTS -//! falls with every frame while video catches up, and hovers around a floor -//! once it is live. A sender that really is late (#33) holds a steady offset -//! from its first packet. -//! -//! So the floor of that offset is tracked, and video counts as live once the -//! floor has not dropped by more than a tolerance across a lookback. A -//! batching relay passes: the newest frame of each batch lands on the same -//! floor as the newest of the one before. A bounded wait covers a stream that -//! never stops catching up. +//! Catching up means arriving faster than real time, so a packet's arrival +//! time minus its PTS falls with every frame and then hovers around a floor; +//! a sender that really is late (#33) holds a steady offset from its first +//! packet. Video counts as live once that floor has not dropped by more than +//! a tolerance across a lookback, or after a bounded wait. A batching relay +//! passes, since the newest frame of each batch lands on the same floor. use std::collections::VecDeque; diff --git a/crates/irl-core/src/audio_buffer.rs b/crates/irl-core/src/audio_buffer.rs index 2e24350..81461e2 100644 --- a/crates/irl-core/src/audio_buffer.rs +++ b/crates/irl-core/src/audio_buffer.rs @@ -1,9 +1,9 @@ -//! PTS-aware audio ring buffer (port of `src/audio-buffer.c`). +//! PTS-aware audio ring buffer. //! //! Interleaved PCM in a byte ring, sized in milliseconds, with a parallel //! queue of `(pts_ns, size, consumed)` chunks so every read can report the -//! stream PTS of its oldest byte. The C version carried its own mutex; here -//! the caller wraps it in one (lock order: the audio state lock first). +//! stream PTS of its oldest byte. It has no lock of its own: the caller wraps +//! it in one (lock order: the audio state lock first). use crate::consts; @@ -59,12 +59,7 @@ pub struct AudioBuffer { impl AudioBuffer { /// Allocate for `sample_rate`/`channels`/`bytes_per_sample` with the - /// capacity `4 × max_ms` implies (plus headroom, as in C). - /// - /// Ports `audio_buffer_init`. The C function returns false only when the - /// mutex could not be created; this type has no mutex (the caller wraps - /// it), so the `Option` is never `None` — it keeps the shape of the C - /// call site. + /// capacity `4 × max_ms` implies. Never returns `None`. pub fn new( sample_rate: i32, channels: i32, @@ -94,11 +89,7 @@ impl AudioBuffer { Some(buf) } - /// Reinitialise for a new format (flushes). - /// - /// Ports `audio_buffer_reconfigure`. The watermarks are kept: the C - /// function took them again only because the caller had them at hand, - /// and always passed the values already in force. + /// Reinitialise for a new format (flushes). The watermarks are kept. pub fn reconfigure(&mut self, sample_rate: i32, channels: i32, bytes_per_sample: i32) -> bool { self.sample_rate = sample_rate; self.channels = channels; @@ -168,7 +159,7 @@ impl AudioBuffer { // Continuation marker: derive the new chunk's PTS from the prior // chunk's end so reads stay PTS-consistent. Without this, read_pts // would report 0 for these bytes while pts_consume drained unrelated - // chunk metadata. Computed before the chunk ring is trimmed, as in C. + // chunk metadata. Computed before the chunk ring is trimmed. let mut pts_ns = 0; if self.chunk_count > 0 && self.sample_rate > 0 && self.frame_size() > 0 { let last_idx = @@ -223,8 +214,7 @@ impl AudioBuffer { (got, pts) } - /// Read up to `out.len()` bytes without reporting a PTS - /// (`audio_buffer_read`). + /// Read up to `out.len()` bytes without reporting a PTS. pub fn read(&mut self, out: &mut [u8]) -> usize { if self.data.is_empty() || out.is_empty() { return 0; @@ -238,8 +228,8 @@ impl AudioBuffer { /// Read applying a linear 1→0 fade across the whole read. /// - /// Float sample format is assumed, as in C; the fade is skipped when the - /// buffer holds anything else. + /// Float sample format is assumed; the fade is skipped when the buffer + /// holds anything else. pub fn read_with_fade_out(&mut self, out: &mut [u8]) -> usize { let got = self.read(out); if got == 0 || self.frame_size() == 0 { @@ -307,7 +297,6 @@ impl AudioBuffer { (samples * 1000 / self.sample_rate as i64) as i32 } - /// Fill in bytes. /// Fill in whole frames (`fill_bytes / frame_size`). pub fn fill_frames(&self) -> i64 { let frame = self.frame_size(); @@ -366,13 +355,13 @@ impl AudioBuffer { self.chunk_count } - /// True when the buffer holds at least `min_ms` (`audio_buffer_ready`). + /// True when the buffer holds at least `min_ms`. pub fn ready(&self) -> bool { self.fill_ms() >= self.min_ms } - /// PTS of the oldest queued byte (`audio_buffer_peek_pts`): the oldest - /// chunk's PTS advanced by the part of it already consumed. 0 when empty. + /// PTS of the oldest queued byte: the oldest chunk's PTS advanced by the + /// part of it already consumed. 0 when empty. pub fn peek_pts(&self) -> i64 { self.oldest_pts() } @@ -387,8 +376,7 @@ impl AudioBuffer { } /// Allocate enough headroom that Max Buffer is not an audible hard trim - /// point (`audio_buffer_init`: `ms_to_bytes(max_ms * 4)`, 65536 on a - /// degenerate format). + /// point. fn capacity_for(&self, max_ms: i32) -> usize { let capacity = self.ms_to_bytes_i64(max_ms as i64 * consts::BUFFER_CAPACITY_MULTIPLIER); if capacity == 0 { diff --git a/crates/irl-core/src/audio_hold.rs b/crates/irl-core/src/audio_hold.rs index fa40331..247ef1e 100644 --- a/crates/irl-core/src/audio_hold.rs +++ b/crates/irl-core/src/audio_hold.rs @@ -2,43 +2,25 @@ //! video which leaves the sender behind its audio is in hand when the two are //! due. //! -//! Plenty of senders hand the transport their audio before the video of the -//! same instant, both stamped with the capture time. A hardware encoder runs -//! a few frames behind the microphone, pocketSRT queues its audio about 300 ms -//! ahead of its deadline, and a phone with video stabilization on sends each -//! frame a second or more after its sound (#33). At the receiver that shows as -//! video with PTS `P` arriving long after audio with PTS `P`. Audio plays -//! Target Buffer plus the output lead after it arrives, so once the skew is -//! more than that, the picture for a sound is not here yet when the sound -//! plays. Nothing done to the video schedule can fix it: a frame cannot be -//! shown before it arrives, and the standing video delay -//! ([`crate::video_delay`]) only trades the lateness for a fixed lip-sync -//! error. The audio has to wait for the picture, which is what the media -//! source does by pacing both streams on their PTS. +//! Many senders hand the transport their audio before the video of the same +//! instant (pocketSRT queues audio ~300 ms early, a stabiliser sends video a +//! second or more late, #33). Once the skew exceeds Target Buffer plus the +//! output lead, the picture for a sound is not here when the sound plays, and +//! no video schedule can fix that: the audio has to wait. So the jitter +//! buffer's target is raised by the uncovered part of the skew plus a margin. +//! Before priming that only starts audio later; after it the speed controller +//! builds the cushion at -2 % while the video delay ([`crate::video_delay`]) +//! bridges the gap. //! -//! So the jitter buffer's target is raised by the part of the skew that Target -//! Buffer and the output lead do not already cover, plus a margin. Before -//! playback primes that costs nothing audible: audio simply starts later, -//! together with the picture. After it, the speed controller builds the extra -//! cushion by playing at its inaudible -2 %, and the video delay bridges the -//! gap until it has (the video thread hands the delay back as the cushion -//! grows, so the picture never jumps). +//! The skew is measured in mux order (each video packet's timestamp against +//! the newest audio PTS decoded before it), so loss and stalls, which delay +//! both streams together, leave it alone. The caller measures only once video +//! is live (`crate::arrival`), which keeps a relay's keyframe replay out. //! -//! The skew is measured in mux order: each video packet's timestamp against -//! the newest audio PTS decoded before it. Audio and video travel in one mux, -//! so loss, throttling and a stall delay them together and leave the reading -//! alone; only the sender, or a relay replaying video from its last keyframe -//! next to live audio, moves it. The caller keeps the second case out by -//! measuring only once video is live (`crate::arrival`). -//! -//! What is raised and what is released are deliberately different readings. -//! A raise takes the *sustained* skew, the lowest reading across -//! `AUDIO_HOLD_RAISE_WINDOW_MS`: a skew that was there the whole window. A -//! burst of late video from a hiccup at the sender is short, the video delay -//! covers it, and paying for it in latency for the rest of the connection -//! would be the wrong trade. A release takes the *worst* reading across the -//! much longer `AUDIO_HOLD_RELAX_WINDOW_MS`, so it never undercuts a skew -//! seen recently, and the hold does not pump up and down with one. +//! A raise takes the *lowest* reading across `AUDIO_HOLD_RAISE_WINDOW_MS`, so +//! a short burst of late video stays the video delay's to cover. A release +//! takes the *worst* reading across the longer `AUDIO_HOLD_RELAX_WINDOW_MS`, +//! so the hold does not pump up and down. use std::collections::VecDeque; diff --git a/crates/irl-core/src/config.rs b/crates/irl-core/src/config.rs index a0d5a0f..c100367 100644 --- a/crates/irl-core/src/config.rs +++ b/crates/irl-core/src/config.rs @@ -27,7 +27,7 @@ impl HwDecode { /// Parse a stored value; out of range degrades to `Auto`. `nvdec_available` /// is false on platforms without a CUDA build (macOS), where a saved NVDEC /// setting also degrades to `Auto`. Returns the value and whether it was - /// degraded (the C plugin logs a warning in that case). + /// degraded, which the caller logs. pub fn from_i64(value: i64, nvdec_available: bool) -> (Self, bool) { match value { 0 => (Self::Auto, false), @@ -56,10 +56,10 @@ pub struct Watermarks { impl Watermarks { /// `min = max(target / 2, 20)`, `max = target + 200`. /// - /// A non-positive target falls back to the default, as `config_load` does, - /// and anything else is clamped to the slider's range: the dialog bounds - /// it, but a scene collection can carry any value, including a target - /// saved by a build with a different ceiling. + /// A non-positive target falls back to the default, and anything else is + /// clamped to the slider's range: the dialog bounds it, but a scene + /// collection can carry any value, including a target saved by a build + /// with a different ceiling. pub fn derive(target_ms: i32) -> Self { let target_ms = if target_ms <= 0 { consts::DEFAULT_BUFFER_TARGET_MS as i32 diff --git a/crates/irl-core/src/consts.rs b/crates/irl-core/src/consts.rs index 5b16fd9..b1eb2c3 100644 --- a/crates/irl-core/src/consts.rs +++ b/crates/irl-core/src/consts.rs @@ -17,7 +17,6 @@ pub const RECONNECT_DELAY_MIN_S: i32 = 1; /// Reconnect delay property bounds. pub const RECONNECT_DELAY_MAX_S: i32 = 60; /// Transport receive buffer handed to FFmpeg (`buffer_size` / `recv_buffer_size`). -/// Formerly the dead `network_buffer_mb` setting; now a constant. pub const NETWORK_BUFFER_MB: i64 = 2; /// Target jitter buffer fill. pub const DEFAULT_BUFFER_TARGET_MS: i64 = 120; @@ -68,7 +67,7 @@ pub const BUFFER_MIN_DIVISOR: i64 = 2; pub const BUFFER_MIN_FLOOR_MS: i64 = 20; /// `max = target + MAX_EXTRA`. pub const BUFFER_MAX_EXTRA_MS: i64 = 200; -/// Ring capacity is this many times `buffer_max_ms` (see `audio-buffer.c`). +/// Ring capacity is this many times `buffer_max_ms`. pub const BUFFER_CAPACITY_MULTIPLIER: i64 = 4; // ── PTS repair ── @@ -141,14 +140,11 @@ pub const AUDIO_SPEED_LEVEL_SMOOTHING: f32 = 0.008; /// Speed at the edge of the deadband. /// -/// The deadband used to be flat: dead-on 1.0 anywhere within 20 ms of target. -/// That is fine for a proportional-only loop, and fatal once the trim is added -/// — a region with zero proportional feedback leaves the integrator undamped, -/// and the pair limit-cycles through it forever (simulated: ±20 ms of fill on -/// a ~2 minute period, never settling). A shallow slope through the deadband -/// restores the damping. At 0.2 % it is 3.5 cents at the very edge, an order -/// of magnitude under anything audible, and it makes the ramp continuous where -/// it used to step. +/// The deadband is sloped rather than flat because a region with zero +/// proportional feedback leaves the trim's integrator undamped, and the pair +/// limit-cycles through it (simulated: ±20 ms of fill on a ~2 minute period, +/// never settling). At 0.2 % the slope is 3.5 cents at the very edge, an order +/// of magnitude under anything audible. pub const AUDIO_SPEED_DEADBAND_SLOPE: f32 = 0.002; /// Integral gain of the speed trim, in 1/s² (error in seconds of buffer, dt @@ -256,10 +252,9 @@ pub const VIDEO_INTERVAL_DEFAULT_NS: i64 = 33_333_333; /// Pacing queue frame ceiling. /// /// It has to carry the largest Target Buffer at the highest frame rate anyone -/// streams: the lead is the audio buffer, so 8 s at 120 fps is 960 frames. At -/// 512 the count bound, not the byte bound, was what decided when pacing gave -/// up — and it did so at a different latency for every frame rate. The byte -/// ceiling below is the one that should bind. +/// streams: the lead is the audio buffer, so 8 s at 120 fps is 960 frames. The +/// byte ceiling below is the one meant to bind; a count bound that binds first +/// gives up at a different latency for every frame rate. pub const VIDEO_PACING_MAX_FRAMES: usize = 1024; /// Pacing queue byte ceiling (1 GiB). pub const VIDEO_PACING_MAX_BYTES: usize = 1024 * 1024 * 1024; @@ -270,23 +265,15 @@ pub const VIDEO_PACING_SLACK_NS: i64 = 1_000_000; /// ticks. /// /// The frame still carries its due time as its timestamp, so libobs shows it -/// at the same moment either way; what the lead buys is that the frame is -/// already queued when the render tick it belongs to runs. +/// at the same moment either way; the lead makes sure it is already queued +/// when its render tick runs. `ready_async_frame()` takes the frame whose +/// timestamp it has just passed, so a frame handed over *at* its due time +/// slips to the next tick whenever the video thread wakes late, which on a +/// 30fps source and a 60fps canvas is visible judder. /// -/// `ready_async_frame()` advances its play head by exact wall-clock deltas and -/// takes the frame whose timestamp it has just passed, so a frame already in -/// the async queue lands on a deterministic tick. A frame handed over *at* its -/// due time has not been queued yet when that tick runs and slips to the next -/// one — but only sometimes, because what decides it is the video thread's -/// wakeup jitter: millisecond-granular at best, and far coarser on a Windows -/// box whose timer resolution nothing has raised. For a 30fps source on a -/// 60fps canvas that is the difference between every frame holding two ticks -/// and frames alternating between one and three — judder, on exactly the -/// panning shots where it shows most. -/// -/// Two ticks covers that jitter, and still leaves a queue depth of one to four -/// source frames, far under the 30 at which `cache_video()` discards the whole -/// async queue. +/// Two ticks covers that wakeup jitter (coarse on Windows) and keeps the queue +/// at one to four source frames, far under the 30 at which `cache_video()` +/// discards the whole async queue. pub const VIDEO_PACING_LEAD_TICKS: u64 = 2; /// Ceiling on that lead, for a canvas running at an unusually low frame rate. pub const VIDEO_PACING_MAX_LEAD_NS: u64 = 50_000_000; @@ -298,31 +285,21 @@ pub const VIDEO_PACING_MAX_WAIT_MS: u64 = 50; /// Margin past the audio prime estimate that video waits for the audio playout /// mapping before anchoring libobs's play head on its own clock. /// -/// While an audio stream is present, the first frame handed to libobs must go -/// out at the due time the *audio mapping* gives it, because libobs anchors its -/// play head to that frame's arrival and never moves it again. Before the -/// mapping exists the only schedule available is the video-only fallback, and -/// the two disagree by roughly the output lead plus a chunk (~100 ms) in one -/// direction, or by whatever audio warm-up remained in the other — so a -/// connection anchored on a fallback frame plays the whole way with that -/// lip-sync error baked in. Video therefore holds until audio primes. The -/// prime is expected within `STARTUP_AUDIO_WARMUP_MS + target + AUDIO_OUT_LEAD_MS`; -/// this is the slack past that before a stream whose audio never arrives is -/// let through on the fallback anyway. +/// libobs anchors its play head to the first frame's arrival and never moves +/// it, and the video-only fallback schedule disagrees with the audio mapping +/// by ~100 ms, so video holds until audio primes. The prime is expected within +/// `STARTUP_AUDIO_WARMUP_MS + target + AUDIO_OUT_LEAD_MS`; this is the slack +/// past that before a stream whose audio never arrives is let through on the +/// fallback anyway. pub const VIDEO_ANCHOR_WAIT_MARGIN_MS: i64 = 1000; /// Ceiling on the standing video delay (`irl_core::video_delay`). /// -/// The delay covers a sender whose video reaches the plugin later than the -/// audio of the same instant by more than Target Buffer absorbs. It costs -/// nothing to carry: a late frame is shown on arrival with or without it, the -/// delay only lets it be paced, so the ceiling is not a latency bound but a -/// stop on a decoder or a host that cannot keep up, whose lateness grows -/// without end. It has to clear every skew a phone can produce, though: a -/// video pipeline running a stabiliser has sent video 1.6 s behind its audio -/// (#33), and past the ceiling the stream plays unpaced (see -/// `VideoThread::settle_anchor_candidate`). The audio hold -/// (`irl_core::audio_hold`) is what puts a sender's skew back in sync. +/// A late frame is shown on arrival with or without the delay, which only +/// lets it be paced, so the ceiling is not a latency bound but a stop on a +/// decoder or host whose lateness grows without end. It has to clear every +/// skew a phone produces: a stabiliser has sent video 1.6 s behind its audio +/// (#33). Past the ceiling the stream plays unpaced. pub const VIDEO_DELAY_MAX_MS: u64 = 5000; /// After the play head is anchored, a raise of the video delay moves the /// picture, so late frames must recur across this window before one is made. @@ -406,7 +383,7 @@ pub const UDP_FIFO_DEFAULT_PACKETS: i64 = 7 * 4096; pub const STATS_LOG_INTERVAL_NS: u64 = 30_000_000_000; /// Ring capacity when the format is degenerate and `4 × max_ms` works out to -/// nothing (`audio_buffer_init`'s `buf->capacity = 65536` fallback). +/// nothing. pub const AUDIO_BUFFER_FALLBACK_CAPACITY: usize = 65536; #[cfg(test)] diff --git a/crates/irl-core/src/dsp.rs b/crates/irl-core/src/dsp.rs index e68ffb0..9e6f9ef 100644 --- a/crates/irl-core/src/dsp.rs +++ b/crates/irl-core/src/dsp.rs @@ -1,11 +1,8 @@ //! Small sample-domain helpers: fades, silence shaping, last-sample memory. //! -//! Ports `remember_last_sample`, `audio_apply_fade_in`, -//! `shape_silence_from_last` (`receiver-audio.c:210-265`), the fade-in ramp in -//! `irl_pump_audio_once` (`receiver-audio.c:861-883`) and the fade of -//! `audio_buffer_read_with_fade_out`. Everything works on interleaved `f32`, -//! which is the only format the plugin ever hands to OBS; [`FloatEdit`] and -//! [`LastSample::remember_bytes`] bridge to the byte buffers it travels in. +//! Everything works on interleaved `f32`, the only format the plugin hands to +//! OBS; [`FloatEdit`] and [`LastSample::remember_bytes`] bridge to the byte +//! buffers it travels in. use crate::consts::{self, AUDIO_MAX_CHANNELS}; @@ -61,13 +58,13 @@ impl LastSample { let off = start + ch * SAMPLE_BYTES; *value = read_f32(&samples[off..off + SAMPLE_BYTES]); } - // `channels > 8` reaches `remember` with a short slice, which is - // exactly the case the C rejected (`*dst_valid = false`). + // `channels > 8` reaches `remember` with a short slice, which clears + // the memory. self.remember(&values[..take], channels); } /// Forget the remembered frame; the next concealment chunk is pure - /// silence (`ctx->audio_out_last_valid = false` after the first one). + /// silence. pub fn forget(&mut self) { self.valid = false; } @@ -78,7 +75,7 @@ impl LastSample { /// forbid the unsafe cast between the two views. The rare in-place edits /// (concealment shaping and the splice fades) therefore decode into a reusable /// `f32` scratch, run the helper, and write the result back. A normal chunk is -/// emitted untouched, so the steady-state copy count matches the C. +/// emitted untouched, so the steady state pays no extra copy. #[derive(Debug, Default)] pub struct FloatEdit { scratch: Vec, @@ -101,7 +98,7 @@ impl FloatEdit { } /// Frames the concealment fade covers: `AUDIO_CONCEAL_FADE_MS` worth, never -/// more than the chunk (`audio_conceal_fade_frames`). +/// more than the chunk. fn conceal_fade_frames(rate: i32, max_frames: usize) -> usize { if rate <= 0 || max_frames == 0 { return 0; @@ -133,11 +130,8 @@ pub fn apply_fade_in(samples: &mut [f32], channels: usize, rate: i32) { } } -/// Fill `samples` with silence that decays from `last` to zero. -/// -/// The C caller memsets the scratch buffer before calling -/// `shape_silence_from_last`; both steps live here, so the whole buffer is -/// silence and only its head carries the decay. +/// Fill `samples` with silence that decays from `last` to zero: the whole +/// buffer is silence and only its head carries the decay. pub fn shape_silence_from_last(samples: &mut [f32], channels: usize, rate: i32, last: &LastSample) { samples.fill(0.0); if channels == 0 { diff --git a/crates/irl-core/src/lib.rs b/crates/irl-core/src/lib.rs index 72083ce..6ce25e3 100644 --- a/crates/irl-core/src/lib.rs +++ b/crates/irl-core/src/lib.rs @@ -3,8 +3,7 @@ //! output-clock arithmetic, video pacing, the demuxer option table, config //! derivation and the stats field table. //! -//! Everything here is plain data in, plain data out, so it is unit-tested -//! against the thresholds the C plugin was tuned with. Time values are passed +//! Everything here is plain data in, plain data out. Time values are passed //! in as parameters (nanoseconds for the OBS domain, microseconds for the //! FFmpeg domain) rather than read from a clock, so tests are deterministic //! and the two domains cannot be mixed by accident. diff --git a/crates/irl-core/src/pacing.rs b/crates/irl-core/src/pacing.rs index afd1adc..48e272f 100644 --- a/crates/irl-core/src/pacing.rs +++ b/crates/irl-core/src/pacing.rs @@ -1,6 +1,6 @@ -//! Video pacing queue (port of `receiver-video.c:82-209`): decoded frames in -//! system memory waiting for their due time, bounded by frame count and -//! bytes, with due times re-derived from the live audio playout offset. +//! Video pacing queue: decoded frames in system memory waiting for their due +//! time, bounded by frame count and bytes, with due times re-derived from the +//! live audio playout offset. use std::collections::VecDeque; @@ -39,13 +39,11 @@ impl PacingQueue { /// Empty queue holding `lead_ns` of media, under hard ceilings of /// `max_frames` and `max_bytes`. /// - /// The two bounds mean different things and that distinction is the whole - /// design. `lead_ns` is the *soft* bound: it is how far ahead of display - /// the caller decodes, it is reached in normal operation every second of - /// every stream, and reaching it simply means "stop decoding for now". - /// `max_frames` / `max_bytes` are the *hard* bound: memory that must not be - /// exceeded whatever the stream does, and reaching one means pacing has - /// failed and frames go out early ([`DueVerdict::EmitEarly`], counted). + /// `lead_ns` is the *soft* bound: how far ahead of display the caller + /// decodes. Reaching it is normal and means "stop decoding for now". + /// `max_frames` / `max_bytes` are the *hard* bound on memory; reaching one + /// means pacing has failed and frames go out early + /// ([`DueVerdict::EmitEarly`], counted). pub fn new(lead_ns: i64, max_frames: usize, max_bytes: usize) -> Self { Self { entries: VecDeque::new(), @@ -96,10 +94,9 @@ impl PacingQueue { /// Re-derive every due time: `due = map(pts)`. /// /// Rescheduling against one offset per cycle preserves the spacing - /// between frames (their due times differ only by their PTS deltas) and - /// moves the whole queue with the audio it is mapped to, so video rides - /// the same latency reclaim instead of trailing it for the depth of the - /// queue. + /// between frames and moves the whole queue with the audio it is mapped + /// to, so video follows a latency change instead of trailing it for the + /// depth of the queue. pub fn reschedule(&mut self, map: impl Fn(i64) -> u64) { for entry in &mut self.entries { entry.due_ns = map(entry.pts_ns); @@ -108,18 +105,17 @@ impl PacingQueue { /// Head frame verdict at `now_ns`. /// - /// Over the ceilings the head goes out early rather than being dropped: - /// too-early video is what the un-paced path did all the time, and it - /// beats a hole in the picture. As in C, a cycle spent over a ceiling - /// counts an overflow even if the head happened to be due anyway. + /// Over the ceilings the head goes out early rather than being dropped, + /// since early video beats a hole in the picture. A cycle spent over a + /// ceiling counts an overflow even if the head was due anyway. /// `slack_ns` is how early a frame may go out: the emit slack plus the - /// delivery lead (see [`consts::VIDEO_PACING_LEAD_TICKS`]). The caller - /// samples it per cycle, because the canvas frame rate is a setting the - /// user can change while the source runs. + /// delivery lead (see [`crate::consts::VIDEO_PACING_LEAD_TICKS`]). The + /// caller samples it per cycle, because the canvas frame rate can change + /// while the source runs. /// `allow_early` is false while the caller still needs a frame at its exact - /// due time — the one that re-anchors libobs's play head. Emitting that one - /// early would anchor the whole connection early, which is the offset the - /// lead exists to avoid, so a hard ceiling waits instead of overflowing. + /// due time (the one that re-anchors libobs's play head). Emitting that one + /// early would anchor the whole connection early, so a hard ceiling waits + /// instead of overflowing. pub fn due_now(&mut self, now_ns: u64, slack_ns: i64, allow_early: bool) -> Option { let due_ns = self.entries.front()?.due_ns; // Only a *hard* ceiling forces a frame out early. Sitting at the decode @@ -423,11 +419,8 @@ mod tests { assert_eq!(q.overflows(), 1); } - /// The distinction the packet-paced design rests on: holding the decode - /// lead is the normal steady state and must never emit a frame early. - /// Before the split, "full" meant both things, so a Target Buffer that - /// needed more decoded frames than the byte ceiling allowed degraded into - /// permanent early emission instead of just decoding later. + /// Holding the decode lead is the normal steady state and must never emit + /// a frame early; only a memory ceiling does. #[test] fn reaching_the_decode_lead_stops_intake_without_emitting_early() { // 100ms of lead, 30fps frames, no memory ceiling in reach. diff --git a/crates/irl-core/src/pts_repair.rs b/crates/irl-core/src/pts_repair.rs index 2076e5c..3ed5023 100644 --- a/crates/irl-core/src/pts_repair.rs +++ b/crates/irl-core/src/pts_repair.rs @@ -1,4 +1,4 @@ -//! Three-tier PTS discontinuity repair (port of `src/pts-repair.c`). +//! Three-tier PTS discontinuity repair. use crate::consts; use crate::rescale; @@ -25,9 +25,8 @@ pub struct Verdict { pub corrected_pts: i64, /// Silence to insert (only for `Silence`). pub silence_ms: i32, - /// Gap observed, in milliseconds. This is the C `last_action_gap_ms`: the - /// magnitude of the gap, unsigned, for both directions (the direction is - /// carried by the action — a backward jump either passes or resets). + /// Magnitude of the gap observed, in milliseconds, for both directions + /// (the action carries the direction: a backward jump passes or resets). pub gap_ms: i32, } @@ -48,7 +47,8 @@ pub struct PtsRepair { } impl PtsRepair { - /// `pts_repair_init`. + /// Repair state for one stream; gaps are in milliseconds, the time base + /// is the stream's. pub fn new(small_gap_ms: i32, large_gap_ms: i32, tb_num: i32, tb_den: i32) -> Self { Self { last_pts: 0, @@ -65,7 +65,7 @@ impl PtsRepair { } } - /// `pts_repair_reset`. + /// Forget the timeline; the next frame starts a new one. pub fn reset(&mut self) { self.last_pts = 0; self.last_duration = 0; @@ -76,7 +76,7 @@ impl PtsRepair { self.initialised = false; } - /// `pts_repair_evaluate`. + /// Judge one frame's `pts` and `duration` (stream time base). pub fn evaluate(&mut self, pts: i64, duration: i64) -> Verdict { // First frame — just record and pass through. if !self.initialised { @@ -100,17 +100,13 @@ impl PtsRepair { // within one tick of the stream's own time base. Re-baseline on the // PTS the sender actually sent. // - // The tick test is what makes a frame duration the container cannot - // express harmless. 1024 samples at 44.1 kHz is 2089.8 ticks of a - // 90 kHz clock and `duration` can only carry 2090, so the stream lands - // one tick *before* the prediction every few frames; over RTMP's - // millisecond time base a frame is 23.22 ticks against a carried 23. - // Without this, the backward wobble falls into the leading-edge rule - // below, which deliberately freezes the baseline — and the next frame - // then reads as a whole-frame forward gap and gets interpolated onto - // the frozen baseline. The repaired timeline is a frame short from - // there on and never recovers it, which lands as a standing ~23 ms - // offset in the audio→video playout mapping on every 44.1 kHz stream. + // The tick test makes a frame duration the container cannot express + // harmless: 1024 samples at 44.1 kHz is 2089.8 ticks of a 90 kHz + // clock against a carried 2090, so the stream lands one tick early + // every few frames. Without it that wobble falls into the leading-edge + // rule below, which freezes the baseline, and the repaired timeline + // ends up a frame short for good: a standing ~23 ms lip-sync error on + // every 44.1 kHz stream. if gap_ms < 1 || gap.abs() <= 1 { self.last_pts = pts; self.last_duration = if duration > 0 { @@ -235,8 +231,7 @@ impl PtsRepair { (self.tb_num, self.tb_den) } - /// Gap of the most recent evaluation, in milliseconds - /// (`pts_repair.last_action_gap_ms`). + /// Gap of the most recent evaluation, in milliseconds. pub fn last_action_gap_ms(&self) -> i32 { self.last_action_gap_ms } @@ -288,8 +283,7 @@ mod tests { /// early every few frames. The repaired timeline must track the sender, /// not lose a frame to it: the audio→video playout mapping is derived from /// these PTS, so a frame lost here is a standing ~23 ms lip-sync error on - /// every 44.1 kHz stream. 48 kHz divides the 90 kHz clock exactly and was - /// never affected, which is why this only ever showed on phone encoders. + /// every 44.1 kHz stream. 48 kHz divides the 90 kHz clock exactly. #[test] fn a_frame_duration_the_time_base_cannot_express_does_not_lose_a_frame() { for (rate, tb_den) in [(44_100i64, 90_000i64), (44_100, 1_000), (48_000, 90_000)] { diff --git a/crates/irl-core/src/speed.rs b/crates/irl-core/src/speed.rs index 754e985..4eb52ce 100644 --- a/crates/irl-core/src/speed.rs +++ b/crates/irl-core/src/speed.rs @@ -1,12 +1,10 @@ -//! Playback-speed controller and stuck-drain watch (ports of -//! `compute_buffered_output_speed`, `audio_update_speed_trim`, -//! `apply_output_speed`'s fractional carry and `audio_check_drain_progress`). +//! Playback-speed controller, fractional sample carry and stuck-drain watch. //! //! The controller is PI: a fast proportional ramp with an almost-flat //! deadband and asymmetric authority, plus a slow integral trim underneath it. -//! `docs/audio-timing-pitfalls.md` is the record of why each piece is shaped -//! the way it is; `cargo run -p irl-core --example speed-controller-sim` is -//! the closed-loop harness that found every defect it ever had. +//! `docs/audio-timing-pitfalls.md` explains why each piece is shaped the way +//! it is; `cargo run -p irl-core --example speed-controller-sim` is the +//! closed-loop harness. use crate::config::Watermarks; use crate::consts; @@ -36,35 +34,24 @@ pub struct SpeedInputs { pub max_speed: f32, /// FFmpeg `av_gettime` microseconds, for the trim's dt. pub now_us: u64, - /// `irl_audio_recovery_active`: concealment or post-reset recovery is - /// moving the fill for reasons that are not the sender's clock. + /// Concealment or post-reset recovery is moving the fill for reasons that + /// are not the sender's clock. pub recovery_active: bool, } /// The integral term that holds the buffer at target when the sender's media /// clock is not wall clock. /// -/// The ramp in [`SpeedController`] is proportional: it only produces a speed -/// away from 1.0 while the buffer sits away from target. That is the right -/// shape for a transient — a stall's backlog drains and the ramp relaxes — -/// but it cannot hold a *constant*. A sender whose media clock runs at 1.003× -/// delivers 3 ms of extra audio every second forever, and the only ramp -/// position that consumes it is one with a permanent level error, so the -/// buffer parks off-target and the latency parks with it, right up until the -/// offset re-anchor concedes and splices. +/// The proportional ramp in [`SpeedController`] can only hold a constant rate +/// offset (a sender at 1.003× delivers 3 ms of extra audio every second) with +/// a permanent level error, so the buffer and the latency park off target. +/// The trim removes that error. It accumulates only near target, where the +/// level reports the sender's rate, is clamped to ±1 %, and converges on the +/// rate without measuring it (see `docs/audio-timing-pitfalls.md`). /// -/// The trim removes that standing error. It accumulates only in the ramp's -/// linear region, where the level genuinely reports the sender's rate, and is -/// clamped to ±1 %. It converges to the sender's rate without ever measuring -/// it — see the estimator section of `docs/audio-timing-pitfalls.md` for the -/// measurement that was built, measured and deleted. -/// -/// Lives in the audio state rather than on the pump because its lifetime is -/// not the pump's: it deliberately survives a throttled decoder flush (an -/// audio-only reset must not cost two minutes of relearning) and is cleared by -/// a stream reset, where the timeline broke badly enough that the level no -/// longer maps to the sender's clock and a reconnect may not even be the same -/// encoder. +/// It survives a decoder flush (an audio-only reset must not cost two minutes +/// of relearning) and is cleared by a stream reset, after which the level no +/// longer maps to the sender's clock. #[derive(Debug, Clone, Copy, Default)] pub struct SpeedTrim { value: f32, @@ -85,7 +72,7 @@ impl SpeedTrim { self.value } - /// Forget the sender (`irl_reset_stream_timing_state`). + /// Forget the sender. pub fn reset(&mut self) { *self = Self::new(); } @@ -122,18 +109,12 @@ impl SpeedTrim { let err_s = err_ms as f64 / 1000.0; let step = consts::AUDIO_SPEED_TRIM_GAIN * err_s * dt; - // Anti-windup, the second half of the error-window gate above. - // - // While the loop is saturated the level has stopped reporting the - // sender's rate — it reports that the controller ran out of authority. - // At the default target the window gate already covers this, but a - // small target puts min_ms within 60 ms of target and the command can - // pin while the error is still inside the window, so the check earns - // its place here. - // - // Test the command actually issued, not the ramp alone: the actuator - // clamps ramp + trim, so with the trim near its own limit the sum - // saturates while the ramp is still short of it. + // Anti-windup. While the loop is saturated the level reports that the + // controller ran out of authority, not the sender's rate. A small + // target puts min_ms inside the error window, so the window gate alone + // does not cover it. Test the command actually issued, not the ramp + // alone: the actuator clamps ramp + trim, so the sum can saturate + // while the ramp is still short of it. let command = ramp + self.value; let pinned_high = command >= inp.max_speed - 0.0005; let pinned_low = command <= consts::AUDIO_SPEED_MIN + 0.0005; @@ -180,12 +161,8 @@ impl SpeedController { return 1.0; } - // Regulate the smoothed level, not the instantaneous one. An upstream - // that hands over batches rather than a smooth stream — a remux hop, a - // relay — makes the level sawtooth across the whole ramp, and a - // controller that chases that modulates playback speed at the batch - // period. That is audible as pitch wobble and, because video due times - // ride the audio playout offset, visible as judder. + // Regulate the smoothed level, not the instantaneous one; see + // `AUDIO_SPEED_LEVEL_SMOOTHING`. if self.level < 0.0 { self.level = fill_ms as f32; } @@ -195,12 +172,11 @@ impl SpeedController { let ramp = Self::ramp(fill_ms, inp.wm, inp.max_speed); trim.update(fill_ms, inp.wm.target_ms, ramp, &inp); - // The trim shifts the operating point the ramp swings around; the hard - // clamp below is unchanged, because the min and the catch-up ceiling - // are the audibility limits and hold absolutely. That the slow-down - // authority shrinks to −1 % once the trim has learned +1 % is correct, - // not a loss: having established that the sender runs fast, dropping - // to 0.98 absolute would be over-correcting. + // The trim shifts the operating point the ramp swings around, but the + // clamp below stays absolute: the min and the catch-up ceiling are the + // audibility limits. Once the trim has learned +1 % the slow-down + // authority shrinks to −1 %, which is correct for a sender known to + // run fast. let target_speed = ramp + trim.value(); if self.current <= 0.0 { @@ -264,13 +240,10 @@ impl Default for SpeedController { /// The fractional output-sample debt carried between chunks. /// /// The resampler is driven in whole samples per chunk, so rounding each chunk -/// independently quantises the applied speed to multiples of `1/in_frames` — -/// about 0.1 % at 1024 frames. Everything the controller asks for below that -/// either rounds away to 1.0 or gets executed at twice its size, which makes -/// both the deadband slope and the trim meaningless, and makes the -/// compensation chatter on and off as the request crosses a rounding boundary. -/// Carrying the remainder makes the long-run rate exact at any requested -/// speed. +/// independently quantises the applied speed to multiples of `1/in_frames` +/// (about 0.1 % at 1024 frames). Below that a request rounds away to 1.0 or +/// runs at twice its size, which defeats the deadband slope and the trim. +/// Carrying the remainder makes the long-run rate exact at any speed. #[derive(Debug, Clone, Copy, Default)] pub struct SpeedCarry { frac: f64, @@ -282,7 +255,7 @@ impl SpeedCarry { Self { frac: 0.0 } } - /// Forget the debt (`irl_reset_audio_timing_state`). + /// Forget the debt. pub fn reset(&mut self) { *self = Self::new(); } @@ -440,8 +413,8 @@ mod tests { #[test] fn the_ramp_is_continuous_across_the_deadband_edges() { - // The old flat deadband stepped here. One millisecond either side of - // an edge must now differ by about one millisecond's worth of slope. + // One millisecond either side of an edge differs by about one + // millisecond's worth of slope, not a step. for edge in [100, 140] { let inside = settle(edge); let outside = settle(if edge == 100 { edge - 1 } else { edge + 1 }); @@ -654,7 +627,7 @@ mod tests { #[test] fn the_loop_settles_rather_than_limit_cycling() { - // The flat deadband left the integrator undamped and the pair swung + // A flat deadband leaves the integrator undamped and the pair swings // ±20 ms forever. Measure the swing after it has had time to settle. const DT_US: u64 = 21_333; let mut c = SpeedController::new(); @@ -831,9 +804,8 @@ mod tests { #[test] fn the_carry_applies_speeds_below_the_rounding_step() { // Without it, 1/1024 (~0.1 %) is the smallest applicable speed: a - // requested +0.02 % was discarded and +0.05 % came out at +0.098 %. - // That is the whole range the deadband slope and most of the trim - // operate in. + // requested +0.02 % would be discarded and +0.05 % would come out at + // +0.098 %, the whole range the deadband slope and the trim work in. for req in [ 1.0f32, 1.0002, 1.0005, 1.001, 1.002, 1.005, 1.01, 0.9995, 0.998, 0.99, ] { diff --git a/crates/irl-core/src/timing.rs b/crates/irl-core/src/timing.rs index 5d2a433..d26856b 100644 --- a/crates/irl-core/src/timing.rs +++ b/crates/irl-core/src/timing.rs @@ -1,12 +1,12 @@ -//! Output-clock arithmetic (port of `receiver-audio.c:267-329`). +//! Output-clock arithmetic. use crate::consts; use crate::rescale; /// `anchor + samples / rate` in nanoseconds: the pure sample-counter clock. /// -/// Ports `audio_output_next_ts`. The whole audio contract rests on this being -/// a counter and not a clock read: OBS wants `ts[n+1] = ts[n] + frames/rate` +/// The whole audio contract rests on this being a counter and not a clock +/// read: OBS wants `ts[n+1] = ts[n] + frames/rate` /// exactly, so the timestamp is always re-derived from the anchor and the /// running sample count rather than accumulated. pub fn output_next_ts(anchor_ns: u64, samples: u64, rate: u32) -> u64 { @@ -20,9 +20,8 @@ pub fn output_next_ts(anchor_ns: u64, samples: u64, rate: u32) -> u64 { /// Lead kept ahead of wall clock: `max(AUDIO_OUT_LEAD_MS, 3 chunks)`, or one /// chunk in low-latency mode. /// -/// Ports `audio_output_lead_ns`. The 80 ms floor has to cover the plugin's own -/// delivery jitter (1 ms pump sleep plus scheduling) and one OBS mix tick -/// (21.3 ms), with margin. +/// The 80 ms floor has to cover the plugin's own delivery jitter (1 ms pump +/// sleep plus scheduling) and one OBS mix tick (21.3 ms), with margin. pub fn output_lead_ns(chunk_samples: i32, rate: i32, low_latency: bool) -> u64 { if rate <= 0 || chunk_samples <= 0 { return if low_latency { @@ -45,8 +44,6 @@ pub fn output_lead_ns(chunk_samples: i32, rate: i32, low_latency: bool) -> u64 { /// Samples a packet of `duration` (stream time base) should contain at /// `rate`; `fallback` when the duration is unusable. -/// -/// Ports `audio_expected_samples`. pub fn expected_samples(duration: i64, tb_num: i32, tb_den: i32, rate: i32, fallback: i32) -> i32 { if duration <= 0 || rate <= 0 || tb_den <= 0 { return fallback; @@ -61,9 +58,8 @@ pub fn expected_samples(duration: i64, tb_num: i32, tb_den: i32, rate: i32, fall /// `expected − actual` clamped to ±`AUDIO_SOFT_COMPENSATION_MAX_SAMPLES`, /// zero outside that window. /// -/// Ports `audio_soft_compensation_samples`. Real discontinuities are PTS -/// repair's job; this only takes out tiny per-frame drift, in the spirit of a -/// bounded `aresample` async correction. +/// Real discontinuities are PTS repair's job; this only takes out tiny +/// per-frame drift, in the spirit of a bounded `aresample` async correction. pub fn soft_compensation_samples(expected: i32, actual: i32) -> i32 { let delta = expected - actual; let window = @@ -75,8 +71,6 @@ pub fn soft_compensation_samples(expected: i32, actual: i32) -> i32 { } /// Fill required before priming: `target + lead`, or 0 in low-latency mode. -/// -/// Ports the prime gate in `irl_pump_audio_once` (`receiver-audio.c:776-783`). pub fn prime_threshold_ms(target_ms: i32, lead_ns: u64, low_latency: bool) -> i32 { if low_latency { return 0; @@ -87,31 +81,23 @@ pub fn prime_threshold_ms(target_ms: i32, lead_ns: u64, low_latency: bool) -> i3 /// Frames to read this cycle so the buffer's reachable levels straddle the /// target evenly instead of landing wherever priming happened to leave them. /// -/// Reads and writes are both whole decoded chunks, so the level can only ever -/// be `phase + k · chunk` — 21.3 ms steps for 1024-sample AAC. It dithers -/// between the two grid points either side of the target as writes and reads -/// alternate, and `phase` is set by accident at priming. On a 120 ms target -/// with AAC that was 106 ms and 128 ms: the low state is 14 ms short, so the -/// *average* cushion the user gets is up to a chunk below what they asked for. -/// See "The buffer level is quantised to one chunk" in -/// `docs/audio-timing-pitfalls.md`. +/// Reads and writes are both whole decoded chunks, so the level can only be +/// `phase + k · chunk` (21.3 ms steps for 1024-sample AAC) and dithers between +/// the two grid points either side of the target, with `phase` set by accident +/// at priming. The average cushion can then be up to a chunk below the target +/// (see `docs/audio-timing-pitfalls.md`). /// -/// One read of a different size moves `phase`, permanently. Centring the pair -/// on the target — reachable levels at `target ± chunk/2` — makes the average -/// cushion the configured one and halves the worst-case shortfall. Note that -/// putting a grid point *on* the target would be worse, not better: the pair -/// becomes `target` and `target − chunk`, so the average sits half a chunk low. +/// One read of a different size moves `phase` for good. Centring the pair on +/// the target (levels at `target ± chunk/2`) makes the average cushion the +/// configured one. Putting a grid point *on* the target would be worse: the +/// pair becomes `target` and `target − chunk`, half a chunk low on average. /// -/// Nothing is skipped or duplicated — this cycle consumes a little more or less -/// and the remainder is still there next cycle. It is a phase correction, not a -/// rate one, so it cannot fight the speed controller, which owns the rate: over -/// any window the average read size still equals the average write size. +/// Nothing is skipped or duplicated, and it is a phase correction, not a rate +/// one, so it cannot fight the speed controller. /// -/// Returns `base_frames` when the grid is already centred, and otherwise a size -/// within half a chunk of a normal read — long rather than absurdly short when -/// the correction is large. The caller must have that many frames buffered, -/// which after priming it does by construction (priming waits for the target -/// plus the output lead). +/// Returns `base_frames` when the grid is already centred, otherwise a size +/// within half a chunk of a normal read. The caller must have that many frames +/// buffered, which after priming it does by construction. pub fn aligning_read_frames(fill_frames: i64, target_frames: i64, base_frames: i32) -> i32 { if base_frames <= 0 || fill_frames <= 0 { return base_frames; @@ -136,8 +122,8 @@ pub fn aligning_read_frames(fill_frames: i64, target_frames: i64, base_frames: i frames as i32 } -/// Nanoseconds for `frames` at `rate`, truncating (the C `chunk_ns` / -/// `stream_duration_ns` form: plain integer division, not `av_rescale`). +/// Nanoseconds for `frames` at `rate`, truncating (plain integer division, +/// not `av_rescale`). pub fn frames_to_ns(frames: u64, rate: u32) -> u64 { if rate == 0 { return 0; @@ -181,8 +167,8 @@ mod alignment_tests { #[test] fn one_read_centres_the_dither_on_the_target() { - // The log this came from: primed at 213ms against a 120ms target and - // dithering between 106ms and 128ms, so the average cushion ran short. + // Primed at 213ms against a 120ms target, the level dithers between + // 106ms and 128ms, so the average cushion runs short. assert!(straddles_target(ms(213.0), ms(120.0), BASE)); // And from wherever else priming happens to land. for fill_ms in [100, 121, 150, 200, 213, 400, 874, 8000] { diff --git a/crates/irl-core/src/url_opts.rs b/crates/irl-core/src/url_opts.rs index 5f63b19..7bf68ab 100644 --- a/crates/irl-core/src/url_opts.rs +++ b/crates/irl-core/src/url_opts.rs @@ -1,9 +1,6 @@ -//! Demuxer option table (port of `apply_demuxer_options`, `receiver-stream.c:20-187`). -//! -//! The C function writes straight into an `AVDictionary`; here it is an -//! ordered list the caller feeds to `av_dict_set` in order, which gives the -//! same result — later entries override earlier ones, and the user's own -//! options come last so they win. +//! Demuxer option table: an ordered list the caller feeds to `av_dict_set` +//! in order, so later entries override earlier ones and the user's own +//! options, which come last, win. use std::borrow::Cow; @@ -107,10 +104,8 @@ pub fn demuxer_options( opts } -/// Parse space-separated `key=value` pairs; entries without `=` are ignored. -/// -/// Ports the `strtok_r(dup, " ")` loop: runs of spaces collapse, and a token -/// splits at its first `=`. +/// Parse space-separated `key=value` pairs; entries without `=` are ignored, +/// runs of spaces collapse, and a token splits at its first `=`. pub fn parse_extra(extra: &str) -> Vec<(String, String)> { extra .split(' ') @@ -130,10 +125,10 @@ pub fn parse_extra(extra: &str) -> Vec<(String, String)> { /// waits the same way from the caller's point of view. The option reaches /// FFmpeg either way the user writes it — in the URL's query string or in the /// FFmpeg Options field, which [`demuxer_options`] merges into the same -/// dictionary — so both are read here. Reading only the URL made a listener -/// configured through FFmpeg Options keep the caller's deadline: the accept -/// was torn down every 10 s and rebound after the reconnect delay, and a -/// sender whose handshake landed in the gap never got in. +/// dictionary — so both are read here. Reading only the URL would leave a +/// listener configured through FFmpeg Options on the caller's deadline: the +/// accept is torn down every 10 s and rebound after the reconnect delay, and +/// a sender whose handshake lands in the gap never gets in. /// /// The URL test is on the query only: a path or a passphrase that happens to /// contain the word must not decide this. @@ -179,7 +174,7 @@ mod tests { assert!(!has_scheme("srt:/host", "srt")); assert!(!has_scheme("srtla://host", "srt")); assert!(!has_scheme("", "srt")); - // A bare scheme still matches, as the C strncmp pair does. + // A bare scheme still matches. assert!(has_scheme("srt://", "srt")); } diff --git a/crates/irl-core/src/video_delay.rs b/crates/irl-core/src/video_delay.rs index 5f978bd..1e2682d 100644 --- a/crates/irl-core/src/video_delay.rs +++ b/crates/irl-core/src/video_delay.rs @@ -1,44 +1,24 @@ //! The standing delay on the video schedule. //! -//! Pacing hands each frame to libobs before it is due, which is what keeps -//! libobs's async queue about one frame deep and the cadence smooth. A frame -//! handed over *after* it is due is shown a canvas tick late, and when two -//! late frames reach libobs inside one tick it discards the older one: that is -//! the "low fps" a stream shows when its video reaches the plugin later than -//! the audio of the same instant by more than Target Buffer covers. How early -//! a frame is in hand is the sender's to set, so the plugin cannot make a late -//! frame early; what it can do is move the whole video schedule later by a -//! fixed amount, so that the frames stop being late. +//! A frame handed to libobs after it is due is shown a tick late, and when two +//! late frames land inside one tick libobs drops the older one ("low fps"). +//! The plugin cannot make a late frame early, so it moves the whole video +//! schedule later by this delay, sized from the worst shortfall seen. It +//! trades a known lip-sync error for a smooth picture. //! -//! That amount is the delay here. It is added to every due time and sized from -//! the worst shortfall seen. It trades a fixed, known lip-sync error for a -//! smooth picture; the caller's log line says how much more Target Buffer -//! would take the error back to zero. Before libobs's play head is anchored -//! nothing has been shown yet, so a shortfall is acted on at once. Afterwards a -//! raise moves the picture (one frame holds for the size of the raise), so it -//! takes a shortfall that recurs across a window, not a single late frame from -//! a scheduling hiccup. +//! Before libobs's play head is anchored nothing has been shown, so a +//! shortfall raises the delay at once. Afterwards a raise moves the picture, +//! so it takes a shortfall that recurs across a window. //! -//! The delay is also taken back, slowly. The measurement that sets it before -//! the anchor is one frame at the start of a connection, and on a poor link -//! that moment is a burst followed by catch-up (a relay replaying video from -//! an older keyframe next to live audio, a throttled uplink draining its -//! queue): the frame can look more than a second late on a stream that has no -//! skew at all once it settles, and a delay that never shrank then held -//! on-time video that far behind its audio for the whole connection. So every -//! frame handed over also reports its *arrival* margin, and when a whole -//! window of them needed less than the delay in force by more than a -//! threshold, the delay ramps down to what the window needed plus a tick. It -//! ramps rather than steps: due times move a little earlier each cycle, so -//! the picture plays a few percent fast (the caller passes the Catch-Up -//! Speed) and nothing jumps or is skipped. A raise cancels a ramp. +//! The pre-anchor reading is one frame and can be a startup burst, so the +//! delay also comes back down: when a whole window of frames needed less by +//! more than a threshold, it ramps (never steps) to what the window needed +//! plus a tick, at the Catch-Up Speed the caller passes. A raise cancels a +//! ramp. //! -//! The bar is deliberately "not late", not "a full delivery lead early". The -//! lead the pacing queue applies to a frame it holds is an allowance for its -//! own timer oversleeping on the way to the due time; a frame handed over on -//! arrival never sleeps, and libobs shows it on time as long as it is in the -//! queue before its due time passes. Asking every frame for the lead would -//! delay a healthy stream by the lead for nothing. +//! The bar is "not late", not "a full delivery lead early": the lead covers +//! the pacing timer oversleeping, and a frame handed over on arrival never +//! sleeps. use crate::consts; diff --git a/crates/irl-core/src/video_time.rs b/crates/irl-core/src/video_time.rs index 3ce34e3..4743198 100644 --- a/crates/irl-core/src/video_time.rs +++ b/crates/irl-core/src/video_time.rs @@ -1,9 +1,5 @@ -//! Video timestamp mapping (port of `video-handler.c:285-411`). -//! -//! The arithmetic of `irl_video_due_time` and `video_record_lead`, extracted -//! from the locking and logging around it: what those C functions do beyond -//! this is snapshot audio-thread state under `audio_state_lock` and throttle a -//! warning line, both of which belong to the plugin crate. +//! Video timestamp mapping: the arithmetic behind video due times and the +//! lead libobs can absorb. Locking and logging stay in the plugin crate. use crate::consts; @@ -12,18 +8,16 @@ use crate::consts; /// `pts` lands at `obs_end + (pts − buffered_end)`. Negative results clamp to /// zero. /// -/// Ports the mapped branch of `irl_video_due_time` (`video-handler.c:360-369`) -/// and the offset form used by `irl_video_playout_offset`. The caller decides -/// whether a mapping exists at all: the C test is `obs_end_ts_ns != 0 && -/// buffered_end_pts_ns > 0`, and it holds the last known offset for +/// The caller decides whether a mapping exists at all (`obs_end_ts_ns != 0 +/// && buffered_end_pts_ns > 0`), and holds the last known offset for /// `VIDEO_OFFSET_HOLD_NS` after that stops being true. pub fn map_through_playout(pts_ns: i64, obs_end_ts_ns: u64, buffered_end_pts_ns: i64) -> u64 { let mapped = pts_ns + (obs_end_ts_ns as i64 - buffered_end_pts_ns); if mapped < 0 { 0 } else { mapped as u64 } } -/// The stream-PTS → OBS-clock offset the mapping implies -/// (`irl_video_playout_offset`), for re-deriving queued frames' due times. +/// The stream-PTS → OBS-clock offset the mapping implies, for re-deriving +/// queued frames' due times. pub fn playout_offset_ns(obs_end_ts_ns: u64, buffered_end_pts_ns: i64) -> i64 { obs_end_ts_ns as i64 - buffered_end_pts_ns } @@ -31,9 +25,8 @@ pub fn playout_offset_ns(obs_end_ts_ns: u64, buffered_end_pts_ns: i64) -> i64 { /// Video-only fallback anchored at the first frame: drift below −500 ms shows /// now, drift above +500 ms caps at now + 200 ms. /// -/// Ports `video-handler.c:371-388`. The anchor deliberately stays put — a -/// re-anchor would be a visible timeline jump, while clamping lets ordinary -/// frames self-correct after a burst. +/// The anchor deliberately stays put: a re-anchor would be a visible timeline +/// jump, while clamping lets ordinary frames self-correct after a burst. pub fn fallback_anchor(pts_ns: i64, pts_base_ns: i64, sys_base_ns: u64, now_ns: u64) -> u64 { let computed = sys_base_ns.wrapping_add((pts_ns - pts_base_ns) as u64); let drift = computed as i64 - now_ns as i64; @@ -50,11 +43,10 @@ pub fn fallback_anchor(pts_ns: i64, pts_base_ns: i64, sys_base_ns: u64, now_ns: /// Lead libobs can absorb: `OBS_ASYNC_FRAME_BUDGET × frame_interval`, floored /// at the audio re-anchor margin. /// -/// Ports the budget half of `video_record_lead` (`video-handler.c:293-297`). -/// The caller adds `buffer_target_ms × 1e6` — the jitter buffer's own -/// contribution to the lead — to get the C's `queue_safe_ns`. The budget is -/// expressed in frames because that is what libobs counts: the same 400 ms is -/// 12 frames at 30 fps and 48 at 120 fps. +/// The caller adds the jitter buffer's own contribution to the lead +/// (`buffer_target_ms × 1e6`). The budget is expressed in frames because that +/// is what libobs counts: the same 400 ms is 12 frames at 30 fps and 48 at +/// 120 fps. pub fn queue_safe_ns(frame_interval_ns: i64) -> i64 { let interval = if frame_interval_ns <= 0 { consts::VIDEO_INTERVAL_DEFAULT_NS @@ -72,11 +64,9 @@ pub fn queue_safe_ns(frame_interval_ns: i64) -> i64 { /// EMA (1/8 step) of PTS deltas, ignoring deltas outside the 4–100 ms window. /// -/// Ports the interval estimator in `irl_handle_video_frame` -/// (`receiver-video.c:420-437`). Measured rather than taken from -/// `avg_frame_rate`, which live SRT/RTMP demuxers routinely leave unset or -/// wrong; out-of-range deltas (PTS repair, discontinuities, reordering) are -/// skipped rather than smoothed in. +/// Measured rather than taken from `avg_frame_rate`, which live SRT/RTMP +/// demuxers routinely leave unset or wrong; out-of-range deltas (PTS repair, +/// discontinuities, reordering) are skipped rather than smoothed in. pub fn interval_ema(prev_ns: i64, delta_ns: i64) -> i64 { let usable = consts::VIDEO_INTERVAL_MIN_NS..=consts::VIDEO_INTERVAL_MAX_NS; if !usable.contains(&delta_ns) { From 06982e78beac304cfd5f7574865ec74dc0922550 Mon Sep 17 00:00:00 2001 From: datagutt Date: Fri, 2 Oct 2026 03:14:32 +0200 Subject: [PATCH 23/28] docs(irl-provider): drop a comment that restates its name --- crates/irl-provider/src/loopback.rs | 1 - 1 file changed, 1 deletion(-) diff --git a/crates/irl-provider/src/loopback.rs b/crates/irl-provider/src/loopback.rs index 67f5e2e..aa5ca4f 100644 --- a/crates/irl-provider/src/loopback.rs +++ b/crates/irl-provider/src/loopback.rs @@ -79,7 +79,6 @@ pub fn parse_query(request_line: &str) -> HashMap { out } -/// How a redirect resolves. #[derive(Clone, Debug, PartialEq, Eq)] pub enum Outcome { Code(String), From 3b6320cec49ed5badd7a8504232898763dc6c8de Mon Sep 17 00:00:00 2001 From: datagutt Date: Fri, 2 Oct 2026 03:14:46 +0200 Subject: [PATCH 24/28] docs(irl-source): trim shell and receiver comments to what the code cannot say --- crates/irl-source/src/config.rs | 17 +++--- crates/irl-source/src/lib.rs | 6 +- crates/irl-source/src/log.rs | 19 +++--- crates/irl-source/src/receiver/audio_in.rs | 22 +++---- crates/irl-source/src/receiver/decode.rs | 25 +++----- crates/irl-source/src/receiver/mod.rs | 29 ++++----- crates/irl-source/src/receiver/probe.rs | 5 +- crates/irl-source/src/receiver/stream.rs | 44 +++++++------- crates/irl-source/src/settings.rs | 11 +--- crates/irl-source/src/shared.rs | 69 +++++++++------------- crates/irl-source/src/source.rs | 37 +++++------- crates/irl-source/src/websocket.rs | 5 +- 12 files changed, 116 insertions(+), 173 deletions(-) diff --git a/crates/irl-source/src/config.rs b/crates/irl-source/src/config.rs index 802175a..448dcda 100644 --- a/crates/irl-source/src/config.rs +++ b/crates/irl-source/src/config.rs @@ -1,5 +1,4 @@ -//! Settings loading, restart diffing and hot apply (port of -//! `config_load` / `config_requires_restart` / `config_apply_hot`). +//! Settings loading, restart diffing and hot apply. use std::ffi::{CStr, CString}; use std::sync::atomic::Ordering::Relaxed; @@ -25,12 +24,11 @@ pub struct Config { } impl Config { - /// `config_load`. pub fn load(settings: &Data<'_>) -> Self { - // `Data::get_str` already returns `None` for an empty string, which - // is the C `url && *url` idiom. An interior NUL cannot survive - // `obs_data_get_string`, but a lossy conversion is still cheaper than - // a panic path: an unusable URL becomes "no URL". + // `Data::get_str` already returns `None` for an empty string. An + // interior NUL cannot survive `obs_data_get_string`, but a lossy + // conversion is still cheaper than a panic path: an unusable URL + // becomes "no URL". let url = settings .get_str(c"url") .and_then(|s| CString::new(s).ok()) @@ -63,8 +61,7 @@ impl Config { .clamp(consts::CATCHUP_PERCENT_MIN, consts::CATCHUP_PERCENT_MAX), wait_for_keyframe: settings.get_bool(c"wait_for_keyframe"), clear_on_disconnect: settings.get_bool(c"clear_on_disconnect"), - // A non-positive target falls back to the default, as - // `config_load` does. + // A non-positive target falls back to the default. watermarks: Watermarks::derive(settings.get_i64(c"buffer_target_ms") as i32), }, close_when_inactive: settings.get_bool(c"close_when_inactive"), @@ -89,7 +86,7 @@ impl Config { || self.stream.low_latency_audio != other.stream.low_latency_audio } - /// `config_apply_hot`: swap the live settings into a running receiver. + /// Swap the live settings into a running receiver. /// /// Lock order is the documented one: `audio_state`, then the jitter /// buffer, then the watermark mutex. Nothing below takes any of them. diff --git a/crates/irl-source/src/lib.rs b/crates/irl-source/src/lib.rs index d72c30b..54d7d9c 100644 --- a/crates/irl-source/src/lib.rs +++ b/crates/irl-source/src/lib.rs @@ -1,7 +1,5 @@ -//! obs-irl-source — IRL streaming source plugin for OBS Studio. -//! -//! Module entry points and the source registration. Everything unsafe lives -//! in the `obs` and `ffmpeg` crates; this crate is plain safe Rust. +//! The IRL Source plugin for OBS Studio: module entry points and the source +//! registration. Everything unsafe lives in the `obs` and `ffmpeg` crates. #![forbid(unsafe_code)] diff --git a/crates/irl-source/src/log.rs b/crates/irl-source/src/log.rs index a21aa84..153415e 100644 --- a/crates/irl-source/src/log.rs +++ b/crates/irl-source/src/log.rs @@ -32,7 +32,7 @@ pub fn emit(level: obs::log::Level, args: std::fmt::Arguments<'_>) { /// The redacted form of an input URL: protocol, hostname and port only. /// Paths, userinfo, query parameters and fragments can all contain /// credentials (SRT passphrases, RTMP stream keys), so they are never copied -/// into the log. Port of the C `irl_log_input_url` (master 706372c). +/// into the log. pub fn redacted_input_url(url: &std::ffi::CStr) -> String { let p = ffmpeg::url_split(url); if p.protocol.is_empty() { @@ -75,13 +75,11 @@ const SENSITIVE_PARAMS: &[&str] = &[ /// One line of FFmpeg's own logging, with every credential removed. /// -/// FFmpeg logs whole URLs at warning and error level — `libavformat/libsrt.c` -/// prints `h->filename` for "Connection to %s failed", which is the user's -/// full `srt://host:port?passphrase=…&streamid=…` — so routing its log into -/// the OBS log (see [`route_ffmpeg_log`]) would otherwise undo the redaction -/// [`redacted_input_url`] exists for. Every `scheme://…` token is cut down to -/// the same protocol/host/port form, and the value of any -/// [`SENSITIVE_PARAMS`] key that survives outside a URL is dropped. +/// FFmpeg logs whole URLs at warning and error level (`libavformat/libsrt.c` +/// prints `h->filename`, the user's full `srt://…?passphrase=…&streamid=…`, +/// for "Connection to %s failed"). Every `scheme://…` token is cut down to the +/// [`redacted_input_url`] form, and the value of any `SENSITIVE_PARAMS` key +/// that survives outside a URL is dropped. pub fn redacted_log_line(line: &str) -> String { redact_sensitive_params(&redact_urls(line)) } @@ -190,9 +188,8 @@ fn is_param_terminator(c: char) -> bool { /// Route the bundled FFmpeg's warnings and errors into the OBS log as /// `[irl-source] [ffmpeg] `, redacted. /// -/// Port of the C `irl_ffmpeg_log` (master f06d705), which the plugin needs -/// because its FFmpeg is statically linked and hidden behind the module's -/// symbol map: the host's own `av_log` callback cannot see it, and FFmpeg's +/// The bundled FFmpeg is statically linked and hidden behind the module's +/// symbol map, so the host's own `av_log` callback cannot see it, and FFmpeg's /// default one writes to a stderr a Windows OBS does not have. pub fn route_ffmpeg_log() { ffmpeg::log::route_to(|level, line| { diff --git a/crates/irl-source/src/receiver/audio_in.rs b/crates/irl-source/src/receiver/audio_in.rs index 7e2f750..1f91ff4 100644 --- a/crates/irl-source/src/receiver/audio_in.rs +++ b/crates/irl-source/src/receiver/audio_in.rs @@ -22,8 +22,7 @@ pub struct AudioIntake { } impl AudioIntake { - /// Per-connection reset (`irl_prepare_new_connection` for the audio - /// fields): drops the resampler, resets PTS repair. + /// Per-connection reset: drops the resampler, resets PTS repair. pub fn reset(&mut self) { self.swr = None; if let Some(pts) = self.pts.as_mut() { @@ -32,8 +31,8 @@ impl AudioIntake { self.last_sample = LastSample::default(); } - /// (Re)initialise PTS repair for the audio stream's time base - /// (`pts_repair_init` at decoder open and after a decoder flush). + /// (Re)initialise PTS repair for the audio stream's time base, at decoder + /// open and after a decoder flush. pub fn init_pts_repair(&mut self, tb: ffmpeg::Rational) { self.pts = Some(PtsRepair::new( consts::SMALL_GAP_MS, @@ -98,8 +97,7 @@ impl AudioIntake { consts::AUDIO_RECOVERY_HOLD_US, ); drop(state); - // The video half of `irl_reset_stream_timing_state`. Video - // decode runs on its own thread now, so this is a request + // Video decode runs on its own thread, so this is a request // rather than a write: it picks it up on its next cycle. shared.video_flags.corrupted.store(false, Relaxed); shared.video_flags.timeline_reset.store(true, Relaxed); @@ -130,9 +128,8 @@ impl AudioIntake { None => return, } } else if inserted_silence { - // The C faded the decoder's own buffer in place; a decoded frame - // is borrowed immutably here, so the (rare) fade path copies it - // into the scratch first. + // A decoded frame is borrowed immutably, so the (rare) fade path + // copies it into the scratch first. let Some(bytes) = frame.interleaved_f32_bytes() else { return; }; @@ -180,7 +177,7 @@ impl AudioIntake { state.decoded_frame_samples = out_samples; } - /// The PTS half of `irl_handle_audio_frame`: the frame's timestamp (or an + /// The PTS half of [`Self::handle_frame`]: the frame's timestamp (or an /// extrapolated one), its duration, and the repair verdict. `None` drops /// the frame. fn evaluate_pts( @@ -215,7 +212,7 @@ impl AudioIntake { Some((verdict, Rational::new(tb_num, tb_den), duration)) } - /// The format-change half of `irl_handle_audio_frame`: (re)build the + /// The format-change half of [`Self::handle_frame`]: (re)build the /// jitter buffer and restart the output clock. Returns false when the /// buffer could not be configured (the frame is then dropped). fn ensure_buffer_format(&mut self, shared: &Shared, out_rate: i32, out_channels: i32) -> bool { @@ -260,7 +257,7 @@ impl AudioIntake { reconfigured } - /// The `PTS_ACTION_SILENCE` branch: shaped silence, timestamped to end + /// The [`PtsAction::Silence`] branch: shaped silence, timestamped to end /// where the repaired frame begins. fn insert_silence( &mut self, @@ -351,7 +348,6 @@ impl AudioIntake { } } -/// `audio_frame_duration_ms`. fn audio_frame_duration_ms(samples: i32, sample_rate: i32) -> i32 { if samples <= 0 || sample_rate <= 0 { return 0; diff --git a/crates/irl-source/src/receiver/decode.rs b/crates/irl-source/src/receiver/decode.rs index 1d0bfb0..d8e3c95 100644 --- a/crates/irl-source/src/receiver/decode.rs +++ b/crates/irl-source/src/receiver/decode.rs @@ -95,20 +95,14 @@ fn drain_audio_frames( } /// The video decoder is never flushed on a corruption burst, unlike the audio -/// one. `avcodec_flush_buffers` empties the reference picture buffer and clears -/// the decoder's recovery state, and neither the H.264 nor the HEVC decoder can -/// produce a real picture again until the next IDR/CRA: h264dec paints every -/// frame gray until it sees a recovery point, and the HEVC decoder synthesizes -/// each missing reference as a flat mid-gray frame that every later P-frame is -/// predicted from. So the flush turned "a few damaged frames" into a whole GOP -/// of gray — one to two seconds at the keyframe intervals IRL encoders use — on -/// exactly the lossy streams it was meant to help. A decoder error on a live -/// stream is a property of the packet, not of the decoder's state; the next -/// intact packet decodes fine without any reset, and the reference chain heals -/// at the next keyframe either way. The burst is still counted and logged so it -/// shows up in diagnostics. +/// one. `avcodec_flush_buffers` empties the reference picture buffer, and +/// neither the H.264 nor the HEVC decoder produces a real picture again until +/// the next IDR/CRA (h264dec paints gray until a recovery point; the HEVC +/// decoder synthesizes each missing reference as flat mid-gray). A flush +/// would turn a few damaged frames into one to two seconds of gray. The next +/// intact packet decodes fine without a reset, and the reference chain heals +/// at the next keyframe either way. impl Receiver { - /// `irl_handle_audio_packet`. pub(super) fn handle_audio_packet(&mut self) { let audio_tb = self.audio_tb; let Self { @@ -149,10 +143,7 @@ impl Receiver { /// The video half of the read loop: hand the packet to the video thread. /// - /// Nothing is decoded here. The stream's latency is held as compressed - /// packets and decoded just before display, which is what makes a deep - /// Target Buffer affordable at 4K — and it has to be the video thread that - /// decodes, because this one spends a stall blocked in `av_read_frame`. + /// Nothing is decoded here; see [`crate::shared::VideoChannel`] for why. pub(super) fn push_video_packet(&mut self) { // Only used to bound the queue by media duration; output timing comes // from the decoded frame's own PTS, after repair. diff --git a/crates/irl-source/src/receiver/mod.rs b/crates/irl-source/src/receiver/mod.rs index 6d43191..4587adf 100644 --- a/crates/irl-source/src/receiver/mod.rs +++ b/crates/irl-source/src/receiver/mod.rs @@ -23,13 +23,12 @@ use crate::shared::Shared; /// the audio intake. Every field is receiver-thread-only. #[derive(Debug, Default)] pub struct ReceiverFlags { - /// Which streams the current connection carries (`audio_stream_idx >= 0` - /// / `video_stream_idx >= 0` in C); set at open, cleared at close. + /// Which streams the current connection carries; set at open, cleared at + /// close. pub has_audio_stream: bool, pub has_video_stream: bool, /// Consecutive audio decode errors (the audio decoder flushes after a - /// burst). Video decode is not on this thread; its state lives in - /// [`crate::video::DecodeState`]. + /// burst). pub audio_decode_errors: i32, /// Throttles (FFmpeg µs domain). pub audio_last_decoder_flush_time_us: u64, @@ -37,8 +36,7 @@ pub struct ReceiverFlags { } impl ReceiverFlags { - /// `irl_prepare_new_connection` + `irl_reset_stream_timing_state` for the - /// receiver-only fields: everything back to the fresh-connection state. + /// Put every field back to the fresh-connection state. pub fn reset(&mut self) { *self = Self::default(); } @@ -57,9 +55,9 @@ pub struct Receiver { shared: Arc, fmt: Option, audio_dec: Option, - /// Created with the connection and released with it: keeping a device - /// across reconnects made the probe loop silently skip and attach a stale - /// device, so reconnects behaved differently from fresh connects. + /// Created with the connection and released with it: a device kept across + /// reconnects makes the probe loop skip and attach a stale device, so a + /// reconnect would behave differently from a fresh connect. hw_device: Option, audio_stream_idx: i32, video_stream_idx: i32, @@ -81,8 +79,8 @@ pub struct Receiver { } impl Receiver { - /// Allocate the packet and frame the read loop reuses. `None` mirrors the - /// C's "failed to allocate packet/frame" bail-out. + /// Allocate the packet and frame the read loop reuses. `None` when either + /// allocation fails. fn new(shared: Arc) -> Option { let pkt = ffmpeg::Packet::new().ok()?; let frame = ffmpeg::Frame::new().ok()?; @@ -122,9 +120,9 @@ impl Receiver { /// should retry rather than treat it as a read error. /// /// EAGAIN is a non-blocking demuxer saying "nothing yet", not a failure. - /// Treating it as one tore down a healthy connection — closing the input, - /// resetting PTS repair, fading the buffered audio out and clearing the - /// source — for a normal empty poll. + /// Treating it as one would tear down a healthy connection (close the + /// input, reset PTS repair, fade out the buffered audio, clear the + /// source) for a normal empty poll. /// /// Bounded, because a retry re-arms the interrupt watch on every pass and /// the watch only measures one `av_read_frame` call: unbounded, a demuxer @@ -167,8 +165,7 @@ impl Receiver { let read = { let Self { fmt, pkt, .. } = self; let Some(fmt) = fmt.as_mut() else { continue }; - // `read_frame` arms the interrupt watch, which is the C's - // `ctx->io_start_us = av_gettime()` before `av_read_frame`. + // `read_frame` arms the interrupt watch. fmt.read_frame(pkt) }; match &read { diff --git a/crates/irl-source/src/receiver/probe.rs b/crates/irl-source/src/receiver/probe.rs index 3e12bcd..7975e32 100644 --- a/crates/irl-source/src/receiver/probe.rs +++ b/crates/irl-source/src/receiver/probe.rs @@ -1,6 +1,5 @@ //! Hardware-decode probe tables and the two receiver helpers that are pure -//! enough to test on their own (port of the tables and `nvdec_get_format` -//! in `src/receiver-stream.c`). +//! enough to test on their own. //! //! Nothing here touches plugin state, so `crates/irl-source/tests/receiver_*.rs` //! includes this file directly with `#[path]` — `crate::receiver` is private, @@ -14,7 +13,7 @@ use ffmpeg::{AVHWDeviceType, AVPixelFormat, Codec}; /// /// Windows D3D11VA → CUDA, macOS VideoToolbox, everything else VAAPI → CUDA. /// The first device that is created wins; `HwDeviceContext::probe` reports -/// each failure so the caller can log it the way the C plugin does. +/// each failure so the caller can log it. #[cfg(windows)] pub const HW_DEVICE_TYPES: &[AVHWDeviceType] = &[ AVHWDeviceType::AV_HWDEVICE_TYPE_D3D11VA, diff --git a/crates/irl-source/src/receiver/stream.rs b/crates/irl-source/src/receiver/stream.rs index d15a0ee..50fb647 100644 --- a/crates/irl-source/src/receiver/stream.rs +++ b/crates/irl-source/src/receiver/stream.rs @@ -10,8 +10,8 @@ use crate::audio; use crate::receiver::{Receiver, probe}; use crate::shared::{AudioState, Shared, VideoDecoder}; -/// `nvdec_get_format`. Installed only for forced NVDEC, where a software -/// fallback is exactly what must not happen. +/// Installed only for forced NVDEC, where a software fallback is exactly what +/// must not happen. fn nvdec_get_format(codec: &Codec, offered: &[AVPixelFormat]) -> AVPixelFormat { let picked = probe::pick_cuda_format(codec, offered); if picked == AVPixelFormat::AV_PIX_FMT_NONE { @@ -20,7 +20,7 @@ fn nvdec_get_format(codec: &Codec, offered: &[AVPixelFormat]) -> AVPixelFormat { picked } -/// Open one decoder for `stream` (port of `open_decoder`). +/// Open one decoder for `stream`. /// /// `hw_device` is the connection's shared device slot: the video decoder /// creates it, and the software-fallback path releases it. `using_hw_decode` @@ -226,9 +226,9 @@ impl Receiver { irl_info!("Listening for the sender to call in; no I/O deadline until it does"); } - // Unrecognised options are dropped without a word, as `av_dict_free` - // does in the C: FFmpeg option names differ per protocol, so the - // table above deliberately sets keys most inputs ignore. + // Unrecognised options are dropped silently: FFmpeg option names + // differ per protocol, so the table above deliberately sets keys most + // inputs ignore. let fmt = match ffmpeg::FormatContext::open(&url, opts, self.shared.interrupt.clone()) { Ok((fmt, _unrecognised)) => fmt, Err(err) => { @@ -365,8 +365,8 @@ impl Receiver { } } - /// `irl_open_stream`: fast probe when the previous session on this thread - /// showed what the stream carries, full probe otherwise. + /// Fast probe when the previous session on this thread showed what the + /// stream carries, full probe otherwise. /// /// The short probe can miss a stream some encoders advertise late, so a /// result thinner than the previous session is thrown away and re-probed @@ -398,9 +398,8 @@ impl Receiver { true } - /// `irl_close_ffmpeg`. The hardware device goes with the connection it was - /// created for; the software scaler belongs to the video thread and is not - /// touched here. + /// The hardware device goes with the connection it was created for; the + /// software scaler belongs to the video thread and is not touched here. pub(super) fn close_ffmpeg(&mut self) { self.audio_dec = None; // The video decoder belongs to the video thread; it drops it on the @@ -420,7 +419,6 @@ impl Receiver { self.flags.reset(); } - /// `irl_prepare_new_connection`. pub(super) fn prepare_new_connection(&mut self) { self.shared.flags.reconnecting.store(false, Relaxed); self.shared.video_flags.first_keyframe.store(false, Relaxed); @@ -436,7 +434,7 @@ impl Receiver { crate::audio::hold::reset_connection(&self.shared, &mut state); } - /// `irl_wait_for_reconnect`. Returns whether the run is still active. + /// Returns whether the run is still active. pub(super) fn wait_for_reconnect(&mut self) -> bool { self.shared.flags.reconnecting.store(true, Relaxed); // Sampled once: a delay edited mid-wait should apply to the next @@ -448,9 +446,9 @@ impl Receiver { self.shared.is_active() } - /// `irl_handle_stream_read_error`: log, tear the connection down, blank - /// the source, fade the buffered audio out and reset the per-connection - /// counters. The read loop reconnects immediately afterwards. + /// Log, tear the connection down, blank the source, fade the buffered + /// audio out and reset the per-connection counters. The read loop + /// reconnects immediately afterwards. pub(super) fn handle_stream_read_error(&mut self, err: ffmpeg::Error) { let shared = self.shared.clone(); shared.flags.reconnecting.store(true, Relaxed); @@ -523,9 +521,9 @@ impl Receiver { // from separate reads: its three inputs are only meaningful against // each other, and the audio thread updates them together. // - // The rest of what other threads write are atomics, so unlike the C - // they need no lock at all — and the stats line is the last place the - // audio_state / video queue lock edge should be introduced. + // The rest of what other threads write are atomics and need no lock, + // and the stats line must not introduce an audio_state / video queue + // lock edge. let (av_drift_ms, av_skew_ms) = { let state = shared.audio_state(); let skew = crate::source::av_skew_ms(shared, &state); @@ -581,11 +579,9 @@ impl Receiver { 0.0 }, lifetime.video_queue_drops.load(Relaxed), - // The compressed video queue: where the stream's latency is - // actually held. Its duration should track the Target Buffer, and - // its size is what a deep buffer costs at this bitrate — the - // decoded side is bounded by VIDEO_DECODE_LEAD_MS whatever this - // says. + // The compressed video queue holds the stream's latency: its + // duration should track the Target Buffer, and its size is what a + // deep buffer costs at this bitrate. shared.video.len(), lifetime.video_queue_peak.load(Relaxed), shared.video.bytes() / 1024, diff --git a/crates/irl-source/src/settings.rs b/crates/irl-source/src/settings.rs index 39a2297..cffccea 100644 --- a/crates/irl-source/src/settings.rs +++ b/crates/irl-source/src/settings.rs @@ -1,4 +1,4 @@ -//! Properties UI and defaults (port of `src/settings.c`). +//! Properties UI and defaults. use std::ffi::CString; @@ -8,10 +8,6 @@ use obs::{Data, Properties, TextType}; use crate::module_text; use crate::source::IrlSource; -/// `irl_source_get_defaults`. -/// -/// `network_buffer_mb` is deliberately gone: nothing ever read it (the -/// demuxer options use the constant), so it was removed rather than ported. pub fn defaults(settings: &Data<'_>) { settings.set_default_str(c"url", c""); crate::providers::defaults(settings); @@ -29,7 +25,6 @@ pub fn defaults(settings: &Data<'_>) { settings.set_default_bool(c"clear_on_disconnect", consts::DEFAULT_CLEAR_ON_DISCONNECT); } -/// `irl_source_get_properties`. pub fn properties(instance: Option<&IrlSource>) -> Properties { let props = Properties::new(); @@ -57,8 +52,8 @@ pub fn properties(instance: Option<&IrlSource>) -> Properties { // gaps with 287 underruns at the 120ms default), and riding those out is // the only way to avoid the concealment that inflates the A/V mapping and // holds video back with it. High-bitrate senders with deep buffering of - // their own stall for longer still, which is why the ceiling is - // `BUFFER_TARGET_MAX_MS` rather than the 2s it was. + // their own stall for longer still, hence the `BUFFER_TARGET_MAX_MS` + // ceiling. props.add_int( c"buffer_target_ms", module_text(c"TargetBuffer"), diff --git a/crates/irl-source/src/shared.rs b/crates/irl-source/src/shared.rs index 6c699b8..c41bb7b 100644 --- a/crates/irl-source/src/shared.rs +++ b/crates/irl-source/src/shared.rs @@ -33,7 +33,7 @@ use irl_core::{ }; /// Settings latched when the stream opens; changing any of them forces a -/// restart (`config_requires_restart`). +/// restart (`Config::requires_restart`). #[derive(Debug, Clone)] pub struct StreamConfig { pub url: CString, @@ -42,7 +42,7 @@ pub struct StreamConfig { pub low_latency_audio: bool, } -/// Settings swapped in place while the workers run (`config_apply_hot`). +/// Settings swapped in place while the workers run (`Config::apply_hot`). pub struct HotConfig { pub reconnect_delay_s: AtomicI32, pub adaptive_speed: AtomicBool, @@ -79,13 +79,10 @@ impl HotConfig { } } -/// The two video-decode flags the audio path also touches, now that decode -/// lives on the video thread. -/// -/// `first_keyframe` gates audio intake as well as video output (audio is not -/// admitted before the first keyframe), and the receiver clears `corrupted` -/// when PTS repair resets the timeline. Everything else about video decode is -/// owned outright by the video thread and is not here. +/// The video-decode flags the audio path also touches. `first_keyframe` gates +/// audio intake as well as video output, and the receiver clears `corrupted` +/// when PTS repair resets the timeline. The rest of video decode state is the +/// video thread's own. #[derive(Default)] pub struct VideoFlags { pub first_keyframe: AtomicBool, @@ -98,20 +95,19 @@ pub struct VideoFlags { /// Run-level flags. pub struct RunFlags { - /// `thread_active`. An `Arc` because the FFmpeg interrupt watch shares it, - /// so a stop request reaches a receiver blocked inside `av_read_frame`. + /// An `Arc` because the FFmpeg interrupt watch shares it, so a stop + /// request reaches a receiver blocked inside `av_read_frame`. pub thread_active: Arc, - /// `reconnecting`. pub reconnecting: AtomicBool, - /// `audio_stream_present`: "this connection carries audio", mirrored for - /// the video thread, which must not touch `audio_stream_idx`. + /// "This connection carries audio", mirrored for the video thread, which + /// must not touch the receiver's `audio_stream_idx`. pub audio_present: AtomicBool, /// "This connection carries video", for the audio pump, which waits for a /// skew reading before it primes (`audio::hold::prime_may_wait`). pub video_present: AtomicBool, } -/// Everything the C guarded with `audio_state_lock` that is not a counter. +/// Audio timing state that is not a counter, under `Shared::audio_state`. #[derive(Debug, Default)] pub struct AudioState { // Output clock: `ts = anchor + samples / rate`, anchored once at prime. @@ -238,15 +234,11 @@ impl ConnStats { } } -/// Counters cumulative for the source's life: exactly the set the C -/// `reset_runtime_state()` left alone. +/// Counters cumulative for the source's life, carried across runs. #[derive(Default)] pub struct LifetimeStats { pub video_queue_drops: AtomicU64, - /// Peak packets queued for decode. Was the peak count of *decoded* frames - /// pinning hardware surfaces, which no longer exist: the receiver queues - /// compressed packets and the video thread decodes them just before - /// display. + /// Peak packets queued for decode. pub video_queue_peak: AtomicI32, pub pacing_now: AtomicI32, pub pacing_peak: AtomicI32, @@ -264,22 +256,17 @@ pub struct LifetimeStats { /// The receiver → video queue: **compressed packets**, not decoded frames. /// -/// This is where the stream's configured latency is held. Video has to be -/// delayed by the same amount as audio (its due time is the frame PTS plus the -/// audio playout offset), and holding that as decoded frames does not scale: -/// 1080p NV12 is ~3.1MB a frame and 4K P010 ~24.9MB, so an 8s Target Buffer at -/// 4K60 would be ~6GB. The same 8s of packets is ~20MB. -/// -/// So the receiver decodes nothing: it pushes packets here and the video thread -/// decodes them just before they are due, keeping only -/// [`consts::VIDEO_DECODE_LEAD_MS`] of decoded frames alive at a time. That is -/// also why video decode cannot live on the receiver thread — during a network -/// stall the receiver is blocked in `av_read_frame`, which is exactly when the -/// video thread must keep draining the buffer it has. +/// This is where video's share of the configured latency is held. Held as +/// decoded frames it would not scale (8s of 4K60 is ~6GB; the same 8s of +/// packets is ~20MB), so the video thread decodes each packet just before it +/// is due and keeps only [`irl_core::consts::VIDEO_DECODE_LEAD_MS`] of +/// decoded frames alive. Decode cannot live on the receiver thread either: +/// during a network stall it is blocked in `av_read_frame`, which is exactly +/// when video must keep draining the buffer it has. /// /// Bounded by media duration and bytes. Overflow drops from the front, which -/// costs artifacts until the next keyframe; it should not happen, because the -/// receiver's own read backpressure stops ingest long before this fills. +/// costs artifacts until the next keyframe; the receiver's read backpressure +/// stops ingest long before this fills. #[derive(Default)] pub struct VideoChannel { q: Mutex, @@ -287,8 +274,8 @@ pub struct VideoChannel { } /// A packet waiting to be decoded, with everything the queue needs to bound -/// itself. `pts_ns` is approximate — it is only used for the span bound, while -/// output timing comes from the decoded frame's own PTS. +/// itself. `pts_ns` is approximate: it only feeds the span bound, and output +/// timing comes from the decoded frame's own PTS. pub struct TimedPacket { pub packet: ffmpeg::Packet, pub pts_ns: i64, @@ -448,11 +435,9 @@ impl VideoChannel { /// Whether the video thread has something to do right now. /// /// A queued packet is only work if there is somewhere to put what it - /// decodes: `has_room` is the pacing queue's. Treating a queued message as - /// proof of work spins the thread at full CPU whenever the pacing queue is - /// at its decode lead and the head frame is not due — which, once the - /// channel carries the whole configured latency as packets, is the *normal* - /// steady state at any Target Buffer above that lead. + /// decodes (`has_room` is the pacing queue's). Counting any queued message + /// as work would spin the thread at full CPU whenever the pacing queue + /// sits at its decode lead, which is the normal steady state. pub fn has_work(&self, has_room: bool) -> bool { self.q.lock().has_work(has_room) } diff --git a/crates/irl-source/src/source.rs b/crates/irl-source/src/source.rs index c78d5ce..220cc1a 100644 --- a/crates/irl-source/src/source.rs +++ b/crates/irl-source/src/source.rs @@ -1,5 +1,5 @@ -//! Source lifecycle (port of `src/irl-source.c`): create, destroy, update, -//! tick, the show/activate gating, the media controls and the stats proc. +//! Source lifecycle: create, destroy, update, tick, the show/activate gating, +//! the media controls and the stats proc. use std::ffi::{CStr, CString}; use std::sync::Arc; @@ -40,8 +40,7 @@ struct ObsState { /// The IRL source instance. pub struct IrlSource { source: SourceHandle, - /// Counters that outlive a connection (the set the C `reset_runtime_state` - /// deliberately skipped). + /// Counters that outlive a connection. lifetime: Arc, /// Owns the `get_stats` closure; dropped in [`Drop`], before libobs tears /// the source's proc handler down. @@ -89,10 +88,8 @@ impl Source for IrlSource { running: None, })); - // Register the stats proc so scripts and overlays can query state. - // The obs-websocket vendor extension calls this same proc, so both - // transports are guaranteed to report the same numbers. The field - // list itself lives in `irl_core::stats::FIELDS`. + // The obs-websocket vendor extension calls this same proc, so scripts + // and websocket clients see the same numbers. let proc_cb = match CString::new(proc_declaration()) { Ok(decl) => { let state = Arc::clone(&obs_state); @@ -306,8 +303,8 @@ impl Source for IrlSource { } impl Drop for IrlSource { - /// `irl_source_destroy`. The frame is not cleared: the source itself is - /// going away, so there is nothing left to show it on. + /// The frame is not cleared: the source itself is going away, so there is + /// nothing left to show it on. fn drop(&mut self) { stop_receiver(&mut self.obs_state.lock(), self.source, false); // Explicit for order's sake: the closure the callback owns holds an @@ -328,9 +325,8 @@ fn should_run_receiver(state: &ObsState, source: SourceHandle) -> bool { && (!state.config.close_when_inactive || source.showing()) } -/// Start the three workers for a fresh [`Shared`] (which is what replaces the -/// C `reset_runtime_state`: every per-connection field starts zeroed and the -/// lifetime counters are carried over untouched). +/// Start the three workers for a fresh [`Shared`]: every per-connection field +/// starts zeroed and the lifetime counters carry over untouched. fn start_receiver(state: &mut ObsState, source: SourceHandle, lifetime: &Arc) { if state.running.is_some() || !should_run_receiver(state, source) { return; @@ -344,8 +340,8 @@ fn start_receiver(state: &mut ObsState, source: SourceHandle, lifetime: &Arc handle, Err(err) => { @@ -461,13 +457,10 @@ fn fit_to_canvas(source: SourceHandle) { // ── Stats ───────────────────────────────────────────────────── /// `av_skew_ms`: video PTS minus audio PTS of what last reached the plugin, -/// in ms. Both values are stamped by the receiver at arrival (audio as it -/// writes the jitter buffer, video as it pushes the packet), so the -/// difference is the sender's timestamp skew before any buffering here. The -/// decoded-frame PTS would not do for the video side: a packet waits in the -/// channel for its due time, standing video delay included, so that PTS -/// trails arrival by the queue depth and the delay would read as a skew. -/// Zero until both streams have delivered something. +/// in ms. Both are stamped by the receiver at arrival, so the difference is +/// the sender's skew before any buffering here. The decoded-frame PTS would +/// trail arrival by the queue depth and read the video delay as skew. Zero +/// until both streams have delivered something. pub(crate) fn av_skew_ms(shared: &Shared, audio: &AudioState) -> i64 { let video_pts_ns = shared.conn.video_arrival_pts_ns.load(Relaxed); if video_pts_ns == 0 || audio.latest_audio_stream_pts_ns == 0 { diff --git a/crates/irl-source/src/websocket.rs b/crates/irl-source/src/websocket.rs index b05a7a8..04f311f 100644 --- a/crates/irl-source/src/websocket.rs +++ b/crates/irl-source/src/websocket.rs @@ -1,4 +1,4 @@ -//! obs-websocket vendor extension (port of `src/websocket-vendor.c`). +//! obs-websocket vendor extension. //! //! Exposes the per-source stats over obs-websocket so an overlay, a bot or an //! IRL dashboard can read them from another machine, without the Lua or @@ -21,7 +21,6 @@ use irl_core::consts; use irl_core::stats::{FIELDS, StatKind}; use obs::{CallData, Data, DataArray, OwnedData, OwnedSource, SourceHandle}; -/// One vendor request handler. type RequestFn = fn(&Data<'_>, &OwnedData); /// Register the vendor and its requests. @@ -166,7 +165,7 @@ fn get_stats(request: &Data<'_>, response: &OwnedData) { // calldata is a typed blob that cannot be enumerated, so the fields to // copy out have to be named: `FIELDS` is that list, shared with the proc // declaration and the snapshot writer. The names are the JSON keys - // clients see. An absent field copies as zero, as in the C. + // clients see. An absent field copies as zero. for (name, kind) in FIELDS { let Ok(key) = CString::new(*name) else { continue; From 7d0ac24dd70776fb7cb993a47296d0d40a460735 Mon Sep 17 00:00:00 2001 From: datagutt Date: Fri, 2 Oct 2026 03:17:15 +0200 Subject: [PATCH 25/28] docs(irl-source): trim audio and video thread comments to what the code cannot say --- crates/irl-source/src/audio/mod.rs | 34 ++--- crates/irl-source/src/audio/pump.rs | 104 ++++++--------- crates/irl-source/src/video/decode.rs | 29 ++--- crates/irl-source/src/video/intake.rs | 27 ++-- crates/irl-source/src/video/output.rs | 82 +++++------- crates/irl-source/src/video/thread.rs | 177 ++++++++++---------------- 6 files changed, 181 insertions(+), 272 deletions(-) diff --git a/crates/irl-source/src/audio/mod.rs b/crates/irl-source/src/audio/mod.rs index 1fbdaad..9caf445 100644 --- a/crates/irl-source/src/audio/mod.rs +++ b/crates/irl-source/src/audio/mod.rs @@ -14,8 +14,8 @@ use crate::shared::{AudioState, Shared}; pub use pump::AudioPump; -/// Audio thread body: 16 pump iterations per wakeup, 1 ms sleep, until the -/// run is stopped (`receiver.c: irl_audio_thread`). +/// Audio thread body: up to `AUDIO_PUMP_BURST` pump iterations per wakeup, +/// until the run is stopped. pub fn audio_thread(shared: Arc) { let mut pump = AudioPump::new(shared.clone()); @@ -62,16 +62,16 @@ impl AudioSink for obs::SourceHandle { } } -/// `irl_audio_output_claim`: reserve `frames` on the sample-counter clock and -/// return the OBS timestamp for them. Caller holds `audio_state`. +/// Reserve `frames` on the sample-counter clock and return the OBS timestamp +/// for them. Caller holds `audio_state`. pub fn output_claim(state: &mut AudioState, frames: u32, rate: u32) -> u64 { let ts = timing::output_next_ts(state.anchor_ns, state.samples, rate); state.samples += frames as u64; ts } -/// `irl_reset_audio_timing_state`: output clock, playout mapping, fades and -/// concealment back to the not-yet-primed state. Caller holds `audio_state`. +/// Output clock, playout mapping, fades and concealment back to the +/// not-yet-primed state. Caller holds `audio_state`. pub fn reset_audio_timing_state(state: &mut AudioState) { state.primed = false; state.anchor_ns = 0; @@ -93,14 +93,13 @@ pub fn reset_audio_timing_state(state: &mut AudioState) { state.startup_warmup_remaining_ms = 0; state.drain = irl_core::DrainWatch::default(); - // The receiver-thread half of the C function (decode-error counters, - // last-sample memory) lives in `ReceiverFlags` / `AudioIntake`, cleared - // by their owners at the same call sites. + // The receiver-thread half (decode-error counters, last-sample memory) + // lives in `ReceiverFlags` / `AudioIntake`, cleared by their owners at the + // same call sites. } -/// `irl_reset_stream_timing_state`: the audio reset plus the video-side -/// mirrors in `ConnStats` and the stream PTS trackers. Caller holds -/// `audio_state`. +/// The audio reset plus the video-side mirrors in `ConnStats` and the stream +/// PTS trackers. Caller holds `audio_state`. pub fn reset_stream_timing_state(shared: &Shared, state: &mut AudioState) { reset_audio_timing_state(state); @@ -109,8 +108,6 @@ pub fn reset_stream_timing_state(shared: &Shared, state: &mut AudioState) { // minutes of relearning). It does not survive this one: a PTS-repair reset // means the timeline broke badly enough that the level no longer maps to // the sender's clock, and a reconnect may not even be the same encoder. - // Relearning costs nothing worse than the behaviour before the trim - // existed. state.speed_trim.reset(); // The skew readings straddle the break; the hold they sized stays, and a // release window takes it back if the sender no longer needs it. @@ -121,14 +118,12 @@ pub fn reset_stream_timing_state(shared: &Shared, state: &mut AudioState) { shared.conn.video_ts_init.store(false, Relaxed); shared.conn.video_frame_interval_ns.store(0, Relaxed); - // Every C call site set `current_speed = 1.0f` immediately after this - // call (`irl-source.c:234-235`, `receiver-stream.c:671-678`); the - // controller itself lives on the audio thread and re-arms from 1.0 while - // playback is unprimed, which is exactly the window a reset opens. + // The controller on the audio thread re-arms from 1.0 while playback is + // unprimed, which is exactly the window a reset opens. shared.conn.set_current_speed(1.0); } -/// `irl_mark_audio_recovery`: hold recovery for `duration_us` from now. +/// Extend recovery to at least `duration_us` from now. pub fn mark_audio_recovery(state: &mut AudioState, now_us: u64, duration_us: u64) { let until_us = now_us + duration_us; if until_us > state.recovery_until_us { @@ -136,7 +131,6 @@ pub fn mark_audio_recovery(state: &mut AudioState, now_us: u64, duration_us: u64 } } -/// `irl_audio_recovery_active`. pub fn audio_recovery_active(state: &AudioState, now_us: u64) -> bool { state.recovery_until_us != 0 && now_us < state.recovery_until_us } diff --git a/crates/irl-source/src/audio/pump.rs b/crates/irl-source/src/audio/pump.rs index c5e8e10..a85323a 100644 --- a/crates/irl-source/src/audio/pump.rs +++ b/crates/irl-source/src/audio/pump.rs @@ -11,7 +11,7 @@ //! 2. `samples_per_sec` must be constant: any change makes OBS //! destroy/recreate its per-source resampler with no crossfade (a click //! per change). Playback speed is instead applied here with a persistent -//! swresample compensation, ffplay-style. +//! swresample compensation. //! 3. The OBS mixer consumes 21.3 ms ticks against wall clock; a source //! whose queued audio runs dry gets a tick of silence plus a time-shifted //! splice (crackle), and a source that falls behind the mix window makes @@ -50,10 +50,8 @@ pub struct AudioPump { now_ns: Box u64 + Send>, /// The FFmpeg-domain clock (`av_gettime`), which times the speed trim's /// integration and the underrun recovery hold. Injectable alongside - /// [`Self::now_ns`] and for the same reason: a test that steps the OBS - /// clock while this one runs at wall speed makes the trim integrate - /// hundreds of times too slowly, so the loop it is meant to close never - /// closes. The two are always the same real clock in production. + /// [`Self::now_ns`]: a test that steps the OBS clock while this one runs at + /// wall speed makes the trim integrate far too slowly to close its loop. now_us: Box u64 + Send>, /// How long the audio thread may sleep before this pump next has work. /// See [`Self::idle_sleep_ms`]. @@ -90,9 +88,8 @@ impl AudioPump { self } - /// Replace the FFmpeg-domain clock (tests only). A test that steps - /// [`Self::with_clock`] should step this one with it, or the speed trim - /// integrates against wall time while the buffer moves in virtual time. + /// Replace the FFmpeg-domain clock (tests only). Step it together with + /// [`Self::with_clock`]. #[must_use] pub fn with_us_clock(mut self, now_us: Box u64 + Send>) -> Self { self.now_us = now_us; @@ -104,9 +101,8 @@ impl AudioPump { /// Once primed the output clock says exactly when the next chunk is due, so /// the thread can sleep to that deadline instead of polling. When the pump /// is waiting on data rather than on the clock there is no deadline to - /// compute, and this stays at [`consts::AUDIO_PUMP_SLEEP_MS`] — the C - /// behaviour, and the right one, since the wake condition is a write from - /// another thread. + /// compute, and this stays at [`consts::AUDIO_PUMP_SLEEP_MS`], since the + /// wake condition is a write from another thread. pub fn idle_sleep_ms(&self) -> u32 { self.idle_sleep_ms } @@ -115,11 +111,10 @@ impl AudioPump { /// call; returns whether audio (or concealment) was emitted. pub fn pump_once(&mut self) -> bool { let shared = Arc::clone(&self.shared); - // The whole call runs under the audio state lock, exactly like the C - // (`receiver.c: irl_audio_thread` takes it around - // `irl_pump_audio_once`). Nothing below may take it again — the mutex - // is not recursive. Buffer-mutex calls nest underneath it, which is - // the documented order (audio state lock, then buffer). + // The whole call runs under the audio state lock. Nothing below may + // take it again: the mutex is not recursive. Buffer-mutex calls nest + // underneath it, which is the documented order (audio state lock, then + // buffer). let mut state = shared.audio_state(); // Re-check under the same lock as the disconnect fade. The receiver // can pause playback while a pump burst is already in progress. @@ -132,7 +127,7 @@ impl AudioPump { fn pump_locked(&mut self, shared: &Shared, state: &mut AudioState) -> bool { // Every path but the "already queued far enough ahead" one below is // waiting on a write from another thread, which no deadline here can - // predict; those keep the C's poll interval. + // predict; those keep the fixed poll interval. self.idle_sleep_ms = consts::AUDIO_PUMP_SLEEP_MS; let low_latency = shared.cfg.low_latency_audio; @@ -152,10 +147,9 @@ impl AudioPump { let now = (self.now_ns)(); if !state.primed { - // The C set `current_speed = 1.0f` at every connection prep and - // runtime reset; the controller lives on this thread, and "not - // yet primed" is exactly the window those resets cover (the speed - // is only ever computed after priming). + // The speed is only computed after priming, so the controller + // re-arms from 1.0 for as long as playback is unprimed, which is + // the window every connection prep and runtime reset opens. self.speed.reset(); shared.conn.set_current_speed(1.0); } @@ -264,9 +258,9 @@ impl AudioPump { } if !has_audio { - // Low-latency mode keeps the old behaviour: no concealment, - // resume where the clock line left off (the stall restart above - // covers long droughts). + // Low-latency mode emits no concealment and resumes where the + // clock line left off (the stall restart above covers long + // droughts). if low_latency { return false; } @@ -467,8 +461,8 @@ Video stays in sync with it; check the sender's frame rate and clock", true } - /// `emit_concealment_silence`: one chunk of silence that decays out of the - /// last real sample, on the clock line the real audio left behind. + /// One chunk of silence that decays out of the last real sample, on the + /// clock line the real audio left behind. fn emit_concealment_silence( &mut self, shared: &Shared, @@ -515,9 +509,9 @@ Video stays in sync with it; check the sender's frame rate and clock", true } - /// `apply_output_speed`: stretch/shrink one chunk by `speed` through the - /// persistent output resampler. Returns the output frame count, or `None` - /// to fall back to the unmodified input chunk. + /// Stretch or shrink one chunk by `speed` through the persistent output + /// resampler. Returns the output frame count, or `None` to fall back to the + /// unmodified input chunk. /// /// A free function rather than a method because it writes `speed_scratch` /// while reading `pump_scratch`, which a `&mut self` receiver would not @@ -533,9 +527,8 @@ Video stays in sync with it; check the sender's frame rate and clock", channels: i32, speed: f32, ) -> Option { - // `ensure_speed_swr`: rebuild when the format moved; a failed build - // leaves `None`, so the next chunk retries (as the C zeroed the - // cached rate/channels). + // Rebuild when the format moved; a failed build leaves `None`, so the + // next chunk retries. if swr .as_ref() .is_none_or(|s| !s.matches_params(rate, channels)) @@ -575,9 +568,8 @@ struct BufferFormat { channels: i32, bytes_per_sample: i32, frame_size: usize, - /// The buffer's own target, which `audio_buffer_resize` moves; the - /// config's target (the hot watermarks) is a separate quantity and the C - /// used each in specific places. + /// The buffer's own target, which `AudioBuffer::resize` moves. Not the + /// same quantity as the config's target in the hot watermarks. target_ms: i32, } @@ -597,18 +589,15 @@ impl BufferFormat { /// Stand the low-latency output clock down until real audio returns. /// -/// Low-latency mode deliberately emits no concealment, so an empty input -/// cannot advance the sample counter. The output clock then sits still while -/// wall clock moves, and the stall check reads that as a stalled audio thread — -/// which it is not. Restarting it there re-anchors, waits one lead and trips -/// again, so a silent input produced a "restarting output clock" warning every -/// ~150ms for as long as it stayed silent, with `audio_output_restarts` -/// climbing on a source that was merely quiet. +/// Low-latency mode emits no concealment, so an empty input cannot advance the +/// sample counter, and the stall check reads the still clock as a stalled +/// audio thread. Restarting the clock there would trip again one lead later, +/// for as long as the input stays silent. /// /// Drop the stale mapping instead and let the normal prime path establish one -/// new clock when a real chunk arrives. Counted as an underrun, which is what -/// it is. Buffered mode is untouched: its concealment keeps the counter moving, -/// so a late clock there really is an output-side stall. +/// new clock when a real chunk arrives. Counted as an underrun. Buffered mode +/// never gets here: its concealment keeps the counter moving, so a late clock +/// there really is an output-side stall. fn suspend_low_latency_clock(shared: &Shared, state: &mut AudioState, lag_ns: u64, now_us: u64) { state.primed = false; state.anchor_ns = 0; @@ -626,8 +615,6 @@ fn suspend_low_latency_clock(shared: &Shared, state: &mut AudioState, lag_ns: u6 ); } -/// `irl_audio_maybe_reanchor_offset`. -/// /// The audio→OBS playout offset is (obs clock end) − (stream PTS end) of the /// latest chunk handed to OBS; the video path adds this same offset to every /// frame PTS for lip sync. Concealment freezes the stream-PTS side while @@ -676,17 +663,14 @@ fn maybe_reanchor_offset( } // Only reclaim latency the speed-drain cannot. While backlog is queued - // the inflation is real buffered audio, and draining it at up to the - // catch-up speed preserves every sample, so leave it to the controller - // (content is never skipped). We step in only once the buffer is back - // at/below target, where the residual offset is phantom: concealment - // silence with no backing audio (the concealed packets were dropped, not - // merely late), which no speed-up can ever recover. Re-anchoring here - // skips nothing. + // the inflation is real buffered audio, which the controller drains + // without skipping a sample. Once the buffer is back at or below target + // the residual offset is phantom: concealment silence with no backing + // audio (the concealed packets were dropped, not late), which no speed-up + // can recover. Re-anchoring here skips nothing. // - // The audio state lock is already held (see `pump_once`); this fill query - // takes and releases the buffer mutex underneath it, which is the - // documented order. + // The audio state lock is already held (see `pump_once`); taking the + // buffer mutex underneath it is the documented order. let fill_ms = shared.audio_buf().as_ref().map_or(0, |b| b.fill_ms()); if fill_ms > buffer_target_ms { return; @@ -707,8 +691,6 @@ fn maybe_reanchor_offset( ); } -/// `maybe_trim_hidden_audio_backlog`. -/// /// Before playback primes, nothing has been audible yet, so excess startup /// backlog can be dropped for free. This is the only trim path. Once audio is /// live, content is never skipped: the read loop stops ingesting above a fill @@ -724,7 +706,6 @@ fn maybe_trim_hidden_backlog( if !shared.hot.adaptive_speed.load(Relaxed) { return false; } - // `should_hide_audio_backlog`. if low_latency || state.primed { return false; } @@ -779,7 +760,7 @@ fn maybe_trim_hidden_backlog( true } -/// `finalize_audio_output`: publish the playout mapping and count the chunk. +/// Publish the playout mapping and count the chunk. fn finalize_audio_output( shared: &Shared, state: &mut AudioState, @@ -798,7 +779,6 @@ fn finalize_audio_output( shared.conn.total_audio_frames.fetch_add(1, Relaxed); } -/// Grow a scratch buffer to at least `need` bytes (`ensure_scratch`). fn ensure_scratch(buf: &mut Vec, need: usize) { if buf.len() < need { buf.resize(need, 0); diff --git a/crates/irl-source/src/video/decode.rs b/crates/irl-source/src/video/decode.rs index ec76ba8..df48e9e 100644 --- a/crates/irl-source/src/video/decode.rs +++ b/crates/irl-source/src/video/decode.rs @@ -1,12 +1,9 @@ -//! Video decode (the video half of `irl_handle_video_packet` / -//! `drain_video_frames`, moved off the receiver thread). +//! Video decode, on the video thread. //! -//! It lives here rather than in `receiver/` because the receiver must not be -//! what decides *when* a packet is decoded. The stream's latency is held as -//! compressed packets, and decode happens only as the frames come due — but the -//! receiver spends most of its life blocked in `av_read_frame`, and a network -//! stall is exactly when it blocks longest and when video most needs to keep -//! draining what it already has. +//! The stream's latency is held as compressed packets and decoded only as the +//! frames come due. The receiver cannot be the one to decode: it spends a +//! network stall blocked in `av_read_frame`, which is exactly when video most +//! needs to keep draining what it already has. use std::sync::atomic::Ordering::Relaxed; @@ -109,16 +106,14 @@ pub fn decode_packet( let mut result = decoder.ctx.send_packet(packet); if result.as_ref().is_err_and(ffmpeg::Error::is_eagain) { - // The decoder refused the packet: it has output waiting and, on - // fixed-pool hardware decoders, no free surface until we take it. - // FFmpeg's contract is to read the output and resend the same packet — - // falling through would discard it, and with a reference frame that - // costs artifacts until the next keyframe rather than one dropped - // frame. + // The decoder has output waiting and, on fixed-pool hardware decoders, + // no free surface until we take it. FFmpeg's contract is to read the + // output and resend the same packet; dropping a reference frame costs + // artifacts until the next keyframe. // - // One retry, deliberately not a loop: a single drain frees every - // surface the decoder was waiting on, and a decoder that returned - // EAGAIN without producing anything would otherwise spin here. + // One retry, not a loop: a single drain frees every surface the + // decoder was waiting on, and a decoder that returned EAGAIN without + // producing anything would otherwise spin here. shared.lifetime.video_pkt_eagain.fetch_add(1, Relaxed); drain(decoder, scratch, shared, state, out); result = decoder.ctx.send_packet(packet); diff --git a/crates/irl-source/src/video/intake.rs b/crates/irl-source/src/video/intake.rs index 136e1b1..4754941 100644 --- a/crates/irl-source/src/video/intake.rs +++ b/crates/irl-source/src/video/intake.rs @@ -1,10 +1,5 @@ -//! Decoded video intake (port of `irl_handle_video_frame`, -//! `receiver-video.c:289-438`). -//! -//! Runs on the **video** thread, not the receiver: decode moved there so that -//! the stream's latency can be held as compressed packets rather than decoded -//! frames, and so that a receiver blocked in `av_read_frame` during a network -//! stall cannot stop video from draining the buffer it already has. +//! Decoded video intake. Runs on the video thread, next to the decoder (see +//! [`crate::video::decode`] for why that is not the receiver). use std::sync::atomic::Ordering::Relaxed; @@ -12,12 +7,9 @@ use irl_core::video_time; use crate::shared::Shared; -/// Video-thread-owned decode and intake state. -/// -/// The C kept all of it on `struct irl_source` under the receiver's lock -/// discipline. Only the two flags the audio path also touches are shared -/// ([`crate::shared::VideoFlags`]); everything here belongs to one thread and -/// needs no synchronisation at all. +/// Video-thread-owned decode and intake state. Only the two flags the audio +/// path also touches are shared ([`crate::shared::VideoFlags`]); everything +/// here belongs to one thread. #[derive(Default)] pub struct DecodeState { /// Packet-level keyframe gate: the decoder is not fed until a key packet @@ -46,9 +38,8 @@ impl DecodeState { *self = Self::default(); } - /// The video half of `irl_reset_stream_timing_state`: an audio PTS reset - /// broke the timeline, so the interval estimate and the decoder-error - /// bookkeeping no longer describe this stream. + /// An audio PTS reset broke the timeline, so the interval estimate and the + /// decoder-error bookkeeping no longer describe this stream. /// /// The keyframe gates are deliberately untouched — the connection did not /// change and video has not lost its reference frames. @@ -86,8 +77,8 @@ pub fn handle_frame( let is_key = frame.is_key(); let first_keyframe = shared.video_flags.first_keyframe.load(Relaxed); - // The frame-level backstop only gates when Wait For Keyframe is on - // (master 64dcd0f); the first-keyframe bookkeeping runs either way. + // The frame-level backstop only gates when Wait For Keyframe is on; the + // first-keyframe bookkeeping runs either way. if !first_keyframe && !is_key && shared.hot.wait_for_keyframe.load(Relaxed) { if shared.conn.total_video_frames.load(Relaxed) == 0 { irl_debug!("Waiting for keyframe (dropped non-keyframe)"); diff --git a/crates/irl-source/src/video/output.rs b/crates/irl-source/src/video/output.rs index c6d43c5..1688ba1 100644 --- a/crates/irl-source/src/video/output.rs +++ b/crates/irl-source/src/video/output.rs @@ -19,15 +19,15 @@ const MAX_PLANES: usize = 8; /* ── swscale backend selection ────────────────────────────── */ -/// `IRL_SWS_UNSTABLE`, read (and logged) once, exactly as the C -/// `sws_unstable_enabled()` reads it: set, non-empty, not starting with `0`. +/// `IRL_SWS_UNSTABLE`, read and logged once: on when set, non-empty and not +/// starting with `0`. /// /// FFmpeg 9.0's op-chain backends are reachable only through the dynamic API /// (`sws_alloc_context` with no `sws_init_context`, driven by -/// `sws_scale_frame`), which is what [`Scaler`] uses; `SWS_UNSTABLE` is what -/// makes it prefer them. Every format OBS makes us convert is subsampled, so -/// as of 9.0 the op chain declines and the legacy pass runs anyway — the flag -/// is wired up for the day that changes, and defaults off. +/// `sws_scale_frame`), which is what [`Scaler`] uses; `SWS_UNSTABLE` makes it +/// prefer them. Every format OBS makes us convert is subsampled, which the op +/// chain declines in 9.0, so the legacy pass runs anyway and the flag defaults +/// off. fn sws_unstable() -> bool { static UNSTABLE: OnceLock = OnceLock::new(); *UNSTABLE.get_or_init(|| { @@ -51,7 +51,6 @@ fn sws_unstable() -> bool { /* ── Format mapping ───────────────────────────────────────── */ -/// `avpixfmt_to_obs` (`video-handler.c:170-199`). pub fn avpixfmt_to_obs(fmt: AVPixelFormat) -> VideoFormat { use AVPixelFormat as F; match fmt { @@ -69,9 +68,7 @@ pub fn avpixfmt_to_obs(fmt: AVPixelFormat) -> VideoFormat { } } -/// `convert_color_space` (`video-handler.c:210-230`). The C signature also -/// takes the primaries and never reads them; BT.2020 splits on the transfer -/// function alone. +/// BT.2020 splits into HLG and PQ on the transfer function alone. pub fn convert_color_space(cs: AVColorSpace, trc: AVColorTransferCharacteristic) -> ColorSpace { match cs { AVColorSpace::AVCOL_SPC_BT709 => ColorSpace::Bt709, @@ -87,7 +84,6 @@ pub fn convert_color_space(cs: AVColorSpace, trc: AVColorTransferCharacteristic) } } -/// `convert_color_range` (`video-handler.c:232-236`). pub fn convert_color_range(range: AVColorRange) -> ColorRange { if range == AVColorRange::AVCOL_RANGE_JPEG { ColorRange::Full @@ -99,17 +95,15 @@ pub fn convert_color_range(range: AVColorRange) -> ColorRange { impl VideoThread { /* ── Transfer to system memory ────────────────────────── */ - /// `irl_video_to_sysmem` (`video-handler.c:539-587`). - /// /// A system-memory frame comes back as a new reference (no copy). A /// hardware frame is copied out exactly once, into a buffer recycled /// through [`FramePool`]: `av_hwframe_map` would keep the decoder surface /// pinned for the whole output lead and exhaust the pool within a few - /// frames, and letting `av_hwframe_transfer_data` allocate meant a + /// frames, and letting `av_hwframe_transfer_data` allocate costs a /// frame-sized malloc/free per frame (page zeroing plus thousands of soft /// faults per 4K frame). - // Named after the C `irl_video_to_sysmem`; the `&mut self` is the transfer - // pool and its broken latch, not a conversion of the receiver. + // The `&mut self` is the transfer pool and its broken latch, not a + // conversion of the receiver. #[allow(clippy::wrong_self_convention)] pub fn to_sysmem(&mut self, frame: &Frame) -> Option { if !frame.is_hw() { @@ -127,9 +121,8 @@ impl VideoThread { Some(out) } - /// The pooled half of the transfer (`xfer_frame_from_pool` plus the - /// broken-backend latch). `None` means "let FFmpeg allocate the - /// destination instead", which is exactly what shipped before the pool. + /// The pooled half of the transfer. `None` means "let FFmpeg allocate the + /// destination instead". fn pooled_transfer(&mut self, frame: &Frame) -> Option { if self.xfer_pool_broken { return None; @@ -172,8 +165,7 @@ impl VideoThread { /* ── Timestamp mapping ────────────────────────────────── */ - /// `irl_video_due_time` (`video-handler.c:339-411`): the OBS timestamp a - /// freshly transferred frame is scheduled for. + /// The OBS timestamp a freshly transferred frame is scheduled for. /// /// While audio is playing, queued audio is the master playout clock: video /// PTS maps through the same stream-PTS → OBS-clock offset as the latest @@ -210,11 +202,10 @@ impl VideoThread { return mapped; } - // `conn.video_ts_init` is the cross-thread half of the C's - // `video_ts_init` and the authority here: the receiver clears it on a + // `conn.video_ts_init` is the authority here: it is cleared on a // resolution change and on every new connection // (`prepare_new_connection` / `reset_stream_timing_state`), so a - // cleared mirror means "re-anchor now" even though the private anchors + // cleared flag means "re-anchor now" even though the private anchors // below still hold the previous connection's epoch. This is the only // place that sets it. if !self.ts_init || !self.shared.conn.video_ts_init.load(Relaxed) { @@ -227,10 +218,9 @@ impl VideoThread { let mut computed = video_time::fallback_anchor(pts_ns, self.pts_base, self.sys_base, now) .saturating_add(delay_ns); - // Startup fallback before the audio playout mapping exists. Here the - // mapping cannot stand in for "there is audio" — the whole point is - // that it does not exist yet — so this reads the flag the receiver - // thread publishes. + // Startup fallback before the audio playout mapping exists. The + // mapping cannot stand in for "there is audio" here, so this reads the + // flag the receiver publishes. if self.shared.flags.audio_present.load(Relaxed) { let mut audio_lead_ns = 0; if obs_end == 0 { @@ -257,14 +247,12 @@ impl VideoThread { self.shared.audio_state().playout_mapping().is_some() } - /// `irl_video_playout_offset` (`video-handler.c:431-453`): the current - /// stream-PTS → OBS-clock offset, for re-deriving the due time of frames - /// already queued. + /// The current stream-PTS → OBS-clock offset, for re-deriving the due time + /// of frames already queued. /// - /// Deliberately free of the side effects in [`Self::due_time`] — the lead - /// warning and the video-only fallback anchor both belong - /// to a frame arriving, and running them again for every queued frame on - /// every pacing cycle would report the queue rather than the stream. + /// Free of the side effects in [`Self::due_time`]: the lead warning and the + /// fallback anchor belong to a frame arriving, and running them for every + /// queued frame would report the queue rather than the stream. /// `None` when there is no audio to slave to, or when the mapping has been /// gone long enough that holding it would be a guess; the caller then /// keeps the due times the frames arrived with. The standing video delay @@ -285,8 +273,8 @@ impl VideoThread { Some(self.playout_offset_ns + self.delay.delay_ns() as i64) } - /// `video_record_lead` (`video-handler.c:285-327`): warn when the mapping - /// placed this frame further ahead of wall clock than libobs can queue. + /// Warn when the mapping placed this frame further ahead of wall clock + /// than libobs can queue. /// /// The lead is never clamped. What libobs queues is the lead's *growth* /// since its play head last anchored, not its size, so a large but steady @@ -323,15 +311,15 @@ impl VideoThread { /* ── Output ───────────────────────────────────────────── */ - /// `irl_video_output_frame` (`video-handler.c:591-675`): hand a - /// system-memory frame to OBS with the timestamp pacing scheduled it for. + /// Hand a system-memory frame to OBS with the timestamp pacing scheduled + /// it for. /// /// A directly supported format lends its planes to libobs, which copies - /// them inside `obs_source_output_video`; the borrow ends at that call, so - /// this path performs no copy of its own. Anything else — an unmapped - /// pixel format, or the bottom-up layout OBS's async path cannot express - /// (`Frame::plane` reports a negative stride as no plane) — goes through - /// swscale into the persistent NV12 scratch. + /// them inside `obs_source_output_video`, so this path makes no copy of its + /// own. Anything else (an unmapped pixel format, or the bottom-up layout + /// OBS's async path cannot express, which `Frame::plane` reports as no + /// plane) goes through swscale into the persistent NV12 scratch. + /// /// Returns whether the frame reached libobs. A conversion that fails /// submits nothing, and the caller needs to know: libobs's play head is /// only anchored by a frame it actually received. @@ -397,10 +385,8 @@ impl VideoThread { self.sws_src = Some((width, height, pix_fmt)); } - // Stride is the display width, as in the C. The chroma plane is sized - // for `ceil(height / 2)` rows rather than the C's `y_size / 2`, which - // is one row short for an odd height — never smaller than the C - // reserved, and it is what `scale_into_nv12` validates. + // Stride is the display width. The chroma plane has `ceil(height / 2)` + // rows, which is what `scale_into_nv12` validates. let stride = width as usize; let y_size = stride * height as usize; let uv_size = stride * (height as usize).div_ceil(2); diff --git a/crates/irl-source/src/video/thread.rs b/crates/irl-source/src/video/thread.rs index a9f8871..3815b27 100644 --- a/crates/irl-source/src/video/thread.rs +++ b/crates/irl-source/src/video/thread.rs @@ -1,13 +1,13 @@ //! Video thread loop and pacing. //! -//! The thread pops compressed packets off [`VideoChannel`](crate::shared::VideoChannel), -//! decodes each one as it approaches its due time, copies the frame out of the -//! hardware pool immediately (which returns the decoder's surface), then holds -//! it in a thread-private pacing queue until its mapped timestamp is due, the -//! way OBS's own media source paces in -//! `mp_media_sleep`. Handing libobs a frame early makes libobs hold it, and -//! past `MAX_ASYNC_FRAMES` (30) held frames `cache_video` silently discards the -//! whole queue, so this queue is what keeps libobs's async queue about one +//! The thread pops compressed packets off +//! [`VideoChannel`](crate::shared::VideoChannel), decodes each one as it +//! approaches its due time, copies the frame out of the hardware pool at once +//! (which returns the decoder's surface), then holds it in a thread-private +//! pacing queue until its mapped timestamp is due, the way OBS's own media +//! source paces in `mp_media_sleep`. Handing libobs a frame early makes libobs +//! hold it, and past `MAX_ASYNC_FRAMES` (30) held frames `cache_video` silently +//! discards the whole queue, so this queue keeps libobs's async queue about one //! frame deep. use std::sync::Arc; @@ -34,7 +34,6 @@ pub struct Paced { } impl Paced { - /// The system-memory frame itself. pub fn frame(&self) -> &Frame { &self.frame } @@ -71,7 +70,6 @@ pub struct VideoThread { scratch: Frame, /// Reusable output list for one packet's frames. decoded: Vec, - /// Decode and intake state, owned outright by this thread. state: DecodeState, pacing: PacingQueue, /// Last known stream-PTS → OBS-clock offset and when it was taken. @@ -162,7 +160,6 @@ impl VideoThread { } } - /// The thread body (`irl_video_thread`). pub fn run(&mut self) { while self.shared.is_active() { let wait = self.run_once(obs::time::gettime_ns()); @@ -184,8 +181,7 @@ impl VideoThread { } /// One pass of the loop body, at OBS clock `now_ns`. Returns how long to - /// sleep before the next pass; zero means "go round again immediately", - /// which is the C's `continue`. + /// sleep before the next pass; zero means "go round again immediately". pub fn run_once(&mut self, now_ns: u64) -> Duration { if self.shared.video.take_clear() { // The receiver already dropped `video_queue`; the paced frames @@ -229,8 +225,8 @@ impl VideoThread { } self.pacing_emit_due(now_ns, slack_ns); self.publish_counters(); - // Fresh clock for the sleep, as in the C: the emit above may have - // taken long enough to make the next frame due already. + // Fresh clock for the sleep: the emit above may have taken long + // enough to make the next frame due already. self.sleep_hint(obs::time::gettime_ns(), slack_ns) } @@ -247,12 +243,11 @@ impl VideoThread { /// Decode packets into the pacing queue until it holds its lead. /// - /// This is the whole point of the design: the queue's soft bound is - /// [`consts::VIDEO_DECODE_LEAD_MS`] of *media*, not the stream's latency, - /// so only a quarter second of decoded frames is ever resident however deep - /// the Target Buffer is. Everything behind that stays compressed in the - /// channel. Each frame is copied out of the hardware pool immediately, so a - /// decoder surface is pinned only for the length of one transfer. + /// The queue's soft bound is [`consts::VIDEO_DECODE_LEAD_MS`] of *media*, + /// not the stream's latency, so only that much decoded video is resident + /// however deep the Target Buffer is; everything behind it stays + /// compressed in the channel. Each frame is copied out of the hardware pool + /// at once, so a decoder surface is pinned only for one transfer. fn decode_intake(&mut self) { let shared = self.shared.clone(); if shared.video_flags.timeline_reset.swap(false, Relaxed) { @@ -307,7 +302,7 @@ impl VideoThread { /// /// Public as the seam the pacing tests use to put a frame in front of the /// loop without a decoder; production reaches it only through - /// [`Self::decode_intake`]. + /// `decode_intake`. pub fn pace_decoded(&mut self, frame: Frame, received_ns: u64) { let sysmem = self.to_sysmem(&frame); let due_ns = match &sysmem { @@ -319,7 +314,6 @@ impl VideoThread { if let Some(f) = sysmem { let pts_ns = f.pts(); self.arrival.note(received_ns, pts_ns); - // `av_image_get_buffer_size(fmt, w, h, 1)`, as `pacing_frame_bytes`. let bytes = f.image_buffer_size().unwrap_or(0); let paced = Paced { frame: f, @@ -329,8 +323,8 @@ impl VideoThread { } } - /// `pacing_reschedule`: re-derive every queued frame's due time from the - /// offset as it stands now. + /// Re-derive every queued frame's due time from the offset as it stands + /// now. /// /// The audio side reclaims playout latency two ways, and both would leave /// paced video behind for the depth of this queue. The speed controller @@ -352,10 +346,9 @@ impl VideoThread { }); } - /// `pacing_emit_due`: emit every frame whose moment has arrived. Over the - /// ceilings the head goes out early rather than being dropped — too-early - /// video is what the un-paced path did all the time, and it beats a hole - /// in the picture. + /// Emit every frame whose moment has arrived. Over the ceilings the head + /// goes out early rather than being dropped: early video beats a hole in + /// the picture. fn pacing_emit_due(&mut self, now_ns: u64, slack_ns: i64) { let tick_ns = self.canvas_tick_ns(); if self.anchor_pending { @@ -413,16 +406,11 @@ impl VideoThread { /// it hands libobs the frame that anchors its play head. /// /// libobs anchors the play head to the *arrival* of the first frame after - /// a start or a clear and only ever advances it by wall-clock deltas, so - /// whatever error that frame's timing carries is the connection's - /// lip-sync error for good. Before audio primes the only schedule is the - /// video-only fallback, and it does not agree with the mapping audio will - /// publish: the fallback places the first frame `Target Buffer` after - /// arrival, the mapping places it at the first audio chunk — a prime - /// threshold plus a chunk after the first *kept* audio, so ~100 ms later - /// when the warm-up had already drained, and earlier than the fallback by - /// whatever warm-up remained when it had not. Which case a connection - /// lands in is a race between the audio warm-up and the first keyframe. + /// a start or a clear and only advances it by wall-clock deltas, so that + /// frame's timing error is the connection's lip-sync error for good. + /// Before audio primes the only schedule is the video-only fallback, which + /// disagrees with the mapping audio will publish by ~100 ms, in a + /// direction set by whether the audio warm-up or the first keyframe wins. /// /// So while an audio stream is present, video holds until the mapping /// exists and anchors from it. A stream whose audio never primes is let @@ -468,55 +456,40 @@ impl VideoThread { /// Settle the frame that will anchor libobs's play head. /// - /// Two things are decided for each head frame, in this order. First its - /// arrival margin: nothing has been shown yet, so a frame whose packet - /// reached this thread less than a canvas tick before it is due raises the - /// standing video delay on the spot ([`VideoDelay::before_anchor`]) and the - /// queue is moved onto it. A tick, not the delivery lead: the frame is - /// measured at its packet's arrival and still has to be decoded, so the - /// tick is the allowance for that, while the lead is an allowance for the - /// pacing timer oversleeping, which a frame handed over on arrival never - /// meets. That is what keeps a sender whose video trails its audio - /// by more than Target Buffer covers from playing unpaced — every frame - /// late, handed over on arrival, and dropped by libobs whenever two arrive - /// inside one canvas tick — or, before this existed, from being dropped - /// here indefinitely. The delay is a fixed lip-sync error in return for a - /// smooth picture, and the log line says how much more Target Buffer would - /// remove it. + /// Two things are decided for each head frame, in this order. /// - /// Second, with audio present, whether the head is stale. The anchoring - /// frame must go out at its due time, so a head already past due cannot be - /// it, and such frames are dropped until one that is on time is at the - /// head. The mapping arriving is what makes frames overdue here — - /// everything decoded during the wait maps to the moments its audio was - /// discarded by the warm-up or already played — and handing them over would - /// anchor the connection late by however stale the first one was. They - /// amount to a fraction of a second at connection start and nothing has - /// been shown yet. Their margins are *not* measured: the probe buffers - /// about a second of packets and the receiver pushes them in one burst, - /// all stamped with the same arrival time, so the oldest of them looks - /// late by up to the probe span when it was merely buffered, and even the - /// one that happens to be on time may be so by buffering. Only the newest - /// frame in hand — the queue holds nothing behind it and the channel is - /// empty, so its arrival is a live one — says anything about the sender's - /// skew. An on-time head that is not the newest anchors unmeasured; if the - /// sender really is late, the hand-over measurement after the anchor sees - /// it within a window. + /// First its arrival margin. Nothing has been shown yet, so a frame whose + /// packet reached this thread less than a canvas tick before it is due + /// raises the standing video delay on the spot + /// ([`VideoDelay::before_anchor`]) and the queue moves with it. The + /// allowance is a tick for the decode still to come, not the delivery + /// lead: the lead covers the pacing timer oversleeping, and a frame handed + /// over on arrival never sleeps. This keeps a sender whose video trails its + /// audio by more than Target Buffer covers from playing unpaced, with + /// libobs dropping a frame whenever two arrive inside one canvas tick. + /// + /// Only the newest frame in hand is measured (nothing queued behind it and + /// the channel empty, so its arrival is a live one). The receiver pushes + /// the probe backlog in one burst stamped with a single arrival time, so + /// older frames look late by up to the probe span when they were merely + /// buffered. An on-time head that is not the newest anchors unmeasured; a + /// sender that really is late is caught by the hand-over measurement after + /// the anchor. /// - /// A sender later than the delay ceiling covers is the one case where the - /// stale test must not run its course: no frame of such a stream is ever - /// on time, so dropping until one is drops the whole connection, which - /// audio still plays through (#33). Once the delay sits at its ceiling, - /// the newest frame in hand anchors however late it is, and the - /// connection plays unpaced from there — every frame handed over on - /// arrival — exactly as the ceiling's warning says it does. + /// Second, with audio present, whether the head is stale. The anchoring + /// frame must go out at its due time, so heads already past due are + /// dropped until one is on time: they map to audio the warm-up discarded + /// or already played, and anchoring on them would make the connection late + /// by that much. The exception is a sender later than the delay ceiling + /// covers, which has no on-time frame at all: once the delay sits at its + /// ceiling the newest frame anchors however late it is, and the connection + /// plays unpaced from there (#33). /// /// "Past due" is measured against a canvas tick, not the emit slack: libobs - /// quantises display to its ticks anyway, and a box with a coarse timer can - /// oversleep by most of one, which must not make it drop every candidate in - /// turn. Without audio there is nothing to be in sync with, and the - /// fallback frames go out as they always did, now a tick after they arrive - /// instead of on arrival. + /// quantises display to its ticks anyway, and a coarse timer can oversleep + /// by most of one, which must not make it drop every candidate in turn. + /// Without audio there is nothing to be in sync with, and the fallback + /// frames go out a tick after they arrive. fn settle_anchor_candidate(&mut self, now_ns: u64, tick_ns: u64) { let audio_present = self.shared.flags.audio_present.load(Relaxed); let mut dropped = 0u32; @@ -554,12 +527,9 @@ impl VideoThread { if !audio_present || on_time { break; } - // Late past the delay ceiling, on the newest frame there is: no - // delay can make this stream on time, and waiting for a frame that - // is would wait forever, dropping every one of them meanwhile - // (#33). It anchors as it is — late, and handed over on arrival - // from here on, as the ceiling promises — rather than showing - // nothing. + // Late past the delay ceiling on the newest frame: no delay makes + // this stream on time, so it anchors as it is rather than dropping + // every frame while waiting for one that is (#33). if newest_in_hand && self.delay.at_ceiling() { break; } @@ -670,13 +640,10 @@ impl VideoThread { /// standing delay. /// /// The hold grows the audio playout offset, and with it every video due - /// time, by playing audio slower until the buffer holds the raise. Video - /// that was late enough to need a delay was late against the old offset, - /// so each millisecond the offset grows is a millisecond of delay it no - /// longer needs. Taking it back here keeps due times exactly where they - /// were: the picture neither jumps nor changes speed, and the lip-sync - /// error the delay stands for drains as the sound slows into step with - /// it. + /// time. Video late enough to need a delay was late against the old + /// offset, so each millisecond the offset grows is a millisecond of delay + /// it no longer needs. Taking it back keeps due times where they were: the + /// picture neither jumps nor changes speed while the lip-sync error drains. fn absorb_audio_hold(&mut self) { let built_ns = self.shared.audio_state().hold_built_ns; let Some(seen_ns) = self.hold_built_seen_ns.replace(built_ns) else { @@ -716,19 +683,15 @@ impl VideoThread { /// lead. /// /// The frame keeps its due time as its timestamp, so this changes when - /// libobs *receives* it, not when it is shown — see - /// [`consts::VIDEO_PACING_LEAD_TICKS`]. + /// libobs *receives* it, not when it is shown (see + /// [`consts::VIDEO_PACING_LEAD_TICKS`]). /// - /// The one exception is the frame that re-anchors libobs's play head. + /// The exception is the frame that re-anchors libobs's play head. /// `get_closest_frame()` shows the first frame after `last_frame_ts` hits /// zero the moment it arrives, whatever its timestamp, and anchors the play - /// head to it; the head only advances by wall-clock deltas from there. So - /// anchoring from a frame handed over two ticks early would run the whole - /// connection two ticks ahead of the schedule the audio mapping set — a - /// permanent ~33ms video-leads-audio offset at a 60fps canvas, in the more - /// noticeable of the two directions. That frame goes at its due time; the - /// jitter the lead guards against cannot show on a frame displayed on - /// arrival regardless. + /// head to it. A frame handed over two ticks early would run the whole + /// connection two ticks ahead of the audio (~33ms at a 60fps canvas), so + /// that frame goes at its due time. fn emit_slack_ns(&self) -> i64 { if self.anchor_pending { return consts::VIDEO_PACING_SLACK_NS; From 8cb376b3d4dfb2dde301e7a51b4a9cfb8447ce7d Mon Sep 17 00:00:00 2001 From: datagutt Date: Fri, 2 Oct 2026 03:17:15 +0200 Subject: [PATCH 26/28] docs(irl-source): drop port history and change narration from test comments --- crates/irl-source/tests/audio_core.rs | 22 ++++----- crates/irl-source/tests/network_sim.rs | 56 ++++++++++------------- crates/irl-source/tests/receiver_probe.rs | 10 ++-- crates/irl-source/tests/shell_redact.rs | 4 +- crates/irl-source/tests/smoke.rs | 2 +- crates/irl-source/tests/video_pipeline.rs | 26 +++++------ 6 files changed, 52 insertions(+), 68 deletions(-) diff --git a/crates/irl-source/tests/audio_core.rs b/crates/irl-source/tests/audio_core.rs index 12ced4e..3d92645 100644 --- a/crates/irl-source/tests/audio_core.rs +++ b/crates/irl-source/tests/audio_core.rs @@ -458,7 +458,7 @@ fn an_idle_pump_reports_when_it_next_has_work() { while pump.pump_once() {} // Primed and queued ahead: it now knows when the lead runs down, and that - // is further away than the 1ms poll it used to spend. + // is further away than a 1ms poll. assert!(shared.audio_state().primed); let hint = pump.idle_sleep_ms(); assert!( @@ -479,10 +479,10 @@ fn an_idle_pump_reports_when_it_next_has_work() { } /// Low-latency mode emits no concealment, so an empty input cannot advance the -/// sample counter. The output clock then sits still while wall clock moves, and -/// the stall check used to read that as a stalled audio thread — restarting, -/// re-anchoring, waiting one lead and tripping again, roughly every 150ms for -/// as long as the source stayed quiet. +/// sample counter. The output clock then sits still while wall clock moves, +/// and the stall check must not read that as a stalled audio thread: it would +/// restart, re-anchor, wait one lead and trip again, roughly every 150ms for +/// as long as the source stays quiet. #[test] fn a_quiet_low_latency_input_suspends_the_clock_instead_of_restart_looping() { let (shared, clock, _, mut pump) = harness(true, true); // low latency @@ -520,13 +520,11 @@ fn a_quiet_low_latency_input_suspends_the_clock_instead_of_restart_looping() { } /// AAC decodes 1024 frames at a time, which does not divide a 120ms target -/// (5760 frames). Reads and writes are both whole chunks, so before the read -/// alignment the residual could only be a multiple of 1024 and the loop had to -/// straddle the target at 106ms or 128ms — up to a whole chunk of cushion the -/// user configured and never got. -/// -/// The existing tests all use 960-frame chunks, where 120ms *is* on the grid, -/// so none of them could see this. +/// (5760 frames). Reads and writes are both whole chunks, so without the read +/// alignment the residual can only be a multiple of 1024 and the loop +/// straddles the target at 106ms or 128ms: up to a whole chunk of cushion the +/// user configured and never gets. With 960-frame chunks 120ms *is* on the +/// grid, so only a 1024-frame stream shows this. #[test] fn the_buffer_settles_on_the_configured_target_with_aac_chunks() { const AAC_FRAMES: usize = 1024; diff --git a/crates/irl-source/tests/network_sim.rs b/crates/irl-source/tests/network_sim.rs index 2bea71e..1dc03d6 100644 --- a/crates/irl-source/tests/network_sim.rs +++ b/crates/irl-source/tests/network_sim.rs @@ -1,15 +1,12 @@ //! End-to-end simulation of the plugin under bad network conditions. //! -//! Everything this plugin is *for* happens when the connection misbehaves, and -//! none of it was testable: a stall, a burst, a sender whose clock is not wall -//! clock. Those were validated by streaming for an hour and reading a stats -//! line. This drives the real jitter buffer, PTS repair, speed controller, -//! output clock, packet queue and pacing queue against a synthetic sender on a -//! virtual clock, and asserts the invariants the design actually promises. -//! -//! The seams were already there: `AudioSink`/`VideoSink` are traits, the pump -//! takes both of its clocks by injection, `irl-core` is pure, and `Shared` can -//! be built without libobs. +//! Everything this plugin is *for* happens when the connection misbehaves: a +//! stall, a burst, a sender whose clock is not wall clock. This drives the +//! real jitter buffer, PTS repair, speed controller, output clock, packet +//! queue and pacing queue against a synthetic sender on a virtual clock, and +//! asserts the invariants the design promises. `AudioSink`/`VideoSink` are +//! traits, the pump takes both of its clocks by injection, and `Shared` can be +//! built without libobs. //! //! **What it does not cover.** The demuxer, and the video decoder itself: the //! bundled FFmpeg carries only the decoders the plugin needs (no rawvideo), so @@ -384,12 +381,10 @@ impl Sim { /// the whole speed ramp at the batch period. /// /// A controller that regulates the instantaneous level chases that sawtooth and -/// modulates playback speed at the same period. Measured here before the level -/// was smoothed: 1.2% peak-to-peak at a 500ms batch and 3.3% at a 1s batch. -/// 1% is 17 cents, so that is plainly audible pitch wobble — and because video -/// due times are the frame PTS plus the audio playout offset, the same -/// modulation lands on video as judder that looks like the picture repeatedly -/// speeding up and catching up. +/// modulates playback speed at the same period: 1.2% peak-to-peak at a 500ms +/// batch and 3.3% at a 1s batch. 1% is 17 cents, so that is audible pitch +/// wobble, and because video due times are the frame PTS plus the audio +/// playout offset, the same modulation lands on video as judder. /// /// Regulating the smoothed level instead leaves the batch period alone while /// still tracking anything that lasts: a stall's backlog persists for tens of @@ -504,17 +499,14 @@ fn repeated_short_dropouts_do_not_ratchet_latency() { #[test] fn a_sender_whose_clock_is_not_wall_clock_still_holds_the_target() { // The case the speed trim exists for: a sender 0.3% fast delivers 3ms of - // extra audio every second, forever. A proportional-only loop parks - // off-target and the latency parks with it. + // extra audio every second, forever. A proportional-only loop parks tens + // of milliseconds off target, and the latency parks with it. for rate in [1.003, 0.997] { let mut sim = Sim::new(120); sim.sender_rate = rate; sim.run(240.0, Link::Up); sim.assert_healthy(); - // Without the integral trim a proportional loop parks tens of - // milliseconds off target here, permanently, and the latency parks - // with it. let mean = sim.mean_fill_ms(60.0); assert!( (mean - 120.0).abs() <= 20.0, @@ -545,7 +537,7 @@ fn an_unwinnably_fast_sender_is_bounded_rather_than_unbounded() { /// The property the packet-paced design turns on: video output does not depend /// on the receiver thread running. The receiver spends a stall blocked in /// `av_read_frame`, and that is exactly when video has to keep draining what it -/// already holds. If decode ever moves back onto the receiver, this stops. +/// already holds. #[test] fn video_keeps_flowing_while_the_receiver_is_blocked() { let mut sim = Sim::new(120); @@ -582,8 +574,7 @@ fn decoded_memory_does_not_grow_with_the_target() { common::push_packet(&deep.shared, i * 16_666_667, 16 * 1024); } - // Compressed, that is single-digit megabytes. Decoded it would be ~1.5GB, - // which is what the pacing queue used to be asked to hold. + // Compressed, that is single-digit megabytes. Decoded it would be ~1.5GB. let queued = deep.shared.video.bytes(); assert!( queued < 16 * 1024 * 1024, @@ -605,11 +596,10 @@ fn decoded_memory_does_not_grow_with_the_target() { /// pocketSRT queues its audio about 300 ms ahead of its deadline, so the /// audio of an instant reaches the plugin ~350 ms before the video of it, /// against a default cushion of 120 ms plus the 80 ms output lead. Played as -/// it arrives, the sound ran that far ahead of the picture, and the only -/// remedies were a Target Buffer the user had to know to raise or a Sync -/// Offset by hand (#34). The hold measures the skew before audio starts and -/// starts audio late enough for the picture: nothing inserted, nothing -/// skipped, and video maps with an ordinary margin. +/// it arrives, the sound runs that far ahead of the picture (#34). The hold +/// measures the skew before audio starts and starts audio late enough for the +/// picture: nothing inserted, nothing skipped, and video maps with an +/// ordinary margin. #[test] fn audio_that_arrives_ahead_of_its_video_starts_late_enough_to_keep_lip_sync() { let mut sim = Sim::new(120).with_video_trailing_by(350); @@ -649,7 +639,7 @@ fn video_seconds_behind_its_audio_is_held_for_too() { } /// A sender whose video is within what Target Buffer already covers needs no -/// hold, and starts as soon as it always did. +/// hold. #[test] fn a_sender_within_the_target_gets_no_hold() { let mut sim = Sim::new(120).with_video_trailing_by(50); @@ -714,8 +704,8 @@ fn video_that_falls_behind_mid_stream_is_caught_up_by_slowing_audio() { assert_eq!(sim.underruns(), 0); } -/// The regression #34 asked for: a sender whose video is steadily behind -/// its audio, with a short burst of later video every few seconds. The hold +/// The case from #34: a sender whose video is steadily behind its audio, +/// with a short burst of later video every few seconds. The hold /// covers the steady skew; the bursts are the video delay's to cover and do /// not ratchet the latency up. #[test] @@ -776,7 +766,7 @@ fn low_latency_audio_holds_at_connection_start_only() { assert_eq!(sim.shared.hot.watermarks().target_ms, 120, "nothing folded"); sim.assert_in_lip_sync(); - // A later skew is the video delay's, as before. + // A later skew is the video delay's. sim.video_trails_by(900); sim.run(20.0, Link::Up); assert_eq!(sim.hold_ms(), hold); diff --git a/crates/irl-source/tests/receiver_probe.rs b/crates/irl-source/tests/receiver_probe.rs index c534c92..734de6b 100644 --- a/crates/irl-source/tests/receiver_probe.rs +++ b/crates/irl-source/tests/receiver_probe.rs @@ -1,10 +1,8 @@ //! Hardware-decode probe order, the forced-NVDEC pixel format decision and the //! reconnect countdown (`src/receiver/probe.rs`). //! -//! The module is included by path rather than imported: `crate::receiver` is -//! private to the plugin crate and an integration test is a separate crate. -//! `probe.rs` depends on nothing but the `ffmpeg` crate, so the copy behaves -//! identically to the one the plugin builds. +//! The module is included by path. It depends on nothing but the `ffmpeg` +//! crate, so the copy behaves identically to the one the plugin builds. #[path = "../src/receiver/probe.rs"] mod probe; @@ -93,8 +91,8 @@ fn nvdec_format_selection_skips_software_formats() { match cuda_formats(&codec).first().copied() { Some(cuda) => { // The CUDA format wins even when a software format is offered - // first: the C loop returns the first *offered* format that any - // CUDA config declares. + // first: the first *offered* format that any CUDA config declares + // is returned. assert_eq!( probe::pick_cuda_format(&codec, &[AVPixelFormat::AV_PIX_FMT_YUV420P, cuda]), cuda diff --git a/crates/irl-source/tests/shell_redact.rs b/crates/irl-source/tests/shell_redact.rs index 4af8894..ae79cc7 100644 --- a/crates/irl-source/tests/shell_redact.rs +++ b/crates/irl-source/tests/shell_redact.rs @@ -1,5 +1,5 @@ -//! Input-URL redaction (port of master 706372c): log lines keep the endpoint -//! identity, never credentials. +//! Input-URL redaction: log lines keep the endpoint identity, never +//! credentials. use obs_irl_source::log::redacted_input_url; diff --git a/crates/irl-source/tests/smoke.rs b/crates/irl-source/tests/smoke.rs index 5c6b93a..067b9ae 100644 --- a/crates/irl-source/tests/smoke.rs +++ b/crates/irl-source/tests/smoke.rs @@ -1,5 +1,5 @@ //! Integration-test target (libobs is linked for test binaries only; see -//! build.rs). Wave-2 agents add their tests as further files in this dir. +//! build.rs). #[test] fn crate_links() { assert!(!obs_irl_source::PLUGIN_VERSION.is_empty()); diff --git a/crates/irl-source/tests/video_pipeline.rs b/crates/irl-source/tests/video_pipeline.rs index a96e544..f382d20 100644 --- a/crates/irl-source/tests/video_pipeline.rs +++ b/crates/irl-source/tests/video_pipeline.rs @@ -65,8 +65,7 @@ impl Recorder { } } -/// Local newtype: `VideoSink` and `Arc` are both foreign to this test crate -/// now that the plugin modules come from the library. +/// Local newtype: `VideoSink` and `Arc` are both foreign to this test crate. struct RecorderSink(Arc); impl VideoSink for RecorderSink { @@ -871,7 +870,7 @@ fn a_frame_late_by_under_a_canvas_tick_still_anchors() { } /// A stream that advertises audio but never primes it cannot hold video -/// forever: past the expected prime the fallback anchors as before. +/// forever: past the expected prime the fallback anchors. #[test] fn video_stops_waiting_for_audio_that_never_primes() { let shared = shared_with_audio(); @@ -1163,7 +1162,7 @@ fn frames_handed_over_short_of_the_lead_but_before_due_do_not_raise_the_delay() } /// A few late frames in a row are a scheduling hiccup on the host, not a -/// sender skew: they go out late, as they always did, and the delay stays. +/// sender skew: they go out late, and the delay stays. #[test] fn a_single_late_burst_after_the_anchor_does_not_raise_the_delay() { let shared = shared_with_audio(); @@ -1285,11 +1284,11 @@ fn video_later_than_the_delay_ceiling_anchors_and_plays_unpaced() { /// The delay before the anchor is sized from one frame, and that frame can /// still lie in a way the live check cannot see: a sender that is late for /// its first seconds and then is not (an encoder warming up, a stabiliser -/// switched off), arriving in real time throughout. A delay that never shrank then held on-time video that far behind -/// its audio for the whole connection, which a tester heard as the sound -/// running ahead of the picture until the stream dropped. Once a whole window -/// of frames has needed less, the delay ramps back down at the Catch-Up -/// Speed: video plays slightly fast, nothing jumps and nothing is dropped. +/// switched off), arriving in real time throughout. A delay that never shrank +/// would hold on-time video that far behind its audio for the whole +/// connection. Once a whole window of frames has needed less, the delay ramps +/// back down at the Catch-Up Speed: video plays slightly fast, nothing jumps +/// and nothing is dropped. #[test] fn a_delay_set_by_a_bad_first_frame_ramps_back_without_a_jump() { let shared = shared_with_audio(); @@ -1420,11 +1419,10 @@ fn the_video_delay_hands_itself_back_as_the_audio_hold_builds() { /// audio, so the connection opens with video a second behind its audio, and /// that video then arrives faster than real time until it has caught up. A /// frame measured during the catch-up reads as a late sender on a stream -/// with no skew at all; a tester on a throttled link got 1333 ms of delay -/// from it and the sound ran ahead of the picture until the stream dropped. -/// The network cannot cause this (both streams share one mux), so it is not -/// a poor-link problem and must not be answered like one: video sizes the -/// delay only once it is live, and this stream anchors in sync with none. +/// with no skew at all. The network cannot cause this (both streams share +/// one mux), so it is not a poor-link problem and must not be answered like +/// one: video sizes the delay only once it is live, and this stream anchors +/// in sync with none. #[test] fn video_catching_up_to_live_at_connection_start_sets_no_delay() { let shared = shared_with_audio(); From 4804a97c8c5ec56b93eb6ff5b16c464567c05dcc Mon Sep 17 00:00:00 2001 From: datagutt Date: Fri, 2 Oct 2026 03:19:25 +0200 Subject: [PATCH 27/28] refactor(irl-core): audio buffer construction cannot fail, so it no longer returns an option --- crates/irl-core/src/audio_buffer.rs | 7 +++---- crates/irl-source/src/receiver/audio_in.rs | 8 +++----- crates/irl-source/tests/audio_core.rs | 6 +++--- 3 files changed, 9 insertions(+), 12 deletions(-) diff --git a/crates/irl-core/src/audio_buffer.rs b/crates/irl-core/src/audio_buffer.rs index 81461e2..a47deea 100644 --- a/crates/irl-core/src/audio_buffer.rs +++ b/crates/irl-core/src/audio_buffer.rs @@ -59,7 +59,7 @@ pub struct AudioBuffer { impl AudioBuffer { /// Allocate for `sample_rate`/`channels`/`bytes_per_sample` with the - /// capacity `4 × max_ms` implies. Never returns `None`. + /// capacity `4 × max_ms` implies. pub fn new( sample_rate: i32, channels: i32, @@ -67,7 +67,7 @@ impl AudioBuffer { target_ms: i32, min_ms: i32, max_ms: i32, - ) -> Option { + ) -> Self { let mut buf = Self { data: Vec::new(), head: 0, @@ -86,7 +86,7 @@ impl AudioBuffer { }; let capacity = buf.capacity_for(max_ms); buf.data = vec![0u8; capacity]; - Some(buf) + buf } /// Reinitialise for a new format (flushes). The watermarks are kept. @@ -507,7 +507,6 @@ mod tests { target_ms / 2, target_ms + 200, ) - .unwrap() } /// A chunk whose every float sample equals `value`. diff --git a/crates/irl-source/src/receiver/audio_in.rs b/crates/irl-source/src/receiver/audio_in.rs index 1f91ff4..bfc5845 100644 --- a/crates/irl-source/src/receiver/audio_in.rs +++ b/crates/irl-source/src/receiver/audio_in.rs @@ -231,17 +231,15 @@ impl AudioIntake { match guard.as_mut() { Some(buf) => buf.reconfigure(out_rate, out_channels, dsp::SAMPLE_BYTES as i32), None => { - let buf = irl_core::AudioBuffer::new( + *guard = Some(irl_core::AudioBuffer::new( out_rate, out_channels, dsp::SAMPLE_BYTES as i32, watermarks.target_ms, watermarks.min_ms, watermarks.max_ms, - ); - let ok = buf.is_some(); - *guard = buf; - ok + )); + true } } }; diff --git a/crates/irl-source/tests/audio_core.rs b/crates/irl-source/tests/audio_core.rs index 3d92645..25052d6 100644 --- a/crates/irl-source/tests/audio_core.rs +++ b/crates/irl-source/tests/audio_core.rs @@ -28,7 +28,7 @@ fn make_shared(low_latency: bool, adaptive: bool) -> Arc { /// A configured jitter buffer and a pump wired to `clock` and `recorder`. fn make_pump(shared: &Arc, clock: &Arc, recorder: &Recorder) -> AudioPump { - *shared.audio_buf() = AudioBuffer::new(RATE, CHANNELS, 4, 120, 60, 320); + *shared.audio_buf() = Some(AudioBuffer::new(RATE, CHANNELS, 4, 120, 60, 320)); shared.audio_state().decoded_frame_samples = CHUNK_FRAMES; let clock = Arc::clone(clock); @@ -214,7 +214,7 @@ fn disconnect_fade_does_not_submit_stale_audio() { let shared = make_shared(false, true); let recorder = Recorder::with_samples(); let now = 10_000_000_000; - *shared.audio_buf() = AudioBuffer::new(rate, CHANNELS, 4, 120, 60, 320); + *shared.audio_buf() = Some(AudioBuffer::new(rate, CHANNELS, 4, 120, 60, 320)); let data = vec![0u8; rate as usize / 10 * CHANNELS as usize * 4]; shared .audio_buf() @@ -534,7 +534,7 @@ fn the_buffer_settles_on_the_configured_target_with_aac_chunks() { let clock = Arc::new(AtomicU64::new(1_000_000_000)); let recorder = Recorder::with_samples(); - *shared.audio_buf() = AudioBuffer::new(RATE, CHANNELS, 4, 120, 60, 320); + *shared.audio_buf() = Some(AudioBuffer::new(RATE, CHANNELS, 4, 120, 60, 320)); shared.audio_state().decoded_frame_samples = AAC_FRAMES as i32; let mut pump = { let ns = Arc::clone(&clock); From 9540fd17e3bda4404539424ec554df0ceb99edac Mon Sep 17 00:00:00 2001 From: datagutt Date: Fri, 2 Oct 2026 03:21:15 +0200 Subject: [PATCH 28/28] docs(workspace): cut CLAUDE.md to the rules and move the design narrative to docs/architecture.md the deviation list against the 1.x C plugin is gone; current behaviour is the spec. --- CLAUDE.md | 290 ++++++++----------------------------------- docs/architecture.md | 97 +++++++++++++++ 2 files changed, 152 insertions(+), 235 deletions(-) create mode 100644 docs/architecture.md diff --git a/CLAUDE.md b/CLAUDE.md index 5233680..080f728 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -2,24 +2,21 @@ This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. -## Project overview +## Project -IRL Source is a third-party OBS Studio plugin (Rust 2024, AGPL-3.0) for receiving live IRL streams over SRT, RTMP, RIST, or any FFmpeg-supported protocol. It solves IRL-specific problems: audio jitter buffering, PTS discontinuity repair, adaptive playback speed, keyframe gating, hardware-accelerated decoding, and mid-stream resolution changes. +IRL Source is a third-party OBS Studio plugin (Rust 2024, AGPL-3.0) that receives live IRL streams over SRT, RTMP, RIST or any FFmpeg-supported protocol. It handles what IRL feeds need: audio jitter buffering, PTS repair, adaptive playback speed, keyframe gating, hardware decoding, mid-stream resolution changes and lip sync for senders whose video trails their audio. -Version 2.0.0 is a full port of the 1.x C plugin. The C tree is gone; its last commit (`c727912`) is the specification, and `git show c727912:src/.c` is the way to check what the original did. Behaviour is identical apart from the deliberate deviations listed at the bottom of this file. +Read `docs/architecture.md` before changing threads, timing or A/V sync. Read `docs/audio-timing-pitfalls.md` before touching `crates/irl-core/src/speed.rs`. -## Build commands +## Build -cargo drives everything; there is no CMake. Two prerequisites: +cargo drives everything. Two prerequisites: -1. **The bundled media stack.** The plugin statically links its own FFmpeg, libsrt, librist and mbedTLS (see `deps/README.md`), so `./deps/build-deps.sh` runs first. It is incremental, so this is a one-time cost per version bump. It writes `deps/.build/prefix/irl-deps.env`, which `crates/ffmpeg/build.rs` replays as link lines. -2. **libclang.** `ffmpeg-sys-next` runs bindgen over the bundled headers at build time. - -libobs is neither built nor linked. `crates/obs-sys` declares the ~58 functions the plugin uses and the symbols resolve against the host OBS process at load time (`raw-dylib` from `obs.dll` on Windows, undefined symbols elsewhere). `libobs-dev` is only needed to *test*. - -### Linux +1. **The bundled media stack.** The plugin statically links its own FFmpeg, libsrt, librist and mbedTLS (`deps/README.md`). Run `./deps/build-deps.sh` once per version bump. It writes `deps/.build/prefix/irl-deps.env`, which `crates/ffmpeg/build.rs` replays as link lines. On Windows it runs inside MSYS2 with the MSVC environment active; see the `windows-x64` job in `.github/workflows/build.yml`. +2. **libclang**, for bindgen in `ffmpeg-sys-next`. Set `LIBCLANG_PATH` on Windows and macOS. ```bash +# Linux sudo apt install build-essential cmake pkg-config nasm meson ninja-build \ clang libclang-dev libobs-dev libva-dev ./deps/build-deps.sh @@ -27,248 +24,71 @@ cargo build --release ./scripts/verify-plugin.sh target/release/libobs_irl_source.so ``` -### Windows (MSVC) - -`deps/build-deps.sh` runs inside MSYS2 with the MSVC environment active (FFmpeg's configure needs a POSIX shell even when driving `cl.exe`); the cargo build runs from a normal MSVC prompt. See the `windows-x64` job in `.github/workflows/build.yml` for the exact setup. +libobs is never linked; `libobs-dev` is only needed to run tests. `.cargo/config.toml` sets `FFMPEG_DIR` to `deps/.build/prefix`; override it and `IRL_DEPS_PREFIX` to build against another prefix. `rust-toolchain.toml` pins stable; the plugin must never need nightly. -```powershell -$env:LIBCLANG_PATH = "$env:ProgramFiles\LLVM\bin" -cargo build --release -``` - -### macOS (Apple Silicon) +`make` runs every gate with an explicit config out of `.config/`, so a machine's global settings cannot change the result: ```bash -brew install cmake pkg-config nasm meson ninja -export LIBCLANG_PATH=/Applications/Xcode.app/Contents/Developer/Toolchains/XcodeDefault.xctoolchain/usr/lib -./deps/build-deps.sh -cargo build --release -./scripts/verify-plugin.sh target/release/libobs_irl_source.dylib -``` - -### Environment - -`FFMPEG_DIR` is set to `deps/.build/prefix` by `.cargo/config.toml` (relative, `force = false`), which keeps ffmpeg-sys-next on its prebuilt-tree branch instead of probing pkg-config. Override it, and `IRL_DEPS_PREFIX`, to build against a prefix produced elsewhere. `rust-toolchain.toml` pins the stable toolchain; the plugin must never need nightly. - -### Everything else - -```bash -cargo xlint # clippy --workspace --all-targets -- -D warnings -cargo xtest # test --workspace -cargo test -p obs-sys --features layout-test # struct layouts vs real libobs headers -cargo build --release -p irl-source --features deadlocks -scripts/package.sh linux target/release dist # the release archive, locally -``` - -`make` wraps the same gates plus the ones cargo does not cover, each with an explicit config out of `.config/` so a machine's global settings cannot change the result. `make style` is the only target that rewrites files; `cargo fmt` on its own picks the wrong width, so always go through the Makefile or pass `--config-path .config/rustfmt.toml`. - -```bash -make style # cargo fmt -make style-check -make lint # cargo xlint -make test # cargo xtest -make test-shim # the libobs-dependent tests on macOS, against scripts/libobs-shim/ -make spell-check # codespell -make tls-provider # Cargo.lock still resolves rustls onto ring, not aws-lc-rs make check # style-check + lint + test + spell-check + tls-provider, what CI runs -make sim # the speed-controller simulation; not a CI target -``` - -`--features deadlocks` spawns parking_lot's deadlock detector at module load and logs any cycle with backtraces. It replaces the C build's `IRL_CHECKED_LOCKS`; use it whenever you touch the threading model. Development only. - -`cargo build` names the artifact `libobs_irl_source.so` / `obs_irl_source.dll` / `libobs_irl_source.dylib`. `scripts/package.sh` is what renames it to `obs-irl-source.*` and stages the platform's install layout. - -`scripts/verify-plugin.sh` is not optional polish. It asserts what a successful compile does not prove: the binary carries no `libav*` dependency, it exports nothing but `obs_module_*`, its undefined symbols are libobs and libc only, and `#![forbid(unsafe_code)]` is still on `irl-core` and `irl-source`. CI runs it (and a `dumpbin` equivalent on Windows) on every build. - -## Architecture - -One cdylib, five workspace crates. The rule that shapes the split: **all unsafe code lives in `obs-sys`, `obs` and `ffmpeg`.** `irl-core` and `irl-source` carry `#![forbid(unsafe_code)]`, so no port of C pointer arithmetic can sneak in. - -| crate | what it is | -| --- | --- | -| `crates/obs-sys` | Hand-written libobs FFI: `#[repr(C)]` structs, `extern` declarations, constants. No safety, no abstraction. A `layout-test` feature runs bindgen over the real headers and asserts every field offset. | -| `crates/obs` | Safe, plugin-agnostic libobs API: the `Source` trait and registration, `declare_module!`, `Data`/`Properties`/`CallData`/`ProcHandler`, `VideoFrame`/`AudioFrame` builders, scene transforms, the obs-websocket vendor helper, `panic::guard`. Knows nothing about IRL streaming. | -| `crates/ffmpeg` | RAII over `ffmpeg-sys-next` (package `irl-ffmpeg`, lib name `ffmpeg`): `FormatContext`, `CodecContext`, `Frame`, `Packet`, `HwDeviceContext`, `FramePool`, `Resampler`, `Scaler`, `InterruptWatch`, and `log::route_to`, which hands the bundled FFmpeg's `av_log` to a caller-supplied sink. `build.rs` replays `irl-deps.env`. | -| `crates/irl-core` | Everything that needs neither libobs nor FFmpeg: the jitter buffer, PTS repair, the speed controller, output-clock arithmetic, video pacing, demuxer options, config derivation, the stats table, every tuning constant. Plain data in, plain data out — and therefore the only crate with a real unit-test suite. | -| `crates/irl-provider` | The plugin side of `docs/provider-protocol.md`: discovery, OAuth code + PKCE over a loopback redirect, the per-provider state file, the key-free ingest list, the resolve call, and the `Catalog` (the provider list, parsed from the shipped `providers.json`). `#![forbid(unsafe_code)]`, no libobs; the plugin hands it a state directory, a logger and a wake-the-dialogs callback through `init`. Pure parts are tested under `tests/` without a network. | -| `crates/irl-source` | The plugin itself: module entry points, the source lifecycle and the three worker threads. | - -### Data flow - -``` -[receiver thread]: FFmpeg URL, demux - audio: decode, PTS repair, resample, write to jitter buffer - video: push the *compressed packet* onto the video queue - -[video thread]: decode packets as they come due, keyframe gate, HW frame - transfer, hold until due, format conversion, - OBS async video output - -[audio thread]: drain jitter buffer, speed correction, concealment, - OBS audio output +make style # the only target that rewrites files; plain `cargo fmt` picks the wrong width +make test-shim # libobs-dependent tests on macOS, against scripts/libobs-shim/ +make sim # speed controller closed-loop simulation; run it whenever you touch speed.rs +cargo test -p obs-sys --features layout-test # struct layouts vs the real libobs headers +cargo build --release -p irl-source --features deadlocks # use whenever you touch threading +scripts/package.sh linux target/release dist # the release archive, locally ``` -### Audio output contract (verified against libobs source) - -The audio core is built around three facts about libobs: - -1. OBS timestamps must be contiguous (`ts[n+1] = ts[n] + frames/rate`). Deviations under 70ms are smoothed, 70ms to 2s gaps are zero filled by OBS (audible), larger jumps flush all queued audio. The plugin therefore derives timestamps from a pure sample counter anchored once at prime time and never jumps the clock outside declared restarts. -2. Changing `samples_per_sec` between submissions makes OBS destroy and recreate its per source resampler with no crossfade (a click per change). Playback speed is instead applied inside the plugin with a persistent swresample compensation, and the rate submitted to OBS never changes. -3. The OBS mixer consumes 21.3ms ticks against wall clock. A source whose queued audio runs dry gets a tick of silence plus a time shifted splice (crackle), and a source that falls behind the mix window causes OBS to permanently add global audio buffering. After priming, the pump always emits (real audio or shaped concealment silence) and keeps a fixed lead ahead of wall clock. - -Buffer regulation happens through playback speed only, asymmetric like IRLToolkit's player: builds at an inaudible -2%, drains post-stall backlog at up to the Catch-Up Speed setting (+5% by default, mild chipmunk). The loop is PI, not P: a slow integral trim under the proportional ramp removes the standing error a sender whose media clock is not wall clock would otherwise leave, and it converges on the sender's rate without measuring it. `docs/audio-timing-pitfalls.md` is why every part of it is shaped the way it is, and is required reading before touching `speed.rs`. Content is never skipped once playback has primed. Backlog beyond a fill ceiling is pushed back into the transport by pausing the read loop (TCP/RTMP backpressure; SRT bounds itself via its latency window), and startup backlog is trimmed only before priming. +`scripts/verify-plugin.sh` asserts what a compile does not prove: no `libav*` dependency, nothing exported but `obs_module_*`, undefined symbols from libobs and libc only, and `#![forbid(unsafe_code)]` still on `irl-core` and `irl-source`. CI runs it on every build. -### `crates/irl-core` +## Layout -| module | contents | -| --- | --- | -| `consts.rs` | Every tuning constant, with a test pinning the values to the C plugin's. Nothing else may hardcode a threshold. | -| `audio_buffer.rs` | The jitter buffer: a ring sized in milliseconds with a parallel 256-entry PTS chunk queue. Carries no lock of its own; the caller's mutex is the lock. `resize` grows and never shrinks. | -| `pts_repair.rs` | Three-tier discontinuity repair plus the relock path: small gaps interpolated, medium gaps get silence, large gaps reset the timeline. | -| `speed.rs` | The PI playback-speed controller, regulating a ~2.5s EMA of the buffer level rather than the level itself (a batching upstream otherwise makes it modulate playback at the batch period): the proportional ramp (sloped deadband, EMA, asymmetric limits), `SpeedTrim` (the integral term, with its error window and anti-windup), `SpeedCarry` (the fractional output-sample debt that makes sub-0.1% speeds applicable at all) and `DrainWatch`, which notices a buffer that stopped draining. `examples/speed-controller-sim.rs` drives all of it closed-loop. | -| `pacing.rs` | The video-thread pacing queue: a soft bound (the decode lead, in media time) that stops intake, and hard frame/byte ceilings that emit early; `reschedule` re-derives due times, `due_now` returns Emit / EmitEarly / Wait. | -| `timing.rs` | Output-clock arithmetic: next timestamp, lead, expected samples, soft compensation, prime threshold. | -| `dsp.rs` | Fades, shaped concealment silence, last-sample memory. | -| `video_time.rs` | Mapping video PTS through the audio playout offset, the fallback anchor and its clamps, the frame-interval EMA. | -| `audio_hold.rs` | `AudioHold`: how much longer than Target Buffer audio waits so that video which arrives behind its audio is in hand when due. Reads the skew in mux order, sizes the hold from the worst reading before priming, raises it afterwards only for a skew sustained across `AUDIO_HOLD_RAISE_WINDOW_MS` and releases it once `AUDIO_HOLD_RELAX_WINDOW_MS` of readings needed less. Pure decisions; `irl-source/src/audio/hold.rs` applies them. | -| `arrival.rs` | `ArrivalFloor`: whether video is live or still catching up to live, from the floor of arrival minus PTS. Gates the pre-anchor delay measurement, so a relay replaying video from its last keyframe next to live audio does not read as a late sender. | -| `video_delay.rs` | The standing video delay: sized from the worst shortfall seen so that no frame reaches libobs after its due time, raised at once before the play head is anchored (measured at packet arrival on the newest frame in hand only, with a tick's allowance for decode) and only for lateness that recurs across a window after it (measured at hand-over), capped. It ramps back down at the Catch-Up Speed when a ten-second window of frames all needed less at arrival, so a bad first frame on a poor link does not hold the picture behind the sound for the whole connection. | -| `url_opts.rs` | The demuxer option table (probe sizes, SRT latency, RIST/UDP buffers, `tls_verify=0`), parsing of the user's FFmpeg Options, and `awaits_caller`, which decides from the URL's query *and* the FFmpeg Options whether the I/O stall deadline applies before a connection exists. | -| `stats.rs` | `FIELDS`, `StatsSnapshot`, `proc_declaration()`. | -| `config.rs` | `HwDecode`, `Watermarks::derive`. | +- `crates/obs-sys`: hand-written libobs FFI, checked by `layout-test`. +- `crates/obs`: the safe libobs API this plugin uses. +- `crates/ffmpeg` (package `irl-ffmpeg`): RAII over `ffmpeg-sys-next`. +- `crates/irl-core`: everything pure. Jitter buffer, PTS repair, speed controller, output clock, pacing, video delay, audio hold, stats table, tuning constants. +- `crates/irl-provider`: the plugin side of `docs/provider-protocol.md`. +- `crates/irl-source`: the plugin. `source.rs` (lifecycle), `settings.rs` (dialog), `providers.rs`, `config.rs`, `shared.rs` (per-run shared state), `receiver/`, `video/`, `audio/`, `websocket.rs`. -### `crates/irl-source` +## Rules -| file | ports | -| --- | --- | -| `lib.rs` | `plugin.c`: `declare_module!`, load → the FFmpeg log route, `register_source::()` and `providers::init()`, post_load → `websocket::register()`, the deadlock poller under the feature. | -| `log.rs` | `irl_info!` / `irl_warn!` / `irl_error!` / `irl_debug!`, which bind the `[irl-source]` prefix, plus the redaction (`redacted_input_url`, `redacted_log_line`) and the `[ffmpeg]` sink. | -| `source.rs` | `irl-source.c`: create/update/tick/activate/deactivate/show/hide/Drop, the media callbacks and the `media_stopped` latch, `start_receiver`/`stop_receiver`, fit-to-canvas, the `get_stats` proc. | -| `settings.rs` | `settings.c`: defaults and the properties dialog. | -| `providers.rs` | New in 2.x: the Provider dropdown, one ingest picker per provider and the sign-in buttons. The dropdown lists the `Catalog`, read once from `providers.json` in the module data directory (`obs::module::data_file`); `data/providers.json` is the stock file and ships in every archive, like the locale. Writes into `url` and nothing else; `tests/provider_seam.rs` pins that no file outside it, `settings.rs` and `lib.rs` mentions providers. Its module doc lists the four libobs dialog behaviours that dictate its shape. | -| `config.rs` | `config_load` / `config_requires_restart` / `config_apply_hot`. | -| `shared.rs` | The decomposition of the C `struct irl_source` into owners (see below). | -| `receiver/{mod,stream,decode,audio_in}.rs` | `receiver.c`, `receiver-stream.c`, the audio half of `receiver-decode.c`, and the intake half of `receiver-audio.c`. | -| `audio/{mod,pump}.rs` | The output half of `receiver-audio.c`: the pump, concealment, speed application, re-anchoring. | -| `audio/hold.rs` | New in 2.x: the audio hold wired into the plugin. The receiver reads the skew per video packet, the pump waits for the first reading before priming and credits what it builds, and the hold is folded into the published watermarks. | -| `video/{mod,thread,decode,intake,output}.rs` | `receiver-video.c`, `video-handler.c` and the video half of `receiver-decode.c`. | -| `websocket.rs` | `websocket-vendor.c`. | +- **Unsafe** lives only in `obs-sys`, `obs` and `ffmpeg`, and every `unsafe` block carries a `// SAFETY:` comment. If plugin code needs a raw pointer, add a safe wrapper in one of those crates. +- **Lock order** is `audio_state` → `audio_buf` → `hot.watermarks`. `video.q` is never held together with any of them. The audio pump takes `audio_state` once per iteration and passes `&mut AudioState` down; parking_lot mutexes are not recursive, so a nested acquire deadlocks. +- **Clocks.** OBS timestamps come from `obs::time::gettime_ns`, never `std::time::Instant`. FFmpeg-side timers stay in the `av_gettime` microsecond domain. `irl-core` takes both as parameters so they cannot be mixed. +- **Audio timestamps** submitted to OBS stay contiguous and the submitted sample rate never changes. Speed is applied inside the plugin. See `docs/architecture.md`. +- **Panics** never cross an FFI boundary: `obs::panic::guard` wraps every `extern "C"` shim and `shared::spawn_worker` wraps every worker thread. +- **Logging** goes through `irl_info!` / `irl_warn!` / `irl_error!` / `irl_debug!`, never `blog` directly. +- **URLs** never reach the log whole. Plugin lines go through `log::redacted_input_url`, FFmpeg's through `log::redacted_log_line`, because FFmpeg prints the user's URL (with `passphrase=` and `streamid=`) in its own errors. +- **UI strings** never pass English to `module_text`. A new string goes in the call site and `data/locale/en-US.ini`; `tests/locale_keys.rs` checks both directions. +- **Stats** are one table: a new stat is one line in `irl_core::stats::FIELDS` plus its `StatsSnapshot` field and `values()`. The proc, the calldata writer and the websocket loop walk the table. Update the README table too. A stat stays only if it diagnoses real issues. +- **Tuning values** live in `irl_core::consts`, pinned by `consts_are_pinned`. Nothing else hardcodes a threshold. +- **Frozen interface:** settings keys and defaults, the names of the stats, and the media-control behavior that NOALBS's `!fix` relies on. Log text is free to change. +- **Source flags:** `OBS_SOURCE_AUDIO | OBS_SOURCE_ASYNC_VIDEO | OBS_SOURCE_DO_NOT_DUPLICATE | OBS_SOURCE_CONTROLLABLE_MEDIA`. -`update` diffs the new settings against the live config: URL, FFmpeg Options, Hardware Decode and Low Latency Audio are latched at stream open and force a reconnect; everything else is swapped in place through `Config::apply_hot`, so a settings tweak neither drops the connection nor clears the stats counters. Retuning Target Buffer live goes through `AudioBuffer::resize`, which grows the ring (never shrinks it) and only then publishes the new watermarks — if the resize fails the old target stays in force, including in the OBS-thread config that the next diff compares against. +## Tests -`video_tick` runs the one-shot fit-to-canvas: a source created without a URL (so, freshly added rather than restored from a scene collection) applies the same `obs_transform_info` as the frontend's Fit to Screen action to every scene item referencing it, once, as soon as the source reports a non-zero size. +- `irl-core` and `ffmpeg` have unit tests in each module. Everywhere else, tests live in `tests/`. The link arguments that resolve libobs only reach integration-test targets, so `crates/irl-source` sets `test = false` on its lib and `crates/obs` keeps its lib free of `#[cfg(test)]`. Shared fixtures for irl-source live in `crates/irl-source/tests/common/`. +- Tests that touch libobs run on Linux. CI skips them on Windows and macOS. `calldata_*` is safe anywhere. On a Mac, `make test-shim` runs the pacing, audio-core and network tests against a four-function stand-in; plain `cargo test -p irl-source` there dies with SIGSEGV before its first assertion. +- `tests/network_sim.rs` drives the real jitter buffer, PTS repair, speed controller, output clock and packet queue against a synthetic sender on a virtual clock (stall, burst, dropouts, a sender off wall clock, video behind audio). It asserts the design's promises: the OBS clock never jumps except across a declared restart, audio is never skipped once primed, latency does not ratchet, decoded memory does not grow with Target Buffer. +- Where you read the buffer fill decides the number. It oscillates by one chunk within every cycle: before the pump's read it averages the target, after it a chunk lower. The stats line's `buf=` is a random sample of that oscillation. +- `tests/video_pipeline.rs` reads the real clock and can flake under parallel load; rerun with `-- --test-threads=1` before suspecting the code. +- Real-stream validation is manual: run the same feed through this build and a known-good one and compare the 30-second stats line field by field. -When "Close Stream When Inactive" is enabled, show/activate start the receiver and hide/deactivate stop it; otherwise those callbacks are no-ops and the stream runs from create to destroy. Every "the stream stopped" clear — hide/deactivate, a restart-forcing settings edit, and the disconnect in `receiver/stream.rs` — is gated on "Show Nothing When the Stream Ends" (`clear_on_disconnect`, on by default). Turning it off leaves the last decoded frame frozen on screen until the stream returns. +## CI and releases -`OBS_SOURCE_CONTROLLABLE_MEDIA` and its four callbacks exist because that flag is what makes the source addressable through obs-websocket's `TriggerMediaInputAction` / `GetMediaInputStatus`, which is how NOALBS's `!fix` reconnects a stalled feed (it enumerates candidates by media state, so a source reporting `OBS_MEDIA_STATE_NONE` is invisible to it), and it is also what puts the source in the media controls dock. A live stream has nothing to seek or pause, so they reduce to "run the receiver" and "don't", with a `media_stopped` latch that survives show/activate and is cleared by Restart or a settings edit. Note that `!fix` for `ffmpeg_source` works by writing empty settings, relying on `ffmpeg_source_update` restarting unconditionally; that trick deliberately does not work here, because `update` diffs and hot-applies. Restart is the explicit request. +`.github/workflows/build.yml` builds Linux x64 (Ubuntu 22.04, glibc 2.35, so the artifact loads in the Flatpak sandbox), Windows x64 and macOS ARM64. Each job builds the media stack (cached on `deps/versions.env` plus `deps/build-deps.sh`), then build, clippy, tests and the isolation checks. The Windows job also compiles the Inno Setup installer on every push. The Linux job installs OBS from `ppa:obsproject/obs-studio` only so test binaries have a `libobs.so`. -### Threading model and the lock contract +`api_version` in `declare_module!` is the oldest supported OBS line. libobs gates plugins on major and minor only, so one binary loads there and on every newer release. Raise it only to drop old OBS releases. -Four threads. The C plugin enforced its lock contract by convention and a debug-only checker; the Rust port enforces most of it by ownership, which is the point of `shared.rs`. +Releases are tag driven (`RELEASING.md`). Pushing `vX.Y.Z` checks the tag against `[workspace.package] version`, builds, packages, and creates a draft release whose notes come from `scripts/changelog.sh`, grouped by conventional commit type. Commit subjects are the release notes, so write them as such. -- **OBS thread** — `IrlSource`: create, destroy, update, tick, get_properties, activate/deactivate/show/hide (the last four only matter with "Close Stream When Inactive"). Everything it owns sits in one `Mutex` (config, `fit_pending`, `media_stopped`, the running threads). It is behind a mutex only because the stats proc can arrive on another thread. -- **`Shared`** — built fresh at every `start_receiver`, which is what replaces the C `reset_runtime_state()`: everything that function zeroed is a field of `Shared` and starts zeroed, and everything it deliberately kept lives in `LifetimeStats`, which is an `Arc` carried across runs. -- **Receiver thread** — owns demux and the *audio* decoder as a plain struct on its own stack. Writes to the jitter buffer, and pushes video packets onto the video channel without decoding them. Never touches the GPU. -- **Video thread** — owns the video decoder, handed over by the receiver at stream open (`VideoMsg::Decoder`, ordered ahead of the packets it belongs to). Decodes packets only as they approach their due time, does the HW transfer, paces each frame, converts and calls `obs_source_output_video`. Its pacing queue is a local, lock-free `PacingQueue`; only the counters are mirrored into `LifetimeStats`. +## Shipped files -- **Audio thread** — drains the jitter buffer and submits audio, paced against the sample-counter output clock. - -Frames are handed to libobs a couple of canvas ticks *before* their due time. libobs is a scheduler too — `ready_async_frame` advances its play head by wall-clock deltas and takes the frame it has just passed — so a frame handed over exactly at its due time is not queued yet when its render tick runs and slips to the next one, which at 30fps on a 60fps canvas is visible judder. The frame keeps its due time as its timestamp, so the lead changes when libobs *receives* it, not when it is shown. The exception is the frame that re-anchors libobs's play head after a start or a clear: `get_closest_frame` displays that one on arrival whatever its timestamp and anchors from it, so a lead there would run the whole connection early. That frame goes at its due time, and the anchor only clears once a frame libobs actually received went out at its real due time. And while an audio stream is present, that frame's due time has to come from the *audio mapping*, not the video-only fallback: the two disagree by ~100 ms (the fallback schedules the first frame one Target Buffer out; the mapping puts it at the first audio chunk, a prime threshold plus a chunk after the first *kept* audio), which way depends on whether the audio warm-up or the first keyframe won, and libobs freezes whichever error the anchor frame carried into the connection's lip sync. So video holds until audio primes (the pump wakes the video thread the moment it publishes the mapping), drops anything the mapping lands more than a canvas tick in the past, and anchors on the first frame that is on time. A stream whose audio never primes is let through on the fallback after `VIDEO_ANCHOR_WAIT_MARGIN_MS` past the expected prime. - -The lead only exists for a frame that is in hand a lead before it is due. How early a frame is in hand — its *arrival margin*, due time minus the OBS time its packet reached the video thread (`TimedPacket::received_ns`) — is the sender's to set: video that leaves the encoder later than the audio of the same instant has that much less margin, and once the skew exceeds what Target Buffer plus the audio output lead covers, every frame is late. A late frame goes out on arrival, unpaced, and libobs then drops one whenever two arrive inside a canvas tick, which is what a stream with trailing video looks like: low fps. `irl_core::video_delay` closes that gap with a standing delay on every video due time, sized from the worst shortfall seen so that frames stop reaching libobs late. The bar is "not late", not "a full lead early": the lead is an allowance for the pacing timer oversleeping on a frame the queue holds, and a frame handed over on arrival never sleeps, so demanding the lead of every frame would delay a healthy stream by the lead for nothing (which is exactly what the first version of this did). Before the anchor a shortfall raises it on the spot, measured at packet arrival with one canvas tick of allowance for the decode still to come (nothing has been shown; `VideoThread::settle_anchor_candidate` does this while it also drops the stale backlog). It measures only once video is *live* (`irl_core::arrival`): a relay hands a new subscriber video from its last keyframe next to live audio, so a connection can open with video a second behind its audio and catching up faster than real time, which one frame reads as a late sender on a stream with no skew. The network cannot cause that reading, since both streams share a mux, so the test is whether the floor of arrival minus PTS has stopped falling across `VIDEO_LIVE_LOOKBACK_MS`; a sender that really is late holds a steady offset from its first packet and passes after one lookback, a batching relay passes because the newest frame of each batch lands on the same floor, and `VIDEO_LIVE_MAX_WAIT_MS` bounds the wait. Until then past-due frames go as stale and an on-time frame still anchors. And it measures only the newest frame in hand (a queue of one with the channel empty): the probe backlog reaches the video thread as one burst stamped with a single arrival time, so the older frames in it look late by up to the probe span when they were merely buffered, and even the one that anchors may be on time by buffering alone; measuring them would set a delay of up to a second for nothing. A sender that really is late is caught at hand-over after the anchor. After the anchor the measurement is the hand-over itself, and a raise moves the picture, so it takes `VIDEO_DELAY_MIN_FRAMES` frames handed over past due, spread across `VIDEO_DELAY_WINDOW_MS`, and the delay is capped at `VIDEO_DELAY_MAX_MS`. It is not permanent: every frame handed over also reports its *arrival* margin, and when a whole `VIDEO_DELAY_RELAX_WINDOW_MS` of them needed less than the delay in force by more than `VIDEO_DELAY_RELAX_MIN_MS`, it ramps down to what the window needed plus a tick (`VideoDelay::note_arrival`, `ramp`, `VideoThread::step_delay_ramp`). The pre-anchor measurement is one frame at connection start, and on a poor link that moment is a burst followed by catch-up, so it can read more than a second late on a stream with no skew; a delay that never shrank held on-time video that far behind its audio until the stream dropped. The ramp moves every due time a little earlier per cycle at the Catch-Up Speed, so video plays a few percent fast and nothing jumps or is dropped. A raise cancels it. It is a lip-sync error in return for a smooth picture, and `video_delay_ms` reports it. A clear and a decoder handover reset it. The video-only fallback schedules its first frame at arrival, so a stream without audio always carries a delay of exactly one tick. - -The video delay is a bridge, not the answer to a sender whose video trails its audio: a frame cannot be shown before it arrives, so the only way to put such a stream back in sync is for the audio to wait. That is the audio hold (`irl_core::audio_hold`, `audio/hold.rs`). The receiver reads the skew as it pushes each video packet: its decode timestamp against the newest audio PTS decoded before it. Mux order is what makes the reading trustworthy, since loss and stalls delay both streams together; a relay's catch-up replay is kept out by an `ArrivalFloor` of its own. The part of the skew that Target Buffer and the output lead do not cover, plus `AUDIO_HOLD_MARGIN_MS`, is folded into the published watermarks, so their target is Target Buffer plus the hold and every reader of the target follows it. `Config::apply_hot` composes a Target Buffer edit with it. Before priming, the pump waits up to `AUDIO_HOLD_PRIME_WAIT_MS` for the first reading, so a sender that is always late starts in sync. After priming the hold moves only while the speed controller regulates the buffer (not in low-latency mode, not with Adaptive Latency Control off): a skew sustained across `AUDIO_HOLD_RAISE_WINDOW_MS` raises it, and the controller builds it at -2 %. The pump credits that growth of the playout offset to `AudioState::hold_built_ns`, and `VideoThread::absorb_audio_hold` takes the same amount out of the video delay (`VideoDelay::absorb`), so due times stay put while the lip-sync error drains. The offset re-anchor nets the hold out of its drift for the same reason. Low-latency mode has no target to fold the hold into: the pump primes on the hold's worth of audio and keeps that much queued. Shorter bursts stay the video delay's, and the delay's warnings stop suggesting a Sync Offset where the hold can move, since the two would correct the same skew twice. - -Video decode is on the video thread and not the receiver for two reasons, and the second is the load-bearing one. Decoding eagerly would mean holding the stream's whole latency as decoded frames — 8s of 4K60 is ~6GB — where the same 8s of packets is ~20MB. And the receiver spends a network stall blocked in `av_read_frame`, which is exactly when video must keep draining the buffer it already has, so the thread that decodes cannot be the thread that reads. - -Lock order, and the whole of it: **`audio_state` → `audio_buf` → `hot.watermarks`.** `video.q` is never held together with any of them. The audio pump takes `audio_state` exactly once per iteration and passes `&mut AudioState` down, so nothing below it can take it again — parking_lot mutexes are not recursive, and a nested acquire would hang the audio thread and then the video thread behind it. - -Hot config (`reconnect_delay_s`, `adaptive_speed`, `catchup_percent`, `wait_for_keyframe`, `clear_on_disconnect`) is atomics, read with `Relaxed` on the worker threads. `catchup_percent` is read once per controller cycle and passed down as a speed, because the ramp, the anti-windup, the actuator clamp and the stuck-drain watch all have to agree on the same ceiling within a cycle. The three watermarks publish together under a mutex because they must never be read torn mid-resize. Stat counters are relaxed atomics: unsynchronised in C, explicitly relaxed here, same values. - -Panics never cross an FFI boundary. `obs::panic::guard` wraps every `extern "C"` shim (source callbacks, proc handlers, enumeration trampolines, vendor requests, module exports) and `shared::spawn_worker` wraps every worker thread: a panic is logged, `thread_active` is cleared (which also trips the FFmpeg interrupt watch, so a receiver blocked in `av_read_frame` unblocks), the video sleeper is woken, and the normal stop path takes over. - -### Conventions - -- **Unsafe.** Only in `obs-sys`, `obs` and `ffmpeg`, and every `unsafe` block there carries a `// SAFETY:` comment. If a port needs a raw pointer, the answer is a new safe wrapper in one of those crates, not an `unsafe` block in `irl-source`. -- **Logging.** `irl_info!("…")`, never `blog` directly; the macros bind the `[irl-source]` prefix. Log strings are part of the interface people grep for — keep them byte-identical to the C where the C had one. -- **Credentials in the log.** A URL never reaches the log whole. The plugin's own lines go through `log::redacted_input_url` (protocol, host and port; the C `irl_log_input_url`), and FFmpeg's go through `log::redacted_log_line`, because FFmpeg prints `h->filename` — the user's `srt://…?passphrase=…&streamid=…` — for its own connect failures. Anything new that logs a URL, or a string that might contain one, belongs behind one of the two. -- **Clocks.** OBS timestamps come from `obs::time::gettime_ns` (`os_gettime_ns`), never `std::time::Instant`. FFmpeg-side timers stay in the `av_gettime` microsecond domain. `irl-core` takes both as parameters so the two can never be mixed by accident. -- **UI strings.** Never pass English text to `module_text`. A new string belongs in two places: the call site and `data/locale/en-US.ini`, keyed by a short identifier. The version in the About block is substituted with `str::replace` on a `%1` token rather than a format string, so a bad translation renders oddly instead of failing. -- **Stats.** A new stat is *one line* in `irl_core::stats::FIELDS` plus its field in `StatsSnapshot` and `values()`. The proc declaration, the calldata writer (`source.rs`) and the websocket copy loop (`websocket.rs`) all walk that table, so they cannot drift. The README table is the only other place to update. -- **Tuning values.** Every threshold lives in `irl_core::consts`, pinned by a test. -- **Source flags.** `OBS_SOURCE_AUDIO | OBS_SOURCE_ASYNC_VIDEO | OBS_SOURCE_DO_NOT_DUPLICATE | OBS_SOURCE_CONTROLLABLE_MEDIA`. - -### Tests - -`irl-core` has unit tests (`#[cfg(test)]` in each module) derived from the C plugin's thresholds; they are the regression net for the port. - -Everywhere else, tests live in `tests/`, never inside the lib. The link arguments that make a test binary resolve libobs (`cargo::rustc-link-arg-tests`) only reach integration-test targets, so `crates/irl-source` sets `test = false` on the lib (its `cdylib` half must not gain test harness code either) and `crates/obs` keeps its lib free of `#[cfg(test)]`. A test that touches libobs runs on Linux (with `libobs-dev`) and is skipped in CI on Windows and macOS, where there is no libobs for the binary to load against. `calldata_*` is the exception that is safe to call anywhere: it is pure bookkeeping over libobs's allocator and needs no `obs_startup`. On a Mac, `make test-shim` runs the pacing, audio-core and network-simulation tests anyway: they reach only four libobs functions (`os_gettime_ns`, `blog`, `obs_get_frame_interval_ns`, `video_format_get_parameters_for_format`), and `scripts/test-with-libobs-shim.sh` links the stand-in in `scripts/libobs-shim/` into those binaries, refusing to run one that imports a libobs symbol the shim lacks. Without it a libobs call on macOS is a jump to address zero, so `cargo test -p irl-source` there dies with SIGSEGV before its first assertion, which is not a failure of the code under test. - -`crates/irl-source/tests/network_sim.rs` is the end-to-end harness for the conditions the plugin exists to survive: a stall, a burst, repeated dropouts, a sender whose media clock is not wall clock, one too fast to ever catch. It drives the real jitter buffer, PTS repair, speed controller, output clock and packet queue against a synthetic sender on a virtual clock, and asserts what the design promises — the OBS clock never jumps except across a *declared* restart or re-anchor, audio is never skipped once primed, latency does not ratchet across dropouts, and decoded memory does not grow with Target Buffer. It does not cover the demuxer or the video decoder: the bundled FFmpeg carries only the decoders the plugin needs (no rawvideo), so there is no packet a decoder here would accept, and video is driven at the two ends of the decoder instead. - -Note the sampling point in it. The jitter buffer's level oscillates by one whole chunk within every cycle, so *where* you read the fill decides what number you get: before the pump's read (what the controller regulates) it averages the target, and after it, a chunk lower. The stats line's `buf=` is a random sample of that oscillation, which is why it reads low as often as not. - -`crates/irl-source/tests/locale_keys.rs` is the mechanical half of the "a new UI string belongs in two places" rule: it scans `settings.rs`, `source.rs` and `providers.rs` for `module_text` keys and fails if one has no `data/locale/en-US.ini` entry, or if the ini carries a string nothing uses. `module_text` falls back to returning the key, so without it a missing string is only noticed by opening the properties dialog. - -The speed controller has one more check that is not a test, because a controller that limit-cycles still passes every assertion you would think to write about one sample of it: - -```bash -cargo run -p irl-core --example speed-controller-sim -``` - -It runs the real `irl_core::speed` closed-loop against a simulated sender and exits non-zero if the loop fails to settle at any buffer target or if a requested speed is not applied faithfully. Not a CI target; run it by hand whenever you touch `speed.rs`, and read `docs/audio-timing-pitfalls.md` first. - -Real-stream validation is manual: run the same feed through this build and a known-good one and compare the 30-second stats line field by field. - -## CI - -GitHub Actions (`.github/workflows/build.yml`) builds on Linux x64 (Ubuntu 22.04 — glibc 2.35, so the artifact loads in the Flatpak sandbox; see #29), Windows x64 (VS 2026) and macOS ARM64 (macos-15). Every job builds the bundled media stack first (cached on the hash of `deps/versions.env` plus `deps/build-deps.sh`), then runs `cargo build --release --workspace`, clippy with `-D warnings`, the tests, and the isolation checks. `Swatinem/rust-cache@v2` caches the cargo build. No job builds libobs any more: the Linux job installs OBS from `ppa:obsproject/obs-studio` purely so the test binaries have a `libobs.so` to link and run against — 22.04's own `libobs-dev` is 27.x and predates `video_format_get_parameters_for_format`. `layout-test` reads the `OBS_VERSION` source headers instead, so the hand-written structs stay pinned to the declared floor rather than to whatever the runner happens to have. - -`OBS_VERSION` at the top of the workflow documents the oldest supported OBS line; the value that actually reaches libobs is `api_version` in the `declare_module!` call. `obs_init_module` gates a plugin on `(mod.ver() & 0xFFFF0000) <= LIBOBS_API_VER` — major and minor only — and looks up nothing but `obs_module_*` symbols, so declaring the oldest supported line yields one binary that loads there and on every newer release. Raise it only to drop support for older OBS releases, never to chase a newer one. - -Releases are tag driven (`.github/workflows/release.yml`, see `RELEASING.md`). Pushing `vX.Y.Z` verifies the tag against `[workspace.package] version` in `Cargo.toml`, runs the build workflow, calls `scripts/package.sh` once per platform, generates `sha256sums.txt`, and creates a draft GitHub release whose body is `.github/release-notes-header.md` plus a changelog. Publishing the draft is manual, after testing the artifacts. - -`scripts/changelog.sh` builds that changelog by grouping the commits since the previous `v*` tag on their conventional commit type. Commit subjects are the release notes, so write them as such. Run `scripts/changelog.sh HEAD` to preview. - -## Deliberate deviations from the C plugin - -The port is behaviour-identical except for these, which are intentional: - -1. The dead `network_buffer_mb` setting is gone. Nothing read it; the transport buffer is `irl_core::consts::NETWORK_BUFFER_MB`. -2. The `video_decoder_flushes` stat is gone (it was always 0 after the video decoder stopped being flushed). Deviations 16 and 17 later added `video_delay_ms`, `av_skew_ms` and `audio_hold_ms`, and 3.0.0 cut the surface down to the 11 stat fields that diagnosed real issues (the stats log line was trimmed with it). -3. `irl-stats.lua` finds the source by its plugin id instead of by display name, and takes source names as script properties. -4. The vestigial `hw_map_ok` flag is not ported. -5. `w32-pthreads.dll` is no longer shipped on Windows: Rust never calls `pthread_*`, so the librist shim hazard that `include/irl-threading.h` existed for is gone. The installer deletes a stale copy. -6. `obs_get_video_info` and `obs_sceneitem_set_info2` go through slack wrappers with 64 trailing bytes, since libobs reads and writes those structs by *its* size. -7. Two latent races where the stats snapshot read unlocked video-thread writes are closed by mirroring the anchors into atomics. Same values. -8. The stats field list is one table (`irl_core::stats::FIELDS`) instead of three hand-synchronised copies. -9. Version 2.0.0. The artifact is built by cargo, and CI no longer builds libobs. -10. The speed-controller simulation **links** the controller instead of replicating it. `tools/speed-controller-sim.c` copy-pasted the constants and both update rules, because the real controller read `struct irl_source`; `crates/irl-core/examples/speed-controller-sim.rs` calls `irl_core::speed` directly, so the C file's standing caveat — "change them there and you must change them here too, or this quietly starts simulating a controller that no longer exists" — does not apply. `tools/` is gone with it. -11. The UI strings are checked against `data/locale/en-US.ini` by a test rather than by convention (`crates/irl-source/tests/locale_keys.rs`). -12. The I/O stall deadline is not armed while a listener URL waits to be called, and once connected it is measured from the last byte that arrived rather than from the start of the call (master `6d09dea`). `InterruptWatch` therefore tracks the `AVFormatContext` so the callback can read `pb->bytes_read`; `FormatContext` clears that pointer on a failed open and in `Drop`, which cannot wait for the watch's own `Drop` because the receiver holds the same `Arc` across connections. -13. Video is decoded on the video thread, just before each frame is due, and the receiver → video queue carries compressed packets instead of decoded frames. The C decoded eagerly on the receiver thread, which made the Target Buffer cost decoded-frame memory: 1 GiB of pacing budget is 5.7s of 1080p60 but only 1.4s of 4K60 and 0.7s of 4K60 10-bit, and past that frames were emitted early and dropped. Decoded memory is now bounded by `VIDEO_DECODE_LEAD_MS` regardless of the target. `PacingQueue` gained the matching soft/hard bound split: holding the decode lead is normal and must not emit early, while the byte and frame ceilings are memory limits that still do. The stats line reports `pktq=` instead of `pinned_peak=`, since no decoded frame pins a decoder surface any more. -14. PTS repair treats a gap of at most one time-base tick, in either direction, as the sender being on time rather than as a discontinuity. The C tested only `< 1 ms` and only forwards, which is a threshold a 90 kHz clock cannot express a 44.1 kHz frame against: 1024 samples is 2089.795 ticks and `duration` can carry only 2090, so the stream landed a tick *early* every few frames, fell into the leading-edge rule for backward jumps (which deliberately freezes the baseline), and the next frame was then interpolated onto the frozen baseline. The repaired timeline stayed one frame short from there on. Since the audio→video mapping is derived from those PTS, every 44.1 kHz stream — which is what phone encoders send — carried a standing ~23 ms lip-sync error, and its `norm=` counter climbed at the frame rate. 48 kHz divides 90 kHz exactly and was never affected. -15. Video does not anchor libobs's play head on a fallback-scheduled frame when an audio stream is present. The C handed over whichever frame came due first, fallback or mapped, and libobs — which anchors to that frame's *arrival* and never moves the anchor — then played the whole connection with the ~100 ms disagreement between the two schedules baked in, in whichever direction the warm-up/keyframe race went. In the port the race was almost always lost the same way (the video thread decodes the keyframe while the receiver is still working through the probe backlog, so the warm-up has drained and the fallback runs ~100 ms early), which surfaced as audio consistently lagging on phone encoders. Video now waits for the mapping, drops frames it lands in the past, and anchors on the first on-time frame (`VideoThread::awaiting_audio_mapping`, `settle_anchor_candidate`). Deviation 16 is what keeps this from dropping a stream whose frames are *all* in the past. -16. Video that arrives too late to be paced is delayed by a measured, standing amount instead of played unpaced. The C mapped each frame through the audio playout and handed over whatever was past due on arrival, so a sender whose video trailed its audio by more than Target Buffer covered — 120 ms by default, against phone hardware encoders that routinely run 100 to 300 ms behind their audio — played the whole connection with zero lead: every frame handed to libobs at or after its due time, and one dropped by `ready_async_frame` whenever two arrived inside a canvas tick. That is the "low fps" such a stream showed, and the delivery lead (master `964f74b`) could not help it, because a lead needs a frame in hand early. After deviation 15 the same stream was dropped forever instead, since no frame was ever on time. The port measures each frame's arrival margin and adds the shortfall to the schedule as `irl_core::video_delay` (see the threading section); `video_delay_ms` reports it and the warning that sets it tells the user how much Target Buffer restores lip sync. A stream without audio is affected too: the video-only fallback scheduled its first frame at arrival, with no lead for any frame that followed it on time, and now carries a delay of one tick. - -17. Audio waits for video that trails it. The C played audio as soon as its buffer held the target, so a sender whose video reached the plugin later than its audio (pocketSRT queues its audio about 300 ms early; a stabiliser sends video a second and more late) played with the sound that far ahead of the picture. Deviation 16 made the picture smooth, but left that error standing for the user to find and fix by hand with Target Buffer or a Sync Offset (#34). The audio hold measures the skew and raises the jitter buffer's target by what Target Buffer does not cover (see the threading section); `audio_hold_ms` reports it. +- `data/locale/en-US.ini` must ship: the lookup falls back to the key, so a package without it shows bare identifiers in the dialog. +- `data/providers.json` is the Provider dropdown's list; a deployment edits it to show only its own provider. `crates/irl-provider/tests/catalog.rs` parses the repo copy. +- `THIRD_PARTY_NOTICES.md` ships in every archive, because LGPLv3 FFmpeg wants its notices conveyed with the object code. +- `installer/obs-irl-source.iss` resolves the OBS folder from the registry and requires OBS 32.1 or newer (a minimum, not an exact match). +- `irl-stats.lua` is an example overlay script that reads the stats proc. ## Contributing -If you wish to contribute PRs to this project, please understand what you are changing. You should be able to write any replies to reviews/PRs yourself — don't copy and paste replies directly from AI. - -This plugin was heavily built with LLM assistance, including the Rust port. The author (datagutt) has experience with video and SRT(LA) protocols but is less familiar with the OBS Studio codebase. Tagged releases are fully tested; individual commits may not be. - -## Other files - -- **`irl-stats.lua`** — Example OBS Lua script that reads plugin stats via `proc_handler` and updates a text source overlay. -- **`installer/obs-irl-source.iss`** — Inno Setup script for the Windows setup .exe. It resolves the OBS folder from the registry (`Uninstall\OBS Studio` in HKLM64 then HKCU, then `HKLM\SOFTWARE\OBS Studio`), validates it by finding `bin\64bit\obs64.exe`, and installs the same payload as the release zip. The OBS version check is a *minimum* (32.1), not an exact match, because nothing binds the plugin to one OBS line. The Windows job in `build.yml` compiles it on every push, not just at tag time, so a broken `.iss` fails a normal build instead of a release. -- **`data/providers.json`** — The Provider dropdown's list: names, base URLs, priorities and whether Custom provider is offered. Shipped next to `locale/` in every archive and by the installer; a deployment edits it to show only its own provider. `crates/irl-provider/tests/catalog.rs` parses the repo copy so a broken one fails CI. Format in `docs/provider-protocol.md`. -- **`data/locale/en-US.ini`** — UI strings, loaded by `declare_module!`'s locale exports. Shipping it is not optional: the lookup falls back to returning the key, so a package built without it renders the dialog as bare identifiers like `AudioBufferHelp`. All three release archives and the installer place it where `obs_module_file()` looks (`data/locale/` next to the binary on Linux, `data/obs-plugins/obs-irl-source/locale/` on Windows, `Contents/Resources/locale/` inside the macOS bundle). -- **`THIRD_PARTY_NOTICES.md`** — Licenses for the statically linked stack and the Rust crates, shipped inside every release archive rather than only living in the repo, because LGPLv3 FFmpeg wants its notices conveyed with the object code. `deps/README.md` has the reasoning behind the license choices; this file is the artifact-facing copy. -- **`docs/audio-pipeline.md`** — Deep dive on the buffered vs low-latency audio paths, jitter buffer, adaptive latency control, PTS repair tiers, and timestamp handling. -- **`docs/viewer-quality-plan.md`** — The viewer-quality policy and the recovery/diagnostics behavior that implements it (what stats to watch and what healthy looks like). -- **`docs/provider-protocol.md`** — The contract a service implements to appear in the Provider dropdown: discovery document, OAuth sign-in, the key-free ingest list and the resolve call. The plugin side of it lives in `crates/irl-provider`. -- **`docs/audio-timing-pitfalls.md`** — What was built wrong first in the audio timing path, and the media-clock estimator that was built, measured and deleted. Required reading before changing `crates/irl-core/src/speed.rs`; most of it is re-inventable. -- **`Makefile`**, **`.config/`** — The quality gates and their explicit configs (`rustfmt.toml`, `codespellrc`), so `make check` gives the same answer everywhere. -- **`AGENTS.md`**, **`GEMINI.md`** — Symlinks to this file (`CLAUDE.md`). +If you contribute PRs, understand what you are changing, and write review replies yourself rather than pasting them from an AI. This plugin was built with heavy LLM assistance. The author (datagutt) knows video and SRT(LA) well but is less familiar with the OBS codebase. Tagged releases are fully tested; individual commits may not be. diff --git a/docs/architecture.md b/docs/architecture.md new file mode 100644 index 0000000..7c18a17 --- /dev/null +++ b/docs/architecture.md @@ -0,0 +1,97 @@ +# Architecture + +This page explains why the plugin is shaped the way it is: the threads, what each one owns, and how audio and video are kept in step. `CLAUDE.md` lists the rules that follow from it. `docs/audio-pipeline.md` covers the audio path in depth, and `docs/audio-timing-pitfalls.md` covers the speed controller. + +## Crates + +All unsafe code lives in `obs-sys`, `obs` and `ffmpeg`. `irl-core`, `irl-provider` and `irl-source` carry `#![forbid(unsafe_code)]`, so a raw pointer in plugin logic needs a new safe wrapper in one of the first three. + +- `obs-sys`: hand-written libobs FFI. `#[repr(C)]` structs, `extern` declarations and constants. The `layout-test` feature runs bindgen over the real headers and asserts every field offset. libobs is never linked: the symbols resolve against the host OBS process at load time (`raw-dylib` from `obs.dll` on Windows, undefined symbols elsewhere). +- `obs`: the safe libobs API this plugin uses. The `Source` trait and registration, `declare_module!`, data, properties, calldata, frames, scene transforms, the obs-websocket vendor helper and `panic::guard`. +- `ffmpeg` (package `irl-ffmpeg`): RAII over `ffmpeg-sys-next`. `build.rs` replays the link lines that `deps/build-deps.sh` wrote to `irl-deps.env`. +- `irl-core`: everything that needs neither libobs nor FFmpeg. The jitter buffer, PTS repair, the speed controller, output clock arithmetic, video pacing and delay, the audio hold, demuxer options, config derivation, the stats table and every tuning constant. It is the crate with the real unit test suite. +- `irl-provider`: the plugin side of `docs/provider-protocol.md` (discovery, OAuth with PKCE over a loopback redirect, the ingest list, the resolve call). It has no libobs dependency; the plugin hands it a state directory, a logger and a callback through `init`. +- `irl-source`: the plugin. Module entry points, the source lifecycle and the three worker threads. + +## Data flow + +``` +[receiver thread]: FFmpeg URL, demux + audio: decode, PTS repair, resample, write to jitter buffer + video: push the compressed packet onto the video queue + +[video thread]: decode packets as they come due, keyframe gate, HW frame + transfer, hold until due, format conversion, + OBS async video output + +[audio thread]: drain jitter buffer, speed correction, concealment, + OBS audio output +``` + +Video decodes on the video thread, not the receiver, for two reasons. Decoding eagerly would hold the stream's whole latency as decoded frames (8 s of 4K60 is about 6 GB, against about 20 MB of packets). And the receiver spends a network stall blocked in `av_read_frame`, which is exactly when video must keep draining the buffer it already has. The thread that decodes cannot be the thread that reads. + +## Threads and ownership + +- **OBS thread** (`IrlSource`): create, destroy, update, tick, properties, and the show/hide/activate/deactivate callbacks. Its state sits in one `Mutex` only because the stats proc can arrive on another thread. +- **`Shared`** is built fresh at every `start_receiver`, so per-run state starts zeroed. What survives a restart lives in `LifetimeStats`, an `Arc` carried across runs. +- **Receiver thread** owns demux and the audio decoder as a plain struct on its own stack. It writes the jitter buffer and pushes video packets onto the video channel. It never touches the GPU. +- **Video thread** owns the video decoder, handed over by the receiver at stream open (`VideoMsg::Decoder`, queued ahead of its packets). Its pacing queue is a local `PacingQueue` with no lock. +- **Audio thread** drains the jitter buffer and submits audio, paced against the output clock. + +The lock order is `audio_state` → `audio_buf` → `hot.watermarks`, and `video.q` is never held together with any of them. The audio pump takes `audio_state` once per iteration and passes `&mut AudioState` down. parking_lot mutexes are not recursive, so a nested acquire would hang the audio thread and then the video thread behind it. + +Hot config (`reconnect_delay_s`, `adaptive_speed`, `catchup_percent`, `wait_for_keyframe`, `clear_on_disconnect`) is atomics, read with `Relaxed`. `catchup_percent` is read once per controller cycle and passed down, because the ramp, the anti-windup, the actuator clamp and the stuck-drain watch must agree on one ceiling within a cycle. The three watermarks publish together under a mutex so no reader sees them torn mid-resize. + +A panic never crosses an FFI boundary. `obs::panic::guard` wraps every `extern "C"` shim and `shared::spawn_worker` wraps every worker thread. A panic is logged, `thread_active` is cleared (which also trips the FFmpeg interrupt watch, so a receiver blocked in `av_read_frame` returns), the video sleeper is woken, and the normal stop path takes over. + +## Source lifecycle + +`update` diffs the new settings against the live config. URL, FFmpeg Options, Hardware Decode and Low Latency Audio are latched at stream open and force a reconnect. Everything else is swapped in place through `Config::apply_hot`, so a settings tweak neither drops the connection nor clears the stats. Retuning Target Buffer live goes through `AudioBuffer::resize`, which grows the ring (never shrinks it) and only then publishes the new watermarks. If the resize fails, the old target stays in force, including in the OBS-thread config that the next diff compares against. + +`video_tick` runs a one-shot fit to canvas. A source created without a URL (freshly added, not restored from a scene collection) applies the frontend's Fit to Screen transform to every scene item that references it, once, as soon as the source reports a non-zero size. + +With "Close Stream When Inactive" on, show/activate start the receiver and hide/deactivate stop it. Otherwise those callbacks do nothing and the stream runs from create to destroy. Every "the stream stopped" clear (hide/deactivate, a restart-forcing settings edit, a disconnect) is gated on "Show Nothing When the Stream Ends" (`clear_on_disconnect`, on by default). With it off, the last frame stays frozen on screen until the stream returns. + +`OBS_SOURCE_CONTROLLABLE_MEDIA` exists because that flag makes the source addressable through obs-websocket's `TriggerMediaInputAction` and `GetMediaInputStatus`. NOALBS's `!fix` reconnects a stalled feed that way, and it enumerates candidates by media state, so a source that reports `OBS_MEDIA_STATE_NONE` is invisible to it. A live stream has nothing to seek or pause, so the media callbacks reduce to "run the receiver" and "don't", with a `media_stopped` latch that survives show/activate and is cleared by Restart or a settings edit. `!fix` for `ffmpeg_source` writes empty settings and relies on `ffmpeg_source_update` restarting unconditionally. That trick does not work here, because `update` diffs. Restart is the explicit request. + +## Audio output + +Three facts about libobs shape the audio core: + +1. OBS timestamps must be contiguous (`ts[n+1] = ts[n] + frames/rate`). Deviations under 70 ms are smoothed, gaps of 70 ms to 2 s are zero filled (audible), and larger jumps flush all queued audio. The plugin derives timestamps from a sample counter anchored once at prime time and never jumps the clock outside declared restarts. +2. Changing `samples_per_sec` between submissions makes OBS rebuild its per-source resampler with no crossfade, which clicks. Playback speed is applied inside the plugin with a persistent swresample compensation, and the rate submitted to OBS never changes. +3. The OBS mixer consumes 21.3 ms ticks against wall clock. A source whose queued audio runs dry gets a tick of silence and a time-shifted splice (crackle), and a source that falls behind the mix window makes OBS add global audio buffering for good. After priming, the pump always emits (real audio or shaped concealment silence) and keeps a fixed lead ahead of wall clock. + +The buffer is regulated through playback speed only, asymmetric: it builds at an inaudible -2 % and drains post-stall backlog at up to the Catch-Up Speed setting (+5 % by default). The loop is PI. A slow integral trim under the proportional ramp removes the standing error that a sender whose media clock is not wall clock would otherwise leave. Content is never skipped once playback has primed. Backlog beyond a fill ceiling is pushed back into the transport by pausing the read loop (TCP and RTMP backpressure; SRT bounds itself through its latency window), and startup backlog is trimmed only before priming. + +## Video pacing and the play head + +Frames go to libobs a couple of canvas ticks before their due time. libobs is a scheduler too: `ready_async_frame` advances its play head by wall-clock deltas and takes the frame it has just passed, so a frame handed over exactly at its due time is not queued when its render tick runs and slips to the next one. At 30 fps on a 60 fps canvas that is visible judder. The frame keeps its due time as its timestamp, so the lead changes when libobs receives it, not when it is shown. + +The exception is the frame that anchors libobs's play head after a start or a clear. `get_closest_frame` shows that frame on arrival whatever its timestamp and anchors from it, so a lead there would run the whole connection early. That frame goes at its due time, and the anchor only clears once a frame libobs received went out at its real due time. + +While an audio stream is present, the anchor frame's due time must come from the audio mapping, not the video-only fallback. The two disagree by about 100 ms (the fallback schedules the first frame one Target Buffer out; the mapping puts it at the first audio chunk), and libobs freezes whichever error the anchor frame carried into the connection's lip sync. So video holds until audio primes (the pump wakes the video thread when it publishes the mapping), drops frames the mapping lands more than a canvas tick in the past, and anchors on the first frame that is on time. A stream whose audio never primes goes ahead on the fallback after `VIDEO_ANCHOR_WAIT_MARGIN_MS` past the expected prime. + +## Video delay + +A frame can only get the lead if it is in hand a lead before it is due. Its arrival margin (due time minus the OBS time its packet reached the video thread) is set by the sender. Video that leaves the encoder later than its audio has less margin, and once the skew exceeds what Target Buffer and the audio output lead cover, every frame is late. A late frame goes out on arrival, unpaced, and libobs drops one whenever two arrive inside a canvas tick: low fps. + +`irl_core::video_delay` closes that gap with a standing delay on every video due time, sized from the worst shortfall seen. The bar is "not late", not "a full lead early": the lead covers the pacing timer oversleeping on a queued frame, and a frame handed over on arrival never sleeps. + +- **Before the anchor**, a shortfall raises the delay at once, measured at packet arrival with one canvas tick of allowance for the decode still to come (`VideoThread::settle_anchor_candidate`). It measures only once video is live (`irl_core::arrival`): a relay hands a new subscriber video from its last keyframe next to live audio, so a connection can open with video a second behind and catching up faster than real time. Both streams share a mux, so the network cannot cause that reading; the test is whether the floor of arrival minus PTS has stopped falling across `VIDEO_LIVE_LOOKBACK_MS`, bounded by `VIDEO_LIVE_MAX_WAIT_MS`. It also measures only the newest frame in hand, because the probe backlog arrives as one burst with one arrival time and its older frames look late when they were only buffered. +- **After the anchor**, the measurement is the hand-over itself, and a raise moves the picture. It takes `VIDEO_DELAY_MIN_FRAMES` frames past due, spread across `VIDEO_DELAY_WINDOW_MS`, and the delay is capped at `VIDEO_DELAY_MAX_MS`. +- **Relax.** Every frame handed over also reports its arrival margin. When a whole `VIDEO_DELAY_RELAX_WINDOW_MS` of them needed less than the delay in force by more than `VIDEO_DELAY_RELAX_MIN_MS`, the delay ramps down to what the window needed plus a tick, at the Catch-Up Speed, so video plays a few percent fast and nothing jumps. A raise cancels the ramp. This matters because the pre-anchor measurement is one frame at connection start, and on a poor link it can read a second late on a stream with no skew. + +A clear and a decoder handover reset the delay. The video-only fallback schedules its first frame at arrival, so a stream without audio always carries a delay of exactly one tick. `video_delay_ms` reports it. + +## Audio hold + +The video delay is a bridge, not the answer to a sender whose video trails its audio: a frame cannot be shown before it arrives, so the only way back to sync is for audio to wait. That is the audio hold (`irl_core::audio_hold`, `irl-source/src/audio/hold.rs`). + +The receiver reads the skew as it pushes each video packet: its decode timestamp against the newest audio PTS decoded before it. Mux order makes the reading trustworthy, since loss and stalls delay both streams together. A relay's catch-up replay is kept out by an `ArrivalFloor` of its own. The part of the skew that Target Buffer and the output lead do not cover, plus `AUDIO_HOLD_MARGIN_MS`, is folded into the published watermarks, so their target is Target Buffer plus the hold and every reader of the target follows it. `Config::apply_hot` composes a Target Buffer edit with it. + +- Before priming, the pump waits up to `AUDIO_HOLD_PRIME_WAIT_MS` for the first reading, so a sender that is always late starts in sync. +- After priming, the hold moves only while the speed controller regulates the buffer (not in low-latency mode, not with Adaptive Latency Control off). A skew sustained across `AUDIO_HOLD_RAISE_WINDOW_MS` raises it, and the controller builds it at -2 %. The pump credits that growth of the playout offset to `AudioState::hold_built_ns`, and `VideoThread::absorb_audio_hold` takes the same amount out of the video delay, so due times stay put while the lip-sync error drains. The offset re-anchor nets the hold out of its drift for the same reason. +- Low-latency mode has no target to fold the hold into: the pump primes on the hold's worth of audio and keeps that much queued. + +Shorter bursts stay the video delay's job, and the delay's warnings stop suggesting a Sync Offset where the hold can move, since both would correct the same skew. `audio_hold_ms` reports the hold.