grafton_ndi/framesync.rs
1//! Frame synchronization for clock-corrected video/audio capture.
2//!
3//! The [`FrameSync`] type provides a "pull" interface for receiving NDI streams with
4//! automatic time-base correction. This transforms the NDI SDK's push-based capture
5//! model into a pull model suitable for:
6//!
7//! - Video playback synced to GPU v-sync
8//! - Audio playback synced to sound card clock
9//! - Multi-source mixing with a common output clock
10//!
11//! # When to Use FrameSync vs Raw Capture
12//!
13//! | Use Case | Raw Receiver | FrameSync |
14//! |----------|-------------|-----------|
15//! | Recording (preserving timing) | ✓ | |
16//! | Playback to GPU | | ✓ |
17//! | Playback to sound card | | ✓ |
18//! | Multi-source mixing | | ✓ |
19//! | Analysis/processing pipeline | ✓ | |
20//! | Low-latency monitoring | | ✓ |
21//!
22//! # Why FrameSync Exists
23//!
24//! Computer clocks drift. The NDI SDK documentation explains:
25//!
26//! > Computer clocks rely on crystals which while all rated for the same frequency
27//! > are still not exact. If your sending computer has an audio clock that it "thinks"
28//! > is 48000Hz, to the receiver computer that has a different audio clock this might
29//! > be 48001Hz or 47998Hz.
30//!
31//! Without time-base correction, this causes:
32//! - **Video jitter**: When sender/receiver clocks are nearly aligned, naive frame
33//! buffering causes visible jitter as frames occasionally repeat or skip.
34//! - **Audio drift**: Accumulated clock difference causes audio to fall out of sync
35//! or cause glitches as the receiver runs out of or accumulates too many samples.
36//!
37//! FrameSync solves these by:
38//! - Using hysteresis-based video timing to determine optimal frame repeat/skip points
39//! - Dynamically resampling audio with high-order filters to track clock differences
40//!
41//! # Example
42//!
43//! ```no_run
44//! use grafton_ndi::{
45//! NDI, Finder, FinderOptions, ReceiverOptions, Receiver, FrameSync,
46//! FrameSyncAudioRequest, ScanType,
47//! };
48//! use std::time::Duration;
49//!
50//! fn main() -> Result<(), grafton_ndi::Error> {
51//! let ndi = NDI::new()?;
52//! let finder = Finder::new(&ndi, &FinderOptions::default())?;
53//! finder.wait_for_sources(Duration::from_secs(1))?;
54//! let sources = finder.current_sources()?;
55//!
56//! let options = ReceiverOptions::builder(sources[0].clone()).build();
57//! let receiver = Receiver::new(&ndi, &options)?;
58//!
59//! // Create frame-sync from receiver
60//! let framesync = FrameSync::new(receiver)?;
61//!
62//! // Capture video synced to your output timing
63//! if let Some(frame) = framesync.capture_video(ScanType::Progressive)? {
64//! println!("{}x{} frame", frame.width(), frame.height());
65//! }
66//!
67//! // Capture audio at your sound card's rate
68//! let request = FrameSyncAudioRequest::capture_with(Some(48_000), 2, 1_024)?;
69//! let audio = framesync.capture_audio(request)?;
70//! println!("{} audio samples", audio.num_samples());
71//!
72//! Ok(())
73//! }
74//! ```
75
76use std::fmt;
77
78use crate::{
79 capture::{FrameSyncAudioFree, FrameSyncVideoFree, Guard},
80 frames::{
81 validate_packed_audio_dimensions, AudioFrame, AudioRef, ScanType, VideoFrame, VideoRef,
82 },
83 ndi_lib::*,
84 receiver::{FrameSyncAttached, Receiver},
85 Error, Result,
86};
87
88/// A zero-copy borrowed video frame from a [`FrameSync`] capture.
89///
90/// This is the FrameSync spelling of the generic
91/// [`VideoRef`]; the [`Receiver`]
92/// spelling is [`VideoFrameRef`](crate::VideoFrameRef). Both share one accessor
93/// and `Debug` implementation. The frame is automatically freed when dropped via
94/// `NDIlib_framesync_free_video`.
95/// This owning reference is neither `Send` nor `Sync`; its immutable plane/row
96/// views support scoped worker reads while it stays borrowed on this thread.
97pub type FrameSyncVideoRef<'fs> = VideoRef<'fs, FrameSyncVideoFree>;
98
99/// A zero-copy borrowed audio frame from a [`FrameSync`] capture.
100///
101/// This is the FrameSync spelling of the generic
102/// [`AudioRef`]; the [`Receiver`]
103/// spelling is [`AudioFrameRef`](crate::AudioFrameRef). The FrameSync path can
104/// produce a validated empty query/no-source state, so
105/// [`is_empty`](AudioRef::is_empty) and an `Option`-returning
106/// [`format`](AudioRef::format)/[`to_owned`](AudioRef::to_owned) are available.
107/// The frame is automatically freed when dropped via
108/// `NDIlib_framesync_free_audio_v2`.
109pub type FrameSyncAudioRef<'fs> = AudioRef<'fs, FrameSyncAudioFree>;
110
111/// A validated, reusable audio operation for [`FrameSync::capture_audio`].
112///
113/// [`capture`](Self::capture) and [`capture_with`](Self::capture_with) require
114/// explicit positive output channels and samples per channel. Construction checks
115/// the SDK's signed byte stride and the 64 MiB packed sample payload limit before
116/// any native work. This bounds requested samples, not SDK internal memory use.
117/// Successful construction and request reuse allocate nothing in the wrapper;
118/// submitting a request performs no dimension checks, locks, or extra SDK queries.
119/// Returned descriptors and metadata are still validated after each capture.
120///
121/// [`query_input`](Self::query_input) queries the current input format without
122/// requesting samples. To follow source channels, query first, wait for a positive
123/// channel count, then build a capture request with that concrete count. Re-query
124/// and rebuild when the application chooses to follow format changes. The SDK
125/// converts to the explicit output count even if the source changes meanwhile.
126#[derive(Debug, Clone, Copy, PartialEq, Eq)]
127pub struct FrameSyncAudioRequest {
128 sample_rate: i32,
129 channels: i32,
130 samples: i32,
131}
132
133impl FrameSyncAudioRequest {
134 /// Query the current incoming audio format without requesting sample data.
135 /// The result has zero sample rate and channels if no input format is known.
136 pub const fn query_input() -> Self {
137 Self {
138 sample_rate: 0,
139 channels: 0,
140 samples: 0,
141 }
142 }
143
144 /// Capture samples using the source sample rate and explicit output channels.
145 ///
146 /// # Errors
147 ///
148 /// Returns [`Error::InvalidFrame`] for nonpositive dimensions, an
149 /// unrepresentable signed byte stride, arithmetic overflow, or a sample
150 /// payload exceeding 64 MiB. Validation does not allocate sample storage.
151 pub fn capture(channels: i32, samples: i32) -> Result<Self> {
152 Self::capture_with(None, channels, samples)
153 }
154
155 /// Capture samples with an optional source-derived sample rate.
156 ///
157 /// `None` uses the current source rate. `Some(rate)` must be positive.
158 /// Channels and samples per channel must always be explicitly positive.
159 /// A request can be copied and reused across borrowed and owned captures.
160 ///
161 /// # Errors
162 ///
163 /// Returns [`Error::InvalidFrame`] for a nonpositive explicit sample rate or
164 /// invalid dimensions as described in [`capture`](Self::capture).
165 pub fn capture_with(sample_rate: Option<i32>, channels: i32, samples: i32) -> Result<Self> {
166 if let Some(rate) = sample_rate {
167 if rate <= 0 {
168 return Err(Error::InvalidFrame(format!("Invalid sample rate: {rate}")));
169 }
170 }
171 validate_packed_audio_dimensions(channels, samples)?;
172 Ok(Self {
173 sample_rate: sample_rate.unwrap_or(0),
174 channels,
175 samples,
176 })
177 }
178}
179
180/// Frame synchronizer for clock-corrected capture.
181///
182/// Converts push-based NDI streams into pull-based capture with automatic
183/// time-base correction and dynamic audio resampling.
184///
185/// # Ownership
186///
187/// `FrameSync` takes ownership of the [`Receiver`] (similar to how `BufWriter`
188/// wraps a `Write`). Use [`receiver()`](Self::receiver) to access the underlying
189/// receiver for tally, PTZ, or status operations. Use
190/// [`into_receiver()`](Self::into_receiver) to recover the receiver when done.
191///
192/// # Thread Safety
193///
194/// `FrameSync` is `Send + Sync` like `Receiver`, as the underlying NDI SDK
195/// frame-sync functions are thread-safe. Frames returned by capture methods
196/// borrow from the FrameSync and are neither `Send` nor `Sync`. Convert them
197/// to owned frames to transfer ownership to another thread. Immutable video
198/// plane/row views can instead be moved or shared with scoped workers while
199/// the frame guard stays on its capture thread until those workers finish.
200///
201/// Raw video capture through the underlying receiver is disabled while this
202/// FrameSync exists. [`Self::into_receiver`] destroys the synchronizer and
203/// restores raw video capture before returning the receiver.
204///
205/// # Example
206///
207/// ```no_run
208/// # use grafton_ndi::{NDI, ReceiverOptions, Receiver, FrameSync, FrameSyncAudioRequest, Source, SourceAddress, ScanType};
209/// # fn main() -> Result<(), grafton_ndi::Error> {
210/// # let ndi = NDI::new()?;
211/// # let source = Source { name: "Test".into(), address: SourceAddress::None };
212/// # let options = ReceiverOptions::builder(source).build();
213/// # let receiver = Receiver::new(&ndi, &options)?;
214/// let framesync = FrameSync::new(receiver)?;
215///
216/// // Sample available data without waiting for a new network frame
217/// if let Some(video) = framesync.capture_video(ScanType::Progressive)? {
218/// println!("Video: {}x{}", video.width(), video.height());
219/// }
220///
221/// // Audio capture fixes the output dimensions before entering the SDK.
222/// let audio = framesync.capture_audio(FrameSyncAudioRequest::capture_with(
223/// Some(48_000), 2, 1_024,
224/// )?)?;
225/// println!("Audio: {} samples", audio.num_samples());
226/// # Ok(())
227/// # }
228/// ```
229pub struct FrameSync {
230 // Rust drops fields in declaration order. The SDK requires FrameSync to be
231 // destroyed before its receiver (Processing.NDI.FrameSync.h, create contract).
232 resource: FrameSyncResource,
233 receiver: Receiver<FrameSyncAttached>,
234}
235
236struct FrameSyncResource {
237 instance: NDIlib_framesync_instance_t,
238 // Retain the runtime directly until after native destruction.
239 _ndi: crate::NDI,
240}
241
242impl Drop for FrameSyncResource {
243 fn drop(&mut self) {
244 // SAFETY: Creation checked for null; exclusive ownership and borrowed
245 // guards prevent destruction while capture buffers remain live.
246 unsafe { NDIlib_framesync_destroy(self.instance) };
247 #[cfg(test)]
248 crate::receiver::lifecycle_test_events::record("framesync");
249 }
250}
251
252impl FrameSync {
253 /// Create a frame synchronizer from a receiver.
254 ///
255 /// Takes ownership of the receiver. Use [`receiver()`](Self::receiver)
256 /// to access the underlying receiver for tally, PTZ, or status operations.
257 /// Use [`into_receiver()`](Self::into_receiver) to recover the receiver.
258 ///
259 /// # Errors
260 ///
261 /// Returns an error if the NDI SDK fails to create the frame-sync instance.
262 ///
263 /// # Example
264 ///
265 /// ```no_run
266 /// # use grafton_ndi::{NDI, ReceiverOptions, Receiver, FrameSync, Source, SourceAddress};
267 /// # fn main() -> Result<(), grafton_ndi::Error> {
268 /// # let ndi = NDI::new()?;
269 /// # let source = Source { name: "Test".into(), address: SourceAddress::None };
270 /// # let options = ReceiverOptions::builder(source).build();
271 /// let receiver = Receiver::new(&ndi, &options)?;
272 /// let framesync = FrameSync::new(receiver)?;
273 /// # Ok(())
274 /// # }
275 /// ```
276 pub fn new(receiver: Receiver) -> Result<Self> {
277 // SAFETY: The owned receiver retains a live SDK resource through creation;
278 // successful creation transfers both resources into the ordered owners.
279 Self::new_with(receiver, |instance| unsafe {
280 NDIlib_framesync_create(instance)
281 })
282 }
283
284 fn new_with(
285 receiver: Receiver,
286 create: impl FnOnce(NDIlib_recv_instance_t) -> NDIlib_framesync_instance_t,
287 ) -> Result<Self> {
288 let instance = create(receiver.core.instance);
289 if instance.is_null() {
290 // The untouched raw receiver drops normally on creation failure.
291 return Err(Error::InitializationFailed(
292 "Failed to create NDI framesync instance".into(),
293 ));
294 }
295 let resource = FrameSyncResource {
296 instance,
297 _ndi: receiver.core._ndi.clone(),
298 };
299 Ok(Self {
300 resource,
301 receiver: receiver.into_mode(),
302 })
303 }
304
305 /// Access the underlying receiver.
306 ///
307 /// Use this to access receiver functionality like tally, PTZ, or status
308 /// queries while the FrameSync is active. Tally control publishes state
309 /// upstream; it does not query incoming tally. This receiver mode has no
310 /// raw-video method; use FrameSync capture methods or
311 /// recover the receiver with [`Self::into_receiver`] first.
312 pub fn receiver(&self) -> &Receiver<FrameSyncAttached> {
313 &self.receiver
314 }
315
316 /// Consume the FrameSync and recover the underlying Receiver.
317 ///
318 /// This destroys the frame synchronizer and returns the receiver for
319 /// continued use with raw capture or for creating a new FrameSync.
320 pub fn into_receiver(self) -> Receiver {
321 let Self { resource, receiver } = self;
322 drop(resource);
323 receiver.into_mode()
324 }
325
326 /// Capture video with time-base correction.
327 ///
328 /// This does not wait for a new network frame. It returns the best frame for
329 /// the current output timing, handling clock drift and jitter automatically.
330 /// The same frame may be returned multiple times when capture rate exceeds
331 /// source frame rate. Wrapper synchronization and SDK processing still take time.
332 ///
333 /// # Arguments
334 ///
335 /// * `field_type` - The desired field format. Use `ScanType::Progressive` for
336 /// most applications. For interlaced output, use the appropriate field type
337 /// to maintain correct field ordering.
338 ///
339 /// # Returns
340 ///
341 /// * `Ok(Some(FrameSyncVideoRef))` - A validated zero-copy reference to the captured frame
342 /// * `Ok(None)` - The SDK returned the documented all-zero "no video yet" state
343 /// * `Err(_)` - The SDK returned an unsupported format or malformed frame
344 /// descriptor, including invalid dimensions, stride, buffers, or metadata
345 ///
346 /// # Example
347 ///
348 /// ```no_run
349 /// # use grafton_ndi::{NDI, ReceiverOptions, Receiver, FrameSync, Source, SourceAddress, ScanType};
350 /// # fn main() -> Result<(), grafton_ndi::Error> {
351 /// # let ndi = NDI::new()?;
352 /// # let source = Source { name: "Test".into(), address: SourceAddress::None };
353 /// # let options = ReceiverOptions::builder(source).build();
354 /// # let receiver = Receiver::new(&ndi, &options)?;
355 /// let framesync = FrameSync::new(receiver)?;
356 ///
357 /// // Capture loop - call at your output frame rate
358 /// loop {
359 /// if let Some(frame) = framesync.capture_video(ScanType::Progressive)? {
360 /// // Process/display frame
361 /// println!("{}x{} @ {}/{} fps",
362 /// frame.width(), frame.height(),
363 /// frame.frame_rate_n(), frame.frame_rate_d());
364 /// } else {
365 /// // No video received yet - display placeholder
366 /// println!("Waiting for video...");
367 /// }
368 /// # break;
369 /// }
370 /// # Ok(())
371 /// # }
372 /// ```
373 pub fn capture_video(&self, field_type: ScanType) -> Result<Option<FrameSyncVideoRef<'_>>> {
374 let mut frame = NDIlib_video_frame_v2_t::default();
375
376 unsafe {
377 NDIlib_framesync_capture_video(self.resource.instance, &mut frame, field_type.into());
378 }
379
380 // Per SDK docs: Returns zeroed struct if no video received yet
381 if is_framesync_video_empty(&frame) {
382 return Ok(None);
383 }
384
385 let guard = unsafe { Guard::<FrameSyncVideoFree>::new(self.resource.instance, frame) };
386 let frame = unsafe { FrameSyncVideoRef::new(guard)? };
387
388 Ok(Some(frame))
389 }
390
391 /// Capture video and convert to an owned frame.
392 ///
393 /// This is a convenience method that captures video and immediately converts
394 /// it to an owned [`VideoFrame`] that can be sent across threads.
395 ///
396 /// # Arguments
397 ///
398 /// * `field_type` - The desired field format.
399 ///
400 /// # Returns
401 ///
402 /// * `Ok(Some(VideoFrame))` - An owned copy of the captured frame
403 /// * `Ok(None)` - No video has been received yet
404 ///
405 /// # Errors
406 ///
407 /// Propagates format and frame-validation errors from [`Self::capture_video`]
408 /// or conversion to an owned frame.
409 pub fn capture_video_owned(&self, field_type: ScanType) -> Result<Option<VideoFrame>> {
410 self.capture_video(field_type)?
411 .map(|frame| frame.to_owned())
412 .transpose()
413 }
414
415 /// Capture audio with dynamic resampling.
416 ///
417 /// This does not wait for new network audio. Capture requests ask the NDI
418 /// SDK to resample incoming audio to match the requested sample rate,
419 /// channel count, and sample count. Query requests return the current input
420 /// format without requesting samples. Wrapper synchronization and resampling
421 /// still take time.
422 ///
423 /// Call this at the rate you need audio - the SDK will adapt the incoming
424 /// signal to match your output timing using dynamic audio sampling.
425 ///
426 /// # Arguments
427 ///
428 /// * `request` - Explicit capture or query operation. For capture requests,
429 /// `None` sample rate means "use the current source rate"; channels are explicit.
430 ///
431 /// # Querying Input Format
432 ///
433 /// Use [`FrameSyncAudioRequest::query_input`] to query the current input
434 /// format without capturing samples. The returned frame contains the
435 /// input's sample rate and channel count, or a validated empty no-source
436 /// state when no audio format has been received yet.
437 ///
438 /// # Errors
439 ///
440 /// Returns frame-validation errors if the SDK returns malformed audio
441 /// dimensions, sample layout, buffers, or metadata. Request dimensions were
442 /// already validated at construction.
443 ///
444 /// # Example
445 ///
446 /// ```no_run
447 /// # use grafton_ndi::{NDI, ReceiverOptions, Receiver, FrameSync, FrameSyncAudioRequest, Source, SourceAddress};
448 /// # fn main() -> Result<(), grafton_ndi::Error> {
449 /// # let ndi = NDI::new()?;
450 /// # let source = Source { name: "Test".into(), address: SourceAddress::None };
451 /// # let options = ReceiverOptions::builder(source).build();
452 /// # let receiver = Receiver::new(&ndi, &options)?;
453 /// let framesync = FrameSync::new(receiver)?;
454 ///
455 /// // Audio callback - called by sound card at its rate
456 /// let request = FrameSyncAudioRequest::capture_with(Some(48_000), 2, 1_024)?;
457 ///
458 /// loop {
459 /// // Request 1024 stereo samples at 48kHz
460 /// let audio = framesync.capture_audio(request)?;
461 ///
462 /// // Process each channel without reading inter-channel padding.
463 /// for samples in audio.channels() {
464 /// println!("Got {} samples in a channel", samples.len());
465 /// }
466 ///
467 /// // Check for a validated query/no-source empty state
468 /// if audio.is_empty() {
469 /// println!("No audio source yet");
470 /// }
471 /// # break;
472 /// }
473 /// # Ok(())
474 /// # }
475 /// ```
476 pub fn capture_audio(&self, request: FrameSyncAudioRequest) -> Result<FrameSyncAudioRef<'_>> {
477 let mut frame = NDIlib_audio_frame_v3_t::default();
478
479 #[cfg(test)]
480 audio_test_calls::capture();
481 // SAFETY: The live FrameSync owns its receiver/runtime. Request fields
482 // are private: capture construction checked the packed FLTP payload and
483 // signed byte stride; only query_input() can submit zero samples/channels.
484 // Processing.NDI.FrameSync.h permits explicit output channels independent
485 // of input and all-zero format queries. No source-channel sentinel can
486 // race a format change here. The SDK owns the returned allocation until
487 // Guard frees it through this same instance, including validation errors.
488 unsafe {
489 NDIlib_framesync_capture_audio_v2(
490 self.resource.instance,
491 &mut frame,
492 request.sample_rate,
493 request.channels,
494 request.samples,
495 );
496 }
497
498 let guard = unsafe { Guard::<FrameSyncAudioFree>::new(self.resource.instance, frame) };
499 unsafe { FrameSyncAudioRef::new(guard, request.samples == 0) }
500 }
501
502 /// Capture audio and convert to an owned frame.
503 ///
504 /// This is a convenience method that captures audio and immediately converts
505 /// it to an owned [`AudioFrame`] that can be sent across threads. Query or
506 /// no-source states that contain no sample buffer return `Ok(None)`.
507 ///
508 /// # Arguments
509 ///
510 /// * `request` - Explicit capture or query operation.
511 ///
512 /// # Errors
513 ///
514 /// Propagates returned-frame validation and owned-copy errors from
515 /// [`Self::capture_audio`] and [`AudioRef::to_owned`].
516 pub fn capture_audio_owned(
517 &self,
518 request: FrameSyncAudioRequest,
519 ) -> Result<Option<AudioFrame>> {
520 self.capture_audio(request)?.to_owned()
521 }
522
523 /// Query the current audio queue depth.
524 ///
525 /// Returns the approximate number of audio samples currently buffered.
526 /// This can be useful when using an inaccurate timer for audio playback.
527 ///
528 /// **Note:** This value may change immediately after being read as new
529 /// samples are continuously received. For most applications, simply call
530 /// `capture_audio` at your desired rate and let the SDK handle timing.
531 ///
532 /// # Example
533 ///
534 /// ```no_run
535 /// # use grafton_ndi::{NDI, ReceiverOptions, Receiver, FrameSync, Source, SourceAddress};
536 /// # fn main() -> Result<(), grafton_ndi::Error> {
537 /// # let ndi = NDI::new()?;
538 /// # let source = Source { name: "Test".into(), address: SourceAddress::None };
539 /// # let options = ReceiverOptions::builder(source).build();
540 /// # let receiver = Receiver::new(&ndi, &options)?;
541 /// let framesync = FrameSync::new(receiver)?;
542 ///
543 /// // Check available samples before capture
544 /// let available = framesync.audio_queue_depth();
545 /// println!("Audio samples available: {}", available);
546 /// # Ok(())
547 /// # }
548 /// ```
549 pub fn audio_queue_depth(&self) -> i32 {
550 unsafe { NDIlib_framesync_audio_queue_depth(self.resource.instance) }
551 }
552}
553
554/// # Safety
555///
556/// The NDI SDK documentation states that framesync operations are thread-safe.
557/// The FrameSync owns its receiver, keeping the SDK runtime alive. Borrowed
558/// frame guards cannot be moved between threads and keep this owner borrowed
559/// until their buffers are freed; destruction requires exclusive ownership.
560unsafe impl Send for FrameSync {}
561
562/// # Safety
563///
564/// The NDI SDK guarantees that framesync capture functions are internally
565/// synchronized and can be called concurrently from multiple threads. Receiver
566/// attachment disables raw video capture until the synchronizer is destroyed,
567/// including when the receiver is accessed through [`FrameSync::receiver`].
568unsafe impl Sync for FrameSync {}
569
570impl fmt::Debug for FrameSync {
571 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
572 f.debug_struct("FrameSync")
573 .field("instance", &self.resource.instance)
574 .field("audio_queue_depth", &self.audio_queue_depth())
575 .finish()
576 }
577}
578
579pub(crate) fn is_framesync_video_empty(frame: &NDIlib_video_frame_v2_t) -> bool {
580 frame.xres == 0
581 && frame.yres == 0
582 && frame.FourCC == 0
583 && frame.frame_rate_N == 0
584 && frame.frame_rate_D == 0
585 && frame.picture_aspect_ratio == 0.0
586 && frame.frame_format_type == 0
587 && frame.timecode == 0
588 && frame.p_data.is_null()
589 && unsafe { frame.__bindgen_anon_1.data_size_in_bytes } == 0
590 && frame.p_metadata.is_null()
591 && frame.timestamp == 0
592}
593
594// A thread-local seam at the actual SDK capture/free boundaries. This lets tests
595// exercise both public entry points without replacing SDK-owned frame storage.
596#[cfg(test)]
597pub(crate) mod audio_test_calls {
598 use std::cell::Cell;
599 std::thread_local! {
600 static CALLS: Cell<(usize, usize)> = const { Cell::new((0, 0)) };
601 }
602 pub(super) fn capture() {
603 CALLS.with(|calls| {
604 let (captures, frees) = calls.get();
605 calls.set((captures + 1, frees));
606 });
607 }
608 pub(crate) fn free() {
609 CALLS.with(|calls| {
610 let (captures, frees) = calls.get();
611 calls.set((captures, frees + 1));
612 });
613 }
614 pub(super) fn take() -> (usize, usize) {
615 CALLS.with(|calls| calls.replace((0, 0)))
616 }
617}
618
619#[cfg(test)]
620mod tests {
621 use super::*;
622 use crate::frames::{AudioFormat, LineStrideOrSize, PixelFormat};
623 use std::ptr;
624
625 #[test]
626 fn receiver_modes_preserve_resource_order_and_exactly_once_destruction() {
627 use crate::receiver::lifecycle_test_events::track;
628 for recover in [false, true] {
629 let events = track(|| {
630 let ndi = crate::NDI::new().unwrap();
631 let mut receiver = Receiver::new(&ndi, &crate::ReceiverOptions::default()).unwrap();
632 let original = receiver.core.instance;
633 drop(ndi);
634 for _ in 0..3 {
635 let fs = FrameSync::new(receiver).unwrap();
636 assert_eq!(fs.receiver().core.instance, original);
637 receiver = fs.into_receiver();
638 assert_eq!(receiver.core.instance, original);
639 assert!(receiver
640 .video()
641 .try_capture(std::time::Duration::ZERO)
642 .is_ok());
643 }
644 let fs = FrameSync::new(receiver).unwrap();
645 if recover {
646 drop(fs.into_receiver());
647 } else {
648 drop(fs);
649 }
650 });
651 assert_eq!(
652 events,
653 [
654 "framesync",
655 "framesync",
656 "framesync",
657 "framesync",
658 "receiver"
659 ]
660 );
661 }
662 let events = track(|| {
663 let ndi = crate::NDI::new().unwrap();
664 let receiver = Receiver::new(&ndi, &crate::ReceiverOptions::default()).unwrap();
665 drop(ndi);
666 assert!(matches!(
667 FrameSync::new_with(receiver, |_| ptr::null_mut()),
668 Err(Error::InitializationFailed(_))
669 ));
670 });
671 assert_eq!(events, ["receiver"]);
672 }
673
674 #[test]
675 fn audio_requests_reject_invalid_rates_and_dimensions_at_construction() {
676 for rate in [i32::MIN, -48_000, -1, 0] {
677 assert!(FrameSyncAudioRequest::capture_with(Some(rate), 2, 1024).is_err());
678 }
679 for (channels, samples) in [
680 (0, 1024),
681 (-1, 1024),
682 (2, 0),
683 (2, -1),
684 (i32::MIN, i32::MIN),
685 (2, 8_388_609),
686 (2, 536_870_912),
687 (1, 1_073_741_825),
688 (i32::MAX, 1),
689 (i32::MAX, i32::MAX),
690 ] {
691 assert!(FrameSyncAudioRequest::capture(channels, samples).is_err());
692 assert!(FrameSyncAudioRequest::capture_with(Some(48_000), channels, samples).is_err());
693 }
694 // Exact cap, with no sample allocation or native call.
695 assert!(FrameSyncAudioRequest::capture(2, 8_388_608).is_ok());
696 assert!(FrameSyncAudioRequest::capture(1, 16_777_216).is_ok());
697 assert!(FrameSyncAudioRequest::capture(1, 16_777_217).is_err());
698 }
699
700 #[test]
701 fn audio_request_reuse_and_rejection_at_both_sdk_entry_points() {
702 let ndi = crate::NDI::new().unwrap();
703 let receiver = Receiver::new(
704 &ndi,
705 &crate::ReceiverOptions::builder(crate::Source::default()).build(),
706 )
707 .unwrap();
708 let fs = FrameSync::new(receiver).unwrap();
709 audio_test_calls::take();
710 for (channels, samples) in [
711 (2, 8_388_609),
712 (2, 536_870_912),
713 (1, 1_073_741_825),
714 (i32::MAX, 1),
715 ] {
716 assert!(
717 FrameSyncAudioRequest::capture_with(Some(48_000), channels, samples)
718 .and_then(|request| fs.capture_audio(request))
719 .is_err()
720 );
721 assert!(
722 FrameSyncAudioRequest::capture_with(Some(48_000), channels, samples)
723 .and_then(|request| fs.capture_audio_owned(request))
724 .is_err()
725 );
726 assert_eq!(audio_test_calls::take(), (0, 0));
727 }
728
729 let ((), allocations) = crate::test_alloc::allocation_count(|| {
730 for _ in 0..1000 {
731 std::hint::black_box(FrameSyncAudioRequest::capture(2, 8_388_608).unwrap());
732 std::hint::black_box(FrameSyncAudioRequest::query_input());
733 }
734 });
735 assert_eq!(allocations, 0);
736 let (request, allocations) = crate::test_alloc::allocation_count(|| {
737 FrameSyncAudioRequest::capture_with(Some(48_000), 2, 1024).unwrap()
738 });
739 assert_eq!(allocations, 0);
740 for _ in 0..3 {
741 let ((), allocations) = crate::test_alloc::allocation_count(|| {
742 let frame = fs.capture_audio(std::hint::black_box(request)).unwrap();
743 assert_eq!(
744 (
745 frame.sample_rate(),
746 frame.num_channels(),
747 frame.num_samples()
748 ),
749 (48_000, 2, 1024)
750 );
751 assert!(frame
752 .channels()
753 .all(|channel| channel.iter().all(|&s| s == 0.0)));
754 });
755 assert_eq!(allocations, 0);
756 assert_eq!(audio_test_calls::take(), (1, 1));
757 assert!(fs.capture_audio_owned(request).unwrap().is_some());
758 assert_eq!(audio_test_calls::take(), (1, 1));
759 }
760
761 // No source rate is known: response validation must still run and free
762 // the SDK descriptor on its error path, for both entry points.
763 let unknown_rate = FrameSyncAudioRequest::capture(2, 1024).unwrap();
764 assert!(fs.capture_audio(unknown_rate).is_err());
765 assert_eq!(audio_test_calls::take(), (1, 1));
766 assert!(fs.capture_audio_owned(unknown_rate).is_err());
767 assert_eq!(audio_test_calls::take(), (1, 1));
768 assert!(fs
769 .capture_audio(FrameSyncAudioRequest::query_input())
770 .unwrap()
771 .is_empty());
772 assert_eq!(audio_test_calls::take(), (1, 1));
773 assert!(fs
774 .capture_audio_owned(FrameSyncAudioRequest::query_input())
775 .unwrap()
776 .is_none());
777 assert_eq!(audio_test_calls::take(), (1, 1));
778 }
779
780 #[test]
781 fn test_framesync_size() {
782 // FrameSync contains an opaque pointer + the owned Receiver
783 assert_eq!(
784 std::mem::size_of::<FrameSync>(),
785 std::mem::size_of::<NDIlib_framesync_instance_t>() + std::mem::size_of::<Receiver>()
786 );
787 }
788
789 #[test]
790 fn test_video_ref_size() {
791 // FrameSyncVideoRef includes a reference, frame struct, and pixel_format
792 let size = std::mem::size_of::<FrameSyncVideoRef>();
793 assert!(size > 0, "FrameSyncVideoRef should have non-zero size");
794 }
795
796 #[test]
797 fn test_audio_ref_size() {
798 // FrameSyncAudioRef includes a reference and frame struct
799 let size = std::mem::size_of::<FrameSyncAudioRef>();
800 assert!(size > 0, "FrameSyncAudioRef should have non-zero size");
801 }
802
803 #[test]
804 fn test_video_empty_classifier_accepts_only_all_zero() {
805 let empty = NDIlib_video_frame_v2_t::default();
806 assert!(is_framesync_video_empty(&empty));
807
808 let mut partial = NDIlib_video_frame_v2_t {
809 xres: 1920,
810 ..NDIlib_video_frame_v2_t::default()
811 };
812 assert!(!is_framesync_video_empty(&partial));
813
814 let mut byte = 0u8;
815 partial = NDIlib_video_frame_v2_t {
816 p_data: &mut byte as *mut u8,
817 ..NDIlib_video_frame_v2_t::default()
818 };
819 assert!(!is_framesync_video_empty(&partial));
820 }
821
822 #[test]
823 fn test_framesync_video_ref_uses_validated_layout() {
824 let width = 16;
825 let height = 8;
826 let stride = width * 4;
827 let expected_len = (stride * height) as usize;
828 let mut data = vec![0u8; expected_len];
829 let mut metadata = b"framesync video\0".to_vec();
830
831 let raw = NDIlib_video_frame_v2_t {
832 xres: width,
833 yres: height,
834 FourCC: PixelFormat::BGRA.into(),
835 frame_rate_N: 60,
836 frame_rate_D: 1,
837 picture_aspect_ratio: 16.0 / 9.0,
838 frame_format_type: ScanType::Progressive.into(),
839 timecode: 0,
840 p_data: data.as_mut_ptr(),
841 __bindgen_anon_1: NDIlib_video_frame_v2_t__bindgen_ty_1 {
842 line_stride_in_bytes: stride,
843 },
844 p_metadata: metadata.as_mut_ptr().cast(),
845 timestamp: 0,
846 };
847
848 let guard = unsafe { Guard::<FrameSyncVideoFree>::new(ptr::null_mut(), raw) };
849 let frame = unsafe { FrameSyncVideoRef::new(guard) }.expect("valid video frame");
850
851 assert_eq!(frame.metadata(), Some("framesync video"));
852 assert_eq!(frame.data().unwrap().len(), expected_len);
853 assert_eq!(
854 frame.line_stride_or_size(),
855 LineStrideOrSize::LineStrideBytes(stride)
856 );
857 let owned = frame.to_owned().expect("owned conversion");
858 assert_eq!(owned.metadata(), Some("framesync video"));
859 assert!(format!("{frame:?}").contains("FrameSyncVideoRef"));
860 }
861
862 #[test]
863 fn test_framesync_video_ref_rejects_malformed_metadata() {
864 let width = 16;
865 let height = 8;
866 let stride = width * 4;
867 let expected_len = (stride * height) as usize;
868 let mut data = vec![0u8; expected_len];
869 let mut metadata = vec![b'x'; crate::frames::MAX_METADATA_BYTES];
870
871 let raw = NDIlib_video_frame_v2_t {
872 xres: width,
873 yres: height,
874 FourCC: PixelFormat::BGRA.into(),
875 frame_rate_N: 60,
876 frame_rate_D: 1,
877 picture_aspect_ratio: 16.0 / 9.0,
878 frame_format_type: ScanType::Progressive.into(),
879 timecode: 0,
880 p_data: data.as_mut_ptr(),
881 __bindgen_anon_1: NDIlib_video_frame_v2_t__bindgen_ty_1 {
882 line_stride_in_bytes: stride,
883 },
884 p_metadata: metadata.as_mut_ptr().cast(),
885 timestamp: 0,
886 };
887
888 let guard = unsafe { Guard::<FrameSyncVideoFree>::new(ptr::null_mut(), raw) };
889 assert!(matches!(
890 unsafe { FrameSyncVideoRef::new(guard) },
891 Err(Error::InvalidFrame(_))
892 ));
893 }
894
895 #[test]
896 fn test_framesync_video_ref_rejects_partial_empty() {
897 let raw = NDIlib_video_frame_v2_t {
898 xres: 16,
899 yres: 8,
900 FourCC: PixelFormat::BGRA.into(),
901 frame_rate_N: 60,
902 frame_rate_D: 1,
903 picture_aspect_ratio: 16.0 / 9.0,
904 frame_format_type: ScanType::Progressive.into(),
905 timecode: 0,
906 p_data: ptr::null_mut(),
907 __bindgen_anon_1: NDIlib_video_frame_v2_t__bindgen_ty_1 {
908 line_stride_in_bytes: 16 * 4,
909 },
910 p_metadata: ptr::null(),
911 timestamp: 0,
912 };
913
914 let guard = unsafe { Guard::<FrameSyncVideoFree>::new(ptr::null_mut(), raw) };
915 let result = unsafe { FrameSyncVideoRef::new(guard) };
916 assert!(matches!(result, Err(Error::InvalidFrame(_))));
917 }
918
919 #[test]
920 fn test_framesync_audio_ref_query_no_source_empty() {
921 let raw = NDIlib_audio_frame_v3_t::default();
922 let guard = unsafe { Guard::<FrameSyncAudioFree>::new(ptr::null_mut(), raw) };
923 let frame = unsafe { FrameSyncAudioRef::new(guard, true) }.expect("empty query frame");
924
925 assert!(frame.is_empty());
926 assert_eq!(frame.metadata(), None);
927 assert!(frame.data().unwrap().is_empty());
928 assert_eq!(frame.format(), None);
929 assert!(frame.to_owned().expect("owned conversion").is_none());
930 assert!(format!("{frame:?}").contains("FrameSyncAudioRef"));
931 }
932
933 #[test]
934 fn test_framesync_audio_ref_query_source_format_empty() {
935 let raw = NDIlib_audio_frame_v3_t {
936 sample_rate: 48000,
937 no_channels: 2,
938 no_samples: 0,
939 timecode: 0,
940 FourCC: NDIlib_FourCC_audio_type_e_NDIlib_FourCC_audio_type_FLTP,
941 p_data: ptr::null_mut(),
942 __bindgen_anon_1: NDIlib_audio_frame_v3_t__bindgen_ty_1 {
943 channel_stride_in_bytes: 0,
944 },
945 p_metadata: ptr::null(),
946 timestamp: 0,
947 };
948
949 let guard = unsafe { Guard::<FrameSyncAudioFree>::new(ptr::null_mut(), raw) };
950 let frame = unsafe { FrameSyncAudioRef::new(guard, true) }.expect("query source frame");
951
952 assert!(frame.is_empty());
953 assert_eq!(frame.sample_rate(), 48000);
954 assert_eq!(frame.num_channels(), 2);
955 assert_eq!(frame.format(), Some(AudioFormat::FLTP));
956 }
957
958 #[test]
959 fn test_framesync_audio_ref_capture_rejects_empty() {
960 let raw = NDIlib_audio_frame_v3_t::default();
961 let guard = unsafe { Guard::<FrameSyncAudioFree>::new(ptr::null_mut(), raw) };
962 let result = unsafe { FrameSyncAudioRef::new(guard, false) };
963
964 assert!(matches!(result, Err(Error::InvalidFrame(_))));
965 }
966
967 #[test]
968 fn test_framesync_audio_ref_supports_strided_planar_data() {
969 let no_samples = 4;
970 let no_channels = 2;
971 let stride_samples = 6;
972 let mut data = vec![0.0f32; 10];
973 let mut metadata = b"framesync audio\0".to_vec();
974 data[0..4].copy_from_slice(&[1.0, 2.0, 3.0, 4.0]);
975 data[6..10].copy_from_slice(&[10.0, 20.0, 30.0, 40.0]);
976
977 let raw = NDIlib_audio_frame_v3_t {
978 sample_rate: 48000,
979 no_channels,
980 no_samples,
981 timecode: 0,
982 FourCC: NDIlib_FourCC_audio_type_e_NDIlib_FourCC_audio_type_FLTP,
983 p_data: data.as_mut_ptr() as *mut u8,
984 __bindgen_anon_1: NDIlib_audio_frame_v3_t__bindgen_ty_1 {
985 channel_stride_in_bytes: stride_samples * 4,
986 },
987 p_metadata: metadata.as_mut_ptr().cast(),
988 timestamp: 0,
989 };
990
991 let guard = unsafe { Guard::<FrameSyncAudioFree>::new(ptr::null_mut(), raw) };
992 let frame = unsafe { FrameSyncAudioRef::new(guard, false) }.expect("strided audio frame");
993
994 assert_eq!(frame.metadata(), Some("framesync audio"));
995 assert!(frame.data().is_none());
996 assert_eq!(frame.channel_data(0), Some(&[1.0, 2.0, 3.0, 4.0][..]));
997 assert_eq!(frame.channel_data(1), Some(&[10.0, 20.0, 30.0, 40.0][..]));
998 assert_eq!(frame.channel_stride_in_bytes(), stride_samples * 4);
999
1000 let owned = frame
1001 .to_owned()
1002 .expect("owned conversion")
1003 .expect("samples");
1004 assert_eq!(owned.metadata(), Some("framesync audio"));
1005 assert_eq!(owned.data().len(), 8);
1006 assert_eq!(owned.channel_stride_in_bytes(), 16);
1007 assert_eq!(owned.channel_data(1), Some(&[10.0, 20.0, 30.0, 40.0][..]));
1008 }
1009
1010 #[test]
1011 fn test_framesync_audio_ref_rejects_invalid_utf8_metadata() {
1012 let no_samples = 4;
1013 let no_channels = 2;
1014 let mut data = vec![0.0f32; (no_samples * no_channels) as usize];
1015 let mut metadata = vec![0xFF, 0];
1016
1017 let raw = NDIlib_audio_frame_v3_t {
1018 sample_rate: 48000,
1019 no_channels,
1020 no_samples,
1021 timecode: 0,
1022 FourCC: NDIlib_FourCC_audio_type_e_NDIlib_FourCC_audio_type_FLTP,
1023 p_data: data.as_mut_ptr() as *mut u8,
1024 __bindgen_anon_1: NDIlib_audio_frame_v3_t__bindgen_ty_1 {
1025 channel_stride_in_bytes: no_samples * 4,
1026 },
1027 p_metadata: metadata.as_mut_ptr().cast(),
1028 timestamp: 0,
1029 };
1030
1031 let guard = unsafe { Guard::<FrameSyncAudioFree>::new(ptr::null_mut(), raw) };
1032 assert!(matches!(
1033 unsafe { FrameSyncAudioRef::new(guard, false) },
1034 Err(Error::InvalidUtf8(_))
1035 ));
1036 }
1037}