diff --git a/Cargo.lock b/Cargo.lock index ca991ee0..1bd6149e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -706,6 +706,7 @@ dependencies = [ "async-trait", "coldvox-foundation", "dirs", + "dtw", "parking_lot", "serde", "thiserror 2.0.17", @@ -1460,6 +1461,15 @@ version = "1.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "75b325c5dbd37f80359721ad39aca5a29fb04c89279657cffdda8736d0c0b9d2" +[[package]] +name = "dtw" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7774a50f5cde1abf2457ffcc73512a9171701586912609e42367cdd057bb14ba" +dependencies = [ + "num-traits", +] + [[package]] name = "either" version = "1.15.0" diff --git a/crates/app/src/runtime.rs b/crates/app/src/runtime.rs index 3af5adc1..f85de539 100644 --- a/crates/app/src/runtime.rs +++ b/crates/app/src/runtime.rs @@ -512,7 +512,7 @@ pub async fn start( // Text injection channel #[cfg(feature = "text-injection")] - let (text_injection_tx, text_injection_rx) = mpsc::channel::(100); + let (_text_injection_tx, text_injection_rx) = mpsc::channel::(100); #[cfg(not(feature = "text-injection"))] let (_text_injection_tx, _text_injection_rx) = mpsc::channel::(100); @@ -521,10 +521,10 @@ pub async fn start( let mut stt_forward_handle: Option> = None; #[allow(unused_variables)] let (stt_handle, vad_fanout_handle) = if let Some(pm) = plugin_manager.clone() { - // This is the single, unified path for STT processing. - #[cfg(feature = "whisper")] - let (session_tx, session_rx) = mpsc::channel::(100); - let stt_audio_rx = audio_tx.subscribe(); + // This is the single, unified path for STT processing. + #[cfg(feature = "whisper")] + let (session_tx, session_rx) = mpsc::channel::(100); + let stt_audio_rx = audio_tx.subscribe(); #[cfg(feature = "whisper")] let (stt_pipeline_tx, stt_pipeline_rx) = mpsc::channel::(100); @@ -550,8 +550,8 @@ pub async fn start( Settings::default(), // Use default settings for now ); - let vad_bcast_tx_clone = vad_bcast_tx.clone(); - let activation_mode = opts.activation_mode; + let vad_bcast_tx_clone = vad_bcast_tx.clone(); + let activation_mode = opts.activation_mode; // This task is the new "translator" from VAD/Hotkey events to generic SessionEvents. let vad_fanout_handle = tokio::spawn(async move { @@ -770,11 +770,9 @@ pub async fn start( #[cfg(test)] mod tests { use super::*; - - + use coldvox_stt::plugin::{FailoverConfig, GcPolicy, PluginSelectionConfig}; use coldvox_stt::TranscriptionEvent; - /// Helper to create default runtime options for testing. fn test_opts(activation_mode: ActivationMode) -> AppRuntimeOptions { diff --git a/crates/app/tests/golden_master.rs b/crates/app/tests/golden_master.rs index e402313c..ab3551d9 100644 --- a/crates/app/tests/golden_master.rs +++ b/crates/app/tests/golden_master.rs @@ -117,20 +117,36 @@ pub mod harness { (Value::Object(ao), Value::Object(bo)) => { let kind_a = ao.get("kind").and_then(|v| v.as_str()).unwrap_or(""); let kind_b = bo.get("kind").and_then(|v| v.as_str()).unwrap_or(""); - if kind_a != kind_b { all_ok = false; break; } + if kind_a != kind_b { + all_ok = false; + break; + } if kind_a == "SpeechEnd" { - let da = ao.get("duration_ms").and_then(|v| v.as_u64()).unwrap_or(0); - let db = bo.get("duration_ms").and_then(|v| v.as_u64()).unwrap_or(0); + let da = + ao.get("duration_ms").and_then(|v| v.as_u64()).unwrap_or(0); + let db = + bo.get("duration_ms").and_then(|v| v.as_u64()).unwrap_or(0); let diff = da.abs_diff(db); - if diff > 128 { all_ok = false; break; } + if diff > 128 { + all_ok = false; + break; + } } else if kind_a == "SpeechStart" { // SpeechStart has no duration, ignore } else { // Unknown kind fallback to strict equality - if av != bv { all_ok = false; break; } + if av != bv { + all_ok = false; + break; + } + } + } + _ => { + if av != bv { + all_ok = false; + break; } } - _ => { if av != bv { all_ok = false; break; } } } } all_ok diff --git a/crates/coldvox-stt/Cargo.toml b/crates/coldvox-stt/Cargo.toml index 8fde54a6..daa98d7e 100644 --- a/crates/coldvox-stt/Cargo.toml +++ b/crates/coldvox-stt/Cargo.toml @@ -13,6 +13,7 @@ thiserror = "2.0" dirs = "5.0" serde = { version = "1.0", features = ["derive"] } coldvox-foundation = { path = "../coldvox-foundation" } +dtw = "0.1.0" ## Removed Python-dependent faster-whisper backend; will replace with pure Rust implementation diff --git a/crates/coldvox-stt/src/candle/audio.rs b/crates/coldvox-stt/src/candle/audio.rs new file mode 100644 index 00000000..a124cf80 --- /dev/null +++ b/crates/coldvox-stt/src/candle/audio.rs @@ -0,0 +1,87 @@ +use candle::{Result, Tensor, Device}; + +const N_FFT: usize = 400; +const N_MELS: usize = 80; +const HOP_LENGTH: usize = 160; +const CHUNK_LENGTH: usize = 30; +const SAMPLING_RATE: usize = 16000; + +pub fn log_mel_spectrogram(pcm: &[f32], device: &Device) -> Result { + let pcm_len = pcm.len(); + let n_samples = CHUNK_LENGTH * SAMPLING_RATE; + let pcm = if pcm_len < n_samples { + let mut padded = vec![0.0; n_samples]; + padded[..pcm_len].copy_from_slice(pcm); + padded + } else { + pcm.to_vec() + }; + + let stft = stft(&pcm, N_FFT, HOP_LENGTH)?; + let magnitudes = stft.abs()?.powf(2.0)?; + let mel_filters = mel_filters(device, N_MELS, N_FFT)?; + let mel_spec = magnitudes.matmul(&mel_filters.t()?)?; + let log_spec = (mel_spec.max(1e-10)?).log10()?; + let log_spec = (log_spec.max(log_spec.max_all()?.to_scalar::()? - 8.0)?)?; + let log_spec = (log_spec + 4.0)? / 4.0?; + Ok(log_spec) +} + +fn stft(pcm: &[f32], n_fft: usize, hop_length: usize) -> Result { + let window = hamming_window(n_fft, &Device::Cpu)?; + let n_frames = (pcm.len() - n_fft) / hop_length + 1; + let mut frames = Vec::with_capacity(n_frames); + for i in 0..n_frames { + let start = i * hop_length; + let end = start + n_fft; + frames.extend_from_slice(&pcm[start..end]); + } + let frames = Tensor::new(frames, &Device::Cpu)?.reshape((n_frames, n_fft))?; + let frames = frames.broadcast_mul(&window)?; + let stft = frames.fft(n_fft)?; + Ok(stft.i((.., ..n_fft / 2 + 1))?) +} + +fn hamming_window(n: usize, device: &Device) -> Result { + let ts = Tensor::arange(0, n as u32, device)?.to_dtype(candle::DType::F32)?; + let cos = (ts * (2.0 * std::f64::consts::PI / (n - 1) as f64))?.cos()?; + (0.54 - 0.46 * cos)? +} + +fn hz_to_mel(hz: f64) -> f64 { + 2595.0 * (1.0 + hz / 700.0).log10() +} + +fn mel_to_hz(mel: f64) -> f64 { + 700.0 * (10.0f64.powf(mel / 2595.0) - 1.0) +} + +fn mel_filters(device: &Device, n_mels: usize, n_fft: usize) -> Result { + let f_min = 0.0; + let f_max = SAMPLING_RATE as f64 / 2.0; + let mel_min = hz_to_mel(f_min); + let mel_max = hz_to_mel(f_max); + let mel_points = (0..=n_mels + 1) + .map(|i| mel_min + (mel_max - mel_min) * i as f64 / (n_mels + 1) as f64) + .collect::>(); + let fft_freqs = (0..=n_fft / 2) + .map(|i| i as f64 * SAMPLING_RATE as f64 / n_fft as f64) + .collect::>(); + let mel_edges = mel_points.windows(3).map(|w| (w[0], w[1], w[2])).collect::>(); + + let mut filters = vec![0.0; n_mels * (n_fft / 2 + 1)]; + for (i, (mel_start, mel_center, mel_end)) in mel_edges.iter().enumerate() { + for (j, &freq) in fft_freqs.iter().enumerate() { + let mel_freq = hz_to_mel(freq); + let slope = if mel_freq >= *mel_start && mel_freq <= *mel_center { + (mel_freq - mel_start) / (mel_center - mel_start) + } else if mel_freq >= *mel_center && mel_freq <= *mel_end { + (mel_end - mel_freq) / (mel_end - mel_center) + } else { + 0.0 + }; + filters[i * (n_fft / 2 + 1) + j] = slope as f32; + } + } + Tensor::new(filters, device)?.reshape((n_mels, n_fft / 2 + 1)) +} diff --git a/crates/coldvox-stt/src/candle/decode.rs b/crates/coldvox-stt/src/candle/decode.rs new file mode 100644 index 00000000..1acceaf7 --- /dev/null +++ b/crates/coldvox-stt/src/candle/decode.rs @@ -0,0 +1,72 @@ +use candle::{Result, Tensor, D}; +use candle_nn::ops::softmax_last_dim; +use crate::candle::timestamps::{perform_word_alignment, perform_timestamp_probs_alignment}; +use candle_transformers::models::whisper::{self as whisper, Config, Whisper}; +use crate::candle::timestamps::TranscriptionResult; +use crate::candle::WordTimestampHeuristic; + +pub struct Decoder { + model: Whisper, + tokenizer: whisper::tokenizer::Tokenizer, + heuristic: WordTimestampHeuristic, +} + +impl Decoder { + pub fn new(model: Whisper, tokenizer: whisper::tokenizer::Tokenizer, heuristic: &WordTimestampHeuristic) -> Self { + Self { model, tokenizer, heuristic: heuristic.clone() } + } + + pub fn run(&mut self, mel: &Tensor) -> Result> { + let mut audio_features = self.model.encoder.forward(mel, true)?; + let mut tokens = vec![self.tokenizer.sot_token() as i32]; + let mut words = vec![]; + + for _ in 0..self.model.config.max_target_positions { + let tokens_tensor = Tensor::new(tokens.as_slice(), mel.device())?.unsqueeze(0)?; + let (logits, cross_attentions) = self.model.decoder.forward(&tokens_tensor, &audio_features, false)?; + + let next_token = self.argmax(&logits)?; + + tokens.push(next_token); + + if self.is_segment_end(next_token) { + let segment_tokens = &tokens; + let segment_words = match self.heuristic { + WordTimestampHeuristic::AttentionDtw => { + if let Some(cross_attentions) = cross_attentions { + perform_word_alignment( + segment_tokens, + &cross_attentions, + &self.tokenizer, + true, // Assuming space-based splitting + )? + } else { + vec![] + } + } + WordTimestampHeuristic::TimestampProbs => { + perform_timestamp_probs_alignment(segment_tokens, &logits, &self.tokenizer)? + } + }; + words.extend(segment_words); + + if next_token == self.tokenizer.eot_token() as i32 { + break; + } + tokens = vec![self.tokenizer.sot_token() as i32]; + } + } + + Ok(words) + } + + fn argmax(&self, logits: &Tensor) -> Result { + let logits = logits.i((0, logits.dim(D::Minus1)? - 1, ..))?; + let next_token = logits.argmax(D::Minus1)?.to_scalar::()? as i32; + Ok(next_token) + } + + fn is_segment_end(&self, token: i32) -> bool { + token >= self.tokenizer.timestamp_begin() as i32 || token == self.tokenizer.eot_token() as i32 + } +} diff --git a/crates/coldvox-stt/src/candle/loader.rs b/crates/coldvox-stt/src/candle/loader.rs new file mode 100644 index 00000000..1447c000 --- /dev/null +++ b/crates/coldvox-stt/src/candle/loader.rs @@ -0,0 +1,22 @@ +use candle::{Device, Result, safetensors}; +use candle_transformers::models::whisper::{self as whisper, Config, Whisper}; +use hf_hub::api::sync::Api; +use hf_hub::{Repo, RepoType}; +use std::fs::File; +use std::path::Path; + +pub fn load_model( + model_path: &str, + tokenizer_path: &str, + config_path: &str, + quantized: bool, +) -> Result<(Whisper, whisper::tokenizer::Tokenizer)> { + let device = Device::Cpu; + + let config: Config = serde_json::from_reader(File::open(config_path).map_err(|e| candle::Error::Msg(e.to_string()))?).map_err(|e| candle::Error::Msg(e.to_string()))?; + let tokenizer = whisper::tokenizer::Tokenizer::from_file(tokenizer_path).map_err(|e| candle::Error::Msg(e.to_string()))?; + + let mut vb = candle_nn::VarBuilder::from_safetensors(vec![model_path.to_string()], candle::DType::F32, &device)?; + let model = Whisper::load(&vb, config)?; + Ok((model, tokenizer)) +} diff --git a/crates/coldvox-stt/src/candle/mod.rs b/crates/coldvox-stt/src/candle/mod.rs new file mode 100644 index 00000000..d8362ef3 --- /dev/null +++ b/crates/coldvox-stt/src/candle/mod.rs @@ -0,0 +1,48 @@ +pub mod audio; +pub mod decode; +pub mod loader; +pub mod timestamps; + +use candle::{Device, Result, Tensor}; +use crate::candle::audio::log_mel_spectrogram; +use crate::candle::decode::Decoder; +use crate::candle::loader::load_model; +use candle_transformers::models::whisper::{self as whisper, Config, Whisper}; +use timestamps::TranscriptionResult; + +pub enum WordTimestampHeuristic { + AttentionDtw, + TimestampProbs, +} + +pub struct WhisperEngine { + decoder: Decoder, + config: WhisperEngineConfig, +} + +pub struct WhisperEngineConfig { + pub model_path: String, + pub tokenizer_path: String, + pub config_path: String, + pub quantized: bool, + pub enable_timestamps: bool, + pub heuristic: WordTimestampHeuristic, +} + +impl WhisperEngine { + pub fn new(config: WhisperEngineConfig) -> Result { + let (model, tokenizer) = load_model(&config.model_path, &config.tokenizer_path, &config.config_path, config.quantized)?; + let decoder = Decoder::new(model, tokenizer, &config.heuristic); + Ok(Self { decoder, config }) + } + + pub fn transcribe(&mut self, pcm_audio: &[f32]) -> Result> { + let mel = self.preprocess_audio(pcm_audio)?; + let words = self.decoder.run(&mel)?; + Ok(words) + } + + fn preprocess_audio(&self, pcm_audio: &[f32]) -> Result { + log_mel_spectrogram(pcm_audio, &Device::Cpu) + } +} diff --git a/crates/coldvox-stt/src/candle/timestamps.rs b/crates/coldvox-stt/src/candle/timestamps.rs new file mode 100644 index 00000000..1a16ef0a --- /dev/null +++ b/crates/coldvox-stt/src/candle/timestamps.rs @@ -0,0 +1,193 @@ +use candle::{Result, Tensor, D}; +use candle_nn::ops::softmax_last_dim; +use dtw::Dtw; +use candle_transformers::models::whisper::tokenizer::Tokenizer; + +#[derive(Debug, Clone)] +pub struct TranscriptionResult { + pub start: f64, + pub end: f64, + pub text: String, +} + +const AUDIO_TIME_PER_TOKEN: f64 = 0.02; + +/// Performs word alignment on the given tokens and attention weights. +pub fn perform_word_alignment( + tokens: &[i32], + attention_weights: &[Tensor], + tokenizer: &Tokenizer, + use_space: bool, +) -> Result> { + let (words, word_tokens_indices) = if use_space { + split_tokens_on_spaces(tokens, tokenizer) + } else { + let (words, _, indices) = split_tokens_on_unicode(tokens, tokenizer); + (words, indices) + }; + + let weights = Tensor::cat(attention_weights, 0)?; + let num_tokens = weights.dims()[2]; + let num_frames = weights.dims()[3]; + + let mut weights = weights.mean(0)?.mean(0)?; // Average over layers and heads + weights = weights.to_dtype(candle::DType::F64)?; + + let weights_data = weights.flatten_all()?.to_vec1::()?; + + let mut dtw = Dtw::new(&weights_data, num_frames as usize, num_tokens as usize); + let alignment = dtw.run(); + + let mut jumps = vec![]; + if !alignment.path.is_empty() { + let mut last_token = alignment.path[0].0; + jumps.push(alignment.path[0].1); + for &(token, frame) in &alignment.path { + if token != last_token { + jumps.push(frame); + last_token = token; + } + } + jumps.push(alignment.path.last().unwrap().1); + } + + let mut word_boundaries = vec![0]; + word_boundaries.extend(word_tokens_indices.iter().scan(0, |acc, tokens| { + *acc += tokens.len(); + Some(*acc) + })); + + let begin_times = word_boundaries + .iter() + .map(|&boundary| jumps.get(boundary).cloned().unwrap_or(0)) + .collect::>(); + let end_times = word_boundaries + .iter() + .skip(1) + .map(|&boundary| jumps.get(boundary).cloned().unwrap_or(0)) + .collect::>(); + + let mut results = Vec::new(); + for i in 0..words.len() { + if words[i].starts_with("<|") { + continue; + } + results.push(TranscriptionResult { + text: words[i].clone(), + start: round_timestamp(begin_times[i] as f64 * AUDIO_TIME_PER_TOKEN), + end: round_timestamp(end_times[i] as f64 * AUDIO_TIME_PER_TOKEN), + }); + } + + Ok(results) +} + +pub fn perform_timestamp_probs_alignment( + tokens: &[i32], + logits: &Tensor, + tokenizer: &Tokenizer, +) -> Result> { + let mut words = vec![]; + let mut current_word = String::new(); + let mut start_time = 0.0; + + let timestamp_begin = tokenizer.timestamp_begin() as usize; + for (i, &token) in tokens.iter().enumerate() { + let text = tokenizer.decode(&[token as u32], true).unwrap_or_default(); + if text.starts_with("<|") && text.ends_with("|>") { + if !current_word.is_empty() { + let end_time = get_timestamp_from_logits(logits, i, timestamp_begin)?; + words.push(TranscriptionResult { + start: start_time, + end: end_time, + text: current_word, + }); + } + current_word = String::new(); + start_time = get_timestamp_from_logits(logits, i, timestamp_begin)?; + } else { + current_word.push_str(&text); + } + } + + if !current_word.is_empty() { + let end_time = get_timestamp_from_logits(logits, tokens.len() - 1, timestamp_begin)?; + words.push(TranscriptionResult { + start: start_time, + end: end_time, + text: current_word, + }); + } + + Ok(words) +} + +fn get_timestamp_from_logits(logits: &Tensor, index: usize, timestamp_begin: usize) -> Result { + let logits = logits.i((0, index, ..))?; + let probs = softmax_last_dim(&logits)?; + let probs_data: Vec = probs.to_vec1()?; + + let mut max_prob = 0.0; + let mut max_index = 0; + for (i, &prob) in probs_data.iter().enumerate() { + if i >= timestamp_begin && prob > max_prob { + max_prob = prob; + max_index = i; + } + } + + Ok((max_index - timestamp_begin) as f64 * AUDIO_TIME_PER_TOKEN) +} + + +fn split_tokens_on_spaces( + tokens: &[i32], + tokenizer: &Tokenizer, +) -> (Vec, Vec>) { + let (subwords, _, subword_tokens_indices_list) = + split_tokens_on_unicode(tokens, tokenizer); + let mut words = vec![]; + let mut word_indices = vec![]; + + for (subword, indices) in subwords.into_iter().zip(subword_tokens_indices_list.into_iter()) { + if subword.starts_with(' ') { + words.push(subword.trim_start().to_string()); + word_indices.push(indices); + } else if let Some(last_word) = words.last_mut() { + *last_word += &subword; + word_indices.last_mut().unwrap().extend(indices); + } else { + words.push(subword); + word_indices.push(indices); + } + } + (words, word_indices) +} + +fn split_tokens_on_unicode( + tokens: &[i32], + tokenizer: &Tokenizer, +) -> (Vec, Vec>, Vec>) { + let mut words = vec![]; + let mut word_tokens = vec![]; + let mut word_tokens_indices = vec![]; + let mut current_tokens = vec![]; + + for &token in tokens { + current_tokens.push(token); + let u32_tokens: Vec = current_tokens.iter().map(|&t| t as u32).collect(); + if let Ok(decoded) = tokenizer.decode(&u32_tokens, true) { + if !decoded.contains('�') { + words.push(decoded.clone()); + word_tokens.push(vec![decoded]); + word_tokens_indices.push(current_tokens.clone()); + current_tokens.clear(); + } + } + } + (words, word_tokens, word_tokens_indices) +} + +fn round_timestamp(x: f64) -> f64 { + (x * 100.0).round() / 100.0 +} diff --git a/crates/coldvox-text-injection/src/confirm.rs b/crates/coldvox-text-injection/src/confirm.rs index cacff8a6..8340f323 100644 --- a/crates/coldvox-text-injection/src/confirm.rs +++ b/crates/coldvox-text-injection/src/confirm.rs @@ -54,11 +54,11 @@ //! - Future: Could extend to clipboard/enigo fallbacks for cross-method validation use crate::types::{InjectionConfig, InjectionResult}; +use coldvox_foundation::error::InjectionError; use std::sync::Arc; use std::time::{Duration, Instant}; use tokio::sync::Mutex; -use tracing::{info, warn, debug, trace, error}; -use coldvox_foundation::error::InjectionError; +use tracing::{debug, error, info, trace, warn}; use unicode_segmentation::UnicodeSegmentation; /// Confirmation result for text injection @@ -226,16 +226,12 @@ pub async fn text_changed( .map_err(|e| InjectionError::Other(format!("TextProxy path failed: {e}")))? .build(); - if let Ok(text_proxy) = time::timeout(Duration::from_millis(25), text_fut).await { - if let Ok(text_proxy) = text_proxy { - let get_text_fut = text_proxy.get_text(0, -1); - if let Ok(current_text) = - time::timeout(Duration::from_millis(25), get_text_fut).await - { - if let Ok(current_text) = current_text { - last_text = current_text; - } - } + if let Ok(Ok(text_proxy)) = time::timeout(Duration::from_millis(25), text_fut).await { + let get_text_fut = text_proxy.get_text(0, -1); + if let Ok(Ok(current_text)) = + time::timeout(Duration::from_millis(25), get_text_fut).await + { + last_text = current_text; } } } diff --git a/crates/coldvox-text-injection/src/injectors/atspi.rs b/crates/coldvox-text-injection/src/injectors/atspi.rs index cf85d11b..c371a5a0 100644 --- a/crates/coldvox-text-injection/src/injectors/atspi.rs +++ b/crates/coldvox-text-injection/src/injectors/atspi.rs @@ -5,6 +5,8 @@ //! while providing the new TextInjector trait interface. use crate::confirm::{create_confirmation_context, ConfirmationContext}; +use crate::log_throttle::log_atspi_connection_failure; +use crate::logging::utils; use crate::types::{ InjectionConfig, InjectionContext, InjectionMethod, InjectionMode, InjectionResult, }; @@ -13,8 +15,6 @@ use async_trait::async_trait; use coldvox_foundation::error::InjectionError; use std::time::Instant; use tracing::{debug, trace, warn}; -use crate::log_throttle::log_atspi_connection_failure; -use crate::logging::utils; // Re-export the old Context type for backwards compatibility #[deprecated( diff --git a/crates/coldvox-text-injection/src/prewarm.rs b/crates/coldvox-text-injection/src/prewarm.rs index 8065bbb8..7c635526 100644 --- a/crates/coldvox-text-injection/src/prewarm.rs +++ b/crates/coldvox-text-injection/src/prewarm.rs @@ -9,7 +9,7 @@ use crate::types::{InjectionConfig, InjectionMethod, InjectionResult}; use std::sync::Arc; use std::time::{Duration, Instant}; use tokio::sync::{Mutex, RwLock}; -use tracing::{debug, info, warn, trace}; +use tracing::{debug, info, trace, warn}; /// TTL for cached pre-warmed data (3 seconds) const CACHE_TTL: Duration = Duration::from_secs(3); @@ -161,7 +161,7 @@ impl PrewarmController { /// Pre-warm AT-SPI connection and snapshot focused element async fn prewarm_atspi(&self) -> Result { - let start_time = Instant::now(); + let start_time = Instant::now(); debug!("Starting AT-SPI pre-warming"); #[cfg(feature = "atspi")] @@ -246,7 +246,7 @@ impl PrewarmController { /// Arm the event listener for text change confirmation async fn arm_event_listener(&self) -> Result { - let start_time = Instant::now(); + let start_time = Instant::now(); debug!("Arming event listener for text change confirmation"); #[cfg(feature = "atspi")] diff --git a/crates/coldvox-text-injection/src/tests/wl_copy_stdin_test.rs b/crates/coldvox-text-injection/src/tests/wl_copy_stdin_test.rs index 8e67d069..c95ff706 100644 --- a/crates/coldvox-text-injection/src/tests/wl_copy_stdin_test.rs +++ b/crates/coldvox-text-injection/src/tests/wl_copy_stdin_test.rs @@ -11,9 +11,7 @@ use crate::types::{InjectionConfig, InjectionContext}; use std::process::Command; use std::time::Duration; -use super::test_utils::{ - command_exists, is_wayland_environment, read_clipboard_with_wl_paste, -}; +use super::test_utils::{command_exists, is_wayland_environment, read_clipboard_with_wl_paste}; /// Test that wl-copy properly receives content via stdin /// This is the core test for the stdin piping fix @@ -152,7 +150,7 @@ async fn test_wl_copy_timeout_handling() { // Create config with very short timeout to force timeout let config = InjectionConfig { - per_method_timeout_ms: 10, // Very short timeout + per_method_timeout_ms: 10, // Very short timeout paste_action_timeout_ms: 10, // Very short timeout ..Default::default() }; diff --git a/crates/coldvox-text-injection/src/ydotool_injector.rs b/crates/coldvox-text-injection/src/ydotool_injector.rs index 8b4204c0..0806bf07 100644 --- a/crates/coldvox-text-injection/src/ydotool_injector.rs +++ b/crates/coldvox-text-injection/src/ydotool_injector.rs @@ -53,12 +53,9 @@ fn candidate_socket_paths() -> Vec { } fn locate_existing_socket() -> Option { - for candidate in candidate_socket_paths() { - if Path::new(&candidate).exists() { - return Some(candidate); - } - } - None + candidate_socket_paths() + .into_iter() + .find(|candidate| Path::new(&candidate).exists()) } fn preferred_socket_path() -> Option {