voxtype 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- voxtype/__init__.py +7 -0
- voxtype/app.py +683 -0
- voxtype/cli.py +280 -0
- voxtype/config.py +445 -0
- voxtype/engine_parakeet.py +1161 -0
- voxtype/engine_parakeet_mlx.py +227 -0
- voxtype/engines.py +183 -0
- voxtype/hotkey.py +481 -0
- voxtype/inject.py +167 -0
- voxtype/logic.py +119 -0
- voxtype/overlay.py +428 -0
- voxtype/setup_wizard.py +360 -0
- voxtype-0.1.0.dist-info/METADATA +156 -0
- voxtype-0.1.0.dist-info/RECORD +17 -0
- voxtype-0.1.0.dist-info/WHEEL +4 -0
- voxtype-0.1.0.dist-info/entry_points.txt +2 -0
- voxtype-0.1.0.dist-info/licenses/LICENSE +21 -0
voxtype/__init__.py
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
"""voxtype: local, streaming speech-to-text that types where your cursor is.
|
|
2
|
+
|
|
3
|
+
Fully on-device transcription — NVIDIA Parakeet-TDT on the Apple GPU
|
|
4
|
+
(MLX) or CPU (sherpa-onnx), or Moonshine streaming models — with voice
|
|
5
|
+
activity detection, a configurable global hotkey, and an optional wake
|
|
6
|
+
word (e.g. "claude").
|
|
7
|
+
"""
|
voxtype/app.py
ADDED
|
@@ -0,0 +1,683 @@
|
|
|
1
|
+
"""The voxtype runtime: mic transcription -> state machine -> keystrokes.
|
|
2
|
+
|
|
3
|
+
Wires Moonshine's ``MicTranscriber`` (streaming transcription with built-in
|
|
4
|
+
voice activity detection) to a small activation state machine and the
|
|
5
|
+
keystroke injector. Each utterance is typed in one go when the VAD
|
|
6
|
+
completes a line (most apps undo it as a single edit).
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import sys
|
|
12
|
+
import threading
|
|
13
|
+
import time
|
|
14
|
+
from enum import Enum
|
|
15
|
+
from typing import Callable
|
|
16
|
+
|
|
17
|
+
from .config import Config
|
|
18
|
+
from .inject import SoundPlayer, Typist, copy_to_clipboard
|
|
19
|
+
from .logic import (
|
|
20
|
+
collapse_repeats,
|
|
21
|
+
contains_phrase,
|
|
22
|
+
is_exact_phrase,
|
|
23
|
+
strip_fillers,
|
|
24
|
+
text_after_wake_word,
|
|
25
|
+
)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class State(Enum):
|
|
29
|
+
"""Activation state of the dictation loop."""
|
|
30
|
+
|
|
31
|
+
PAUSED = "paused"
|
|
32
|
+
PASSIVE = "waiting for wake word"
|
|
33
|
+
ACTIVE = "dictating"
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _preview(text: str, limit: int = 60) -> str:
|
|
37
|
+
"""Shorten ``text`` for a one-line status message."""
|
|
38
|
+
return text if len(text) <= limit else text[: limit - 1] + "…"
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class VoiceTypeApp:
|
|
42
|
+
"""Runs the mic transcriber and types utterances per the config."""
|
|
43
|
+
|
|
44
|
+
def __init__(self, cfg: Config) -> None:
|
|
45
|
+
self.cfg = cfg
|
|
46
|
+
self.typist = Typist()
|
|
47
|
+
# Preload the chimes so the first one is instant and in sync
|
|
48
|
+
# with the overlay (afplay would lag ~100 ms behind the pill).
|
|
49
|
+
self._sound = SoundPlayer(cfg.sound_start, cfg.sound_stop)
|
|
50
|
+
self._lock = threading.Lock()
|
|
51
|
+
self._state = (
|
|
52
|
+
State.ACTIVE
|
|
53
|
+
if cfg.mode == "vad"
|
|
54
|
+
else State.PASSIVE
|
|
55
|
+
if cfg.mode == "wake"
|
|
56
|
+
else State.PAUSED
|
|
57
|
+
)
|
|
58
|
+
self._off_state = (
|
|
59
|
+
State.PASSIVE if cfg.mode == "wake" else State.PAUSED
|
|
60
|
+
)
|
|
61
|
+
# Bumped on every state transition; keystroke injection is
|
|
62
|
+
# skipped if the version moved between the state snapshot and
|
|
63
|
+
# the injection (see _inject).
|
|
64
|
+
self._state_version = 0
|
|
65
|
+
# Serializes transition side effects (sound + status) so they
|
|
66
|
+
# emit in commit order and stale ones are suppressed.
|
|
67
|
+
self._effects_lock = threading.Lock()
|
|
68
|
+
self._last_activity = time.monotonic()
|
|
69
|
+
self._grace_until = 0.0
|
|
70
|
+
# Hold-mode handoff: count of hold-stop requests whose decoded
|
|
71
|
+
# take has not arrived yet (however long decoding takes) —
|
|
72
|
+
# delivery is tied one-to-one to stop requests, never to a
|
|
73
|
+
# wall-clock window. A counter (not a bool) so a rapid
|
|
74
|
+
# off/on/off sequence with two takes still in decode delivers
|
|
75
|
+
# both instead of silently dropping the second.
|
|
76
|
+
self._expect_hold = 0
|
|
77
|
+
self._last_toggle = 0.0
|
|
78
|
+
self._engine = None
|
|
79
|
+
# Set once _start_hotkey_listener runs — in run() (no overlay) or
|
|
80
|
+
# from inside the overlay loop (see _start_hotkeys_from_loop); the
|
|
81
|
+
# finally block stops it. May be None if no valid hotkeys.
|
|
82
|
+
self._hotkeys = None
|
|
83
|
+
# Text of the current/most recent dictation session, for the
|
|
84
|
+
# clipboard option and the paste-again hotkey.
|
|
85
|
+
self._session_texts: list[str] = []
|
|
86
|
+
self._stop = threading.Event()
|
|
87
|
+
# Cleared while the engine loads (run() clears then sets it),
|
|
88
|
+
# so the toggle hotkey is a no-op — with feedback — until audio
|
|
89
|
+
# capture actually exists. Without this, a toggle pressed during
|
|
90
|
+
# the seconds-long model load flipped the state machine to ACTIVE
|
|
91
|
+
# against an engine that could not yet record, leaving a stuck
|
|
92
|
+
# overlay and an app that looked hung. Set by default so unit
|
|
93
|
+
# tests that drive the state machine directly still toggle.
|
|
94
|
+
self._ready = threading.Event()
|
|
95
|
+
self._ready.set()
|
|
96
|
+
|
|
97
|
+
# -- state transitions ------------------------------------------------
|
|
98
|
+
|
|
99
|
+
def _transition(
|
|
100
|
+
self, compute: Callable[[State], State]
|
|
101
|
+
) -> int:
|
|
102
|
+
"""Atomically commit ``compute(current_state)`` as the new state.
|
|
103
|
+
|
|
104
|
+
The current state is read, the new state computed, and the
|
|
105
|
+
transition committed under a single lock acquisition, so
|
|
106
|
+
concurrent transitions (two hotkey toggles, a toggle vs. the
|
|
107
|
+
idle timeout) serialize: none can act on a stale snapshot.
|
|
108
|
+
Effects are emitted after the lock is released.
|
|
109
|
+
|
|
110
|
+
Returns:
|
|
111
|
+
The resulting state version (unchanged if ``compute``
|
|
112
|
+
returned the current state).
|
|
113
|
+
"""
|
|
114
|
+
with self._lock:
|
|
115
|
+
new = compute(self._state)
|
|
116
|
+
if new == self._state:
|
|
117
|
+
return self._state_version
|
|
118
|
+
old, self._state = self._state, new
|
|
119
|
+
self._state_version += 1
|
|
120
|
+
version = self._state_version
|
|
121
|
+
self._last_activity = time.monotonic()
|
|
122
|
+
if new == State.ACTIVE:
|
|
123
|
+
# Fresh dictation session: the buffer is cleared as
|
|
124
|
+
# part of the SAME commit that publishes ACTIVE, so a
|
|
125
|
+
# transcript delivered right after activation can never
|
|
126
|
+
# be appended first and then erased by a later effect
|
|
127
|
+
# (which would break clipboard/paste-last recovery).
|
|
128
|
+
self._session_texts = []
|
|
129
|
+
self._emit_transition_effects(version, old, new)
|
|
130
|
+
return version
|
|
131
|
+
|
|
132
|
+
def _set_state(self, new: State) -> int:
|
|
133
|
+
"""Transition to ``new``; returns the resulting state version."""
|
|
134
|
+
return self._transition(lambda _current: new)
|
|
135
|
+
|
|
136
|
+
def _emit_transition_effects(
|
|
137
|
+
self,
|
|
138
|
+
version: int,
|
|
139
|
+
old: State,
|
|
140
|
+
new: State,
|
|
141
|
+
pre_msg: str | None = None,
|
|
142
|
+
) -> None:
|
|
143
|
+
"""Emit the sound/status for a committed transition, unless stale.
|
|
144
|
+
|
|
145
|
+
The state commit and its user-visible effects are two steps; a
|
|
146
|
+
concurrent transition (hotkey toggle vs. idle timeout) can land
|
|
147
|
+
between them. Effects therefore run under a dedicated lock and
|
|
148
|
+
re-check the state version first: a superseded transition emits
|
|
149
|
+
nothing, so the last sound/status always reflects the actual
|
|
150
|
+
state.
|
|
151
|
+
"""
|
|
152
|
+
with self._effects_lock:
|
|
153
|
+
with self._lock:
|
|
154
|
+
if version != self._state_version:
|
|
155
|
+
return
|
|
156
|
+
if pre_msg is not None:
|
|
157
|
+
self._status(pre_msg)
|
|
158
|
+
if self.cfg.sounds and State.ACTIVE in (old, new):
|
|
159
|
+
self._sound.play(
|
|
160
|
+
self.cfg.sound_start
|
|
161
|
+
if new == State.ACTIVE
|
|
162
|
+
else self.cfg.sound_stop
|
|
163
|
+
)
|
|
164
|
+
self._status(new.value)
|
|
165
|
+
|
|
166
|
+
#: Seconds after a toggle-off during which an in-flight utterance
|
|
167
|
+
#: (speech finished just before the toggle) is still committed.
|
|
168
|
+
TOGGLE_GRACE = 2.0
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
#: Ignore a second toggle within this window: macro keys (e.g. UHK)
|
|
172
|
+
#: can re-fire the chord on hold, flipping the state twice for one
|
|
173
|
+
#: intended press. OS autorepeat is already filtered at the event
|
|
174
|
+
#: tap; this catches firmware-generated repeats too.
|
|
175
|
+
TOGGLE_DEBOUNCE = 0.3
|
|
176
|
+
|
|
177
|
+
def toggle(self) -> None:
|
|
178
|
+
"""Hotkey handler: flip between active and the off state.
|
|
179
|
+
|
|
180
|
+
The read-invert-commit runs atomically inside ``_transition``:
|
|
181
|
+
two concurrent toggles can never both observe the same state
|
|
182
|
+
and collapse into one transition (a lost toggle).
|
|
183
|
+
|
|
184
|
+
Toggle-off means "commit what I said": the engine is asked to
|
|
185
|
+
flush its in-flight VAD segment and a grace window lets that
|
|
186
|
+
trailing utterance still type. Toggle-on resets the engine's
|
|
187
|
+
audio state so stale pre-activation speech never leaks into the
|
|
188
|
+
first utterance.
|
|
189
|
+
"""
|
|
190
|
+
if not self._ready.is_set():
|
|
191
|
+
# Engine still loading: swallow the press (with feedback)
|
|
192
|
+
# rather than arm a recording the engine cannot fulfill yet.
|
|
193
|
+
self._status("still starting up — wait for the 'ready' message")
|
|
194
|
+
return
|
|
195
|
+
now = time.monotonic()
|
|
196
|
+
with self._lock:
|
|
197
|
+
if now - self._last_toggle < self.TOGGLE_DEBOUNCE:
|
|
198
|
+
return
|
|
199
|
+
self._last_toggle = now
|
|
200
|
+
predicted_active = self._state == State.ACTIVE
|
|
201
|
+
|
|
202
|
+
engine = self._engine
|
|
203
|
+
hold = self.cfg.segmentation == "hold"
|
|
204
|
+
# Arm the handoff BEFORE the new state becomes visible, so an
|
|
205
|
+
# utterance delivered mid-transition can never fall in a gap:
|
|
206
|
+
# deactivation arms the grace/hold-delivery flags first;
|
|
207
|
+
# activation clears stale engine audio while still off.
|
|
208
|
+
if predicted_active:
|
|
209
|
+
with self._lock:
|
|
210
|
+
if hold:
|
|
211
|
+
self._expect_hold += 1
|
|
212
|
+
else:
|
|
213
|
+
self._grace_until = now + self.TOGGLE_GRACE
|
|
214
|
+
else:
|
|
215
|
+
action = getattr(
|
|
216
|
+
engine,
|
|
217
|
+
"request_hold_start" if hold else "request_reset",
|
|
218
|
+
None,
|
|
219
|
+
)
|
|
220
|
+
if action is not None:
|
|
221
|
+
action()
|
|
222
|
+
|
|
223
|
+
deactivating = {}
|
|
224
|
+
|
|
225
|
+
def compute(current: State) -> State:
|
|
226
|
+
deactivating["was_active"] = current == State.ACTIVE
|
|
227
|
+
return (
|
|
228
|
+
self._off_state
|
|
229
|
+
if current == State.ACTIVE
|
|
230
|
+
else State.ACTIVE
|
|
231
|
+
)
|
|
232
|
+
|
|
233
|
+
self._transition(compute)
|
|
234
|
+
was_active = deactivating.get("was_active")
|
|
235
|
+
if was_active != predicted_active:
|
|
236
|
+
# A concurrent transition raced us between the prediction
|
|
237
|
+
# and the commit (debounce makes this rare). Disarm the
|
|
238
|
+
# possibly-wrong handoff; the winning transition owns it.
|
|
239
|
+
with self._lock:
|
|
240
|
+
self._grace_until = 0.0
|
|
241
|
+
self._expect_hold = 0
|
|
242
|
+
return
|
|
243
|
+
if was_active:
|
|
244
|
+
action = getattr(
|
|
245
|
+
engine,
|
|
246
|
+
"request_hold_stop" if hold else "request_flush",
|
|
247
|
+
None,
|
|
248
|
+
)
|
|
249
|
+
if action is not None:
|
|
250
|
+
action()
|
|
251
|
+
|
|
252
|
+
def cancel(self) -> None:
|
|
253
|
+
"""Cancel-hotkey handler: discard the recording in progress.
|
|
254
|
+
|
|
255
|
+
Transitions to the off state FIRST — with NO grace window — and
|
|
256
|
+
only then asks the engine to drop its pending audio (hold
|
|
257
|
+
buffer / open VAD segment). The engine reset is asynchronous,
|
|
258
|
+
so the order matters: invalidating the state/version before
|
|
259
|
+
requesting the reset means a transcript completing concurrently
|
|
260
|
+
fails the version check and grace test instead of typing after
|
|
261
|
+
the user pressed cancel. Only bound while recording, so Escape
|
|
262
|
+
works normally otherwise.
|
|
263
|
+
"""
|
|
264
|
+
with self._lock:
|
|
265
|
+
if self._state != State.ACTIVE:
|
|
266
|
+
return
|
|
267
|
+
self._grace_until = 0.0
|
|
268
|
+
self._expect_hold = 0
|
|
269
|
+
self._set_state(self._off_state)
|
|
270
|
+
with self._lock:
|
|
271
|
+
self._grace_until = 0.0 # _set_state must not resurrect it
|
|
272
|
+
self._expect_hold = 0
|
|
273
|
+
reset = getattr(self._engine, "request_reset", None)
|
|
274
|
+
if reset is not None:
|
|
275
|
+
reset()
|
|
276
|
+
self._status("recording cancelled (discarded)")
|
|
277
|
+
|
|
278
|
+
def _is_recording(self) -> bool:
|
|
279
|
+
with self._lock:
|
|
280
|
+
return self._state == State.ACTIVE
|
|
281
|
+
|
|
282
|
+
# -- transcript handling ----------------------------------------------
|
|
283
|
+
|
|
284
|
+
def note_activity(self) -> None:
|
|
285
|
+
"""Record speech evidence (partial text / VAD) to defer idle re-arm.
|
|
286
|
+
|
|
287
|
+
Engines call this while an utterance is still in progress so that
|
|
288
|
+
a long dictation never trips the wake-mode idle timeout mid-speech.
|
|
289
|
+
"""
|
|
290
|
+
with self._lock:
|
|
291
|
+
self._last_activity = time.monotonic()
|
|
292
|
+
|
|
293
|
+
def handle_utterance(self, text: str) -> None:
|
|
294
|
+
"""Process one VAD-completed utterance from the transcriber.
|
|
295
|
+
|
|
296
|
+
Malformed transcripts (None, non-strings) are ignored rather
|
|
297
|
+
than raised on: engines may surface junk event payloads.
|
|
298
|
+
"""
|
|
299
|
+
if not isinstance(text, str):
|
|
300
|
+
return
|
|
301
|
+
text = text.strip()
|
|
302
|
+
if not text:
|
|
303
|
+
return
|
|
304
|
+
with self._lock:
|
|
305
|
+
state = self._state
|
|
306
|
+
version = self._state_version
|
|
307
|
+
self._last_activity = time.monotonic()
|
|
308
|
+
if state in (State.PAUSED, State.PASSIVE):
|
|
309
|
+
if self._consume_handoff():
|
|
310
|
+
# In-flight speech from just before a toggle-off:
|
|
311
|
+
# commit it (the user said it while dictating).
|
|
312
|
+
if any(
|
|
313
|
+
is_exact_phrase(text, p)
|
|
314
|
+
for p in self.cfg.submit_phrases
|
|
315
|
+
):
|
|
316
|
+
with self._lock:
|
|
317
|
+
self.typist.press_enter()
|
|
318
|
+
self._status("submitted (Enter, in-flight)")
|
|
319
|
+
elif self.cfg.stop_phrase and is_exact_phrase(
|
|
320
|
+
text, self.cfg.stop_phrase
|
|
321
|
+
):
|
|
322
|
+
# The trailing utterance was itself the stop
|
|
323
|
+
# phrase: the user was deactivating, not dictating
|
|
324
|
+
# — typing it would spray "stop listening" into
|
|
325
|
+
# the focused app.
|
|
326
|
+
self._status("stop phrase in flight (dropped)")
|
|
327
|
+
else:
|
|
328
|
+
self._status(f'typed in-flight: "{_preview(text)}"')
|
|
329
|
+
self._type(text, None)
|
|
330
|
+
return
|
|
331
|
+
if state == State.PAUSED:
|
|
332
|
+
self._status(f'heard while paused (dropped): "{_preview(text)}"')
|
|
333
|
+
return
|
|
334
|
+
if state == State.PASSIVE:
|
|
335
|
+
remainder = None
|
|
336
|
+
for word in (self.cfg.wake_word, *self.cfg.wake_word_aliases):
|
|
337
|
+
remainder = text_after_wake_word(text, word)
|
|
338
|
+
if remainder is not None:
|
|
339
|
+
break
|
|
340
|
+
if remainder is None:
|
|
341
|
+
self._status(
|
|
342
|
+
f'heard (awaiting wake word): "{_preview(text)}"'
|
|
343
|
+
)
|
|
344
|
+
return
|
|
345
|
+
version = self._set_state(State.ACTIVE)
|
|
346
|
+
if remainder:
|
|
347
|
+
self._type(remainder, version)
|
|
348
|
+
return
|
|
349
|
+
# ACTIVE
|
|
350
|
+
if any(
|
|
351
|
+
is_exact_phrase(text, p) for p in self.cfg.submit_phrases
|
|
352
|
+
):
|
|
353
|
+
if self._inject(version, self.typist.press_enter):
|
|
354
|
+
self._status("submitted (Enter)")
|
|
355
|
+
elif self._consume_handoff():
|
|
356
|
+
# Snapshot said ACTIVE but a toggle-off landed before
|
|
357
|
+
# injection: this utterance IS the in-flight one that
|
|
358
|
+
# toggle's grace/hold handoff authorizes.
|
|
359
|
+
with self._lock:
|
|
360
|
+
self.typist.press_enter()
|
|
361
|
+
self._status("submitted (Enter, in-flight)")
|
|
362
|
+
return
|
|
363
|
+
if self.cfg.stop_phrase and contains_phrase(
|
|
364
|
+
text, self.cfg.stop_phrase
|
|
365
|
+
):
|
|
366
|
+
self._set_state(self._off_state)
|
|
367
|
+
return
|
|
368
|
+
self._status(f'typed: "{_preview(text)}"')
|
|
369
|
+
self._type(text, version)
|
|
370
|
+
|
|
371
|
+
def _consume_handoff(self) -> bool:
|
|
372
|
+
"""Consume the armed toggle-off handoff; True if one was armed.
|
|
373
|
+
|
|
374
|
+
The handoff (a pending hold delivery or the wall-clock grace
|
|
375
|
+
window) authorizes exactly ONE delivery per toggle-off: the
|
|
376
|
+
segment in flight at that toggle. Consuming it atomically —
|
|
377
|
+
wherever the delivery happens to land — means later utterances
|
|
378
|
+
(speech begun after the toggle) can never ride the same
|
|
379
|
+
window, and each hold take is delivered exactly once per stop
|
|
380
|
+
request, however long the decode took.
|
|
381
|
+
"""
|
|
382
|
+
with self._lock:
|
|
383
|
+
armed = (
|
|
384
|
+
self._expect_hold > 0
|
|
385
|
+
or time.monotonic() < self._grace_until
|
|
386
|
+
)
|
|
387
|
+
if self._expect_hold:
|
|
388
|
+
self._expect_hold -= 1
|
|
389
|
+
self._grace_until = 0.0
|
|
390
|
+
return armed
|
|
391
|
+
|
|
392
|
+
def _inject(
|
|
393
|
+
self, version: int, action: Callable[[], None]
|
|
394
|
+
) -> bool:
|
|
395
|
+
"""Run a keystroke action unless a state transition intervened.
|
|
396
|
+
|
|
397
|
+
The state snapshot in ``handle_utterance`` and the injection
|
|
398
|
+
here are two steps; the hotkey or idle-timeout thread may
|
|
399
|
+
deactivate in between. Holding the lock while re-validating the
|
|
400
|
+
version AND performing the action serializes injection against
|
|
401
|
+
transitions, so text or Enter can never land after a
|
|
402
|
+
deactivation.
|
|
403
|
+
|
|
404
|
+
Returns:
|
|
405
|
+
True if the action ran, False if it was skipped as stale.
|
|
406
|
+
"""
|
|
407
|
+
with self._lock:
|
|
408
|
+
if version != self._state_version:
|
|
409
|
+
return False
|
|
410
|
+
action()
|
|
411
|
+
return True
|
|
412
|
+
|
|
413
|
+
def _type(self, text: str, version: int | None) -> None:
|
|
414
|
+
"""Type ``text``; ``version=None`` skips the staleness check
|
|
415
|
+
(used for grace-window commits, which by definition arrive
|
|
416
|
+
after a state transition)."""
|
|
417
|
+
if self.cfg.strip_fillers:
|
|
418
|
+
# Fillers first: removing "um"s can make a stutter run
|
|
419
|
+
# adjacent ("I um I um I" -> "I I I"), which then collapses.
|
|
420
|
+
text = collapse_repeats(strip_fillers(text))
|
|
421
|
+
if not text:
|
|
422
|
+
return
|
|
423
|
+
# Record for the paste-again hotkey / clipboard BEFORE typing:
|
|
424
|
+
# even text that lands in the wrong window (or is skipped as
|
|
425
|
+
# stale) stays rescuable.
|
|
426
|
+
with self._lock:
|
|
427
|
+
self._session_texts.append(text)
|
|
428
|
+
session = " ".join(self._session_texts)
|
|
429
|
+
if self.cfg.copy_to_clipboard:
|
|
430
|
+
copy_to_clipboard(session)
|
|
431
|
+
if self.cfg.trailing_space:
|
|
432
|
+
text += " "
|
|
433
|
+
if version is None:
|
|
434
|
+
with self._lock:
|
|
435
|
+
self.typist.type_text(text)
|
|
436
|
+
elif not self._inject(
|
|
437
|
+
version, lambda: self.typist.type_text(text)
|
|
438
|
+
) and self._consume_handoff():
|
|
439
|
+
# The utterance snapshotted ACTIVE but lost the injection
|
|
440
|
+
# race to a toggle-off that armed the grace/hold handoff.
|
|
441
|
+
# It IS the one in-flight delivery that handoff authorizes
|
|
442
|
+
# — dropping it here would lose the promised toggle-off
|
|
443
|
+
# commit while leaving the grace window armed for stray
|
|
444
|
+
# later speech. Consume the handoff and type.
|
|
445
|
+
with self._lock:
|
|
446
|
+
self.typist.type_text(text)
|
|
447
|
+
|
|
448
|
+
def paste_last(self) -> None:
|
|
449
|
+
"""Paste-hotkey handler: type the last session's transcript.
|
|
450
|
+
|
|
451
|
+
Rescues dictation that went to the wrong window — place the
|
|
452
|
+
cursor where it should have gone and hit the paste hotkey.
|
|
453
|
+
"""
|
|
454
|
+
with self._lock:
|
|
455
|
+
session = " ".join(self._session_texts)
|
|
456
|
+
if not session:
|
|
457
|
+
self._status("nothing to paste")
|
|
458
|
+
return
|
|
459
|
+
if self.cfg.trailing_space:
|
|
460
|
+
session += " "
|
|
461
|
+
with self._lock:
|
|
462
|
+
self.typist.type_text(session)
|
|
463
|
+
self._status(f'pasted last transcript: "{_preview(session)}"')
|
|
464
|
+
|
|
465
|
+
# -- main loop --------------------------------------------------------
|
|
466
|
+
|
|
467
|
+
def run(self) -> int:
|
|
468
|
+
"""Block running the transcriber until Ctrl+C or engine failure.
|
|
469
|
+
|
|
470
|
+
The whole lifecycle (engine + hotkey listener) sits inside one
|
|
471
|
+
try/finally: a failure at any point — engine construction, model
|
|
472
|
+
download, device startup — still stops every resource that did
|
|
473
|
+
start, each in its own guarded cleanup block. Synchronous engine
|
|
474
|
+
failures are reported and produce exit code 1, matching the
|
|
475
|
+
asynchronous ``fatal_error`` path; only ``ImportError`` is
|
|
476
|
+
re-raised so the CLI can print its install hint.
|
|
477
|
+
|
|
478
|
+
Returns:
|
|
479
|
+
0 on clean shutdown, 1 if the engine failed fatally.
|
|
480
|
+
|
|
481
|
+
Raises:
|
|
482
|
+
ImportError: If the engine's optional dependencies are
|
|
483
|
+
missing (cli.py turns this into an install hint).
|
|
484
|
+
"""
|
|
485
|
+
from . import overlay as overlay_mod
|
|
486
|
+
from .engines import create_engine
|
|
487
|
+
|
|
488
|
+
engine = None
|
|
489
|
+
exit_code = 0
|
|
490
|
+
try:
|
|
491
|
+
self._ready.clear()
|
|
492
|
+
# The overlay runs AppKit's event loop, which races the
|
|
493
|
+
# keyboard event tap: a tap installed BEFORE that loop starts
|
|
494
|
+
# is intermittently killed by it (hotkey silently dead). So
|
|
495
|
+
# with the overlay we defer the listener to on_ready, fired
|
|
496
|
+
# from INSIDE the running loop (see _main_loop); without it
|
|
497
|
+
# there is no such loop, so we start early — which also gives
|
|
498
|
+
# "still starting up" feedback during the model load.
|
|
499
|
+
use_overlay = (
|
|
500
|
+
self.cfg.overlay and overlay_mod.overlay_available()
|
|
501
|
+
)
|
|
502
|
+
if self.cfg.overlay and not use_overlay:
|
|
503
|
+
self._status("overlay unavailable; running without it")
|
|
504
|
+
engine = create_engine(self.cfg, self._status)
|
|
505
|
+
self._engine = engine
|
|
506
|
+
if not use_overlay:
|
|
507
|
+
self._hotkeys = self._start_hotkey_listener()
|
|
508
|
+
engine.start(self.handle_utterance, self.note_activity)
|
|
509
|
+
self._ready.set()
|
|
510
|
+
self._status(
|
|
511
|
+
f"ready — engine={self.cfg.engine}, mode={self.cfg.mode}, "
|
|
512
|
+
f"hotkey={self.cfg.hotkey}, Ctrl+C to quit"
|
|
513
|
+
)
|
|
514
|
+
self._status(self._state.value)
|
|
515
|
+
exit_code = self._main_loop(engine, use_overlay)
|
|
516
|
+
except KeyboardInterrupt:
|
|
517
|
+
pass
|
|
518
|
+
except ImportError:
|
|
519
|
+
# Missing optional dependency: propagate so the CLI can
|
|
520
|
+
# print which extra to install (cleanup still runs).
|
|
521
|
+
raise
|
|
522
|
+
except Exception as e:
|
|
523
|
+
self._status(f"engine failed: {e}")
|
|
524
|
+
exit_code = 1
|
|
525
|
+
finally:
|
|
526
|
+
if engine is not None:
|
|
527
|
+
try:
|
|
528
|
+
engine.stop()
|
|
529
|
+
except Exception as e:
|
|
530
|
+
self._status(f"error stopping engine: {e}")
|
|
531
|
+
if self._hotkeys is not None:
|
|
532
|
+
try:
|
|
533
|
+
self._hotkeys.stop()
|
|
534
|
+
except Exception as e:
|
|
535
|
+
self._status(f"error stopping hotkey listener: {e}")
|
|
536
|
+
self._status("stopped")
|
|
537
|
+
return exit_code
|
|
538
|
+
|
|
539
|
+
def _start_hotkeys_from_loop(self) -> None:
|
|
540
|
+
"""Install the hotkey listener; fired from inside the overlay loop.
|
|
541
|
+
|
|
542
|
+
Running here — after AppKit's event loop is already up — means
|
|
543
|
+
the keyboard event tap is installed on top of it, so the loop
|
|
544
|
+
can't race and silently kill it (the intermittent dead-hotkey
|
|
545
|
+
bug when the tap was installed first).
|
|
546
|
+
"""
|
|
547
|
+
self._hotkeys = self._start_hotkey_listener()
|
|
548
|
+
|
|
549
|
+
def _main_loop(self, engine, use_overlay: bool) -> int: # noqa: ANN001
|
|
550
|
+
"""Run the housekeeping loop until stop; returns the exit code.
|
|
551
|
+
|
|
552
|
+
With the overlay enabled (macOS), AppKit's run loop drives the
|
|
553
|
+
ticks (the waveform pill needs the main thread) and the hotkey
|
|
554
|
+
listener is started from inside it (see
|
|
555
|
+
``_start_hotkeys_from_loop``); otherwise a plain sleep loop does
|
|
556
|
+
and the listener was already started in ``run``.
|
|
557
|
+
"""
|
|
558
|
+
result = {"code": 0}
|
|
559
|
+
|
|
560
|
+
def tick() -> None:
|
|
561
|
+
self._check_idle_timeout()
|
|
562
|
+
fatal = getattr(engine, "fatal_error", None)
|
|
563
|
+
if fatal:
|
|
564
|
+
self._status(f"engine failed: {fatal}")
|
|
565
|
+
result["code"] = 1
|
|
566
|
+
self._stop.set()
|
|
567
|
+
|
|
568
|
+
if use_overlay:
|
|
569
|
+
from . import overlay
|
|
570
|
+
|
|
571
|
+
def sample() -> tuple[str, bool, float]:
|
|
572
|
+
with self._lock:
|
|
573
|
+
state = self._state
|
|
574
|
+
level = getattr(engine, "level", 0.0)
|
|
575
|
+
try:
|
|
576
|
+
level = float(level)
|
|
577
|
+
except (TypeError, ValueError):
|
|
578
|
+
level = 0.0
|
|
579
|
+
return state.value, state is State.ACTIVE, level
|
|
580
|
+
|
|
581
|
+
overlay.run_overlay(
|
|
582
|
+
sample,
|
|
583
|
+
tick,
|
|
584
|
+
self._stop.is_set,
|
|
585
|
+
on_ready=self._start_hotkeys_from_loop,
|
|
586
|
+
flex=self.cfg.overlay_flex,
|
|
587
|
+
speed=self.cfg.overlay_speed,
|
|
588
|
+
)
|
|
589
|
+
return result["code"]
|
|
590
|
+
while not self._stop.is_set():
|
|
591
|
+
time.sleep(0.25)
|
|
592
|
+
tick()
|
|
593
|
+
return result["code"]
|
|
594
|
+
|
|
595
|
+
def _check_idle_timeout(self) -> None:
|
|
596
|
+
if self.cfg.mode != "wake":
|
|
597
|
+
return
|
|
598
|
+
with self._lock:
|
|
599
|
+
# Check AND commit under one lock acquisition: note_activity()
|
|
600
|
+
# and handle_utterance() take the same lock, so fresh speech
|
|
601
|
+
# can never slip in between the expiry check and the
|
|
602
|
+
# ACTIVE -> PASSIVE transition.
|
|
603
|
+
expired = (
|
|
604
|
+
self._state == State.ACTIVE
|
|
605
|
+
and time.monotonic() - self._last_activity
|
|
606
|
+
> self.cfg.idle_timeout
|
|
607
|
+
)
|
|
608
|
+
if expired:
|
|
609
|
+
self._state = State.PASSIVE
|
|
610
|
+
self._state_version += 1
|
|
611
|
+
version = self._state_version
|
|
612
|
+
self._last_activity = time.monotonic()
|
|
613
|
+
if expired:
|
|
614
|
+
self._emit_transition_effects(
|
|
615
|
+
version,
|
|
616
|
+
State.ACTIVE,
|
|
617
|
+
State.PASSIVE,
|
|
618
|
+
pre_msg=f"idle for {self.cfg.idle_timeout:g}s",
|
|
619
|
+
)
|
|
620
|
+
|
|
621
|
+
def _start_hotkey_listener(self): # noqa: ANN202
|
|
622
|
+
"""Start the global hotkey listener; invalid chords degrade.
|
|
623
|
+
|
|
624
|
+
Missing macOS permissions are reported LOUDLY first: a launch
|
|
625
|
+
context without Input Monitoring gets a dead event tap with no
|
|
626
|
+
error from the OS, which previously looked exactly like "the
|
|
627
|
+
hotkey just doesn't work".
|
|
628
|
+
|
|
629
|
+
Each configured chord is validated independently, so one
|
|
630
|
+
malformed OPTIONAL binding (paste/cancel) only disables itself
|
|
631
|
+
— a typo in cancel_hotkey must never take the toggle hotkey
|
|
632
|
+
(and with it all of toggle mode) down.
|
|
633
|
+
"""
|
|
634
|
+
from .hotkey import check_permissions, parse_hotkey, start_hotkeys
|
|
635
|
+
|
|
636
|
+
for warning in check_permissions():
|
|
637
|
+
self._status(f"WARNING: {warning}")
|
|
638
|
+
|
|
639
|
+
candidates: list[tuple[str, tuple]] = [
|
|
640
|
+
("toggle", (self.cfg.hotkey, self.toggle))
|
|
641
|
+
]
|
|
642
|
+
if self.cfg.paste_hotkey:
|
|
643
|
+
candidates.append(
|
|
644
|
+
("paste", (self.cfg.paste_hotkey, self.paste_last))
|
|
645
|
+
)
|
|
646
|
+
if self.cfg.cancel_hotkey:
|
|
647
|
+
# Conditional: intercepted only while recording; passes
|
|
648
|
+
# through to the focused app otherwise.
|
|
649
|
+
candidates.append(
|
|
650
|
+
(
|
|
651
|
+
"cancel",
|
|
652
|
+
(
|
|
653
|
+
self.cfg.cancel_hotkey,
|
|
654
|
+
self.cancel,
|
|
655
|
+
self._is_recording,
|
|
656
|
+
),
|
|
657
|
+
)
|
|
658
|
+
)
|
|
659
|
+
bindings: list[tuple] = []
|
|
660
|
+
for name, binding in candidates:
|
|
661
|
+
try:
|
|
662
|
+
parse_hotkey(binding[0])
|
|
663
|
+
except ValueError as e:
|
|
664
|
+
self._status(
|
|
665
|
+
f"invalid {name} hotkey {binding[0]!r} ({e}); "
|
|
666
|
+
f"{name} hotkey disabled"
|
|
667
|
+
)
|
|
668
|
+
continue
|
|
669
|
+
bindings.append(binding)
|
|
670
|
+
if not bindings:
|
|
671
|
+
self._status("no valid hotkeys configured; hotkeys disabled")
|
|
672
|
+
return None
|
|
673
|
+
try:
|
|
674
|
+
return start_hotkeys(bindings)
|
|
675
|
+
except ValueError as e:
|
|
676
|
+
self._status(
|
|
677
|
+
f"invalid hotkey config ({e}); hotkeys disabled"
|
|
678
|
+
)
|
|
679
|
+
return None
|
|
680
|
+
|
|
681
|
+
@staticmethod
|
|
682
|
+
def _status(msg: str) -> None:
|
|
683
|
+
print(f"[voxtype] {msg}", file=sys.stderr, flush=True)
|