Skip to content

Commit af88f35

Browse files
authored
voxtype: overlay_opacity knob + double-tap right-Option toggle (#195)
* feat(voxtype): overlay_opacity knob and double-tap modifier toggle Two small UX additions: - overlay_opacity (0.1-1.0, default 1.0): whole-ghost alpha, applied at the NSPanel level so the drawing keeps its per-element alphas. For when the ghost hides the text under it. - double_tap_key / double_tap_ms: a second toggle trigger alongside the hotkey chord — tap one modifier (e.g. right_alt, the right Option key) twice on its own. Implemented inside the existing macOS event tap by observing kCGEventFlagsChanged; the modifier is never swallowed. Left/right are told apart by keycode + NX_DEVICE* flag bit (verified against the SDK headers and live on the built-in keyboard). Clean-tap rules: no other key or modifier during the taps, press-to-press within double_tap_ms, fires on the second release so Option-chords never trigger it. Tests: new tests/test_voxtype_doubletap.py (config bounds, detector state machine, stubbed event-tap integration, app wiring). * docs: release note for PR #195 (voxtype opacity + double-tap) * fix(voxtype): keep the double tap armed when a chord falls back GitHub Codex on PR #195: an unresolvable chord (e.g. ';' needing Shift on some layouts) sends the chords to pynput's observing fallback, which silently dropped the layout-independent double-tap binding. Keep a chord-less event tap for the double tap in that case (built before the chord listener starts, so a failure leaks no handle-less listener).
1 parent a10835f commit af88f35

8 files changed

Lines changed: 963 additions & 17 deletions

File tree

‎docs-site/src/content/docs/tools/voxtype/configuration.mdx‎

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -206,6 +206,12 @@ language = "en"
206206

207207
# Hotkeys (pynput or bracket-less syntax; `voxtype hotkey` records one)
208208
hotkey = "<ctrl>+;" # toggle recording
209+
# A second toggle for the laptop keyboard (macOS): tap a modifier key
210+
# twice on its own. One of left_alt right_alt left_cmd right_cmd
211+
# left_ctrl right_ctrl left_shift right_shift ("alt" = Option). The key
212+
# keeps working as a normal modifier. "" disables.
213+
double_tap_key = ""
214+
double_tap_ms = 400 # max gap between the two taps, press to press
209215
cancel_hotkey = "<esc>" # cancel recording (only intercepted WHILE recording)
210216
paste_hotkey = "" # re-type last transcript, e.g. "<cmd>+<ctrl>+v"
211217

@@ -239,6 +245,7 @@ copy_to_clipboard = false
239245
overlay = true
240246
overlay_flex = 1.0 # how much the face flexes its shape (higher = more)
241247
overlay_speed = 1.0 # animation speed (lower = slower/gentler)
248+
overlay_opacity = 1.0 # 0.1–1.0; lower it if the ghost hides text under it
242249

243250
# Keep the model's memory warm (parakeet-mlx only). After this many
244251
# minutes without a decode, voxtype quietly decodes a short silent
@@ -296,6 +303,23 @@ Flags mirror the config keys: `--mode`, `--engine`,
296303
`--stop-phrase`, `--no-sounds`, `--no-overlay`,
297304
`--config PATH`.
298305

306+
### Double-tap toggle
307+
308+
`double_tap_key` adds a second way to start and stop recording, next
309+
to the `hotkey` chord: tap one modifier key twice, by itself. It exists
310+
for the laptop keyboard, where a chord like Ctrl+; is awkward without
311+
an external keyboard; `right_alt` (the right Option key) is a good
312+
choice because nothing else uses it alone.
313+
314+
- Both triggers stay active at once; use whichever is under your hand.
315+
- The taps must be *clean*: press, release, press, release, with no
316+
other key in between and within `double_tap_ms` of each other
317+
(press to press). Holding the key to type an Option-shortcut or an
318+
accented character never triggers it.
319+
- The modifier is never swallowed — voxtype only observes it — so
320+
Option, Command, and friends keep working everywhere.
321+
- macOS only (it rides on the same event tap as the chord).
322+
299323
## Hotkey notation
300324

301325
Both spellings work everywhere a chord is accepted:

‎packages/voxtype/src/voxtype/app.py‎

Lines changed: 22 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -585,6 +585,7 @@ def sample() -> tuple[str, bool, float]:
585585
on_ready=self._start_hotkeys_from_loop,
586586
flex=self.cfg.overlay_flex,
587587
speed=self.cfg.overlay_speed,
588+
opacity=self.cfg.overlay_opacity,
588589
)
589590
return result["code"]
590591
while not self._stop.is_set():
@@ -667,16 +668,35 @@ def _start_hotkey_listener(self): # noqa: ANN202
667668
)
668669
continue
669670
bindings.append(binding)
670-
if not bindings:
671+
double_tap = None
672+
if self.cfg.double_tap_key:
673+
# Second toggle trigger: two taps of a lone modifier key
674+
# (validated by Config, so no per-binding try here).
675+
double_tap = (
676+
self.cfg.double_tap_key,
677+
self.cfg.double_tap_ms,
678+
self.toggle,
679+
)
680+
if not bindings and double_tap is None:
671681
self._status("no valid hotkeys configured; hotkeys disabled")
672682
return None
673683
try:
674-
return start_hotkeys(bindings)
684+
listener = start_hotkeys(bindings, double_tap=double_tap)
675685
except ValueError as e:
676686
self._status(
677687
f"invalid hotkey config ({e}); hotkeys disabled"
678688
)
679689
return None
690+
if double_tap is not None and getattr(
691+
listener, "double_tap_active", False
692+
):
693+
# Only claim it when the listener actually armed it: the
694+
# non-tap fallback reports the omission itself.
695+
self._status(
696+
f"double-tap {self.cfg.double_tap_key} also toggles "
697+
"recording"
698+
)
699+
return listener
680700

681701
@staticmethod
682702
def _status(msg: str) -> None:

‎packages/voxtype/src/voxtype/config.py‎

Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -80,6 +80,20 @@ def default_segmentation(engine: str, mode: str) -> str:
8080
"medium-streaming",
8181
)
8282

83+
# Modifier keys that may serve as a double-tap toggle trigger. Must stay
84+
# in sync with hotkey.DOUBLE_TAP_KEYS (a test enforces it); kept here so
85+
# config validation never imports the (pynput-backed) hotkey module.
86+
VALID_DOUBLE_TAP_KEYS = (
87+
"left_alt",
88+
"right_alt",
89+
"left_cmd",
90+
"right_cmd",
91+
"left_ctrl",
92+
"right_ctrl",
93+
"left_shift",
94+
"right_shift",
95+
)
96+
8397

8498
@dataclass
8599
class Config:
@@ -111,6 +125,8 @@ class Config:
111125
shape (1.0 = default; higher = more flexible, calmer < 1.0).
112126
overlay_speed: Overall animation speed multiplier (1.0 =
113127
default; lower = slower/gentler motion).
128+
overlay_opacity: Whole-ghost opacity, 0.1 (barely there) to
129+
1.0 (as drawn). Lower it if the ghost hides text under it.
114130
keepalive_minutes: Re-decode a short silent clip after this
115131
many minutes without a decode (parakeet-mlx only). Under
116132
system-wide memory pressure macOS evicts an idle model to
@@ -124,6 +140,14 @@ class Config:
124140
only).
125141
language: Language tag understood by Moonshine (e.g. "en").
126142
hotkey: Global toggle hotkey in pynput syntax, e.g. "<ctrl>+;".
143+
double_tap_key: A second way to toggle recording, alongside
144+
``hotkey``: tap this modifier key twice, alone (e.g.
145+
"right_alt" — the right Option key — for laptop use where
146+
a chord is awkward). One of VALID_DOUBLE_TAP_KEYS; empty
147+
disables. The key is never swallowed, so it keeps working
148+
as a modifier; macOS only.
149+
double_tap_ms: Maximum gap between the two taps, press to
150+
press, in milliseconds (50–2000).
127151
wake_word: Phrase that activates dictation in "wake" mode.
128152
wake_word_aliases: Alternate spellings the transcriber may
129153
produce for the wake word (e.g. "claud", "clawed"); any
@@ -165,10 +189,13 @@ class Config:
165189
overlay: bool = True
166190
overlay_flex: float = 1.0
167191
overlay_speed: float = 1.0
192+
overlay_opacity: float = 1.0
168193
keepalive_minutes: float = 0.0
169194
model_arch: str = "medium-streaming"
170195
language: str = "en"
171196
hotkey: str = "<ctrl>+;"
197+
double_tap_key: str = ""
198+
double_tap_ms: float = 400
172199
wake_word: str = "claude"
173200
wake_word_aliases: list[str] = field(default_factory=list)
174201
stop_phrase: str = "stop listening"
@@ -266,6 +293,40 @@ def validate(self) -> None:
266293
raise ValueError(
267294
f"{name} must be between 0 and 5, got {value!r}"
268295
)
296+
if (
297+
isinstance(self.overlay_opacity, bool)
298+
or not isinstance(self.overlay_opacity, (int, float))
299+
or (
300+
isinstance(self.overlay_opacity, float)
301+
and not math.isfinite(self.overlay_opacity)
302+
)
303+
or not 0.1 <= self.overlay_opacity <= 1.0
304+
):
305+
raise ValueError(
306+
"overlay_opacity must be a number between 0.1 and 1.0, "
307+
f"got {self.overlay_opacity!r}"
308+
)
309+
if not isinstance(self.double_tap_key, str) or (
310+
self.double_tap_key and
311+
self.double_tap_key not in VALID_DOUBLE_TAP_KEYS
312+
):
313+
raise ValueError(
314+
f"double_tap_key must be empty or one of "
315+
f"{VALID_DOUBLE_TAP_KEYS}, got {self.double_tap_key!r}"
316+
)
317+
if (
318+
isinstance(self.double_tap_ms, bool)
319+
or not isinstance(self.double_tap_ms, (int, float))
320+
or (
321+
isinstance(self.double_tap_ms, float)
322+
and not math.isfinite(self.double_tap_ms)
323+
)
324+
or not 50 <= self.double_tap_ms <= 2000
325+
):
326+
raise ValueError(
327+
"double_tap_ms must be a number of milliseconds between "
328+
f"50 and 2000, got {self.double_tap_ms!r}"
329+
)
269330
if isinstance(self.keepalive_minutes, bool) or not isinstance(
270331
self.keepalive_minutes, (int, float)
271332
):
@@ -427,6 +488,9 @@ def sample_config() -> str:
427488
overlay_flex = 1.0
428489
# Overall animation speed (lower = slower, gentler motion).
429490
overlay_speed = 1.0
491+
# Whole-ghost opacity, 0.1–1.0. Lower it (e.g. 0.6) if the ghost hides
492+
# the text underneath it.
493+
overlay_opacity = 1.0
430494
431495
# Remove standalone filler words (uh, um, ...) from typed text.
432496
strip_fillers = true
@@ -454,6 +518,15 @@ def sample_config() -> str:
454518
# "<ctrl>+<alt>+d", "<cmd>+<shift>+v"
455519
hotkey = "<ctrl>+;"
456520
521+
# A second way to toggle recording (macOS): tap a modifier key twice,
522+
# on its own — handy on the laptop keyboard where a chord is awkward.
523+
# One of: left_alt right_alt left_cmd right_cmd left_ctrl right_ctrl
524+
# left_shift right_shift ("alt" is the Option key). The key still works
525+
# normally as a modifier. Empty disables.
526+
double_tap_key = ""
527+
# Max gap between the two taps, press to press, in milliseconds.
528+
double_tap_ms = 400
529+
457530
# Wake word / stop phrase. The wake word is used in "wake" mode; the
458531
# stop phrase deactivates dictation wherever utterances are
459532
# transcribed as you pause (segmentation "vad"). A "hold" take is raw

0 commit comments

Comments
 (0)