Skip to main content

rff_filter/
audio.rs

1//! Audio filters — the `-af` / `-filter:a` chain.
2//!
3//! An [`AudioFilterChain`] transforms decoded [`AudioFrame`]s between the
4//! decoder and the encoder, mirroring [`crate::FilterChain`] for video. The
5//! supported set is deliberately the high-traffic core:
6//!
7//! * `volume=0.5` / `volume=-6dB` — gain (linear factor or decibels),
8//! * `atrim=start=1:end=3.5` (also positional `atrim=1:3.5`, `duration=`) —
9//!   sample-accurate trimming against the frame's presentation time,
10//! * `aresample=48000` — resampling, surfaced via
11//!   [`AudioFilterChain::resample_target`] and performed by the engine's
12//!   resampler (the same one implicit rate conversion uses),
13//! * `anull` — pass-through.
14//!
15//! Frames are interleaved `s16`/`f32`; planar layouts are rejected rather than
16//! silently mis-sliced.
17
18use rff_core::{AudioFrame, Error, Result, SampleFormat};
19
20enum AFilter {
21    Volume(f32),
22    ATrim { start: Option<f64>, end: Option<f64> },
23}
24
25/// An ordered `-af` chain plus the resample target it requests, if any.
26#[derive(Default)]
27pub struct AudioFilterChain {
28    filters: Vec<AFilter>,
29    resample_to: Option<u32>,
30}
31
32/// Parse `key=value` or bare-positional arguments of `atrim`.
33fn parse_atrim(args: &[&str]) -> Result<AFilter> {
34    let (mut start, mut end, mut duration) = (None, None, None);
35    let mut positional = 0usize;
36    for a in args {
37        let a = a.trim();
38        if a.is_empty() {
39            continue;
40        }
41        let (key, value) = match a.split_once('=') {
42            Some((k, v)) => (k.trim(), v.trim()),
43            None => {
44                let key = match positional {
45                    0 => "start",
46                    _ => "end",
47                };
48                positional += 1;
49                (key, a)
50            }
51        };
52        let v: f64 = value
53            .parse()
54            .map_err(|_| Error::Option(format!("atrim: bad number `{value}`")))?;
55        match key {
56            "start" => start = Some(v),
57            "end" => end = Some(v),
58            "duration" => duration = Some(v),
59            other => return Err(Error::Option(format!("atrim: unknown key `{other}`"))),
60        }
61    }
62    if let (None, Some(d)) = (end, duration) {
63        end = Some(start.unwrap_or(0.0) + d);
64    }
65    Ok(AFilter::ATrim { start, end })
66}
67
68/// Parse a volume value: a linear factor (`0.5`) or decibels (`-6dB`).
69fn parse_volume(value: &str) -> Result<f32> {
70    let value = value.trim();
71    if let Some(db) = value
72        .strip_suffix("dB")
73        .or_else(|| value.strip_suffix("db"))
74    {
75        let db: f32 = db
76            .trim()
77            .parse()
78            .map_err(|_| Error::Option(format!("volume: bad dB value `{value}`")))?;
79        return Ok(10f32.powf(db / 20.0));
80    }
81    value
82        .parse()
83        .map_err(|_| Error::Option(format!("volume: bad value `{value}`")))
84}
85
86impl AudioFilterChain {
87    /// Parse an FFmpeg-style `-af` spec. Empty/blank yields a pass-through chain.
88    pub fn parse(spec: &str) -> Result<AudioFilterChain> {
89        let mut chain = AudioFilterChain::default();
90        for token in spec.split(',') {
91            let token = token.trim();
92            if token.is_empty() {
93                continue;
94            }
95            let (name, args) = token.split_once('=').unwrap_or((token, ""));
96            let parts: Vec<&str> = if args.is_empty() {
97                Vec::new()
98            } else {
99                args.split(':').collect()
100            };
101            match name {
102                "volume" => {
103                    let v = parts
104                        .first()
105                        .ok_or_else(|| Error::Option("volume: missing value".into()))?;
106                    chain.filters.push(AFilter::Volume(parse_volume(v)?));
107                }
108                "atrim" => chain.filters.push(parse_atrim(&parts)?),
109                "aresample" => {
110                    // `aresample=48000` or `aresample=osr=48000`.
111                    let v = parts
112                        .first()
113                        .map(|p| p.trim_start_matches("osr=").trim())
114                        .ok_or_else(|| Error::Option("aresample: missing rate".into()))?;
115                    let rate: u32 = v
116                        .parse()
117                        .map_err(|_| Error::Option(format!("aresample: bad rate `{v}`")))?;
118                    chain.resample_to = Some(rate);
119                }
120                "anull" => {}
121                other => {
122                    return Err(Error::unsupported(format!(
123                        "unknown audio filter `{other}` (have: volume, atrim, aresample, anull)"
124                    )))
125                }
126            }
127        }
128        Ok(chain)
129    }
130
131    /// True when no per-frame work is queued (`aresample` alone still counts as
132    /// empty here: the engine's resampler performs it).
133    pub fn is_empty(&self) -> bool {
134        self.filters.is_empty()
135    }
136
137    /// The output sample rate an `aresample` in the chain asked for.
138    pub fn resample_target(&self) -> Option<u32> {
139        self.resample_to
140    }
141
142    /// Run the chain on one frame. `t` is the frame's start time in seconds
143    /// (output-relative). Returns `None` when trimming consumed the whole frame.
144    pub fn apply(&mut self, mut frame: AudioFrame, t: Option<f64>) -> Result<Option<AudioFrame>> {
145        for f in &self.filters {
146            frame = match f {
147                AFilter::Volume(gain) => {
148                    apply_volume(&mut frame, *gain)?;
149                    frame
150                }
151                AFilter::ATrim { start, end } => match apply_atrim(frame, t, *start, *end)? {
152                    Some(f) => f,
153                    None => return Ok(None),
154                },
155            };
156        }
157        Ok(Some(frame))
158    }
159}
160
161fn apply_volume(frame: &mut AudioFrame, gain: f32) -> Result<()> {
162    match frame.format {
163        SampleFormat::F32 => {
164            for chunk in frame.planes[0].chunks_exact_mut(4) {
165                let v = f32::from_le_bytes([chunk[0], chunk[1], chunk[2], chunk[3]]) * gain;
166                chunk.copy_from_slice(&v.to_le_bytes());
167            }
168            Ok(())
169        }
170        SampleFormat::S16 => {
171            for chunk in frame.planes[0].chunks_exact_mut(2) {
172                let v = i16::from_le_bytes([chunk[0], chunk[1]]) as f32 * gain;
173                let v = v.round().clamp(i16::MIN as f32, i16::MAX as f32) as i16;
174                chunk.copy_from_slice(&v.to_le_bytes());
175            }
176            Ok(())
177        }
178        other => Err(Error::unsupported(format!(
179            "volume: sample format `{}` (interleaved s16/f32 only)",
180            other.name()
181        ))),
182    }
183}
184
185/// Sample-accurate trim: keep the intersection of this frame with
186/// `[start, end)`. The frame's start time `t` must be known. Public because
187/// the engine's `-ss`/`-t` window uses the same slice for audio — an audio
188/// "frame" can be arbitrarily large (a WAV decodes as one), so keep/drop at
189/// frame granularity would cut wildly wrong.
190pub fn trim_audio_frame(
191    frame: AudioFrame,
192    t: Option<f64>,
193    start: Option<f64>,
194    end: Option<f64>,
195) -> Result<Option<AudioFrame>> {
196    apply_atrim(frame, t, start, end)
197}
198
199fn apply_atrim(
200    frame: AudioFrame,
201    t: Option<f64>,
202    start: Option<f64>,
203    end: Option<f64>,
204) -> Result<Option<AudioFrame>> {
205    let t = t.ok_or_else(|| Error::unsupported("atrim: input has no timestamps"))?;
206    let rate = frame.sample_rate.max(1) as f64;
207    let n = frame.samples;
208    let s0 = match start {
209        Some(s) if s > t => (((s - t) * rate).round() as usize).min(n),
210        _ => 0,
211    };
212    let s1 = match end {
213        Some(e) => {
214            let upto = ((e - t) * rate).round();
215            if upto <= 0.0 {
216                0
217            } else {
218                (upto as usize).min(n)
219            }
220        }
221        None => n,
222    };
223    if s1 <= s0 {
224        return Ok(None);
225    }
226    if s0 == 0 && s1 == n {
227        return Ok(Some(frame));
228    }
229    let bytes_per = frame.format.bytes_per_sample() * frame.channels.max(1) as usize;
230    if frame.format == SampleFormat::F32Planar {
231        return Err(Error::unsupported("atrim: planar audio not supported"));
232    }
233    let plane = &frame.planes[0][s0 * bytes_per..s1 * bytes_per];
234    Ok(Some(AudioFrame {
235        sample_rate: frame.sample_rate,
236        channels: frame.channels,
237        format: frame.format,
238        samples: s1 - s0,
239        planes: vec![plane.to_vec()],
240        pts: frame.pts, // pts unit is the caller's; the engine tracks time via `t`
241    }))
242}
243
244#[cfg(test)]
245mod tests {
246    use super::*;
247
248    fn s16_frame(samples: &[i16], rate: u32, channels: u16) -> AudioFrame {
249        AudioFrame {
250            sample_rate: rate,
251            channels,
252            format: SampleFormat::S16,
253            samples: samples.len() / channels.max(1) as usize,
254            planes: vec![samples.iter().flat_map(|s| s.to_le_bytes()).collect()],
255            pts: Some(0),
256        }
257    }
258
259    #[test]
260    fn volume_scales_and_clamps_s16() {
261        let mut chain = AudioFilterChain::parse("volume=2.0").unwrap();
262        let f = s16_frame(&[100, -100, 30000], 48_000, 1);
263        let out = chain.apply(f, Some(0.0)).unwrap().unwrap();
264        let got: Vec<i16> = out.planes[0]
265            .chunks_exact(2)
266            .map(|b| i16::from_le_bytes([b[0], b[1]]))
267            .collect();
268        assert_eq!(got, vec![200, -200, 32767]); // clamped, not wrapped
269    }
270
271    #[test]
272    fn volume_parses_decibels() {
273        let g = parse_volume("-6dB").unwrap();
274        assert!((g - 0.501).abs() < 0.01, "got {g}");
275        assert_eq!(parse_volume("0dB").unwrap(), 1.0);
276    }
277
278    #[test]
279    fn atrim_slices_sample_accurately() {
280        // 1 kHz-rate mono frame starting at t=1.0 with 1000 samples (1 s).
281        let samples: Vec<i16> = (0..1000).map(|i| i as i16).collect();
282        let mut chain = AudioFilterChain::parse("atrim=start=1.25:end=1.5").unwrap();
283        let out = chain
284            .apply(s16_frame(&samples, 1000, 1), Some(1.0))
285            .unwrap()
286            .unwrap();
287        assert_eq!(out.samples, 250);
288        let first = i16::from_le_bytes([out.planes[0][0], out.planes[0][1]]);
289        assert_eq!(first, 250);
290    }
291
292    #[test]
293    fn atrim_consumes_frames_outside_the_window() {
294        let mut chain = AudioFilterChain::parse("atrim=end=0.5").unwrap();
295        let out = chain
296            .apply(s16_frame(&[1, 2, 3, 4], 8000, 1), Some(2.0))
297            .unwrap();
298        assert!(out.is_none());
299    }
300
301    #[test]
302    fn aresample_is_surfaced_not_applied() {
303        let chain = AudioFilterChain::parse("aresample=44100").unwrap();
304        assert_eq!(chain.resample_target(), Some(44_100));
305        assert!(chain.is_empty()); // engine's resampler does the work
306    }
307
308    #[test]
309    fn unknown_filters_error() {
310        assert!(AudioFilterChain::parse("acompressor").is_err());
311    }
312}