524 lines
21 KiB
Rust
524 lines
21 KiB
Rust
//! 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;
|
||
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 = crate::paths::app_data(app)
|
||
.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)
|
||
}
|