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:
parent
d92c33bc51
commit
200aa7ad0f
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 "$@"
|
||||
|
|
|
|||
Loading…
Reference in New Issue