whisper.linux: add global hotkey, fix X11 injection, improve tray UX

- 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) <noreply@anthropic.com>
This commit is contained in:
nnnet 2026-04-04 18:18:50 +03:00
parent d92c33bc51
commit 200aa7ad0f
7 changed files with 196 additions and 57 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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 "$@"