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