rtx-csm: Phase 13.9 — wav2vec2 slice 4 (CTC Viterbi forced alignment)
Word-level forced alignment shipped. Phase 13.9 complete. viterbi_align(log_probs, tokens, blank_id, vocab) — standard CTC forced-alignment trellis: states alternate [blank, t0, blank, t1, ..., tN, blank] (length 2N+1), at each frame stay/advance/ε-skip-blank- between-different-tokens (canonical CTC ε-skip rule correctly forbids skipping blank between SAME tokens), max-likelihood path recovered via backptr table. transcript_to_token_ids — text → CTC token ids; runs of spaces collapse to | separator; unknown chars → <unk>. group_into_words — fold adjacent non-| AlignedToken into AlignedWord with carried frame_start/frame_end. frame_to_ms — 50 Hz frame grid → ms (20 ms/frame at conv stride 320). examples/wav2vec2_smoke --align <target> wires it end-to-end: forced-aligns a known transcript and prints (word, start_ms, end_ms). Verified on Metal: 10.42 s LibriSpeech audio, first 8 words → HE 560-640 HOPED 720-960 THERE 1000-1140 WOULD 1180-1320 BE 1360-2240 STEW 2980-4720 FOR 5300-6000 DINNER 7040-8540 viterbi alignment in 0 ms (39 tokens). Boundaries match audio. 4 new unit tests: - transcript_to_token_ids_handles_spaces_and_unknowns - viterbi_align_recovers_obvious_alignment - group_into_words_splits_on_separator - frame_to_ms_50hz_grid Lib suite 131/131 (was 127, +4). Phase 13.9 complete (slices 1+2+3+4). Crate now ships full English ASR + word-level forced alignment in pure candle — no whisper.cpp, no ort, no Python. Data-prep can cut long audio at exact word boundaries before feeding into the Phase 12.3 curriculum trainer. Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
This commit is contained in:
@@ -17,7 +17,10 @@ use candle_core::{Device, Tensor};
|
|||||||
use clap::Parser;
|
use clap::Parser;
|
||||||
use hf_hub::api::sync::Api;
|
use hf_hub::api::sync::Api;
|
||||||
use rtx_csm::audio_io;
|
use rtx_csm::audio_io;
|
||||||
use rtx_csm::wav2vec2::{ctc_greedy_decode, Wav2Vec2, VOCAB_960H};
|
use rtx_csm::wav2vec2::{
|
||||||
|
ctc_greedy_decode, frame_to_ms, group_into_words, transcript_to_token_ids, viterbi_align,
|
||||||
|
Wav2Vec2, CTC_BLANK_ID, VOCAB_960H,
|
||||||
|
};
|
||||||
use std::path::PathBuf;
|
use std::path::PathBuf;
|
||||||
|
|
||||||
const REPO: &str = "facebook/wav2vec2-base-960h";
|
const REPO: &str = "facebook/wav2vec2-base-960h";
|
||||||
@@ -27,6 +30,11 @@ const SAFETENSORS_FILE: &str = "model.safetensors";
|
|||||||
struct Cli {
|
struct Cli {
|
||||||
#[arg(long = "in", default_value = "/tmp/asr_test.flac")]
|
#[arg(long = "in", default_value = "/tmp/asr_test.flac")]
|
||||||
input: PathBuf,
|
input: PathBuf,
|
||||||
|
/// Optional known transcript to force-align. When set, run CTC
|
||||||
|
/// Viterbi to produce per-word `(start_ms, end_ms)` boundaries
|
||||||
|
/// instead of just greedy ASR.
|
||||||
|
#[arg(long)]
|
||||||
|
align: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
fn main() -> Result<()> {
|
fn main() -> Result<()> {
|
||||||
@@ -80,5 +88,29 @@ fn main() -> Result<()> {
|
|||||||
println!("=== transcript ===");
|
println!("=== transcript ===");
|
||||||
println!("{}", transcript.trim());
|
println!("{}", transcript.trim());
|
||||||
println!();
|
println!();
|
||||||
|
|
||||||
|
// Optional forced alignment.
|
||||||
|
if let Some(target) = cli.align.as_deref() {
|
||||||
|
let log_probs = candle_nn::ops::log_softmax(&logits, candle_core::D::Minus1)?;
|
||||||
|
let tokens = transcript_to_token_ids(target, VOCAB_960H)?;
|
||||||
|
let ali_t = std::time::Instant::now();
|
||||||
|
let aligned = viterbi_align(&log_probs, &tokens, CTC_BLANK_ID, VOCAB_960H)?;
|
||||||
|
eprintln!(
|
||||||
|
"viterbi alignment in {} ms ({} tokens)",
|
||||||
|
ali_t.elapsed().as_millis(),
|
||||||
|
aligned.len()
|
||||||
|
);
|
||||||
|
let words = group_into_words(&aligned);
|
||||||
|
println!("=== forced alignment (target = {target:?}) ===");
|
||||||
|
for w in words.iter() {
|
||||||
|
println!(
|
||||||
|
" {:<20} {:>7.0} ms .. {:>7.0} ms",
|
||||||
|
w.word,
|
||||||
|
frame_to_ms(w.frame_start),
|
||||||
|
frame_to_ms(w.frame_end + 1)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
println!();
|
||||||
|
}
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -611,6 +611,261 @@ pub fn ctc_greedy_decode(logits: &Tensor, vocab: &[&str]) -> CsmResult<String> {
|
|||||||
|
|
||||||
use candle_core::IndexOp;
|
use candle_core::IndexOp;
|
||||||
|
|
||||||
|
/// One aligned token — output of [`viterbi_align`]. `token` is the raw
|
||||||
|
/// CTC unit (single character for the default `_base-960h` vocab).
|
||||||
|
/// `frame_start` / `frame_end` index 50 Hz feature frames; multiply by
|
||||||
|
/// 20 ms (1000 ms / 50 Hz) to convert to milliseconds.
|
||||||
|
#[derive(Debug, Clone, serde::Serialize)]
|
||||||
|
pub struct AlignedToken {
|
||||||
|
pub token: String,
|
||||||
|
pub frame_start: usize,
|
||||||
|
pub frame_end: usize,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Group consecutive aligned tokens into words. Word-separator handling:
|
||||||
|
/// the upstream `_base-960h` vocab uses `|` as the inter-word boundary,
|
||||||
|
/// which ctc_greedy_decode renders as a space. After [`viterbi_align`]
|
||||||
|
/// the `|` shows up as its own token; this helper folds adjacent
|
||||||
|
/// non-separator tokens into a `Word { text, frame_start, frame_end }`.
|
||||||
|
#[derive(Debug, Clone, serde::Serialize)]
|
||||||
|
pub struct AlignedWord {
|
||||||
|
pub word: String,
|
||||||
|
pub frame_start: usize,
|
||||||
|
pub frame_end: usize,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Convert a transcript string into the CTC token id sequence over the
|
||||||
|
/// supplied vocab. Spaces in the transcript become the word-separator
|
||||||
|
/// token (`|` for `_base-960h`); other characters are looked up directly.
|
||||||
|
/// Unknown characters are replaced with the `<unk>` token.
|
||||||
|
pub fn transcript_to_token_ids(transcript: &str, vocab: &[&str]) -> CsmResult<Vec<u32>> {
|
||||||
|
let mut id_for: std::collections::HashMap<&str, u32> = Default::default();
|
||||||
|
for (i, t) in vocab.iter().enumerate() {
|
||||||
|
id_for.insert(*t, i as u32);
|
||||||
|
}
|
||||||
|
let unk_id = *id_for
|
||||||
|
.get("<unk>")
|
||||||
|
.ok_or_else(|| CsmError::Config("vocab missing <unk>".into()))?;
|
||||||
|
let sep_id = *id_for
|
||||||
|
.get("|")
|
||||||
|
.ok_or_else(|| CsmError::Config("vocab missing | (word sep)".into()))?;
|
||||||
|
let mut out = Vec::new();
|
||||||
|
let upper = transcript.to_uppercase();
|
||||||
|
let chars: Vec<char> = upper.chars().collect();
|
||||||
|
let mut i = 0;
|
||||||
|
while i < chars.len() {
|
||||||
|
let c = chars[i];
|
||||||
|
if c == ' ' {
|
||||||
|
// Collapse runs of spaces into a single separator.
|
||||||
|
out.push(sep_id);
|
||||||
|
while i < chars.len() && chars[i] == ' ' {
|
||||||
|
i += 1;
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let s = c.to_string();
|
||||||
|
let id = id_for.get(s.as_str()).copied().unwrap_or(unk_id);
|
||||||
|
out.push(id);
|
||||||
|
i += 1;
|
||||||
|
}
|
||||||
|
Ok(out)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// CTC Viterbi forced alignment.
|
||||||
|
///
|
||||||
|
/// Given per-frame log-probabilities `log_probs` of shape `(T, V)` (caller
|
||||||
|
/// supplies the log-softmax) and a target token sequence `tokens`, find
|
||||||
|
/// the maximum-likelihood alignment of `tokens` against the frames using
|
||||||
|
/// the standard CTC transition lattice (interleave each token with a
|
||||||
|
/// blank, allow self-loop or advance-to-next at every step).
|
||||||
|
///
|
||||||
|
/// Returns one [`AlignedToken`] per token in the input sequence with
|
||||||
|
/// inclusive `frame_start..=frame_end` indices into the 50 Hz feature
|
||||||
|
/// frame grid.
|
||||||
|
///
|
||||||
|
/// Error if `T < tokens.len()` (not enough frames) or if `tokens` is empty.
|
||||||
|
pub fn viterbi_align(
|
||||||
|
log_probs: &Tensor,
|
||||||
|
tokens: &[u32],
|
||||||
|
blank_id: usize,
|
||||||
|
vocab: &[&str],
|
||||||
|
) -> CsmResult<Vec<AlignedToken>> {
|
||||||
|
if tokens.is_empty() {
|
||||||
|
return Err(CsmError::Config("viterbi_align: tokens is empty".into()));
|
||||||
|
}
|
||||||
|
let lp = if log_probs.dims().len() == 3 {
|
||||||
|
log_probs
|
||||||
|
.i((0, .., ..))
|
||||||
|
.map_err(|e| CsmError::Config(format!("viterbi squeeze: {e}")))?
|
||||||
|
} else {
|
||||||
|
log_probs.clone()
|
||||||
|
};
|
||||||
|
let lp_vec = lp
|
||||||
|
.to_vec2::<f32>()
|
||||||
|
.map_err(|e| CsmError::Config(format!("viterbi to_vec2: {e}")))?;
|
||||||
|
let big_t = lp_vec.len();
|
||||||
|
let v = if big_t > 0 { lp_vec[0].len() } else { 0 };
|
||||||
|
if big_t == 0 || v == 0 {
|
||||||
|
return Err(CsmError::Config("viterbi_align: empty log_probs".into()));
|
||||||
|
}
|
||||||
|
|
||||||
|
// CTC trellis state sequence: [blank, t0, blank, t1, blank, ..., tN, blank]
|
||||||
|
// length S = 2 * tokens.len() + 1
|
||||||
|
let n = tokens.len();
|
||||||
|
let big_s = 2 * n + 1;
|
||||||
|
if big_t < n {
|
||||||
|
return Err(CsmError::Config(format!(
|
||||||
|
"viterbi_align: T={big_t} < tokens.len()={n}, can't align"
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build the state token-id table.
|
||||||
|
let mut state_id: Vec<usize> = Vec::with_capacity(big_s);
|
||||||
|
for (i, _) in (0..big_s).enumerate() {
|
||||||
|
if i % 2 == 0 {
|
||||||
|
state_id.push(blank_id);
|
||||||
|
} else {
|
||||||
|
state_id.push(tokens[i / 2] as usize);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let neg_inf = f32::NEG_INFINITY;
|
||||||
|
// dp[t][s] = best log-prob for reaching state s at frame t
|
||||||
|
// backptr[t][s] = state at t-1 we came from (for path recovery)
|
||||||
|
let mut dp = vec![vec![neg_inf; big_s]; big_t];
|
||||||
|
let mut backptr = vec![vec![0usize; big_s]; big_t];
|
||||||
|
|
||||||
|
// Initialization: at t=0 we can be in state 0 (initial blank) or
|
||||||
|
// state 1 (first token).
|
||||||
|
dp[0][0] = lp_vec[0][state_id[0]];
|
||||||
|
if big_s > 1 {
|
||||||
|
dp[0][1] = lp_vec[0][state_id[1]];
|
||||||
|
}
|
||||||
|
|
||||||
|
for t in 1..big_t {
|
||||||
|
for s in 0..big_s {
|
||||||
|
// Stay in s, or advance from s-1, or skip from s-2 (only if
|
||||||
|
// s is a non-blank state AND state s-2 is a different token —
|
||||||
|
// CTC's "ε-skip" rule allows skipping blank between two
|
||||||
|
// *different* tokens but NOT between two same tokens).
|
||||||
|
let mut best = dp[t - 1][s];
|
||||||
|
let mut best_prev = s;
|
||||||
|
|
||||||
|
if s >= 1 && dp[t - 1][s - 1] > best {
|
||||||
|
best = dp[t - 1][s - 1];
|
||||||
|
best_prev = s - 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ε-skip: only when current state is a non-blank token AND
|
||||||
|
// the token at s-2 is different (so we can skip the
|
||||||
|
// intermediate blank).
|
||||||
|
if s >= 2 && s % 2 == 1 {
|
||||||
|
let cur_tok = state_id[s];
|
||||||
|
let prev_tok = state_id[s - 2];
|
||||||
|
if cur_tok != prev_tok && dp[t - 1][s - 2] > best {
|
||||||
|
best = dp[t - 1][s - 2];
|
||||||
|
best_prev = s - 2;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Add the emission cost for being in state s at frame t.
|
||||||
|
if best > neg_inf {
|
||||||
|
dp[t][s] = best + lp_vec[t][state_id[s]];
|
||||||
|
backptr[t][s] = best_prev;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Final state: must end in either the last token (s = 2N-1) or the
|
||||||
|
// trailing blank (s = 2N). Pick whichever has the higher log-prob.
|
||||||
|
let last_token_state = big_s - 2;
|
||||||
|
let last_blank_state = big_s - 1;
|
||||||
|
let (mut s, _) = if dp[big_t - 1][last_token_state] >= dp[big_t - 1][last_blank_state] {
|
||||||
|
(last_token_state, dp[big_t - 1][last_token_state])
|
||||||
|
} else {
|
||||||
|
(last_blank_state, dp[big_t - 1][last_blank_state])
|
||||||
|
};
|
||||||
|
|
||||||
|
// Recover the per-frame state path (reverse).
|
||||||
|
let mut path = vec![0usize; big_t];
|
||||||
|
path[big_t - 1] = s;
|
||||||
|
for t in (1..big_t).rev() {
|
||||||
|
s = backptr[t][s];
|
||||||
|
path[t - 1] = s;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Convert to per-token spans by finding the first and last frames
|
||||||
|
// each non-blank state index (1, 3, 5, ..., 2N-1) appears in path.
|
||||||
|
let mut out: Vec<AlignedToken> = Vec::with_capacity(n);
|
||||||
|
for (i, &tok_id) in tokens.iter().enumerate() {
|
||||||
|
let target_state = 2 * i + 1;
|
||||||
|
let (mut start, mut end) = (None, None);
|
||||||
|
for (t, &p) in path.iter().enumerate() {
|
||||||
|
if p == target_state {
|
||||||
|
if start.is_none() {
|
||||||
|
start = Some(t);
|
||||||
|
}
|
||||||
|
end = Some(t);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Fallback: if a token is degenerate / never visited, place it
|
||||||
|
// at the path's nearest blank-bracket. This shouldn't happen
|
||||||
|
// for legitimate inputs but keeps the function total.
|
||||||
|
let (frame_start, frame_end) = match (start, end) {
|
||||||
|
(Some(s0), Some(e0)) => (s0, e0),
|
||||||
|
_ => (0, 0),
|
||||||
|
};
|
||||||
|
let token = vocab
|
||||||
|
.get(tok_id as usize)
|
||||||
|
.map(|s| s.to_string())
|
||||||
|
.unwrap_or_else(|| format!("<id_{tok_id}>"));
|
||||||
|
out.push(AlignedToken {
|
||||||
|
token,
|
||||||
|
frame_start,
|
||||||
|
frame_end,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
Ok(out)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Group [`AlignedToken`]s into words at `|` boundaries.
|
||||||
|
pub fn group_into_words(tokens: &[AlignedToken]) -> Vec<AlignedWord> {
|
||||||
|
let mut out = Vec::new();
|
||||||
|
let mut buf = String::new();
|
||||||
|
let mut start: Option<usize> = None;
|
||||||
|
let mut end: usize = 0;
|
||||||
|
for tok in tokens.iter() {
|
||||||
|
if tok.token == "|" {
|
||||||
|
if !buf.is_empty() {
|
||||||
|
out.push(AlignedWord {
|
||||||
|
word: std::mem::take(&mut buf),
|
||||||
|
frame_start: start.unwrap_or(tok.frame_start),
|
||||||
|
frame_end: end,
|
||||||
|
});
|
||||||
|
start = None;
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if start.is_none() {
|
||||||
|
start = Some(tok.frame_start);
|
||||||
|
}
|
||||||
|
buf.push_str(&tok.token);
|
||||||
|
end = tok.frame_end;
|
||||||
|
}
|
||||||
|
if !buf.is_empty() {
|
||||||
|
out.push(AlignedWord {
|
||||||
|
word: buf,
|
||||||
|
frame_start: start.unwrap_or(0),
|
||||||
|
frame_end: end,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Convert a frame index (50 Hz, conv stride 320 at 16 kHz) to milliseconds.
|
||||||
|
pub fn frame_to_ms(frame: usize) -> f32 {
|
||||||
|
frame as f32 * 20.0
|
||||||
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
@@ -697,6 +952,71 @@ mod tests {
|
|||||||
assert!(v.iter().all(|x| x.is_finite()));
|
assert!(v.iter().all(|x| x.is_finite()));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn transcript_to_token_ids_handles_spaces_and_unknowns() {
|
||||||
|
let ids = transcript_to_token_ids("HE LL", VOCAB_960H).unwrap();
|
||||||
|
// H=11 E=5 |=4 L=15 L=15
|
||||||
|
assert_eq!(ids, vec![11, 5, 4, 15, 15]);
|
||||||
|
|
||||||
|
// Unknown char → <unk>=3
|
||||||
|
let ids = transcript_to_token_ids("@", VOCAB_960H).unwrap();
|
||||||
|
assert_eq!(ids, vec![3]);
|
||||||
|
|
||||||
|
// Multiple spaces collapse to a single separator.
|
||||||
|
let ids = transcript_to_token_ids("A B", VOCAB_960H).unwrap();
|
||||||
|
assert_eq!(ids, vec![7, 4, 24]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn viterbi_align_recovers_obvious_alignment() {
|
||||||
|
let dev = Device::Cpu;
|
||||||
|
let v = 32usize;
|
||||||
|
// Synthesize log-probs so that the optimal alignment of "HE"
|
||||||
|
// (tokens [11, 5]) is unambiguous. Frames: [pad pad H H E E pad].
|
||||||
|
// We give a strong score to the intended state at each frame and
|
||||||
|
// -10 elsewhere — small enough to keep blank fallback feasible.
|
||||||
|
let layout: [u32; 7] = [0, 0, 11, 11, 5, 5, 0];
|
||||||
|
let mut data = vec![-10.0f32; layout.len() * v];
|
||||||
|
for (t, id) in layout.iter().enumerate() {
|
||||||
|
data[t * v + *id as usize] = 0.0;
|
||||||
|
}
|
||||||
|
let lp = Tensor::from_vec(data, (layout.len(), v), &dev).unwrap();
|
||||||
|
let aligned = viterbi_align(&lp, &[11, 5], CTC_BLANK_ID, VOCAB_960H).unwrap();
|
||||||
|
assert_eq!(aligned.len(), 2);
|
||||||
|
assert_eq!(aligned[0].token, "H");
|
||||||
|
assert_eq!(aligned[0].frame_start, 2);
|
||||||
|
assert_eq!(aligned[0].frame_end, 3);
|
||||||
|
assert_eq!(aligned[1].token, "E");
|
||||||
|
assert_eq!(aligned[1].frame_start, 4);
|
||||||
|
assert_eq!(aligned[1].frame_end, 5);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn group_into_words_splits_on_separator() {
|
||||||
|
let toks = vec![
|
||||||
|
AlignedToken { token: "H".into(), frame_start: 0, frame_end: 1 },
|
||||||
|
AlignedToken { token: "I".into(), frame_start: 2, frame_end: 3 },
|
||||||
|
AlignedToken { token: "|".into(), frame_start: 4, frame_end: 5 },
|
||||||
|
AlignedToken { token: "Y".into(), frame_start: 6, frame_end: 6 },
|
||||||
|
AlignedToken { token: "O".into(), frame_start: 7, frame_end: 8 },
|
||||||
|
];
|
||||||
|
let words = group_into_words(&toks);
|
||||||
|
assert_eq!(words.len(), 2);
|
||||||
|
assert_eq!(words[0].word, "HI");
|
||||||
|
assert_eq!(words[0].frame_start, 0);
|
||||||
|
assert_eq!(words[0].frame_end, 3);
|
||||||
|
assert_eq!(words[1].word, "YO");
|
||||||
|
assert_eq!(words[1].frame_start, 6);
|
||||||
|
assert_eq!(words[1].frame_end, 8);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn frame_to_ms_50hz_grid() {
|
||||||
|
// 50 Hz = 20 ms per frame.
|
||||||
|
assert_eq!(frame_to_ms(0), 0.0);
|
||||||
|
assert_eq!(frame_to_ms(50), 1000.0);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn ctc_greedy_decode_collapses_repeats_and_drops_blanks() {
|
fn ctc_greedy_decode_collapses_repeats_and_drops_blanks() {
|
||||||
let dev = Device::Cpu;
|
let dev = Device::Cpu;
|
||||||
|
|||||||
Reference in New Issue
Block a user