super-stt-daemon/src/output/typer.rs
Line | Count | Source |
1 | | // SPDX-License-Identifier: GPL-3.0-only |
2 | | |
3 | | //! The preview typer state machine: session/stabilization state plus the |
4 | | //! keyboard-driving update logic. The pure text-diff algorithms it builds on |
5 | | //! live in [`crate::output::preview`]. |
6 | | |
7 | | use crate::output::keyboard::Simulator; |
8 | | use crate::output::preview::{find_common_prefix, find_tail_match_in_text, preprocess_text}; |
9 | | use log::{debug, info, warn}; |
10 | | |
11 | | /// State for tracking preview updates |
12 | | pub struct State { |
13 | | pub last_transcription: String, |
14 | | pub prev_text: String, |
15 | | /// Complete transcription built from all audio (for final output) |
16 | | pub full_session_text: String, |
17 | | /// When we last saw substantial text growth (to commit to full session) |
18 | | pub last_growth_time: std::time::Instant, |
19 | | /// History of transcriptions for stabilization |
20 | | pub text_storage: Vec<String>, |
21 | | /// Text confirmed by appearing in multiple transcriptions |
22 | | pub stabilized_text: String, |
23 | | } |
24 | | |
25 | | impl Default for State { |
26 | 16 | fn default() -> Self { |
27 | 16 | Self { |
28 | 16 | last_transcription: String::new(), |
29 | 16 | prev_text: String::new(), |
30 | 16 | full_session_text: String::new(), |
31 | 16 | last_growth_time: std::time::Instant::now(), |
32 | 16 | text_storage: Vec::new(), |
33 | 16 | stabilized_text: String::new(), |
34 | 16 | } |
35 | 16 | } |
36 | | } |
37 | | |
38 | | impl State { |
39 | | /// Stabilization and session text update (Phase 1). |
40 | | /// |
41 | | /// Keyboard-free: mutates only session/stabilization state, so it is |
42 | | /// independently testable. |
43 | 4 | fn update_with_stabilization(&mut self, new_preview_text: &str) { |
44 | | // Add current text to storage |
45 | 4 | self.text_storage.push(new_preview_text.to_string()); |
46 | | |
47 | | // Keep only recent texts for stabilization (prevent unbounded growth) |
48 | 4 | if self.text_storage.len() > 10 { |
49 | 0 | self.text_storage.remove(0); |
50 | 4 | } |
51 | | |
52 | | // Find common prefix between last two texts |
53 | 4 | if self.text_storage.len() >= 2 { |
54 | 2 | let last_two = &self.text_storage[self.text_storage.len() - 2..]; |
55 | 2 | let common_prefix = find_common_prefix(&last_two[0], &last_two[1]); |
56 | 2 | let prefix_text = last_two[0].chars().take(common_prefix).collect::<String>(); |
57 | | |
58 | | // Only update stabilized text if we found a longer stable prefix |
59 | 2 | if prefix_text.len() > self.stabilized_text.len() { |
60 | 2 | self.stabilized_text = prefix_text; |
61 | 2 | debug!( |
62 | | "Updated stabilized text: '{}'", |
63 | 0 | self.stabilized_text.chars().take(30).collect::<String>() |
64 | | ); |
65 | 0 | } |
66 | 2 | } |
67 | | |
68 | | // Update full session text using stabilized text + tail matching |
69 | 4 | self.update_full_session_text(new_preview_text); |
70 | 4 | } |
71 | | |
72 | | /// Update the full session text using stabilized text as base. |
73 | 10 | fn update_full_session_text(&mut self, new_preview_text: &str) { |
74 | | // If we have stabilized text, use it as our base |
75 | 10 | if !self.stabilized_text.is_empty() |
76 | 2 | && self.stabilized_text.len() > self.full_session_text.len() |
77 | | { |
78 | 0 | self.full_session_text = self.stabilized_text.clone(); |
79 | 0 | self.last_growth_time = std::time::Instant::now(); |
80 | 0 | debug!( |
81 | | "Updated session from stabilized: '{}'", |
82 | 0 | self.full_session_text.chars().take(30).collect::<String>() |
83 | | ); |
84 | 10 | } |
85 | | |
86 | | // Only grow the session text, never shrink it |
87 | 10 | if self.full_session_text.is_empty() { |
88 | 4 | self.full_session_text = new_preview_text.to_string(); |
89 | 4 | self.last_growth_time = std::time::Instant::now(); |
90 | 4 | debug!( |
91 | | "Started session text: '{}'", |
92 | 0 | self.full_session_text.chars().take(30).collect::<String>() |
93 | | ); |
94 | 4 | return; |
95 | 6 | } |
96 | | |
97 | | // Check if preview text extends our session text |
98 | 6 | if new_preview_text.len() > self.full_session_text.len() |
99 | 4 | && new_preview_text.starts_with(&self.full_session_text) |
100 | | { |
101 | | // Perfect extension - just grow |
102 | 2 | self.full_session_text = new_preview_text.to_string(); |
103 | 2 | self.last_growth_time = std::time::Instant::now(); |
104 | 2 | debug!( |
105 | | "Extended session text to: '{}'", |
106 | 0 | self.full_session_text.chars().take(40).collect::<String>() |
107 | | ); |
108 | 2 | return; |
109 | 4 | } |
110 | | |
111 | | // Use tail matching to extend session with new content |
112 | 4 | if let Some(pos2 ) = find_tail_match_in_text(&self.full_session_text, new_preview_text, 3) { |
113 | 2 | let extended = format!("{}{}", self.full_session_text, &new_preview_text[pos..]); |
114 | 2 | if extended.len() > self.full_session_text.len() { |
115 | 2 | self.full_session_text = extended; |
116 | 2 | self.last_growth_time = std::time::Instant::now(); |
117 | 2 | debug!( |
118 | | "Extended session via tail match: '{}'", |
119 | 0 | self.full_session_text.chars().take(40).collect::<String>() |
120 | | ); |
121 | 0 | } |
122 | 2 | } |
123 | 10 | } |
124 | | |
125 | | /// Build the display text (Phase 2) - what actually shows on screen. |
126 | 8 | fn build_display_text(&self, preview_text: &str) -> String { |
127 | | // Use stabilized text as base, but be smart about it |
128 | | |
129 | | // If no stabilized text yet, show the preview |
130 | 8 | if self.stabilized_text.is_empty() { |
131 | 2 | return preview_text.to_string(); |
132 | 6 | } |
133 | | |
134 | | // Try tail matching first |
135 | 6 | if let Some(pos2 ) = find_tail_match_in_text(&self.stabilized_text, preview_text, 3) { |
136 | | // Found overlap - combine stabilized text with new part from preview |
137 | 2 | return format!("{}{}", self.stabilized_text, &preview_text[pos..]); |
138 | 4 | } |
139 | | |
140 | | // No tail match found - be conservative to avoid text loss |
141 | | // Prefer the longer text (session text or preview) to avoid disappearing words |
142 | 4 | let best_text = if self.full_session_text.len() >= preview_text.len() { |
143 | 2 | &self.full_session_text |
144 | | } else { |
145 | 2 | preview_text |
146 | | }; |
147 | | |
148 | 4 | best_text.to_string() |
149 | 8 | } |
150 | | } |
151 | | |
152 | | /// Unified, simplified preview typer that combines the best of both approaches |
153 | | pub struct Typer { |
154 | | keyboard_simulator: Simulator, |
155 | | state: State, |
156 | | } |
157 | | |
158 | | impl Typer { |
159 | | #[must_use] |
160 | 0 | pub fn new(keyboard_simulator: Simulator) -> Self { |
161 | 0 | Self { |
162 | 0 | keyboard_simulator, |
163 | 0 | state: State::default(), |
164 | 0 | } |
165 | 0 | } |
166 | | |
167 | | #[must_use] |
168 | 0 | pub fn write_method_name(&self) -> &'static str { |
169 | 0 | self.keyboard_simulator.name() |
170 | 0 | } |
171 | | |
172 | | /// Extract the simulator so it can be cached for reuse. |
173 | | #[must_use] |
174 | 0 | pub fn take_simulator(self) -> Simulator { |
175 | 0 | self.keyboard_simulator |
176 | 0 | } |
177 | | |
178 | | /// Apply a simple differential update by backspacing to the first differing |
179 | | /// character and retyping the rest. Returns the **net change in screen |
180 | | /// characters** (chars typed minus chars deleted) so callers accounting in |
181 | | /// chars stay consistent — mixing this with a byte length would drift on any |
182 | | /// multibyte text. |
183 | 0 | pub async fn apply_simple_diff(&mut self, old_text: &str, new_text: &str) -> isize { |
184 | | // Safety checks |
185 | 0 | if old_text == new_text { |
186 | 0 | return 0; |
187 | 0 | } |
188 | | |
189 | 0 | if old_text.is_empty() && !new_text.is_empty() { |
190 | 0 | if let Err(e) = self.keyboard_simulator.type_text(new_text).await { |
191 | 0 | debug!("Failed to type new text: {e}"); |
192 | 0 | } |
193 | 0 | return isize::try_from(new_text.chars().count()).unwrap_or(isize::MAX); |
194 | 0 | } |
195 | | |
196 | 0 | if new_text.is_empty() { |
197 | | // Skip |
198 | 0 | return 0; |
199 | 0 | } |
200 | | |
201 | 0 | let old_chars: Vec<char> = old_text.chars().collect(); |
202 | 0 | let new_chars: Vec<char> = new_text.chars().collect(); |
203 | | |
204 | | // Find first different character position |
205 | 0 | let common_prefix = find_common_prefix(old_text, new_text); |
206 | | |
207 | | // Calculate what to delete and what to type |
208 | 0 | let chars_to_delete = old_chars.len() - common_prefix; |
209 | 0 | let text_to_type: String = new_chars[common_prefix..].iter().collect(); |
210 | 0 | let chars_to_type = new_chars.len() - common_prefix; |
211 | | |
212 | 0 | debug!( |
213 | | "Simple diff: prefix={}, delete={}, type='{}'", |
214 | | common_prefix, |
215 | | chars_to_delete, |
216 | 0 | text_to_type.chars().take(20).collect::<String>() |
217 | | ); |
218 | | |
219 | | // Backspace to the first different position |
220 | 0 | let _ = self.keyboard_simulator.backspace_n(chars_to_delete).await; |
221 | | |
222 | | // Type the new part |
223 | 0 | let _ = self.keyboard_simulator.type_text(&text_to_type).await; |
224 | | |
225 | | // Net screen delta in chars: what we added minus what we removed. |
226 | 0 | isize::try_from(chars_to_type).unwrap_or(isize::MAX) |
227 | 0 | - isize::try_from(chars_to_delete).unwrap_or(isize::MAX) |
228 | 0 | } |
229 | | |
230 | | /// Update preview text using two-phase approach |
231 | 0 | pub async fn update_preview(&mut self, new_text: &str, actually_typed: &mut String) { |
232 | 0 | let processed_text = preprocess_text(new_text, true); |
233 | | |
234 | 0 | info!( |
235 | | "Preview update: new='{}', prev='{}', typed='{}'", |
236 | 0 | processed_text.chars().take(30).collect::<String>(), |
237 | 0 | self.state.prev_text.chars().take(30).collect::<String>(), |
238 | 0 | actually_typed.chars().take(30).collect::<String>() |
239 | | ); |
240 | | |
241 | | // Skip if text hasn't changed |
242 | 0 | if processed_text == self.state.prev_text { |
243 | 0 | debug!("Text unchanged, skipping"); |
244 | 0 | return; |
245 | 0 | } |
246 | | |
247 | | // Skip empty text |
248 | 0 | if processed_text.is_empty() { |
249 | 0 | debug!("Empty text, skipping"); |
250 | 0 | return; |
251 | 0 | } |
252 | | |
253 | | // PHASE 1: Stabilization and session text update |
254 | 0 | self.state.update_with_stabilization(&processed_text); |
255 | | |
256 | | // PHASE 2: Decide what to show on screen |
257 | 0 | let display_text = self.state.build_display_text(&processed_text); |
258 | | |
259 | 0 | info!( |
260 | | "Display logic: display='{}', session='{}', stabilized='{}'", |
261 | 0 | display_text.chars().take(30).collect::<String>(), |
262 | 0 | self.state |
263 | 0 | .full_session_text |
264 | 0 | .chars() |
265 | 0 | .take(30) |
266 | 0 | .collect::<String>(), |
267 | 0 | self.state |
268 | 0 | .stabilized_text |
269 | 0 | .chars() |
270 | 0 | .take(30) |
271 | 0 | .collect::<String>() |
272 | | ); |
273 | | |
274 | | // Apply the update to screen |
275 | 0 | self.apply_text_update(&display_text, actually_typed).await; |
276 | 0 | self.state.prev_text = processed_text; |
277 | 0 | } |
278 | | |
279 | | /// Process final text (completed sentence) - Uses full session audio |
280 | 0 | pub async fn process_final_text(&mut self, transcription_result: &str) { |
281 | | // No preview typing, type directly |
282 | 0 | let processed_text = preprocess_text(transcription_result, false); |
283 | 0 | let final_text = format!("{processed_text} "); |
284 | 0 | if let Err(e) = self.keyboard_simulator.type_text(&final_text).await { |
285 | 0 | warn!("Failed to type final transcription: {e}"); |
286 | | } else { |
287 | 0 | info!("Step 6 complete: Final transcription typed directly"); |
288 | | } |
289 | | |
290 | | // Reset state for next sentence - but keep the full session text for user reference |
291 | 0 | self.state.prev_text.clear(); |
292 | 0 | self.state.last_transcription = processed_text; |
293 | 0 | self.state.last_growth_time = std::time::Instant::now(); |
294 | | |
295 | 0 | info!( |
296 | | "Completed sentence. Session text: '{}'", |
297 | 0 | self.state |
298 | 0 | .full_session_text |
299 | 0 | .chars() |
300 | 0 | .take(50) |
301 | 0 | .collect::<String>() |
302 | | ); |
303 | | |
304 | | // Clear session for next recording |
305 | 0 | self.state.full_session_text.clear(); |
306 | 0 | } |
307 | | |
308 | | /// Apply text update to screen (common logic) |
309 | 0 | async fn apply_text_update(&mut self, new_text: &str, actually_typed: &mut String) { |
310 | 0 | info!( |
311 | | "Typing logic: old_typed='{}', new_display='{}'", |
312 | 0 | actually_typed.chars().take(30).collect::<String>(), |
313 | 0 | new_text.chars().take(30).collect::<String>(), |
314 | | ); |
315 | | |
316 | 0 | if actually_typed.is_empty() { |
317 | | // Screen is empty — type the whole thing. |
318 | 0 | info!( |
319 | | "Screen empty, typing new text: '{}'", |
320 | 0 | new_text.chars().take(30).collect::<String>() |
321 | | ); |
322 | 0 | let _ = self |
323 | 0 | .keyboard_simulator |
324 | 0 | .type_text(&format!("{new_text} ")) |
325 | 0 | .await; |
326 | 0 | } else if new_text.starts_with(actually_typed.as_str()) |
327 | 0 | && new_text.len() > actually_typed.len() |
328 | | { |
329 | | // Perfect extension — append only the new suffix. |
330 | 0 | let suffix = &new_text[actually_typed.len()..]; |
331 | 0 | info!("Perfect extension, adding suffix: '{suffix}'"); |
332 | 0 | let _ = self |
333 | 0 | .keyboard_simulator |
334 | 0 | .type_text(&format!("{suffix} ")) |
335 | 0 | .await; |
336 | | } else { |
337 | | // Replacement — backspace to the first difference and retype. |
338 | 0 | let net_change = self.apply_simple_diff(actually_typed, new_text).await; |
339 | 0 | info!("Diff replacement: net {net_change} char(s)"); |
340 | | } |
341 | | |
342 | | // `actually_typed` mirrors what we drove onto the screen so |
343 | | // `clear_preview` backspaces the right count next time. Every branch |
344 | | // above leaves the screen showing `new_text`. The keyboard results are |
345 | | // best-effort and unchecked, so there is no measured count to reconcile |
346 | | // against — the old byte-vs-char reconciliation was both wrong (it added |
347 | | // `apply_simple_diff`'s byte length to a char count) and dead (both of |
348 | | // its branches did exactly this assignment). |
349 | 0 | actually_typed.clear(); |
350 | 0 | actually_typed.push_str(new_text); |
351 | 0 | } |
352 | | |
353 | | /// Clear all typed text and reset state |
354 | 0 | pub async fn clear_preview(&mut self, actually_typed: &mut String) { |
355 | 0 | info!("clear_preview called with actually_typed: '{actually_typed}'"); |
356 | | |
357 | 0 | if actually_typed.is_empty() { |
358 | 0 | info!("actually_typed is empty, nothing to clear"); |
359 | 0 | return; |
360 | 0 | } |
361 | | |
362 | 0 | let chars_to_delete = actually_typed.chars().count(); |
363 | 0 | info!("Backspacing {chars_to_delete} characters"); |
364 | | |
365 | 0 | if let Err(e) = self.keyboard_simulator.backspace_n(chars_to_delete).await { |
366 | 0 | warn!("Failed to backspace preview text: {e}"); |
367 | | } else { |
368 | 0 | info!("Successfully backspaced {chars_to_delete} characters"); |
369 | | } |
370 | | |
371 | 0 | actually_typed.clear(); |
372 | | |
373 | | // Also clear state when explicitly clearing preview |
374 | 0 | self.state.prev_text.clear(); |
375 | 0 | self.state.last_transcription.clear(); |
376 | 0 | self.state.full_session_text.clear(); |
377 | 0 | self.state.last_growth_time = std::time::Instant::now(); |
378 | | |
379 | 0 | info!("Cleared all {chars_to_delete} characters and reset state"); |
380 | 0 | } |
381 | | } |
382 | | |
383 | | #[cfg(test)] |
384 | | #[path = "typer_tests.rs"] |
385 | | mod tests; |