Preview App midi_visualizer 示例输出

scenes.rs(共享场景与项目装配)

//! Scrolling piano-roll visualization of the bundled Nyan Cat MIDI file,
//! with the song synthesized into the scene as a [`Sound`] leaf.
//!
//! MIDI source: <https://freemidi.org/getter-25332>
//! See `SOURCE.md` for the download filename, retrieval date, and checksum.

#[path = "midi.rs"]
pub mod midi;
#[path = "visual.rs"]
pub mod visual;

use std::{f64::consts::TAU, sync::Arc};

use midi::parse_song;
use ranim::{glam::DVec3, prelude::*, utils::rate_functions::linear};
use ranim_core::animation::{
    build::{IntoAnimNode, Unplaced},
    eval::StaticAnim,
};
use visual::{
    FRAME_HEIGHT, HitEffectsEval, MidiNotesEval, PianoKeyboardEval, PianoLayout, SingleNoteEval,
    make_background,
};

/// Synthesize the whole song into one clip: each note becomes a harmonic
/// tone with a short attack/release envelope; notes are summed and the
/// result is peak-normalized.
///
/// Thousands of dense note events belong in one pre-summed clip — a
/// [`Sound`] leaf per note would scan the scene window once per leaf during
/// mixing. Leaves are for sparse placement, not for event tracks.
fn render_song_audio(song: &midi::MidiSong) -> AudioClip {
    const SAMPLE_RATE: u32 = 48_000;
    const ATTACK_SECS: f64 = 0.004;
    const RELEASE_SECS: f64 = 0.04;
    // The parser closes stuck notes at song end; cap the drone.
    const MAX_NOTE_SECS: f64 = 5.0;

    // Floor, not ceil: the clip must not run past the scene's visual window,
    // or the sub-frame tail renders without a camera (D0002).
    let total = (song.duration_secs * f64::from(SAMPLE_RATE)) as usize;
    let mut pcm = vec![0.0_f64; total];
    for note in &song.notes {
        let freq = 440.0 * ((f64::from(note.key) - 69.0) / 12.0).exp2();
        let amp = f64::from(note.velocity) / 127.0;
        let note_len = (note.end_sec - note.start_sec).min(MAX_NOTE_SECS);
        let len = (note_len * f64::from(SAMPLE_RATE)).ceil() as usize;
        let start = (note.start_sec * f64::from(SAMPLE_RATE)).round() as usize;
        let end = (start + len).min(total);
        let attack = ((ATTACK_SECS * f64::from(SAMPLE_RATE)) as usize).max(1);
        let release = ((RELEASE_SECS * f64::from(SAMPLE_RATE)) as usize).max(1);
        for (j, sample) in pcm[start..end].iter_mut().enumerate() {
            let mut envelope = amp;
            if j < attack {
                envelope *= j as f64 / attack as f64;
            }
            if j > len.saturating_sub(release) {
                envelope *= (len - j) as f64 / release as f64;
            }
            let t = j as f64 / f64::from(SAMPLE_RATE);
            let wave = 0.6 * (TAU * freq * t).sin()
                + 0.25 * (TAU * 2.0 * freq * t).sin()
                + 0.15 * (TAU * 3.0 * freq * t).sin();
            *sample += envelope * wave;
        }
    }

    let peak = pcm
        .iter()
        .fold(0.0_f64, |max, sample| max.max(sample.abs()));
    let scale = if peak > 0.8 { 0.8 / peak } else { 1.0 };
    AudioClip::from_pcm(
        pcm.into_iter()
            .map(|sample| (sample * scale) as f32)
            .collect::<Vec<_>>(),
        SAMPLE_RATE,
        1,
    )
}

fn common_layers(song: &Arc<midi::MidiSong>, layout: PianoLayout) -> AnimStack {
    let duration = song.duration_secs;
    let background = make_background(layout);

    stack![
        background.show().with_duration(duration),
        PianoKeyboardEval {
            song: song.clone(),
            layout,
        }
        .with_duration(duration)
        .with_rate_func(linear),
        HitEffectsEval {
            song: song.clone(),
            layout,
        }
        .with_duration(duration)
        .with_rate_func(linear),
    ]
}

fn camera(duration: f64) -> impl IntoAnimNode {
    CameraFrame {
        pos: DVec3::ZERO,
        frame_height: FRAME_HEIGHT,
        ..Default::default()
    }
    .show()
    .with_duration(duration)
}

#[scene(clear_color = "#070711")]
#[wasm_demo_doc]
#[output(fps = 30, dir = "./output/midi_visualizer")]
fn midi_visualizer(r: &mut RanimScene) {
    let song = Arc::new(parse_song());
    let layout = PianoLayout::new(&song);
    let duration = song.duration_secs;

    r.play(camera(duration));
    r.play(Sound::new(render_song_audio(&song)));
    r.play(stack![
        MidiNotesEval {
            song: song.clone(),
            layout,
        }
        .with_duration(duration)
        .with_rate_func(linear),
        common_layers(&song, layout),
    ]);
    r.insert_time_mark(duration * 0.5, TimeMark::Capture("preview.png".to_string()));
}

#[scene(clear_color = "#070711")]
#[wasm_demo_doc]
#[output(fps = 30, dir = "./output/midi_visualizer")]
fn midi_visualizer_per_note(r: &mut RanimScene) {
    let song = Arc::new(parse_song());
    let layout = PianoLayout::new(&song);

    let mut notes = AnimStack::new();
    for &note in &song.notes {
        let note_eval = SingleNoteEval::new(note, layout);
        let start_sec = note_eval.start_sec();
        let duration_secs = note_eval.duration_secs();
        notes.push(
            note_eval
                .with_duration(duration_secs)
                .with_rate_func(linear)
                .at(start_sec),
        );
    }
    r.play(camera(song.duration_secs));
    r.play(Sound::new(render_song_audio(&song)));
    r.play(stack![notes, common_layers(&song, layout)]);
    r.insert_time_mark(
        song.duration_secs * 0.5,
        TimeMark::Capture("preview.png".to_string()),
    );
}

#[test]
fn bundled_midi_parses() {
    let song = parse_song();
    assert_eq!(song.notes.len(), 3378);
    assert!(song.duration_secs > 58.0);
}

#[test]
fn audio_sample_rounding_keeps_the_final_camera() {
    // A fractional audio sample must not extend the timeline past the camera.
    let song = midi::MidiSong {
        notes: vec![],
        notes_by_key: vec![],
        max_note_duration_secs: 0.0,
        duration_secs: 1.0 + 0.5 / 48_000.0,
    };
    let mut scene = RanimScene::new();
    scene.play(camera(song.duration_secs));
    scene.play(Sound::new(render_song_audio(&song)));
    let sealed = scene.seal();
    assert_eq!(sealed.total_secs(), song.duration_secs);
    let mut frame = ranim_core::frame::EvaluatedFrame::new();
    for sec in [0.0, 1.0, sealed.total_secs()] {
        sealed.eval_at_sec(sec, &mut frame);
        let cameras = frame.items::<CameraFrame>().len();
        assert_eq!(cameras, 1, "at {sec}s");
    }
}

/// Register the scenes provided by this example.
pub struct ScenesPlugin;

impl ranim::Plugin for ScenesPlugin {
    fn build(&self, app: &mut bevy_app::App) {
        app.add_scenes([midi_visualizer::scene(), midi_visualizer_per_note::scene()]);
    }
}

main.rs(平台入口)

mod scenes;

#[cfg(not(target_arch = "wasm32"))]
fn main() {
    #[cfg(feature = "cmd-render")]
    {
        use ranim::prelude::*;
        let mut app = App::new();
        app.add_plugins(scenes::ScenesPlugin);
        run_cli(app.build());
    }
    #[cfg(not(feature = "cmd-render"))]
    compile_error!("native examples require the `cmd-render` feature (enabled by default)");
}

// The WASM module is driven by JavaScript through app().
#[cfg(target_arch = "wasm32")]
fn main() {}

#[cfg(target_arch = "wasm32")]
#[wasm_bindgen::prelude::wasm_bindgen]
pub fn app() -> ranim::wasm::WasmApp {
    use ranim::prelude::*;

    let mut app = App::new();
    app.add_plugins(scenes::ScenesPlugin);
    ranim::wasm::WasmApp::new(app.build())
}