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