#[cfg(test)]
mod tests;
use crate::error::{WhisperError, WhisperResult};
pub const AUDIO_FRAME_RATE: f32 = 50.0;
#[derive(Debug, Clone)]
pub struct AlignmentConfig {
pub layers: Vec<usize>,
pub heads: Option<Vec<usize>>,
pub min_attention: f32,
pub temperature: f32,
pub use_median: bool,
}
impl Default for AlignmentConfig {
fn default() -> Self {
Self {
layers: vec![0, 1, 2, 3, 4, 5], heads: None, min_attention: 0.1,
temperature: 1.0,
use_median: false,
}
}
}
impl AlignmentConfig {
#[must_use]
pub fn for_accuracy() -> Self {
Self {
layers: vec![2, 3, 4, 5], heads: None,
min_attention: 0.05,
temperature: 0.5,
use_median: true,
}
}
#[must_use]
pub fn for_speed() -> Self {
Self {
layers: vec![3, 4], heads: Some(vec![0, 1, 2, 3]), min_attention: 0.15,
temperature: 1.0,
use_median: false,
}
}
#[must_use]
pub fn with_layers(mut self, layers: Vec<usize>) -> Self {
self.layers = layers;
self
}
#[must_use]
pub fn with_min_attention(mut self, threshold: f32) -> Self {
self.min_attention = threshold;
self
}
}
#[derive(Debug, Clone)]
pub struct TokenAlignment {
pub token_index: usize,
pub token_id: u32,
pub frame_position: usize,
pub start_time: f32,
pub end_time: f32,
pub confidence: f32,
pub attention_weights: Vec<f32>,
}
impl TokenAlignment {
#[must_use]
pub fn new(token_index: usize, token_id: u32, frame_position: usize, confidence: f32) -> Self {
let start_time = frame_position as f32 / AUDIO_FRAME_RATE;
Self {
token_index,
token_id,
frame_position,
start_time,
end_time: start_time,
confidence,
attention_weights: Vec::new(),
}
}
pub fn set_end_time(&mut self, end_frame: usize) {
self.end_time = end_frame as f32 / AUDIO_FRAME_RATE;
}
#[must_use]
pub fn duration(&self) -> f32 {
self.end_time - self.start_time
}
#[must_use]
pub fn is_confident(&self) -> bool {
self.confidence >= 0.5
}
#[must_use]
pub fn with_attention_weights(mut self, weights: Vec<f32>) -> Self {
self.attention_weights = weights;
self
}
}
#[derive(Debug, Clone)]
pub struct WordAlignment {
pub word: String,
pub start_time: f32,
pub end_time: f32,
pub confidence: f32,
pub tokens: Vec<TokenAlignment>,
}
impl WordAlignment {
#[must_use]
pub fn new(word: String, tokens: Vec<TokenAlignment>) -> Self {
let start_time = tokens.first().map_or(0.0, |t| t.start_time);
let end_time = tokens.last().map_or(0.0, |t| t.end_time);
let n = tokens.len();
let confidence = tokens.iter().map(|t| t.confidence).sum::<f32>() / n.max(1) as f32;
Self {
word,
start_time,
end_time,
confidence,
tokens,
}
}
#[must_use]
pub fn duration(&self) -> f32 {
self.end_time - self.start_time
}
#[must_use]
pub fn token_count(&self) -> usize {
self.tokens.len()
}
}
#[derive(Debug, Clone)]
pub struct CrossAttentionAlignment {
config: AlignmentConfig,
}
impl CrossAttentionAlignment {
#[must_use]
pub fn new(config: AlignmentConfig) -> Self {
Self { config }
}
pub fn extract_token_alignments(
&self,
attention_weights: &[Vec<Vec<Vec<f32>>>],
token_ids: &[u32],
num_frames: usize,
) -> WhisperResult<Vec<TokenAlignment>> {
if attention_weights.is_empty() {
return Err(WhisperError::Inference(
"No attention weights provided".to_string(),
));
}
if token_ids.is_empty() {
return Ok(Vec::new());
}
let averaged = self.average_attention(attention_weights, num_frames, token_ids.len())?;
let mut alignments = Vec::with_capacity(token_ids.len());
for (token_idx, (&token_id, token_attention)) in
token_ids.iter().zip(averaged.iter()).enumerate()
{
let (peak_frame, peak_value) = self.find_peak(token_attention);
let confidence = self.compute_confidence(token_attention, peak_frame, peak_value);
let mut alignment = TokenAlignment::new(token_idx, token_id, peak_frame, confidence)
.with_attention_weights(token_attention.clone());
let end_frame = averaged
.get(token_idx + 1)
.map_or(num_frames, |next| self.find_peak(next).0);
alignment.set_end_time(end_frame);
alignments.push(alignment);
}
Ok(alignments)
}
#[allow(clippy::unnecessary_wraps)]
fn average_attention(
&self,
attention_weights: &[Vec<Vec<Vec<f32>>>],
num_frames: usize,
num_tokens: usize,
) -> WhisperResult<Vec<Vec<f32>>> {
let mut averaged = vec![vec![0.0f32; num_frames]; num_tokens];
let mut count = 0usize;
let selected_heads: Vec<&Vec<Vec<f32>>> = attention_weights
.iter()
.enumerate()
.filter(|(li, _)| self.config.layers.contains(li))
.flat_map(|(_, layer)| {
layer
.iter()
.enumerate()
.filter(|(hi, _)| self.config.heads.as_ref().map_or(true, |h| h.contains(hi)))
})
.map(|(_, head)| head)
.collect();
for head in &selected_heads {
for (token_idx, token_attention) in head.iter().enumerate().take(num_tokens) {
for (frame_idx, &weight) in token_attention.iter().enumerate().take(num_frames) {
averaged[token_idx][frame_idx] += weight;
}
}
count += 1;
}
let scale = 1.0 / (count.max(1) as f32);
for token_attention in &mut averaged {
for weight in token_attention.iter_mut() {
*weight *= scale;
}
}
Ok(averaged)
}
fn find_peak(&self, attention: &[f32]) -> (usize, f32) {
let _ = self; attention
.iter()
.enumerate()
.max_by(|(_, a), (_, b)| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal))
.map_or((0, 0.0), |(idx, &val)| (idx, val))
}
fn compute_confidence(&self, attention: &[f32], peak_frame: usize, peak_value: f32) -> f32 {
let sum: f32 = attention.iter().sum();
if attention.is_empty() || peak_value < self.config.min_attention || sum <= 0.0 {
return 0.0;
}
let concentration = peak_value / sum;
let window = 5;
let start = peak_frame.saturating_sub(window);
let end = (peak_frame + window + 1).min(attention.len());
let local_sum: f32 = attention[start..end].iter().sum();
let locality = local_sum / sum;
concentration.mul_add(0.5, locality * 0.5).min(1.0)
}
}
impl Default for CrossAttentionAlignment {
fn default() -> Self {
Self::new(AlignmentConfig::default())
}
}
#[derive(Debug, Clone)]
pub struct WordTimestampExtractor {
alignment: CrossAttentionAlignment,
}
impl WordTimestampExtractor {
#[must_use]
pub fn new(config: AlignmentConfig) -> Self {
Self {
alignment: CrossAttentionAlignment::new(config),
}
}
pub fn extract_word_alignments(
&self,
attention_weights: &[Vec<Vec<Vec<f32>>>],
token_ids: &[u32],
token_texts: &[String],
num_frames: usize,
) -> WhisperResult<Vec<WordAlignment>> {
let token_alignments =
self.alignment
.extract_token_alignments(attention_weights, token_ids, num_frames)?;
let words = self.group_tokens_into_words(&token_alignments, token_texts);
Ok(words)
}
fn group_tokens_into_words(
&self,
alignments: &[TokenAlignment],
token_texts: &[String],
) -> Vec<WordAlignment> {
let _ = self; let mut words = Vec::new();
let mut current_word = String::new();
let mut current_tokens: Vec<TokenAlignment> = Vec::new();
for (alignment, text) in alignments.iter().zip(token_texts.iter()) {
let starts_new_word = text.starts_with(' ') || text.starts_with('▁');
if starts_new_word && !current_word.is_empty() {
words.push(WordAlignment::new(
current_word.trim().to_string(),
current_tokens.clone(),
));
current_word.clear();
current_tokens.clear();
}
current_word.push_str(text.trim_start_matches([' ', '▁']));
current_tokens.push(alignment.clone());
}
if !current_word.is_empty() {
words.push(WordAlignment::new(
current_word.trim().to_string(),
current_tokens,
));
}
words
}
}
impl Default for WordTimestampExtractor {
fn default() -> Self {
Self::new(AlignmentConfig::default())
}
}