mirror of
https://github.com/DeeJanuz/frametop.git
synced 2026-10-14 16:00:43 +02:00
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:
1 parent
9df7df5f91
commit
1d88af4c6e
4 files changed
+132
-5
No files matched your search
@@ -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
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
Reference in new issue
Block a user