rustmotion-core 0.8.1

Core types, traits, and rendering utilities for rustmotion
Documentation
use std::collections::HashMap;

use crate::schema::time::TimeCtx;

use super::dsp;
use super::score::{Score, ScoreError};

pub const SYNTH_SAMPLE_RATE: u32 = 48_000;

pub fn render(score: &Score, ctx: TimeCtx, duration_secs: f64) -> Result<Vec<f32>, ScoreError> {
    let duration_secs = duration_secs.max(0.0);
    let num_samples = (duration_secs * SYNTH_SAMPLE_RATE as f64).ceil() as usize;
    let mut mono = vec![0.0f32; num_samples];

    let hits = score.resolve_hits(&ctx, duration_secs)?;

    let mut voice_names: Vec<&String> = hits.keys().collect();
    voice_names.sort();

    let mut grains: HashMap<&str, Vec<f32>> = HashMap::new();
    for name in &voice_names {
        if let Some(voice) = score.voices.get(*name) {
            grains.insert(name.as_str(), voice.render_grain(SYNTH_SAMPLE_RATE));
        }
    }

    for name in &voice_names {
        let times = &hits[*name];
        let Some(grain) = grains.get(name.as_str()) else {
            continue;
        };
        for &t in times {
            if t < 0.0 {
                continue;
            }
            let start = (t * SYNTH_SAMPLE_RATE as f64).round() as i64;
            for (i, &s) in grain.iter().enumerate() {
                let idx = start + i as i64;
                if idx < 0 {
                    continue;
                }
                let idx = idx as usize;
                if idx >= mono.len() {
                    break;
                }
                mono[idx] += s;
            }
        }
    }

    if let Some(gain_db) = score.master.gain {
        let gain = 10f32.powf(gain_db / 20.0);
        for sample in mono.iter_mut() {
            *sample *= gain;
        }
    }
    if let Some(comp) = &score.master.compressor {
        dsp::apply_compressor(
            &mut mono,
            dsp::CompressorParams {
                threshold_db: comp.threshold,
                ratio: comp.ratio,
                attack_ms: comp.attack,
                release_ms: comp.release,
            },
            SYNTH_SAMPLE_RATE,
        );
    }
    if score.master.limiter {
        dsp::apply_limiter(&mut mono, SYNTH_SAMPLE_RATE);
    }

    let mut stereo = Vec::with_capacity(mono.len() * 2);
    for sample in mono {
        stereo.push(sample);
        stereo.push(sample);
    }
    Ok(stereo)
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::audio::score::{CompressorConfig, MasterBus, ScoreEvent};
    use crate::audio::voices::{FilterKind, FilterSpec, FreqSpec, OscKind, Voice};
    use crate::schema::time::TimePoint;

    fn issue_example_score() -> Score {
        let mut voices = HashMap::new();
        voices.insert(
            "kick".to_string(),
            Voice {
                kind: OscKind::Sine,
                freq: Some(FreqSpec::Sweep([150.0, 42.0])),
                sweep: Some(0.14),
                filter: None,
                attack: 0.002,
                decay: 0.38,
                sustain: 0.0,
                hold: 0.0,
                release: 0.0,
                gain: 1.0,
                seed: None,
            },
        );
        voices.insert(
            "hat".to_string(),
            Voice {
                kind: OscKind::Noise,
                freq: None,
                sweep: None,
                filter: Some(FilterSpec {
                    kind: FilterKind::Highpass,
                    freq: 7500.0,
                    q: 0.707,
                }),
                attack: 0.002,
                decay: 0.05,
                sustain: 0.0,
                hold: 0.0,
                release: 0.0,
                gain: 1.0,
                seed: Some(11),
            },
        );
        Score {
            voices,
            score: vec![
                ScoreEvent {
                    voice: "kick".to_string(),
                    at: None,
                    every: Some(TimePoint::Spec("1b".to_string())),
                    from: Some(TimePoint::Spec("@2.2s".to_string())),
                    to: Some(TimePoint::Spec("@12.6s".to_string())),
                    offset: None,
                    gain: None,
                },
                ScoreEvent {
                    voice: "hat".to_string(),
                    at: None,
                    every: Some(TimePoint::Spec("1b".to_string())),
                    from: Some(TimePoint::Spec("@2.2s".to_string())),
                    to: None,
                    offset: Some(TimePoint::Spec("0.5b".to_string())),
                    gain: None,
                },
            ],
            master: MasterBus {
                gain: None,
                compressor: Some(CompressorConfig {
                    threshold: -14.0,
                    ratio: 4.0,
                    attack: 5.0,
                    release: 50.0,
                }),
                limiter: true,
            },
        }
    }

    fn ctx() -> TimeCtx {
        TimeCtx {
            bpm: Some(115.0),
            beat_offset: 0.0,
            scene_start: 0.0,
        }
    }

    #[test]
    fn render_produces_a_nonsilent_stereo_buffer_of_the_requested_length() {
        let buf = render(&issue_example_score(), ctx(), 13.0).expect("render must succeed");
        assert_eq!(
            buf.len(),
            (13.0 * SYNTH_SAMPLE_RATE as f64).ceil() as usize * 2
        );
        assert!(buf.iter().any(|&s| s.abs() > 0.01), "must not be silent");
    }

    #[test]
    fn render_is_deterministic_across_two_calls() {
        let a = render(&issue_example_score(), ctx(), 13.0).unwrap();
        let b = render(&issue_example_score(), ctx(), 13.0).unwrap();
        assert_eq!(a, b);
    }

    #[test]
    fn render_with_limiter_on_never_exceeds_the_limiter_ceiling() {
        let buf = render(&issue_example_score(), ctx(), 13.0).unwrap();
        let ceiling = 10f32.powf(dsp::LIMITER_CEILING_DB / 20.0);
        assert!(buf.iter().all(|&s| s.abs() <= ceiling + 1e-6));
    }

    #[test]
    fn stereo_channels_are_identical_mono_duplicated() {
        let buf = render(&issue_example_score(), ctx(), 13.0).unwrap();
        for pair in buf.as_chunks::<2>().0 {
            assert_eq!(pair[0], pair[1]);
        }
    }

    #[test]
    fn unknown_voice_propagates_as_a_score_error() {
        let mut score = issue_example_score();
        score.score.push(ScoreEvent {
            voice: "cowbell".to_string(),
            at: Some(TimePoint::Seconds(1.0)),
            every: None,
            from: None,
            to: None,
            offset: None,
            gain: None,
        });
        let err = render(&score, ctx(), 13.0).unwrap_err();
        assert!(matches!(err, ScoreError::UnknownVoice(v) if v == "cowbell"));
    }
}