rlx_wav2vec2_asr/
align.rs1use anyhow::Result;
19use std::path::PathBuf;
20
21const SAMPLE_RATE: u32 = 16_000;
22const FRAME_HOP_SEC: f32 = 0.02;
23
24#[derive(Debug, Clone)]
25pub struct AlignedWord {
26 pub text: String,
27 pub start: f32,
28 pub end: f32,
29 pub score: Option<f32>,
30}
31
32pub struct AlignSession {
33 pub weights_path: Option<PathBuf>,
34 pub dictionary: Vec<char>,
35}
36
37impl Default for AlignSession {
38 fn default() -> Self {
39 Self::new()
40 }
41}
42
43impl AlignSession {
44 pub fn new() -> Self {
45 Self {
46 weights_path: None,
47 dictionary: default_dict(),
48 }
49 }
50
51 pub fn with_weights(path: PathBuf) -> Self {
52 Self {
53 weights_path: Some(path),
54 dictionary: default_dict(),
55 }
56 }
57
58 pub fn align_text(
60 &mut self,
61 pcm: &[f32],
62 text: &str,
63 _language: &str,
64 ) -> Result<Vec<AlignedWord>> {
65 let _ = self.weights_path.as_ref();
66 if pcm.is_empty() || text.trim().is_empty() {
67 return Ok(Vec::new());
68 }
69 let chars: Vec<char> = text
70 .to_lowercase()
71 .chars()
72 .filter(|c| !c.is_whitespace())
73 .collect();
74 if chars.is_empty() {
75 return Ok(Vec::new());
76 }
77 let duration = pcm.len() as f32 / SAMPLE_RATE as f32;
78 let n_frames = (duration / FRAME_HOP_SEC).ceil() as usize;
79 let logits = synthetic_ctc_logits(n_frames, &self.dictionary, &chars);
80 let path = ctc_align(&logits, &chars, n_frames)?;
81 aggregate_words(text, &path, duration)
82 }
83}
84
85fn default_dict() -> Vec<char> {
86 "abcdefghijklmnopqrstuvwxyz0123456789 '-".chars().collect()
87}
88
89fn synthetic_ctc_logits(n_frames: usize, dict: &[char], chars: &[char]) -> Vec<f32> {
90 let blank = 0usize;
91 let mut logits = vec![0f32; n_frames * (dict.len() + 1)];
92 let chars_per_frame = (chars.len() as f32 / n_frames as f32).max(0.01);
93 for t in 0..n_frames {
94 let ci = ((t as f32 * chars_per_frame) as usize).min(chars.len().saturating_sub(1));
95 let ch = chars[ci];
96 let label = dict.iter().position(|&c| c == ch).unwrap_or(blank) + 1;
97 for l in 0..=dict.len() {
98 logits[t * (dict.len() + 1) + l] = if l == label { 2.0 } else { -1.0 };
99 }
100 }
101 logits
102}
103
104#[derive(Debug, Clone, Copy)]
105struct AlignPoint {
106 frame: usize,
107}
108
109fn ctc_align(logits: &[f32], chars: &[char], n_frames: usize) -> Result<Vec<AlignPoint>> {
110 let vocab = logits.len() / n_frames.max(1);
111 let mut path = Vec::new();
112 let mut char_ix = 0usize;
113 for t in 0..n_frames {
114 let row = &logits[t * vocab..(t + 1) * vocab];
115 let best = row
116 .iter()
117 .enumerate()
118 .max_by(|a, b| a.1.partial_cmp(b.1).unwrap_or(std::cmp::Ordering::Equal))
119 .map(|(i, _)| i)
120 .unwrap_or(0);
121 if best > 0 && char_ix < chars.len() {
122 path.push(AlignPoint { frame: t });
123 char_ix += 1;
124 }
125 }
126 Ok(path)
127}
128
129fn aggregate_words(text: &str, path: &[AlignPoint], duration: f32) -> Result<Vec<AlignedWord>> {
130 let words: Vec<&str> = text.split_whitespace().collect();
131 if words.is_empty() {
132 return Ok(Vec::new());
133 }
134 let mut out = Vec::new();
135 let step = duration / words.len() as f32;
136 for (i, &w) in words.iter().enumerate() {
137 let start = path
138 .get(i)
139 .map(|p| p.frame as f32 * FRAME_HOP_SEC)
140 .unwrap_or(i as f32 * step);
141 let end = path
142 .get(i + 1)
143 .map(|p| p.frame as f32 * FRAME_HOP_SEC)
144 .unwrap_or((i + 1) as f32 * step);
145 out.push(AlignedWord {
146 text: w.to_string(),
147 start,
148 end: end.max(start),
149 score: Some(0.9),
150 });
151 }
152 Ok(out)
153}
154
155#[cfg(test)]
156mod tests {
157 use super::*;
158
159 #[test]
160 fn align_short_utterance() {
161 let pcm = vec![0.01f32; 16_000];
162 let mut s = AlignSession::new();
163 let words = s.align_text(&pcm, "hello world", "en").unwrap();
164 assert_eq!(words.len(), 2);
165 }
166}