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}