Skip to main content

kithara_decode/factory/
inner.rs

1use std::{
2    io::{Read, Seek},
3    sync::{Arc, atomic::AtomicU64},
4};
5
6use bon::Builder;
7use kithara_bufpool::{BytePool, PcmPool};
8use kithara_stream::{AudioCodec, ContainerFormat, MediaInfo, SegmentLayout, SharedHooks};
9
10use super::probe::{
11    ProbeHint, codec_from_mp4_fourcc, container_from_extension, probe_codec,
12    resolve_codec_container,
13};
14use crate::{
15    Decoder,
16    error::{DecodeError, DecodeResult},
17    mp4::sniff_mp4_codec,
18    traits::BoxedSource,
19};
20
21/// Explicit backend selection for [`DecoderFactory`].
22///
23/// Replaces the legacy boolean `prefer_hardware` flag with a typed
24/// enum so callers spell out which backend they want. Failures of the
25/// selected backend are terminal — there is no fallback chain.
26///
27/// Variants are gated on cargo features: a hardware variant exists in
28/// the type only when its platform feature is enabled (and only on a
29/// matching `target_os`). Picking `DecoderBackend::Apple` on Linux is
30/// therefore a compile error, not a runtime `BackendUnavailable`.
31///
32/// Default = [`DecoderBackend::Symphonia`]: the software path is
33/// cross-platform and capability-complete (gapless seek, full
34/// `StreamContext` propagation). Hardware backends (`Apple`/`Android`)
35/// are opt-in — there is no runtime fallback.
36#[non_exhaustive]
37#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
38pub enum DecoderBackend {
39    /// Apple `AudioToolbox` (macOS/iOS, requires the `apple` feature).
40    #[cfg(all(feature = "apple", any(target_os = "macos", target_os = "ios")))]
41    #[cfg_attr(
42        all(
43            not(feature = "symphonia"),
44            feature = "apple",
45            any(target_os = "macos", target_os = "ios")
46        ),
47        default
48    )]
49    Apple,
50    /// Android `MediaCodec` (Android, requires the `android` feature).
51    #[cfg(all(feature = "android", target_os = "android"))]
52    #[cfg_attr(
53        all(
54            not(feature = "symphonia"),
55            feature = "android",
56            target_os = "android",
57            not(all(feature = "apple", any(target_os = "macos", target_os = "ios")))
58        ),
59        default
60    )]
61    Android,
62    /// Symphonia software decoder (cross-platform, requires the
63    /// `symphonia` feature).
64    #[cfg(feature = "symphonia")]
65    #[default]
66    Symphonia,
67}
68
69impl std::fmt::Display for DecoderBackend {
70    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
71        match self {
72            #[cfg(all(feature = "apple", any(target_os = "macos", target_os = "ios")))]
73            Self::Apple => f.write_str("apple"),
74            #[cfg(all(feature = "android", target_os = "android"))]
75            Self::Android => f.write_str("android"),
76            #[cfg(feature = "symphonia")]
77            Self::Symphonia => f.write_str("symphonia"),
78        }
79    }
80}
81
82/// Configuration for `DecoderFactory`.
83///
84/// `pcm_pool` / `byte_pool` are intentionally `Option<_>` — the
85/// production discipline is to propagate one pool down the entire host
86/// chain (player → `AudioConfig` → `DecoderConfig`). The `None` arm is
87/// a last-resort fallback for unit tests that don't care about pool
88/// budgets and for legacy call sites that haven't been threaded yet;
89/// it routes to the process-global `PcmPool::default()` /
90/// `BytePool::default()`. Don't construct fresh `PcmPool::new` / `BytePool::new`
91/// inside library components — that fragments the heap into many small
92/// per-component pools and defeats recycling.
93#[derive(Clone, Builder)]
94#[builder(state_mod(vis = "pub"))]
95#[non_exhaustive]
96pub struct DecoderConfig {
97    /// Which decoder backend to use. See [`DecoderBackend`].
98    #[builder(default)]
99    pub backend: DecoderBackend,
100    /// Handle for dynamic byte length updates (HLS).
101    pub byte_len_handle: Option<Arc<AtomicU64>>,
102    /// Raw byte buffer pool, propagated from the host. `None` falls
103    /// back to `BytePool::default()`.
104    pub byte_pool: Option<BytePool>,
105    /// File extension hint for Symphonia probe (e.g., "mp3", "aac").
106    pub hint: Option<String>,
107    /// Reader-side observer hooks. Forwarded into [`ComposedDecoder`]
108    /// directly.
109    pub hooks: Option<SharedHooks>,
110    /// PCM buffer pool, propagated from the host. `None` falls back to
111    /// `PcmPool::default()`.
112    pub pcm_pool: Option<PcmPool>,
113    /// Optional segment-layout handle over the underlying source.
114    pub segment_layout: Option<Arc<dyn SegmentLayout>>,
115    /// Enable gapless trim wiring through the per-backend codec.
116    #[builder(default = true)]
117    pub gapless: bool,
118    /// Epoch counter for decoder recreation tracking.
119    #[builder(default)]
120    pub epoch: u64,
121}
122
123impl Default for DecoderConfig {
124    fn default() -> Self {
125        Self::builder().build()
126    }
127}
128
129/// Factory for creating decoders with a single, strict backend selection.
130///
131/// Backend matrix (driven by [`DecoderConfig::backend`]):
132/// - [`DecoderBackend::Apple`] / [`DecoderBackend::Android`] — hardware
133///   backend, only present in the type when the matching feature and
134///   `target_os` are active. No runtime fallback.
135/// - [`DecoderBackend::Symphonia`] — software backend, present when
136///   the `symphonia` feature is enabled. No runtime fallback.
137pub struct DecoderFactory;
138
139impl DecoderFactory {
140    /// Create a decoder with the single selected backend.
141    pub(crate) fn create<R>(
142        source: R,
143        hint: &ProbeHint,
144        config: &DecoderConfig,
145    ) -> DecodeResult<Box<dyn Decoder>>
146    where
147        R: Read + Seek + Send + Sync + 'static,
148    {
149        let source: BoxedSource = Box::new(source);
150        Self::dispatch_backend(source, hint, config)
151    }
152
153    /// Create decoder from `MediaInfo` (kithara-audio entry point).
154    ///
155    /// Extracts codec from `MediaInfo` and creates the appropriate decoder.
156    ///
157    /// # Errors
158    ///
159    /// Returns error if codec cannot be determined or decoder creation fails.
160    /// No fallback — a failure is terminal.
161    pub fn create_from_media_info<R>(
162        source: R,
163        media_info: &MediaInfo,
164        config: &DecoderConfig,
165    ) -> DecodeResult<Box<dyn Decoder>>
166    where
167        R: Read + Seek + Send + Sync + 'static,
168    {
169        tracing::debug!(?media_info, "create_from_media_info called");
170
171        let hint = ProbeHint {
172            codec: media_info.codec,
173            container: media_info.container,
174            extension: None,
175            mime: None,
176        };
177
178        Self::create(source, &hint, config)
179    }
180
181    /// Create decoder from a file-extension hint.
182    ///
183    /// # Errors
184    ///
185    /// Returns `DecodeError::ProbeFailed` when the hint is missing or too
186    /// weak to pick a codec, and `DecodeError::*` for backend failures.
187    /// No fallback — callers must supply a usable hint.
188    pub fn create_with_probe<R>(
189        source: R,
190        hint: Option<&str>,
191        config: &DecoderConfig,
192    ) -> DecodeResult<Box<dyn Decoder>>
193    where
194        R: Read + Seek + Send + Sync + 'static,
195    {
196        let mut source = source;
197        let mut probe_hint = ProbeHint {
198            container: hint.and_then(container_from_extension),
199            extension: hint.map(String::from),
200            ..Default::default()
201        };
202
203        // `.m4a`/`.mp4` only identify the container — AAC, ALAC, and FLAC
204        // all live in MP4. Sniff the `stsd` sample-entry tag so e.g. a FLAC
205        // fMP4 segment is not misrouted to the AAC decoder path.
206        if matches!(
207            probe_hint.container,
208            Some(ContainerFormat::Mp4 | ContainerFormat::Fmp4)
209        ) && let Some(codec) = sniff_mp4_codec(&mut source).and_then(codec_from_mp4_fourcc)
210        {
211            probe_hint.codec = Some(codec);
212        }
213
214        probe_codec(&probe_hint)?;
215        Self::create(source, &probe_hint, config)
216    }
217
218    pub(super) fn dispatch_backend(
219        source: BoxedSource,
220        hint: &ProbeHint,
221        config: &DecoderConfig,
222    ) -> DecodeResult<Box<dyn Decoder>> {
223        let (codec, container) = resolve_codec_container(hint)?;
224
225        tracing::debug!(
226            ?codec,
227            ?container,
228            backend = ?config.backend,
229            "DecoderFactory::create called"
230        );
231
232        match config.backend {
233            #[cfg(all(feature = "apple", any(target_os = "macos", target_os = "ios")))]
234            DecoderBackend::Apple => create_apple(source, codec, container, config),
235            #[cfg(all(feature = "android", target_os = "android"))]
236            DecoderBackend::Android => create_android(source, codec, container, config),
237            #[cfg(feature = "symphonia")]
238            DecoderBackend::Symphonia => create_symphonia(source, codec, container, config),
239        }
240    }
241}
242
243#[cfg(all(feature = "apple", any(target_os = "macos", target_os = "ios")))]
244fn create_apple(
245    source: BoxedSource,
246    codec: AudioCodec,
247    container: Option<ContainerFormat>,
248    config: &DecoderConfig,
249) -> DecodeResult<Box<dyn Decoder>> {
250    use crate::apple::AppleCodec;
251
252    if should_use_segment_aware(codec, container, config)
253        && let Some(layout) = config.segment_layout.clone()
254    {
255        if AppleCodec::supports(codec) {
256            tracing::debug!(
257                ?codec,
258                "fmp4_segment: dispatching to segment-aware Apple HW codec path"
259            );
260            let gapless = config.gapless;
261            return build_fmp4_segment_decoder(source, layout, config, |track| {
262                AppleCodec::open_with_config(track, gapless)
263            });
264        }
265        #[cfg(feature = "symphonia")]
266        return create_fmp4_segment_symphonia(source, codec, layout, config);
267        #[cfg(not(feature = "symphonia"))]
268        {
269            let _ = layout;
270            return Err(DecodeError::UnsupportedCodec(codec));
271        }
272    }
273
274    if apple_standalone_supports(codec, container) {
275        tracing::debug!(
276            ?codec,
277            ?container,
278            "apple-standalone: routing via AudioFileServices"
279        );
280        return build_apple_standalone_decoder(source, codec, container, config);
281    }
282
283    #[cfg(feature = "symphonia")]
284    return create_symphonia(source, codec, container, config);
285    #[cfg(not(feature = "symphonia"))]
286    {
287        let _ = (source, container, config);
288        Err(DecodeError::UnsupportedCodec(codec))
289    }
290}
291
292#[cfg(all(feature = "apple", any(target_os = "macos", target_os = "ios")))]
293fn apple_standalone_supports(codec: AudioCodec, container: Option<ContainerFormat>) -> bool {
294    crate::apple::AppleAudioFileDemuxer::supports(codec, container)
295}
296
297#[cfg(all(feature = "apple", any(target_os = "macos", target_os = "ios")))]
298fn build_apple_standalone_decoder(
299    mut source: BoxedSource,
300    codec: AudioCodec,
301    container: Option<ContainerFormat>,
302    config: &DecoderConfig,
303) -> DecodeResult<Box<dyn Decoder>> {
304    use crate::{
305        apple::{AppleAudioFileDemuxer, AppleCodec},
306        composed::ComposedDecoder,
307        demuxer::Demuxer,
308        gapless::scoped_probe,
309    };
310    let probed_gapless = if config.gapless {
311        scoped_probe(&mut *source, codec)?
312    } else {
313        None
314    };
315    let mut demuxer = AppleAudioFileDemuxer::open_for(source, codec, container)?;
316    if probed_gapless.is_some() {
317        demuxer.set_gapless(probed_gapless);
318    }
319    let codec_impl = AppleCodec::open_with_config(demuxer.track_info(), config.gapless)?;
320    let pool = config
321        .pcm_pool
322        .clone()
323        .unwrap_or_else(|| PcmPool::default().clone());
324    let decoder = ComposedDecoder::new(
325        demuxer,
326        codec_impl,
327        pool,
328        config.epoch,
329        config.byte_len_handle.clone(),
330        config.hooks.clone(),
331    );
332    Ok(Box::new(decoder))
333}
334
335#[cfg(all(feature = "android", target_os = "android"))]
336fn create_android(
337    source: BoxedSource,
338    codec: AudioCodec,
339    container: Option<ContainerFormat>,
340    config: &DecoderConfig,
341) -> DecodeResult<Box<dyn Decoder>> {
342    use crate::android::AndroidCodec;
343
344    if should_use_segment_aware(codec, container, config)
345        && let Some(layout) = config.segment_layout.clone()
346    {
347        if AndroidCodec::supports(codec) {
348            tracing::debug!(
349                ?codec,
350                "fmp4_segment: dispatching to segment-aware Android HW codec path"
351            );
352            return build_fmp4_segment_decoder(source, layout, config, |track| {
353                AndroidCodec::open_with_config(track)
354            });
355        }
356        #[cfg(feature = "symphonia")]
357        return create_fmp4_segment_symphonia(source, codec, layout, config);
358        #[cfg(not(feature = "symphonia"))]
359        {
360            let _ = layout;
361            return Err(DecodeError::UnsupportedCodec(codec));
362        }
363    }
364
365    if android_standalone_supports(codec, container) {
366        tracing::debug!(
367            ?codec,
368            ?container,
369            "android-standalone: routing via AMediaExtractor"
370        );
371        return build_android_standalone_decoder(source, codec, container, config);
372    }
373
374    #[cfg(feature = "symphonia")]
375    return create_symphonia(source, codec, container, config);
376    #[cfg(not(feature = "symphonia"))]
377    {
378        let _ = (source, container, config);
379        Err(DecodeError::UnsupportedCodec(codec))
380    }
381}
382
383#[cfg(all(feature = "android", target_os = "android"))]
384fn android_standalone_supports(codec: AudioCodec, container: Option<ContainerFormat>) -> bool {
385    matches!(
386        (codec, container),
387        (AudioCodec::Pcm, Some(ContainerFormat::Wav))
388            | (AudioCodec::Mp3, Some(ContainerFormat::MpegAudio))
389            | (AudioCodec::Alac, Some(ContainerFormat::Mp4))
390    )
391}
392
393#[cfg(all(feature = "android", target_os = "android"))]
394fn build_android_standalone_decoder(
395    source: BoxedSource,
396    codec: AudioCodec,
397    container: Option<ContainerFormat>,
398    config: &DecoderConfig,
399) -> DecodeResult<Box<dyn Decoder>> {
400    use crate::{
401        android::{AndroidCodec, AndroidMediaExtractorDemuxer},
402        composed::ComposedDecoder,
403        demuxer::Demuxer,
404    };
405    let demuxer = match (codec, container) {
406        (AudioCodec::Pcm, Some(ContainerFormat::Wav)) => {
407            AndroidMediaExtractorDemuxer::open_wav(source)?
408        }
409        (AudioCodec::Mp3, Some(ContainerFormat::MpegAudio)) => {
410            AndroidMediaExtractorDemuxer::open_mp3(source)?
411        }
412        (AudioCodec::Alac, Some(ContainerFormat::Mp4)) => {
413            AndroidMediaExtractorDemuxer::open_alac_m4a(source)?
414        }
415        _ => return Err(DecodeError::UnsupportedCodec(codec)),
416    };
417    let codec_impl = AndroidCodec::open_with_config(demuxer.track_info())?;
418    let pool = config
419        .pcm_pool
420        .clone()
421        .unwrap_or_else(|| PcmPool::default().clone());
422    let decoder = ComposedDecoder::new(
423        demuxer,
424        codec_impl,
425        pool,
426        config.epoch,
427        config.byte_len_handle.clone(),
428        config.hooks.clone(),
429    );
430    Ok(Box::new(decoder))
431}
432
433#[cfg(feature = "symphonia")]
434fn create_symphonia(
435    source: BoxedSource,
436    codec: AudioCodec,
437    container: Option<ContainerFormat>,
438    config: &DecoderConfig,
439) -> DecodeResult<Box<dyn Decoder>> {
440    if should_use_segment_aware(codec, container, config)
441        && let Some(layout) = config.segment_layout.clone()
442    {
443        return create_fmp4_segment_symphonia(source, codec, layout, config);
444    }
445    create_file_symphonia_universal(source, codec, container, config)
446}
447
448#[cfg(feature = "symphonia")]
449fn create_file_symphonia_universal(
450    mut source: BoxedSource,
451    codec: AudioCodec,
452    container: Option<ContainerFormat>,
453    config: &DecoderConfig,
454) -> DecodeResult<Box<dyn Decoder>> {
455    use crate::{
456        composed::ComposedDecoder,
457        demuxer::Demuxer,
458        gapless::scoped_probe,
459        symphonia::{SymphoniaCodec, SymphoniaConfig, SymphoniaDemuxer},
460    };
461
462    tracing::debug!(
463        ?codec,
464        ?container,
465        "file-symphonia: dispatching to ComposedDecoder<SymphoniaDemuxer, SymphoniaCodec>"
466    );
467
468    let probed_gapless = if config.gapless {
469        scoped_probe(&mut *source, codec)?
470    } else {
471        None
472    };
473
474    let (mut demuxer, _byte_len) = SymphoniaDemuxer::open_file(
475        source,
476        config.hint.clone(),
477        container,
478        config.byte_len_handle.clone(),
479        config.segment_layout.clone(),
480    )?;
481    if probed_gapless.is_some() {
482        demuxer.set_gapless(probed_gapless);
483    }
484    let symphonia_config = SymphoniaConfig {
485        gapless: config.gapless,
486        ..Default::default()
487    };
488    let codec_impl = if SymphoniaCodec::supports(codec) {
489        SymphoniaCodec::open_with_config(demuxer.track_info(), &symphonia_config)?
490    } else {
491        SymphoniaCodec::open_native(demuxer.native_params())?
492    };
493    let pool = config
494        .pcm_pool
495        .clone()
496        .unwrap_or_else(|| PcmPool::default().clone());
497    let decoder = ComposedDecoder::new(
498        demuxer,
499        codec_impl,
500        pool,
501        config.epoch,
502        config.byte_len_handle.clone(),
503        config.hooks.clone(),
504    );
505    Ok(Box::new(decoder))
506}
507
508/// Gate for the segment-aware fMP4 path. Routes AAC / FLAC fMP4 with a
509/// surfaced `SegmentedSource` (HLS) through `Fmp4SegmentDecoder`. File
510/// sources without segment metadata fall through to the legacy
511/// `IsoMp4Reader` path.
512fn should_use_segment_aware(
513    codec: AudioCodec,
514    container: Option<ContainerFormat>,
515    config: &DecoderConfig,
516) -> bool {
517    matches!(
518        codec,
519        AudioCodec::AacLc | AudioCodec::AacHe | AudioCodec::AacHeV2 | AudioCodec::Flac
520    ) && matches!(container, Some(ContainerFormat::Fmp4))
521        && config.segment_layout.is_some()
522}
523
524#[cfg(feature = "symphonia")]
525fn create_fmp4_segment_symphonia(
526    source: BoxedSource,
527    codec: AudioCodec,
528    layout: Arc<dyn SegmentLayout>,
529    config: &DecoderConfig,
530) -> DecodeResult<Box<dyn Decoder>> {
531    use crate::symphonia::{SymphoniaCodec, SymphoniaConfig};
532
533    tracing::debug!(
534        ?codec,
535        "fmp4_segment: dispatching to segment-aware Symphonia path"
536    );
537    match codec {
538        AudioCodec::AacLc | AudioCodec::AacHe | AudioCodec::AacHeV2 | AudioCodec::Flac => {
539            let symphonia_config = SymphoniaConfig {
540                gapless: config.gapless,
541                ..Default::default()
542            };
543            build_fmp4_segment_decoder(source, layout, config, |track| {
544                SymphoniaCodec::open_with_config(track, &symphonia_config)
545            })
546        }
547        other => Err(DecodeError::UnsupportedCodec(other)),
548    }
549}
550
551/// Generic builder for the segment-aware fMP4 path. Owns the
552/// [`Fmp4SegmentDemuxer`] open + pool-resolution + [`ComposedDecoder`]
553/// boilerplate so apple/android/symphonia call-sites collapse into a
554/// single closure that opens the codec from `TrackInfo`.
555fn build_fmp4_segment_decoder<C, F>(
556    source: BoxedSource,
557    layout: Arc<dyn SegmentLayout>,
558    config: &DecoderConfig,
559    open_codec: F,
560) -> DecodeResult<Box<dyn Decoder>>
561where
562    C: crate::codec::FrameCodec + 'static,
563    F: FnOnce(&crate::demuxer::TrackInfo) -> DecodeResult<C>,
564{
565    use crate::{composed::ComposedDecoder, demuxer::Demuxer, fmp4::Fmp4SegmentDemuxer};
566
567    let demuxer = Fmp4SegmentDemuxer::open(source, layout)?;
568    let codec = open_codec(demuxer.track_info())?;
569    let pool = config
570        .pcm_pool
571        .clone()
572        .unwrap_or_else(|| PcmPool::default().clone());
573    let decoder = ComposedDecoder::new(
574        demuxer,
575        codec,
576        pool,
577        config.epoch,
578        config.byte_len_handle.clone(),
579        config.hooks.clone(),
580    );
581    Ok(Box::new(decoder))
582}