Skip to main content

kithara_decode/
traits.rs

1use std::{
2    io::{ErrorKind, Read, Seek},
3    num::NonZeroUsize,
4    time::Duration,
5};
6
7use kithara_stream::{
8    AudioCodec, NotReadyCause, PendingReason, PrerollHint, StreamPending, StreamReadError,
9    VariantChangeError,
10};
11
12mod kithara {
13    pub(crate) use kithara_test_macros::mock;
14}
15
16use crate::{
17    error::DecodeResult,
18    types::{PcmChunk, PcmSpec, TrackMetadata},
19};
20
21/// Outcome of a [`DecoderInput::try_read`] call.
22///
23/// Mirrors the [`kithara_stream::ReadOutcome`] shape but operates on
24/// the input-byte plane. `Bytes` carries a [`NonZeroUsize`] count;
25/// `Pending` carries the typed [`PendingReason`] recovered from
26/// `Stream`'s `impl Read` (or synthesised from `io::ErrorKind` for
27/// non-stream inputs); `Eof` is terminal.
28#[derive(Debug, Clone, Copy, PartialEq, Eq)]
29pub enum InputReadOutcome {
30    Bytes(NonZeroUsize),
31    Pending(PendingReason),
32    Eof,
33}
34
35/// Outcome of a [`Decoder::seek`] call.
36///
37/// `Landed.landed_at` is the position the decoder actually parked at
38/// (often the granule boundary nearest the requested target — never
39/// the requested target itself unless it coincides). `PastEof` carries
40/// the decoder's known total duration so the caller can park at EOF
41/// without rounding.
42// Not #[non_exhaustive]: `tests/src/decode_mock.rs` constructs variants by
43// named-field syntax across crates; direct construction is part of the
44// intended mock contract (AGENTS.md "small, obviously stable exception").
45#[derive(Debug, Clone, Copy, PartialEq, Eq)]
46pub enum DecoderSeekOutcome {
47    /// Decoder is now parked at `landed_at` / `landed_frame` /
48    /// `landed_byte`. All three come from the decoder's own state
49    /// and refer to the *same* point. `landed_byte`, when present,
50    /// is the absolute byte offset in the underlying source where
51    /// the next packet body begins — the pipeline plugs it into
52    /// `Stream::seek` so we never recompute byte offsets from
53    /// `frame × bytes_per_frame` heuristics on the consumer side.
54    /// `None` is reserved for the rare case where the decoder
55    /// successfully seeked but cannot expose a packet-aligned byte
56    /// offset (e.g. `AudioFile` on a streaming MP3 whose seek-table
57    /// is not yet built). For those, the pipeline relies on the
58    /// producer-side `Stream::byte_position` updated by the
59    /// decoder's own `Read::seek` calls — no extra arithmetic.
60    Landed {
61        landed_at: Duration,
62        landed_frame: u64,
63        landed_byte: Option<u64>,
64        /// Hint from the demuxer: an earlier byte position the source must
65        /// still hold so the decoder can warm MDCT overlap-add state before
66        /// emitting user-visible samples. `NotNeeded` until a demuxer
67        /// computes a meaningful pre-roll hint (file backend by byte offset
68        /// back, HLS by prev segment start); consumed by the audio pipeline
69        /// to warm MDCT overlap-add state before emitting user-visible
70        /// chunks.
71        preroll: PrerollHint,
72    },
73    /// Seek target was past the decoder's known duration. The decoder
74    /// is parked at the end; the next `next_chunk` returns
75    /// [`DecoderChunkOutcome::Eof`].
76    PastEof { duration: Duration },
77}
78
79/// Outcome of an [`Decoder::next_chunk`] call.
80///
81/// Mirrors [`kithara_stream::ReadOutcome`] / [`InputReadOutcome`] in
82/// shape so every layer of the pipeline carries the same three-way
83/// distinction (`progress | pending | terminal`). `Pending` carries
84/// the typed [`PendingReason`] — typically
85/// [`PendingReason::SeekPending`] when an in-flight seek aborted the
86/// underlying read, or [`PendingReason::NotReady`] when the source
87/// signalled transient backpressure.
88#[derive(Debug)]
89pub enum DecoderChunkOutcome {
90    /// Decoded PCM chunk.
91    Chunk(PcmChunk),
92    /// Decoder is alive but produced no chunk this call. See
93    /// [`PendingReason`] for the precise cause.
94    Pending(PendingReason),
95    /// Natural end of stream — no more chunks will be produced.
96    Eof,
97}
98
99impl DecoderChunkOutcome {
100    /// `true` when the outcome is [`Self::Eof`].
101    #[must_use]
102    pub fn is_eof(&self) -> bool {
103        matches!(self, Self::Eof)
104    }
105}
106
107impl TryFrom<DecoderChunkOutcome> for PcmChunk {
108    type Error = DecoderChunkOutcome;
109
110    fn try_from(outcome: DecoderChunkOutcome) -> Result<Self, Self::Error> {
111        match outcome {
112            DecoderChunkOutcome::Chunk(chunk) => Ok(chunk),
113            other => Err(other),
114        }
115    }
116}
117
118/// Combined trait for decoder input sources.
119///
120/// Supertrait combining `Read + Seek + Send + Sync`. Adds typed
121/// [`try_read`] returning [`InputReadOutcome`] so decoders never
122/// confuse "0 bytes" between EOF and `Pending(...)`.
123/// `kithara_stream::Stream` packages its typed status (`SeekPending`,
124/// `VariantChange`, `NotReady`/`Retry`) into `io::Error` payloads via
125/// `impl Read for Stream`; the default `try_read` here downcasts those
126/// payloads back into [`PendingReason`]. Arbitrary `Read + Seek`
127/// sources (test cursors, fixtures) take the same default impl —
128/// raw `io::Error` becomes [`StreamReadError::Source`], `Ok(0)` →
129/// [`InputReadOutcome::Eof`].
130pub trait DecoderInput: Read + Seek + Send + Sync {
131    /// Typed read.
132    ///
133    /// # Errors
134    ///
135    /// Returns [`StreamReadError::Source`] for genuine source I/O
136    /// failures. Status conditions (seek pending, variant change,
137    /// data not ready) come back as `Ok(InputReadOutcome::Pending(...))`.
138    fn try_read(&mut self, buf: &mut [u8]) -> Result<InputReadOutcome, StreamReadError> {
139        match Read::read(self, buf) {
140            Ok(0) => Ok(InputReadOutcome::Eof),
141            Ok(n) => {
142                Ok(NonZeroUsize::new(n).map_or(InputReadOutcome::Eof, InputReadOutcome::Bytes))
143            }
144            Err(e) => {
145                let stream_pending = e
146                    .get_ref()
147                    .and_then(|src| src.downcast_ref::<StreamPending>())
148                    .map(|p| p.reason);
149                let pending = e
150                    .get_ref()
151                    .and_then(|src| src.downcast_ref::<PendingReason>())
152                    .copied();
153                let variant = e
154                    .get_ref()
155                    .and_then(|src| src.downcast_ref::<VariantChangeError>())
156                    .is_some();
157                let interrupted = e.kind() == ErrorKind::Interrupted;
158                match (stream_pending, pending, variant, interrupted) {
159                    (Some(reason), _, _, _) | (_, Some(reason), _, _) => {
160                        Ok(InputReadOutcome::Pending(reason))
161                    }
162                    (None, None, true, _) => {
163                        Ok(InputReadOutcome::Pending(PendingReason::VariantChange))
164                    }
165                    (None, None, false, true) => Ok(InputReadOutcome::Pending(
166                        PendingReason::NotReady(NotReadyCause::SourcePending),
167                    )),
168                    (None, None, false, false) => Err(StreamReadError::Source(e)),
169                }
170            }
171        }
172    }
173}
174
175impl<T: Read + Seek + Send + Sync + ?Sized> DecoderInput for T {}
176
177/// Boxed [`DecoderInput`] alias used by demuxer constructors. The factory
178/// dispatch path materialises the input as a `BoxedSource` so concrete
179/// demuxers don't have to be generic over the byte source.
180pub(crate) type BoxedSource = Box<dyn DecoderInput>;
181
182/// Trait for runtime-polymorphic audio decoders.
183///
184/// This trait is used by kithara-audio for dynamic dispatch when the
185/// decoder type is determined at runtime (e.g., based on media info).
186#[kithara::mock(api = DecoderMock)]
187pub trait Decoder: Send + 'static {
188    /// Default leading-silence frame count for `codec` when no
189    /// container-/encoder-level gapless metadata is available.
190    ///
191    /// Default implementation returns the codec's encoder-side
192    /// priming ([`AudioCodec::encoder_priming_frames`]) — every
193    /// decoder inherits it for free. Concrete decoders override only
194    /// when they add their own algorithmic delay on top
195    /// ([`crate::codec::FrameCodec::decoder_algo_delay`] — currently
196    /// Symphonia `mpa` adds 529 for MP3).
197    ///
198    /// Used by `kithara_audio::pipeline::gapless::resolve_codec_priming`
199    /// for the [`crate::GaplessMode::CodecPriming`] fallback path.
200    fn default_priming_frames(&self, codec: AudioCodec) -> u64 {
201        AudioCodec::encoder_priming_frames(codec)
202    }
203
204    /// Get total duration from track metadata.
205    ///
206    /// Returns `None` if duration cannot be determined.
207    fn duration(&self) -> Option<Duration>;
208
209    /// Get track metadata (title, artist, album, artwork).
210    ///
211    /// Returns default metadata if not available.
212    fn metadata(&self) -> TrackMetadata {
213        TrackMetadata::default()
214    }
215
216    /// Decode the next chunk of PCM data.
217    ///
218    /// Returns [`DecoderChunkOutcome::Chunk`] with PCM data,
219    /// [`DecoderChunkOutcome::Pending`] with a typed [`PendingReason`]
220    /// when the underlying source aborted (seek pending, transient
221    /// backpressure), or [`DecoderChunkOutcome::Eof`] at natural end
222    /// of stream. Real decoder/codec failures surface as
223    /// [`crate::error::DecodeError`] via the `Err` arm.
224    ///
225    /// # Errors
226    ///
227    /// Returns [`crate::error::DecodeError`] if decoding fails.
228    fn next_chunk(&mut self) -> DecodeResult<DecoderChunkOutcome>;
229
230    /// Seek to a time position.
231    ///
232    /// On success returns [`DecoderSeekOutcome::Landed`] with the
233    /// authoritative landed position (often a granule boundary near
234    /// the requested target — never assume `landed_at == pos`), or
235    /// [`DecoderSeekOutcome::PastEof`] when the target is beyond the
236    /// decoder's known duration.
237    ///
238    /// # Errors
239    ///
240    /// Returns [`crate::error::DecodeError::SeekFailed`] if seeking is not supported
241    /// or the position is invalid for reasons other than past-EOF.
242    fn seek(&mut self, pos: Duration) -> DecodeResult<DecoderSeekOutcome>;
243
244    /// Get the PCM output specification.
245    fn spec(&self) -> PcmSpec;
246
247    /// Decoder-owned playback contract — currently the captured
248    /// [`crate::GaplessInfo`] (encoder priming + trailing padding in
249    /// PCM frames) when `DecoderConfig.gapless = true` and the codec
250    /// reports it. Default implementation returns the empty contract,
251    /// so most decoders inherit the no-trim behaviour.
252    ///
253    /// The audio-pipeline gapless stage reads this once per track and
254    /// constructs a [`crate::GaplessTrimmer`] when the contract is
255    /// non-empty. Returned by-value (clone) so callers don't pin a
256    /// borrow on `&self` across `next_chunk`/`seek`.
257    fn track_info(&self) -> crate::DecoderTrackInfo {
258        crate::DecoderTrackInfo::default()
259    }
260
261    /// Update the byte length reported to the underlying media source.
262    ///
263    /// For HLS streams, the total length becomes known after metadata
264    /// calculation. Call this before seeking so the decoder can compute
265    /// correct seek deltas.
266    fn update_byte_len(&self, len: u64);
267}