makepad/libs/audio_synth/tests/instrument_renders.rs
Admin 0e64721efa render, model, platform, audio_synth, ai: shadow atlases clipped per cascade and face, portable math for identical model builds everywhere, foliage and city rendering, Score replaces the synth sequencer, and sandbox audio generation fixes
- render: CSM cascades and spot/point shadow faces draw inside a per-draw scissor, with a depth shader that has no discard for solid and rigid casters (downtown city shadow pass 3.3 ms → 0.4 ms, spot shadows 2.7 → 0.1 ms); cascade selection by caster bounds on Apple; per-frame model draw order built once; clustered light terms per pixel; foliage wind, impostors, grass and LOD model sets; city glass and interior light; the viewmodel eye-space fix for narrow panes; joint-following parts use the skin's exact joint matrix.
- model / csg / mesh_edit / remesh / gltf: makepad_csg_math::portable (sin/cos/atan/exp/ln/pow/hypot/cbrt from exactly rounded operations), used at 320 call sites, so a model hashes and builds byte-identically on macOS and Linux (MODEL_GENERATOR_VERSION 2, CHARACTER_GENERATOR_VERSION 42; tests/portable_identity.rs); lathes always face outward; sculpted-shell booleans with a tolerant surface map; decal and badge lettering; LOD model sets; grip poses for first-person arms.
- platform: DrawCall scissor rectangles (Metal and Vulkan; other backends keep the old clip); draw_shader_ready for a fallback while a pipeline compiles; Metal clock reads once per frame; Linux render-texture readback.
- audio_synth: score.rs and its Sequencer are removed; game music now runs on Stage's Score engine; drum_note moves into instruments.rs. Includes Stage SF1's SoundFontInstrument change: programs share one loaded font instead of ~300 MB each.
- ai: the sfx model generates at whole seconds and trims to the requested length (fractional lengths came back as noise); fleet roles let node 165 serve audio as a fallback.
- widgets/core: screen recordings of any app inside a Makepad checkout land in <makepad>/local/screencap.
- fab: FabDragNumber read_only, for the sandbox time dial.

Checked on node 165: cargo check --release --workspace; makepad crate suites 1442 passed; sandbox 3095 passed; portable model hashes identical on the Mac mini.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-30 08:11:15 +02:00

145 lines
7.4 KiB
Rust

//! Offline renders of every instrument playing a short phrase, written as
//! WAVs for listening (SYNTH_RENDER_DIR, with a scratch default) and checked
//! for level, NaN and render speed.
#![cfg(feature = "instruments")]
use makepad_audio_synth::instruments::{build, parse_kind, InstrumentKind, SoundFontInstrument};
use makepad_audio_synth::wav::stereo_wav_16;
use makepad_audio_synth::{Control, Instrument};
use std::time::Instant;
const RATE: f32 = 48_000.0;
const DEFAULT_DIR: &str =
"/private/tmp/claude-501/-Users-admin-makepad/bbb86735-3473-4fd6-ad57-d987802a0619/scratchpad/synth/instruments";
/// A phrase: (beat, MIDI note, beats) at a tempo, looped or once.
struct Phrase {
bpm: f32,
beats: f32,
looping: bool,
notes: &'static [(f32, u8, f32)],
}
const PIANO: Phrase = Phrase { bpm: 80.0, beats: 16.0, looping: false, notes: &[(0.0, 48, 4.0), (0.0, 55, 4.0), (0.0, 64, 4.0), (1.0, 67, 0.5), (1.5, 76, 0.5), (2.0, 74, 1.0), (3.0, 72, 1.0), (4.0, 45, 4.0), (4.0, 52, 4.0), (4.0, 60, 4.0), (5.0, 76, 0.5), (5.5, 72, 0.5), (6.0, 69, 2.0), (8.0, 41, 4.0), (8.0, 48, 4.0), (8.0, 57, 4.0), (9.0, 77, 0.5), (9.5, 81, 0.5), (10.0, 79, 1.0), (11.0, 77, 1.0), (12.0, 43, 4.0), (12.0, 50, 4.0), (12.0, 59, 4.0), (12.0, 74, 2.0), (14.0, 71, 2.0)] };
const DRUMS: Phrase = Phrase { bpm: 110.0, beats: 8.0, looping: true, notes: &[(0.0, 36, 1.0), (0.0, 42, 0.5), (0.5, 42, 0.5), (1.0, 38, 1.0), (1.0, 42, 0.5), (1.5, 46, 0.5), (2.0, 36, 0.5), (2.0, 46, 0.5), (2.5, 36, 0.5), (2.5, 46, 0.5), (3.0, 38, 1.0), (3.0, 46, 0.5), (3.5, 46, 0.5), (4.0, 36, 1.0), (4.0, 42, 0.5), (4.5, 42, 0.5), (5.0, 38, 1.0), (5.0, 42, 0.5), (5.5, 42, 0.5), (6.0, 36, 0.5), (6.0, 42, 0.5), (6.5, 36, 0.5), (6.5, 42, 0.5), (7.0, 38, 0.5), (7.0, 49, 1.0), (7.5, 39, 0.5)] };
const IRONFISH: Phrase = Phrase { bpm: 128.0, beats: 8.0, looping: true, notes: &[(0.0, 57, 0.5), (0.5, 57, 0.5), (1.0, 60, 0.5), (1.5, 57, 0.5), (2.0, 64, 0.5), (2.5, 57, 0.5), (3.0, 62, 0.5), (3.5, 60, 0.5), (4.0, 53, 0.5), (4.5, 53, 0.5), (5.0, 57, 0.5), (5.5, 53, 0.5), (6.0, 60, 0.5), (6.5, 53, 0.5), (7.0, 64, 0.5), (7.5, 62, 0.5)] };
const RIFF: Phrase = Phrase { bpm: 100.0, beats: 8.0, looping: false, notes: &[(0.0, 60, 0.5), (0.5, 64, 0.5), (1.0, 67, 0.5), (1.5, 72, 0.5), (2.0, 71, 1.0), (3.0, 67, 1.0), (4.0, 69, 0.5), (4.5, 72, 0.5), (5.0, 76, 0.5), (5.5, 81, 0.5), (6.0, 79, 2.0)] };
const CHORDS: Phrase = Phrase { bpm: 90.0, beats: 8.0, looping: false, notes: &[(0.0, 60, 2.0), (0.0, 64, 2.0), (0.0, 67, 2.0), (0.0, 71, 2.0), (2.0, 57, 2.0), (2.0, 60, 2.0), (2.0, 64, 2.0), (2.0, 67, 2.0), (4.0, 53, 2.0), (4.0, 57, 2.0), (4.0, 60, 2.0), (4.0, 64, 2.0)] };
const BASS: Phrase = Phrase { bpm: 100.0, beats: 8.0, looping: false, notes: &[(0.0, 36, 0.5), (0.5, 36, 0.5), (1.0, 43, 0.5), (1.5, 36, 0.5), (2.0, 34, 0.5), (2.5, 36, 0.5), (3.0, 31, 1.0), (4.0, 29, 0.5), (4.5, 29, 0.5), (5.0, 36, 0.5), (5.5, 29, 0.5), (6.0, 31, 2.0)] };
/// Render `secs` of `phrase` on `inst`, splitting each block at note on and
/// off frames so notes land sample-accurately. `pedal_bars` re-takes the
/// sustain pedal at every bar line (a pianist's legato pedalling).
fn render(inst: &mut dyn Instrument, phrase: &Phrase, secs: f32, pedal_bars: bool) -> (Vec<f32>, Vec<f32>, f64) {
let frames = (secs * RATE) as usize;
let per_beat = RATE * 60.0 / phrase.bpm;
// Every on (vel > 0) and off (vel 0) as (frame, note, vel), in order.
let mut events: Vec<(usize, u8, u8)> = Vec::new();
let mut offset = 0.0;
while (offset * per_beat) < frames as f32 {
for &(beat, note, dur) in phrase.notes {
events.push((((offset + beat) * per_beat) as usize, note, 100));
events.push((((offset + beat + dur) * per_beat) as usize, note, 0));
}
if !phrase.looping {
break;
}
offset += phrase.beats;
}
events.sort_by_key(|e| (e.0, e.2));
let (mut l, mut r) = (vec![0.0f32; frames], vec![0.0f32; frames]);
let bar = (per_beat * 4.0) as usize;
let start = Instant::now();
let mut at = 0;
let mut next = 0;
while at < frames {
let n = (frames - at).min(256);
if pedal_bars && at % bar < n {
inst.control(Control::Sustain, 0.0);
inst.control(Control::Sustain, 1.0);
}
let mut cursor = at;
while next < events.len() && events[next].0 < at + n {
let (f, note, vel) = events[next];
if f > cursor {
inst.render(&mut l[cursor..f], &mut r[cursor..f]);
cursor = f;
}
if vel > 0 { inst.note_on(note, vel) } else { inst.note_off(note) }
next += 1;
}
inst.render(&mut l[cursor..at + n], &mut r[cursor..at + n]);
at += n;
}
let speed = secs as f64 / start.elapsed().as_secs_f64();
(l, r, speed)
}
fn check_and_write(name: &str, gain: f32, l: &mut [f32], r: &mut [f32], speed: f64) {
for x in l.iter_mut().chain(r.iter_mut()) {
*x *= gain;
}
assert!(l.iter().chain(r.iter()).all(|x| x.is_finite()), "{name}: NaN");
let peak = l.iter().chain(r.iter()).fold(0.0f32, |a, x| a.max(x.abs()));
let rms = (l.iter().chain(r.iter()).map(|x| x * x).sum::<f32>() / (l.len() * 2) as f32).sqrt();
println!(
"{name:>16}: peak {:6.1} dBFS rms {:6.1} dBFS render {speed:7.1}x realtime",
20.0 * peak.max(1e-9).log10(),
20.0 * rms.max(1e-9).log10()
);
assert!(peak < 1.0, "{name}: clips at gain {gain} (peak {peak})");
assert!(rms > 1.0e-3, "{name}: silent (rms {rms})");
let dir = std::env::var("SYNTH_RENDER_DIR").unwrap_or_else(|_| DEFAULT_DIR.to_string());
if std::fs::create_dir_all(&dir).is_ok() {
let path = format!("{dir}/{name}.wav");
std::fs::write(&path, stereo_wav_16(l, r, RATE as u32)).unwrap();
println!("{:>16} -> {path}", "");
}
}
fn run(name: &str, kind: InstrumentKind, phrase: &Phrase, pedal: bool, gain: f32) {
let t = Instant::now();
let mut inst = build(&kind, RATE).unwrap_or_else(|e| panic!("{name}: {e}"));
println!("{name:>16}: built in {:.0} ms", t.elapsed().as_secs_f64() * 1000.0);
let (mut l, mut r, speed) = render(inst.as_mut(), phrase, 6.0, pedal);
check_and_write(name, gain, &mut l, &mut r, speed);
}
#[test]
fn render_every_instrument() {
let kind = |n: &str, p: Option<&str>| parse_kind(n, p).unwrap_or_else(|| panic!("{n}"));
// I - vi - IV - V with a melody on top, pedal re-taken each bar.
run(
"piano",
kind("piano", None),
&PIANO,
true,
1.0,
);
run(
"drums",
kind("drums", None),
&DRUMS,
false,
1.0,
);
run(
"ironfish",
kind("ironfish", Some("supersaw")),
&IRONFISH,
false,
1.0,
);
let (riff, chords) = (&RIFF, &CHORDS);
run("synth_lead", kind("lead", None), riff, false, 1.0);
run("synth_bass", kind("bass", None), &BASS, false, 1.0);
run("synth_pad", kind("pad", None), chords, false, 1.0);
run("synth_pluck", kind("pluck", None), riff, false, 1.0);
run("synth_epiano", kind("epiano", None), chords, false, 1.0);
// The soundfont crate's procedural piano (no SF2 file needed).
let t = Instant::now();
let mut sf = SoundFontInstrument::fallback_piano(RATE);
println!("{:>16}: built in {:.0} ms", "soundfont_fallback", t.elapsed().as_secs_f64() * 1000.0);
let (mut l, mut r, speed) = render(&mut sf, riff, 6.0, false);
check_and_write("soundfont_fallback", 1.0, &mut l, &mut r, speed);
}