//! Intonation: sung lines rendered offline and tracked (`tuning`), every //! note's pitch against its score in cents. The survey (ignored, run with //! `--ignored --nocapture`) prints voices × ranges × note lengths × leaps × //! vibrato on/off; the tests hold the targets: a held note's centre within //! ±5 cents, no systematic offset, arrival settled within about 60 ms. use makepad_score_voice::render::render_line; use makepad_score_voice::tuning::{self, NoteReport, TargetNote}; use makepad_score_voice::*; use std::sync::Arc; const RATE: f32 = 48_000.0; const BPM: f64 = 120.0; /// (note, beats, syllable) phrases. fn phrase(list: &[(f32, f64)], lyrics: &str) -> Lowered { let mut beat = 0.0; let notes: Vec = list .iter() .map(|(p, d)| { let n = NoteIn { beat, dur: *d, pitches: vec![*p], vel: 0.8 }; beat += d; n }) .collect(); lower_sing(¬es, lyrics, &SingOpts { bpm: BPM, min_beat: -4.0, ..SingOpts::default() }, &builtin_pronounce) } /// Sing `line` solo and report every note (its vowel to the next note's /// first sound). fn sing(profile: &str, style: Style, seed: u32, line: &Lowered) -> Vec { let mut singer = Singer::new(Arc::new(VoiceProfile::named(profile).unwrap()), RATE, style, seed); let (l, r) = render_line(&mut singer, line, BPM, RATE, 256, 0.4); let x: Vec = l.iter().zip(&r).map(|(a, b)| a + b).collect(); let origin = line.events.iter().map(|e| e.beat).fold(f64::INFINITY, f64::min); let secs = |b: f64| ((b - origin) * 60.0 / BPM) as f32; let targets: Vec = line .events .iter() .enumerate() .map(|(i, e)| { let end = line.events.get(i + 1).map_or(e.beat + e.dur, |n| n.beat); TargetNote { start: secs(e.vowel_beat), end: secs(end), note: e.note } }) .collect(); tuning::report(&x, RATE, &targets, style.vibrato_depth * 100.0) } /// The pitch the singer was told to sing (its control curve, no audio), /// reported the same way: what the synthesis adds is the difference. fn commanded(profile: &str, style: Style, seed: u32, line: &Lowered) -> Vec { let mut singer = Singer::new(Arc::new(VoiceProfile::named(profile).unwrap()), RATE, style, seed); let step = 0.005 * BPM / 60.0; let curve = makepad_score_voice::render::pitch_curve(&mut singer, line, BPM, step); let origin = line.events.iter().map(|e| e.beat).fold(f64::INFINITY, f64::min); let secs = |b: f64| ((b - origin) * 60.0 / BPM) as f32; line.events .iter() .enumerate() .map(|(i, e)| { let target = TargetNote { start: secs(e.vowel_beat), end: secs(line.events.get(i + 1).map_or(e.beat + e.dur, |n| n.beat)), note: e.note }; let frames: Vec<(f32, f32)> = curve .iter() .map(|(b, m)| (secs(*b) - target.start, (m - e.note) * 100.0)) .filter(|(t, _)| *t >= 0.0 && *t < target.end - target.start) .collect(); tuning::note_report(target, &frames, style.vibrato_depth * 100.0) }) .collect() } fn show(name: &str, reports: &[NoteReport], detail: bool) { let (mean, mean_abs, worst, drift, settle) = tuning::summary(reports); println!("{name:44} centre mean {mean:+5.1} |mean| {mean_abs:4.1} worst {worst:5.1} drift {drift:4.1} settle {:3.0} ms", settle * 1000.0); if detail { for r in reports { println!( " note {:5.1} {:4.2}s centre {:+6.1} drift {:+6.1} arrival {:+6.1} settle {:4.0} ms ({} frames)", r.target.note, r.target.end - r.target.start, r.centre, r.drift, r.arrival, r.settle * 1000.0, r.frames ); } } } fn lines() -> Vec<(&'static str, Vec<(f32, f64)>, &'static str)> { vec![ ("steps (quarters)", vec![(0.0, 1.0), (2.0, 1.0), (4.0, 1.0), (5.0, 1.0), (7.0, 1.0), (5.0, 1.0), (4.0, 1.0), (2.0, 1.0), (0.0, 2.0)], "la la la la la la la la la"), ("leaps", vec![(0.0, 1.0), (12.0, 1.0), (7.0, 1.0), (0.0, 1.0), (9.0, 2.0), (-3.0, 1.0), (4.0, 2.0)], "sing a bright song to the sky"), ("long notes", vec![(0.0, 6.0), (4.0, 6.0), (7.0, 8.0)], "ah oh ee"), ("eighths", vec![(0.0, 0.5), (2.0, 0.5), (4.0, 0.5), (2.0, 0.5), (0.0, 0.5), (4.0, 0.5), (7.0, 0.5), (4.0, 0.5), (0.0, 2.0)], "ta ta ta ta ta ta ta ta ta"), ("vowels", vec![(0.0, 3.0), (0.0, 3.0), (0.0, 3.0), (0.0, 3.0), (0.0, 3.0)], "/ɑ/ /i/ /u/ /ɛ/ /oʊ/"), ("slurred melisma", vec![(0.0, 1.0), (2.0, 0.5), (4.0, 0.5), (5.0, 1.0), (4.0, 1.0), (0.0, 2.0)], "glo _ _ _ _ ry"), ] } fn voices() -> [(&'static str, f32); 5] { [("bass", 43.0), ("baritone", 50.0), ("tenor", 57.0), ("alto", 62.0), ("soprano", 69.0)] } /// Held notes sing in tune: every voice and range, eight seeds, with /// vibrato, drift and jitter (the defaults). #[test] fn held_notes_are_in_tune() { let mut all = Vec::new(); for (voice, base) in voices() { for (lname, list, lyrics) in lines() { let list: Vec<(f32, f64)> = list.iter().map(|(p, d)| (base + p, *d)).collect(); let line = phrase(&list, lyrics); for seed in [1, 2] { for r in sing(voice, Style::default(), seed * 31 + base as u32, &line) { let len = r.target.end - r.target.start; if len >= 0.3 && r.centre.is_finite() { // Long notes within 5 cents, shorter ones within 8. let limit = if len >= 0.8 { 5.0 } else { 8.0 }; assert!(r.centre.abs() <= limit, "{voice} {lname} seed {seed}: note {} ({len:.2} s) centred {:+.1} cents", r.target.note, r.centre); all.push(r); } } } } } let (mean, mean_abs, worst, drift, _) = tuning::summary(&all); println!("{} notes: centre mean {mean:+.2}, mean |centre| {mean_abs:.2}, worst {worst:.1}, mean |drift| {drift:.1}", all.len()); assert!(mean.abs() < 1.0, "a systematic offset of {mean:+.2} cents"); assert!(mean_abs < 3.0 && drift < 6.0); } /// A new pitch lands on its vowel: steps, leaps up to an octave and /// vowel-initial joins (nothing to glide through) settle within 15 cents /// by about 60 ms into the vowel, overshooting by little. #[test] fn transitions_land_on_the_vowel() { let style = Style { vibrato_depth: 0.0, drift_cents: 0.0, jitter_cents: 0.0, ..Style::default() }; for (voice, base) in voices() { for (lname, list, lyrics) in lines() { let list: Vec<(f32, f64)> = list.iter().map(|(p, d)| (base + p, *d)).collect(); let line = phrase(&list, lyrics); for r in sing(voice, style, 1, &line) { assert!(r.settle.is_nan() || r.settle <= 0.065, "{voice} {lname}: note {} settles at {:.0} ms", r.target.note, r.settle * 1000.0); assert!(r.centre.abs() <= 1.5, "{voice} {lname}: note {} centred {:+.1} cents", r.target.note, r.centre); } } } } /// A legato join does not jump: the vibrato dies away into the new note /// instead of stopping dead mid-swing (which stepped the pitch by up to /// its full depth, 30 cents, within one block). Vowel-to-vowel joins on the /// same pitch and by step, the vibrato's part of every block's pitch (what /// the voicer sings) against the one before; the held notes still centre /// within 5 cents. #[test] fn legato_joins_are_continuous() { let style = Style { drift_cents: 0.0, jitter_cents: 0.0, ..Style::default() }; // One block of the singer's control rate, in beats. let block = 64.0 / RATE as f64 * BPM / 60.0; let mut worst_all = 0.0f32; for (voice, base) in voices() { for seed in 1..=6u32 { for steps in [[0.0f32, 0.0, 0.0, 0.0, 0.0, 0.0], [0.0, 2.0, 4.0, 2.0, 0.0, 2.0]] { let list: Vec<(f32, f64)> = steps.iter().enumerate().map(|(i, s)| (base + s, 1.0 + 0.37 * ((i as u32 * 7 + seed) % 5) as f64)).collect(); let line = phrase(&list, "ah ah ah ah ah ah"); let curve_of = |style: Style| { let mut singer = Singer::new(Arc::new(VoiceProfile::named(voice).unwrap()), RATE, style, seed); makepad_score_voice::render::pitch_curve(&mut singer, &line, BPM, block) }; // The vibrato's part of the pitch: the curve less the same // voice's without vibrato (its glides and scoops). let (with, without) = (curve_of(style), curve_of(Style { vibrato_depth: 0.0, ..style })); let vib: Vec<(f64, f32)> = with.iter().zip(&without).map(|(a, b)| (a.0, a.1 - b.1)).collect(); for e in &line.events[1..] { // The blocks either side of the join. let at = vib.partition_point(|(b, _)| *b < e.beat); let near = &vib[at.saturating_sub(4)..(at + 4).min(vib.len())]; let worst = near.windows(2).map(|w| ((w[1].1 - w[0].1) * 100.0).abs()).fold(0.0, f32::max); // A swing of 30 cents at 5.4 Hz moves at most 1.4 cents // a block; ramping its depth down adds as much again. worst_all = worst_all.max(worst); assert!(worst <= 2.5, "{voice} seed {seed}: join onto {} at beat {} steps the vibrato {worst:.1} cents in a block", e.note, e.beat); } for r in sing(voice, style, seed, &line) { if r.target.end - r.target.start >= 0.8 { assert!(r.centre.abs() <= 5.0, "{voice} seed {seed}: note {} centred {:+.1} cents", r.target.note, r.centre); } } } } } println!("worst vibrato step at a join: {worst_all:.1} cents a block"); } /// A choir's parts are each in tune: the singers' detune spreads about the /// part's pitch (a part of one singer sings the note). #[test] fn choir_parts_are_centred() { let profiles = [Arc::new(VoiceProfile::named("alto").unwrap())]; for seed in 0..6 { let style = ChoirStyle { base: Style { vibrato_depth: 0.0, drift_cents: 0.0, jitter_cents: 0.0, ..Style::default() }, parts: 3, timing_jitter: 0.0, ..ChoirStyle::default() }; let mut choir = Choir::new(&profiles, 3, RATE, style, seed); for part in 0..3u8 { choir.note_on(0, SungNote { note: 64.0, vel: 0.8, dur: 1.0, syl: PhonemeSeq::ipa("ɑ") }, part); let mut x = vec![0.0f32; (1.3 * RATE) as usize]; choir.render(&mut x); let r = tuning::report(&x, RATE, &[TargetNote { start: 0.0, end: 1.0, note: 64.0 }], 0.0)[0]; assert!(r.centre.abs() < 1.0, "seed {seed} part {part}: centred {:+.1} cents", r.centre); } } } #[test] #[ignore] fn survey() { let styles = [ ("default", Style::default()), ("no vibrato", Style { vibrato_depth: 0.0, ..Style::default() }), ("no drift/jitter", Style { drift_cents: 0.0, jitter_cents: 0.0, ..Style::default() }), ("straight", Style::straight()), ]; for (voice, base) in voices() { for (lname, list, lyrics) in lines() { let list: Vec<(f32, f64)> = list.iter().map(|(p, d)| (base + p, *d)).collect(); let line = phrase(&list, lyrics); for (sname, style) in styles { let reports = sing(voice, style, 3, &line); show(&format!("{voice} {lname} [{sname}]"), &reports, voice == "tenor" && sname == "default"); if voice == "tenor" { show(&format!(" (commanded) [{sname}]"), &commanded(voice, style, 3, &line), false); } } } } // Seeds: how far the default voice's held notes wander from singer to singer. let line = phrase(&[(57.0, 4.0), (60.0, 4.0), (64.0, 4.0), (62.0, 4.0)], "ah ah ah ah"); let mut all = Vec::new(); for seed in 0..24 { all.extend(sing("tenor", Style::default(), seed, &line)); } show("tenor held notes, 24 seeds [default]", &all, false); }