Skip to main content

kithara_decode/
types.rs

1use std::{fmt, sync::Arc, time::Duration};
2
3use kithara_bufpool::{PcmBuf, PcmPool};
4
5use crate::gapless::GaplessInfo;
6
7/// Decoder-owned per-track playback contract.
8///
9/// `#[non_exhaustive]` because callers in this crate construct it by
10/// `..Default::default()` spread and additional fields (e.g. encoder
11/// delay metadata, container-level flags) are expected to land here in
12/// follow-up port commits.
13#[derive(Debug, Clone, Default, PartialEq, Eq)]
14#[non_exhaustive]
15pub struct DecoderTrackInfo {
16    /// Gapless trim information applied by the engine pipeline.
17    pub gapless: Option<GaplessInfo>,
18}
19
20/// Audio track metadata extracted from Symphonia tags.
21///
22/// Intentionally without `#[non_exhaustive]` — this is a stable POD of
23/// optional tag fields, constructed via direct struct literal in
24/// downstream test/processor code; future additions go through
25/// `Default::default()` spread.
26#[derive(Debug, Clone, Default)]
27pub struct TrackMetadata {
28    /// Album name.
29    pub album: Option<String>,
30    /// Artist name.
31    pub artist: Option<String>,
32    /// Album artwork (JPEG/PNG bytes).
33    pub artwork: Option<Arc<Vec<u8>>>,
34    /// Track title.
35    pub title: Option<String>,
36}
37
38/// PCM specification - core audio format information
39///
40/// Intentionally without `#[non_exhaustive]`: this is a stable POD pair
41/// (`channels`, `sample_rate`) at the heart of every audio API in the
42/// workspace, constructed via direct struct literal at >100 call sites.
43/// Adding fields would force a workspace-wide migration regardless of
44/// non-exhaustiveness, so the marker buys nothing.
45#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
46pub struct PcmSpec {
47    pub channels: u16,
48    pub sample_rate: u32,
49}
50
51impl fmt::Display for PcmSpec {
52    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
53        write!(f, "{} Hz, {} channels", self.sample_rate, self.channels)
54    }
55}
56
57impl From<&PcmMeta> for kithara_stream::ChunkPosition {
58    fn from(meta: &PcmMeta) -> Self {
59        Self {
60            sample_rate: meta.spec.sample_rate,
61            frame_offset: meta.frame_offset,
62            frames: u64::from(meta.frames),
63            source_bytes: meta.source_bytes,
64            source_byte_offset: meta.source_byte_offset,
65            end_position_ns: u64::try_from(meta.end_timestamp.as_nanos()).unwrap_or(u64::MAX),
66        }
67    }
68}
69
70/// Timeline metadata for a PCM chunk.
71///
72/// Combines audio format specification with position on the logical timeline.
73/// Each chunk gets unique timeline coordinates; `PcmSpec` is the static part.
74///
75/// Intentionally without `#[non_exhaustive]`: external crates construct
76/// it via `PcmMeta { spec, ..Default::default() }` for fixtures; the
77/// pattern survives field additions, and `non_exhaustive` would block
78/// the struct-literal idiom altogether.
79#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
80pub struct PcmMeta {
81    /// Wall-clock position **after** this chunk's frames have played
82    /// out, computed by the decoder from its own frame counter. Used
83    /// by `Timeline::advance_committed_chunk` to update the playhead
84    /// without re-doing `frames * 1e9 / sample_rate` arithmetic on the
85    /// consumer side. For frame-based decoders (MP3 / AAC) the last
86    /// chunk may legitimately push this a few ms past the rounded
87    /// `total_duration`; the timeline clamps to duration on write.
88    pub end_timestamp: Duration,
89    /// Timestamp of the first frame in this chunk.
90    pub timestamp: Duration,
91    /// Segment index within playlist (`None` for progressive files).
92    pub segment_index: Option<u32>,
93    /// Absolute byte offset of this chunk's source data within the input
94    /// stream, when the decoder reports it. Apple's `AudioFile` exposes
95    /// this via `AudioStreamPacketDescription.mStartOffset`; other
96    /// backends (Symphonia, Android `MediaExtractor`) do not surface
97    /// per-packet byte offsets through their public API and leave this
98    /// `None`. When present, downstream code can pin the chunk to an
99    /// exact byte range without recomputing rate × time.
100    pub source_byte_offset: Option<u64>,
101    /// Variant/quality level index (`None` for progressive files).
102    pub variant_index: Option<usize>,
103    /// Audio format (channels, sample rate).
104    pub spec: PcmSpec,
105    /// Number of audio frames this chunk represents (one frame =
106    /// `spec.channels` interleaved samples). Decoder fills it from the
107    /// output buffer length; consumer-side splits update it in place
108    /// when slicing a chunk into consumed/remaining halves.
109    pub frames: u32,
110    /// Decoder generation — increments on each ABR switch / decoder recreation.
111    pub epoch: u64,
112    /// Absolute frame offset from the start of the track.
113    pub frame_offset: u64,
114    /// Number of source-stream bytes that produced this chunk's PCM, as
115    /// reported by the underlying decoder packet (e.g. `Packet.data.len()`
116    /// for Symphonia, `mDataByteSize` for Apple `AudioConverter`,
117    /// `readSampleData` return for Android `MediaExtractor`).
118    ///
119    /// Lets the consumer correlate chunk frames with the source byte
120    /// position without recomputing rate × time externally — the decoder
121    /// already knows the exact mapping for variable-bitrate compressed
122    /// formats and arbitrary-sized PCM packets. `0` means "unknown" (mock
123    /// decoders, post-EOF flush chunks).
124    pub source_bytes: u64,
125}
126
127/// PCM chunk containing interleaved audio samples with automatic pool recycling.
128///
129/// The `pcm` buffer is pool-backed via [`PcmBuf`]: when the chunk is dropped,
130/// the buffer returns to the global PCM pool for reuse instead of being deallocated.
131///
132/// # Invariants
133/// - `pcm.len() % channels == 0` (frame-aligned)
134/// - `spec.channels > 0` and `spec.sample_rate > 0`
135/// - All samples are f32 and interleaved (LRLRLR...)
136#[derive(Debug)]
137pub struct PcmChunk {
138    pub pcm: PcmBuf,
139    pub meta: PcmMeta,
140}
141
142impl Default for PcmChunk {
143    fn default() -> Self {
144        Self {
145            pcm: PcmPool::default().get(),
146            meta: PcmMeta::default(),
147        }
148    }
149}
150
151impl Clone for PcmChunk {
152    /// Clone creates a new pool-backed buffer with copied samples.
153    ///
154    /// Each clone gets its own [`PcmBuf`] from the global pool,
155    /// so both original and clone recycle independently on drop.
156    fn clone(&self) -> Self {
157        let mut new_pcm = PcmPool::default().get();
158        new_pcm.extend_from_slice(&self.pcm);
159        Self {
160            pcm: new_pcm,
161            meta: self.meta,
162        }
163    }
164}
165
166impl PcmChunk {
167    /// Create a new `PcmChunk` from a pool-backed buffer.
168    #[must_use]
169    pub fn new(meta: PcmMeta, pcm: PcmBuf) -> Self {
170        Self { pcm, meta }
171    }
172
173    /// Number of audio frames in this chunk.
174    ///
175    /// A frame contains one sample per channel.
176    #[must_use]
177    pub fn frames(&self) -> usize {
178        let channels = self.meta.spec.channels as usize;
179        self.pcm.len().checked_div(channels).unwrap_or(0)
180    }
181
182    /// Borrow the raw interleaved sample buffer.
183    ///
184    /// Sugar accessor for `&chunk.pcm[..]`; the underlying field stays
185    /// `pub` for the legacy direct-access call sites that currently rely
186    /// on `Deref<Target = [f32]>` semantics of `PcmBuf`.
187    #[must_use]
188    pub fn samples(&self) -> &[f32] {
189        &self.pcm
190    }
191
192    /// Audio format specification.
193    #[must_use]
194    pub fn spec(&self) -> PcmSpec {
195        self.meta.spec
196    }
197}
198
199impl AsRef<[f32]> for PcmChunk {
200    fn as_ref(&self) -> &[f32] {
201        &self.pcm
202    }
203}
204
205#[cfg(test)]
206mod tests {
207    use kithara_test_utils::kithara;
208
209    use super::*;
210
211    fn test_chunk(spec: PcmSpec, pcm: Vec<f32>) -> PcmChunk {
212        PcmChunk::new(
213            PcmMeta {
214                spec,
215                ..Default::default()
216            },
217            PcmPool::default().attach(pcm),
218        )
219    }
220
221    #[kithara::test]
222    #[case(44100, 2, "44100 Hz, 2 channels")]
223    #[case(48000, 1, "48000 Hz, 1 channels")]
224    #[case(96000, 6, "96000 Hz, 6 channels")]
225    #[case(192000, 8, "192000 Hz, 8 channels")]
226    #[case(0, 0, "0 Hz, 0 channels")]
227    fn test_pcm_spec_display(
228        #[case] sample_rate: u32,
229        #[case] channels: u16,
230        #[case] expected: &str,
231    ) {
232        let spec = PcmSpec {
233            channels,
234            sample_rate,
235        };
236        assert_eq!(format!("{}", spec), expected);
237    }
238
239    #[kithara::test]
240    fn test_pcm_spec_clone() {
241        let spec = PcmSpec {
242            channels: 2,
243            sample_rate: 44100,
244        };
245        let cloned = spec;
246        assert_eq!(spec, cloned);
247    }
248
249    #[kithara::test]
250    #[case(44100, 2, 44100, 2, true)]
251    #[case(44100, 2, 48000, 2, false)]
252    #[case(44100, 2, 44100, 1, false)]
253    #[case(0, 0, 0, 0, true)]
254    fn test_pcm_spec_partial_eq(
255        #[case] sr1: u32,
256        #[case] ch1: u16,
257        #[case] sr2: u32,
258        #[case] ch2: u16,
259        #[case] should_equal: bool,
260    ) {
261        let spec1 = PcmSpec {
262            channels: ch1,
263            sample_rate: sr1,
264        };
265        let spec2 = PcmSpec {
266            channels: ch2,
267            sample_rate: sr2,
268        };
269        assert_eq!(spec1 == spec2, should_equal);
270    }
271
272    #[kithara::test]
273    fn test_pcm_spec_debug() {
274        let spec = PcmSpec {
275            channels: 2,
276            sample_rate: 44100,
277        };
278        let debug_str = format!("{:?}", spec);
279        assert!(debug_str.contains("PcmSpec"));
280        assert!(debug_str.contains("44100"));
281        assert!(debug_str.contains("2"));
282    }
283
284    #[kithara::test]
285    #[case(44100, 2)]
286    #[case(48000, 1)]
287    #[case(96000, 6)]
288    fn test_pcm_spec_copy_trait(#[case] sample_rate: u32, #[case] channels: u16) {
289        let spec = PcmSpec {
290            channels,
291            sample_rate,
292        };
293        let copied = spec;
294        assert_eq!(spec, copied);
295    }
296
297    #[kithara::test]
298    fn test_pcm_meta_default() {
299        let meta = PcmMeta::default();
300        assert_eq!(meta.spec, PcmSpec::default());
301        assert_eq!(meta.frame_offset, 0);
302        assert_eq!(meta.timestamp, Duration::ZERO);
303        assert_eq!(meta.segment_index, None);
304        assert_eq!(meta.variant_index, None);
305        assert_eq!(meta.epoch, 0);
306    }
307
308    #[kithara::test]
309    fn test_pcm_meta_copy() {
310        let meta = PcmMeta {
311            spec: PcmSpec {
312                channels: 2,
313                sample_rate: 44100,
314            },
315            frame_offset: 1000,
316            timestamp: Duration::from_millis(22),
317            end_timestamp: Duration::from_millis(22),
318            segment_index: Some(5),
319            variant_index: Some(2),
320            epoch: 3,
321            frames: 0,
322            source_bytes: 0,
323            source_byte_offset: None,
324        };
325        let copied = meta;
326        assert_eq!(meta, copied);
327    }
328
329    #[kithara::test]
330    fn test_pcm_meta_with_spec() {
331        let spec = PcmSpec {
332            channels: 2,
333            sample_rate: 48000,
334        };
335        let meta = PcmMeta {
336            spec,
337            ..Default::default()
338        };
339        assert_eq!(meta.spec, spec);
340        assert_eq!(meta.frame_offset, 0);
341    }
342
343    #[kithara::test]
344    fn test_pcm_meta_partial_eq() {
345        let a = PcmMeta {
346            spec: PcmSpec {
347                channels: 2,
348                sample_rate: 44100,
349            },
350            frame_offset: 100,
351            timestamp: Duration::from_millis(2),
352            end_timestamp: Duration::from_millis(2),
353            segment_index: Some(1),
354            variant_index: Some(0),
355            epoch: 1,
356            frames: 0,
357            source_bytes: 0,
358            source_byte_offset: None,
359        };
360        let mut b = a;
361        assert_eq!(a, b);
362        b.frame_offset = 200;
363        assert_ne!(a, b);
364    }
365
366    #[kithara::test]
367    fn test_pcm_chunk_new() {
368        let spec = PcmSpec {
369            channels: 2,
370            sample_rate: 44100,
371        };
372        let pcm = vec![0.1f32, 0.2, 0.3, 0.4];
373        let chunk = test_chunk(spec, pcm.clone());
374
375        assert_eq!(chunk.spec(), spec);
376        assert_eq!(&chunk.pcm[..], &pcm[..]);
377    }
378
379    #[kithara::test]
380    #[case(vec![0.0, 1.0, 2.0, 3.0], 2, 2)]
381    #[case(vec![0.0, 1.0, 2.0, 3.0, 4.0, 5.0], 2, 3)]
382    #[case(vec![0.0], 1, 1)]
383    #[case(vec![0.0, 1.0, 2.0, 3.0, 4.0, 5.0], 6, 1)]
384    #[case(vec![], 2, 0)]
385    fn test_frames_calculation(
386        #[case] pcm: Vec<f32>,
387        #[case] channels: u16,
388        #[case] expected_frames: usize,
389    ) {
390        let spec = PcmSpec {
391            channels,
392            sample_rate: 44100,
393        };
394        let chunk = test_chunk(spec, pcm);
395        assert_eq!(chunk.frames(), expected_frames);
396    }
397
398    #[kithara::test]
399    fn test_frames_zero_channels() {
400        let spec = PcmSpec {
401            channels: 0,
402            sample_rate: 44100,
403        };
404        let chunk = test_chunk(spec, vec![0.0, 1.0, 2.0, 3.0]);
405        assert_eq!(chunk.frames(), 0);
406    }
407
408    #[kithara::test]
409    fn test_samples_access() {
410        let spec = PcmSpec {
411            channels: 2,
412            sample_rate: 44100,
413        };
414        let pcm = vec![0.1, 0.2, 0.3, 0.4];
415        let chunk = test_chunk(spec, pcm.clone());
416
417        let samples: &[f32] = &chunk.pcm;
418        assert_eq!(samples.len(), 4);
419        assert_eq!(samples, &pcm[..]);
420    }
421
422    #[kithara::test]
423    fn test_pcm_chunk_clone() {
424        let spec = PcmSpec {
425            channels: 2,
426            sample_rate: 44100,
427        };
428        let pcm = vec![0.1, 0.2, 0.3, 0.4];
429        let chunk = test_chunk(spec, pcm);
430        let cloned = chunk.clone();
431
432        assert_eq!(cloned.spec(), chunk.spec());
433        assert_eq!(cloned.pcm, chunk.pcm);
434    }
435
436    #[kithara::test]
437    fn test_pcm_chunk_debug() {
438        let spec = PcmSpec {
439            channels: 2,
440            sample_rate: 44100,
441        };
442        let pcm = vec![0.1f32, 0.2];
443        let chunk = test_chunk(spec, pcm);
444        let debug_str = format!("{:?}", chunk);
445
446        assert!(debug_str.contains("PcmChunk"));
447    }
448}