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: the running transcript of the current |
4 | | //! recording plus the keyboard-driving update logic. The pure text algorithms |
5 | | //! it builds on — normalization, the screen diff, window stitching — live in |
6 | | //! [`crate::output::preview`]. |
7 | | |
8 | | use crate::output::keyboard::Simulator; |
9 | | use crate::output::preview::{ |
10 | | capitalize_first, find_common_prefix, merge_window_preview, normalize_text, preprocess_text, |
11 | | sanitize_for_typing, |
12 | | }; |
13 | | use log::{debug, info, warn}; |
14 | | use super_stt_shared::models::protocol::PreviewSource; |
15 | | |
16 | | /// The running transcript of the current recording. |
17 | | #[derive(Default)] |
18 | | pub struct State { |
19 | | /// The last normalized preview, so a repeat is not retyped. |
20 | | pub prev_text: String, |
21 | | /// Everything recognized so far this recording: a stream's own running |
22 | | /// transcript, or the daemon's sliding windows stitched together. |
23 | | pub full_session_text: String, |
24 | | } |
25 | | |
26 | | impl State { |
27 | | /// Fold one preview into the running transcript. |
28 | | /// |
29 | | /// A stream frame is the transcript so far and simply replaces it. A window |
30 | | /// frame is the last few seconds only, so it is stitched onto the |
31 | | /// transcript by the audio the two windows share. |
32 | | /// |
33 | | /// The old two-phase scheme — lock a "stabilized" prefix from consecutive |
34 | | /// previews, then graft each preview onto it by its last three characters — |
35 | | /// was built for previews that all start at the same audio. Sliding windows |
36 | | /// do not, so the prefix froze after the first seconds, and the three-char |
37 | | /// graft found the *rightmost* recurrence of the prefix's tail and dropped |
38 | | /// whatever lay between ("the cat sat on the mat and the dog" became |
39 | | /// "the cat sat on the dog"). |
40 | 25 | fn absorb(&mut self, preview: &str, source: PreviewSource) { |
41 | 25 | self.full_session_text = match source { |
42 | 4 | PreviewSource::Stream => preview.to_string(), |
43 | 21 | PreviewSource::Window => merge_window_preview(&self.full_session_text, preview), |
44 | | }; |
45 | 25 | } |
46 | | } |
47 | | |
48 | | /// How long [`Typer::type_notice`] waits before typing, so the user's shortcut |
49 | | /// keys can be fully released first. |
50 | | /// |
51 | | /// A failure notice can be emitted within milliseconds of a keypress, at which |
52 | | /// point the shortcut's modifier keys (Ctrl/Alt/Super/Shift) are usually still |
53 | | /// physically held down. Simulated keystrokes sent during that window arrive |
54 | | /// *modified*, so the focused application interprets the notice as shortcuts |
55 | | /// rather than text. |
56 | | /// |
57 | | /// Two distinct cases hit this window, which is why the wait applies to every |
58 | | /// notice rather than just the first: |
59 | | /// |
60 | | /// - The no-model preflight rejects the request before capture even starts, so |
61 | | /// the notice follows the *start* keypress almost immediately. |
62 | | /// - In manual stop mode the user presses the hotkey to end the recording, and |
63 | | /// a failure that surfaces quickly after that — a model unloaded mid-cycle, |
64 | | /// say — puts the notice right behind the *stop* keypress. |
65 | | /// |
66 | | /// This does not apply to transcription output: that is typed after speech has |
67 | | /// been captured and transcribed, long past any plausible key-release window. |
68 | | const NOTICE_KEY_RELEASE_DELAY: std::time::Duration = std::time::Duration::from_secs(1); |
69 | | |
70 | | /// Types previews and the final transcript into the focused window. |
71 | | pub struct Typer { |
72 | | // `None` for a recording that types nothing. Every keystroke path is gated |
73 | | // on write mode, so such a recording never reaches for the keyboard. |
74 | | keyboard_simulator: Option<Simulator>, |
75 | | state: State, |
76 | | } |
77 | | |
78 | | impl Typer { |
79 | | #[must_use] |
80 | 33 | pub fn new(keyboard_simulator: Simulator) -> Self { |
81 | 33 | Self { |
82 | 33 | keyboard_simulator: Some(keyboard_simulator), |
83 | 33 | state: State::default(), |
84 | 33 | } |
85 | 33 | } |
86 | | |
87 | | /// A typer for a recording that types nothing. Building a keyboard is not |
88 | | /// free: the portal backend asks the user for permission and waits for the |
89 | | /// answer. |
90 | | #[must_use] |
91 | 5 | pub fn without_keyboard() -> Self { |
92 | 5 | Self { |
93 | 5 | keyboard_simulator: None, |
94 | 5 | state: State::default(), |
95 | 5 | } |
96 | 5 | } |
97 | | |
98 | | #[must_use] |
99 | 0 | pub fn write_method_name(&self) -> &'static str { |
100 | 0 | self.keyboard_simulator |
101 | 0 | .as_ref() |
102 | 0 | .map_or("none", Simulator::name) |
103 | 0 | } |
104 | | |
105 | | /// Extract the simulator so it can be cached for reuse. |
106 | | #[must_use] |
107 | 5 | pub fn take_simulator(self) -> Option<Simulator> { |
108 | 5 | self.keyboard_simulator |
109 | 5 | } |
110 | | |
111 | 34 | async fn type_text(&mut self, text: &str) -> anyhow::Result<()> { |
112 | 34 | match self.keyboard_simulator.as_mut() { |
113 | 34 | Some(keyboard) => keyboard.type_text(text).await, |
114 | 0 | None => Err(anyhow::anyhow!("this recording has no keyboard")), |
115 | | } |
116 | 34 | } |
117 | | |
118 | 7 | async fn backspace_n(&mut self, n: usize) -> anyhow::Result<()> { |
119 | 7 | match self.keyboard_simulator.as_mut() { |
120 | 7 | Some(keyboard) => keyboard.backspace_n(n).await, |
121 | 0 | None => Err(anyhow::anyhow!("this recording has no keyboard")), |
122 | | } |
123 | 7 | } |
124 | | |
125 | | /// Drive the screen from `old_text` to `new_text`: backspace to the first |
126 | | /// differing character and type the rest. |
127 | | /// |
128 | | /// The one path every preview update takes. An empty screen (common prefix |
129 | | /// 0, nothing to delete) and a pure extension (the prefix is all of |
130 | | /// `old_text`, nothing to delete) are just what the diff computes, not |
131 | | /// separate branches that can disagree about what they typed. |
132 | 25 | async fn retype_from_first_difference(&mut self, old_text: &str, new_text: &str) { |
133 | 25 | if old_text == new_text { |
134 | 0 | return; |
135 | 25 | } |
136 | | |
137 | 25 | let common_prefix = find_common_prefix(old_text, new_text); |
138 | 25 | let chars_to_delete = old_text.chars().count() - common_prefix; |
139 | 25 | let text_to_type: String = new_text.chars().skip(common_prefix).collect(); |
140 | | |
141 | 25 | debug!( |
142 | | "Retype: prefix={common_prefix}, delete={chars_to_delete}, type='{}'", |
143 | 0 | text_to_type.chars().take(20).collect::<String>() |
144 | | ); |
145 | | |
146 | | // A refused keystroke is the only sign that the screen and the mirror |
147 | | // may now disagree; it has to be visible in the log. |
148 | 25 | if chars_to_delete > 0 |
149 | 4 | && let Err(e0 ) = self.backspace_n(chars_to_delete).await |
150 | | { |
151 | 0 | warn!("Failed to backspace preview text: {e}"); |
152 | 25 | } |
153 | 25 | if !text_to_type.is_empty() |
154 | 25 | && let Err(e0 ) = self.type_text(&text_to_type).await |
155 | | { |
156 | 0 | warn!("Failed to type preview text: {e}"); |
157 | 25 | } |
158 | 25 | } |
159 | | |
160 | | /// Type one preview: fold it into the running transcript and put the |
161 | | /// result on screen. The screen shows the transcript so far, never the |
162 | | /// preview itself — a window is only a few seconds of it. |
163 | 26 | pub async fn update_preview( |
164 | 26 | &mut self, |
165 | 26 | new_text: &str, |
166 | 26 | source: PreviewSource, |
167 | 26 | actually_typed: &mut String, |
168 | 26 | ) { |
169 | 26 | let normalized = normalize_text(new_text); |
170 | 26 | debug!( |
171 | | "Preview update ({source}): new='{normalized}', prev='{}', typed='{actually_typed}'", |
172 | | self.state.prev_text |
173 | | ); |
174 | | |
175 | 26 | if normalized == self.state.prev_text { |
176 | 1 | debug!("Preview unchanged; nothing to type"); |
177 | 1 | return; |
178 | 25 | } |
179 | 25 | if normalized.is_empty() { |
180 | 0 | debug!("Preview is empty; nothing to type"); |
181 | 0 | return; |
182 | 25 | } |
183 | | |
184 | 25 | self.state.absorb(&normalized, source); |
185 | 25 | let display_text = capitalize_first(&self.state.full_session_text); |
186 | | // The head of a running transcript stops changing after the first |
187 | | // seconds; its tail is where each window lands. |
188 | 25 | info!("Transcript so far ends '{}'", tail_of0 (&display_text0 , 40)); |
189 | | |
190 | 25 | self.apply_text_update(&display_text, actually_typed).await; |
191 | 25 | self.state.prev_text = normalized; |
192 | 26 | } |
193 | | |
194 | | /// Type the final transcript, then reset for the next recording. |
195 | 5 | pub async fn process_final_text(&mut self, transcription_result: &str) { |
196 | | // No preview typing, type directly |
197 | 5 | let processed_text = preprocess_text(transcription_result, false); |
198 | | |
199 | | // An empty transcript has nothing to type. Without this guard the |
200 | | // `format!("{processed_text} ")` below deposits a bare space into the |
201 | | // user's focused window every time a recording produces no text. |
202 | 5 | if processed_text.trim().is_empty() { |
203 | 3 | info!("Final transcription is empty; typing nothing"); |
204 | 3 | self.reset_after_recording(); |
205 | 3 | return; |
206 | 2 | } |
207 | | |
208 | 2 | let final_text = format!("{processed_text} "); |
209 | 2 | if let Err(e0 ) = self.type_text(&final_text).await { |
210 | 0 | warn!("Failed to type final transcription: {e}"); |
211 | | } else { |
212 | 2 | info!("Final transcript typed"); |
213 | | } |
214 | | |
215 | 2 | self.reset_after_recording(); |
216 | 5 | } |
217 | | |
218 | | /// Clear the per-recording transcript state so the next recording starts |
219 | | /// clean. Window stitching reads `full_session_text` and the repeat check |
220 | | /// reads `prev_text`, so anything left here would be extended. |
221 | | /// |
222 | | /// Split out of [`Self::process_final_text`] because the no-speech path |
223 | | /// finishes a recording without typing anything and still has to reset. |
224 | 6 | pub fn reset_after_recording(&mut self) { |
225 | 6 | self.state.prev_text.clear(); |
226 | 6 | self.state.full_session_text.clear(); |
227 | 6 | } |
228 | | |
229 | | /// Type a fixed daemon-authored notice into the focused window. |
230 | | /// |
231 | | /// Deliberately **not** `process_final_text`. That method mutates |
232 | | /// transcript state (`prev_text`, `full_session_text`), which the next |
233 | | /// recording's window stitching would extend, and applies transcript |
234 | | /// semantics — capitalization, a trailing period, a trailing space — that |
235 | | /// a fixed marker must not inherit. A notice is typed verbatim and leaves |
236 | | /// session state alone. |
237 | | /// |
238 | | /// Routed through the same [`sanitize_for_typing`] choke point as every |
239 | | /// other write path (audit 2 Tier 3 #8). The callers pass constants, so |
240 | | /// this is a no-op today; it is here so the property holds by |
241 | | /// construction. |
242 | | /// |
243 | | /// Waits [`NOTICE_KEY_RELEASE_DELAY`] before typing — see that constant for |
244 | | /// why. |
245 | 7 | pub async fn type_notice(&mut self, notice: &str) { |
246 | | // Let the user's hotkey come back up first. The no-model preflight |
247 | | // rejects before capture starts, so this can run within milliseconds of |
248 | | // the press, while the shortcut's modifiers are still physically held. |
249 | | // Typing then delivers *modified* keystrokes to the focused window — |
250 | | // the notice would fire shortcuts in the user's application instead of |
251 | | // inserting text. |
252 | 7 | tokio::time::sleep(NOTICE_KEY_RELEASE_DELAY).await; |
253 | | |
254 | 7 | let sanitized = sanitize_for_typing(notice); |
255 | 7 | if let Err(e0 ) = self.type_text(&sanitized).await { |
256 | 0 | warn!("Failed to type notice: {e}"); |
257 | | } else { |
258 | 7 | info!("Typed failure notice: {sanitized}"); |
259 | | } |
260 | 7 | } |
261 | | |
262 | | /// Put `new_text` on screen in place of what `actually_typed` says is |
263 | | /// there, then record `new_text` as what is there now. |
264 | | /// |
265 | | /// The mirror has to be exactly the screen: `clear_preview` backspaces its |
266 | | /// length and the next update diffs against it. It used to drift by one |
267 | | /// space per update — the first text and every extension were typed with a |
268 | | /// trailing space the mirror never held — so extensions doubled spaces, |
269 | | /// replacements backspaced too few characters and left fragments of the |
270 | | /// old text behind, and the clear at the end of a recording left the first |
271 | | /// characters of the preview in front of the final transcript. |
272 | 25 | async fn apply_text_update(&mut self, new_text: &str, actually_typed: &mut String) { |
273 | 25 | self.retype_from_first_difference(actually_typed, new_text) |
274 | 25 | .await; |
275 | | |
276 | 25 | actually_typed.clear(); |
277 | 25 | actually_typed.push_str(new_text); |
278 | 25 | } |
279 | | |
280 | | /// Erase the typed preview from the screen and reset the transcript state. |
281 | 3 | pub async fn clear_preview(&mut self, actually_typed: &mut String) { |
282 | 3 | let chars_to_delete = actually_typed.chars().count(); |
283 | 3 | if chars_to_delete == 0 { |
284 | 0 | debug!("No preview on screen to clear"); |
285 | | } else { |
286 | 3 | info!("Clearing the {chars_to_delete}-character preview"); |
287 | 3 | if let Err(e0 ) = self.backspace_n(chars_to_delete).await { |
288 | 0 | warn!("Failed to backspace preview text: {e}"); |
289 | 3 | } |
290 | | } |
291 | | |
292 | 3 | actually_typed.clear(); |
293 | 3 | self.state.prev_text.clear(); |
294 | 3 | self.state.full_session_text.clear(); |
295 | 3 | } |
296 | | } |
297 | | |
298 | | /// The last `n` chars of `text`, marked when that is not all of it. |
299 | 0 | fn tail_of(text: &str, n: usize) -> String { |
300 | 0 | let total = text.chars().count(); |
301 | 0 | if total <= n { |
302 | 0 | return text.to_string(); |
303 | 0 | } |
304 | 0 | format!("…{}", text.chars().skip(total - n).collect::<String>()) |
305 | 0 | } |
306 | | |
307 | | #[cfg(test)] |
308 | | #[path = "typer_tests.rs"] |
309 | | mod tests; |