ez_ffmpeg/core/frame_export/audio.rs
1//! The [`SampleExtractor`] builder and its `samples()` / `collect_samples()` runs.
2
3use super::audio_iter::SampleIter;
4use super::audio_options::Channels;
5use super::audio_resolve::{resolve_and_build_desc, AudioResolvePlan};
6use super::audio_sink::SampleSink;
7use super::collected::CollectedAudio;
8use super::error::FrameExportError;
9use crate::core::context::demuxer::Demuxer;
10use crate::core::context::ffmpeg_context::FfmpegContext;
11use crate::core::context::filter_complex::FilterComplex;
12use crate::core::context::input::Input;
13use crate::core::context::output::Output;
14use crate::core::filter::frame_pipeline_builder::FramePipelineBuilder;
15use crate::core::scheduler::ffmpeg_scheduler::FfmpegScheduler;
16use ffmpeg_sys_next::AVMediaType::AVMEDIA_TYPE_AUDIO;
17
18/// Whisper's canonical input sample rate (16 kHz).
19const WHISPER_SAMPLE_RATE: u32 = 16_000;
20
21/// Decodes an input's audio into owned, interleaved `f32` PCM in one pass, for
22/// ASR / audio-ML pipelines.
23///
24/// Build one with [`SampleExtractor::new`], chain options, then call
25/// [`samples`](SampleExtractor::samples) for a streaming iterator or
26/// [`collect_samples`](SampleExtractor::collect_samples) for one flat buffer.
27///
28/// Defaults **preserve the source**: the source sample rate and channel layout
29/// pass through untouched, and only the sample *format* is pinned (packed
30/// `f32`) — so the default output is whatever rate/channel shape the source
31/// has, NOT a model-ready 16 kHz mono stream. Normalization is opt-in —
32/// [`sample_rate`](Self::sample_rate) and [`channels`](Self::channels) insert
33/// a resample / channel-layout conversion — so music and analysis users get
34/// no silent rate surprise. Use [`for_whisper`](Self::for_whisper) (a thin
35/// preset over those two setters) when a speech model expects 16 kHz mono.
36///
37/// ```no_run
38/// use ez_ffmpeg::frame_export::SampleExtractor;
39///
40/// # fn main() -> Result<(), ez_ffmpeg::error::Error> {
41/// // 16 kHz mono f32 — the whisper-rs / candle handoff shape.
42/// let pcm: Vec<f32> = SampleExtractor::for_whisper("input.mp4").collect_samples()?;
43/// # let _ = pcm;
44/// # Ok(())
45/// # }
46/// ```
47///
48/// # Threading & teardown
49///
50/// A run drives the normal scheduler with the sample sink mounted on the audio
51/// output pipeline. The returned [`SampleIter`] is `Send` and fused (exactly one
52/// terminal error, then `None` forever); dropping it early aborts the run
53/// cleanly (see [`SampleIter`] for the teardown ordering).
54pub struct SampleExtractor {
55 input: Input,
56 audio_stream_index: Option<usize>,
57 sample_rate: Option<u32>,
58 channels: Option<Channels>,
59 start_time_us: Option<i64>,
60 duration_us: Option<i64>,
61 channel_capacity: usize,
62}
63
64impl SampleExtractor {
65 /// Creates an extractor over `input` (a path, URL, or anything convertible
66 /// into an [`Input`]). Defaults: best audio stream, source rate, source
67 /// layout, packed `f32`, channel capacity 4.
68 pub fn new(input: impl Into<Input>) -> Self {
69 Self {
70 input: input.into(),
71 audio_stream_index: None,
72 sample_rate: None,
73 channels: None,
74 start_time_us: None,
75 duration_us: None,
76 channel_capacity: 4,
77 }
78 }
79
80 /// Creates an extractor preset for whisper-style ASR: 16 kHz, mono, `f32`.
81 ///
82 /// Thin convenience over [`new`](Self::new) followed by
83 /// `.sample_rate(16000).channels(Channels::Mono)`; every other option still
84 /// applies and can be overridden afterwards.
85 pub fn for_whisper(input: impl Into<Input>) -> Self {
86 Self::new(input)
87 .sample_rate(WHISPER_SAMPLE_RATE)
88 .channels(Channels::Mono)
89 }
90
91 /// Selects an explicit audio stream by absolute index (default: best audio
92 /// stream).
93 pub fn audio_stream_index(mut self, index: usize) -> Self {
94 self.audio_stream_index = Some(index);
95 self
96 }
97
98 /// Resamples to this output rate in Hz (default: source rate). `0` is
99 /// rejected at [`samples`](Self::samples) time.
100 pub fn sample_rate(mut self, hz: u32) -> Self {
101 self.sample_rate = Some(hz);
102 self
103 }
104
105 /// Converts to this channel layout (default: source layout). The
106 /// conversion downmixes, upmixes, or passes through depending on the
107 /// source layout.
108 pub fn channels(mut self, channels: Channels) -> Self {
109 self.channels = Some(channels);
110 self
111 }
112
113 /// Seeks to this start time (microseconds) before extracting.
114 pub fn start_time_us(mut self, us: i64) -> Self {
115 self.start_time_us = Some(us);
116 self
117 }
118
119 /// Limits extraction to this many microseconds of content past the start.
120 pub fn duration_us(mut self, us: i64) -> Self {
121 self.duration_us = Some(us);
122 self
123 }
124
125 /// Sets the prefetch channel capacity (default 4, minimum 1). Chunks are
126 /// small (a few KiB each), so a slightly deeper queue is nearly free.
127 pub fn channel_capacity(mut self, capacity: usize) -> Self {
128 self.channel_capacity = capacity;
129 self
130 }
131
132 /// Starts the run and returns a streaming iterator over the exported chunks.
133 ///
134 /// Option and stream-resolution errors surface here, before any chunk is
135 /// produced; runtime failures surface as the iterator's terminal `Err`.
136 pub fn samples(self) -> crate::error::Result<SampleIter> {
137 self.validate()?;
138
139 let capacity = self.channel_capacity.max(1);
140 let (tx, rx) = crossbeam_channel::bounded(capacity);
141
142 // Output-side sink on the single graph output stream ([export] => 0).
143 let sink = SampleSink::new(tx);
144 let sink_pipeline = FramePipelineBuilder::new(AVMEDIA_TYPE_AUDIO)
145 .filter("frame_export_sample_sink", Box::new(sink))
146 .set_stream_index(0)
147 .build();
148
149 // Input time window (no decoder fast path applies to audio).
150 let mut input = self.input;
151 if let Some(start) = self.start_time_us {
152 input = input.set_start_time_us(start);
153 }
154 if let Some(dur) = self.duration_us {
155 input = input.set_recording_time_us(dur);
156 }
157
158 // Null output. The audio codec pin is load-bearing: `pcm_f32le` advertises
159 // `sample_fmts={flt}`, so the encoder-negotiated `aformat` appended to the
160 // graph output agrees with our own `sample_fmts=flt` and the tap sees
161 // packed f32. The null muxer's default `pcm_s16le` would instead force
162 // `s16` at the output, which the sink rejects.
163 let output = Output::from("-")
164 .set_format("null")
165 .set_audio_codec("pcm_f32le")
166 .add_stream_map("[export]")
167 .add_frame_pipeline(sink_pipeline);
168
169 // Deferred resolver: runs against the opened demuxer (S8/S9).
170 let resolve_plan = AudioResolvePlan {
171 stream_index: self.audio_stream_index,
172 sample_rate: self.sample_rate,
173 channels: self.channels,
174 };
175 let resolver = move |demuxs: &[Demuxer]| -> crate::error::Result<FilterComplex> {
176 let demux = demuxs.first().ok_or(FrameExportError::NoAudioStream)?;
177 // SAFETY: the demuxer is opened for the lifetime of this call.
178 let desc = unsafe { resolve_and_build_desc(demux.in_fmt_ctx_ptr(), &resolve_plan)? };
179 Ok(desc.into())
180 };
181
182 let context = FfmpegContext::builder()
183 .input(input)
184 .output(output)
185 .add_deferred_filter_desc(Box::new(resolver))
186 .build()?;
187 let scheduler = FfmpegScheduler::new(context).start()?;
188 Ok(SampleIter::new(rx, scheduler))
189 }
190
191 /// Runs to completion and flattens every exported chunk into one interleaved
192 /// `f32` buffer — the raw convenience layer: just the samples, no metadata.
193 /// With explicit normalization (e.g. [`for_whisper`](Self::for_whisper)) the
194 /// shape is already known and this is the whisper-rs / candle handoff form;
195 /// with the source-preserving defaults the buffer arrives in the source's
196 /// own rate/channel shape, which a bare `Vec<f32>` does not describe — use
197 /// [`collect_audio`](Self::collect_audio) for the same samples with their
198 /// sample rate, channel count, and channel layout attached, or stream
199 /// [`samples`](Self::samples) for per-chunk metadata.
200 ///
201 /// Memory is `duration_s × rate × channels × 4` bytes (1 h @ 16 kHz mono ≈
202 /// 230 MB); use [`samples`](Self::samples) to stream when that is too large.
203 /// On a terminal error the partial samples are dropped and the error is
204 /// returned.
205 pub fn collect_samples(self) -> crate::error::Result<Vec<f32>> {
206 let mut out: Vec<f32> = Vec::new();
207 for chunk in self.samples()? {
208 let chunk = chunk?;
209 if out.is_empty() {
210 // First non-empty chunk: move its buffer in with no copy.
211 out = chunk.into_vec();
212 } else {
213 out.extend_from_slice(chunk.as_slice());
214 }
215 }
216 Ok(out)
217 }
218
219 /// Runs to completion and returns every exported sample together with the
220 /// shape metadata that describes the buffer — the self-describing form of
221 /// [`collect_samples`](Self::collect_samples): same samples, same order,
222 /// plus the sample rate, channel count, and channel layout they were
223 /// decoded to.
224 ///
225 /// Metadata is read from the first delivered chunk (one run negotiates one
226 /// output shape), so it reports what the run actually produced: the source
227 /// shape under the default passthrough, or the converted shape when
228 /// [`sample_rate`](Self::sample_rate) / [`channels`](Self::channels)
229 /// requested one. When the run delivers no samples at all (an empty audio
230 /// stream), the buffer is empty and the metadata is zeroed (rate 0,
231 /// channels 0, empty layout).
232 ///
233 /// The memory bound and error behavior match
234 /// [`collect_samples`](Self::collect_samples).
235 ///
236 /// ```no_run
237 /// use ez_ffmpeg::frame_export::SampleExtractor;
238 ///
239 /// # fn main() -> Result<(), ez_ffmpeg::error::Error> {
240 /// // Source-preserving extraction that still knows its own shape.
241 /// let audio = SampleExtractor::new("input.mp4").collect_audio()?;
242 /// println!(
243 /// "{} samples @ {} Hz, {} channel(s), layout {}",
244 /// audio.as_slice().len(),
245 /// audio.sample_rate(),
246 /// audio.channels(),
247 /// audio.channel_layout(),
248 /// );
249 /// # Ok(())
250 /// # }
251 /// ```
252 pub fn collect_audio(self) -> crate::error::Result<CollectedAudio> {
253 let mut samples: Vec<f32> = Vec::new();
254 let mut sample_rate = 0u32;
255 let mut channels = 0u16;
256 let mut channel_layout = String::new();
257 for chunk in self.samples()? {
258 let chunk = chunk?;
259 if samples.is_empty() {
260 // First chunk (delivered chunks are never empty): capture the
261 // run's shape, then move the buffer in with no copy.
262 sample_rate = chunk.sample_rate();
263 channels = chunk.channels();
264 channel_layout = chunk.channel_layout().to_string();
265 samples = chunk.into_vec();
266 } else {
267 samples.extend_from_slice(chunk.as_slice());
268 }
269 }
270 Ok(CollectedAudio::new(
271 samples,
272 sample_rate,
273 channels,
274 channel_layout,
275 ))
276 }
277
278 fn validate(&self) -> crate::error::Result<()> {
279 if self.channel_capacity == 0 {
280 return Err(invalid("channel_capacity must be >= 1"));
281 }
282 if self.sample_rate == Some(0) {
283 return Err(invalid("sample_rate must be > 0"));
284 }
285 // Mirrors FrameExtractor: atrim treats duration=0 as "no limit", so a
286 // non-positive window must be rejected here or a live input runs
287 // unbounded instead of erroring. Effective value: a recording time
288 // preconfigured on the Input feeds the same trim machinery.
289 if let Some(d) = self.duration_us.or(self.input.recording_time_us) {
290 if d <= 0 {
291 return Err(invalid(
292 "duration_us (or the Input's recording time) must be > 0",
293 ));
294 }
295 }
296 Ok(())
297 }
298}
299
300fn invalid(msg: &str) -> crate::error::Error {
301 FrameExportError::InvalidOption(msg.to_string()).into()
302}
303
304#[cfg(test)]
305mod tests {
306 use super::*;
307
308 #[test]
309 fn zero_capacity_is_rejected() {
310 let r = SampleExtractor::new("x.mp4").channel_capacity(0).validate();
311 assert!(matches!(
312 r,
313 Err(crate::error::Error::FrameExport(
314 FrameExportError::InvalidOption(_)
315 ))
316 ));
317 }
318
319 #[test]
320 fn zero_sample_rate_is_rejected() {
321 assert!(SampleExtractor::new("x.mp4")
322 .sample_rate(0)
323 .validate()
324 .is_err());
325 }
326
327 // Regression: duration_us(0) used to pass validation and reach atrim as
328 // duration=0, which FFmpeg reads as "no limit" — the opposite of the
329 // documented meaning, and an unbounded run on live inputs.
330 #[test]
331 fn non_positive_duration_is_rejected() {
332 assert!(SampleExtractor::new("x.mp4")
333 .duration_us(0)
334 .validate()
335 .is_err());
336 assert!(SampleExtractor::new("x.mp4")
337 .duration_us(-1)
338 .validate()
339 .is_err());
340 }
341
342 #[test]
343 fn defaults_validate() {
344 assert!(SampleExtractor::new("x.mp4").validate().is_ok());
345 assert!(SampleExtractor::new("x.mp4")
346 .sample_rate(16000)
347 .channels(Channels::Mono)
348 .duration_us(250_000)
349 .validate()
350 .is_ok());
351 }
352
353 #[test]
354 fn whisper_preset_sets_rate_and_mono() {
355 let e = SampleExtractor::for_whisper("x.mp4");
356 assert_eq!(e.sample_rate, Some(WHISPER_SAMPLE_RATE));
357 assert_eq!(e.channels, Some(Channels::Mono));
358 assert!(e.validate().is_ok());
359 }
360}