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 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)