Skip to main content

azul_core/
audio.rs

1//! POD types for the audio surface (SUPER_PLAN_2 §4 P7).
2//!
3//! Audio playback + microphone capture (rodio / cpal on the desktop;
4//! AVAudioEngine / AAudio on mobile). Capture mirrors the sensor manager (the
5//! backend pushes [`AudioFrame`]s to a process-global channel; the layout pass
6//! drains them and a callback reads them); playback queues frames to the
7//! backend. The mic permission is the existing
8//! `azul_layout::managers::permission::Capability::Microphone`.
9//!
10//! Defined in `azul-core` so the config + frame types cross the FFI without
11//! `azul-layout` (or rodio / cpal) as a dependency. For azul-meet (P8),
12//! [`AudioFrame`] is the unit captured -> sent over UDP -> played back.
13
14use azul_css::F32Vec;
15
16/// Audio stream format.
17#[repr(C)]
18#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
19pub struct AudioConfig {
20    /// Samples per second per channel (e.g. 48000).
21    pub sample_rate: u32,
22    /// Channel count (1 = mono, 2 = stereo).
23    pub channels: u16,
24}
25
26impl Default for AudioConfig {
27    fn default() -> Self {
28        Self {
29            sample_rate: 48_000,
30            channels: 1,
31        }
32    }
33}
34
35impl AudioConfig {
36    /// A config with the given rate + channel count.
37    #[must_use] pub const fn new(sample_rate: u32, channels: u16) -> Self {
38        Self {
39            sample_rate,
40            channels,
41        }
42    }
43}
44
45/// A chunk of audio - interleaved `f32` samples in `[-1.0, 1.0]`.
46///
47/// For stereo
48/// the layout is `L, R, L, R, ...`. This is the unit the mic backend delivers,
49/// playback consumes, and (P8) azul-meet sends over UDP.
50#[repr(C)]
51#[derive(Debug, Clone, PartialEq)]
52pub struct AudioFrame {
53    /// Samples per second per channel.
54    pub sample_rate: u32,
55    /// Channel count (1 = mono, 2 = stereo).
56    pub channels: u16,
57    /// Interleaved `f32` samples.
58    pub samples: F32Vec,
59}
60
61impl AudioFrame {
62    /// Number of sample *frames* (samples per channel) in this chunk.
63    #[must_use] pub fn frame_count(&self) -> usize {
64        if self.channels == 0 {
65            0
66        } else {
67            self.samples.as_ref().len() / self.channels as usize
68        }
69    }
70}
71
72// FFI Option wrapper for accessors that may have no frame yet. `copy = false`
73// because AudioFrame holds a F32Vec (matches the convention in `json.rs`).
74impl_option!(AudioFrame, OptionAudioFrame, copy = false, [Clone, Debug]);
75
76#[cfg(test)]
77mod autotest_generated {
78    use alloc::{vec, vec::Vec};
79
80    use super::*;
81
82    // --- helpers ---------------------------------------------------------
83
84    /// Build an `AudioFrame` from a channel count + owned sample vec.
85    /// `AudioFrame` derives no `Default`, so each test constructs explicitly.
86    fn frame(sample_rate: u32, channels: u16, samples: Vec<f32>) -> AudioFrame {
87        AudioFrame {
88            sample_rate,
89            channels,
90            samples: F32Vec::from_vec(samples),
91        }
92    }
93
94    // --- AudioConfig::new (constructor) ----------------------------------
95
96    /// invariants_hold: fields match the exact args, for representative input.
97    #[test]
98    fn config_new_fields_match_args() {
99        let c = AudioConfig::new(48_000, 2);
100        assert_eq!(c.sample_rate, 48_000);
101        assert_eq!(c.channels, 2);
102    }
103
104    /// no_panic: extreme integer bounds must not panic or wrap; fields are stored verbatim.
105    #[test]
106    fn config_new_extreme_args_no_panic() {
107        let zero = AudioConfig::new(0, 0);
108        assert_eq!(zero.sample_rate, 0);
109        assert_eq!(zero.channels, 0);
110
111        let max = AudioConfig::new(u32::MAX, u16::MAX);
112        assert_eq!(max.sample_rate, u32::MAX);
113        assert_eq!(max.channels, u16::MAX);
114
115        // A sample_rate of 1 with a very large channel count is nonsensical but legal POD.
116        let odd = AudioConfig::new(1, u16::MAX);
117        assert_eq!(odd.sample_rate, 1);
118        assert_eq!(odd.channels, u16::MAX);
119    }
120
121    /// invariants_hold: `const fn` is usable in a const context (compile-time evaluation).
122    #[test]
123    fn config_new_is_const_evaluable() {
124        const C: AudioConfig = AudioConfig::new(44_100, 1);
125        assert_eq!(C.sample_rate, 44_100);
126        assert_eq!(C.channels, 1);
127    }
128
129    /// invariants_hold: the documented default equals the equivalent explicit construction.
130    #[test]
131    fn config_default_matches_new() {
132        assert_eq!(AudioConfig::default(), AudioConfig::new(48_000, 1));
133    }
134
135    /// invariants_hold: `Copy`/`Eq`/`Hash` are mutually consistent (equal values hash equal).
136    #[test]
137    fn config_eq_hash_consistent() {
138        use core::hash::{Hash, Hasher};
139        use std::collections::hash_map::DefaultHasher;
140
141        let a = AudioConfig::new(96_000, 2);
142        let b = a; // Copy
143        assert_eq!(a, b);
144
145        let mut ha = DefaultHasher::new();
146        let mut hb = DefaultHasher::new();
147        a.hash(&mut ha);
148        b.hash(&mut hb);
149        assert_eq!(ha.finish(), hb.finish());
150
151        // Differing fields compare unequal.
152        assert_ne!(AudioConfig::new(96_000, 2), AudioConfig::new(96_000, 1));
153        assert_ne!(AudioConfig::new(96_000, 2), AudioConfig::new(48_000, 2));
154    }
155
156    // --- AudioFrame::frame_count (getter) --------------------------------
157
158    /// basic_access: mono => frame_count == sample count.
159    #[test]
160    fn frame_count_mono_known_value() {
161        let f = frame(48_000, 1, vec![0.0, 0.5, -0.5, 1.0]);
162        assert_eq!(f.frame_count(), 4);
163    }
164
165    /// basic_access: stereo => frame_count == samples / 2.
166    #[test]
167    fn frame_count_stereo_known_value() {
168        let f = frame(48_000, 2, vec![0.0, 0.5, -0.5, 1.0]);
169        assert_eq!(f.frame_count(), 2);
170    }
171
172    /// edge_access: channels == 0 must hit the guard and return 0, NOT divide by zero.
173    #[test]
174    fn frame_count_zero_channels_no_div_by_zero() {
175        let f = frame(48_000, 0, vec![0.0, 0.1, 0.2, 0.3, 0.4]);
176        assert_eq!(f.frame_count(), 0);
177    }
178
179    /// edge_access: zero channels with zero samples is still 0 (guard short-circuits first).
180    #[test]
181    fn frame_count_zero_channels_empty_samples() {
182        let f = frame(0, 0, Vec::new());
183        assert_eq!(f.frame_count(), 0);
184    }
185
186    /// edge_access: empty sample buffer yields 0 frames for any nonzero channel count.
187    #[test]
188    fn frame_count_empty_samples() {
189        assert_eq!(frame(48_000, 1, Vec::new()).frame_count(), 0);
190        assert_eq!(frame(48_000, 2, Vec::new()).frame_count(), 0);
191        assert_eq!(
192            frame(48_000, u16::MAX, Vec::new()).frame_count(),
193            0
194        );
195    }
196
197    /// edge_access: a partial trailing frame is truncated by integer division (5 / 2 == 2).
198    #[test]
199    fn frame_count_truncates_partial_frame() {
200        let f = frame(48_000, 2, vec![0.0, 0.1, 0.2, 0.3, 0.4]);
201        assert_eq!(f.frame_count(), 2);
202    }
203
204    /// edge_access: channel count far exceeding the sample count yields 0, never underflow/panic.
205    #[test]
206    fn frame_count_channels_exceed_samples() {
207        let f = frame(48_000, u16::MAX, vec![0.0, 1.0, -1.0]);
208        assert_eq!(f.frame_count(), 0);
209    }
210
211    /// edge_access: exactly one full frame across the maximum channel count.
212    #[test]
213    fn frame_count_one_full_max_channel_frame() {
214        let samples = vec![0.25_f32; u16::MAX as usize];
215        let f = frame(48_000, u16::MAX, samples);
216        assert_eq!(f.frame_count(), 1);
217    }
218
219    /// edge_access: non-finite sample values (NaN / +-inf) do not affect the length-only math.
220    #[test]
221    fn frame_count_ignores_non_finite_samples() {
222        let f = frame(
223            48_000,
224            2,
225            vec![f32::NAN, f32::INFINITY, f32::NEG_INFINITY, 0.0],
226        );
227        assert_eq!(f.frame_count(), 2);
228    }
229
230    /// edge_access: a large buffer computes without overflow and matches the plain division.
231    #[test]
232    fn frame_count_large_buffer() {
233        let n = 100_000usize;
234        let f = frame(48_000, 2, vec![0.0_f32; n]);
235        assert_eq!(f.frame_count(), n / 2);
236    }
237
238    // --- round-trip / trait invariants -----------------------------------
239
240    /// round-trip: cloning an `AudioFrame` reproduces an equal value (finite samples).
241    #[test]
242    fn frame_clone_round_trip_eq() {
243        let original = frame(44_100, 2, vec![0.0, 0.5, -0.5, 1.0, -1.0, 0.25]);
244        let cloned = original.clone();
245        assert_eq!(original, cloned);
246        assert_eq!(original.frame_count(), cloned.frame_count());
247        assert_eq!(original.samples.as_ref(), cloned.samples.as_ref());
248    }
249
250    /// round-trip: `Option<AudioFrame>` <-> `OptionAudioFrame` preserves the payload both ways.
251    #[test]
252    fn option_audio_frame_round_trip() {
253        let f = frame(48_000, 1, vec![0.1, 0.2, 0.3]);
254
255        let wrapped: OptionAudioFrame = Some(f.clone()).into();
256        let unwrapped: Option<AudioFrame> = wrapped.into();
257        assert_eq!(unwrapped, Some(f));
258
259        let none_wrapped: OptionAudioFrame = None.into();
260        let none_unwrapped: Option<AudioFrame> = none_wrapped.into();
261        assert_eq!(none_unwrapped, None);
262    }
263
264    /// numeric edge: `PartialEq` follows IEEE-754 — a NaN sample makes a frame unequal to its clone.
265    #[test]
266    fn frame_with_nan_is_not_self_equal() {
267        let f = frame(48_000, 1, vec![f32::NAN]);
268        // NaN != NaN, so PartialEq on the sample vec must report inequality.
269        assert_ne!(f, f.clone());
270        // ...yet the length-based getter is unaffected.
271        assert_eq!(f.frame_count(), 1);
272    }
273
274    // --- AudioConfig: FFI / POD layout + numeric invariants --------------
275
276    /// invariants_hold: `#[repr(C)]` layout is the one the FFI headers assume
277    /// (u32 + u16 + 2 bytes tail padding, 4-byte aligned). A silent layout
278    /// change here would corrupt every cross-language `AudioConfig`.
279    #[test]
280    fn config_repr_c_layout_is_stable() {
281        assert_eq!(core::mem::size_of::<AudioConfig>(), 8);
282        assert_eq!(core::mem::align_of::<AudioConfig>(), 4);
283    }
284
285    /// numeric: no field is truncated, sign-extended or swapped for any
286    /// boundary combination — `new` must store both args verbatim.
287    #[test]
288    fn config_new_no_field_truncation_sweep() {
289        const RATES: [u32; 8] = [
290            0,
291            1,
292            8_000,
293            44_100,
294            48_000,
295            192_000,
296            u16::MAX as u32, // would alias `channels` if the fields were swapped
297            u32::MAX,
298        ];
299        const CHANNELS: [u16; 6] = [0, 1, 2, 8, 255, u16::MAX];
300
301        for &rate in &RATES {
302            for &ch in &CHANNELS {
303                let c = AudioConfig::new(rate, ch);
304                assert_eq!(c.sample_rate, rate, "rate {rate} ch {ch}");
305                assert_eq!(c.channels, ch, "rate {rate} ch {ch}");
306                // Round-trip through the struct must be lossless.
307                assert_eq!(c, AudioConfig::new(c.sample_rate, c.channels));
308            }
309        }
310    }
311
312    /// invariants_hold: `new` performs no normalization — a nonsense config is
313    /// stored as given and is NOT silently coerced to the default.
314    #[test]
315    fn config_new_does_not_normalize() {
316        let c = AudioConfig::new(0, 0);
317        assert_ne!(c, AudioConfig::default());
318        assert_eq!(c.sample_rate, 0);
319        assert_eq!(c.channels, 0);
320    }
321
322    /// invariants_hold: `Copy` gives an independent value — mutating the copy
323    /// must not write through to the original.
324    #[test]
325    fn config_copy_is_independent() {
326        let original = AudioConfig::new(48_000, 2);
327        let mut copy = original;
328        copy.sample_rate = 8_000;
329        copy.channels = 1;
330        assert_eq!(original.sample_rate, 48_000);
331        assert_eq!(original.channels, 2);
332        assert_eq!(copy, AudioConfig::new(8_000, 1));
333    }
334
335    /// invariants_hold: equal configs dedup in a hash set, distinct ones do not
336    /// (Eq/Hash agreement under a real hasher, not just `DefaultHasher`).
337    #[test]
338    fn config_hash_set_dedup() {
339        use std::collections::HashSet;
340
341        let mut set = HashSet::new();
342        assert!(set.insert(AudioConfig::new(48_000, 2)));
343        assert!(!set.insert(AudioConfig::new(48_000, 2))); // equal -> dedup
344        assert!(set.insert(AudioConfig::new(48_000, 1))); // channels differ
345        assert!(set.insert(AudioConfig::new(44_100, 2))); // rate differs
346        assert!(set.insert(AudioConfig::new(u32::MAX, u16::MAX)));
347        assert_eq!(set.len(), 4);
348        assert!(set.contains(&AudioConfig::new(48_000, 2)));
349    }
350
351    // --- AudioFrame::frame_count: exhaustive invariant sweep --------------
352
353    /// invariants_hold: for every (channels, sample-count) pair,
354    /// `frame_count()` equals the integer division, never panics, and satisfies
355    /// `frame_count * channels <= len` with `len - frame_count * channels < channels`.
356    #[test]
357    fn frame_count_division_invariant_sweep() {
358        const CHANNELS: [u16; 9] = [0, 1, 2, 3, 5, 7, 8, 255, u16::MAX];
359
360        for &ch in &CHANNELS {
361            for len in 0..24usize {
362                let f = frame(48_000, ch, vec![0.5_f32; len]);
363                let fc = f.frame_count();
364
365                if ch == 0 {
366                    // Guarded: must return 0 rather than divide by zero.
367                    assert_eq!(fc, 0, "ch=0 len={len}");
368                    continue;
369                }
370
371                let ch_us = ch as usize;
372                assert_eq!(fc, len / ch_us, "ch={ch} len={len}");
373                // No over-counting: the reported frames must fit in the buffer.
374                let consumed = fc.checked_mul(ch_us).expect("frame_count * channels overflowed");
375                assert!(consumed <= len, "ch={ch} len={len} consumed={consumed}");
376                // ...and at most one partial frame may be left over.
377                assert!(len - consumed < ch_us, "ch={ch} len={len}");
378            }
379        }
380    }
381
382    /// edge_access: the boundary around one full max-channel frame —
383    /// one sample short is 0 frames, one sample over is still 1 (truncation).
384    #[test]
385    fn frame_count_max_channel_boundaries() {
386        let n = u16::MAX as usize;
387        assert_eq!(frame(48_000, u16::MAX, vec![0.0; n - 1]).frame_count(), 0);
388        assert_eq!(frame(48_000, u16::MAX, vec![0.0; n]).frame_count(), 1);
389        assert_eq!(frame(48_000, u16::MAX, vec![0.0; n + 1]).frame_count(), 1);
390        assert_eq!(frame(48_000, u16::MAX, vec![0.0; 2 * n]).frame_count(), 2);
391        assert_eq!(
392            frame(48_000, u16::MAX, vec![0.0; 2 * n - 1]).frame_count(),
393            1
394        );
395    }
396
397    /// invariants_hold: `sample_rate` is not an input to `frame_count` — the
398    /// extremes must not change the result.
399    #[test]
400    fn frame_count_ignores_sample_rate() {
401        let samples = vec![0.0, 0.1, 0.2, 0.3];
402        for &rate in &[0u32, 1, 48_000, u32::MAX] {
403            assert_eq!(frame(rate, 2, samples.clone()).frame_count(), 2, "rate={rate}");
404        }
405    }
406
407    /// invariants_hold: `frame_count` is a pure read — repeated calls agree and
408    /// the sample buffer is untouched.
409    #[test]
410    fn frame_count_is_pure() {
411        let f = frame(48_000, 2, vec![0.0, 0.1, 0.2, 0.3, 0.4, 0.5]);
412        let before: Vec<f32> = f.samples.as_ref().to_vec();
413        let first = f.frame_count();
414        let second = f.frame_count();
415        let third = f.frame_count();
416        assert_eq!(first, 3);
417        assert_eq!(first, second);
418        assert_eq!(second, third);
419        assert_eq!(f.samples.as_ref(), before.as_slice());
420        assert_eq!(f.samples.len(), 6);
421    }
422
423    /// edge_access: a frame whose `F32Vec` is backed by a `'static` slice
424    /// (no heap allocation, `NoDestructor`) must count + drop cleanly.
425    #[test]
426    fn frame_count_static_backed_samples() {
427        static SAMPLES: [f32; 6] = [0.0, 0.5, -0.5, 1.0, -1.0, 0.25];
428
429        let f = AudioFrame {
430            sample_rate: 48_000,
431            channels: 2,
432            samples: F32Vec::from_const_slice(&SAMPLES),
433        };
434        assert_eq!(f.frame_count(), 3);
435        assert_eq!(f.samples.as_ref(), &SAMPLES[..]);
436
437        // Cloning a static-backed vec must not alias the static into a heap free.
438        let cloned = f.clone();
439        assert_eq!(cloned, f);
440        drop(f);
441        assert_eq!(cloned.frame_count(), 3);
442    }
443
444    /// edge_access: an empty `F32Vec::new()` (null-ptr, zero-cap vec) is a valid
445    /// zero-frame buffer and must not panic on read, clone or drop.
446    #[test]
447    fn frame_count_default_f32vec_is_empty() {
448        let f = AudioFrame {
449            sample_rate: 48_000,
450            channels: 2,
451            samples: F32Vec::new(),
452        };
453        assert_eq!(f.frame_count(), 0);
454        assert!(f.samples.is_empty());
455        assert_eq!(f.samples.len(), 0);
456        assert_eq!(f.samples.as_ref(), &[] as &[f32]);
457        assert_eq!(f.clone(), f);
458    }
459
460    /// invariants_hold: building the sample vec through `FromIterator` yields the
461    /// same frame count as `from_vec`.
462    #[test]
463    fn frame_count_from_iterator_matches_from_vec() {
464        let via_iter = AudioFrame {
465            sample_rate: 48_000,
466            channels: 2,
467            samples: (0..10).map(|i| i as f32).collect::<F32Vec>(),
468        };
469        let via_vec = frame(48_000, 2, (0..10).map(|i| i as f32).collect::<Vec<f32>>());
470        assert_eq!(via_iter, via_vec);
471        assert_eq!(via_iter.frame_count(), 5);
472        assert_eq!(via_iter.frame_count(), via_vec.frame_count());
473    }
474
475    /// edge_access: out-of-bounds sample reads return `None` rather than panicking,
476    /// including on the last valid index of the final counted frame.
477    #[test]
478    fn frame_sample_access_is_bounds_checked() {
479        let f = frame(48_000, 2, vec![0.0, 0.1, 0.2, 0.3]);
480        let last = f.frame_count() * f.channels as usize - 1;
481        assert_eq!(f.samples.get(last), Some(&0.3));
482        assert_eq!(f.samples.get(f.samples.len()), None);
483        assert_eq!(f.samples.get(usize::MAX), None);
484    }
485
486    /// numeric: extreme / non-finite / signed-zero samples survive a clone
487    /// bit-for-bit, and none of them perturb the length-only getter.
488    #[test]
489    fn frame_extreme_sample_values_preserved_bitwise() {
490        let raw = vec![
491            f32::MIN,
492            f32::MAX,
493            f32::MIN_POSITIVE,
494            -f32::MIN_POSITIVE,
495            0.0,
496            -0.0,
497            f32::EPSILON,
498            1e-45, // subnormal
499        ];
500        let f = frame(48_000, 2, raw.clone());
501        assert_eq!(f.frame_count(), 4);
502
503        let cloned = f.clone();
504        let got: Vec<u32> = cloned.samples.as_ref().iter().map(|s| s.to_bits()).collect();
505        let want: Vec<u32> = raw.iter().map(|s| s.to_bits()).collect();
506        assert_eq!(got, want, "clone must be bit-exact (incl. -0.0 and subnormals)");
507
508        // Sanity: -0.0 == 0.0 under PartialEq but their bits differ, so a
509        // value-level compare still says equal while the bit compare above is strict.
510        assert_eq!(cloned, f);
511        assert_ne!(0.0_f32.to_bits(), (-0.0_f32).to_bits());
512    }
513
514    /// invariants_hold: `PartialEq` on `AudioFrame` discriminates the header
515    /// fields, not just the samples.
516    #[test]
517    fn frame_eq_discriminates_header_fields() {
518        let samples = vec![0.0, 0.5, -0.5, 1.0];
519        let base = frame(48_000, 2, samples.clone());
520        assert_eq!(base, frame(48_000, 2, samples.clone()));
521        assert_ne!(base, frame(44_100, 2, samples.clone()));
522        assert_ne!(base, frame(48_000, 1, samples.clone()));
523        assert_ne!(base, frame(48_000, 2, vec![0.0, 0.5, -0.5]));
524        // Same frame_count (2) via different (channels, len) pairs is still not equal.
525        assert_eq!(frame(48_000, 1, vec![0.0, 0.5]).frame_count(), 2);
526        assert_ne!(base, frame(48_000, 1, vec![0.0, 0.5]));
527    }
528
529    /// edge_access: repeated clone/drop cycles of a heap-backed frame must not
530    /// double-free or corrupt the buffer (FFI vec destructor regression guard).
531    #[test]
532    fn frame_repeated_clone_drop_is_stable() {
533        let original = frame(48_000, 2, (0..64).map(|i| i as f32).collect::<Vec<f32>>());
534        for _ in 0..64 {
535            let c = original.clone();
536            assert_eq!(c.frame_count(), 32);
537            assert_eq!(c.samples.as_ref(), original.samples.as_ref());
538            drop(c);
539        }
540        // The original survived every clone + drop.
541        assert_eq!(original.frame_count(), 32);
542        assert_eq!(original.samples.len(), 64);
543    }
544
545    // --- OptionAudioFrame (FFI option wrapper) ----------------------------
546
547    /// invariants_hold: the FFI option defaults to `None` and its predicates agree.
548    #[test]
549    fn option_audio_frame_predicates() {
550        let none = OptionAudioFrame::default();
551        assert!(none.is_none());
552        assert!(!none.is_some());
553        assert!(none.as_ref().is_none());
554        assert!(none.as_option().is_none());
555
556        let some: OptionAudioFrame = Some(frame(48_000, 2, vec![0.0, 1.0])).into();
557        assert!(some.is_some());
558        assert!(!some.is_none());
559        assert_eq!(some.as_ref().map(AudioFrame::frame_count), Some(1));
560    }
561
562    /// edge_access: `into_option` clones out of a `&self`, so calling it twice must
563    /// yield two equal, independently-owned frames (no move-out / double-free).
564    #[test]
565    fn option_audio_frame_into_option_twice() {
566        let wrapped: OptionAudioFrame = Some(frame(48_000, 2, vec![0.0, 0.5, -0.5, 1.0])).into();
567        let a = wrapped.into_option().expect("some");
568        let b = wrapped.into_option().expect("some");
569        assert_eq!(a, b);
570        assert_eq!(a.frame_count(), 2);
571        drop(a);
572        // `b` and `wrapped` must still be intact after `a` is dropped.
573        assert_eq!(b.frame_count(), 2);
574        assert!(wrapped.is_some());
575    }
576
577    /// invariants_hold: `replace` returns the previous value (mem::replace semantics)
578    /// and installs the new one.
579    #[test]
580    fn option_audio_frame_replace_returns_previous() {
581        let mut slot = OptionAudioFrame::None;
582        let prev = slot.replace(frame(48_000, 1, vec![0.0, 0.1]));
583        assert!(prev.is_none());
584        assert_eq!(slot.as_ref().map(AudioFrame::frame_count), Some(2));
585
586        let prev = slot.replace(frame(48_000, 2, vec![0.0, 0.1, 0.2, 0.3]));
587        assert_eq!(prev.as_ref().map(AudioFrame::frame_count), Some(2));
588        assert_eq!(slot.as_ref().map(AudioFrame::frame_count), Some(2));
589        assert_eq!(slot.as_ref().map(|f| f.channels), Some(2));
590    }
591
592    /// edge_access: `as_mut` hands out a live borrow — mutating the samples through it
593    /// must be visible in the wrapper's `frame_count`.
594    #[test]
595    fn option_audio_frame_as_mut_mutation_visible() {
596        let mut slot: OptionAudioFrame = Some(frame(48_000, 2, vec![0.0, 0.1, 0.2, 0.3])).into();
597        assert_eq!(slot.as_ref().map(AudioFrame::frame_count), Some(2));
598
599        if let Some(f) = slot.as_mut() {
600            f.channels = 0; // divide-by-zero guard must engage through the wrapper too
601        }
602        assert_eq!(slot.as_ref().map(AudioFrame::frame_count), Some(0));
603
604        if let Some(f) = slot.as_mut() {
605            f.channels = 1;
606        }
607        assert_eq!(slot.as_ref().map(AudioFrame::frame_count), Some(4));
608    }
609
610    /// round-trip: an empty-sample frame survives the `Option` <-> FFI-option
611    /// round-trip (the zero-length vec is the likeliest null-ptr edge).
612    #[test]
613    fn option_audio_frame_round_trip_empty_samples() {
614        let f = frame(48_000, 2, Vec::new());
615        let wrapped: OptionAudioFrame = Some(f.clone()).into();
616        let back: Option<AudioFrame> = wrapped.into();
617        let back = back.expect("some");
618        assert_eq!(back, f);
619        assert_eq!(back.frame_count(), 0);
620        assert!(back.samples.is_empty());
621    }
622}