use crate::{
Error, Result,
window::{TimedToken, Transcript, WindowRecognizer},
};
use serde::Serialize;
use std::cell::RefCell;
use unicode_segmentation::UnicodeSegmentation;
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize)]
#[serde(rename_all = "snake_case")]
pub enum TokenKind {
Normal,
Unknown,
Control,
UserDefined,
Unused,
Byte,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize)]
pub struct TokenPiece {
pub text: String,
pub kind: TokenKind,
}
#[derive(Clone, Debug, PartialEq, Serialize)]
pub struct TokenTiming {
pub token_id: usize,
pub piece: TokenPiece,
pub start_sample: u64,
pub end_sample: u64,
pub duration_samples: u64,
pub confidence: f32,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize)]
pub struct WordTiming {
pub word: String,
pub start_sample: u64,
pub end_sample: u64,
}
impl WordTiming {
pub fn start_seconds(&self) -> f64 {
self.start_sample as f64 / crate::recording::SAMPLE_RATE as f64
}
pub fn end_seconds(&self) -> f64 {
self.end_sample as f64 / crate::recording::SAMPLE_RATE as f64
}
}
fn timing_whitespace(character: char) -> bool {
matches!(
character,
'\u{0009}' | '\u{0020}' | '\u{00a0}' | '\u{1680}' | '\u{2000}'
..='\u{200b}' | '\u{202f}' | '\u{205f}' | '\u{3000}'
)
}
fn trimmed_word(timing: WordTiming) -> Option<WordTiming> {
let word = timing.word.trim_matches(timing_whitespace);
if word.is_empty() {
None
} else {
Some(WordTiming {
word: word.to_owned(),
start_sample: timing.start_sample,
end_sample: timing.end_sample,
})
}
}
fn normalized_timing_piece(piece: &str) -> String {
if !piece.contains('▁') {
return piece.to_owned();
}
objc2::rc::autoreleasepool(|_| {
let input = objc2_foundation::NSString::from_str(piece);
input
.stringByReplacingOccurrencesOfString_withString(
&objc2_foundation::NSString::from_str("▁"),
&objc2_foundation::NSString::from_str(" "),
)
.to_string()
})
}
pub fn build_word_timings(tokens: &[TokenTiming]) -> Result<Vec<WordTiming>> {
let mut words = Vec::new();
let mut current: Option<WordTiming> = None;
let mut previous_start = 0;
for (index, token) in tokens.iter().enumerate() {
if token.end_sample < token.start_sample || token.start_sample < previous_start {
return Err(Error::Invalid(format!(
"Invalid word timing input: token_index={index}, token_id={}, start_sample={}, end_sample={}, previous_start={previous_start}",
token.token_id, token.start_sample, token.end_sample
)));
}
previous_start = token.start_sample;
let text = normalized_timing_piece(&token.piece.text);
if matches!(text.as_str(), "" | "<blank>" | "<pad>") {
continue;
}
let boundary = text.graphemes(true).next() == Some(" ");
let starts_new = boundary || current.as_ref().is_none_or(|word| word.word.is_empty());
if starts_new {
if let Some(word) = current.take().and_then(trimmed_word) {
words.push(word);
}
let word = if boundary { text[1..].to_owned() } else { text };
current = Some(WordTiming {
word,
start_sample: token.start_sample,
end_sample: token.end_sample,
});
} else {
let word = current
.as_mut()
.expect("a continuation has a non-empty current word");
word.word.push_str(&text);
word.end_sample = token.end_sample;
}
}
if let Some(word) = current.and_then(trimmed_word) {
words.push(word);
}
Ok(words)
}
#[derive(Clone, Copy, Debug, PartialEq, Serialize)]
pub struct StageTiming {
pub preprocess_seconds: f64,
pub encode_seconds: f64,
pub decode_seconds: f64,
}
impl StageTiming {
pub(crate) fn from_recognition(result: &crate::Recognition) -> Self {
Self {
preprocess_seconds: result.preprocess_seconds,
encode_seconds: result.encode_seconds,
decode_seconds: result.decode_seconds,
}
}
fn zero() -> Self {
Self {
preprocess_seconds: 0.,
encode_seconds: 0.,
decode_seconds: 0.,
}
}
fn add(self, other: Self) -> Self {
Self {
preprocess_seconds: self.preprocess_seconds + other.preprocess_seconds,
encode_seconds: self.encode_seconds + other.encode_seconds,
decode_seconds: self.decode_seconds + other.decode_seconds,
}
}
fn validate(self) -> Result<()> {
if [
self.preprocess_seconds,
self.encode_seconds,
self.decode_seconds,
]
.iter()
.any(|v| !v.is_finite() || *v < 0.)
{
return Err(Error::Invalid(format!(
"Invalid ASR stage timing: {self:?}"
)));
}
Ok(())
}
}
#[derive(Clone, Debug, PartialEq, Serialize)]
pub struct InferenceMetrics {
pub calls: u64,
pub audio_samples: u64,
pub padded_audio_samples: u64,
pub processing_seconds: f64,
pub stages: Option<StageTiming>,
}
impl InferenceMetrics {
pub(crate) fn empty() -> Self {
Self {
calls: 0,
audio_samples: 0,
padded_audio_samples: 0,
processing_seconds: 0.,
stages: Some(StageTiming::zero()),
}
}
pub(crate) fn measured(samples: usize, seconds: f64, stages: Option<StageTiming>) -> Self {
Self {
calls: 1,
audio_samples: samples as u64,
padded_audio_samples: samples as u64,
processing_seconds: seconds,
stages,
}
}
pub(crate) fn add(&self, other: &Self) -> Result<Self> {
self.validate()?;
other.validate()?;
let count = |a: u64, b: u64| {
a.checked_add(b)
.ok_or_else(|| Error::Invalid("ASR metric counter overflow".to_owned()))
};
let result = Self {
calls: count(self.calls, other.calls)?,
audio_samples: count(self.audio_samples, other.audio_samples)?,
padded_audio_samples: count(self.padded_audio_samples, other.padded_audio_samples)?,
processing_seconds: self.processing_seconds + other.processing_seconds,
stages: match (self.stages, other.stages) {
(Some(a), Some(b)) => Some(a.add(b)),
_ => None,
},
};
result.validate()?;
Ok(result)
}
fn validate(&self) -> Result<()> {
if !self.processing_seconds.is_finite()
|| self.processing_seconds < 0.
|| self.audio_samples > self.padded_audio_samples
{
return Err(Error::Invalid(format!(
"Invalid ASR inference metrics: {self:?}"
)));
}
if let Some(stage) = self.stages {
stage.validate()?;
}
Ok(())
}
}
#[derive(Clone, Debug, PartialEq, Serialize)]
pub struct AsrMetadata {
pub audio_samples: u64,
pub sample_rate: u32,
pub audio_seconds: f64,
pub token_count: usize,
pub mean_token_confidence: Option<f32>,
pub inference: InferenceMetrics,
}
impl AsrMetadata {
pub fn rtfx(&self) -> Option<f64> {
if self.audio_samples == 0 || self.inference.processing_seconds == 0. {
None
} else {
Some(self.audio_seconds / self.inference.processing_seconds)
}
}
}
#[derive(Clone, Debug, PartialEq, Serialize)]
pub struct AsrResult {
pub text: String,
pub tokens: Vec<TokenTiming>,
pub metadata: AsrMetadata,
}
impl AsrResult {
pub(crate) fn build(
recognizer: &dyn WindowRecognizer,
transcript: &Transcript,
inference: InferenceMetrics,
) -> Result<Self> {
inference.validate()?;
let mut sum = 0f64;
let mut tokens = Vec::with_capacity(transcript.tokens.len());
let mut previous = 0;
for token in &transcript.tokens {
if token.start_sample >= transcript.audio_samples
|| token.start_sample < previous
|| !token.confidence.is_finite()
|| !(0.0..=1.0).contains(&token.confidence)
{
return Err(Error::Invalid(format!(
"Invalid final ASR token: token={token:?}, audio_samples={}",
transcript.audio_samples
)));
}
previous = token.start_sample;
let end = token
.start_sample
.checked_add(token.duration_samples)
.ok_or_else(|| Error::Invalid(format!("ASR token endpoint overflow: {token:?}")))?;
tokens.push(TokenTiming {
token_id: token.token_id,
piece: recognizer.token_piece(token.token_id)?,
start_sample: token.start_sample,
end_sample: end.min(transcript.audio_samples),
duration_samples: token.duration_samples,
confidence: token.confidence,
});
sum += token.confidence as f64;
}
let metadata = AsrMetadata {
audio_samples: transcript.audio_samples,
sample_rate: 16000,
audio_seconds: transcript.audio_seconds(),
token_count: tokens.len(),
mean_token_confidence: if tokens.is_empty() {
None
} else {
Some((sum / tokens.len() as f64) as f32)
},
inference,
};
Ok(Self {
text: transcript.text.clone(),
tokens,
metadata,
})
}
pub fn word_timings(&self) -> Result<Vec<WordTiming>> {
build_word_timings(&self.tokens)
}
pub fn into_transcript(self) -> Transcript {
Transcript {
text: self.text,
tokens: self
.tokens
.into_iter()
.map(|t| TimedToken {
token_id: t.token_id,
start_sample: t.start_sample,
duration_samples: t.duration_samples,
confidence: t.confidence,
})
.collect(),
audio_samples: self.metadata.audio_samples,
}
}
}
pub(crate) struct WindowInference {
pub tokens: Vec<TimedToken>,
pub metrics: InferenceMetrics,
}
impl WindowInference {
pub(crate) fn from_recognition(
recognition: crate::Recognition,
input_samples: usize,
original_samples: usize,
processing_seconds: f64,
) -> Self {
let mut metrics = InferenceMetrics::measured(
input_samples,
processing_seconds,
Some(StageTiming::from_recognition(&recognition)),
);
metrics.audio_samples = original_samples as u64;
Self {
tokens: recognition.token_timings,
metrics,
}
}
}
pub(crate) struct ProfiledRecognizer<'a> {
inner: &'a dyn WindowRecognizer,
metrics: RefCell<InferenceMetrics>,
}
impl<'a> ProfiledRecognizer<'a> {
pub(crate) fn new(inner: &'a dyn WindowRecognizer) -> Self {
Self {
inner,
metrics: RefCell::new(InferenceMetrics::empty()),
}
}
pub(crate) fn metrics(&self) -> InferenceMetrics {
self.metrics.borrow().clone()
}
}
impl WindowRecognizer for ProfiledRecognizer<'_> {
fn input_limits(&self) -> crate::InputLimits {
self.inner.input_limits()
}
fn infer(&self, audio: &[f32], cancellation: &crate::Cancellation) -> Result<Vec<TimedToken>> {
Ok(self
.infer_with_metrics(audio, audio.len(), cancellation)?
.tokens)
}
fn infer_with_metrics(
&self,
audio: &[f32],
original_samples: usize,
cancellation: &crate::Cancellation,
) -> Result<WindowInference> {
let result = self
.inner
.infer_with_metrics(audio, original_samples, cancellation)?;
let next = self.metrics.borrow().add(&result.metrics)?;
self.metrics.replace(next);
Ok(result)
}
fn render(&self, tokens: &[TimedToken]) -> Result<String> {
self.inner.render(tokens)
}
fn token_piece(&self, id: usize) -> Result<TokenPiece> {
self.inner.token_piece(id)
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::{
Cancellation, InputLimits,
window::{recognize_window_with_metrics, test_support::FULL_WINDOW},
};
struct SimulatedRecognizer {
tokens: Vec<TimedToken>,
}
impl WindowRecognizer for SimulatedRecognizer {
fn input_limits(&self) -> InputLimits {
FULL_WINDOW
}
fn infer(&self, _: &[f32], _: &Cancellation) -> Result<Vec<TimedToken>> {
Ok(self.tokens.clone())
}
fn infer_with_metrics(
&self,
audio: &[f32],
original_samples: usize,
cancellation: &Cancellation,
) -> Result<WindowInference> {
cancellation.check()?;
let mut metrics = InferenceMetrics::measured(
audio.len(),
0.25,
Some(StageTiming {
preprocess_seconds: 0.01,
encode_seconds: 0.1,
decode_seconds: 0.05,
}),
);
metrics.audio_samples = original_samples as u64;
Ok(WindowInference {
tokens: self.tokens.clone(),
metrics,
})
}
fn render(&self, _: &[TimedToken]) -> Result<String> {
Ok("あ".to_owned())
}
fn token_piece(&self, id: usize) -> Result<TokenPiece> {
let text = match id {
1 => "<0xE3>",
2 => "<0x81>",
3 => "<0x82>",
_ => {
return Err(Error::Invalid(format!(
"Test vocabulary out of range: {id}"
)));
}
};
Ok(TokenPiece {
text: text.to_owned(),
kind: TokenKind::Byte,
})
}
}
#[test]
fn shared_result_preserves_raw_byte_pieces_zero_duration_and_legacy_projection() {
let tokens = vec![
TimedToken {
token_id: 1,
start_sample: 0,
duration_samples: 0,
confidence: 0.25,
},
TimedToken {
token_id: 2,
start_sample: 0,
duration_samples: 0,
confidence: 0.75,
},
TimedToken {
token_id: 3,
start_sample: 9,
duration_samples: 1280,
confidence: 0.5,
},
];
let recognizer = SimulatedRecognizer {
tokens: tokens.clone(),
};
let transcript = Transcript {
text: "あ".into(),
tokens,
audio_samples: 10,
};
let metrics = InferenceMetrics::measured(
10,
0.25,
Some(StageTiming {
preprocess_seconds: 0.01,
encode_seconds: 0.1,
decode_seconds: 0.05,
}),
);
let result = AsrResult::build(&recognizer, &transcript, metrics).unwrap();
assert_eq!(result.text, "あ");
assert_eq!(
result
.tokens
.iter()
.map(|t| t.piece.text.as_str())
.collect::<Vec<_>>(),
["<0xE3>", "<0x81>", "<0x82>"]
);
assert_eq!(result.tokens[0].duration_samples, 0);
assert_eq!(result.tokens[2].end_sample, 10);
assert_eq!(result.tokens[2].duration_samples, 1280);
assert_eq!(result.metadata.mean_token_confidence, Some(0.5));
assert_eq!(result.metadata.audio_samples, 10);
assert_eq!(result.metadata.audio_seconds, 10.0 / 16000.0);
assert_eq!(result.metadata.rtfx(), Some((10.0 / 16000.0) / 0.25));
assert_eq!(
serde_json::to_value(result.into_transcript()).unwrap(),
serde_json::to_value(transcript).unwrap()
);
}
#[test]
fn padded_and_repeated_windows_count_all_work_without_inflating_audio_duration() {
let recognizer = SimulatedRecognizer { tokens: Vec::new() };
let profiled = ProfiledRecognizer::new(&recognizer);
let cancellation = Cancellation::new();
for size in [2, 3] {
let inference =
recognize_window_with_metrics(&profiled, &vec![0.; size], &cancellation).unwrap();
assert_eq!(inference.metrics.audio_samples, size as u64);
assert_eq!(
inference.metrics.padded_audio_samples,
FULL_WINDOW.minimum_samples() as u64
);
}
let metrics = profiled.metrics();
assert_eq!(metrics.calls, 2);
assert_eq!(metrics.audio_samples, 5);
assert_eq!(
metrics.padded_audio_samples,
2 * FULL_WINDOW.minimum_samples() as u64
);
assert_eq!(metrics.processing_seconds, 0.5);
assert_eq!(metrics.stages.unwrap().encode_seconds, 0.2);
let result = AsrResult::build(
&recognizer,
&Transcript {
text: String::new(),
tokens: Vec::new(),
audio_samples: 3,
},
metrics,
)
.unwrap();
assert_eq!(result.metadata.audio_samples, 3);
assert_eq!(result.metadata.inference.audio_samples, 5);
assert_eq!(result.metadata.mean_token_confidence, None);
}
#[test]
fn empty_audio_and_unprofiled_stages_are_explicit_and_serialize_finitely() {
let recognizer = SimulatedRecognizer { tokens: Vec::new() };
let result = AsrResult::build(
&recognizer,
&Transcript {
text: String::new(),
tokens: Vec::new(),
audio_samples: 0,
},
InferenceMetrics::empty(),
)
.unwrap();
assert_eq!(result.metadata.rtfx(), None);
assert_eq!(result.metadata.mean_token_confidence, None);
assert_eq!(result.metadata.inference.calls, 0);
assert!(serde_json::to_string(&result).is_ok());
let measured = InferenceMetrics::measured(100, 0.1, None);
assert!(
InferenceMetrics::empty()
.add(&measured)
.unwrap()
.stages
.is_none()
);
let measured = InferenceMetrics::measured(0, 0., None);
assert!(
measured
.add(&InferenceMetrics::empty())
.unwrap()
.stages
.is_none()
);
}
#[test]
fn invalid_metrics_and_token_coordinates_are_errors() {
let mut invalid = InferenceMetrics::empty();
invalid.processing_seconds = f64::NAN;
assert!(invalid.add(&InferenceMetrics::empty()).is_err());
let mut overflow = InferenceMetrics::empty();
overflow.calls = u64::MAX;
assert!(
overflow
.add(&InferenceMetrics::measured(1, 0.1, None))
.is_err()
);
let invalid = InferenceMetrics::measured(
1,
0.1,
Some(StageTiming {
preprocess_seconds: -1.,
encode_seconds: 0.,
decode_seconds: 0.,
}),
);
assert!(invalid.add(&InferenceMetrics::empty()).is_err());
let recognizer = SimulatedRecognizer { tokens: Vec::new() };
for token in [
TimedToken {
token_id: 1,
start_sample: 10,
duration_samples: 0,
confidence: 0.5,
},
TimedToken {
token_id: 1,
start_sample: 0,
duration_samples: 0,
confidence: f32::NAN,
},
TimedToken {
token_id: 99,
start_sample: 0,
duration_samples: 0,
confidence: 0.5,
},
] {
assert!(
AsrResult::build(
&recognizer,
&Transcript {
text: String::new(),
tokens: vec![token],
audio_samples: 10
},
InferenceMetrics::empty()
)
.is_err()
);
}
}
fn timing_piece(text: &str, start: u64, end: u64) -> TokenTiming {
TokenTiming {
token_id: 1,
piece: TokenPiece {
text: text.to_owned(),
kind: TokenKind::Normal,
},
start_sample: start,
end_sample: end,
duration_samples: end - start,
confidence: 0.5,
}
}
#[test]
fn word_groups_follow_markers_spaces_and_exact_special_token_names() {
let tokens = [
timing_piece("Hello", 0, 1280),
timing_piece("▁wor", 2560, 3840),
timing_piece("ld", 3840, 5120),
timing_piece("<blank>", 5120, 6400),
timing_piece("", 6400, 7680),
timing_piece("<pad>", 7680, 8960),
timing_piece(" next", 8960, 10240),
timing_piece("<unk>", 10240, 11520),
];
let words = build_word_timings(&tokens).unwrap();
assert_eq!(
words,
vec![
WordTiming {
word: "Hello".into(),
start_sample: 0,
end_sample: 1280
},
WordTiming {
word: "world".into(),
start_sample: 2560,
end_sample: 5120
},
WordTiming {
word: "next<unk>".into(),
start_sample: 8960,
end_sample: 11520
}
]
);
assert_eq!(words[1].start_seconds(), 0.16);
assert_eq!(words[1].end_seconds(), 0.32);
}
#[test]
fn word_groups_use_the_last_end_without_widening_zero_duration_spans() {
let tokens = [
timing_piece("▁over", 0, 5120),
timing_piece("lap", 1280, 1280),
timing_piece("▁zero", 2560, 2560),
];
assert_eq!(
build_word_timings(&tokens).unwrap(),
vec![
WordTiming {
word: "overlap".into(),
start_sample: 0,
end_sample: 1280
},
WordTiming {
word: "zero".into(),
start_sample: 2560,
end_sample: 2560
}
]
);
}
#[test]
fn word_groups_match_foundation_whitespace_without_removing_newlines() {
let tokens = [
timing_piece("▁", 0, 0),
timing_piece("word", 1280, 2560),
timing_piece("▁\t\u{200b}trim\u{00a0}", 2560, 3840),
timing_piece("▁\nline\n", 3840, 5120),
timing_piece("▁ ", 5120, 6400),
];
let words = build_word_timings(&tokens).unwrap();
assert_eq!(
words.iter().map(|w| w.word.as_str()).collect::<Vec<_>>(),
["word", "trim", "\nline\n"]
);
assert_eq!(words[0].start_sample, 1280);
assert!(
build_word_timings(&[timing_piece("▁", 0, 0), timing_piece("\u{200b}\t", 1, 1)])
.unwrap()
.is_empty()
);
assert!(build_word_timings(&[]).unwrap().is_empty());
}
#[test]
fn word_normalization_respects_graphemes_and_does_not_decode_byte_literals() {
let tokens = [
timing_piece("▁one▁two", 0, 1280),
timing_piece("▁\u{0301}x", 1280, 2560),
timing_piece("▁next", 2560, 3840),
timing_piece("▁<0xE3>", 3840, 5120),
timing_piece("<0x81>", 5120, 6400),
timing_piece("<0x82>", 6400, 7680),
];
let words = build_word_timings(&tokens).unwrap();
assert_eq!(
words.iter().map(|w| w.word.as_str()).collect::<Vec<_>>(),
["one two▁\u{0301}x", "next", "<0xE3><0x81><0x82>"]
);
}
#[test]
fn invalid_word_timestamps_are_errors_and_input_is_never_reordered() {
let tokens = [timing_piece("▁a", 1280, 2560), timing_piece("▁b", 0, 1280)];
let before = serde_json::to_value(&tokens).unwrap();
let error = build_word_timings(&tokens).unwrap_err().to_string();
assert!(error.contains("token_index=1") && error.contains("previous_start=1280"));
assert_eq!(serde_json::to_value(&tokens).unwrap(), before);
let mut invalid = timing_piece("<blank>", 10, 10);
invalid.end_sample = 9;
assert!(
build_word_timings(&[invalid])
.unwrap_err()
.to_string()
.contains("end_sample=9")
);
}
#[test]
fn result_word_timings_are_lazy_and_leave_serialized_results_unchanged() {
let recognizer = SimulatedRecognizer { tokens: Vec::new() };
let transcript = Transcript {
text: "あ".into(),
tokens: vec![
TimedToken {
token_id: 1,
start_sample: 0,
duration_samples: 0,
confidence: 0.25,
},
TimedToken {
token_id: 2,
start_sample: 0,
duration_samples: 0,
confidence: 0.75,
},
TimedToken {
token_id: 3,
start_sample: 0,
duration_samples: 1280,
confidence: 0.5,
},
],
audio_samples: 1280,
};
let result = AsrResult::build(
&recognizer,
&transcript,
InferenceMetrics::measured(1280, 0.25, None),
)
.unwrap();
let before = serde_json::to_value(&result).unwrap();
let words = result.word_timings().unwrap();
assert_eq!(
words,
vec![WordTiming {
word: "<0xE3><0x81><0x82>".into(),
start_sample: 0,
end_sample: 1280
}]
);
assert_eq!(serde_json::to_value(&result).unwrap(), before);
assert_eq!(result.metadata.inference.calls, 1);
}
#[test]
fn word_marker_before_zero_width_joiner_uses_foundation_replacement_semantics() {
let tokens = [
timing_piece("▁\u{200d}word", 0, 1280),
timing_piece("▁next", 1280, 2560),
];
let words = build_word_timings(&tokens).unwrap();
assert_eq!(words[0].word, "\u{200d}word");
assert_eq!(words[0].start_sample, 0);
assert_eq!(words[0].end_sample, 1280);
}
}