Skip to main content

ranim_core/
audio.rs

1//! Audio model: clips, tracks, and the leaf mixer.
2//!
3//! Audio never enters the per-frame evaluation pipeline. Tracks are declared
4//! next to visual animations and share the same absolute scene seconds, but
5//! their content is baked once at seal time —
6//! [`RanimScene::seal`](crate::RanimScene::seal) walks the tree and mixes
7//! leaf by leaf into one interleaved stereo buffer — instead of per-frame
8//! pulls. Consumers (video muxing, preview playback) read the baked buffer;
9//! the visual evaluation stack is untouched.
10
11#[cfg(feature = "audio-decode")]
12use std::io::Cursor;
13use std::ops::Range;
14use std::{f64::consts::TAU, fmt, io::Write, path::Path, sync::Arc};
15#[cfg(feature = "audio-decode")]
16use symphonia::core::{
17    codecs::audio::{AudioDecoderOptions, CODEC_ID_NULL_AUDIO},
18    errors::Error as SymphoniaError,
19    formats::{FormatOptions, probe::Hint},
20    io::MediaSourceStream,
21    meta::MetadataOptions,
22};
23
24/// Master sample rate every mixed buffer lives on.
25pub const MASTER_SAMPLE_RATE: u32 = 48_000;
26
27/// Master channel count; mixed buffers are interleaved stereo.
28pub const MASTER_CHANNELS: u16 = 2;
29
30/// An error produced while decoding an [`AudioClip`] from a file.
31#[derive(Debug)]
32pub struct AudioError(String);
33
34impl fmt::Display for AudioError {
35    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
36        write!(f, "audio error: {}", self.0)
37    }
38}
39
40impl std::error::Error for AudioError {}
41
42impl From<std::io::Error> for AudioError {
43    fn from(err: std::io::Error) -> Self {
44        Self(err.to_string())
45    }
46}
47
48/// Decoded audio: interleaved `f32` PCM samples.
49///
50/// Clips are immutable and cheap to clone (sample data is shared). Decoding a
51/// file normalizes to [`MASTER_SAMPLE_RATE`] and [`MASTER_CHANNELS`]; clips
52/// built with [`AudioClip::from_pcm`] may carry any rate/channels and the
53/// mixer adapts them on the fly.
54#[derive(Debug, Clone)]
55pub struct AudioClip {
56    sample_rate: u32,
57    channels: u16,
58    pcm: Arc<[f32]>,
59}
60
61impl AudioClip {
62    /// Build a clip from interleaved PCM samples.
63    pub fn from_pcm(pcm: impl Into<Arc<[f32]>>, sample_rate: u32, channels: u16) -> Self {
64        assert!(sample_rate > 0, "sample rate must be positive");
65        assert!(channels > 0, "channels must be positive");
66        Self {
67            sample_rate,
68            channels,
69            pcm: pcm.into(),
70        }
71    }
72
73    /// A mono sine tone, mainly for tests and self-contained examples.
74    pub fn sine(freq: f64, secs: f64, amplitude: f64) -> Self {
75        let sample_rate = MASTER_SAMPLE_RATE;
76        let len = (secs * sample_rate as f64) as usize;
77        let pcm: Vec<f32> = (0..len)
78            .map(|i| (amplitude * (TAU * freq * i as f64 / sample_rate as f64).sin()) as f32)
79            .collect();
80        Self::from_pcm(pcm, sample_rate, 1)
81    }
82
83    /// Decode audio bytes in memory (requires the `audio-decode` feature).
84    ///
85    /// Any format symphonia supports decodes: WAV, MP3, FLAC, AAC/M4A, Ogg
86    /// Vorbis. Output is normalized to [`MASTER_SAMPLE_RATE`] and
87    /// [`MASTER_CHANNELS`] — other sample rates are resampled with rubato's
88    /// FFT resampler, mono is duplicated to stereo, and beyond-stereo channel
89    /// layouts keep their front left/right pair.
90    ///
91    /// This is the whole-file entry point that works everywhere `std::io`
92    /// does, including wasm; [`AudioClip::from_file`] is a thin wrapper.
93    #[cfg(feature = "audio-decode")]
94    pub fn from_bytes(bytes: &[u8]) -> Result<Self, AudioError> {
95        decode(bytes)
96    }
97
98    /// Decode an audio file (requires the `audio-decode` feature).
99    ///
100    /// Pure-Rust decoding via symphonia — no ffmpeg binary on `PATH`. See
101    /// [`AudioClip::from_bytes`] for the supported formats and the
102    /// normalization applied to the output.
103    #[cfg(feature = "audio-decode")]
104    pub fn from_file(path: impl AsRef<Path>) -> Result<Self, AudioError> {
105        Self::from_bytes(&std::fs::read(path)?)
106    }
107
108    /// Sample rate of the PCM data.
109    pub fn sample_rate(&self) -> u32 {
110        self.sample_rate
111    }
112
113    /// Channel count of the interleaved PCM data.
114    pub fn channels(&self) -> u16 {
115        self.channels
116    }
117
118    /// The interleaved PCM samples.
119    pub fn pcm(&self) -> &Arc<[f32]> {
120        &self.pcm
121    }
122
123    /// Duration in seconds.
124    pub fn duration_secs(&self) -> f64 {
125        self.frames() as f64 / self.sample_rate as f64
126    }
127
128    fn frames(&self) -> usize {
129        self.pcm.len() / self.channels as usize
130    }
131}
132
133/// Decode interleaved `f32` PCM from encoded audio bytes.
134///
135/// Malformed packets are skipped (matching rodio's leniency); a stream that
136/// changes signal spec mid-way is rejected instead of silently misaligned.
137#[cfg(feature = "audio-decode")]
138fn decode(bytes: &[u8]) -> Result<AudioClip, AudioError> {
139    let mss = MediaSourceStream::new(
140        // MediaSourceStream owns its source; keep the bytes via an owned copy.
141        Box::new(Cursor::new(bytes.to_vec())),
142        Default::default(),
143    );
144    let mut format = symphonia::default::get_probe()
145        .probe(
146            &Hint::new(),
147            mss,
148            FormatOptions::default(),
149            MetadataOptions::default(),
150        )
151        .map_err(|err| AudioError(format!("failed to probe audio: {err}")))?;
152    let (track_id, codec_params) = format
153        .tracks()
154        .iter()
155        .find_map(|track| {
156            let params = track.codec_params.as_ref()?.audio()?;
157            (params.codec != CODEC_ID_NULL_AUDIO).then_some((track.id, params))
158        })
159        .ok_or_else(|| AudioError("no decodable audio track".to_string()))?;
160    let mut decoder = symphonia::default::get_codecs()
161        .make_audio_decoder(codec_params, &AudioDecoderOptions::default())
162        .map_err(|err| AudioError(format!("unsupported codec: {err}")))?;
163
164    let mut pcm = Vec::new();
165    let mut spec = None;
166    loop {
167        let packet = match format.next_packet() {
168            Ok(Some(packet)) => packet,
169            Ok(None) => break,
170            Err(SymphoniaError::IoError(_)) => break,
171            Err(err) => return Err(AudioError(format!("failed to read packet: {err}"))),
172        };
173        if packet.track_id != track_id {
174            continue;
175        }
176        let decoded = match decoder.decode(&packet) {
177            Ok(decoded) => decoded,
178            Err(SymphoniaError::DecodeError(_)) => continue,
179            Err(SymphoniaError::IoError(_)) => break,
180            Err(err) => return Err(AudioError(format!("failed to decode packet: {err}"))),
181        };
182        if spec.as_ref().is_some_and(|spec| spec != decoded.spec()) {
183            return Err(AudioError("stream changes format mid-way".to_string()));
184        }
185        spec.get_or_insert_with(|| decoded.spec().clone());
186        let start = pcm.len();
187        pcm.resize(start + decoded.samples_interleaved(), 0.0);
188        decoded.copy_to_slice_interleaved(&mut pcm[start..]);
189    }
190    let spec = spec.ok_or_else(|| AudioError("no audio frames decoded".to_string()))?;
191
192    // Adapt to master stereo: mono is duplicated, beyond-stereo layouts keep
193    // their front left/right pair.
194    let src_channels = spec.channels().count();
195    let frames = pcm.len() / src_channels;
196    let mut stereo = vec![0.0; frames * MASTER_CHANNELS as usize];
197    for (frame, out) in stereo
198        .as_chunks_mut::<{ MASTER_CHANNELS as usize }>()
199        .0
200        .iter_mut()
201        .enumerate()
202    {
203        for (channel, slot) in out.iter_mut().enumerate() {
204            let src = frame * src_channels + channel.min(src_channels - 1);
205            *slot = pcm[src];
206        }
207    }
208    let pcm = if spec.rate() != MASTER_SAMPLE_RATE {
209        resample_to_master(stereo, spec.rate())?
210    } else {
211        stereo
212    };
213    Ok(AudioClip {
214        sample_rate: MASTER_SAMPLE_RATE,
215        channels: MASTER_CHANNELS,
216        pcm: pcm.into(),
217    })
218}
219
220/// Resample interleaved master-stereo PCM to [`MASTER_SAMPLE_RATE`] with
221/// rubato's FFT (synchronous) resampler in one offline pass.
222#[cfg(feature = "audio-decode")]
223fn resample_to_master(pcm: Vec<f32>, src_rate: u32) -> Result<Vec<f32>, AudioError> {
224    use rubato::audioadapter_buffers::owned::InterleavedOwned;
225    use rubato::{Fft, FixedSync, Resampler};
226
227    let channels = MASTER_CHANNELS as usize;
228    let frames = pcm.len() / channels;
229    let input = InterleavedOwned::<f32>::new_from(pcm, channels, frames)
230        .map_err(|err| AudioError(format!("resampler input: {err}")))?;
231    let mut resampler = Fft::<f32>::new(
232        src_rate as usize,
233        MASTER_SAMPLE_RATE as usize,
234        1024,
235        channels,
236        FixedSync::Both,
237    )
238    .map_err(|err| AudioError(format!("failed to init resampler: {err}")))?;
239    let output = resampler
240        .process_all(&input, frames, None)
241        .map_err(|err| AudioError(format!("resampling failed: {err}")))?;
242    Ok(output.take_data())
243}
244
245/// A sound's content data: the clip plus how to play it.
246///
247/// Purely substance — clip trim and gain envelope. Everything time-positional
248/// (placement, window duration, enable, rate warps — including linear speed
249/// changes via `with_duration`) lives on the cell layer (`Unplaced::at`,
250/// `with_duration`, `with_rate_func`, `with_enabled`), exactly like a visual
251/// item's data versus its [`AnimNode`](crate::animation::node::AnimNode).
252#[derive(Debug, Clone)]
253pub struct AudioTrack {
254    clip: AudioClip,
255    gain: f64,
256    fade_in_secs: f64,
257    fade_out_secs: f64,
258    play_range: Option<Range<f64>>,
259}
260
261impl AudioTrack {
262    /// A track playing the whole clip at unit gain.
263    pub fn new(clip: AudioClip) -> Self {
264        Self {
265            clip,
266            gain: 1.0,
267            fade_in_secs: 0.0,
268            fade_out_secs: 0.0,
269            play_range: None,
270        }
271    }
272
273    /// Set the track's linear gain.
274    pub fn with_gain(mut self, gain: f64) -> Self {
275        self.gain = gain;
276        self
277    }
278
279    /// Fade in linearly over the first `secs` of the track.
280    pub fn with_fade_in(mut self, secs: f64) -> Self {
281        self.fade_in_secs = secs;
282        self
283    }
284
285    /// Fade out linearly over the last `secs` of the track.
286    pub fn with_fade_out(mut self, secs: f64) -> Self {
287        self.fade_out_secs = secs;
288        self
289    }
290
291    /// Play only the clip's seconds within `range` — a content trim, unlike
292    /// the cell layer's `with_duration`, which resamples the sound to fit a
293    /// new window length.
294    ///
295    /// The range is clamped to the clip: a start before the clip pulls to
296    /// zero, an end past the clip's duration truncates, and an empty or
297    /// reversed span collapses to silence. Content-time zero always plays
298    /// `range.start` seconds into the clip.
299    pub fn with_play_secs(mut self, range: Range<f64>) -> Self {
300        self.play_range = Some(range);
301        self
302    }
303
304    /// The clip-second span actually played: `play_range` clamped to the
305    /// clip's extent.
306    fn source_span(&self) -> (f64, f64) {
307        let duration = self.clip.duration_secs();
308        match &self.play_range {
309            None => (0.0, duration),
310            Some(range) => {
311                let start = range.start.max(0.0);
312                (start, range.end.min(duration).max(start))
313            }
314        }
315    }
316
317    /// The track's play length (its whole content axis).
318    pub(crate) fn play_window_secs(&self) -> f64 {
319        let (start, end) = self.source_span();
320        end - start
321    }
322}
323
324impl AudioTrack {
325    /// The stereo sample this track produces at content-time `own`, or
326    /// silence when `own` falls outside the play window.
327    ///
328    /// A pure function of content time — the mixing walk calls it once per
329    /// output sample. Fades are measured on the same content axis.
330    pub(crate) fn sample_at(&self, own: f64) -> [f32; 2] {
331        const SILENCE: [f32; 2] = [0.0; 2];
332        let clip_frames = self.clip.frames();
333        if clip_frames == 0 {
334            return SILENCE;
335        }
336        let (start, end) = self.source_span();
337        let play_len = end - start;
338        if !(0.0..play_len).contains(&own) {
339            return SILENCE;
340        }
341        let mut envelope = self.gain;
342        if self.fade_in_secs > 0.0 && own < self.fade_in_secs {
343            envelope *= own / self.fade_in_secs;
344        }
345        if self.fade_out_secs > 0.0 {
346            envelope *= ((play_len - own) / self.fade_out_secs).min(1.0);
347        }
348        let envelope = envelope as f32;
349        // One clip second per content second, offset into the trimmed span.
350        let src_pos = (start + own) * self.clip.sample_rate as f64;
351        let f0 = src_pos.floor() as usize;
352        if f0 >= clip_frames {
353            return SILENCE;
354        }
355        let frac = (src_pos - src_pos.floor()) as f32;
356        let f1 = (f0 + 1).min(clip_frames - 1);
357        let clip_channels = self.clip.channels as usize;
358        let sample = |frame: usize, channel: usize| {
359            self.clip.pcm[frame * clip_channels + channel.min(clip_channels - 1)]
360        };
361        let mut out = SILENCE;
362        for (channel, slot) in out.iter_mut().enumerate() {
363            let value = sample(f0, channel) + (sample(f1, channel) - sample(f0, channel)) * frac;
364            *slot = value * envelope;
365        }
366        out
367    }
368
369    /// Add this track's samples for global frames `[g_lo, g_hi)` into `pcm`
370    /// (the whole timeline's interleaved stereo buffer, absolute frame
371    /// indices), where content time is the affine map
372    /// `t(g) = a + b·(g / sample_rate)`.
373    ///
374    /// The seal-time bake's linear fast path: the per-sample math is
375    /// identical to [`AudioTrack::sample_at`] (window, fades and gain all
376    /// measured on the content axis), arranged as one tight loop over the
377    /// frames the bake proved audible.
378    pub(crate) fn mix_span_into(
379        &self,
380        a: f64,
381        b: f64,
382        g_lo: usize,
383        g_hi: usize,
384        sample_rate: f64,
385        pcm: &mut [f32],
386    ) {
387        let (start, end) = self.source_span();
388        let play_len = end - start;
389        let clip_frames = self.clip.frames();
390        if clip_frames == 0 || play_len <= 0.0 {
391            return;
392        }
393        let clip_rate = self.clip.sample_rate as f64;
394        let clip_channels = self.clip.channels as usize;
395        let sample = |frame: usize, channel: usize| {
396            self.clip.pcm[frame * clip_channels + channel.min(clip_channels - 1)]
397        };
398        for g in g_lo..g_hi {
399            let own = a + b * (g as f64 / sample_rate);
400            // Exact bake bounds keep this check almost always true; it stays
401            // so float drift at a window edge cannot leak a sample.
402            if !(0.0..play_len).contains(&own) {
403                continue;
404            }
405            let mut envelope = self.gain;
406            if self.fade_in_secs > 0.0 && own < self.fade_in_secs {
407                envelope *= own / self.fade_in_secs;
408            }
409            if self.fade_out_secs > 0.0 {
410                envelope *= ((play_len - own) / self.fade_out_secs).min(1.0);
411            }
412            let envelope = envelope as f32;
413            let src_pos = (start + own) * clip_rate;
414            let f0 = src_pos.floor() as usize;
415            if f0 >= clip_frames {
416                continue;
417            }
418            let frac = (src_pos - src_pos.floor()) as f32;
419            let f1 = (f0 + 1).min(clip_frames - 1);
420            let value = |channel: usize| {
421                sample(f0, channel) + (sample(f1, channel) - sample(f0, channel)) * frac
422            };
423            pcm[g * 2] += value(0) * envelope;
424            pcm[g * 2 + 1] += value(1) * envelope;
425        }
426    }
427}
428
429/// Encode interleaved `f32` PCM as a RIFF/WAVE file (IEEE float format).
430pub fn write_wav(
431    path: impl AsRef<Path>,
432    pcm: &[f32],
433    sample_rate: u32,
434    channels: u16,
435) -> std::io::Result<()> {
436    let data_len = (pcm.len() * 4) as u32;
437    let mut file = std::fs::File::create(path)?;
438    let write_chunk =
439        |file: &mut std::fs::File, id: &[u8; 4], payload: &[u8]| -> std::io::Result<()> {
440            file.write_all(id)?;
441            file.write_all(&(payload.len() as u32).to_le_bytes())?;
442            file.write_all(payload)
443        };
444    // RIFF header: "RIFF" + size + "WAVE", then fmt + data chunks.
445    file.write_all(b"RIFF")?;
446    file.write_all(&(4 + 8 + 16 + 8 + data_len).to_le_bytes())?;
447    file.write_all(b"WAVE")?;
448    let mut fmt_chunk = Vec::with_capacity(16);
449    fmt_chunk.extend_from_slice(&3u16.to_le_bytes()); // IEEE float
450    fmt_chunk.extend_from_slice(&channels.to_le_bytes());
451    fmt_chunk.extend_from_slice(&sample_rate.to_le_bytes());
452    fmt_chunk.extend_from_slice(&(sample_rate * channels as u32 * 4).to_le_bytes()); // byte rate
453    fmt_chunk.extend_from_slice(&(channels * 4).to_le_bytes()); // block align
454    fmt_chunk.extend_from_slice(&32u16.to_le_bytes()); // bits per sample
455    write_chunk(&mut file, b"fmt ", &fmt_chunk)?;
456    let mut data = Vec::with_capacity(pcm.len() * 4);
457    for sample in pcm {
458        data.extend_from_slice(&sample.to_le_bytes());
459    }
460    write_chunk(&mut file, b"data", &data)?;
461    Ok(())
462}
463
464#[cfg(test)]
465mod tests {
466    use super::*;
467
468    const EPS: f32 = 1e-6;
469
470    fn constant(value: f32, secs: f64) -> AudioClip {
471        let pcm = vec![value; (secs * MASTER_SAMPLE_RATE as f64) as usize * 2];
472        AudioClip::from_pcm(pcm, MASTER_SAMPLE_RATE, 2)
473    }
474
475    #[test]
476    fn fades_ramp_linearly() {
477        let track = AudioTrack::new(constant(1.0, 2.0))
478            .with_fade_in(1.0)
479            .with_fade_out(1.0);
480        for (sec, expected) in [(0.0, 0.0), (0.5, 0.5), (1.0, 1.0), (1.5, 0.5), (2.0, 0.0)] {
481            assert!(
482                (track.sample_at(sec)[0] - expected).abs() < EPS,
483                "at {sec}s"
484            );
485        }
486    }
487
488    #[test]
489    fn gain_and_play_window_modify_the_track() {
490        let track = AudioTrack::new(constant(0.5, 4.0))
491            .with_gain(0.2)
492            .with_play_secs(0.0..1.0);
493        assert!((track.sample_at(0.5)[0] - 0.1).abs() < EPS);
494        assert_eq!(track.sample_at(1.0), [0.0; 2]);
495    }
496
497    #[test]
498    fn play_range_skips_into_the_clip() {
499        // A 4 Hz ramp whose frame values encode their position; playing
500        // 1.0..3.0 makes content-time zero read frame 4 and the window end
501        // at 2 content seconds.
502        let ramp: Vec<f32> = (0..16).flat_map(|i| [i as f32, i as f32]).collect();
503        let clip = AudioClip::from_pcm(ramp, 4, 2);
504        let track = AudioTrack::new(clip).with_play_secs(1.0..3.0);
505        assert!((track.sample_at(0.0)[0] - 4.0).abs() < EPS);
506        assert!((track.sample_at(1.0)[0] - 8.0).abs() < EPS);
507        assert!(track.sample_at(2.0)[0].abs() < EPS);
508        assert!((track.play_window_secs() - 2.0).abs() < f64::from(EPS));
509    }
510
511    #[test]
512    fn play_range_is_clamped_to_the_clip() {
513        let clip = AudioClip::from_pcm(vec![0.5; 8], 4, 1);
514        // End past the duration truncates; the window is the whole clip.
515        let track = AudioTrack::new(clip.clone()).with_play_secs(0.0..10.0);
516        assert!((track.play_window_secs() - 2.0).abs() < f64::from(EPS));
517        // A negative start pulls to zero.
518        let track = AudioTrack::new(clip.clone()).with_play_secs(-1.0..2.0);
519        assert!((track.play_window_secs() - 2.0).abs() < f64::from(EPS));
520        // Reversed or past-the-end spans are silence.
521        let track = AudioTrack::new(clip.clone()).with_play_secs(3.0..1.0);
522        assert_eq!(track.play_window_secs(), 0.0);
523        assert!(track.sample_at(0.0)[0].abs() < EPS);
524        let track = AudioTrack::new(clip).with_play_secs(5.0..9.0);
525        assert_eq!(track.play_window_secs(), 0.0);
526    }
527
528    #[test]
529    fn mono_clip_duplicates_to_stereo() {
530        let track = AudioTrack::new(AudioClip::from_pcm(vec![0.5; 48], 48, 1));
531        assert_eq!(track.sample_at(0.5), [0.5; 2]);
532    }
533
534    #[test]
535    fn clip_is_resampled_by_linear_interpolation() {
536        // A 24 Hz clip sampled on the 48 Hz grid: its two frames land one
537        // output frame apart, with the midpoint interpolated. t=1/48 reads
538        // clip position 0.5 -> 1.0; t=2/48 reads position 1.0 -> 2.0;
539        // t=3/48 runs past the last frame and clamps; the play window ends
540        // at t=1/12, beyond which the track is silent.
541        let clip = AudioClip::from_pcm(vec![0.0, 2.0], 24, 1);
542        let track = AudioTrack::new(clip);
543        assert!((track.sample_at(0.0 / 48.0)[0] - 0.0).abs() < EPS);
544        assert!((track.sample_at(1.0 / 48.0)[0] - 1.0).abs() < EPS);
545        assert!((track.sample_at(2.0 / 48.0)[0] - 2.0).abs() < EPS);
546        assert!((track.sample_at(3.0 / 48.0)[0] - 2.0).abs() < EPS);
547        assert!(track.sample_at(4.0 / 48.0)[0].abs() < EPS);
548    }
549
550    #[test]
551    fn wav_roundtrip_has_a_sound_header() {
552        let path = std::env::temp_dir().join(format!("ranim-wav-test-{}.wav", std::process::id()));
553        let pcm = vec![0.25f32; 480];
554        write_wav(&path, &pcm, MASTER_SAMPLE_RATE, MASTER_CHANNELS).unwrap();
555        let bytes = std::fs::read(&path).unwrap();
556        std::fs::remove_file(&path).unwrap();
557        assert_eq!(&bytes[0..4], b"RIFF");
558        assert_eq!(&bytes[8..12], b"WAVE");
559        assert_eq!(&bytes[12..16], b"fmt ");
560        let channels = u16::from_le_bytes(bytes[22..24].try_into().unwrap());
561        assert_eq!(channels, MASTER_CHANNELS);
562        assert_eq!(&bytes[36..40], b"data");
563        let data_len = u32::from_le_bytes(bytes[40..44].try_into().unwrap());
564        assert_eq!(data_len as usize, pcm.len() * 4);
565    }
566
567    #[test]
568    #[cfg(feature = "audio-decode")]
569    fn from_file_decodes_the_written_wav() {
570        let path =
571            std::env::temp_dir().join(format!("ranim-decode-test-{}.wav", std::process::id()));
572        let pcm = vec![0.5f32; 480 * MASTER_CHANNELS as usize];
573        write_wav(&path, &pcm, MASTER_SAMPLE_RATE, MASTER_CHANNELS).unwrap();
574        let clip = AudioClip::from_file(&path).unwrap();
575        std::fs::remove_file(&path).unwrap();
576        assert_eq!(clip.sample_rate(), MASTER_SAMPLE_RATE);
577        assert_eq!(clip.channels(), MASTER_CHANNELS);
578        assert!((clip.duration_secs() - 480.0 / MASTER_SAMPLE_RATE as f64).abs() < 1e-9);
579        assert!((clip.pcm()[0] - 0.5).abs() < 1e-6);
580    }
581
582    #[test]
583    #[cfg(feature = "audio-decode")]
584    fn decoding_resamples_to_master_rate() {
585        // A 24 kHz stereo wav with a click at 0.5 s; decoding must keep the
586        // click at 0.5 s on the 48 kHz grid (resampler delay trimmed) and
587        // scale the duration.
588        let rate = 24_000u32;
589        let click = rate as usize / 2;
590        let mut pcm = vec![0.0f32; rate as usize * 2];
591        pcm[click * 2] = 1.0;
592        pcm[click * 2 + 1] = 1.0;
593        let path =
594            std::env::temp_dir().join(format!("ranim-resample-test-{}.wav", std::process::id()));
595        write_wav(&path, &pcm, rate, MASTER_CHANNELS).unwrap();
596        let clip = AudioClip::from_file(&path).unwrap();
597        std::fs::remove_file(&path).unwrap();
598        assert_eq!(clip.sample_rate(), MASTER_SAMPLE_RATE);
599        assert_eq!(clip.channels(), MASTER_CHANNELS);
600        let samples = clip.pcm();
601        let peak = samples
602            .as_chunks::<2>()
603            .0
604            .iter()
605            .enumerate()
606            .max_by(|a, b| a.1[0].abs().total_cmp(&b.1[0].abs()))
607            .unwrap()
608            .0;
609        assert!(
610            (peak as i64 - 24_000).abs() < 1_000,
611            "click at frame {peak}, expected ~24000"
612        );
613        assert!(
614            (samples.len() as i64 / 2 - 48_000).abs() < 2_000,
615            "duration {} frames, expected ~48000",
616            samples.len() / 2
617        );
618    }
619
620    #[test]
621    #[cfg(feature = "audio-decode")]
622    fn decoding_duplicates_mono_to_stereo() {
623        let pcm = vec![0.5f32; MASTER_SAMPLE_RATE as usize];
624        let path = std::env::temp_dir().join(format!("ranim-mono-test-{}.wav", std::process::id()));
625        write_wav(&path, &pcm, MASTER_SAMPLE_RATE, 1).unwrap();
626        let clip = AudioClip::from_file(&path).unwrap();
627        std::fs::remove_file(&path).unwrap();
628        assert_eq!(clip.sample_rate(), MASTER_SAMPLE_RATE);
629        assert_eq!(clip.channels(), MASTER_CHANNELS);
630        assert_eq!(clip.pcm().len(), MASTER_SAMPLE_RATE as usize * 2);
631        assert!(clip.pcm().iter().all(|&s| (s - 0.5).abs() < 1e-6));
632    }
633
634    #[test]
635    #[cfg(feature = "audio-decode")]
636    fn decoding_garbage_is_an_error() {
637        assert!(AudioClip::from_bytes(b"not an audio file").is_err());
638    }
639}