Push-to-talk key counts only on its own, not with another modifier held

A modifier already held when the key went down (Shift, then Right Ctrl)
still started or stopped dictation; only keys pressed during the hold
counted as a shortcut. Now any other modifier held on any keyboard at
the press makes it a shortcut too, in tap and hold mode.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
DeeJanuzandClaude Opus 5.5 committed 2026-10-10 08:26:08 -06:00
1 parent 9df7df5f91
commit 1d88af4c6e
4 files changed
+132 -5

No files matched your search

+2 -2
View File
@@ -22,7 +22,7 @@ The mic is captured once with `pw-record`. The wake word spotter (sherpa-onnx op
| Where | How |
| --- | --- |
| Keyboard or mouse | Tap **Right Ctrl** (default) to start, tap again to stop. Shortcuts with it (Ctrl+C) don't start or stop dictation. Hold mode is available in the settings app. |
| Keyboard or mouse | Tap **Right Ctrl** (default) to start, tap again to stop. It counts only on its own: shortcuts with it (Ctrl+C), or with another modifier held when you press it (Shift+Right Ctrl), don't start or stop dictation. Hold mode is available in the settings app. |
| Steam Frame controllers | Hold left **d-pad down** (default), speak, release. Works in games, the Steam UI and the desktop with no per-app setup. The button is observed, not captured, so whatever else it is bound to still happens. |
| Desktop mode shortcut | KDE System Settings > Shortcuts > add a command shortcut running `frame-voice-ctl toggle`. |
| Scripts, the assistant | `frame-voice-ctl toggle`, `start`, `stop`, `cancel`, `type "text"` |
@@ -131,7 +131,7 @@ cmake -B build -G Ninja -DCMAKE_BUILD_TYPE=Release -DGGML_VULKAN=ON -DGGML_NATIV
cmake --build build -j5 --target whisper-server whisper-cli whisper-bench
```
`vendor/prefix` holds SPIRV-Headers, which SteamOS does not ship. Tests: `python3 -m unittest` in the repo (no audio stack needed). The Python side lives in `.venv` (`evdev sherpa-onnx numpy sentencepiece websockets`). `models/sherpa-onnx-moonshine-base-en-quantized-2026-02-27` holds the Moonshine files from huggingface.co/csukuangfj2 (`encoder_model.ort`, `decoder_model_merged.ort`, `tokens.txt`). The settings app uses the system Python for GTK (`gi`) and the venv for the rest; the `frame-voice-config` launcher sets that up.
`vendor/prefix` holds SPIRV-Headers, which SteamOS does not ship. Tests: `.venv/bin/python -m unittest` in the repo (no audio stack needed; with the system `python3` the push-to-talk key tests are skipped, since they need `evdev`). The Python side lives in `.venv` (`evdev sherpa-onnx numpy sentencepiece websockets`). `models/sherpa-onnx-moonshine-base-en-quantized-2026-02-27` holds the Moonshine files from huggingface.co/csukuangfj2 (`encoder_model.ort`, `decoder_model_merged.ort`, `tokens.txt`). The settings app uses the system Python for GTK (`gi`) and the venv for the rest; the `frame-voice-config` launcher sets that up.
The whisper service sets `DISABLE_GAMESCOPE_WSI=1`. Without it the Gamescope Vulkan layer aborts whisper-server at startup.
+8 -2
View File
@@ -1009,8 +1009,14 @@ class FrameVoice:
self._track_modifier(dev, code, value)
if code in self.hotkeys:
if value == 1:
self.hk_down, self.hk_chord = True, False
if self.kb_mode == "hold":
# Only the key on its own: a modifier already held (Shift, then Right Ctrl, or
# Ctrl, then a mouse button) makes it a shortcut, not a dictation gesture.
others = sorted({c for _, c in self.mods_down if c != code})
self.hk_down, self.hk_chord = True, bool(others)
if others:
log.info("hotkey: %s with %s held, not dictation", self.hotkeys[code],
"+".join(e.KEY.get(c, str(c)) for c in others))
elif self.kb_mode == "hold":
self.hk_timer = loop.call_later(self.dcfg["hold_delay"], self._hold_start)
elif value == 0:
self.hk_down = False
+1 -1
View File
@@ -84,7 +84,7 @@ def key_note(name, mode):
if name in settings.MODIFIERS:
if mode == "hold":
return "While it's held, text waits for release (it would turn letters into shortcuts)"
return "Shortcuts with it (like Ctrl+C) don't start or stop dictation"
return "Counts only on its own: not in shortcuts like Ctrl+C, or with another modifier held"
if name.startswith("KEY_") and (len(name) == 5 or name in ("KEY_SPACE", "KEY_ENTER")):
return "This key also types into apps"
return name
+121
View File
@@ -0,0 +1,121 @@
"""The push-to-talk key starts or stops dictation only when pressed on its own.
Needs the venv's Python (evdev): .venv/bin/python -m unittest"""
import asyncio
import sys
import unittest
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
try:
import frame_voice as fv # noqa: E402
except ImportError as err:
raise unittest.SkipTest(f"needs the venv's Python: {err}")
e = fv.e
class Dev:
def __init__(self, path):
self.path = path
KEYBOARD, OTHER, MOUSE = Dev("/dev/input/event3"), Dev("/dev/input/event7"), Dev("/dev/input/event9")
class Daemon(fv.FrameVoice):
"""on_key() and the state it reads, without models, mic or typing."""
def __init__(self, hotkeys, mode):
self.hotkeys = {e.ecodes[name]: name for name in hotkeys}
self.kb_mode = mode
self.dcfg = {"hold_delay": 0.0}
self.hk_down = self.hk_chord = False
self.hk_timer = None
self.mods_down = set()
self.mods_released = asyncio.Event()
self._held_logged = None
self.stream = None
self.recording_source = None
self.calls = []
async def toggle_dictation(self, source):
self.calls.append("toggle")
def start_dictation(self, source):
self.calls.append("start")
self.recording_source = source
async def stop_dictation(self):
self.calls.append("stop")
self.recording_source = None
def cancel_dictation(self, chime=True):
self.calls.append("cancel")
self.recording_source = None
class Hotkey(unittest.IsolatedAsyncioTestCase):
async def press(self, daemon, *events):
"""events: (device, key name, 1 for down or 0 for up)."""
for dev, name, value in events:
daemon.on_key(dev, e.ecodes[name], value)
await asyncio.sleep(0)
await asyncio.sleep(0.01) # the tasks and the hold timer it started
return daemon.calls
async def test_tap_alone_toggles(self):
d = Daemon(["KEY_RIGHTCTRL"], "toggle")
self.assertEqual(await self.press(d, (KEYBOARD, "KEY_RIGHTCTRL", 1), (KEYBOARD, "KEY_RIGHTCTRL", 0)),
["toggle"])
async def test_modifier_held_before(self):
for other in ("KEY_LEFTSHIFT", "KEY_RIGHTSHIFT", "KEY_LEFTALT", "KEY_LEFTMETA", "KEY_LEFTCTRL"):
d = Daemon(["KEY_RIGHTCTRL"], "toggle")
calls = await self.press(d, (KEYBOARD, other, 1), (KEYBOARD, "KEY_RIGHTCTRL", 1),
(KEYBOARD, "KEY_RIGHTCTRL", 0), (KEYBOARD, other, 0))
self.assertEqual(calls, [], other)
async def test_modifier_released_first(self):
d = Daemon(["KEY_RIGHTCTRL"], "toggle")
self.assertEqual(await self.press(d, (KEYBOARD, "KEY_LEFTSHIFT", 1), (KEYBOARD, "KEY_RIGHTCTRL", 1),
(KEYBOARD, "KEY_LEFTSHIFT", 0), (KEYBOARD, "KEY_RIGHTCTRL", 0)), [])
async def test_modifier_held_on_another_keyboard(self):
d = Daemon(["KEY_RIGHTCTRL"], "toggle")
self.assertEqual(await self.press(d, (OTHER, "KEY_LEFTALT", 1), (KEYBOARD, "KEY_RIGHTCTRL", 1),
(KEYBOARD, "KEY_RIGHTCTRL", 0), (OTHER, "KEY_LEFTALT", 0)), [])
async def test_modifier_pressed_during(self):
d = Daemon(["KEY_RIGHTCTRL"], "toggle")
self.assertEqual(await self.press(d, (KEYBOARD, "KEY_RIGHTCTRL", 1), (KEYBOARD, "KEY_LEFTSHIFT", 1),
(KEYBOARD, "KEY_LEFTSHIFT", 0), (KEYBOARD, "KEY_RIGHTCTRL", 0)), [])
async def test_shortcut_with_the_key(self):
d = Daemon(["KEY_RIGHTCTRL"], "toggle")
self.assertEqual(await self.press(d, (KEYBOARD, "KEY_RIGHTCTRL", 1), (KEYBOARD, "KEY_C", 1),
(KEYBOARD, "KEY_C", 0), (KEYBOARD, "KEY_RIGHTCTRL", 0)), [])
async def test_tap_alone_after_a_shortcut(self):
d = Daemon(["KEY_RIGHTCTRL"], "toggle")
await self.press(d, (KEYBOARD, "KEY_LEFTSHIFT", 1), (KEYBOARD, "KEY_RIGHTCTRL", 1),
(KEYBOARD, "KEY_RIGHTCTRL", 0), (KEYBOARD, "KEY_LEFTSHIFT", 0))
self.assertEqual(await self.press(d, (KEYBOARD, "KEY_RIGHTCTRL", 1), (KEYBOARD, "KEY_RIGHTCTRL", 0)),
["toggle"])
async def test_mouse_button_with_ctrl(self):
d = Daemon(["BTN_SIDE"], "toggle")
self.assertEqual(await self.press(d, (KEYBOARD, "KEY_LEFTCTRL", 1), (MOUSE, "BTN_SIDE", 1),
(MOUSE, "BTN_SIDE", 0), (KEYBOARD, "KEY_LEFTCTRL", 0)), [])
self.assertEqual(await self.press(d, (MOUSE, "BTN_SIDE", 1), (MOUSE, "BTN_SIDE", 0)), ["toggle"])
async def test_hold_mode(self):
d = Daemon(["KEY_RIGHTCTRL"], "hold")
self.assertEqual(await self.press(d, (KEYBOARD, "KEY_LEFTSHIFT", 1), (KEYBOARD, "KEY_RIGHTCTRL", 1)), [])
await self.press(d, (KEYBOARD, "KEY_RIGHTCTRL", 0), (KEYBOARD, "KEY_LEFTSHIFT", 0))
self.assertEqual(d.calls, [])
self.assertEqual(await self.press(d, (KEYBOARD, "KEY_RIGHTCTRL", 1)), ["start"])
self.assertEqual(await self.press(d, (KEYBOARD, "KEY_RIGHTCTRL", 0)), ["start", "stop"])
if __name__ == "__main__":
unittest.main()