use super::bias::{BiasPath, Biaser};
use super::decode::{TokenInfo, argmax_with_confidence};
use super::tokenizer::{Tokenizer, WORD_BOUNDARY};
use super::{SECONDS_PER_FRAME, WordInfo};
const BEAM_WIDTH: usize = 8;
const BEAM_TOP_K: usize = 6;
fn log_add_exp(a: f32, b: f32) -> f32 {
if a == f32::NEG_INFINITY {
return b;
}
if b == f32::NEG_INFINITY {
return a;
}
let (hi, lo) = if a > b { (a, b) } else { (b, a) };
hi + (lo - hi).exp().ln_1p()
}
struct Hypothesis {
p_blank: f32,
p_nonblank: f32,
tokens: Vec<TokenInfo>,
bias: BiasPath,
anchor: f32,
}
impl Hypothesis {
fn total(&self) -> f32 {
log_add_exp(self.p_blank, self.p_nonblank)
}
fn settled_total(&self) -> f32 {
self.total() - self.bias.pending()
}
}
pub(crate) fn ctc_prefix_beam_decode(
log_probs: &[f32],
t_len: usize,
vocab: usize,
blank_id: usize,
biaser: &Biaser,
) -> Vec<TokenInfo> {
if vocab == 0 {
return Vec::new();
}
let usable = t_len.min(log_probs.len() / vocab);
let mut beams: Vec<(Vec<usize>, Hypothesis)> = vec![(
Vec::new(),
Hypothesis {
p_blank: 0.0,
p_nonblank: f32::NEG_INFINITY,
tokens: Vec::new(),
bias: BiasPath::default(),
anchor: 0.0,
},
)];
let mut lp = vec![0.0_f32; vocab];
let mut candidates: Vec<usize> = Vec::new();
for t in 0..usable {
log_softmax(&log_probs[t * vocab..(t + 1) * vocab], &mut lp);
candidates.clear();
top_k_into(&lp, BEAM_TOP_K, &mut candidates);
if !candidates.contains(&blank_id) {
candidates.push(blank_id);
}
for (_, hyp) in &beams {
biaser.continuations(hyp.bias, &mut candidates);
}
candidates.retain(|&c| c < vocab);
candidates.sort_unstable();
candidates.dedup();
let mut next: Vec<(Vec<usize>, Hypothesis)> = Vec::new();
for (labels, hyp) in &beams {
for &c in &candidates {
if lp[c] == f32::NEG_INFINITY {
continue;
}
if c == blank_id {
let p = hyp.total() + lp[c];
merge(&mut next, labels, hyp, Emission::Blank(p));
continue;
}
if labels.last() == Some(&c) {
let same = hyp.p_nonblank + lp[c];
merge(&mut next, labels, hyp, Emission::Repeat(same));
let (bonus, bias) = biaser.score_token(hyp.bias, c);
let extended = hyp.p_blank + lp[c] + bonus;
merge_extension(&mut next, labels, hyp, c, extended, bias, t, lp[c].exp());
continue;
}
let (bonus, bias) = biaser.score_token(hyp.bias, c);
let extended = hyp.total() + lp[c] + bonus;
merge_extension(&mut next, labels, hyp, c, extended, bias, t, lp[c].exp());
}
}
if next.is_empty() {
break;
}
next.sort_by(|a, b| b.1.total().total_cmp(&a.1.total()));
next.truncate(BEAM_WIDTH);
beams = next;
}
beams
.into_iter()
.max_by(|a, b| a.1.settled_total().total_cmp(&b.1.settled_total()))
.map(|(_, hyp)| hyp.tokens)
.unwrap_or_default()
}
enum Emission {
Blank(f32),
Repeat(f32),
}
fn merge(
next: &mut Vec<(Vec<usize>, Hypothesis)>,
labels: &[usize],
from: &Hypothesis,
emission: Emission,
) {
let slot = match next.iter_mut().find(|(l, _)| l == labels) {
Some((_, hyp)) => hyp,
None => {
next.push((
labels.to_vec(),
Hypothesis {
p_blank: f32::NEG_INFINITY,
p_nonblank: f32::NEG_INFINITY,
tokens: from.tokens.clone(),
bias: from.bias,
anchor: from.anchor,
},
));
&mut next.last_mut().expect("just pushed").1
}
};
match emission {
Emission::Blank(p) => slot.p_blank = log_add_exp(slot.p_blank, p),
Emission::Repeat(p) => slot.p_nonblank = log_add_exp(slot.p_nonblank, p),
}
}
#[allow(clippy::too_many_arguments)]
fn merge_extension(
next: &mut Vec<(Vec<usize>, Hypothesis)>,
labels: &[usize],
from: &Hypothesis,
token: usize,
p: f32,
bias: BiasPath,
frame: usize,
confidence: f32,
) {
if p == f32::NEG_INFINITY {
return;
}
let mut extended = Vec::with_capacity(labels.len() + 1);
extended.extend_from_slice(labels);
extended.push(token);
let build = |from: &Hypothesis| {
let mut tokens = Vec::with_capacity(from.tokens.len() + 1);
tokens.extend_from_slice(&from.tokens);
tokens.push(TokenInfo {
token_id: token,
frame_index: frame,
confidence,
});
tokens
};
match next.iter_mut().find(|(l, _)| *l == extended) {
Some((_, hyp)) => {
hyp.p_nonblank = log_add_exp(hyp.p_nonblank, p);
if p > hyp.anchor {
hyp.tokens = build(from);
hyp.bias = bias;
hyp.anchor = p;
}
}
None => next.push((
extended,
Hypothesis {
p_blank: f32::NEG_INFINITY,
p_nonblank: p,
tokens: build(from),
bias,
anchor: p,
},
)),
}
}
fn log_softmax(row: &[f32], out: &mut [f32]) {
let max = row.iter().cloned().fold(f32::NEG_INFINITY, f32::max);
let sum: f32 = row.iter().map(|&l| (l - max).exp()).sum();
let log_sum = max + sum.ln();
for (o, &l) in out.iter_mut().zip(row) {
*o = l - log_sum;
}
}
fn top_k_into(lp: &[f32], k: usize, out: &mut Vec<usize>) {
let mut idx: Vec<usize> = (0..lp.len()).collect();
let k = k.min(idx.len());
idx.select_nth_unstable_by(k.saturating_sub(1), |&a, &b| lp[b].total_cmp(&lp[a]));
out.extend_from_slice(&idx[..k]);
}
pub(crate) fn ctc_greedy_decode(
log_probs: &[f32],
t_len: usize,
vocab: usize,
blank_id: usize,
) -> Vec<TokenInfo> {
if vocab == 0 {
return Vec::new();
}
let usable = t_len.min(log_probs.len() / vocab);
let mut out = Vec::new();
let mut prev: Option<usize> = None;
for t in 0..usable {
let row = &log_probs[t * vocab..(t + 1) * vocab];
let (id, confidence) = argmax_with_confidence(row, blank_id);
if Some(id) == prev {
continue;
}
prev = Some(id);
if id == blank_id {
continue;
}
out.push(TokenInfo {
token_id: id,
frame_index: t,
confidence,
});
}
out
}
pub(crate) fn ctc_tokens_to_words(
tokenizer: &Tokenizer,
tokens: &[TokenInfo],
frame_offset: usize,
) -> Vec<WordInfo> {
let mut words = Vec::new();
let mut current_word = String::new();
let mut word_start_frame: Option<usize> = None;
let mut word_end_frame: usize = 0;
let mut word_confidences: Vec<f32> = Vec::new();
let flush = |word: &mut String,
start: &mut Option<usize>,
end: usize,
confs: &mut Vec<f32>,
out: &mut Vec<WordInfo>| {
if word.is_empty() {
return;
}
let avg_conf: f32 = if confs.is_empty() {
1.0
} else {
confs.iter().sum::<f32>() / confs.len() as f32
};
out.push(WordInfo {
word: std::mem::take(word),
start: (start.unwrap_or(0) + frame_offset) as f64 * SECONDS_PER_FRAME,
end: (end + frame_offset) as f64 * SECONDS_PER_FRAME,
confidence: avg_conf,
speaker: None,
});
*start = None;
confs.clear();
};
for token in tokens {
let ch = tokenizer.token_text(token.token_id);
if ch.starts_with(WORD_BOUNDARY) {
flush(
&mut current_word,
&mut word_start_frame,
word_end_frame,
&mut word_confidences,
&mut words,
);
continue;
}
if !ch.is_empty() {
current_word.push_str(ch);
if word_start_frame.is_none() {
word_start_frame = Some(token.frame_index);
}
word_end_frame = token.frame_index;
word_confidences.push(token.confidence);
}
}
flush(
&mut current_word,
&mut word_start_frame,
word_end_frame,
&mut word_confidences,
&mut words,
);
words
}
#[cfg(test)]
mod tests {
use super::*;
fn logits(ids: &[usize], vocab: usize) -> Vec<f32> {
let mut lp = vec![-10.0f32; ids.len() * vocab];
for (t, &id) in ids.iter().enumerate() {
lp[t * vocab + id] = 5.0;
}
lp
}
#[test]
fn collapses_repeats_and_drops_blank() {
let lp = logits(&[0, 0, 2, 1], 3);
let toks = ctc_greedy_decode(&lp, 4, 3, 2);
assert_eq!(
toks.iter().map(|t| t.token_id).collect::<Vec<_>>(),
vec![0, 1]
);
assert_eq!(toks[0].frame_index, 0);
assert_eq!(toks[1].frame_index, 3);
}
#[test]
fn blank_separates_identical_labels() {
let lp = logits(&[0, 0, 2, 0], 3);
let toks = ctc_greedy_decode(&lp, 4, 3, 2);
assert_eq!(
toks.iter().map(|t| t.token_id).collect::<Vec<_>>(),
vec![0, 0]
);
}
#[test]
fn honours_t_len_truncation() {
let lp = logits(&[0, 1, 0], 3);
let toks = ctc_greedy_decode(&lp, 2, 3, 2);
assert_eq!(toks.len(), 2);
assert_eq!(toks[1].frame_index, 1);
}
fn logits_with_margin(ids: &[usize], vocab: usize, margin: f32) -> Vec<f32> {
let mut lp = vec![0.0f32; ids.len() * vocab];
for (t, &id) in ids.iter().enumerate() {
lp[t * vocab + id] = margin;
}
lp
}
fn ids_of(tokens: &[TokenInfo]) -> Vec<usize> {
tokens.iter().map(|t| t.token_id).collect()
}
#[test]
fn beam_without_a_hotword_hit_matches_greedy() {
let b = Biaser::from_sequences(vec![vec![99]], 5.0).expect("biaser");
for ids in [
vec![0usize, 0, 2, 1],
vec![0, 0, 2, 0],
vec![1, 2, 2, 1, 0],
vec![2, 2, 2],
] {
let lp = logits(&ids, 3);
let beam = ctc_prefix_beam_decode(&lp, ids.len(), 3, 2, &b);
let greedy = ctc_greedy_decode(&lp, ids.len(), 3, 2);
assert_eq!(
ids_of(&beam),
ids_of(&greedy),
"beam diverged from greedy on {ids:?}"
);
}
}
#[test]
fn beam_keeps_the_frame_of_each_emitted_label() {
let b = Biaser::from_sequences(vec![vec![99]], 5.0).expect("biaser");
let lp = logits(&[0, 0, 2, 1], 3);
let beam = ctc_prefix_beam_decode(&lp, 4, 3, 2, &b);
let greedy = ctc_greedy_decode(&lp, 4, 3, 2);
assert_eq!(
beam.iter().map(|t| t.frame_index).collect::<Vec<_>>(),
greedy.iter().map(|t| t.frame_index).collect::<Vec<_>>()
);
}
#[test]
fn boost_recovers_a_hotword_the_model_narrowly_missed() {
let vocab = 4;
let mut lp = logits_with_margin(&[0, 2], vocab, 5.0);
lp[vocab + 1] = 4.6;
let inert = Biaser::from_sequences(vec![vec![99]], 3.0).expect("biaser");
let unbiased = ids_of(&ctc_prefix_beam_decode(&lp, 2, vocab, 3, &inert));
assert_eq!(unbiased, vec![0, 2], "model's own pick");
let hot = Biaser::from_sequences(vec![vec![0, 1]], 3.0).expect("biaser");
let biased = ids_of(&ctc_prefix_beam_decode(&lp, 2, vocab, 3, &hot));
assert_eq!(biased, vec![0, 1], "boost recovers the hotword");
}
#[test]
fn abandoned_partial_match_is_refunded() {
let lp = logits(&[0, 2, 1, 2, 0], 3);
let inert = Biaser::from_sequences(vec![vec![99]], 8.0).expect("biaser");
let hot = Biaser::from_sequences(vec![vec![0, 1, 1, 1]], 8.0).expect("biaser");
assert_eq!(
ids_of(&ctc_prefix_beam_decode(&lp, 5, 3, 2, &hot)),
ids_of(&ctc_prefix_beam_decode(&lp, 5, 3, 2, &inert)),
"an unfinishable hotword must not bend the transcript"
);
}
#[test]
fn beam_tolerates_a_hotword_token_outside_the_vocab() {
let b = Biaser::from_sequences(vec![vec![0, 250]], 5.0).expect("biaser");
let lp = logits(&[0, 1], 3);
let out = ctc_prefix_beam_decode(&lp, 2, 3, 2, &b);
assert_eq!(ids_of(&out), vec![0, 1]);
}
#[test]
fn beam_honours_t_len_truncation() {
let b = Biaser::from_sequences(vec![vec![99]], 5.0).expect("biaser");
let lp = logits(&[0, 1, 0], 3);
let out = ctc_prefix_beam_decode(&lp, 2, 3, 2, &b);
assert_eq!(ids_of(&out), vec![0, 1]);
}
#[test]
fn all_blank_is_empty() {
let lp = logits(&[2, 2, 2], 3);
assert!(ctc_greedy_decode(&lp, 3, 3, 2).is_empty());
}
fn ctc_tokenizer(letters: &[&str]) -> Tokenizer {
let mut toks = vec!["\u{2581}".to_string()];
toks.extend(letters.iter().map(|s| s.to_string()));
toks.push("<blk>".to_string());
Tokenizer::from_tokens(toks)
}
fn tok(id: usize, frame: usize) -> TokenInfo {
TokenInfo {
token_id: id,
frame_index: frame,
confidence: 1.0,
}
}
#[test]
fn groups_words_on_boundary_marker() {
let t = ctc_tokenizer(&["п", "р", "и", "в", "е", "т", "м", "и", "р"]);
let toks = [
tok(1, 0),
tok(2, 1),
tok(3, 2),
tok(4, 3),
tok(5, 4),
tok(6, 5),
tok(0, 6), tok(7, 7),
tok(8, 8),
tok(9, 9),
];
let words = ctc_tokens_to_words(&t, &toks, 0);
assert_eq!(
words.iter().map(|w| w.word.as_str()).collect::<Vec<_>>(),
vec!["привет", "мир"]
);
assert!((words[0].start - 0.0).abs() < 1e-9);
assert!(words[1].start > words[0].end);
}
#[test]
fn leading_and_trailing_boundaries_emit_no_empty_words() {
let t = ctc_tokenizer(&["а", "б"]);
let toks = [tok(0, 0), tok(1, 1), tok(2, 2), tok(0, 3)];
let words = ctc_tokens_to_words(&t, &toks, 0);
assert_eq!(
words.iter().map(|w| w.word.as_str()).collect::<Vec<_>>(),
vec!["аб"]
);
}
}