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 ¬e 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())
}