dj sets + lyrics: a mix that names its own tracks, and words pulled off the separated voice
This commit is contained in:
1 parent
4efe85b6bf
commit
1dd8573808
41 files changed
+9288
-647
No files matched your search
@@ -0,0 +1,525 @@
|
||||
//! Tier B word alignment: CTC forced alignment with a small acoustic model
|
||||
//! through WinML — the same built-in runtime the hero detector uses, so no
|
||||
//! new dependency and no child processes (the preparation-worker charter).
|
||||
//!
|
||||
//! The model (MMS-300m forced-aligner, ONNX f32 export — Meta's multilingual
|
||||
//! wav2vec2 fine-tuned specifically FOR forced alignment, the checkpoint
|
||||
//! torchaudio's alignment API standardized on) emits per-20ms CHARACTER
|
||||
//! probabilities; a Viterbi pass aligns the KNOWN lyric text against them —
|
||||
//! no transcription, no guessing, just "when does each known word occur".
|
||||
//! Its vocab is lowercase romanized characters with NO word separator and
|
||||
//! blank = `<blank>`; the loader reads all of that from the repo's own
|
||||
//! vocab.json rather than hardcoding (the vocab-never-hardcoded rule).
|
||||
//! It is downloaded ONLY from the user's explicit click in Settings
|
||||
//! (`lyric_model_download`) — the charter's consent rule — verified by
|
||||
//! length, and loaded lazily. When it is absent or fails, the worker's
|
||||
//! envelope estimate stands; nothing ever depends on this module working.
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::path::PathBuf;
|
||||
use std::sync::Mutex;
|
||||
|
||||
use tauri::{Emitter, Manager};
|
||||
use windows::{
|
||||
core::{Interface, HSTRING},
|
||||
AI::MachineLearning::{
|
||||
LearningModel, LearningModelBinding, LearningModelDevice, LearningModelDeviceKind,
|
||||
LearningModelSession, TensorFloat,
|
||||
},
|
||||
Win32::System::WinRT::{RoInitialize, RO_INIT_MULTITHREADED},
|
||||
};
|
||||
|
||||
const MODEL_URL: &str =
|
||||
"https://huggingface.co/onnx-community/mms-300m-1130-forced-aligner-ONNX/resolve/main/onnx/model.onnx";
|
||||
const VOCAB_URL: &str =
|
||||
"https://huggingface.co/onnx-community/mms-300m-1130-forced-aligner-ONNX/resolve/main/vocab.json";
|
||||
/// Exact f32 model size, shown in Settings and sanity-checked after download.
|
||||
const MODEL_BYTES: u64 = 1_262_529_881;
|
||||
/// wav2vec2-family conv stride: 320 samples at 16 kHz (MMS keeps it).
|
||||
const FRAME_MS: i64 = 20;
|
||||
|
||||
struct Engine {
|
||||
session: LearningModelSession,
|
||||
/// char → token id, from the repo's own vocab.json (never hardcoded).
|
||||
chars: HashMap<char, usize>,
|
||||
vocab_len: usize,
|
||||
blank: usize,
|
||||
/// Word-separator token — wav2vec2-960h had `|`, MMS has none.
|
||||
sep: Option<usize>,
|
||||
}
|
||||
|
||||
static ENGINE: Mutex<Option<Engine>> = Mutex::new(None);
|
||||
|
||||
pub(crate) fn models_dir(app: &tauri::AppHandle) -> Result<PathBuf, String> {
|
||||
let dir = app
|
||||
.path()
|
||||
.app_data_dir()
|
||||
.map_err(|e| e.to_string())?
|
||||
.join("lyric-models");
|
||||
std::fs::create_dir_all(&dir).map_err(|e| e.to_string())?;
|
||||
Ok(dir)
|
||||
}
|
||||
|
||||
fn model_file(app: &tauri::AppHandle) -> Result<PathBuf, String> {
|
||||
Ok(models_dir(app)?.join("mms-300m-fa.onnx"))
|
||||
}
|
||||
|
||||
fn vocab_file(app: &tauri::AppHandle) -> Result<PathBuf, String> {
|
||||
Ok(models_dir(app)?.join("mms-300m-fa.vocab.json"))
|
||||
}
|
||||
|
||||
pub fn model_present(app: &tauri::AppHandle) -> bool {
|
||||
matches!((model_file(app), vocab_file(app)), (Ok(m), Ok(v)) if m.is_file() && v.is_file())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn lyric_model_status(app: tauri::AppHandle) -> serde_json::Value {
|
||||
let size = model_file(&app)
|
||||
.ok()
|
||||
.and_then(|p| std::fs::metadata(p).ok())
|
||||
.map(|m| m.len())
|
||||
.unwrap_or(0);
|
||||
serde_json::json!({
|
||||
"present": model_present(&app),
|
||||
"bytes": size,
|
||||
// The tier the machine would actually prepare under right now —
|
||||
// ctc-v5 once the separator model is ALSO on disk.
|
||||
"aligner": crate::lyric_prep::active_aligner(&app),
|
||||
"downloadBytes": MODEL_BYTES,
|
||||
})
|
||||
}
|
||||
|
||||
/// Download the model + vocab. Runs ONLY from the user's explicit Settings
|
||||
/// click — the consent-before-download charter rule. Streams to a temp file,
|
||||
/// renames atomically, emits `lyrics:model-progress` along the way.
|
||||
#[tauri::command]
|
||||
pub async fn lyric_model_download(app: tauri::AppHandle) -> Result<(), String> {
|
||||
let client = reqwest::Client::builder()
|
||||
.user_agent("SoulStream/0.1 (lyric alignment)")
|
||||
.build()
|
||||
.map_err(|e| e.to_string())?;
|
||||
// Vocab first — tiny, and the engine cannot load without it.
|
||||
let vocab_dest = vocab_file(&app)?;
|
||||
let vocab = client
|
||||
.get(VOCAB_URL)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| e.to_string())?
|
||||
.error_for_status()
|
||||
.map_err(|e| e.to_string())?
|
||||
.bytes()
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
serde_json::from_slice::<HashMap<String, usize>>(&vocab)
|
||||
.map_err(|_| "vocab.json did not parse".to_string())?;
|
||||
std::fs::write(&vocab_dest, &vocab).map_err(|e| e.to_string())?;
|
||||
|
||||
let dest = model_file(&app)?;
|
||||
let tmp = dest.with_extension("onnx.downloading");
|
||||
let mut response = client
|
||||
.get(MODEL_URL)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| e.to_string())?
|
||||
.error_for_status()
|
||||
.map_err(|e| e.to_string())?;
|
||||
let total = response.content_length().unwrap_or(0);
|
||||
let mut file = std::fs::File::create(&tmp).map_err(|e| e.to_string())?;
|
||||
let mut received: u64 = 0;
|
||||
let mut last_emit: u64 = 0;
|
||||
use std::io::Write;
|
||||
while let Some(chunk) = response.chunk().await.map_err(|e| e.to_string())? {
|
||||
file.write_all(&chunk).map_err(|e| e.to_string())?;
|
||||
received += chunk.len() as u64;
|
||||
if received - last_emit > 4 * 1024 * 1024 {
|
||||
last_emit = received;
|
||||
let _ = app.emit(
|
||||
"lyrics:model-progress",
|
||||
serde_json::json!({ "received": received, "total": total }),
|
||||
);
|
||||
}
|
||||
}
|
||||
drop(file);
|
||||
if total > 0 && received != total {
|
||||
let _ = std::fs::remove_file(&tmp);
|
||||
return Err(format!("download truncated: {received} of {total} bytes"));
|
||||
}
|
||||
std::fs::rename(&tmp, &dest).map_err(|e| e.to_string())?;
|
||||
// The superseded wav2vec2 tier's files are dead weight once this lands.
|
||||
if let Ok(dir) = models_dir(&app) {
|
||||
for legacy in ["wav2vec2-base-960h.onnx", "wav2vec2-base-960h.vocab.json"] {
|
||||
let _ = std::fs::remove_file(dir.join(legacy));
|
||||
}
|
||||
}
|
||||
let _ = app.emit(
|
||||
"lyrics:model-progress",
|
||||
serde_json::json!({ "received": received, "total": total, "done": true }),
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn ensure_engine(app: &tauri::AppHandle) -> Result<(), String> {
|
||||
let mut guard = ENGINE.lock().unwrap();
|
||||
if guard.is_some() {
|
||||
return Ok(());
|
||||
}
|
||||
let model_path = model_file(app)?;
|
||||
if !model_path.is_file() {
|
||||
return Err("model not downloaded".into());
|
||||
}
|
||||
let raw = std::fs::read(vocab_file(app)?).map_err(|e| e.to_string())?;
|
||||
let vocab: HashMap<String, usize> =
|
||||
serde_json::from_slice(&raw).map_err(|_| "vocab.json did not parse".to_string())?;
|
||||
let vocab_len = vocab.values().copied().max().unwrap_or(0) + 1;
|
||||
// CTC blank: MMS names it `<blank>` (its pad_token_id points there);
|
||||
// the wav2vec2-960h family used `<pad>` itself. Prefer the explicit one.
|
||||
let blank = *vocab
|
||||
.get("<blank>")
|
||||
.or_else(|| vocab.get("<pad>"))
|
||||
.ok_or("vocab has no blank token")?;
|
||||
let mut chars = HashMap::new();
|
||||
for (token, id) in &vocab {
|
||||
let mut it = token.chars();
|
||||
if let (Some(c), None) = (it.next(), it.next()) {
|
||||
chars.insert(c, *id);
|
||||
}
|
||||
}
|
||||
let sep = chars.get(&'|').copied();
|
||||
// Match the hero detector's WinRT posture: initialize, tolerate an
|
||||
// existing apartment, never uninitialize someone else's.
|
||||
match unsafe { RoInitialize(RO_INIT_MULTITHREADED) } {
|
||||
Ok(()) => {}
|
||||
Err(e) if e.code().0 as u32 == 0x8001_0106 => {}
|
||||
Err(e) => return Err(format!("WinRT init failed: {e}")),
|
||||
}
|
||||
let model =
|
||||
LearningModel::LoadFromFilePath(&HSTRING::from(model_path.to_string_lossy().as_ref()))
|
||||
.map_err(|e| format!("could not load alignment model: {e}"))?;
|
||||
let device = LearningModelDevice::Create(LearningModelDeviceKind::Cpu)
|
||||
.map_err(|e| format!("could not create WinML device: {e}"))?;
|
||||
let session = LearningModelSession::CreateFromModelOnDevice(&model, &device)
|
||||
.map_err(|e| format!("could not create alignment session: {e}"))?;
|
||||
*guard = Some(Engine {
|
||||
session,
|
||||
chars,
|
||||
vocab_len,
|
||||
blank,
|
||||
sep,
|
||||
});
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Bare-ASCII fold for the Latin letters MMS's romanized vocab expects:
|
||||
/// Western lyrics keep their diacritics, the model's chars do not. Anything
|
||||
/// outside this stays unmapped and the line falls back to the estimate.
|
||||
fn fold_latin(c: char) -> char {
|
||||
match c {
|
||||
'à'..='å' | 'À'..='Å' => 'a',
|
||||
'è'..='ë' | 'È'..='Ë' => 'e',
|
||||
'ì'..='ï' | 'Ì'..='Ï' => 'i',
|
||||
'ò'..='ö' | 'Ò'..='Ö' | 'ø' | 'Ø' => 'o',
|
||||
'ù'..='ü' | 'Ù'..='Ü' => 'u',
|
||||
'ý' | 'ÿ' | 'Ý' => 'y',
|
||||
'ñ' | 'Ñ' => 'n',
|
||||
'ç' | 'Ç' => 'c',
|
||||
// Typographic apostrophes down to the vocab's plain one.
|
||||
'’' | 'ʼ' | '`' => '\'',
|
||||
_ => c,
|
||||
}
|
||||
}
|
||||
|
||||
pub struct AlignedWord {
|
||||
pub start_ms: i64,
|
||||
pub end_ms: i64,
|
||||
}
|
||||
|
||||
pub struct AlignedLine {
|
||||
pub words: Vec<AlignedWord>,
|
||||
pub confidence: f32,
|
||||
}
|
||||
|
||||
/// Force-align a PASSAGE — one or more consecutive lines' words — against a
|
||||
/// 16 kHz mono window. Returns per-line word times RELATIVE to the window
|
||||
/// plus a per-line confidence, or Err when the model cannot carry the
|
||||
/// passage (the caller retries per line, then falls back to the estimate).
|
||||
///
|
||||
/// Aligning lines TOGETHER is what lets word boundaries pull free of a
|
||||
/// slightly-late source cue: inside a passage the line cues are only hints,
|
||||
/// not walls — the research bundle's "whole-song alignment using line
|
||||
/// starts as hints", bounded to passage windows. `floors_ms[l]` is line
|
||||
/// l's earliest permitted emission (window-relative): without that floor,
|
||||
/// a repeating chorus lets the path lock whole lines onto the PREVIOUS
|
||||
/// acoustic instance of the same words (the charter's "competing repeated
|
||||
/// sections") — hint means "at most this much early", never "anywhere".
|
||||
/// `ceils_ms[l]` is the floor's mirror, the latest permitted emission:
|
||||
/// without it a line may smear arbitrarily LATE across its successors'
|
||||
/// audio when the space between lines still sounds vocal-ish (a separated
|
||||
/// stem keeps reverb/delay tails), sustaining one token for seconds at
|
||||
/// HIGH margin confidence — free decoding hears the same tail, so the
|
||||
/// score cannot object. Hint means "at most this much late", too.
|
||||
pub fn align_passage(
|
||||
app: &tauri::AppHandle,
|
||||
pcm: &[f32],
|
||||
lines: &[Vec<String>],
|
||||
floors_ms: &[i64],
|
||||
ceils_ms: &[i64],
|
||||
) -> Result<Vec<AlignedLine>, String> {
|
||||
let word_total: usize = lines.iter().map(|l| l.len()).sum();
|
||||
if pcm.len() < 1600
|
||||
|| word_total == 0
|
||||
|| floors_ms.len() != lines.len()
|
||||
|| ceils_ms.len() != lines.len()
|
||||
{
|
||||
return Err("window too short".into());
|
||||
}
|
||||
ensure_engine(app)?;
|
||||
let guard = ENGINE.lock().unwrap();
|
||||
let engine = guard.as_ref().ok_or("engine unavailable")?;
|
||||
|
||||
// Zero-mean / unit-variance, the model's expected normalization.
|
||||
let mean = pcm.iter().sum::<f32>() / pcm.len() as f32;
|
||||
let var = pcm.iter().map(|s| (s - mean) * (s - mean)).sum::<f32>() / pcm.len() as f32;
|
||||
let scale = 1.0 / (var.sqrt() + 1e-7);
|
||||
let normed: Vec<f32> = pcm.iter().map(|s| (s - mean) * scale).collect();
|
||||
|
||||
let input = TensorFloat::CreateFromShapeArrayAndDataArray(&[1, normed.len() as i64], &normed)
|
||||
.map_err(|e| format!("could not create input tensor: {e}"))?;
|
||||
let binding = LearningModelBinding::CreateFromSession(&engine.session)
|
||||
.map_err(|e| format!("could not create binding: {e}"))?;
|
||||
binding
|
||||
.Bind(&HSTRING::from("input_values"), &input)
|
||||
.map_err(|e| format!("could not bind input: {e}"))?;
|
||||
let result = engine
|
||||
.session
|
||||
.Evaluate(&binding, &HSTRING::from("lyric-align"))
|
||||
.map_err(|e| format!("inference failed: {e}"))?;
|
||||
if !result.Succeeded().unwrap_or(false) {
|
||||
return Err("inference did not succeed".into());
|
||||
}
|
||||
let logits: TensorFloat = result
|
||||
.Outputs()
|
||||
.and_then(|o| o.Lookup(&HSTRING::from("logits")))
|
||||
.map_err(|e| format!("logits missing: {e}"))?
|
||||
.cast()
|
||||
.map_err(|e| format!("logits wrong type: {e}"))?;
|
||||
let values: Vec<f32> = logits
|
||||
.GetAsVectorView()
|
||||
.map_err(|e| format!("could not read logits: {e}"))?
|
||||
.into_iter()
|
||||
.collect();
|
||||
let v = engine.vocab_len;
|
||||
if v == 0 || values.len() % v != 0 {
|
||||
return Err(format!("unexpected logits length {}", values.len()));
|
||||
}
|
||||
let t_frames = values.len() / v;
|
||||
|
||||
// log-softmax per frame.
|
||||
let mut logp = vec![0.0f32; values.len()];
|
||||
for t in 0..t_frames {
|
||||
let row = &values[t * v..(t + 1) * v];
|
||||
let max = row.iter().cloned().fold(f32::NEG_INFINITY, f32::max);
|
||||
let sum: f32 = row.iter().map(|x| (x - max).exp()).sum();
|
||||
let log_z = max + sum.ln();
|
||||
for (i, x) in row.iter().enumerate() {
|
||||
logp[t * v + i] = x - log_z;
|
||||
}
|
||||
}
|
||||
|
||||
// Token sequence: each token knows its WORD (passage-global index), each
|
||||
// word knows its line; a separator joins words only when the vocab has
|
||||
// one (wav2vec2-960h did) — MMS aligns bare chars. Mapping is
|
||||
// vocab-driven and case-agnostic, with a Latin fold so accented lyrics
|
||||
// reach the romanized vocab. A word that maps to nothing (digits,
|
||||
// symbols, other scripts) sinks the whole passage to the per-line
|
||||
// ladder rather than being silently skipped.
|
||||
let map_char = |c: char| -> Option<usize> {
|
||||
engine
|
||||
.chars
|
||||
.get(&c)
|
||||
.or_else(|| c.to_lowercase().next().and_then(|l| engine.chars.get(&l)))
|
||||
.or_else(|| c.to_uppercase().next().and_then(|u| engine.chars.get(&u)))
|
||||
.or_else(|| {
|
||||
let f = fold_latin(c);
|
||||
engine
|
||||
.chars
|
||||
.get(&f)
|
||||
.or_else(|| f.to_uppercase().next().and_then(|u| engine.chars.get(&u)))
|
||||
})
|
||||
.copied()
|
||||
};
|
||||
let mut tokens: Vec<(usize, usize)> = Vec::new();
|
||||
// Per-token emission bounds, frames — the line's cue-derived floor and
|
||||
// its successor-derived ceiling.
|
||||
let mut floor_frame: Vec<usize> = Vec::new();
|
||||
let mut ceil_frame: Vec<usize> = Vec::new();
|
||||
let mut next_wi = 0usize;
|
||||
for (li, line) in lines.iter().enumerate() {
|
||||
let floor = (floors_ms[li].max(0) / FRAME_MS) as usize;
|
||||
let ceil = (ceils_ms[li].max(0) / FRAME_MS) as usize;
|
||||
for word in line {
|
||||
let wi = next_wi;
|
||||
next_wi += 1;
|
||||
if wi > 0 {
|
||||
if let Some(sep) = engine.sep {
|
||||
tokens.push((sep, wi));
|
||||
floor_frame.push(floor);
|
||||
ceil_frame.push(ceil);
|
||||
}
|
||||
}
|
||||
let before = tokens.len();
|
||||
for c in word.chars() {
|
||||
if let Some(id) = map_char(c) {
|
||||
tokens.push((id, wi));
|
||||
floor_frame.push(floor);
|
||||
ceil_frame.push(ceil);
|
||||
}
|
||||
}
|
||||
if tokens.len() == before {
|
||||
return Err("word has no mappable characters".into());
|
||||
}
|
||||
}
|
||||
}
|
||||
let n = tokens.len();
|
||||
if t_frames < n {
|
||||
return Err("window shorter than token sequence".into());
|
||||
}
|
||||
|
||||
// Real CTC trellis: at each frame stay on blank, SUSTAIN the current
|
||||
// token by re-emitting it (the self-loop), or advance by emitting the
|
||||
// next. The self-loop turns single emission frames into token SPANS —
|
||||
// without it every word "ended" one frame after it started and sustained
|
||||
// notes were invisible. Known approximation, kept deliberately: identical
|
||||
// CONSECUTIVE tokens (double letters, "LL") properly require a mandatory
|
||||
// blank between them; collapsing that costs ~one frame (20ms).
|
||||
const NEG: f32 = f32::NEG_INFINITY;
|
||||
let mut trellis = vec![NEG; (t_frames + 1) * (n + 1)];
|
||||
// Backpointers (0 = blank, 1 = self-loop, 2 = advance) make the
|
||||
// backtrack exact instead of re-deriving float comparisons.
|
||||
let mut back = vec![0u8; (t_frames + 1) * (n + 1)];
|
||||
let idx = |t: usize, j: usize| t * (n + 1) + j;
|
||||
trellis[idx(0, 0)] = 0.0;
|
||||
for t in 0..t_frames {
|
||||
let blank_p = logp[t * v + engine.blank];
|
||||
for j in 0..=n.min(t + 1) {
|
||||
let mut best = trellis[idx(t, j)] + blank_p;
|
||||
let mut way = 0u8;
|
||||
// A token may not be emitted before its line's floor (repeated
|
||||
// choruses would lock onto an earlier acoustic instance) nor at
|
||||
// or past its ceiling (stem reverb tails would smear a sustain
|
||||
// across the successors' audio). A line that cannot fit between
|
||||
// its bounds leaves no path — the caller's ladder handles it.
|
||||
if j > 0 && t >= floor_frame[j - 1] && t < ceil_frame[j - 1] {
|
||||
let tok_p = logp[t * v + tokens[j - 1].0];
|
||||
let sustain = trellis[idx(t, j)] + tok_p;
|
||||
if sustain > best {
|
||||
best = sustain;
|
||||
way = 1;
|
||||
}
|
||||
let advance = trellis[idx(t, j - 1)] + tok_p;
|
||||
if advance > best {
|
||||
best = advance;
|
||||
way = 2;
|
||||
}
|
||||
}
|
||||
trellis[idx(t + 1, j)] = best;
|
||||
back[idx(t + 1, j)] = way;
|
||||
}
|
||||
}
|
||||
if trellis[idx(t_frames, n)] == NEG {
|
||||
return Err("no alignment path".into());
|
||||
}
|
||||
// Backtrack the pointers into per-token spans: an advance opens the
|
||||
// token's span, self-loops extend it. Walking backwards, the first
|
||||
// emission met is the token's LAST frame. Every backtrack step consumes
|
||||
// exactly one frame emitting blank or a token — recording that symbol's
|
||||
// logp gives the path's per-frame score, which per-LINE confidence
|
||||
// needs below.
|
||||
let mut first_emit = vec![usize::MAX; n];
|
||||
let mut last_emit = vec![usize::MAX; n];
|
||||
let mut path_logp = vec![0.0f32; t_frames];
|
||||
let mut j = n;
|
||||
let mut t = t_frames;
|
||||
while t > 0 {
|
||||
match back[idx(t, j)] {
|
||||
2 => {
|
||||
first_emit[j - 1] = t - 1;
|
||||
if last_emit[j - 1] == usize::MAX {
|
||||
last_emit[j - 1] = t - 1;
|
||||
}
|
||||
path_logp[t - 1] = logp[(t - 1) * v + tokens[j - 1].0];
|
||||
j -= 1;
|
||||
}
|
||||
1 => {
|
||||
if last_emit[j - 1] == usize::MAX {
|
||||
last_emit[j - 1] = t - 1;
|
||||
}
|
||||
path_logp[t - 1] = logp[(t - 1) * v + tokens[j - 1].0];
|
||||
}
|
||||
_ => {
|
||||
path_logp[t - 1] = logp[(t - 1) * v + engine.blank];
|
||||
}
|
||||
}
|
||||
t -= 1;
|
||||
}
|
||||
if j != 0 {
|
||||
return Err("backtrack failed".into());
|
||||
}
|
||||
|
||||
// Per-word spans, then grouped into lines.
|
||||
let mut word_span: Vec<Option<(usize, usize)>> = vec![None; word_total];
|
||||
for (k, (id, w)) in tokens.iter().enumerate() {
|
||||
if Some(*id) == engine.sep || first_emit[k] == usize::MAX {
|
||||
continue;
|
||||
}
|
||||
word_span[*w] = Some(match word_span[*w] {
|
||||
None => (first_emit[k], last_emit[k]),
|
||||
Some((f, _)) => (f, last_emit[k]),
|
||||
});
|
||||
}
|
||||
|
||||
// Confidence = margin to FREE decoding, per LINE: exp of the mean
|
||||
// per-frame gap between the forced path and the best unconstrained path
|
||||
// over the line's own sung span — "how close is the known lyric to what
|
||||
// the model actually hears", the calibration CTC-segmentation systems
|
||||
// use. Near 1.0 when text matches audio, sinking as it diverges. (v1
|
||||
// sampled exp(logprob) at each token's single emission frame, where CTC
|
||||
// piles mass on blank — every line pinned to the clamp floor.)
|
||||
let free: Vec<f32> = (0..t_frames)
|
||||
.map(|t| logp[t * v..(t + 1) * v].iter().cloned().fold(NEG, f32::max))
|
||||
.collect();
|
||||
let mut out = Vec::with_capacity(lines.len());
|
||||
let mut wi = 0usize;
|
||||
for line in lines {
|
||||
let mut words_out = Vec::with_capacity(line.len());
|
||||
let mut span_first = usize::MAX;
|
||||
let mut span_last = 0usize;
|
||||
for _ in line {
|
||||
match word_span[wi] {
|
||||
Some((f, l)) => {
|
||||
span_first = span_first.min(f);
|
||||
span_last = span_last.max(l);
|
||||
words_out.push(AlignedWord {
|
||||
start_ms: f as i64 * FRAME_MS,
|
||||
end_ms: (l as i64 + 1) * FRAME_MS,
|
||||
});
|
||||
}
|
||||
None => return Err("word lost in backtrack".into()),
|
||||
}
|
||||
wi += 1;
|
||||
}
|
||||
let confidence = if span_first == usize::MAX {
|
||||
0.05
|
||||
} else {
|
||||
let gap: f32 = (span_first..=span_last.min(t_frames - 1))
|
||||
.map(|t| path_logp[t] - free[t])
|
||||
.sum();
|
||||
let frames = (span_last.min(t_frames - 1) - span_first + 1) as f32;
|
||||
(gap / frames).exp().clamp(0.05, 0.99)
|
||||
};
|
||||
out.push(AlignedLine {
|
||||
words: words_out,
|
||||
confidence,
|
||||
});
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
Reference in new issue
Block a user