kithara-decode 0.0.1-alpha2

Pluggable audio decode (Symphonia / Apple / Android) to PCM.
Documentation
use std::{
    io::{ErrorKind, Read, Seek},
    num::NonZeroUsize,
    time::Duration,
};

use kithara_stream::{
    AudioCodec, NotReadyCause, PendingReason, PrerollHint, StreamPending, StreamReadError,
    VariantChangeError,
};

mod kithara {
    pub(crate) use kithara_test_macros::mock;
}

use crate::{
    error::DecodeResult,
    types::{PcmChunk, PcmSpec, TrackMetadata},
};

/// Outcome of a [`DecoderInput::try_read`] call.
///
/// Mirrors the [`kithara_stream::ReadOutcome`] shape but operates on
/// the input-byte plane. `Bytes` carries a [`NonZeroUsize`] count;
/// `Pending` carries the typed [`PendingReason`] recovered from
/// `Stream`'s `impl Read` (or synthesised from `io::ErrorKind` for
/// non-stream inputs); `Eof` is terminal.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum InputReadOutcome {
    Bytes(NonZeroUsize),
    Pending(PendingReason),
    Eof,
}

/// Outcome of a [`Decoder::seek`] call.
///
/// `Landed.landed_at` is the position the decoder actually parked at
/// (often the granule boundary nearest the requested target — never
/// the requested target itself unless it coincides). `PastEof` carries
/// the decoder's known total duration so the caller can park at EOF
/// without rounding.
// Not #[non_exhaustive]: `tests/src/decode_mock.rs` constructs variants by
// named-field syntax across crates; direct construction is part of the
// intended mock contract (AGENTS.md "small, obviously stable exception").
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum DecoderSeekOutcome {
    /// Decoder is now parked at `landed_at` / `landed_frame` /
    /// `landed_byte`. All three come from the decoder's own state
    /// and refer to the *same* point. `landed_byte`, when present,
    /// is the absolute byte offset in the underlying source where
    /// the next packet body begins — the pipeline plugs it into
    /// `Stream::seek` so we never recompute byte offsets from
    /// `frame × bytes_per_frame` heuristics on the consumer side.
    /// `None` is reserved for the rare case where the decoder
    /// successfully seeked but cannot expose a packet-aligned byte
    /// offset (e.g. `AudioFile` on a streaming MP3 whose seek-table
    /// is not yet built). For those, the pipeline relies on the
    /// producer-side `Stream::byte_position` updated by the
    /// decoder's own `Read::seek` calls — no extra arithmetic.
    Landed {
        landed_at: Duration,
        landed_frame: u64,
        landed_byte: Option<u64>,
        /// Hint from the demuxer: an earlier byte position the source must
        /// still hold so the decoder can warm MDCT overlap-add state before
        /// emitting user-visible samples. `NotNeeded` until a demuxer
        /// computes a meaningful pre-roll hint (file backend by byte offset
        /// back, HLS by prev segment start); consumed by the audio pipeline
        /// to warm MDCT overlap-add state before emitting user-visible
        /// chunks.
        preroll: PrerollHint,
    },
    /// Seek target was past the decoder's known duration. The decoder
    /// is parked at the end; the next `next_chunk` returns
    /// [`DecoderChunkOutcome::Eof`].
    PastEof { duration: Duration },
}

/// Outcome of an [`Decoder::next_chunk`] call.
///
/// Mirrors [`kithara_stream::ReadOutcome`] / [`InputReadOutcome`] in
/// shape so every layer of the pipeline carries the same three-way
/// distinction (`progress | pending | terminal`). `Pending` carries
/// the typed [`PendingReason`] — typically
/// [`PendingReason::SeekPending`] when an in-flight seek aborted the
/// underlying read, or [`PendingReason::NotReady`] when the source
/// signalled transient backpressure.
#[derive(Debug)]
pub enum DecoderChunkOutcome {
    /// Decoded PCM chunk.
    Chunk(PcmChunk),
    /// Decoder is alive but produced no chunk this call. See
    /// [`PendingReason`] for the precise cause.
    Pending(PendingReason),
    /// Natural end of stream — no more chunks will be produced.
    Eof,
}

impl DecoderChunkOutcome {
    /// `true` when the outcome is [`Self::Eof`].
    #[must_use]
    pub fn is_eof(&self) -> bool {
        matches!(self, Self::Eof)
    }
}

impl TryFrom<DecoderChunkOutcome> for PcmChunk {
    type Error = DecoderChunkOutcome;

    fn try_from(outcome: DecoderChunkOutcome) -> Result<Self, Self::Error> {
        match outcome {
            DecoderChunkOutcome::Chunk(chunk) => Ok(chunk),
            other => Err(other),
        }
    }
}

/// Combined trait for decoder input sources.
///
/// Supertrait combining `Read + Seek + Send + Sync`. Adds typed
/// [`try_read`] returning [`InputReadOutcome`] so decoders never
/// confuse "0 bytes" between EOF and `Pending(...)`.
/// `kithara_stream::Stream` packages its typed status (`SeekPending`,
/// `VariantChange`, `NotReady`/`Retry`) into `io::Error` payloads via
/// `impl Read for Stream`; the default `try_read` here downcasts those
/// payloads back into [`PendingReason`]. Arbitrary `Read + Seek`
/// sources (test cursors, fixtures) take the same default impl —
/// raw `io::Error` becomes [`StreamReadError::Source`], `Ok(0)` →
/// [`InputReadOutcome::Eof`].
pub trait DecoderInput: Read + Seek + Send + Sync {
    /// Typed read.
    ///
    /// # Errors
    ///
    /// Returns [`StreamReadError::Source`] for genuine source I/O
    /// failures. Status conditions (seek pending, variant change,
    /// data not ready) come back as `Ok(InputReadOutcome::Pending(...))`.
    fn try_read(&mut self, buf: &mut [u8]) -> Result<InputReadOutcome, StreamReadError> {
        match Read::read(self, buf) {
            Ok(0) => Ok(InputReadOutcome::Eof),
            Ok(n) => {
                Ok(NonZeroUsize::new(n).map_or(InputReadOutcome::Eof, InputReadOutcome::Bytes))
            }
            Err(e) => {
                let stream_pending = e
                    .get_ref()
                    .and_then(|src| src.downcast_ref::<StreamPending>())
                    .map(|p| p.reason);
                let pending = e
                    .get_ref()
                    .and_then(|src| src.downcast_ref::<PendingReason>())
                    .copied();
                let variant = e
                    .get_ref()
                    .and_then(|src| src.downcast_ref::<VariantChangeError>())
                    .is_some();
                let interrupted = e.kind() == ErrorKind::Interrupted;
                match (stream_pending, pending, variant, interrupted) {
                    (Some(reason), _, _, _) | (_, Some(reason), _, _) => {
                        Ok(InputReadOutcome::Pending(reason))
                    }
                    (None, None, true, _) => {
                        Ok(InputReadOutcome::Pending(PendingReason::VariantChange))
                    }
                    (None, None, false, true) => Ok(InputReadOutcome::Pending(
                        PendingReason::NotReady(NotReadyCause::SourcePending),
                    )),
                    (None, None, false, false) => Err(StreamReadError::Source(e)),
                }
            }
        }
    }
}

impl<T: Read + Seek + Send + Sync + ?Sized> DecoderInput for T {}

/// Boxed [`DecoderInput`] alias used by demuxer constructors. The factory
/// dispatch path materialises the input as a `BoxedSource` so concrete
/// demuxers don't have to be generic over the byte source.
pub(crate) type BoxedSource = Box<dyn DecoderInput>;

/// Trait for runtime-polymorphic audio decoders.
///
/// This trait is used by kithara-audio for dynamic dispatch when the
/// decoder type is determined at runtime (e.g., based on media info).
#[kithara::mock(api = DecoderMock)]
pub trait Decoder: Send + 'static {
    /// Default leading-silence frame count for `codec` when no
    /// container-/encoder-level gapless metadata is available.
    ///
    /// Default implementation returns the codec's encoder-side
    /// priming ([`AudioCodec::encoder_priming_frames`]) — every
    /// decoder inherits it for free. Concrete decoders override only
    /// when they add their own algorithmic delay on top
    /// ([`crate::codec::FrameCodec::decoder_algo_delay`] — currently
    /// Symphonia `mpa` adds 529 for MP3).
    ///
    /// Used by `kithara_audio::pipeline::gapless::resolve_codec_priming`
    /// for the [`crate::GaplessMode::CodecPriming`] fallback path.
    fn default_priming_frames(&self, codec: AudioCodec) -> u64 {
        AudioCodec::encoder_priming_frames(codec)
    }

    /// Get total duration from track metadata.
    ///
    /// Returns `None` if duration cannot be determined.
    fn duration(&self) -> Option<Duration>;

    /// Get track metadata (title, artist, album, artwork).
    ///
    /// Returns default metadata if not available.
    fn metadata(&self) -> TrackMetadata {
        TrackMetadata::default()
    }

    /// Decode the next chunk of PCM data.
    ///
    /// Returns [`DecoderChunkOutcome::Chunk`] with PCM data,
    /// [`DecoderChunkOutcome::Pending`] with a typed [`PendingReason`]
    /// when the underlying source aborted (seek pending, transient
    /// backpressure), or [`DecoderChunkOutcome::Eof`] at natural end
    /// of stream. Real decoder/codec failures surface as
    /// [`crate::error::DecodeError`] via the `Err` arm.
    ///
    /// # Errors
    ///
    /// Returns [`crate::error::DecodeError`] if decoding fails.
    fn next_chunk(&mut self) -> DecodeResult<DecoderChunkOutcome>;

    /// Seek to a time position.
    ///
    /// On success returns [`DecoderSeekOutcome::Landed`] with the
    /// authoritative landed position (often a granule boundary near
    /// the requested target — never assume `landed_at == pos`), or
    /// [`DecoderSeekOutcome::PastEof`] when the target is beyond the
    /// decoder's known duration.
    ///
    /// # Errors
    ///
    /// Returns [`crate::error::DecodeError::SeekFailed`] if seeking is not supported
    /// or the position is invalid for reasons other than past-EOF.
    fn seek(&mut self, pos: Duration) -> DecodeResult<DecoderSeekOutcome>;

    /// Get the PCM output specification.
    fn spec(&self) -> PcmSpec;

    /// Decoder-owned playback contract — currently the captured
    /// [`crate::GaplessInfo`] (encoder priming + trailing padding in
    /// PCM frames) when `DecoderConfig.gapless = true` and the codec
    /// reports it. Default implementation returns the empty contract,
    /// so most decoders inherit the no-trim behaviour.
    ///
    /// The audio-pipeline gapless stage reads this once per track and
    /// constructs a [`crate::GaplessTrimmer`] when the contract is
    /// non-empty. Returned by-value (clone) so callers don't pin a
    /// borrow on `&self` across `next_chunk`/`seek`.
    fn track_info(&self) -> crate::DecoderTrackInfo {
        crate::DecoderTrackInfo::default()
    }

    /// Update the byte length reported to the underlying media source.
    ///
    /// For HLS streams, the total length becomes known after metadata
    /// calculation. Call this before seeking so the decoder can compute
    /// correct seek deltas.
    fn update_byte_len(&self, len: u64);
}