From 200aa7ad0f14370f88107720f7e56d9c102fe1a0 Mon Sep 17 00:00:00 2001 From: nnnet Date: Sat, 4 Apr 2026 18:18:50 +0300 Subject: [PATCH] whisper.linux: add global hotkey, fix X11 injection, improve tray UX MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add global hotkey (Ctrl+Super) via pynput with config support - Fix display_server auto-detection (wayland → x11) - Fix text injection: use xdotool type directly (no clipboard pollution), fall back to clipboard with both PRIMARY+CLIPBOARD selections - Fix tray icon not updating from background threads (pyqtSignal instead of QTimer.singleShot) - Add blinking tray icon for RECORDING/DICTATING states - Fix wake word → DICTATING: save active window at wake time, play end signal on stop, start silence timer immediately - Reduce VAD trailing silence 300ms → 150ms for faster response - Redesign Settings menu: hotkey always active, wake word listening is an independent ON/OFF toggle Co-Authored-By: Claude Opus 4.6 (1M context) --- examples/whisper.linux/app/app.py | 87 ++++++++++++-- examples/whisper.linux/app/audio.py | 2 +- examples/whisper.linux/app/config.py | 3 + examples/whisper.linux/app/injector.py | 17 ++- examples/whisper.linux/app/tray.py | 106 ++++++++++++++---- .../whisper.linux/tests/test_whisper_linux.py | 37 +++--- examples/whisper.linux/whisper-linux | 1 + 7 files changed, 196 insertions(+), 57 deletions(-) diff --git a/examples/whisper.linux/app/app.py b/examples/whisper.linux/app/app.py index 5be8af0d0..3606190d8 100644 --- a/examples/whisper.linux/app/app.py +++ b/examples/whisper.linux/app/app.py @@ -131,7 +131,6 @@ class WhisperLinuxApp: # -- Streaming mode -- def _start_listening(self): - self._save_active_window() self._wake_detector = WakeWordDetector(self.config.wake_word) self._vad = SimpleVAD(self.config, on_speech_end=self._on_speech_end, on_speech_start=self._on_speech_start) @@ -270,11 +269,15 @@ class WhisperLinuxApp: remaining = self._wake_detector.strip_wake_word(text) if self.state == AppState.LISTENING: - self._cancel_silence_timer() + self._save_active_window() self._accumulated_texts.clear() self.state = AppState.DICTATING self._marshal_set_state(AppState.DICTATING) self._play_start_signal() + # Start silence timer immediately so dictation auto-stops + # even if user says nothing after wake word + if self.config.input_mode == "listen": + self._reset_silence_timer() log.info("Streaming: DICTATING (wake word detected)") self._marshal_notify("whisper.linux", "Dictation started") elif self.state == AppState.DICTATING: @@ -288,6 +291,7 @@ class WhisperLinuxApp: self._flush_accumulated_text() self.state = AppState.LISTENING self._marshal_set_state(AppState.LISTENING) + self._play_end_signal() log.info("Streaming: LISTENING (wake word \u2192 stop dictation)") self._marshal_notify("whisper.linux", "Dictation paused") return @@ -300,7 +304,7 @@ class WhisperLinuxApp: self._accumulated_texts.append(text) if self.config.input_mode == "listen": self._reset_silence_timer() - log.debug("Silence timer started after segment (%.1fs)", + log.debug("Silence timer reset after segment (%.1fs)", self.config.silence_timeout) if self.config.notification: preview = text[:80] + ("..." if len(text) > 80 else "") @@ -313,20 +317,15 @@ class WhisperLinuxApp: os.unlink(wav_path) def _marshal_set_state(self, state: AppState): - if self._qt_app: - from PyQt5.QtCore import QTimer - QTimer.singleShot(0, lambda: self._set_state(state)) + if self._tray: + self._tray._call_helper.call(lambda: self._set_state(state)) else: self._set_state(state) def _marshal_notify(self, title: str, message: str): if not self._tray: return - if self._qt_app: - from PyQt5.QtCore import QTimer - QTimer.singleShot(0, lambda: self._tray.notify(title, message)) - else: - self._tray.notify(title, message) + self._tray._call_helper.call(lambda: self._tray.notify(title, message)) def _reset_silence_timer(self): self._cancel_silence_timer() @@ -484,6 +483,70 @@ class WhisperLinuxApp: if self._qt_app: self._qt_app.quit() + # -- Global hotkey -- + + def _start_hotkey_listener(self): + """Start a global keyboard hotkey listener using pynput.""" + hotkey_str = self.config.hotkey + if not hotkey_str: + log.info("No hotkey configured, skipping listener") + return + try: + from pynput import keyboard + except ImportError: + log.warning("pynput not installed — global hotkey disabled") + return + + # Parse hotkey string like "ctrl+Super_L" into pynput keys + key_map = { + "ctrl": keyboard.Key.ctrl_l, + "ctrl_l": keyboard.Key.ctrl_l, + "ctrl_r": keyboard.Key.ctrl_r, + "alt": keyboard.Key.alt_l, + "alt_l": keyboard.Key.alt_l, + "alt_r": keyboard.Key.alt_r, + "shift": keyboard.Key.shift_l, + "shift_l": keyboard.Key.shift_l, + "shift_r": keyboard.Key.shift_r, + "super": keyboard.Key.cmd, + "super_l": keyboard.Key.cmd_l, + "super_r": keyboard.Key.cmd_r, + } + + parts = [p.strip() for p in hotkey_str.split("+")] + combo = set() + for p in parts: + low = p.lower() + if low in key_map: + combo.add(key_map[low]) + elif len(p) == 1: + combo.add(keyboard.KeyCode.from_char(p.lower())) + else: + log.warning("Unknown hotkey part: %r", p) + + if not combo: + log.warning("Could not parse hotkey: %s", hotkey_str) + return + + self._hotkey_pressed = set() + + def on_press(key): + self._hotkey_pressed.add(key) + if combo.issubset(self._hotkey_pressed): + log.info("Hotkey pressed: %s", hotkey_str) + if self._tray: + self._tray._call_helper.call(self.toggle) + + def on_release(key): + self._hotkey_pressed.discard(key) + + self._hotkey_listener = keyboard.Listener( + on_press=on_press, on_release=on_release, + ) + self._hotkey_listener.daemon = True + self._hotkey_listener.start() + log.info("Global hotkey listener started: %s", hotkey_str) + # -- Main entry -- def run(self): @@ -502,6 +565,7 @@ class WhisperLinuxApp: self._tray = TrayIcon(self) self._set_state(AppState.IDLE) + self._start_hotkey_listener() log.info("whisper.linux started (pid %d)", os.getpid()) log.info(" whisper-cli : %s", self.config.whisper_cli) @@ -516,6 +580,7 @@ class WhisperLinuxApp: log.info(" clipboard : %s", self.config.use_clipboard_fallback) log.info(" input_mode : %s", self.config.input_mode) log.info(" output_mode : %s", self.config.output_mode) + log.info(" hotkey : %s", self.config.hotkey) log.info(" wake_word : %s", self.config.wake_word) log.info(" wake_model : %s", self.config.wake_model or "(same as main)") log.info(" voice_cmds : %s", self.config.voice_commands) diff --git a/examples/whisper.linux/app/audio.py b/examples/whisper.linux/app/audio.py index 60de92738..7e76df2d6 100644 --- a/examples/whisper.linux/app/audio.py +++ b/examples/whisper.linux/app/audio.py @@ -132,7 +132,7 @@ class SimpleVAD: """Energy-based Voice Activity Detection on raw s16le PCM frames.""" FRAME_MS = 30 - TRAILING_SILENCE_MS = 300 + TRAILING_SILENCE_MS = 150 def __init__(self, config: Config, on_speech_end=None, on_speech_start=None): self._threshold = config.vad_threshold diff --git a/examples/whisper.linux/app/config.py b/examples/whisper.linux/app/config.py index dabc59843..56ace4739 100644 --- a/examples/whisper.linux/app/config.py +++ b/examples/whisper.linux/app/config.py @@ -170,6 +170,7 @@ class Config: min_speech_ms: int = 500 max_speech_s: float = 30.0 end_signal: bool = True + hotkey: str = "ctrl+Super_L" voice_commands: bool = True def __post_init__(self): @@ -215,6 +216,7 @@ class Config: self.min_speech_ms = cp.getint(sec, "min_speech_ms", fallback=self.min_speech_ms) self.max_speech_s = cp.getfloat(sec, "max_speech_s", fallback=self.max_speech_s) self.end_signal = cp.getboolean(sec, "end_signal", fallback=self.end_signal) + self.hotkey = cp.get(sec, "hotkey", fallback=self.hotkey) self.voice_commands = cp.getboolean(sec, "voice_commands", fallback=self.voice_commands) vc_sec = "voice-commands" @@ -345,6 +347,7 @@ class Config: cp.set(sec, "min_speech_ms", str(self.min_speech_ms)) cp.set(sec, "max_speech_s", str(self.max_speech_s)) cp.set(sec, "end_signal", str(self.end_signal)) + cp.set(sec, "hotkey", self.hotkey) cp.set(sec, "voice_commands", str(self.voice_commands)) vc_sec = "voice-commands" cp.add_section(vc_sec) diff --git a/examples/whisper.linux/app/injector.py b/examples/whisper.linux/app/injector.py index d10762e4c..dc0f77791 100644 --- a/examples/whisper.linux/app/injector.py +++ b/examples/whisper.linux/app/injector.py @@ -29,12 +29,12 @@ class TextInjector: self._inject_x11(text) def _inject_x11(self, text: str): - if self.config.use_clipboard_fallback or not text.isascii(): + if self.config.use_clipboard_fallback: self._inject_clipboard_x11(text) return try: subprocess.run( - ["xdotool", "type", "--clearmodifiers", "--", text], + ["xdotool", "type", "--delay", "12", "--clearmodifiers", "--", text], check=True, timeout=10, ) except (subprocess.CalledProcessError, FileNotFoundError): @@ -42,13 +42,20 @@ class TextInjector: self._inject_clipboard_x11(text) def _inject_clipboard_x11(self, text: str): + encoded = text.encode("utf-8") + # Set both CLIPBOARD and PRIMARY so shift+Insert works everywhere subprocess.run( ["xclip", "-selection", "clipboard"], - input=text.encode("utf-8"), check=True, timeout=5, + input=encoded, check=True, timeout=5, ) - time.sleep(0.1) subprocess.run( - ["xdotool", "key", "--clearmodifiers", "ctrl+v"], + ["xclip", "-selection", "primary"], + input=encoded, check=True, timeout=5, + ) + time.sleep(0.05) + paste_keys = self.config.paste_keys + subprocess.run( + ["xdotool", "key", "--clearmodifiers", paste_keys], check=True, timeout=5, ) diff --git a/examples/whisper.linux/app/tray.py b/examples/whisper.linux/app/tray.py index ec1588046..dab309fa1 100644 --- a/examples/whisper.linux/app/tray.py +++ b/examples/whisper.linux/app/tray.py @@ -100,14 +100,25 @@ class TrayIcon: "large-v3-turbo": 1_500_000_000, } + # States that blink the tray icon + _BLINK_STATES = {AppState.RECORDING, AppState.DICTATING} + _BLINK_INTERVAL_MS = 500 + def __init__(self, app_ref: "WhisperLinuxApp"): from PyQt5.QtWidgets import QSystemTrayIcon + from PyQt5.QtCore import QTimer self._app_ref = app_ref self._icons = {state: _create_icon(color) for state, color in self.COLORS.items()} + self._bright_icon = _create_icon("#FFFFFF") self._downloading = set() self._kept_actions = [] self._call_helper = _CallHelper() + self._blink_on = True + self._current_state = AppState.IDLE + + self._blink_timer = QTimer() + self._blink_timer.timeout.connect(self._on_blink) self.tray = QSystemTrayIcon() self.tray.setIcon(self._icons[AppState.IDLE]) @@ -241,18 +252,17 @@ class TrayIcon: settings_menu = self.menu.addMenu("Settings") config = self._app_ref.config - # Input mode - input_menu = settings_menu.addMenu("Input mode") - self._input_mode_group = QActionGroup(input_menu) - self._input_mode_group.setExclusive(True) - for val, label in [("hotkey", "Hotkey (push-to-talk)"), ("listen", "Listening (wake word)")]: - a = QAction(label, input_menu, checkable=True) - a.setData(val) - if val == config.input_mode: - a.setChecked(True) - a.triggered.connect(self._on_input_mode_changed) - self._input_mode_group.addAction(a) - input_menu.addAction(a) + # Hotkey (always active) + hotkey_label = self._format_hotkey_label(config.hotkey) + self._hotkey_action = QAction(f"Hotkey: {hotkey_label} (change...)", settings_menu) + self._hotkey_action.triggered.connect(self._on_hotkey_change) + settings_menu.addAction(self._hotkey_action) + + # Wake word listening toggle + self._wake_listen_action = QAction("Wake word listening", settings_menu, checkable=True) + self._wake_listen_action.setChecked(config.input_mode == "listen") + self._wake_listen_action.triggered.connect(self._on_wake_listen_toggled) + settings_menu.addAction(self._wake_listen_action) # Output mode output_menu = settings_menu.addMenu("Output mode") @@ -392,6 +402,25 @@ class TrayIcon: self._edit_cmds_action.triggered.connect(self._on_edit_voice_commands) settings_menu.addAction(self._edit_cmds_action) + @staticmethod + def _format_hotkey_label(hotkey: str) -> str: + """Convert internal hotkey string to a human-readable label.""" + parts = hotkey.split("+") + pretty = [] + for p in parts: + low = p.lower() + if low in ("super_l", "super_r", "super"): + pretty.append("Super") + elif low == "ctrl": + pretty.append("Ctrl") + elif low == "alt": + pretty.append("Alt") + elif low == "shift": + pretty.append("Shift") + else: + pretty.append(p) + return "+".join(pretty) + # -- Event handlers -- def _on_activated(self, reason): @@ -534,18 +563,17 @@ class TrayIcon: self._rebuild_wake_model_menu() log.info("Models dir changed to: %s", new_dir) - def _on_input_mode_changed(self): - action = self._input_mode_group.checkedAction() - if action: - old_val = self._app_ref.config.input_mode - new_val = action.data() - if old_val != new_val and self._app_ref.state != AppState.IDLE: - self._app_ref._force_idle() - self._app_ref.config.input_mode = new_val - self._app_ref.config.save() - log.info("Input mode changed to: %s", new_val) - if new_val == "listen" and self._app_ref.state == AppState.IDLE: - self._app_ref.toggle() + def _on_wake_listen_toggled(self): + enabled = self._wake_listen_action.isChecked() + old_val = self._app_ref.config.input_mode + new_val = "listen" if enabled else "hotkey" + if old_val != new_val and self._app_ref.state != AppState.IDLE: + self._app_ref._force_idle() + self._app_ref.config.input_mode = new_val + self._app_ref.config.save() + log.info("Wake word listening: %s (input_mode=%s)", enabled, new_val) + if enabled and self._app_ref.state == AppState.IDLE: + self._app_ref.toggle() def _on_output_mode_changed(self): action = self._output_mode_group.checkedAction() @@ -558,6 +586,19 @@ class TrayIcon: self._app_ref.config.save() log.info("Output mode changed to: %s", new_val) + def _on_hotkey_change(self): + from PyQt5.QtWidgets import QInputDialog + current = self._app_ref.config.hotkey + text, ok = QInputDialog.getText( + None, "Hotkey", "Enter hotkey (e.g. ctrl+Super_L):", text=current, + ) + if ok and text.strip(): + self._app_ref.config.hotkey = text.strip() + self._app_ref.config.save() + label = self._format_hotkey_label(text.strip()) + self._hotkey_action.setText(f"Hotkey: {label} (change...)") + log.info("Hotkey changed to: %s", text.strip()) + def _on_wake_word_change(self): from PyQt5.QtWidgets import QInputDialog current = self._app_ref.config.wake_word @@ -673,8 +714,25 @@ class TrayIcon: # -- State & notifications -- + def _on_blink(self): + self._blink_on = not self._blink_on + state = self._current_state + if self._blink_on: + self.tray.setIcon(self._icons[state]) + else: + self.tray.setIcon(self._bright_icon) + def set_state(self, state: AppState): + self._current_state = state self.tray.setIcon(self._icons[state]) + self._blink_on = True + + if state in self._BLINK_STATES: + if not self._blink_timer.isActive(): + self._blink_timer.start(self._BLINK_INTERVAL_MS) + else: + self._blink_timer.stop() + if state == AppState.IDLE: self.action_toggle.setText("Start Recording") self.tray.setToolTip("whisper.linux \u2014 Idle") diff --git a/examples/whisper.linux/tests/test_whisper_linux.py b/examples/whisper.linux/tests/test_whisper_linux.py index df75dffec..8585abbda 100644 --- a/examples/whisper.linux/tests/test_whisper_linux.py +++ b/examples/whisper.linux/tests/test_whisper_linux.py @@ -406,37 +406,41 @@ class TestTextInjector: inj = wl.TextInjector(mock_config) inj.inject("hello") - assert mock_run.call_count == 2 - # First call: xclip + assert mock_run.call_count == 3 + # First call: xclip clipboard assert mock_run.call_args_list[0][0][0][0] == "xclip" - # Second call: xdotool key ctrl+v - assert mock_run.call_args_list[1][0][0][0] == "xdotool" + # Second call: xclip primary + assert mock_run.call_args_list[1][0][0][0] == "xclip" + # Third call: xdotool key paste + assert mock_run.call_args_list[2][0][0][0] == "xdotool" @patch("app.subprocess.run") - def test_inject_x11_non_ascii_uses_clipboard(self, mock_run, mock_config): - """Non-ASCII text (Cyrillic) auto-switches to clipboard paste.""" + def test_inject_x11_non_ascii_uses_direct_type(self, mock_run, mock_config): + """Non-ASCII text (Cyrillic) uses xdotool type directly (no clipboard).""" mock_run.return_value = MagicMock(returncode=0) inj = wl.TextInjector(mock_config) inj.inject("Привет мир") - assert mock_run.call_count == 2 - assert mock_run.call_args_list[0][0][0][0] == "xclip" - assert mock_run.call_args_list[1][0][0][0] == "xdotool" + assert mock_run.call_count == 1 + cmd = mock_run.call_args_list[0][0][0] + assert cmd[0] == "xdotool" + assert "type" in cmd @patch("app.subprocess.run") def test_inject_x11_xdotool_fails_falls_back(self, mock_run, mock_config): # First call (xdotool type) fails, then clipboard calls succeed mock_run.side_effect = [ subprocess.CalledProcessError(1, "xdotool"), - MagicMock(returncode=0), # xclip + MagicMock(returncode=0), # xclip clipboard + MagicMock(returncode=0), # xclip primary MagicMock(returncode=0), # xdotool key ] inj = wl.TextInjector(mock_config) inj.inject("test") - assert mock_run.call_count == 3 + assert mock_run.call_count == 4 @patch("app.subprocess.run") def test_inject_wayland_wtype(self, mock_run, mock_config_wayland): @@ -1422,8 +1426,8 @@ class TestWhisperLinuxAppStreaming: assert app._silence_timer is None @patch("app.app.AudioStream") - def test_timer_starts_after_injection_not_wake_word(self, mock_stream_cls, mock_config_stream): - """Timer is NOT started when wake word detected, only after text injection.""" + def test_timer_starts_on_wake_word_and_after_injection(self, mock_stream_cls, mock_config_stream): + """Timer is started both when wake word detected AND after text injection.""" mock_stream = MagicMock() mock_stream_cls.return_value = mock_stream @@ -1431,13 +1435,14 @@ class TestWhisperLinuxAppStreaming: app._wake_detector = wl.WakeWordDetector(mock_config_stream.wake_word) app.state = wl.AppState.LISTENING - # Wake word → DICTATING: timer should NOT be set + # Wake word → DICTATING: timer SHOULD be set immediately app.transcriber.transcribe.return_value = "дуняша" app._process_speech_segment(b"\x00" * 32000) assert app.state == wl.AppState.DICTATING - assert app._silence_timer is None + assert app._silence_timer is not None + app._cancel_silence_timer() - # Text injection → timer SHOULD be set + # Text injection → timer SHOULD be reset app.transcriber.transcribe.return_value = "привет мир" app._process_speech_segment(b"\x00" * 32000) assert app._silence_timer is not None diff --git a/examples/whisper.linux/whisper-linux b/examples/whisper.linux/whisper-linux index 8b1d2e221..1fef7b73c 100755 --- a/examples/whisper.linux/whisper-linux +++ b/examples/whisper.linux/whisper-linux @@ -1,5 +1,6 @@ #!/bin/bash # whisper-linux — Voice typing for Linux desktop # Unique process name for easy pkill -f whisper-linux +pkill -f whisper_linux; cd "$(dirname "$0")" exec python3 -m app "$@"