//! Model-dependent checks of the word-timing paths (EDITS.md §9 P8, P9). //! Ignored by default: they need the weights on this machine. //! //! cargo test --release -p makepad-ai-speech --test timings -- --ignored --nocapture //! //! Weights, first found wins: //! - Kokoro: `$MAKEPAD_TTS_MODEL`, else `~/.makepad/weights/tts/kokoro-v1_0.mktts`; //! voice `$MAKEPAD_TTS_VOICE`, else the first of `af_heart`, `bm_daniel`, //! `bf_lily` `.mkvoice` next to the model. //! - Whisper: `$MAKEPAD_VOICE_MODEL`, else `local/models/ggml-large-v3-turbo.bin` //! in the Makepad checkout, else `~/.makepad/weights/stt/ggml-large-v3-turbo.bin`. //! //! Tokenizer fixtures come from two independent sources, neither of them this //! encoder: the vocab file's own entries (a string that IS one token in the //! file must encode to exactly that id; the ids below were read out of //! `ggml-large-v3-turbo.bin`'s vocab section, and agree with OpenAI's //! `multilingual.tiktoken` ranks: "," 11, "." 13, " the" 264), and the //! model's own writing (Whisper transcribes a Kokoro take; the token ids it //! emitted must be what the encoder makes of the text it emitted). use makepad_ai_speech::kokoro::KokoroSpeaker; use makepad_ai_speech::whisper::{WhisperModel, WhisperParams, WhisperState}; use makepad_ai_speech::{SpeechAudio, WordTiming}; use std::path::PathBuf; fn home() -> PathBuf { PathBuf::from(std::env::var("HOME").unwrap_or_default()) } fn env_file(name: &str) -> Option { std::env::var(name).ok().map(PathBuf::from).filter(|p| p.is_file()) } fn kokoro_paths() -> (PathBuf, PathBuf) { let model = env_file("MAKEPAD_TTS_MODEL") .unwrap_or_else(|| home().join(".makepad/weights/tts/kokoro-v1_0.mktts")); assert!(model.is_file(), "no kokoro weights at {model:?} (set MAKEPAD_TTS_MODEL)"); let dir = model.parent().unwrap().to_path_buf(); let voice = env_file("MAKEPAD_TTS_VOICE") .or_else(|| { ["af_heart", "bm_daniel", "bf_lily"] .iter() .map(|v| dir.join(format!("{v}.mkvoice"))) .find(|p| p.is_file()) }) .expect("no kokoro voice pack next to the model (set MAKEPAD_TTS_VOICE)"); (model, voice) } fn whisper_path() -> PathBuf { let checkout = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../../../local/models/ggml-large-v3-turbo.bin"); let path = env_file("MAKEPAD_VOICE_MODEL") .or_else(|| checkout.is_file().then_some(checkout)) .unwrap_or_else(|| home().join(".makepad/weights/stt/ggml-large-v3-turbo.bin")); assert!(path.is_file(), "no whisper weights at {path:?} (set MAKEPAD_VOICE_MODEL)"); path } fn speaker() -> KokoroSpeaker { let (model, voice) = kokoro_paths(); KokoroSpeaker::load_with_voice(&model.to_string_lossy(), &voice.to_string_lossy()).expect("load kokoro") } const SENTENCES: &[&str] = &[ "Notes starts with one tap.", "Type, and it saves as you go.", "That's it.", "In 2026 we shipped 42 apps — each one faster than the last!", "Hello world. This is a second sentence, with a pause; and a third clause?", ]; /// Where the take is audible: the first and last 10 ms RMS frame above 5 % /// of the loudest one, in seconds. fn envelope(audio: &SpeechAudio) -> (f64, f64) { let hop = (audio.sample_rate / 100) as usize; let rms: Vec = audio .samples .chunks(hop) .map(|c| (c.iter().map(|x| x * x).sum::() / c.len() as f32).sqrt()) .collect(); let peak = rms.iter().cloned().fold(0.0, f32::max); let on = rms.iter().position(|r| *r > peak * 0.05).unwrap_or(0) as f64 * 0.01; let off = rms.iter().rposition(|r| *r > peak * 0.05).unwrap_or(0) as f64 * 0.01; (on, off) } /// `speed` is the synthesis speed: the lead-in and closing pause scale with it. fn check_timings(text: &str, audio: &SpeechAudio, speed: f64) -> Vec { let timings = audio.timings.clone().expect("kokoro timings"); let words: Vec<&str> = text.split_whitespace().collect(); assert_eq!(timings.len(), words.len(), "{text:?}: {timings:?}"); for (timing, word) in timings.iter().zip(&words) { assert_eq!(timing.word, *word); } let duration = audio.duration_secs() as f64; let mut last_end = 0.0; for timing in &timings { assert!(timing.start >= last_end - 1e-9 && timing.end >= timing.start, "{text:?}: {timings:?}"); last_end = timing.end; } // Cover the audio: speech starts right after the lead-in pad and the // last word ends at most a closing pause before the end. assert!(timings[0].start < 0.5 / speed, "{text:?}: first word at {}", timings[0].start); assert!(last_end <= duration + 1e-6, "{text:?}: {last_end} > {duration}"); assert!(duration - last_end < 1.0 / speed, "{text:?}: last word ends {last_end} of {duration}"); timings } #[test] #[ignore = "needs kokoro weights"] fn kokoro_timings_cover_the_audio_word_for_word() { let mut speaker = speaker(); for text in SENTENCES { let audio = speaker.synthesize(text).expect("synthesize"); let timings = check_timings(text, &audio, 1.0); let (on, off) = envelope(&audio); eprintln!("{text:?} ({:.2}s) envelope {on:.2}..{off:.2}", audio.duration_secs()); // Kokoro's nominal first-phoneme boundary sits just after the // acoustic onset (measured 0.03-0.08 s: the decoder's receptive // field starts the sound a little early). assert!((timings[0].start - on).abs() < 0.15, "{text:?}: first word {} vs onset {on}", timings[0].start); for t in &timings { eprintln!(" {:>7.3} {:>7.3} {}", t.start, t.end, t.word); } } // A text long enough to be synthesized in several chunks. let long = SENTENCES.join(" ").repeat(6); let audio = speaker.synthesize(&long).expect("synthesize long"); check_timings(&long, &audio, 1.0); // Speed scales the clock. let slow = speaker.synthesize_with_speed(SENTENCES[0], 0.5).expect("slow"); let fast = speaker.synthesize_with_speed(SENTENCES[0], 2.0).expect("fast"); let (slow, fast) = (check_timings(SENTENCES[0], &slow, 0.5), check_timings(SENTENCES[0], &fast, 2.0)); assert!(slow.last().unwrap().end > 2.5 * fast.last().unwrap().end); } #[test] #[ignore = "needs whisper weights"] fn whisper_encoder_matches_the_vocab_file() { let model = WhisperModel::load_file(&whisper_path().to_string_lossy()).expect("load whisper"); let tok = model.tokenizer(); // Read out of the file's vocab: each string is one token there. let fixtures: &[(&str, i32)] = &[(",", 11), (".", 13), (" the", 264)]; for (text, id) in fixtures { assert_eq!(model.vocab.token_bytes[*id as usize], text.as_bytes()); assert_eq!(tok.encode(text), vec![*id], "{text:?}"); } // Every ordinary vocab entry that is valid UTF-8 encodes back to itself: // tiktoken's merges rebuild each of its tokens from the token's bytes, // unless the pre-split cuts the string apart first. let mut checked = 0; let mut mismatched = Vec::new(); for id in 0..tok.n_text() as i32 { let bytes = tok.token_bytes(id); let Ok(text) = std::str::from_utf8(bytes) else { continue }; if text.is_empty() { continue; } checked += 1; let ids = tok.encode(text); if ids != vec![id] { mismatched.push((id, text.to_string(), ids)); } } eprintln!("{checked} utf-8 tokens checked, {} encode to several tokens", mismatched.len()); for (id, text, ids) in &mismatched { eprintln!(" {id} {text:?} -> {ids:?}"); } // The only tokens a text cannot reach are upper-case contractions // ("'S", "'RE"): the reference pattern's `'s|'t|…` is case-sensitive, so // tiktoken splits them off as "'" + "S" too. for (_, text, _) in &mismatched { assert!(text.starts_with('\'') && text[1..].chars().all(|c| c.is_ascii_uppercase()), "{text:?}"); } // Round trips on varied text. for text in [ " And so my fellow Americans, ask not what your country can do for you.", "Ça va? Très bien — merci! Größe, naïve café, 東京タワー, Привет мир, 😀👍", " multiple spaces\tand\nnewlines\r\n", "It's 3.14… \"quoted\" (paren) [bracket] {brace} <|en|>", ] { assert_eq!(tok.decode(&tok.encode(text)), text); } } #[test] #[ignore = "needs kokoro and whisper weights"] fn whisper_encodes_what_whisper_writes_and_aligns_kokoro_takes() { let mut speaker = speaker(); let model = WhisperModel::load_file(&whisper_path().to_string_lossy()).expect("load whisper"); let mut state = WhisperState::new(&model); let tok = model.tokenizer(); // Word-start errors against Kokoro's clock, per path. let (mut text_errors, mut forced_errors, mut pass1_errors) = (Vec::new(), Vec::new(), Vec::new()); for text in SENTENCES { let audio = speaker.synthesize(text).expect("synthesize"); let kokoro = check_timings(text, &audio, 1.0); let samples_16k = audio.resampled(16_000); // The model's own tokens for what it heard must be the encoder's. let params = WhisperParams::default(); let segments = state.transcribe_aligned(&model, &samples_16k, ¶ms); for segment in &segments { let text_ids: Vec = segment.tokens.iter().copied().filter(|id| (*id as usize) < tok.n_text()).collect(); let written = tok.decode(&text_ids); eprintln!("whisper wrote {written:?} = {text_ids:?}"); assert_eq!(tok.encode(&written), text_ids, "{written:?}"); } // Pass 1 (transcription DTW) and the teacher-forced pass over the // tokens Whisper wrote, matched to Kokoro's words by their text. for segment in &segments { let pass1: Vec<(String, f64)> = segment.words.iter().map(|w| (w.text.clone(), w.start_ms as f64 / 1000.0)).collect(); pass1_errors.extend(start_errors(&pass1, &kokoro)); let forced = state.force_align(&model, &samples_16k, &segment.tokens, "en").expect("force_align"); let forced: Vec<(String, f64)> = forced.iter().map(|w| (w.text.clone(), w.start_ms as f64 / 1000.0)).collect(); forced_errors.extend(start_errors(&forced, &kokoro)); } // Forced alignment of the known text lands on Kokoro's own clock. let aligned = state.align_text(&model, &samples_16k, text, "en").expect("align_text"); assert_eq!(aligned.len(), kokoro.len()); for (a, k) in aligned.iter().zip(&kokoro) { assert_eq!(a.text, k.word); eprintln!( " {:<10} kokoro {:.3}-{:.3} whisper {:.3}-{:.3} ({:.2})", a.text, k.start, k.end, a.start, a.end, a.score ); text_errors.push(a.start - k.start); } let mut last = 0.0; for a in &aligned { assert!(a.start >= last && a.end >= a.start); last = a.end; } // The first word starts where the take becomes audible. let (on, _) = envelope(&audio); assert!((aligned[0].start - on).abs() < 0.1, "{text:?}: first word {} vs onset {on}", aligned[0].start); } // Kokoro's clock is nominal (phoneme frame boundaries, ~0.05 s after the // acoustic onset) and Whisper's is attention on a 20 ms grid. Before the // row fix every path ran +0.12..+0.15 s late (a whole token); now the // starts must be unbiased to within a frame or two. "2026" is the one // outlier: Kokoro says "two thousand twenty six", Whisper wrote digits. for (name, errors) in [("align_text", &text_errors), ("force_align", &forced_errors), ("pass 1", &pass1_errors)] { let n = errors.len().max(1) as f64; let bias = errors.iter().sum::() / n; let mean = errors.iter().map(|e| e.abs()).sum::() / n; eprintln!("{name:<11} {} words: start bias {bias:+.3}s, mean |error| {mean:.3}s", errors.len()); assert!(errors.len() >= 30, "{name}: only {} words matched", errors.len()); assert!(bias.abs() < 0.06, "{name}: start bias {bias}"); assert!(mean < 0.08, "{name}: mean start error {mean}"); } } /// Start errors (seconds) of `words` against Kokoro's words, matched in /// order by their letters and digits (Whisper may write "42" where the /// script says "forty two"; those simply do not match). fn start_errors(words: &[(String, f64)], kokoro: &[WordTiming]) -> Vec { let norm = |w: &str| -> String { w.chars().filter(|c| c.is_alphanumeric()).flat_map(|c| c.to_lowercase()).collect() }; let mut out = Vec::new(); let mut next = 0; for (word, start) in words { let word = norm(word); if let Some(k) = (next..kokoro.len().min(next + 4)).find(|k| norm(&kokoro[*k].word) == word) { out.push(start - kokoro[k].start); next = k + 1; } } out }