kithara-decode 0.0.1-alpha2

Pluggable audio decode (Symphonia / Apple / Android) to PCM.
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
use std::{fmt, sync::Arc, time::Duration};

use kithara_bufpool::{PcmBuf, PcmPool};

use crate::gapless::GaplessInfo;

/// Decoder-owned per-track playback contract.
///
/// `#[non_exhaustive]` because callers in this crate construct it by
/// `..Default::default()` spread and additional fields (e.g. encoder
/// delay metadata, container-level flags) are expected to land here in
/// follow-up port commits.
#[derive(Debug, Clone, Default, PartialEq, Eq)]
#[non_exhaustive]
pub struct DecoderTrackInfo {
    /// Gapless trim information applied by the engine pipeline.
    pub gapless: Option<GaplessInfo>,
}

/// Audio track metadata extracted from Symphonia tags.
///
/// Intentionally without `#[non_exhaustive]` — this is a stable POD of
/// optional tag fields, constructed via direct struct literal in
/// downstream test/processor code; future additions go through
/// `Default::default()` spread.
#[derive(Debug, Clone, Default)]
pub struct TrackMetadata {
    /// Album name.
    pub album: Option<String>,
    /// Artist name.
    pub artist: Option<String>,
    /// Album artwork (JPEG/PNG bytes).
    pub artwork: Option<Arc<Vec<u8>>>,
    /// Track title.
    pub title: Option<String>,
}

/// PCM specification - core audio format information
///
/// Intentionally without `#[non_exhaustive]`: this is a stable POD pair
/// (`channels`, `sample_rate`) at the heart of every audio API in the
/// workspace, constructed via direct struct literal at >100 call sites.
/// Adding fields would force a workspace-wide migration regardless of
/// non-exhaustiveness, so the marker buys nothing.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub struct PcmSpec {
    pub channels: u16,
    pub sample_rate: u32,
}

impl fmt::Display for PcmSpec {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        write!(f, "{} Hz, {} channels", self.sample_rate, self.channels)
    }
}

impl From<&PcmMeta> for kithara_stream::ChunkPosition {
    fn from(meta: &PcmMeta) -> Self {
        Self {
            sample_rate: meta.spec.sample_rate,
            frame_offset: meta.frame_offset,
            frames: u64::from(meta.frames),
            source_bytes: meta.source_bytes,
            source_byte_offset: meta.source_byte_offset,
            end_position_ns: u64::try_from(meta.end_timestamp.as_nanos()).unwrap_or(u64::MAX),
        }
    }
}

/// Timeline metadata for a PCM chunk.
///
/// Combines audio format specification with position on the logical timeline.
/// Each chunk gets unique timeline coordinates; `PcmSpec` is the static part.
///
/// Intentionally without `#[non_exhaustive]`: external crates construct
/// it via `PcmMeta { spec, ..Default::default() }` for fixtures; the
/// pattern survives field additions, and `non_exhaustive` would block
/// the struct-literal idiom altogether.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub struct PcmMeta {
    /// Wall-clock position **after** this chunk's frames have played
    /// out, computed by the decoder from its own frame counter. Used
    /// by `Timeline::advance_committed_chunk` to update the playhead
    /// without re-doing `frames * 1e9 / sample_rate` arithmetic on the
    /// consumer side. For frame-based decoders (MP3 / AAC) the last
    /// chunk may legitimately push this a few ms past the rounded
    /// `total_duration`; the timeline clamps to duration on write.
    pub end_timestamp: Duration,
    /// Timestamp of the first frame in this chunk.
    pub timestamp: Duration,
    /// Segment index within playlist (`None` for progressive files).
    pub segment_index: Option<u32>,
    /// Absolute byte offset of this chunk's source data within the input
    /// stream, when the decoder reports it. Apple's `AudioFile` exposes
    /// this via `AudioStreamPacketDescription.mStartOffset`; other
    /// backends (Symphonia, Android `MediaExtractor`) do not surface
    /// per-packet byte offsets through their public API and leave this
    /// `None`. When present, downstream code can pin the chunk to an
    /// exact byte range without recomputing rate × time.
    pub source_byte_offset: Option<u64>,
    /// Variant/quality level index (`None` for progressive files).
    pub variant_index: Option<usize>,
    /// Audio format (channels, sample rate).
    pub spec: PcmSpec,
    /// Number of audio frames this chunk represents (one frame =
    /// `spec.channels` interleaved samples). Decoder fills it from the
    /// output buffer length; consumer-side splits update it in place
    /// when slicing a chunk into consumed/remaining halves.
    pub frames: u32,
    /// Decoder generation — increments on each ABR switch / decoder recreation.
    pub epoch: u64,
    /// Absolute frame offset from the start of the track.
    pub frame_offset: u64,
    /// Number of source-stream bytes that produced this chunk's PCM, as
    /// reported by the underlying decoder packet (e.g. `Packet.data.len()`
    /// for Symphonia, `mDataByteSize` for Apple `AudioConverter`,
    /// `readSampleData` return for Android `MediaExtractor`).
    ///
    /// Lets the consumer correlate chunk frames with the source byte
    /// position without recomputing rate × time externally — the decoder
    /// already knows the exact mapping for variable-bitrate compressed
    /// formats and arbitrary-sized PCM packets. `0` means "unknown" (mock
    /// decoders, post-EOF flush chunks).
    pub source_bytes: u64,
}

/// PCM chunk containing interleaved audio samples with automatic pool recycling.
///
/// The `pcm` buffer is pool-backed via [`PcmBuf`]: when the chunk is dropped,
/// the buffer returns to the global PCM pool for reuse instead of being deallocated.
///
/// # Invariants
/// - `pcm.len() % channels == 0` (frame-aligned)
/// - `spec.channels > 0` and `spec.sample_rate > 0`
/// - All samples are f32 and interleaved (LRLRLR...)
#[derive(Debug)]
pub struct PcmChunk {
    pub pcm: PcmBuf,
    pub meta: PcmMeta,
}

impl Default for PcmChunk {
    fn default() -> Self {
        Self {
            pcm: PcmPool::default().get(),
            meta: PcmMeta::default(),
        }
    }
}

impl Clone for PcmChunk {
    /// Clone creates a new pool-backed buffer with copied samples.
    ///
    /// Each clone gets its own [`PcmBuf`] from the global pool,
    /// so both original and clone recycle independently on drop.
    fn clone(&self) -> Self {
        let mut new_pcm = PcmPool::default().get();
        new_pcm.extend_from_slice(&self.pcm);
        Self {
            pcm: new_pcm,
            meta: self.meta,
        }
    }
}

impl PcmChunk {
    /// Create a new `PcmChunk` from a pool-backed buffer.
    #[must_use]
    pub fn new(meta: PcmMeta, pcm: PcmBuf) -> Self {
        Self { pcm, meta }
    }

    /// Number of audio frames in this chunk.
    ///
    /// A frame contains one sample per channel.
    #[must_use]
    pub fn frames(&self) -> usize {
        let channels = self.meta.spec.channels as usize;
        self.pcm.len().checked_div(channels).unwrap_or(0)
    }

    /// Borrow the raw interleaved sample buffer.
    ///
    /// Sugar accessor for `&chunk.pcm[..]`; the underlying field stays
    /// `pub` for the legacy direct-access call sites that currently rely
    /// on `Deref<Target = [f32]>` semantics of `PcmBuf`.
    #[must_use]
    pub fn samples(&self) -> &[f32] {
        &self.pcm
    }

    /// Audio format specification.
    #[must_use]
    pub fn spec(&self) -> PcmSpec {
        self.meta.spec
    }
}

impl AsRef<[f32]> for PcmChunk {
    fn as_ref(&self) -> &[f32] {
        &self.pcm
    }
}

#[cfg(test)]
mod tests {
    use kithara_test_utils::kithara;

    use super::*;

    fn test_chunk(spec: PcmSpec, pcm: Vec<f32>) -> PcmChunk {
        PcmChunk::new(
            PcmMeta {
                spec,
                ..Default::default()
            },
            PcmPool::default().attach(pcm),
        )
    }

    #[kithara::test]
    #[case(44100, 2, "44100 Hz, 2 channels")]
    #[case(48000, 1, "48000 Hz, 1 channels")]
    #[case(96000, 6, "96000 Hz, 6 channels")]
    #[case(192000, 8, "192000 Hz, 8 channels")]
    #[case(0, 0, "0 Hz, 0 channels")]
    fn test_pcm_spec_display(
        #[case] sample_rate: u32,
        #[case] channels: u16,
        #[case] expected: &str,
    ) {
        let spec = PcmSpec {
            channels,
            sample_rate,
        };
        assert_eq!(format!("{}", spec), expected);
    }

    #[kithara::test]
    fn test_pcm_spec_clone() {
        let spec = PcmSpec {
            channels: 2,
            sample_rate: 44100,
        };
        let cloned = spec;
        assert_eq!(spec, cloned);
    }

    #[kithara::test]
    #[case(44100, 2, 44100, 2, true)]
    #[case(44100, 2, 48000, 2, false)]
    #[case(44100, 2, 44100, 1, false)]
    #[case(0, 0, 0, 0, true)]
    fn test_pcm_spec_partial_eq(
        #[case] sr1: u32,
        #[case] ch1: u16,
        #[case] sr2: u32,
        #[case] ch2: u16,
        #[case] should_equal: bool,
    ) {
        let spec1 = PcmSpec {
            channels: ch1,
            sample_rate: sr1,
        };
        let spec2 = PcmSpec {
            channels: ch2,
            sample_rate: sr2,
        };
        assert_eq!(spec1 == spec2, should_equal);
    }

    #[kithara::test]
    fn test_pcm_spec_debug() {
        let spec = PcmSpec {
            channels: 2,
            sample_rate: 44100,
        };
        let debug_str = format!("{:?}", spec);
        assert!(debug_str.contains("PcmSpec"));
        assert!(debug_str.contains("44100"));
        assert!(debug_str.contains("2"));
    }

    #[kithara::test]
    #[case(44100, 2)]
    #[case(48000, 1)]
    #[case(96000, 6)]
    fn test_pcm_spec_copy_trait(#[case] sample_rate: u32, #[case] channels: u16) {
        let spec = PcmSpec {
            channels,
            sample_rate,
        };
        let copied = spec;
        assert_eq!(spec, copied);
    }

    #[kithara::test]
    fn test_pcm_meta_default() {
        let meta = PcmMeta::default();
        assert_eq!(meta.spec, PcmSpec::default());
        assert_eq!(meta.frame_offset, 0);
        assert_eq!(meta.timestamp, Duration::ZERO);
        assert_eq!(meta.segment_index, None);
        assert_eq!(meta.variant_index, None);
        assert_eq!(meta.epoch, 0);
    }

    #[kithara::test]
    fn test_pcm_meta_copy() {
        let meta = PcmMeta {
            spec: PcmSpec {
                channels: 2,
                sample_rate: 44100,
            },
            frame_offset: 1000,
            timestamp: Duration::from_millis(22),
            end_timestamp: Duration::from_millis(22),
            segment_index: Some(5),
            variant_index: Some(2),
            epoch: 3,
            frames: 0,
            source_bytes: 0,
            source_byte_offset: None,
        };
        let copied = meta;
        assert_eq!(meta, copied);
    }

    #[kithara::test]
    fn test_pcm_meta_with_spec() {
        let spec = PcmSpec {
            channels: 2,
            sample_rate: 48000,
        };
        let meta = PcmMeta {
            spec,
            ..Default::default()
        };
        assert_eq!(meta.spec, spec);
        assert_eq!(meta.frame_offset, 0);
    }

    #[kithara::test]
    fn test_pcm_meta_partial_eq() {
        let a = PcmMeta {
            spec: PcmSpec {
                channels: 2,
                sample_rate: 44100,
            },
            frame_offset: 100,
            timestamp: Duration::from_millis(2),
            end_timestamp: Duration::from_millis(2),
            segment_index: Some(1),
            variant_index: Some(0),
            epoch: 1,
            frames: 0,
            source_bytes: 0,
            source_byte_offset: None,
        };
        let mut b = a;
        assert_eq!(a, b);
        b.frame_offset = 200;
        assert_ne!(a, b);
    }

    #[kithara::test]
    fn test_pcm_chunk_new() {
        let spec = PcmSpec {
            channels: 2,
            sample_rate: 44100,
        };
        let pcm = vec![0.1f32, 0.2, 0.3, 0.4];
        let chunk = test_chunk(spec, pcm.clone());

        assert_eq!(chunk.spec(), spec);
        assert_eq!(&chunk.pcm[..], &pcm[..]);
    }

    #[kithara::test]
    #[case(vec![0.0, 1.0, 2.0, 3.0], 2, 2)]
    #[case(vec![0.0, 1.0, 2.0, 3.0, 4.0, 5.0], 2, 3)]
    #[case(vec![0.0], 1, 1)]
    #[case(vec![0.0, 1.0, 2.0, 3.0, 4.0, 5.0], 6, 1)]
    #[case(vec![], 2, 0)]
    fn test_frames_calculation(
        #[case] pcm: Vec<f32>,
        #[case] channels: u16,
        #[case] expected_frames: usize,
    ) {
        let spec = PcmSpec {
            channels,
            sample_rate: 44100,
        };
        let chunk = test_chunk(spec, pcm);
        assert_eq!(chunk.frames(), expected_frames);
    }

    #[kithara::test]
    fn test_frames_zero_channels() {
        let spec = PcmSpec {
            channels: 0,
            sample_rate: 44100,
        };
        let chunk = test_chunk(spec, vec![0.0, 1.0, 2.0, 3.0]);
        assert_eq!(chunk.frames(), 0);
    }

    #[kithara::test]
    fn test_samples_access() {
        let spec = PcmSpec {
            channels: 2,
            sample_rate: 44100,
        };
        let pcm = vec![0.1, 0.2, 0.3, 0.4];
        let chunk = test_chunk(spec, pcm.clone());

        let samples: &[f32] = &chunk.pcm;
        assert_eq!(samples.len(), 4);
        assert_eq!(samples, &pcm[..]);
    }

    #[kithara::test]
    fn test_pcm_chunk_clone() {
        let spec = PcmSpec {
            channels: 2,
            sample_rate: 44100,
        };
        let pcm = vec![0.1, 0.2, 0.3, 0.4];
        let chunk = test_chunk(spec, pcm);
        let cloned = chunk.clone();

        assert_eq!(cloned.spec(), chunk.spec());
        assert_eq!(cloned.pcm, chunk.pcm);
    }

    #[kithara::test]
    fn test_pcm_chunk_debug() {
        let spec = PcmSpec {
            channels: 2,
            sample_rate: 44100,
        };
        let pcm = vec![0.1f32, 0.2];
        let chunk = test_chunk(spec, pcm);
        let debug_str = format!("{:?}", chunk);

        assert!(debug_str.contains("PcmChunk"));
    }
}