kithara_audio/traits.rs
1use std::{
2 num::{NonZeroU32, NonZeroUsize},
3 sync::Arc,
4 time::Duration,
5};
6
7pub use kithara_decode::{DecodeError, DecodeResult};
8use kithara_decode::{PcmChunk, PcmSpec, TrackMetadata};
9use kithara_events::EventBus;
10use kithara_platform::tokio as platform_tokio;
11use platform_tokio::sync::Notify;
12
13mod kithara {
14 pub(crate) use kithara_test_macros::mock;
15}
16
17use crate::ServiceClass;
18
19/// Reason a [`ReadOutcome::Pending`] / [`ChunkOutcome::Pending`] was
20/// returned — i.e. why the reader did not advance this call. Each
21/// variant maps to a distinct caller action; there is no overlap and
22/// no string-matching required.
23#[derive(Debug, Clone, Copy, PartialEq, Eq)]
24#[non_exhaustive]
25pub enum PendingReason {
26 /// Producer ringbuf is empty: the consumer has caught up to the
27 /// producer's most recent chunk and is waiting for the next one
28 /// (mid-stream async pause, post-seek refill).
29 Buffering,
30 /// A seek was issued; the consumer is waiting for the producer to
31 /// acknowledge the new epoch and deliver post-seek frames. Old
32 /// pre-seek frames have been drained.
33 SeekInProgress,
34 /// Upstream stream-layer surfaced a pending status (network stall,
35 /// retry, source-level backpressure). The reader will progress
36 /// once the stream resumes.
37 StreamBackpressure,
38}
39
40/// Audio processing effect in the chain (transforms PCM chunks).
41#[kithara::mock(api = AudioEffectMock)]
42pub trait AudioEffect: Send + 'static {
43 /// Flush remaining buffered data (called at end of stream).
44 fn flush(&mut self) -> Option<PcmChunk>;
45
46 /// Process a PCM chunk, returning transformed output.
47 ///
48 /// Returns `None` if the effect is accumulating data (not enough for output yet).
49 fn process(&mut self, chunk: PcmChunk) -> Option<PcmChunk>;
50
51 /// Reset internal state (called after seek).
52 fn reset(&mut self);
53}
54
55/// Result of a PCM read.
56///
57/// Each variant carries distinct caller semantics — the type system
58/// guarantees forward progress in `Frames` (via [`NonZeroUsize`]),
59/// while non-progress is explicit in `Pending` with a typed
60/// [`PendingReason`]. Failures surface as `Err(DecodeError)`, never
61/// as an enum variant.
62#[derive(Debug, Clone, Copy, PartialEq, Eq)]
63pub enum ReadOutcome {
64 /// `count` frames were written into the output buffer (`count > 0`
65 /// by construction). `position` is the reader's position
66 /// **after** the read.
67 Frames {
68 count: NonZeroUsize,
69 position: Duration,
70 },
71 /// Reader is alive but produced no frames this call. See
72 /// [`PendingReason`] for the precise cause and required caller
73 /// action. `position` is the reader's current position (it has
74 /// not advanced since the last successful read).
75 Pending {
76 reason: PendingReason,
77 position: Duration,
78 },
79 /// Natural end of stream — the reader played up to `duration()`.
80 /// No more frames will be produced. `position` is the final
81 /// position (usually `duration()`).
82 Eof { position: Duration },
83}
84
85/// Result of a seek — either the reader landed at a known position or
86/// the target was past the known duration. Failures surface as
87/// `Err(DecodeError)`.
88///
89/// `Landed` carries both the requested `target` and the actual
90/// `landed_at`. The two may differ when the underlying decoder
91/// snapped to a granule/segment boundary; callers that want to write
92/// a "post-seek" position should use `landed_at`, not `target`.
93#[derive(Debug, Clone, Copy, PartialEq, Eq)]
94pub enum SeekOutcome {
95 /// Seek completed; reader is now parked at `landed_at`.
96 Landed {
97 target: Duration,
98 landed_at: Duration,
99 },
100 /// Seek target was past the reader's `duration()`. Reader is
101 /// parked at the end; the next `read()` / `next_chunk()` call
102 /// returns `Eof`.
103 PastEof {
104 target: Duration,
105 duration: Duration,
106 },
107}
108
109/// Result of `next_chunk` — either a decoded chunk (with embedded
110/// spec/timing metadata), a typed non-progress signal, or natural
111/// EOF. Failures surface as `Err(DecodeError)`.
112#[derive(Debug)]
113pub enum ChunkOutcome {
114 /// Next decoded chunk.
115 Chunk(PcmChunk),
116 /// Reader is alive but has no chunk ready this tick. See
117 /// [`PendingReason`] for the precise cause; callers may sleep,
118 /// yield, or retry depending on the reason.
119 Pending {
120 reason: PendingReason,
121 position: Duration,
122 },
123 /// Natural end of stream. `position` is the reader's final position.
124 Eof { position: Duration },
125}
126
127/// Primary PCM interface for reading decoded audio.
128///
129/// **Terminal-state contract.** Three failure-mode-agnostic outcomes
130/// are distinguishable by the caller:
131///
132/// - `Ok(ReadOutcome::Frames { .. })` — reader is alive, produced N
133/// frames (possibly 0 if waiting for data).
134/// - `Ok(ReadOutcome::Eof { .. })` — natural end of stream; no more
135/// frames will ever come.
136/// - `Err(DecodeError)` — decoder or channel failure. The reader may
137/// or may not recover; callers that finalise tracks MUST NOT treat
138/// this as EOF.
139///
140/// **Usage pattern:**
141/// ```ignore
142/// // Async preload before audio callback
143/// resource.preload()?;
144///
145/// // In audio callback (non-blocking after preload)
146/// match resource.read_planar(&mut buffers)? {
147/// ReadOutcome::Frames { count, .. } if count > 0 => play_samples(count),
148/// ReadOutcome::Frames { .. } => { /* silence this tick */ }
149/// ReadOutcome::Eof { .. } => finalise_track(),
150/// }
151/// ```
152#[kithara::mock(api = PcmReaderMock)]
153pub trait PcmReader: kithara_platform::MaybeSend {
154 /// Runtime ABR handle for the underlying stream.
155 ///
156 /// Adaptive readers (HLS) return `Some(handle)` so the queue/FFI can
157 /// drive `set_mode` / `set_max_bandwidth_bps` mid-playback. Default
158 /// `None` for non-adaptive readers (file, test fixtures).
159 fn abr_handle(&self) -> Option<kithara_abr::AbrHandle> {
160 None
161 }
162
163 /// Get total duration (if known).
164 fn duration(&self) -> Option<Duration>;
165
166 /// Access the unified event bus for subscribing to all pipeline events.
167 fn event_bus(&self) -> &EventBus;
168
169 /// Get track metadata.
170 fn metadata(&self) -> &TrackMetadata;
171
172 /// Read the next decoded chunk with full metadata.
173 ///
174 /// Returns [`ChunkOutcome::Chunk`] or [`ChunkOutcome::Eof`].
175 /// Decoder / channel failures surface as `Err(DecodeError)`.
176 /// Discards any partially-consumed chunk from previous
177 /// [`PcmReader::read`] calls.
178 ///
179 /// Default implementation reports immediate natural EOF — readers
180 /// without chunk-level support shouldn't be polled this way.
181 ///
182 /// # Errors
183 ///
184 /// Returns `Err(DecodeError)` for terminal producer failures, same
185 /// semantics as [`Self::read`].
186 fn next_chunk(&mut self) -> Result<ChunkOutcome, DecodeError> {
187 Ok(ChunkOutcome::Eof {
188 position: self.position(),
189 })
190 }
191
192 /// Get current playback position.
193 fn position(&self) -> Duration;
194
195 /// Preload initial chunks into internal buffers.
196 ///
197 /// After calling this, subsequent `read()` / `read_planar()` /
198 /// `next_chunk()` return immediately from buffered data without
199 /// blocking. `Err(DecodeError)` is reserved for setup failures
200 /// (e.g. the producer channel closed during preload). Natural EOF
201 /// encountered during preload is **not** surfaced here — the
202 /// subsequent `read` / `next_chunk` will return `Eof`.
203 ///
204 /// # Errors
205 ///
206 /// Returns `Err(DecodeError)` only on terminal setup failure
207 /// (closed PCM channel, backend error). Successful preload always
208 /// returns `Ok(())` even if the stream contains no data.
209 fn preload(&mut self) -> Result<(), DecodeError> {
210 Ok(())
211 }
212
213 /// Get notify for async preload (first chunk available).
214 fn preload_notify(&self) -> Option<Arc<Notify>> {
215 None
216 }
217
218 /// Read interleaved PCM samples.
219 ///
220 /// After `preload()`, returns immediately from buffered data
221 /// without blocking. The returned [`ReadOutcome`] distinguishes
222 /// "produced N frames" (including `count == 0` for transient
223 /// stalls) from natural EOF. Decoder / channel failures surface as
224 /// `Err(DecodeError)`.
225 ///
226 /// # Errors
227 ///
228 /// Returns `Err(DecodeError)` for terminal producer failures:
229 /// closed PCM channel, decoder fault, or backend error. The error
230 /// is one-way — once returned, subsequent reads continue to fail.
231 fn read(&mut self, buf: &mut [f32]) -> Result<ReadOutcome, DecodeError>;
232
233 /// Read deinterleaved (planar) PCM samples.
234 ///
235 /// After `preload()`, returns immediately from buffered data
236 /// without blocking. Each slice in `output` corresponds to one
237 /// channel. The returned [`ReadOutcome`] has the same semantics as
238 /// [`Self::read`]; `count` is frames-per-channel.
239 ///
240 /// # Errors
241 ///
242 /// Same as [`Self::read`] — terminal producer failures are surfaced
243 /// as `Err(DecodeError)`.
244 fn read_planar<'a>(
245 &mut self,
246 output: &'a mut [&'a mut [f32]],
247 ) -> Result<ReadOutcome, DecodeError>;
248
249 /// Seek to the given position.
250 ///
251 /// Returns [`SeekOutcome::Landed`] when the reader is now parked
252 /// at the requested position, [`SeekOutcome::PastEof`] when the
253 /// target was beyond `duration()`. Seek failures (stream I/O,
254 /// decoder recreate) surface as `Err(DecodeError)`.
255 ///
256 /// # Errors
257 ///
258 /// Returns `Err(DecodeError)` when seek cannot complete: stream I/O
259 /// failure, decoder recreate failure, or terminal producer error.
260 fn seek(&mut self, position: Duration) -> Result<SeekOutcome, DecodeError>;
261
262 /// Set the target sample rate of the audio host.
263 ///
264 /// Used for dynamic updates when the host sample rate changes at runtime.
265 fn set_host_sample_rate(&self, _sample_rate: NonZeroU32) {}
266
267 /// Set the playback rate for timeline scaling.
268 ///
269 /// Rate > 1.0 speeds up playback (position advances faster).
270 /// Rate < 1.0 slows down playback (position advances slower).
271 /// The actual pitch-shifting is done by the resampler.
272 fn set_playback_rate(&self, _rate: f32) {}
273
274 /// Update the scheduling priority hint for the shared worker.
275 ///
276 /// Maps track playback state to worker priority: `Audible` tracks
277 /// are decoded first, then `Warm`, then `Idle`.
278 fn set_service_class(&self, _class: ServiceClass) {}
279
280 /// Get the current PCM specification.
281 fn spec(&self) -> PcmSpec;
282}