Skip to main content

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}