755 lines
29 KiB
Python
755 lines
29 KiB
Python
"""System tray icon and menu for whisper.linux."""
|
|
|
|
import subprocess
|
|
import threading
|
|
from pathlib import Path
|
|
from typing import TYPE_CHECKING
|
|
|
|
from .config import (
|
|
Config, AppState, AVAILABLE_MODELS, DEFAULT_VOICE_COMMANDS, _REPO_ROOT,
|
|
_list_models, _list_audio_devices, log,
|
|
)
|
|
|
|
if TYPE_CHECKING:
|
|
from .app import WhisperLinuxApp
|
|
|
|
|
|
def _create_icon(color, size=64):
|
|
"""Create a microphone tray icon programmatically via QPainter."""
|
|
from PyQt5.QtCore import Qt, QRect, QPoint
|
|
from PyQt5.QtGui import QPixmap, QPainter, QColor, QBrush, QPen, QIcon
|
|
|
|
pixmap = QPixmap(size, size)
|
|
pixmap.fill(Qt.transparent)
|
|
p = QPainter(pixmap)
|
|
p.setRenderHint(QPainter.Antialiasing)
|
|
|
|
c = QColor(color)
|
|
p.setBrush(QBrush(c))
|
|
p.setPen(QPen(c.darker(130), 2))
|
|
|
|
# Mic body (rounded rect)
|
|
bw, bh = size // 3, size // 2
|
|
bx = (size - bw) // 2
|
|
by = size // 8
|
|
p.drawRoundedRect(bx, by, bw, bh, bw // 3, bw // 3)
|
|
|
|
# Arc below mic
|
|
p.setBrush(Qt.NoBrush)
|
|
p.setPen(QPen(c, max(2, size // 16)))
|
|
arc_w = int(bw * 1.6)
|
|
arc_h = size // 4
|
|
arc_x = (size - arc_w) // 2
|
|
arc_y = by + bh - arc_h // 2
|
|
p.drawArc(arc_x, arc_y, arc_w, arc_h, 0, -180 * 16)
|
|
|
|
# Stem
|
|
mid_x = size // 2
|
|
stem_top = arc_y + arc_h
|
|
stem_bot = stem_top + size // 6
|
|
p.drawLine(mid_x, stem_top, mid_x, stem_bot)
|
|
|
|
# Base
|
|
base_w = size // 3
|
|
p.drawLine(mid_x - base_w // 2, stem_bot, mid_x + base_w // 2, stem_bot)
|
|
|
|
p.end()
|
|
return QIcon(pixmap)
|
|
|
|
|
|
class _CallHelper:
|
|
"""Helper to marshal calls from background threads to Qt main thread."""
|
|
|
|
def __init__(self):
|
|
from PyQt5.QtCore import QObject, pyqtSignal
|
|
|
|
class _Obj(QObject):
|
|
sig = pyqtSignal(object)
|
|
|
|
self._obj = _Obj()
|
|
self._obj.sig.connect(self._run)
|
|
|
|
@staticmethod
|
|
def _run(func):
|
|
func()
|
|
|
|
def call(self, func):
|
|
self._obj.sig.emit(func)
|
|
|
|
|
|
class TrayIcon:
|
|
"""System tray icon with right-click menu."""
|
|
|
|
COLORS = {
|
|
AppState.IDLE: "#888888",
|
|
AppState.RECORDING: "#e53935",
|
|
AppState.PROCESSING: "#ffb300",
|
|
AppState.LISTENING: "#2196F3",
|
|
AppState.DICTATING: "#4CAF50",
|
|
}
|
|
|
|
# Minimum expected sizes (bytes) for downloaded models
|
|
_MODEL_MIN_SIZES = {
|
|
"tiny": 70_000_000,
|
|
"base": 130_000_000,
|
|
"small": 450_000_000,
|
|
"medium": 1_400_000_000,
|
|
"large-v1": 2_900_000_000,
|
|
"large-v2": 2_900_000_000,
|
|
"large-v3": 2_900_000_000,
|
|
"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])
|
|
self.tray.setToolTip("whisper.linux \u2014 Voice Typing")
|
|
self.tray.activated.connect(self._on_activated)
|
|
|
|
self._build_menu()
|
|
self.tray.show()
|
|
|
|
# -- Menu construction --
|
|
|
|
def _build_menu(self):
|
|
from PyQt5.QtWidgets import QMenu, QAction, QActionGroup
|
|
|
|
self.menu = QMenu()
|
|
|
|
self.action_toggle = QAction("Start Recording", self.menu)
|
|
self.action_toggle.triggered.connect(lambda: self._app_ref.toggle())
|
|
self.menu.addAction(self.action_toggle)
|
|
|
|
self.menu.addSeparator()
|
|
|
|
# Language
|
|
lang_menu = self.menu.addMenu("Language")
|
|
self._lang_group = QActionGroup(lang_menu)
|
|
self._lang_group.setExclusive(True)
|
|
for code, label in [("ru", "Russian"), ("en", "English"), ("auto", "Auto-detect")]:
|
|
a = QAction(label, lang_menu, checkable=True)
|
|
a.setData(code)
|
|
if code == self._app_ref.config.language:
|
|
a.setChecked(True)
|
|
a.triggered.connect(self._on_language_changed)
|
|
self._lang_group.addAction(a)
|
|
lang_menu.addAction(a)
|
|
|
|
# Model
|
|
self._model_menu = self.menu.addMenu("Model")
|
|
self._model_group = QActionGroup(self._model_menu)
|
|
self._model_group.setExclusive(True)
|
|
self._rebuild_model_menu()
|
|
|
|
# Settings
|
|
self._build_settings_menu()
|
|
|
|
self.menu.addSeparator()
|
|
|
|
quit_action = QAction("Quit", self.menu)
|
|
quit_action.triggered.connect(self._app_ref.quit)
|
|
self.menu.addAction(quit_action)
|
|
|
|
self.tray.setContextMenu(self.menu)
|
|
|
|
def _rebuild_model_menu(self):
|
|
from PyQt5.QtWidgets import QAction
|
|
|
|
menu = self._model_menu
|
|
menu.clear()
|
|
self._kept_actions.clear()
|
|
for a in self._model_group.actions():
|
|
self._model_group.removeAction(a)
|
|
|
|
current_model = Path(self._app_ref.config.model).name if self._app_ref.config.model else ""
|
|
downloaded = _list_models(self._app_ref.config.model_search_dirs)
|
|
downloaded_names = {name for name, _ in downloaded}
|
|
|
|
if not downloaded and self._app_ref.config.model:
|
|
name = Path(self._app_ref.config.model).stem.replace("ggml-", "")
|
|
downloaded = [(name, self._app_ref.config.model)]
|
|
downloaded_names = {name}
|
|
|
|
for name, path in downloaded:
|
|
size_mb = Path(path).stat().st_size / (1024 * 1024)
|
|
label = f"{name} ({size_mb:.0f} MB)"
|
|
a = QAction(label, menu, checkable=True)
|
|
a.setData(path)
|
|
if Path(path).name == current_model:
|
|
a.setChecked(True)
|
|
a.triggered.connect(self._on_model_changed)
|
|
self._model_group.addAction(a)
|
|
menu.addAction(a)
|
|
|
|
available = [(n, s) for n, s in AVAILABLE_MODELS if n not in downloaded_names]
|
|
if available and downloaded:
|
|
menu.addSeparator()
|
|
|
|
for name, size in available:
|
|
if name in self._downloading:
|
|
a = QAction(f"{name} {size} \u23f3", menu)
|
|
a.setEnabled(False)
|
|
else:
|
|
a = QAction(f"{name} {size} \u2193 Download", menu)
|
|
a.setData(name)
|
|
a.triggered.connect(lambda checked, n=name: self._on_download_model(n))
|
|
menu.addAction(a)
|
|
self._kept_actions.append(a)
|
|
|
|
def _rebuild_wake_model_menu(self):
|
|
from PyQt5.QtWidgets import QAction
|
|
|
|
menu = self._wake_model_menu
|
|
menu.clear()
|
|
for a in self._wake_model_group.actions():
|
|
self._wake_model_group.removeAction(a)
|
|
|
|
config = self._app_ref.config
|
|
same_label = "Same as main model"
|
|
if config.model and Path(config.model).is_file():
|
|
main_mb = Path(config.model).stat().st_size / (1024 * 1024)
|
|
same_label = f"Same as main model ({main_mb:.0f} MB)"
|
|
a = QAction(same_label, menu, checkable=True)
|
|
a.setData("")
|
|
if not config.wake_model:
|
|
a.setChecked(True)
|
|
a.triggered.connect(self._on_wake_model_changed)
|
|
self._wake_model_group.addAction(a)
|
|
menu.addAction(a)
|
|
for name, path in _list_models(config.model_search_dirs):
|
|
size_mb = Path(path).stat().st_size / (1024 * 1024)
|
|
label = f"{name} ({size_mb:.0f} MB)"
|
|
a = QAction(label, menu, checkable=True)
|
|
a.setData(path)
|
|
if path == config.wake_model:
|
|
a.setChecked(True)
|
|
a.triggered.connect(self._on_wake_model_changed)
|
|
self._wake_model_group.addAction(a)
|
|
menu.addAction(a)
|
|
|
|
def _build_settings_menu(self):
|
|
from PyQt5.QtWidgets import QAction, QActionGroup
|
|
|
|
settings_menu = self.menu.addMenu("Settings")
|
|
config = self._app_ref.config
|
|
|
|
# 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")
|
|
self._output_mode_group = QActionGroup(output_menu)
|
|
self._output_mode_group.setExclusive(True)
|
|
for val, label in [("batch", "Batch (all at once)"), ("stream", "Streaming (per segment)")]:
|
|
a = QAction(label, output_menu, checkable=True)
|
|
a.setData(val)
|
|
if val == config.output_mode:
|
|
a.setChecked(True)
|
|
a.triggered.connect(self._on_output_mode_changed)
|
|
self._output_mode_group.addAction(a)
|
|
output_menu.addAction(a)
|
|
|
|
settings_menu.addSeparator()
|
|
|
|
# Threads
|
|
threads_menu = settings_menu.addMenu("Threads")
|
|
self._threads_group = QActionGroup(threads_menu)
|
|
self._threads_group.setExclusive(True)
|
|
for n in [1, 2, 4, 8, 16]:
|
|
a = QAction(str(n), threads_menu, checkable=True)
|
|
a.setData(n)
|
|
if n == config.threads:
|
|
a.setChecked(True)
|
|
a.triggered.connect(self._on_threads_changed)
|
|
self._threads_group.addAction(a)
|
|
threads_menu.addAction(a)
|
|
|
|
# GPU
|
|
gpu_menu = settings_menu.addMenu("GPU")
|
|
self._gpu_group = QActionGroup(gpu_menu)
|
|
self._gpu_group.setExclusive(True)
|
|
gpu_devices = getattr(config, '_gpu_devices', [])
|
|
if gpu_devices:
|
|
for name, idx in gpu_devices:
|
|
label = f"{idx}: {name}"
|
|
a = QAction(label, gpu_menu, checkable=True)
|
|
a.setData(idx)
|
|
if idx == config.gpu_device:
|
|
a.setChecked(True)
|
|
a.triggered.connect(self._on_gpu_changed)
|
|
self._gpu_group.addAction(a)
|
|
gpu_menu.addAction(a)
|
|
else:
|
|
a = QAction(f"Device {config.gpu_device}", gpu_menu, checkable=True)
|
|
a.setData(config.gpu_device)
|
|
a.setChecked(True)
|
|
self._gpu_group.addAction(a)
|
|
gpu_menu.addAction(a)
|
|
|
|
# Audio
|
|
audio_menu = settings_menu.addMenu("Audio")
|
|
self._audio_group = QActionGroup(audio_menu)
|
|
self._audio_group.setExclusive(True)
|
|
audio_devices = _list_audio_devices()
|
|
for label, device_id in audio_devices:
|
|
a = QAction(label, audio_menu, checkable=True)
|
|
a.setData(device_id)
|
|
if device_id == config.audio_device:
|
|
a.setChecked(True)
|
|
a.triggered.connect(self._on_audio_changed)
|
|
self._audio_group.addAction(a)
|
|
audio_menu.addAction(a)
|
|
|
|
# Paste mode
|
|
paste_menu = settings_menu.addMenu("Paste mode")
|
|
self._paste_group = QActionGroup(paste_menu)
|
|
self._paste_group.setExclusive(True)
|
|
for keys, label in [("shift+Insert", "Shift+Insert (universal)"),
|
|
("ctrl+v", "Ctrl+V (regular apps)"),
|
|
("ctrl+shift+v", "Ctrl+Shift+V (terminals)")]:
|
|
a = QAction(label, paste_menu, checkable=True)
|
|
a.setData(keys)
|
|
if keys == config.paste_keys:
|
|
a.setChecked(True)
|
|
a.triggered.connect(self._on_paste_keys_changed)
|
|
self._paste_group.addAction(a)
|
|
paste_menu.addAction(a)
|
|
|
|
settings_menu.addSeparator()
|
|
|
|
# Models directory
|
|
models_dir = config.models_dir or "auto"
|
|
self._models_dir_action = QAction(f"Models: {models_dir}", settings_menu)
|
|
self._models_dir_action.triggered.connect(self._on_models_dir_change)
|
|
settings_menu.addAction(self._models_dir_action)
|
|
|
|
settings_menu.addSeparator()
|
|
|
|
# Wake word
|
|
self._wake_word_action = QAction(f"Wake word: {config.wake_word}", settings_menu)
|
|
self._wake_word_action.triggered.connect(self._on_wake_word_change)
|
|
settings_menu.addAction(self._wake_word_action)
|
|
|
|
# Wake model
|
|
self._wake_model_menu = settings_menu.addMenu("Wake model")
|
|
self._wake_model_group = QActionGroup(self._wake_model_menu)
|
|
self._wake_model_group.setExclusive(True)
|
|
self._rebuild_wake_model_menu()
|
|
|
|
settings_menu.addSeparator()
|
|
|
|
# Silence timeout (inline spinbox)
|
|
from PyQt5.QtWidgets import QWidgetAction, QWidget, QHBoxLayout, QLabel, QDoubleSpinBox
|
|
silence_widget = QWidget()
|
|
silence_layout = QHBoxLayout(silence_widget)
|
|
silence_layout.setContentsMargins(8, 2, 8, 2)
|
|
silence_label = QLabel("Silence timeout:")
|
|
self._silence_spin = QDoubleSpinBox()
|
|
self._silence_spin.setRange(0.5, 300.0)
|
|
self._silence_spin.setSingleStep(0.5)
|
|
self._silence_spin.setDecimals(1)
|
|
self._silence_spin.setSuffix("s")
|
|
self._silence_spin.setValue(config.silence_timeout)
|
|
self._silence_spin.valueChanged.connect(self._on_silence_timeout_spin)
|
|
silence_layout.addWidget(silence_label)
|
|
silence_layout.addWidget(self._silence_spin)
|
|
self._silence_widget_action = QWidgetAction(settings_menu)
|
|
self._silence_widget_action.setDefaultWidget(silence_widget)
|
|
settings_menu.addAction(self._silence_widget_action)
|
|
|
|
# End signal toggle
|
|
self._end_signal_action = QAction("End signal (beep)", settings_menu, checkable=True)
|
|
self._end_signal_action.setChecked(config.end_signal)
|
|
self._end_signal_action.triggered.connect(self._on_end_signal_toggled)
|
|
settings_menu.addAction(self._end_signal_action)
|
|
|
|
# Voice commands toggle
|
|
self._voice_cmd_action = QAction("Voice commands", settings_menu, checkable=True)
|
|
self._voice_cmd_action.setChecked(config.voice_commands)
|
|
self._voice_cmd_action.triggered.connect(self._on_voice_commands_toggled)
|
|
settings_menu.addAction(self._voice_cmd_action)
|
|
|
|
# Voice commands editor
|
|
self._edit_cmds_action = QAction("Edit voice commands...", settings_menu)
|
|
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):
|
|
from PyQt5.QtWidgets import QSystemTrayIcon
|
|
if reason == QSystemTrayIcon.Trigger:
|
|
self._app_ref.toggle()
|
|
|
|
def _on_language_changed(self):
|
|
action = self._lang_group.checkedAction()
|
|
if action:
|
|
self._app_ref.config.language = action.data()
|
|
self._app_ref.config.save()
|
|
log.info("Language changed to: %s", action.data())
|
|
|
|
def _on_model_changed(self):
|
|
action = self._model_group.checkedAction()
|
|
if action:
|
|
self._app_ref.config.model = action.data()
|
|
self._app_ref.config.save()
|
|
self._rebuild_wake_model_menu()
|
|
log.info("Model changed to: %s", action.data())
|
|
|
|
def _on_download_model(self, name):
|
|
if name in self._downloading:
|
|
return
|
|
self._downloading.add(name)
|
|
self._rebuild_model_menu()
|
|
log.info("Download requested: model '%s'", name)
|
|
self.notify("whisper.linux", f"Downloading {name}...")
|
|
t = threading.Thread(target=self._do_download_model, args=(name,), daemon=True)
|
|
t.start()
|
|
|
|
def _marshal_call(self, func):
|
|
"""Schedule a callable on the Qt main thread (thread-safe)."""
|
|
self._call_helper.call(func)
|
|
|
|
def _do_download_model(self, name):
|
|
script = _REPO_ROOT / "models" / "download-ggml-model.sh"
|
|
if not script.is_file():
|
|
self._downloading.discard(name)
|
|
log.error("Download script not found: %s", script)
|
|
self._marshal_call(
|
|
lambda: self.notify("Error", "download-ggml-model.sh not found"))
|
|
return
|
|
models_dir = self._app_ref.config.models_dir
|
|
Path(models_dir).mkdir(parents=True, exist_ok=True)
|
|
model_path = Path(models_dir) / f"ggml-{name}.bin"
|
|
|
|
# Remove incomplete downloads so the script doesn't skip them
|
|
min_size = self._MODEL_MIN_SIZES.get(name, 0)
|
|
if model_path.is_file() and min_size and model_path.stat().st_size < min_size:
|
|
log.warning("Removing incomplete model %s (%d bytes, expected >= %d)",
|
|
model_path, model_path.stat().st_size, min_size)
|
|
model_path.unlink()
|
|
|
|
log.info("Download started: model '%s' \u2192 %s", name, models_dir)
|
|
try:
|
|
result = subprocess.run(
|
|
["bash", str(script), name, models_dir],
|
|
capture_output=True, text=True, timeout=3600,
|
|
)
|
|
if result.returncode != 0:
|
|
log.error("Download script failed (rc=%d): %s",
|
|
result.returncode, result.stderr[:200])
|
|
if model_path.is_file():
|
|
size = model_path.stat().st_size
|
|
if min_size and size < min_size:
|
|
log.error("Downloaded model too small (%d bytes, expected >= %d), removing",
|
|
size, min_size)
|
|
model_path.unlink()
|
|
self._downloading.discard(name)
|
|
msg = f"Download incomplete ({size // 1_000_000}MB), please retry"
|
|
self._marshal_call(lambda: self.notify("Error", msg))
|
|
return
|
|
self._downloading.discard(name)
|
|
size_mb = size / (1024 * 1024)
|
|
log.info("Download complete: model '%s' (%d MB)", name, size_mb)
|
|
msg = f"Model '{name}' ready ({size_mb:.0f} MB)"
|
|
self._marshal_call(lambda: (
|
|
self._rebuild_model_menu(),
|
|
self._rebuild_wake_model_menu(),
|
|
self.notify("whisper.linux", msg),
|
|
))
|
|
else:
|
|
self._downloading.discard(name)
|
|
err = result.stderr[:100]
|
|
log.error("Download failed: model file not found after script: %s", model_path)
|
|
self._marshal_call(
|
|
lambda: self.notify("Error", f"Download failed: {err}"))
|
|
except subprocess.TimeoutExpired:
|
|
self._downloading.discard(name)
|
|
log.error("Download timed out for model '%s' (1 hour limit)", name)
|
|
self._marshal_call(
|
|
lambda: self.notify("Error", f"Download timed out for '{name}' (1 hour limit)"))
|
|
except Exception as e:
|
|
self._downloading.discard(name)
|
|
log.error("Download failed for model '%s': %s", name, e)
|
|
err_str = str(e)
|
|
self._marshal_call(
|
|
lambda: self.notify("Error", f"Download failed: {err_str}"))
|
|
|
|
def _on_threads_changed(self):
|
|
action = self._threads_group.checkedAction()
|
|
if action:
|
|
self._app_ref.config.threads = action.data()
|
|
self._app_ref.config.save()
|
|
log.info("Threads changed to: %d", action.data())
|
|
|
|
def _on_gpu_changed(self):
|
|
action = self._gpu_group.checkedAction()
|
|
if action:
|
|
self._app_ref.config.gpu_device = action.data()
|
|
self._app_ref.config.save()
|
|
log.info("GPU device changed to: %d", action.data())
|
|
|
|
def _on_audio_changed(self):
|
|
action = self._audio_group.checkedAction()
|
|
if action:
|
|
self._app_ref.config.audio_device = action.data()
|
|
self._app_ref.config.save()
|
|
log.info("Audio device changed to: %s", action.data())
|
|
|
|
def _on_paste_keys_changed(self):
|
|
action = self._paste_group.checkedAction()
|
|
if action:
|
|
self._app_ref.config.paste_keys = action.data()
|
|
self._app_ref.config.save()
|
|
log.info("Paste mode changed to: %s", action.data())
|
|
|
|
def _on_models_dir_change(self):
|
|
from PyQt5.QtWidgets import QFileDialog
|
|
current = self._app_ref.config.models_dir or str(Path.home())
|
|
new_dir = QFileDialog.getExistingDirectory(None, "Models directory", current)
|
|
if not new_dir:
|
|
return
|
|
self._app_ref.config.models_dir = new_dir
|
|
self._app_ref.config.save()
|
|
self._models_dir_action.setText(f"Models: {new_dir}")
|
|
self._rebuild_model_menu()
|
|
self._rebuild_wake_model_menu()
|
|
log.info("Models dir changed to: %s", new_dir)
|
|
|
|
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()
|
|
if action:
|
|
old_val = self._app_ref.config.output_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.output_mode = new_val
|
|
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
|
|
text, ok = QInputDialog.getText(None, "Wake Word", "Enter wake word:", text=current)
|
|
if ok and text.strip():
|
|
self._app_ref.config.wake_word = text.strip()
|
|
self._app_ref.config.save()
|
|
self._wake_word_action.setText(f"Wake word: {text.strip()}")
|
|
log.info("Wake word changed to: %s", text.strip())
|
|
|
|
def _on_wake_model_changed(self):
|
|
action = self._wake_model_group.checkedAction()
|
|
if action:
|
|
self._app_ref.config.wake_model = action.data()
|
|
self._app_ref.config.save()
|
|
log.info("Wake model changed to: %s", action.data() or "(same as main)")
|
|
|
|
def _on_silence_timeout_spin(self, value):
|
|
self._app_ref.config.silence_timeout = value
|
|
self._app_ref.config.save()
|
|
log.info("Silence timeout changed to: %.1fs", value)
|
|
|
|
def _on_end_signal_toggled(self):
|
|
self._app_ref.config.end_signal = self._end_signal_action.isChecked()
|
|
self._app_ref.config.save()
|
|
log.info("End signal: %s", self._app_ref.config.end_signal)
|
|
|
|
def _on_voice_commands_toggled(self):
|
|
self._app_ref.config.voice_commands = self._voice_cmd_action.isChecked()
|
|
self._app_ref.config.save()
|
|
log.info("Voice commands: %s", self._app_ref.config.voice_commands)
|
|
|
|
def _on_edit_voice_commands(self):
|
|
from PyQt5.QtWidgets import (
|
|
QDialog, QVBoxLayout, QHBoxLayout, QTableWidget,
|
|
QTableWidgetItem, QPushButton, QHeaderView, QAbstractItemView,
|
|
)
|
|
from PyQt5.QtCore import Qt
|
|
|
|
config = self._app_ref.config
|
|
cmds = dict(config.voice_commands_map)
|
|
|
|
dlg = QDialog()
|
|
dlg.setWindowTitle("Voice Commands")
|
|
dlg.setMinimumSize(450, 400)
|
|
layout = QVBoxLayout(dlg)
|
|
|
|
table = QTableWidget(len(cmds), 2)
|
|
table.setHorizontalHeaderLabels(["Word", "Action"])
|
|
table.horizontalHeader().setSectionResizeMode(QHeaderView.Stretch)
|
|
table.setSelectionBehavior(QAbstractItemView.SelectRows)
|
|
for row, (word, action) in enumerate(cmds.items()):
|
|
table.setItem(row, 0, QTableWidgetItem(word))
|
|
table.setItem(row, 1, QTableWidgetItem(action))
|
|
layout.addWidget(table)
|
|
|
|
btn_layout = QHBoxLayout()
|
|
add_btn = QPushButton("Add")
|
|
remove_btn = QPushButton("Remove")
|
|
reset_btn = QPushButton("Reset defaults")
|
|
btn_layout.addWidget(add_btn)
|
|
btn_layout.addWidget(remove_btn)
|
|
btn_layout.addStretch()
|
|
btn_layout.addWidget(reset_btn)
|
|
layout.addLayout(btn_layout)
|
|
|
|
ok_layout = QHBoxLayout()
|
|
ok_btn = QPushButton("OK")
|
|
cancel_btn = QPushButton("Cancel")
|
|
ok_layout.addStretch()
|
|
ok_layout.addWidget(ok_btn)
|
|
ok_layout.addWidget(cancel_btn)
|
|
layout.addLayout(ok_layout)
|
|
|
|
def add_row():
|
|
row = table.rowCount()
|
|
table.insertRow(row)
|
|
table.setItem(row, 0, QTableWidgetItem(""))
|
|
table.setItem(row, 1, QTableWidgetItem("key:Return"))
|
|
table.editItem(table.item(row, 0))
|
|
|
|
def remove_row():
|
|
rows = sorted({idx.row() for idx in table.selectedIndexes()}, reverse=True)
|
|
for row in rows:
|
|
table.removeRow(row)
|
|
|
|
def reset_defaults():
|
|
table.setRowCount(0)
|
|
for word, action in DEFAULT_VOICE_COMMANDS.items():
|
|
row = table.rowCount()
|
|
table.insertRow(row)
|
|
table.setItem(row, 0, QTableWidgetItem(word))
|
|
table.setItem(row, 1, QTableWidgetItem(action))
|
|
|
|
add_btn.clicked.connect(add_row)
|
|
remove_btn.clicked.connect(remove_row)
|
|
reset_btn.clicked.connect(reset_defaults)
|
|
ok_btn.clicked.connect(dlg.accept)
|
|
cancel_btn.clicked.connect(dlg.reject)
|
|
|
|
if dlg.exec_() == QDialog.Accepted:
|
|
new_cmds = {}
|
|
for row in range(table.rowCount()):
|
|
w = (table.item(row, 0).text() or "").strip().lower()
|
|
a = (table.item(row, 1).text() or "").strip()
|
|
if w and a:
|
|
new_cmds[w] = a
|
|
config.voice_commands_map = new_cmds
|
|
config.save()
|
|
# Update the live VoiceCommands instance
|
|
self._app_ref._voice_commands._commands = new_cmds
|
|
log.info("Voice commands updated: %d entries", len(new_cmds))
|
|
|
|
# -- 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")
|
|
elif state == AppState.RECORDING:
|
|
self.action_toggle.setText("Stop Recording")
|
|
self.tray.setToolTip("whisper.linux \u2014 Recording...")
|
|
elif state == AppState.PROCESSING:
|
|
self.action_toggle.setText("Processing...")
|
|
self.tray.setToolTip("whisper.linux \u2014 Transcribing...")
|
|
elif state == AppState.LISTENING:
|
|
self.action_toggle.setText("Stop Listening")
|
|
self.tray.setToolTip("whisper.linux \u2014 Listening (say wake word)...")
|
|
elif state == AppState.DICTATING:
|
|
self.action_toggle.setText("Stop Dictating")
|
|
self.tray.setToolTip("whisper.linux \u2014 Dictating...")
|
|
|
|
def notify(self, title: str, message: str):
|
|
from PyQt5.QtWidgets import QSystemTrayIcon
|
|
self.tray.showMessage(title, message, QSystemTrayIcon.Information, 3000)
|