use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::mpsc;
use std::sync::{Arc, Mutex};
use super::engine::AsrEngine;
use crate::audio::capture::TARGET_RATE;
const MIN_NEW_AUDIO_SECS: f32 = 2.0;
const POLL_INTERVAL_MS: u64 = 300;
const MAX_CHUNK_SECS: f32 = 5.0;
const OVERLAP_SECS: f32 = 1.5;
pub enum StreamingEvent {
PartialText { text: String, replace_chars: usize },
SpeechDetected,
}
pub struct StreamingHandle {
stop_tx: mpsc::Sender<()>,
abort_flag: Arc<AtomicBool>,
join_handle: Option<std::thread::JoinHandle<()>>,
}
impl StreamingHandle {
pub fn stop_and_join(mut self) {
self.abort_flag.store(true, Ordering::Relaxed);
let _ = self.stop_tx.send(());
if let Some(handle) = self.join_handle.take() {
if let Err(e) = handle.join() {
log::error!("Streaming thread panicked: {e:?}");
}
}
}
}
pub fn start_streaming(
sample_buffer: Arc<Mutex<Vec<f32>>>,
engine: Arc<dyn AsrEngine + Send + Sync>,
translate: bool,
filler_word_removal: bool,
tx: mpsc::Sender<StreamingEvent>,
worker: Option<super::subprocess::SubprocessTranscriber>,
) -> StreamingHandle {
let (stop_tx, stop_rx) = mpsc::channel::<()>();
let abort_flag = Arc::new(AtomicBool::new(false));
let abort_flag_clone = Arc::clone(&abort_flag);
let join_handle = std::thread::spawn(move || {
streaming_loop(
sample_buffer,
engine,
translate,
filler_word_removal,
tx,
stop_rx,
abort_flag_clone,
worker,
);
});
StreamingHandle {
stop_tx,
abort_flag,
join_handle: Some(join_handle),
}
}
pub fn start_native_streaming(
sample_buffer: Arc<Mutex<Vec<f32>>>,
engine: Arc<dyn AsrEngine + Send + Sync>,
translate: bool,
tx: mpsc::Sender<StreamingEvent>,
) -> StreamingHandle {
let (stop_tx, stop_rx) = mpsc::channel::<()>();
let abort_flag = Arc::new(AtomicBool::new(false));
let abort_flag_clone = Arc::clone(&abort_flag);
let join_handle = std::thread::spawn(move || {
native_streaming_loop(
sample_buffer,
engine,
translate,
tx,
stop_rx,
abort_flag_clone,
);
});
StreamingHandle {
stop_tx,
abort_flag,
join_handle: Some(join_handle),
}
}
fn native_streaming_loop(
sample_buffer: Arc<Mutex<Vec<f32>>>,
engine: Arc<dyn AsrEngine + Send + Sync>,
translate: bool,
tx: mpsc::Sender<StreamingEvent>,
stop_rx: mpsc::Receiver<()>,
abort_flag: Arc<AtomicBool>,
) {
let min_samples = (MIN_NEW_AUDIO_SECS * TARGET_RATE as f32) as usize;
let mut prev_len: usize = 0;
let mut prev_text = String::new();
loop {
match stop_rx.try_recv() {
Ok(()) | Err(mpsc::TryRecvError::Disconnected) => break,
Err(mpsc::TryRecvError::Empty) => {}
}
let current_len = match sample_buffer.lock() {
Ok(b) => b.len(),
Err(e) => e.into_inner().len(),
};
let new_samples = current_len.saturating_sub(prev_len);
if new_samples < min_samples {
std::thread::sleep(std::time::Duration::from_millis(POLL_INTERVAL_MS));
continue;
}
let samples: Vec<f32> = match sample_buffer.lock() {
Ok(b) => b.clone(),
Err(e) => e.into_inner().clone(),
};
if samples.is_empty() {
std::thread::sleep(std::time::Duration::from_millis(POLL_INTERVAL_MS));
continue;
}
let new_start = prev_len.min(samples.len());
if !super::vad::contains_speech(&samples[new_start..]) {
prev_len = current_len;
std::thread::sleep(std::time::Duration::from_millis(POLL_INTERVAL_MS));
continue;
}
let _ = tx.send(StreamingEvent::SpeechDetected);
match engine.transcribe(&samples, translate) {
Ok(result) => {
if abort_flag.load(Ordering::Relaxed) {
break;
}
if !result.text.is_empty() && result.text != prev_text {
if result.text.starts_with(&prev_text) {
let new_suffix = &result.text[prev_text.len()..];
if !new_suffix.is_empty() {
let _ = tx.send(StreamingEvent::PartialText {
text: new_suffix.to_string(),
replace_chars: 0,
});
}
} else {
let replace_chars = prev_text.chars().count();
let _ = tx.send(StreamingEvent::PartialText {
text: result.text.clone(),
replace_chars,
});
}
prev_text = result.text;
}
}
Err(e) => {
log::error!("Native streaming transcription failed: {e}");
}
}
prev_len = current_len;
}
}
#[allow(clippy::too_many_arguments)]
fn streaming_loop(
sample_buffer: Arc<Mutex<Vec<f32>>>,
engine: Arc<dyn AsrEngine + Send + Sync>,
translate: bool,
filler_word_removal: bool,
tx: mpsc::Sender<StreamingEvent>,
stop_rx: mpsc::Receiver<()>,
_abort_flag: Arc<AtomicBool>,
mut worker: Option<super::subprocess::SubprocessTranscriber>,
) {
let min_new_samples = (MIN_NEW_AUDIO_SECS * TARGET_RATE as f32) as usize;
let max_chunk_samples = (MAX_CHUNK_SECS * TARGET_RATE as f32) as usize;
let overlap_samples = (OVERLAP_SECS * TARGET_RATE as f32) as usize;
let mut consumed_boundary: usize = 0;
let mut committed_words: Vec<String> = Vec::new();
let mut chunk: Vec<f32> = Vec::with_capacity(max_chunk_samples);
loop {
match stop_rx.try_recv() {
Ok(()) | Err(mpsc::TryRecvError::Disconnected) => {
let total = match sample_buffer.lock() {
Ok(b) => b.len(),
Err(e) => e.into_inner().len(),
};
let start = consumed_boundary.saturating_sub(overlap_samples);
if total > start {
chunk.clear();
{
let buf = sample_buffer.lock().unwrap_or_else(|e| e.into_inner());
chunk.extend_from_slice(&buf[start..total]);
}
emit_chunk(
&mut worker,
&engine,
&chunk,
translate,
filler_word_removal,
&mut committed_words,
&tx,
);
}
break;
}
Err(mpsc::TryRecvError::Empty) => {}
}
let total_samples = match sample_buffer.lock() {
Ok(b) => b.len(),
Err(e) => e.into_inner().len(),
};
if !has_enough_new_audio(total_samples, consumed_boundary, min_new_samples) {
std::thread::sleep(std::time::Duration::from_millis(POLL_INTERVAL_MS));
continue;
}
let (chunk_start, chunk_end) = compute_chunk_bounds(
consumed_boundary,
overlap_samples,
total_samples,
max_chunk_samples,
);
chunk.clear();
{
let buf = sample_buffer.lock().unwrap_or_else(|e| e.into_inner());
chunk.extend_from_slice(&buf[chunk_start..chunk_end]);
}
if !super::vad::contains_speech(&chunk) {
consumed_boundary = chunk_end;
std::thread::sleep(std::time::Duration::from_millis(POLL_INTERVAL_MS));
continue;
}
let _ = tx.send(StreamingEvent::SpeechDetected);
emit_chunk(
&mut worker,
&engine,
&chunk,
translate,
filler_word_removal,
&mut committed_words,
&tx,
);
consumed_boundary = match sample_buffer.lock() {
Ok(b) => b.len(),
Err(e) => e.into_inner().len(),
};
}
}
fn emit_chunk(
worker: &mut Option<super::subprocess::SubprocessTranscriber>,
engine: &Arc<dyn AsrEngine + Send + Sync>,
chunk: &[f32],
translate: bool,
filler_word_removal: bool,
committed_words: &mut Vec<String>,
tx: &mpsc::Sender<StreamingEvent>,
) {
let text = if let Some(ref mut w) = worker {
match w.transcribe(chunk, translate) {
Ok(t) => t,
Err(e) => {
log::error!("Streaming transcription failed: {e}");
return;
}
}
} else {
match engine.transcribe(chunk, translate) {
Ok(result) => result.text,
Err(e) => {
log::error!("Streaming transcription failed: {e}");
return;
}
}
};
let new_words = compute_new_words(&text, filler_word_removal, committed_words);
if let Some(event) = format_partial_event(&new_words) {
let _ = tx.send(event);
committed_words.extend(new_words);
}
}
pub(crate) fn has_enough_new_audio(
total_samples: usize,
consumed_boundary: usize,
min_new_samples: usize,
) -> bool {
let new_samples = total_samples.saturating_sub(consumed_boundary);
new_samples >= min_new_samples
}
pub(crate) fn compute_chunk_bounds(
consumed_boundary: usize,
overlap_samples: usize,
total_samples: usize,
max_chunk_samples: usize,
) -> (usize, usize) {
let chunk_start = consumed_boundary.saturating_sub(overlap_samples);
let chunk_end = if total_samples - chunk_start > max_chunk_samples {
chunk_start + max_chunk_samples
} else {
total_samples
};
(chunk_start, chunk_end)
}
pub(crate) fn compute_new_words(
raw_text: &str,
filler_word_removal: bool,
committed_words: &[String],
) -> Vec<String> {
if raw_text.is_empty() {
return Vec::new();
}
let mut text = raw_text.to_string();
if filler_word_removal {
text = super::postprocess::remove_filler_words(&text);
if text.is_empty() {
return Vec::new();
}
}
text = super::postprocess::ensure_space_after_punctuation(&text);
let chunk_words: Vec<String> = text.split_whitespace().map(String::from).collect();
stitch(committed_words, &chunk_words)
}
pub(crate) fn format_partial_event(new_words: &[String]) -> Option<StreamingEvent> {
if new_words.is_empty() {
return None;
}
let new_text = new_words.join(" ");
Some(StreamingEvent::PartialText {
text: format!(" {new_text}"),
replace_chars: 0,
})
}
#[allow(dead_code)]
fn common_prefix_len(a: &str, b: &str) -> usize {
a.chars()
.zip(b.chars())
.take_while(|(ac, bc)| ac == bc)
.map(|(c, _)| c.len_utf8())
.sum()
}
pub fn stitch(committed: &[String], chunk_words: &[String]) -> Vec<String> {
if committed.is_empty() {
return chunk_words.to_vec();
}
if chunk_words.is_empty() {
return Vec::new();
}
let tail_len = committed.len().min(chunk_words.len());
let tail = &committed[committed.len() - tail_len..];
let best_match = longest_suffix_prefix_match(tail, chunk_words);
if best_match > 0 {
chunk_words[best_match..].to_vec()
} else {
chunk_words.to_vec()
}
}
fn normalize_for_match(word: &str) -> &str {
word.trim_matches(|c: char| c.is_ascii_punctuation())
}
fn longest_suffix_prefix_match(a: &[String], b: &[String]) -> usize {
let max_len = a.len().min(b.len());
let mut best = 0;
for len in 1..=max_len {
let suffix = &a[a.len() - len..];
let prefix = &b[..len];
if suffix.iter().zip(prefix.iter()).all(|(s, p)| {
let s_norm = normalize_for_match(s);
let p_norm = normalize_for_match(p);
!s_norm.is_empty() && !p_norm.is_empty() && s_norm.eq_ignore_ascii_case(p_norm)
}) {
best = len;
}
}
best
}
#[allow(dead_code)]
fn split_words(text: &str) -> Vec<String> {
text.split_whitespace().map(|w| w.to_string()).collect()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_stitch_no_overlap() {
let committed: Vec<String> = vec!["hello".into(), "world".into()];
let chunk: Vec<String> = vec!["foo".into(), "bar".into()];
let result = stitch(&committed, &chunk);
assert_eq!(result, vec!["foo", "bar"]);
}
#[test]
fn test_stitch_full_overlap() {
let committed: Vec<String> = vec!["the".into(), "quick".into(), "brown".into()];
let chunk: Vec<String> = vec!["quick".into(), "brown".into(), "fox".into()];
let result = stitch(&committed, &chunk);
assert_eq!(result, vec!["fox"]);
}
#[test]
fn test_stitch_single_word_overlap() {
let committed: Vec<String> = vec!["hello".into(), "world".into()];
let chunk: Vec<String> = vec!["world".into(), "today".into()];
let result = stitch(&committed, &chunk);
assert_eq!(result, vec!["today"]);
}
#[test]
fn test_stitch_empty_committed() {
let committed: Vec<String> = vec![];
let chunk: Vec<String> = vec!["hello".into(), "world".into()];
let result = stitch(&committed, &chunk);
assert_eq!(result, vec!["hello", "world"]);
}
#[test]
fn test_stitch_empty_chunk() {
let committed: Vec<String> = vec!["hello".into()];
let chunk: Vec<String> = vec![];
let result = stitch(&committed, &chunk);
assert!(result.is_empty());
}
#[test]
fn test_stitch_case_insensitive() {
let committed: Vec<String> = vec!["Hello".into(), "World".into()];
let chunk: Vec<String> = vec!["world".into(), "today".into()];
let result = stitch(&committed, &chunk);
assert_eq!(result, vec!["today"]);
}
#[test]
fn test_stitch_complete_duplicate() {
let committed: Vec<String> = vec!["a".into(), "b".into(), "c".into()];
let chunk: Vec<String> = vec!["a".into(), "b".into(), "c".into()];
let result = stitch(&committed, &chunk);
assert!(result.is_empty());
}
#[test]
fn test_vad_silence_no_speech() {
let samples = vec![0.0f32; 16000];
assert!(!crate::transcription::vad::contains_speech(&samples));
}
#[test]
fn test_vad_low_noise_no_speech() {
let samples = vec![0.001f32; 16000];
assert!(!crate::transcription::vad::contains_speech(&samples));
}
#[test]
fn test_vad_empty_no_speech() {
assert!(!crate::transcription::vad::contains_speech(&[]));
}
#[test]
fn test_longest_suffix_prefix_match_basic() {
let a: Vec<String> = vec!["x".into(), "y".into(), "z".into()];
let b: Vec<String> = vec!["y".into(), "z".into(), "w".into()];
assert_eq!(longest_suffix_prefix_match(&a, &b), 2);
}
#[test]
fn test_longest_suffix_prefix_match_none() {
let a: Vec<String> = vec!["a".into(), "b".into()];
let b: Vec<String> = vec!["c".into(), "d".into()];
assert_eq!(longest_suffix_prefix_match(&a, &b), 0);
}
#[test]
fn test_longest_suffix_prefix_match_full() {
let a: Vec<String> = vec!["a".into(), "b".into()];
let b: Vec<String> = vec!["a".into(), "b".into()];
assert_eq!(longest_suffix_prefix_match(&a, &b), 2);
}
#[test]
fn test_split_words_basic() {
assert_eq!(split_words("hello world"), vec!["hello", "world"]);
}
#[test]
fn test_split_words_extra_whitespace() {
assert_eq!(split_words(" hello world "), vec!["hello", "world"]);
}
#[test]
fn test_split_words_empty() {
assert!(split_words("").is_empty());
assert!(split_words(" ").is_empty());
}
#[test]
fn test_split_words_single() {
assert_eq!(split_words("hello"), vec!["hello"]);
}
#[test]
fn test_constants() {
const { assert!(MIN_NEW_AUDIO_SECS > 0.0) };
const { assert!(MAX_CHUNK_SECS > 0.0) };
const { assert!(MAX_CHUNK_SECS <= 30.0) };
const { assert!(OVERLAP_SECS >= 0.0) };
const { assert!(POLL_INTERVAL_MS > 0) };
assert_eq!(TARGET_RATE, 16_000);
}
#[test]
fn test_streaming_event_partial_text() {
let event = StreamingEvent::PartialText {
text: "hello".to_string(),
replace_chars: 3,
};
match event {
StreamingEvent::PartialText {
text,
replace_chars,
} => {
assert_eq!(text, "hello");
assert_eq!(replace_chars, 3);
}
StreamingEvent::SpeechDetected => panic!("unexpected variant"),
}
}
#[test]
fn test_stitch_long_committed_short_chunk() {
let committed: Vec<String> = (0..100).map(|i| format!("w{i}")).collect();
let chunk: Vec<String> = vec!["w99".into(), "new1".into()];
let result = stitch(&committed, &chunk);
assert_eq!(result, vec!["new1"]);
}
#[test]
fn test_longest_suffix_prefix_match_case_insensitive() {
let a: Vec<String> = vec!["Hello".into(), "WORLD".into()];
let b: Vec<String> = vec!["hello".into(), "world".into(), "test".into()];
assert_eq!(longest_suffix_prefix_match(&a, &b), 2);
}
#[test]
fn test_longest_suffix_prefix_match_punctuation() {
let a: Vec<String> = vec!["it'll".into(), "come.".into()];
let b: Vec<String> = vec!["come".into(), "pop".into(), "up".into()];
assert_eq!(longest_suffix_prefix_match(&a, &b), 1);
}
#[test]
fn test_longest_suffix_prefix_match_leading_punctuation() {
let a: Vec<String> = vec!["hello".into(), "world".into()];
let b: Vec<String> = vec!["\"world".into(), "today".into()];
assert_eq!(longest_suffix_prefix_match(&a, &b), 1);
}
#[test]
fn test_stitch_punctuation_mismatch() {
let committed: Vec<String> = vec![
"keep".into(),
"talking".into(),
"and".into(),
"eventually".into(),
"it'll".into(),
"come.".into(),
];
let chunk: Vec<String> = vec![
"eventually".into(),
"it'll".into(),
"come".into(),
"pop".into(),
"up".into(),
];
let result = stitch(&committed, &chunk);
assert_eq!(result, vec!["pop", "up"]);
}
#[test]
fn test_normalize_for_match() {
assert_eq!(normalize_for_match("hello"), "hello");
assert_eq!(normalize_for_match("hello."), "hello");
assert_eq!(normalize_for_match("hello,"), "hello");
assert_eq!(normalize_for_match("\"hello\""), "hello");
assert_eq!(normalize_for_match("..."), "");
}
#[test]
fn test_common_prefix_len_identical() {
assert_eq!(common_prefix_len("hello world", "hello world"), 11);
}
#[test]
fn test_common_prefix_len_prefix_match() {
assert_eq!(common_prefix_len("hello world", "hello world foo"), 11);
}
#[test]
fn test_common_prefix_len_diverges() {
assert_eq!(common_prefix_len("hello world", "hello earth"), 6);
}
#[test]
fn test_common_prefix_len_empty() {
assert_eq!(common_prefix_len("", "hello"), 0);
assert_eq!(common_prefix_len("hello", ""), 0);
}
#[test]
fn test_common_prefix_len_no_common() {
assert_eq!(common_prefix_len("abc", "xyz"), 0);
}
#[test]
fn test_common_prefix_len_unicode() {
assert_eq!(common_prefix_len("café latte", "café mocha"), 6);
}
#[test]
fn test_common_prefix_len_revision_scenario() {
let old = "My name is Jacob Frack and I am";
let new_text = "My name is Jacob Freck and I am a principal";
assert_eq!(common_prefix_len(old, new_text), 19);
}
#[test]
fn test_has_enough_audio_above_threshold() {
assert!(has_enough_new_audio(50_000, 10_000, 32_000));
}
#[test]
fn test_has_enough_audio_exactly_at_threshold() {
assert!(has_enough_new_audio(42_000, 10_000, 32_000));
}
#[test]
fn test_has_enough_audio_below_threshold() {
assert!(!has_enough_new_audio(30_000, 10_000, 32_000));
}
#[test]
fn test_has_enough_audio_no_new_samples() {
assert!(!has_enough_new_audio(10_000, 10_000, 32_000));
}
#[test]
fn test_has_enough_audio_consumed_beyond_total() {
assert!(!has_enough_new_audio(5_000, 10_000, 32_000));
}
#[test]
fn test_has_enough_audio_zero_threshold() {
assert!(has_enough_new_audio(100, 100, 0));
}
#[test]
fn test_chunk_bounds_basic() {
let (start, end) = compute_chunk_bounds(10_000, 2_000, 20_000, 8_000);
assert_eq!(start, 8_000); assert_eq!(end, 16_000); }
#[test]
fn test_chunk_bounds_no_cap() {
let (start, end) = compute_chunk_bounds(10_000, 2_000, 14_000, 80_000);
assert_eq!(start, 8_000);
assert_eq!(end, 14_000);
}
#[test]
fn test_chunk_bounds_overlap_exceeds_consumed() {
let (start, end) = compute_chunk_bounds(1_000, 5_000, 10_000, 80_000);
assert_eq!(start, 0);
assert_eq!(end, 10_000);
}
#[test]
fn test_chunk_bounds_zero_overlap() {
let (start, end) = compute_chunk_bounds(5_000, 0, 10_000, 80_000);
assert_eq!(start, 5_000);
assert_eq!(end, 10_000);
}
#[test]
fn test_chunk_bounds_exact_max() {
let (start, end) = compute_chunk_bounds(5_000, 1_000, 12_000, 8_000);
assert_eq!(start, 4_000);
assert_eq!(end, 12_000); }
#[test]
fn test_chunk_bounds_with_real_constants() {
let overlap_samples = (OVERLAP_SECS * TARGET_RATE as f32) as usize;
let max_chunk_samples = (MAX_CHUNK_SECS * TARGET_RATE as f32) as usize;
let consumed = 5 * TARGET_RATE as usize;
let total = 10 * TARGET_RATE as usize;
let (start, end) =
compute_chunk_bounds(consumed, overlap_samples, total, max_chunk_samples);
assert!(start < consumed);
assert!(end <= total);
assert!(end - start <= max_chunk_samples);
}
#[test]
fn test_compute_new_words_empty_text() {
let committed: Vec<String> = vec!["hello".into()];
assert!(compute_new_words("", false, &committed).is_empty());
}
#[test]
fn test_compute_new_words_no_committed() {
let result = compute_new_words("hello world", false, &[]);
assert_eq!(result, vec!["hello", "world"]);
}
#[test]
fn test_compute_new_words_with_overlap() {
let committed: Vec<String> = vec!["the".into(), "quick".into(), "brown".into()];
let result = compute_new_words("quick brown fox", false, &committed);
assert_eq!(result, vec!["fox"]);
}
#[test]
fn test_compute_new_words_identical_result() {
let committed: Vec<String> = vec!["hello".into(), "world".into()];
let result = compute_new_words("hello world", false, &committed);
assert!(result.is_empty());
}
#[test]
fn test_compute_new_words_no_overlap() {
let committed: Vec<String> = vec!["alpha".into(), "beta".into()];
let result = compute_new_words("gamma delta", false, &committed);
assert_eq!(result, vec!["gamma", "delta"]);
}
#[test]
fn test_compute_new_words_filler_removal_produces_empty() {
let result = compute_new_words("um", true, &[]);
let _ = result;
}
#[test]
fn test_compute_new_words_whitespace_only() {
let result = compute_new_words(" ", false, &[]);
assert!(result.is_empty());
}
#[test]
fn test_format_partial_event_empty() {
assert!(format_partial_event(&[]).is_none());
}
#[test]
fn test_format_partial_event_single_word() {
let words: Vec<String> = vec!["hello".into()];
let event = format_partial_event(&words).unwrap();
match event {
StreamingEvent::PartialText {
text,
replace_chars,
} => {
assert_eq!(text, " hello");
assert_eq!(replace_chars, 0);
}
StreamingEvent::SpeechDetected => panic!("unexpected variant"),
}
}
#[test]
fn test_format_partial_event_multiple_words() {
let words: Vec<String> = vec!["hello".into(), "world".into()];
let event = format_partial_event(&words).unwrap();
match event {
StreamingEvent::PartialText {
text,
replace_chars,
} => {
assert_eq!(text, " hello world");
assert_eq!(replace_chars, 0);
}
StreamingEvent::SpeechDetected => panic!("unexpected variant"),
}
}
#[test]
fn test_format_partial_event_leading_space() {
let words: Vec<String> = vec!["x".into()];
let event = format_partial_event(&words).unwrap();
match event {
StreamingEvent::PartialText { text, .. } => {
assert!(text.starts_with(' '));
}
_ => panic!("unexpected variant"),
}
}
#[test]
fn test_emission_pipeline_with_overlap() {
let committed: Vec<String> = vec!["the".into(), "quick".into()];
let new_words = compute_new_words("the quick brown fox", false, &committed);
assert_eq!(new_words, vec!["brown", "fox"]);
let event = format_partial_event(&new_words).unwrap();
match event {
StreamingEvent::PartialText {
text,
replace_chars,
} => {
assert_eq!(text, " brown fox");
assert_eq!(replace_chars, 0);
}
StreamingEvent::SpeechDetected => panic!("unexpected variant"),
}
}
#[test]
fn test_emission_pipeline_no_novelty() {
let committed: Vec<String> = vec!["hello".into(), "world".into()];
let new_words = compute_new_words("hello world", false, &committed);
assert!(new_words.is_empty());
assert!(format_partial_event(&new_words).is_none());
}
}