Merge cdbcfb6603 into 1fe009caed
This commit is contained in:
commit
70dae5db98
|
|
@ -0,0 +1,3 @@
|
|||
__pycache__/
|
||||
*.pyc
|
||||
.pytest_cache/
|
||||
|
|
@ -0,0 +1,154 @@
|
|||
# whisper.linux
|
||||
|
||||
Voice typing for Linux desktop using [whisper.cpp](https://github.com/ggerganov/whisper.cpp).
|
||||
|
||||
Transcribes speech from microphone and injects text at the cursor position.
|
||||
Supports X11 and Wayland, hotkey and continuous listening modes, voice commands.
|
||||
|
||||
## Requirements
|
||||
|
||||
- Python 3.10+
|
||||
- PyQt5: `pip install PyQt5`
|
||||
- Built `whisper-cli` from whisper.cpp (see root README)
|
||||
- A GGML model file (e.g. `ggml-base.bin`)
|
||||
- `xdotool` (X11/XWayland) or `wtype` (Wayland) for text injection
|
||||
- `xclip` (X11) or `wl-copy` (Wayland) for clipboard fallback
|
||||
|
||||
## Quick Start
|
||||
|
||||
```bash
|
||||
cd examples/whisper.linux
|
||||
|
||||
# Start
|
||||
./whisper-linux
|
||||
|
||||
# Start with debug logging
|
||||
./whisper-linux --debug
|
||||
|
||||
# Toggle recording on a running instance
|
||||
./whisper-linux --toggle
|
||||
|
||||
# Stop
|
||||
pkill -f whisper-linux
|
||||
```
|
||||
|
||||
## CLI Options
|
||||
|
||||
```
|
||||
--toggle Send toggle signal to running instance
|
||||
--language LANG Override language (ru, en, auto)
|
||||
--model PATH Override model path
|
||||
--input-mode MODE hotkey or listen
|
||||
--output-mode MODE batch or stream
|
||||
--stream Shortcut for --output-mode stream
|
||||
--wake-word WORD Override wake word (for listen mode)
|
||||
--wake-model PATH Lighter model for wake word detection
|
||||
--debug Enable debug logging
|
||||
```
|
||||
|
||||
## Input / Output Modes
|
||||
|
||||
Two independent axes control behavior:
|
||||
|
||||
| | batch | stream |
|
||||
|---|---|---|
|
||||
| **hotkey** | Record all, transcribe, inject at once | Each speech segment transcribed and injected live |
|
||||
| **listen** | Wake word activates, text accumulated, injected on stop | Wake word activates, each segment injected live |
|
||||
|
||||
Default: `hotkey` + `batch` (press hotkey to record, press again to transcribe).
|
||||
|
||||
## Voice Commands
|
||||
|
||||
When `voice_commands = True` (default), spoken command words trigger key presses
|
||||
instead of being typed literally. Editable via tray menu: Settings > Edit voice commands.
|
||||
|
||||
| Word (EN) | Word (RU) | Action |
|
||||
|---|---|---|
|
||||
| enter | энтер, ввод | Press Enter |
|
||||
| backspace | бэкспейс, назад | Delete previous word |
|
||||
| tab | таб, табуляция | Press Tab |
|
||||
| escape, stop | эскейп, стоп | Press Escape |
|
||||
|
||||
Commands use fuzzy matching (threshold 0.75), so slight mispronunciations are tolerated.
|
||||
|
||||
**Backspace** has special behavior: if there are buffered words not yet injected,
|
||||
it removes the last word from the buffer. If the buffer is empty, it sends
|
||||
`Ctrl+BackSpace` to delete the previous word in the editor.
|
||||
|
||||
## Keyboard Shortcut
|
||||
|
||||
In hotkey mode, you toggle recording via `--toggle`. Set up a global keyboard shortcut
|
||||
to trigger it from anywhere:
|
||||
|
||||
**GNOME** (Settings → Keyboard → Custom Shortcuts → Add):
|
||||
|
||||
| Field | Value |
|
||||
|---|---|
|
||||
| Name | whisper-linux |
|
||||
| Command | `/path/to/whisper.linux/whisper-linux --toggle` |
|
||||
| Shortcut | `Super+V` or any key you prefer |
|
||||
|
||||
**Or via command line (GNOME):**
|
||||
|
||||
```bash
|
||||
# Replace /path/to/whisper.linux with the actual path
|
||||
TOGGLE_CMD="/path/to/whisper.linux/whisper-linux --toggle"
|
||||
|
||||
gsettings set org.gnome.settings-daemon.plugins.media-keys custom-keybindings \
|
||||
"['/org/gnome/settings-daemon/plugins/media-keys/custom-keybindings/whisper-linux/']"
|
||||
|
||||
dconf write /org/gnome/settings-daemon/plugins/media-keys/custom-keybindings/whisper-linux/name "'whisper-linux'"
|
||||
dconf write /org/gnome/settings-daemon/plugins/media-keys/custom-keybindings/whisper-linux/command "'$TOGGLE_CMD'"
|
||||
dconf write /org/gnome/settings-daemon/plugins/media-keys/custom-keybindings/whisper-linux/binding "'F8'"
|
||||
```
|
||||
|
||||
**KDE:** System Settings → Shortcuts → Custom Shortcuts → Add.
|
||||
|
||||
## Configuration
|
||||
|
||||
Config file: `~/.config/whisper-linux/config.ini`
|
||||
|
||||
All settings are configurable via the system tray menu (right-click the tray icon).
|
||||
|
||||
## Autostart
|
||||
|
||||
Copy the desktop file to autostart:
|
||||
|
||||
```bash
|
||||
cp whisper-linux.desktop ~/.config/autostart/
|
||||
```
|
||||
|
||||
## Project Structure
|
||||
|
||||
```
|
||||
whisper.linux/
|
||||
whisper-linux # Launcher script (unique process name)
|
||||
app/ # Python package
|
||||
__init__.py # Re-exports public API
|
||||
__main__.py # Entry point
|
||||
config.py # Config, AppState, constants, helpers
|
||||
audio.py # AudioRecorder, AudioStream, SimpleVAD
|
||||
transcriber.py # Transcriber, WakeWordDetector
|
||||
injector.py # TextInjector (xdotool/wtype/clipboard)
|
||||
commands.py # VoiceCommands (Enter, Backspace, etc.)
|
||||
tray.py # TrayIcon, system tray menu, settings
|
||||
app.py # WhisperLinuxApp, state machine, CLI
|
||||
tests/
|
||||
conftest.py # Fixtures
|
||||
test_whisper_linux.py # Tests (all mocked, no hardware needed)
|
||||
run_tests.sh # Run all tests with one command
|
||||
whisper-linux.desktop # Desktop entry for autostart
|
||||
```
|
||||
|
||||
## Running Tests
|
||||
|
||||
```bash
|
||||
# All tests
|
||||
./run_tests.sh
|
||||
|
||||
# With options
|
||||
./run_tests.sh --debug -x -k "test_toggle"
|
||||
|
||||
# Or directly
|
||||
python3 -m pytest tests/ -v
|
||||
```
|
||||
|
|
@ -0,0 +1,21 @@
|
|||
"""whisper.linux — Voice typing for Linux desktop."""
|
||||
|
||||
# Re-export public API for backward compatibility (tests import from whisper_linux)
|
||||
import os
|
||||
import subprocess
|
||||
import threading
|
||||
import time
|
||||
|
||||
from .config import (
|
||||
Config, AppState, CONFIG_DIR, CONFIG_FILE, PID_FILE,
|
||||
AVAILABLE_MODELS, DEFAULT_VOICE_COMMANDS, _REPO_ROOT,
|
||||
_find_executable, _find_model, _list_models, _list_audio_devices,
|
||||
_WHISPER_CLI_NAMES, _WHISPER_SEARCH_DIRS, _MODEL_SEARCH_DIRS,
|
||||
LOG_FORMAT, log,
|
||||
)
|
||||
from .audio import AudioRecorder, AudioStream, SimpleVAD, _write_wav
|
||||
from .transcriber import Transcriber, WakeWordDetector, _is_hallucination
|
||||
from .injector import TextInjector
|
||||
from .commands import VoiceCommands
|
||||
from .tray import TrayIcon, _create_icon
|
||||
from .app import WhisperLinuxApp, send_toggle, main
|
||||
|
|
@ -0,0 +1,4 @@
|
|||
"""Entry point for `python3 -m whisper_linux`."""
|
||||
from .app import main
|
||||
|
||||
main()
|
||||
|
|
@ -0,0 +1,750 @@
|
|||
"""Main application logic, state machine, and CLI for whisper.linux."""
|
||||
|
||||
import argparse
|
||||
import logging
|
||||
import os
|
||||
import queue
|
||||
import signal
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import threading
|
||||
import time
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
from . import config as _cfg
|
||||
from .config import Config, AppState, log
|
||||
from .audio import AudioRecorder, AudioStream, SimpleVAD, _write_wav
|
||||
from .transcriber import Transcriber, WakeWordDetector
|
||||
from .injector import TextInjector
|
||||
from .commands import VoiceCommands
|
||||
from .tray import TrayIcon
|
||||
|
||||
|
||||
class WhisperLinuxApp:
|
||||
"""Main application: state machine, threading, PID file, signal handling."""
|
||||
|
||||
def __init__(self, config: Optional[Config] = None):
|
||||
self.config = config or Config()
|
||||
self.state = AppState.IDLE
|
||||
self.recorder = AudioRecorder(self.config)
|
||||
self.transcriber = Transcriber(self.config)
|
||||
self.injector = TextInjector(self.config)
|
||||
self._tray: Optional[TrayIcon] = None
|
||||
self._qt_app = None
|
||||
self._prev_window_id: Optional[str] = None
|
||||
self._audio_stream: Optional[AudioStream] = None
|
||||
self._vad: Optional[SimpleVAD] = None
|
||||
self._wake_detector: Optional[WakeWordDetector] = None
|
||||
self._silence_timer: Optional[threading.Timer] = None
|
||||
self._segment_queue: Optional[queue.Queue] = None
|
||||
self._segment_worker: Optional[threading.Thread] = None
|
||||
self._accumulated_texts: list = []
|
||||
self._voice_commands = VoiceCommands(self.config.voice_commands_map)
|
||||
self._mute_until: float = 0
|
||||
|
||||
def _inject_text(self, text: str):
|
||||
"""Inject text, processing voice commands if enabled."""
|
||||
if self.config.voice_commands:
|
||||
self._voice_commands.process(
|
||||
text, self.injector.inject, self.injector.send_key,
|
||||
)
|
||||
else:
|
||||
self.injector.inject(text)
|
||||
|
||||
# -- PID file management --
|
||||
|
||||
@staticmethod
|
||||
def write_pid():
|
||||
_cfg.PID_FILE.write_text(str(os.getpid()))
|
||||
|
||||
@staticmethod
|
||||
def read_pid() -> Optional[int]:
|
||||
if _cfg.PID_FILE.exists():
|
||||
try:
|
||||
pid = int(_cfg.PID_FILE.read_text().strip())
|
||||
os.kill(pid, 0)
|
||||
return pid
|
||||
except (ValueError, ProcessLookupError, PermissionError):
|
||||
return None
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
def remove_pid():
|
||||
_cfg.PID_FILE.unlink(missing_ok=True)
|
||||
|
||||
# -- Signal handling --
|
||||
|
||||
def _setup_signals(self):
|
||||
signal.signal(signal.SIGUSR1, self._on_sigusr1)
|
||||
signal.signal(signal.SIGTERM, self._on_sigterm)
|
||||
signal.signal(signal.SIGINT, self._on_sigterm)
|
||||
|
||||
def _on_sigusr1(self, signum, frame):
|
||||
log.info("Received SIGUSR1 \u2014 toggling")
|
||||
if self._qt_app:
|
||||
from PyQt5.QtCore import QTimer
|
||||
QTimer.singleShot(0, self.toggle)
|
||||
|
||||
def _on_sigterm(self, signum, frame):
|
||||
log.info("Received %s \u2014 quitting", signal.Signals(signum).name)
|
||||
self.quit()
|
||||
|
||||
# -- State machine --
|
||||
|
||||
def toggle(self):
|
||||
log.info("Toggle: state=%s, input=%s, output=%s",
|
||||
self.state.value, self.config.input_mode, self.config.output_mode)
|
||||
if self.config.input_mode == "listen":
|
||||
# In listen mode, hotkey acts as manual dictation trigger
|
||||
if self.state == AppState.IDLE:
|
||||
self._start_listening()
|
||||
elif self.state == AppState.LISTENING:
|
||||
# Hotkey starts dictation (like wake word)
|
||||
self._save_active_window()
|
||||
self._accumulated_texts.clear()
|
||||
self._cancel_silence_timer()
|
||||
self.state = AppState.DICTATING
|
||||
self._set_state(AppState.DICTATING)
|
||||
self._play_start_signal()
|
||||
self._reset_silence_timer()
|
||||
log.info("Hotkey: LISTENING → DICTATING")
|
||||
elif self.state == AppState.DICTATING:
|
||||
# Hotkey stops dictation back to listening
|
||||
self._cancel_silence_timer()
|
||||
self._flush_accumulated_text()
|
||||
self.state = AppState.LISTENING
|
||||
self._set_state(AppState.LISTENING)
|
||||
self._play_end_signal()
|
||||
log.info("Hotkey: DICTATING → LISTENING")
|
||||
else:
|
||||
self._toggle_listen()
|
||||
else:
|
||||
self._toggle_hotkey()
|
||||
|
||||
def _toggle_hotkey(self):
|
||||
if self.state == AppState.IDLE:
|
||||
if self.config.output_mode == "batch":
|
||||
self._start_recording()
|
||||
else:
|
||||
self._start_hotkey_stream()
|
||||
elif self.state == AppState.RECORDING:
|
||||
self._stop_recording()
|
||||
elif self.state == AppState.DICTATING:
|
||||
self._stop_hotkey_stream()
|
||||
|
||||
def _toggle_listen(self):
|
||||
if self.state == AppState.IDLE:
|
||||
self._start_listening()
|
||||
elif self.state in (AppState.LISTENING, AppState.DICTATING):
|
||||
self._stop_listening()
|
||||
|
||||
def _force_idle(self):
|
||||
if self.state == AppState.RECORDING:
|
||||
self.recorder.stop()
|
||||
elif self.state in (AppState.LISTENING, AppState.DICTATING):
|
||||
if self.config.input_mode == "hotkey" and self.state == AppState.DICTATING:
|
||||
self._stop_hotkey_stream()
|
||||
return
|
||||
self._stop_listening()
|
||||
self._set_state(AppState.IDLE)
|
||||
|
||||
# -- Streaming mode --
|
||||
|
||||
def _start_listening(self):
|
||||
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)
|
||||
self._audio_stream = AudioStream(self.config)
|
||||
self._segment_queue = queue.Queue()
|
||||
self._segment_worker = threading.Thread(
|
||||
target=self._segment_worker_loop, daemon=True,
|
||||
)
|
||||
self._segment_worker.start()
|
||||
try:
|
||||
self._audio_stream.start(on_data=self._on_audio_data)
|
||||
self._set_state(AppState.LISTENING)
|
||||
log.info("Streaming: LISTENING (waiting for wake word '%s')", self.config.wake_word)
|
||||
except Exception as e:
|
||||
log.error("Failed to start audio stream: %s", e)
|
||||
if self._tray:
|
||||
self._tray.notify("Error", f"Stream failed: {e}")
|
||||
if self._segment_queue:
|
||||
self._segment_queue.put(None)
|
||||
self._segment_worker = None
|
||||
self._segment_queue = None
|
||||
|
||||
def _stop_listening(self):
|
||||
self._cancel_silence_timer()
|
||||
self._flush_accumulated_text()
|
||||
if self._audio_stream:
|
||||
self._audio_stream.stop()
|
||||
self._audio_stream = None
|
||||
self._vad = None
|
||||
self._wake_detector = None
|
||||
if self._segment_queue:
|
||||
self._segment_queue.put(None)
|
||||
if self._segment_worker:
|
||||
self._segment_worker.join(timeout=5)
|
||||
self._segment_worker = None
|
||||
self._segment_queue = None
|
||||
self._set_state(AppState.IDLE)
|
||||
log.info("Streaming: stopped")
|
||||
|
||||
def _start_hotkey_stream(self):
|
||||
self._save_active_window()
|
||||
self._wake_detector = None
|
||||
self._vad = SimpleVAD(self.config, on_speech_end=self._on_speech_end,
|
||||
on_speech_start=self._on_speech_start)
|
||||
self._audio_stream = AudioStream(self.config)
|
||||
self._accumulated_texts.clear()
|
||||
self._segment_queue = queue.Queue()
|
||||
self._segment_worker = threading.Thread(
|
||||
target=self._segment_worker_loop, daemon=True,
|
||||
)
|
||||
self._segment_worker.start()
|
||||
try:
|
||||
self._audio_stream.start(on_data=self._on_audio_data)
|
||||
self._set_state(AppState.DICTATING)
|
||||
self._play_start_signal()
|
||||
log.info("Hotkey+Stream: DICTATING started")
|
||||
except Exception as e:
|
||||
log.error("Failed to start audio stream: %s", e)
|
||||
if self._tray:
|
||||
self._tray.notify("Error", f"Stream failed: {e}")
|
||||
if self._segment_queue:
|
||||
self._segment_queue.put(None)
|
||||
self._segment_worker = None
|
||||
self._segment_queue = None
|
||||
|
||||
def _stop_hotkey_stream(self):
|
||||
self._cancel_silence_timer()
|
||||
self._flush_accumulated_text()
|
||||
if self._audio_stream:
|
||||
self._audio_stream.stop()
|
||||
self._audio_stream = None
|
||||
self._vad = None
|
||||
if self._segment_queue:
|
||||
self._segment_queue.put(None)
|
||||
if self._segment_worker:
|
||||
self._segment_worker.join(timeout=5)
|
||||
self._segment_worker = None
|
||||
self._segment_queue = None
|
||||
self._set_state(AppState.IDLE)
|
||||
self._play_end_signal()
|
||||
log.info("Hotkey+Stream: stopped")
|
||||
|
||||
def _flush_accumulated_text(self):
|
||||
if self._accumulated_texts:
|
||||
all_text = " ".join(self._accumulated_texts)
|
||||
self._restore_active_window()
|
||||
self._inject_text(all_text)
|
||||
self._accumulated_texts.clear()
|
||||
|
||||
def _on_audio_data(self, chunk: bytes):
|
||||
if self._mute_until and time.time() < self._mute_until:
|
||||
return
|
||||
if self._vad:
|
||||
self._vad.feed(chunk)
|
||||
|
||||
def _on_speech_start(self):
|
||||
if self.state == AppState.DICTATING:
|
||||
self._cancel_silence_timer()
|
||||
log.debug("Silence timer cancelled (speech started)")
|
||||
|
||||
def _on_speech_end(self, pcm_data: bytes):
|
||||
if self.state == AppState.DICTATING:
|
||||
self._cancel_silence_timer()
|
||||
if self._segment_queue:
|
||||
self._segment_queue.put(pcm_data)
|
||||
|
||||
def _segment_worker_loop(self):
|
||||
while True:
|
||||
try:
|
||||
pcm_data = self._segment_queue.get(timeout=1.0)
|
||||
except queue.Empty:
|
||||
if not self._segment_queue:
|
||||
break
|
||||
continue
|
||||
if pcm_data is None:
|
||||
break
|
||||
self._process_speech_segment(pcm_data)
|
||||
|
||||
def _process_speech_segment(self, pcm_data: bytes):
|
||||
duration_s = len(pcm_data) / (16000 * 2)
|
||||
fd, wav_path = tempfile.mkstemp(suffix=".wav", prefix="whisper-stream-")
|
||||
os.close(fd)
|
||||
try:
|
||||
_write_wav(wav_path, pcm_data)
|
||||
model = None
|
||||
if self.state == AppState.LISTENING and self.config.wake_model:
|
||||
model = self.config.wake_model
|
||||
text = self.transcriber.transcribe(wav_path, model=model,
|
||||
duration_s=duration_s)
|
||||
if not text:
|
||||
return
|
||||
|
||||
log.info("Stream segment: %r (state=%s)", text[:80], self.state.value)
|
||||
|
||||
if self._wake_detector and self._wake_detector.contains_wake_word(text):
|
||||
remaining = self._wake_detector.strip_wake_word(text)
|
||||
|
||||
if self.state == AppState.LISTENING:
|
||||
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:
|
||||
self._cancel_silence_timer()
|
||||
if remaining:
|
||||
if self.config.output_mode == "stream":
|
||||
self._restore_active_window()
|
||||
self._inject_text(remaining)
|
||||
else:
|
||||
self._accumulated_texts.append(remaining)
|
||||
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
|
||||
|
||||
if self.state == AppState.DICTATING:
|
||||
if self.config.output_mode == "stream":
|
||||
self._restore_active_window()
|
||||
self._inject_text(text)
|
||||
else:
|
||||
self._accumulated_texts.append(text)
|
||||
if self.config.input_mode == "listen":
|
||||
self._reset_silence_timer()
|
||||
log.debug("Silence timer reset after segment (%.1fs)",
|
||||
self.config.silence_timeout)
|
||||
if self.config.notification:
|
||||
preview = text[:80] + ("..." if len(text) > 80 else "")
|
||||
self._marshal_notify("whisper.linux", preview)
|
||||
|
||||
except Exception as e:
|
||||
log.error("Stream segment processing failed: %s", e)
|
||||
finally:
|
||||
if os.path.exists(wav_path):
|
||||
os.unlink(wav_path)
|
||||
|
||||
def _marshal_set_state(self, state: AppState):
|
||||
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
|
||||
self._tray._call_helper.call(lambda: self._tray.notify(title, message))
|
||||
|
||||
def _reset_silence_timer(self):
|
||||
self._cancel_silence_timer()
|
||||
self._silence_timer = threading.Timer(
|
||||
self.config.silence_timeout,
|
||||
self._on_silence_timeout,
|
||||
)
|
||||
self._silence_timer.daemon = True
|
||||
self._silence_timer.start()
|
||||
|
||||
def _cancel_silence_timer(self):
|
||||
if self._silence_timer:
|
||||
self._silence_timer.cancel()
|
||||
self._silence_timer = None
|
||||
|
||||
def _on_silence_timeout(self):
|
||||
if self.state == AppState.DICTATING:
|
||||
if self._vad and self._vad._in_speech:
|
||||
log.debug("Silence timeout skipped (speech active), restarting")
|
||||
self._reset_silence_timer()
|
||||
return
|
||||
log.info("Streaming: silence timeout \u2192 LISTENING")
|
||||
self._flush_accumulated_text()
|
||||
self.state = AppState.LISTENING
|
||||
self._marshal_set_state(AppState.LISTENING)
|
||||
self._marshal_notify("whisper.linux", "Dictation paused (silence)")
|
||||
self._play_end_signal()
|
||||
|
||||
def _play_sound(self, sounds: tuple):
|
||||
"""Play the first available sound file."""
|
||||
players = ("pw-play", "paplay", "canberra-gtk-play")
|
||||
for sound in sounds:
|
||||
if not os.path.isfile(sound):
|
||||
continue
|
||||
for player in players:
|
||||
try:
|
||||
subprocess.Popen(
|
||||
[player, sound],
|
||||
stdout=subprocess.DEVNULL,
|
||||
stderr=subprocess.DEVNULL,
|
||||
)
|
||||
log.debug("Sound played via %s: %s", player, sound)
|
||||
return
|
||||
except FileNotFoundError:
|
||||
continue
|
||||
log.debug("No sound player available")
|
||||
|
||||
def _play_start_signal(self):
|
||||
if not self.config.end_signal:
|
||||
return
|
||||
self._mute_until = time.time() + 0.6
|
||||
if self._vad:
|
||||
self._vad.reset()
|
||||
self._play_sound((
|
||||
"/usr/share/sounds/freedesktop/stereo/message-new-instant.oga",
|
||||
"/usr/share/sounds/freedesktop/stereo/message.oga",
|
||||
"/usr/share/sounds/freedesktop/stereo/bell.oga",
|
||||
))
|
||||
|
||||
def _play_end_signal(self):
|
||||
if not self.config.end_signal:
|
||||
return
|
||||
self._mute_until = time.time() + 0.6
|
||||
if self._vad:
|
||||
self._vad.reset()
|
||||
self._play_sound((
|
||||
"/usr/share/sounds/freedesktop/stereo/complete.oga",
|
||||
"/usr/share/sounds/freedesktop/stereo/bell.oga",
|
||||
"/usr/share/sounds/freedesktop/stereo/message.oga",
|
||||
))
|
||||
|
||||
def _set_state(self, state: AppState):
|
||||
self.state = state
|
||||
if self._tray:
|
||||
self._tray.set_state(state)
|
||||
|
||||
def _save_active_window(self):
|
||||
try:
|
||||
result = subprocess.run(
|
||||
["xdotool", "getactivewindow"],
|
||||
capture_output=True, text=True, timeout=2,
|
||||
)
|
||||
if result.returncode == 0 and result.stdout.strip():
|
||||
self._prev_window_id = result.stdout.strip()
|
||||
log.debug("Saved active window: %s", self._prev_window_id)
|
||||
except Exception:
|
||||
self._prev_window_id = None
|
||||
|
||||
def _restore_active_window(self):
|
||||
if not self._prev_window_id:
|
||||
return
|
||||
try:
|
||||
subprocess.run(
|
||||
["xdotool", "windowactivate", "--sync", self._prev_window_id],
|
||||
check=True, timeout=3,
|
||||
)
|
||||
time.sleep(0.15)
|
||||
log.debug("Restored focus to window: %s", self._prev_window_id)
|
||||
except Exception as e:
|
||||
log.debug("Could not restore window focus: %s", e)
|
||||
|
||||
def _start_recording(self):
|
||||
self._save_active_window()
|
||||
try:
|
||||
self.recorder.start()
|
||||
self._set_state(AppState.RECORDING)
|
||||
except Exception as e:
|
||||
log.error("Failed to start recording: %s", e)
|
||||
if self._tray:
|
||||
self._tray.notify("Error", f"Recording failed: {e}")
|
||||
|
||||
def _stop_recording(self):
|
||||
wav_path = self.recorder.stop()
|
||||
if not wav_path:
|
||||
self._set_state(AppState.IDLE)
|
||||
return
|
||||
self._set_state(AppState.PROCESSING)
|
||||
t = threading.Thread(target=self._transcribe_and_inject, args=(wav_path,), daemon=True)
|
||||
t.start()
|
||||
|
||||
def _transcribe_and_inject(self, wav_path: str):
|
||||
try:
|
||||
text = self.transcriber.transcribe(wav_path)
|
||||
if text:
|
||||
self._restore_active_window()
|
||||
self._inject_text(text)
|
||||
if self._tray and self.config.notification:
|
||||
preview = text[:80] + ("..." if len(text) > 80 else "")
|
||||
self._tray.notify("whisper.linux", preview)
|
||||
else:
|
||||
log.info("Empty transcription")
|
||||
if self._tray:
|
||||
self._tray.notify("whisper.linux", "(no speech detected)")
|
||||
except Exception as e:
|
||||
log.error("Transcription/injection failed: %s", e)
|
||||
if self._tray:
|
||||
self._tray.notify("Error", str(e)[:100])
|
||||
finally:
|
||||
if os.path.exists(wav_path):
|
||||
os.unlink(wav_path)
|
||||
self._set_state(AppState.IDLE)
|
||||
self._play_end_signal()
|
||||
|
||||
def quit(self):
|
||||
log.info("Shutting down")
|
||||
self._cancel_silence_timer()
|
||||
if self._audio_stream:
|
||||
self._audio_stream.stop()
|
||||
if self._segment_queue:
|
||||
self._segment_queue.put(None)
|
||||
if self._segment_worker:
|
||||
self._segment_worker.join(timeout=3)
|
||||
self.recorder.cleanup()
|
||||
self.remove_pid()
|
||||
if self._qt_app:
|
||||
self._qt_app.quit()
|
||||
|
||||
# -- Global hotkey --
|
||||
|
||||
def _start_hotkey_listener(self):
|
||||
"""Start a global keyboard hotkey listener using Xlib XRecord."""
|
||||
hotkey_str = self.config.hotkey
|
||||
if not hotkey_str:
|
||||
log.info("No hotkey configured, skipping listener")
|
||||
return
|
||||
|
||||
try:
|
||||
from Xlib import X, XK, display
|
||||
from Xlib.ext import record
|
||||
from Xlib.protocol import rq
|
||||
except ImportError:
|
||||
log.warning("python-xlib not installed — global hotkey disabled")
|
||||
return
|
||||
|
||||
# Parse hotkey string into X11 keycodes
|
||||
keysym_map = {
|
||||
"ctrl": XK.XK_Control_L, "ctrl_l": XK.XK_Control_L,
|
||||
"ctrl_r": XK.XK_Control_R,
|
||||
"alt": XK.XK_Alt_L, "alt_l": XK.XK_Alt_L, "alt_r": XK.XK_Alt_R,
|
||||
"shift": XK.XK_Shift_L, "shift_l": XK.XK_Shift_L,
|
||||
"shift_r": XK.XK_Shift_R,
|
||||
"super": XK.XK_Super_L, "super_l": XK.XK_Super_L,
|
||||
"super_r": XK.XK_Super_R,
|
||||
}
|
||||
|
||||
d = display.Display()
|
||||
combo_keycodes = set()
|
||||
parts = [p.strip() for p in hotkey_str.split("+")]
|
||||
for p in parts:
|
||||
low = p.lower()
|
||||
if low in keysym_map:
|
||||
kc = d.keysym_to_keycode(keysym_map[low])
|
||||
combo_keycodes.add(kc)
|
||||
elif len(p) == 1:
|
||||
kc = d.keysym_to_keycode(XK.string_to_keysym(p))
|
||||
if kc:
|
||||
combo_keycodes.add(kc)
|
||||
else:
|
||||
log.warning("Unknown hotkey part: %r", p)
|
||||
else:
|
||||
log.warning("Unknown hotkey part: %r", p)
|
||||
|
||||
if not combo_keycodes:
|
||||
log.warning("Could not parse hotkey: %s", hotkey_str)
|
||||
d.close()
|
||||
return
|
||||
|
||||
log.debug("Hotkey keycodes: %s", combo_keycodes)
|
||||
pressed = set()
|
||||
hotkey_fired = [False] # prevent repeats while held
|
||||
|
||||
def callback(reply):
|
||||
if reply.category != record.FromServer:
|
||||
return
|
||||
if reply.client_swapped:
|
||||
return
|
||||
data = reply.data
|
||||
while data:
|
||||
event, data = rq.EventField(None).parse_binary_value(
|
||||
data, record_display.display, None, None)
|
||||
keycode = event.detail
|
||||
if event.type == X.KeyPress:
|
||||
pressed.add(keycode)
|
||||
if combo_keycodes.issubset(pressed) and not hotkey_fired[0]:
|
||||
hotkey_fired[0] = True
|
||||
log.info("Hotkey pressed: %s", hotkey_str)
|
||||
if self._tray:
|
||||
self._tray._call_helper.call(self.toggle)
|
||||
elif event.type == X.KeyRelease:
|
||||
pressed.discard(keycode)
|
||||
if not combo_keycodes.issubset(pressed):
|
||||
hotkey_fired[0] = False
|
||||
|
||||
record_display = display.Display()
|
||||
ctx = record_display.record_create_context(
|
||||
0,
|
||||
[record.AllClients],
|
||||
[{
|
||||
'core_requests': (0, 0),
|
||||
'core_replies': (0, 0),
|
||||
'ext_requests': (0, 0, 0, 0),
|
||||
'ext_replies': (0, 0, 0, 0),
|
||||
'delivered_events': (0, 0),
|
||||
'device_events': (X.KeyPress, X.KeyRelease),
|
||||
'errors': (0, 0),
|
||||
'client_started': False,
|
||||
'client_died': False,
|
||||
}]
|
||||
)
|
||||
|
||||
def listener_thread():
|
||||
try:
|
||||
record_display.record_enable_context(ctx, callback)
|
||||
except Exception as e:
|
||||
log.error("Hotkey listener error: %s", e)
|
||||
|
||||
self._hotkey_record_display = record_display
|
||||
self._hotkey_record_ctx = ctx
|
||||
t = threading.Thread(target=listener_thread, daemon=True)
|
||||
t.start()
|
||||
d.close()
|
||||
log.info("Global hotkey listener started (Xlib XRecord): %s", hotkey_str)
|
||||
|
||||
# -- Main entry --
|
||||
|
||||
def run(self):
|
||||
from PyQt5.QtWidgets import QApplication
|
||||
|
||||
self._qt_app = QApplication(sys.argv)
|
||||
self._qt_app.setQuitOnLastWindowClosed(False)
|
||||
|
||||
self.write_pid()
|
||||
self._setup_signals()
|
||||
|
||||
from PyQt5.QtCore import QTimer
|
||||
signal_timer = QTimer()
|
||||
signal_timer.timeout.connect(lambda: None)
|
||||
signal_timer.start(200)
|
||||
|
||||
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)
|
||||
log.info(" model : %s", self.config.model)
|
||||
log.info(" models_dir : %s", self.config.models_dir)
|
||||
log.info(" language : %s", self.config.language)
|
||||
log.info(" threads : %d", self.config.threads)
|
||||
log.info(" gpu_device : %d", self.config.gpu_device)
|
||||
log.info(" audio_device: %s", self.config.audio_device)
|
||||
log.info(" display : %s", self.config.display_server)
|
||||
log.info(" paste_keys : %s", self.config.paste_keys)
|
||||
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)
|
||||
|
||||
if self.config.input_mode == "listen":
|
||||
from PyQt5.QtCore import QTimer as _QT
|
||||
_QT.singleShot(0, self.toggle)
|
||||
|
||||
try:
|
||||
sys.exit(self._qt_app.exec_())
|
||||
finally:
|
||||
self.remove_pid()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# CLI
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def send_toggle():
|
||||
pid = WhisperLinuxApp.read_pid()
|
||||
if pid is None:
|
||||
print("whisper-linux is not running.", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
os.kill(pid, signal.SIGUSR1)
|
||||
log.info("Sent SIGUSR1 to pid %d", pid)
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(
|
||||
description="whisper.linux \u2014 Voice typing for Linux desktop",
|
||||
)
|
||||
parser.add_argument("--toggle", action="store_true",
|
||||
help="Toggle recording on a running instance (sends SIGUSR1)")
|
||||
parser.add_argument("--language", "-l", default=None,
|
||||
help="Override transcription language (e.g. ru, en, auto)")
|
||||
parser.add_argument("--model", "-m", default=None,
|
||||
help="Override model path")
|
||||
parser.add_argument("--input-mode", choices=["hotkey", "listen"], default=None,
|
||||
help="Override input mode (hotkey or listen)")
|
||||
parser.add_argument("--output-mode", choices=["batch", "stream"], default=None,
|
||||
help="Override output mode (batch or stream)")
|
||||
parser.add_argument("--stream", action="store_true",
|
||||
help="Shortcut for --output-mode stream")
|
||||
parser.add_argument("--wake-word", default=None,
|
||||
help="Override wake word for listen mode")
|
||||
parser.add_argument("--wake-model", default=None,
|
||||
help="Override wake model (lighter model for wake word detection)")
|
||||
parser.add_argument("--debug", action="store_true",
|
||||
help="Enable debug logging")
|
||||
args = parser.parse_args()
|
||||
|
||||
if args.debug:
|
||||
logging.getLogger().setLevel(logging.DEBUG)
|
||||
|
||||
if args.toggle:
|
||||
send_toggle()
|
||||
return
|
||||
|
||||
existing_pid = WhisperLinuxApp.read_pid()
|
||||
if existing_pid is not None:
|
||||
print(f"whisper-linux is already running (pid {existing_pid}).", file=sys.stderr)
|
||||
print("Use --toggle to start/stop recording, or kill the existing instance.", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
config = Config()
|
||||
if args.language:
|
||||
config.language = args.language
|
||||
if args.model:
|
||||
config.model = args.model
|
||||
if args.stream:
|
||||
config.output_mode = "stream"
|
||||
if args.input_mode:
|
||||
config.input_mode = args.input_mode
|
||||
if args.output_mode:
|
||||
config.output_mode = args.output_mode
|
||||
if args.wake_word:
|
||||
config.wake_word = args.wake_word
|
||||
if args.wake_model:
|
||||
config.wake_model = args.wake_model
|
||||
|
||||
if not config.model or not os.path.isfile(config.model):
|
||||
if config.model:
|
||||
log.warning("Model not found: %s — searching for alternatives", config.model)
|
||||
from .config import _find_model
|
||||
fallback = _find_model(config.model_search_dirs)
|
||||
if fallback:
|
||||
log.info("Using model: %s", fallback)
|
||||
config.model = fallback
|
||||
config.save()
|
||||
else:
|
||||
print("Error: No model found. Run install.sh or set model path in config.", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
if config.wake_model and not os.path.isfile(config.wake_model):
|
||||
log.warning("Wake model not found: %s — using main model", config.wake_model)
|
||||
config.wake_model = ""
|
||||
|
||||
app = WhisperLinuxApp(config)
|
||||
app.run()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
|
|
@ -0,0 +1,288 @@
|
|||
"""Audio recording, streaming, and VAD for whisper.linux."""
|
||||
|
||||
import array
|
||||
import math
|
||||
import os
|
||||
import signal
|
||||
import struct
|
||||
import subprocess
|
||||
import tempfile
|
||||
import threading
|
||||
import time
|
||||
from typing import Optional
|
||||
|
||||
from .config import Config, log
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# AudioRecorder
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class AudioRecorder:
|
||||
"""Records audio to a temporary WAV file."""
|
||||
|
||||
def __init__(self, config: Config):
|
||||
self._config = config
|
||||
self._proc: Optional[subprocess.Popen] = None
|
||||
self._wav_path: Optional[str] = None
|
||||
|
||||
@property
|
||||
def wav_path(self) -> Optional[str]:
|
||||
return self._wav_path
|
||||
|
||||
@property
|
||||
def is_recording(self) -> bool:
|
||||
return self._proc is not None and self._proc.poll() is None
|
||||
|
||||
def _build_record_cmd(self, path: str) -> list:
|
||||
audio_device = self._config.audio_device
|
||||
if audio_device.startswith("pw") or audio_device == "auto":
|
||||
pw = self._find_pw_record()
|
||||
if pw:
|
||||
return [pw, "--format", "s16", "--rate", "16000", "--channels", "1", path]
|
||||
if audio_device and audio_device not in ("auto", "default"):
|
||||
return ["arecord", "-D", audio_device, "-f", "S16_LE", "-r", "16000",
|
||||
"-c", "1", "-t", "wav", path]
|
||||
pw = self._find_pw_record()
|
||||
if pw:
|
||||
return [pw, "--format", "s16", "--rate", "16000", "--channels", "1", path]
|
||||
return ["arecord", "-f", "S16_LE", "-r", "16000", "-c", "1", "-t", "wav", path]
|
||||
|
||||
@staticmethod
|
||||
def _find_pw_record():
|
||||
for p in ("/usr/bin/pw-record", "/usr/local/bin/pw-record"):
|
||||
if os.path.isfile(p) and os.access(p, os.X_OK):
|
||||
return p
|
||||
return None
|
||||
|
||||
def start(self) -> str:
|
||||
if self.is_recording:
|
||||
raise RuntimeError("Already recording")
|
||||
fd, path = tempfile.mkstemp(suffix=".wav", prefix="whisper-linux-")
|
||||
os.close(fd)
|
||||
self._wav_path = path
|
||||
cmd = self._build_record_cmd(path)
|
||||
self._proc = subprocess.Popen(
|
||||
cmd,
|
||||
stdout=subprocess.DEVNULL,
|
||||
stderr=subprocess.PIPE,
|
||||
)
|
||||
time.sleep(0.2)
|
||||
if self._proc.poll() is not None:
|
||||
stderr = self._proc.stderr.read().decode(errors="replace").strip()
|
||||
self._proc = None
|
||||
os.unlink(path)
|
||||
self._wav_path = None
|
||||
raise RuntimeError(f"Recording failed to start ({cmd[0]}): {stderr}")
|
||||
log.info("Recording started → %s (pid %d, cmd=%s)", path, self._proc.pid, cmd[0])
|
||||
return path
|
||||
|
||||
def stop(self) -> Optional[str]:
|
||||
if self._proc is None:
|
||||
return None
|
||||
if self._proc.poll() is None:
|
||||
self._proc.send_signal(signal.SIGINT)
|
||||
try:
|
||||
self._proc.wait(timeout=5)
|
||||
except subprocess.TimeoutExpired:
|
||||
self._proc.kill()
|
||||
self._proc.wait()
|
||||
log.info("Recording stopped → %s", self._wav_path)
|
||||
self._proc = None
|
||||
return self._wav_path
|
||||
|
||||
def cleanup(self):
|
||||
self.stop()
|
||||
if self._wav_path and os.path.exists(self._wav_path):
|
||||
os.unlink(self._wav_path)
|
||||
self._wav_path = None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# _write_wav
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _write_wav(path: str, pcm_data: bytes, sample_rate: int = 16000, channels: int = 1, bits: int = 16):
|
||||
"""Write raw PCM s16le data to a WAV file with RIFF header."""
|
||||
data_size = len(pcm_data)
|
||||
byte_rate = sample_rate * channels * bits // 8
|
||||
block_align = channels * bits // 8
|
||||
with open(path, "wb") as f:
|
||||
f.write(b"RIFF")
|
||||
f.write(struct.pack("<I", 36 + data_size))
|
||||
f.write(b"WAVE")
|
||||
f.write(b"fmt ")
|
||||
f.write(struct.pack("<I", 16))
|
||||
f.write(struct.pack("<H", 1))
|
||||
f.write(struct.pack("<H", channels))
|
||||
f.write(struct.pack("<I", sample_rate))
|
||||
f.write(struct.pack("<I", byte_rate))
|
||||
f.write(struct.pack("<H", block_align))
|
||||
f.write(struct.pack("<H", bits))
|
||||
f.write(b"data")
|
||||
f.write(struct.pack("<I", data_size))
|
||||
f.write(pcm_data)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# SimpleVAD
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class SimpleVAD:
|
||||
"""Energy-based Voice Activity Detection on raw s16le PCM frames."""
|
||||
|
||||
FRAME_MS = 30
|
||||
TRAILING_SILENCE_MS = 150
|
||||
|
||||
def __init__(self, config: Config, on_speech_end=None, on_speech_start=None):
|
||||
self._threshold = config.vad_threshold
|
||||
self._min_speech_ms = config.min_speech_ms
|
||||
self._max_speech_s = config.max_speech_s
|
||||
self._sample_rate = 16000
|
||||
self._frame_bytes = self._sample_rate * 2 * self.FRAME_MS // 1000
|
||||
self._silence_frames = self.TRAILING_SILENCE_MS // self.FRAME_MS
|
||||
self.on_speech_end = on_speech_end
|
||||
self.on_speech_start = on_speech_start
|
||||
|
||||
self._buffer = bytearray()
|
||||
self._speech_buffer = bytearray()
|
||||
self._in_speech = False
|
||||
self._silent_count = 0
|
||||
|
||||
def reset(self):
|
||||
self._buffer.clear()
|
||||
self._speech_buffer.clear()
|
||||
self._in_speech = False
|
||||
self._silent_count = 0
|
||||
|
||||
@staticmethod
|
||||
def _rms(frame_bytes: bytes) -> float:
|
||||
if len(frame_bytes) < 2:
|
||||
return 0.0
|
||||
samples = array.array("h")
|
||||
samples.frombytes(frame_bytes[:len(frame_bytes) - len(frame_bytes) % 2])
|
||||
if not samples:
|
||||
return 0.0
|
||||
return math.sqrt(sum(s * s for s in samples) / len(samples))
|
||||
|
||||
def feed(self, chunk: bytes):
|
||||
self._buffer.extend(chunk)
|
||||
while len(self._buffer) >= self._frame_bytes:
|
||||
frame = bytes(self._buffer[:self._frame_bytes])
|
||||
del self._buffer[:self._frame_bytes]
|
||||
rms = self._rms(frame)
|
||||
|
||||
if rms >= self._threshold:
|
||||
if not self._in_speech:
|
||||
self._in_speech = True
|
||||
self._silent_count = 0
|
||||
log.debug("VAD: speech start (RMS=%.0f)", rms)
|
||||
if self.on_speech_start:
|
||||
self.on_speech_start()
|
||||
self._speech_buffer.extend(frame)
|
||||
self._silent_count = 0
|
||||
elif self._in_speech:
|
||||
self._speech_buffer.extend(frame)
|
||||
self._silent_count += 1
|
||||
if self._silent_count >= self._silence_frames:
|
||||
self._emit_speech()
|
||||
|
||||
max_bytes = int(self._max_speech_s * self._sample_rate * 2)
|
||||
if self._in_speech and len(self._speech_buffer) >= max_bytes:
|
||||
self._emit_speech()
|
||||
|
||||
def _emit_speech(self):
|
||||
pcm = bytes(self._speech_buffer)
|
||||
duration_ms = len(pcm) * 1000 // (self._sample_rate * 2)
|
||||
self._speech_buffer.clear()
|
||||
self._in_speech = False
|
||||
self._silent_count = 0
|
||||
if duration_ms >= self._min_speech_ms:
|
||||
log.debug("VAD: speech end (%dms)", duration_ms)
|
||||
if self.on_speech_end:
|
||||
self.on_speech_end(pcm)
|
||||
else:
|
||||
log.debug("VAD: speech too short (%dms), discarded", duration_ms)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# AudioStream
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class AudioStream:
|
||||
"""Streams raw PCM from pw-record to a callback in a reader thread."""
|
||||
|
||||
SAMPLE_RATE = 16000
|
||||
BYTES_PER_SEC = 32000
|
||||
CHUNK_MS = 100
|
||||
CHUNK_BYTES = BYTES_PER_SEC * CHUNK_MS // 1000
|
||||
|
||||
def __init__(self, config: Config):
|
||||
self._config = config
|
||||
self._proc: Optional[subprocess.Popen] = None
|
||||
self._thread: Optional[threading.Thread] = None
|
||||
self._on_data = None
|
||||
self._running = False
|
||||
|
||||
@property
|
||||
def is_running(self) -> bool:
|
||||
return self._running and self._proc is not None and self._proc.poll() is None
|
||||
|
||||
def _build_stream_cmd(self) -> list:
|
||||
audio_device = self._config.audio_device
|
||||
pw = AudioRecorder._find_pw_record()
|
||||
if pw:
|
||||
return [pw, "--format", "s16", "--rate", "16000", "--channels", "1", "-"]
|
||||
cmd = ["arecord", "-f", "S16_LE", "-r", "16000", "-c", "1", "-t", "raw"]
|
||||
if audio_device and audio_device not in ("auto", "default"):
|
||||
cmd.extend(["-D", audio_device])
|
||||
return cmd
|
||||
|
||||
def start(self, on_data):
|
||||
if self._running:
|
||||
raise RuntimeError("AudioStream already running")
|
||||
self._on_data = on_data
|
||||
self._running = True
|
||||
cmd = self._build_stream_cmd()
|
||||
self._proc = subprocess.Popen(
|
||||
cmd,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.PIPE,
|
||||
)
|
||||
time.sleep(0.2)
|
||||
if self._proc.poll() is not None:
|
||||
stderr = self._proc.stderr.read().decode(errors="replace").strip()
|
||||
self._running = False
|
||||
self._proc = None
|
||||
raise RuntimeError(f"AudioStream failed to start ({cmd[0]}): {stderr}")
|
||||
self._thread = threading.Thread(target=self._reader_loop, daemon=True)
|
||||
self._thread.start()
|
||||
log.info("AudioStream started (pid %d, cmd=%s)", self._proc.pid, cmd[0])
|
||||
|
||||
def stop(self):
|
||||
self._running = False
|
||||
if self._proc and self._proc.poll() is None:
|
||||
self._proc.send_signal(signal.SIGINT)
|
||||
try:
|
||||
self._proc.wait(timeout=3)
|
||||
except subprocess.TimeoutExpired:
|
||||
self._proc.kill()
|
||||
self._proc.wait()
|
||||
if self._thread:
|
||||
self._thread.join(timeout=3)
|
||||
self._thread = None
|
||||
self._proc = None
|
||||
log.info("AudioStream stopped")
|
||||
|
||||
def _reader_loop(self):
|
||||
try:
|
||||
while self._running and self._proc and self._proc.poll() is None:
|
||||
data = self._proc.stdout.read(self.CHUNK_BYTES)
|
||||
if not data:
|
||||
break
|
||||
if self._on_data and self._running:
|
||||
self._on_data(data)
|
||||
except Exception as e:
|
||||
log.error("AudioStream reader error: %s", e)
|
||||
finally:
|
||||
self._running = False
|
||||
|
|
@ -0,0 +1,81 @@
|
|||
"""Voice command processing for whisper.linux.
|
||||
|
||||
Detects command words in transcribed text (e.g. "Enter", "Backspace")
|
||||
and executes corresponding key presses instead of typing them literally.
|
||||
"""
|
||||
|
||||
import difflib
|
||||
from typing import Callable, Optional
|
||||
|
||||
from .config import DEFAULT_VOICE_COMMANDS, log
|
||||
|
||||
|
||||
class VoiceCommands:
|
||||
"""Processes transcribed text for voice commands (Enter, Backspace, etc.)."""
|
||||
|
||||
DEFAULT_COMMANDS = DEFAULT_VOICE_COMMANDS
|
||||
FUZZY_THRESHOLD = 0.75
|
||||
|
||||
def __init__(self, commands: dict = None):
|
||||
self._commands = commands if commands is not None else dict(self.DEFAULT_COMMANDS)
|
||||
|
||||
def process(self, text: str, inject_fn: Callable, send_key_fn: Callable) -> bool:
|
||||
"""Process text, calling inject_fn for text and send_key_fn for key presses.
|
||||
|
||||
For "backspace", removes the previous word from the buffer. If the buffer
|
||||
is empty, sends Ctrl+BackSpace to delete the word in the editor.
|
||||
|
||||
Returns True if any commands were found.
|
||||
"""
|
||||
words = text.split()
|
||||
if not words:
|
||||
return False
|
||||
|
||||
buffer = []
|
||||
had_commands = False
|
||||
|
||||
for word in words:
|
||||
clean = word.strip(".,!?;:-\"'()[]").lower()
|
||||
action = self._match_command(clean)
|
||||
if action:
|
||||
had_commands = True
|
||||
if action == "backspace":
|
||||
if buffer:
|
||||
removed = buffer.pop()
|
||||
log.info("Voice command: backspace (removed '%s' from buffer)", removed)
|
||||
else:
|
||||
send_key_fn("ctrl+BackSpace")
|
||||
log.info("Voice command: backspace (Ctrl+BackSpace sent)")
|
||||
else:
|
||||
# Flush text buffer first, then send key
|
||||
if buffer:
|
||||
inject_fn(" ".join(buffer))
|
||||
buffer.clear()
|
||||
key = action[4:] # strip "key:" prefix
|
||||
send_key_fn(key)
|
||||
log.info("Voice command: %s -> %s", clean, key)
|
||||
else:
|
||||
buffer.append(word)
|
||||
|
||||
# Flush remaining text
|
||||
if buffer:
|
||||
inject_fn(" ".join(buffer))
|
||||
|
||||
return had_commands
|
||||
|
||||
def _match_command(self, word: str) -> Optional[str]:
|
||||
"""Match a word to a command (exact, then fuzzy)."""
|
||||
if not word:
|
||||
return None
|
||||
# Exact match
|
||||
if word in self._commands:
|
||||
return self._commands[word]
|
||||
# Fuzzy match — pick best above threshold
|
||||
best_ratio = 0.0
|
||||
best_action = None
|
||||
for cmd_word, action in self._commands.items():
|
||||
ratio = difflib.SequenceMatcher(None, cmd_word, word).ratio()
|
||||
if ratio >= self.FUZZY_THRESHOLD and ratio > best_ratio:
|
||||
best_ratio = ratio
|
||||
best_action = action
|
||||
return best_action
|
||||
|
|
@ -0,0 +1,364 @@
|
|||
"""Configuration, constants, and helpers for whisper.linux."""
|
||||
|
||||
import configparser
|
||||
import enum
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
import subprocess
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
|
||||
LOG_FORMAT = "%(asctime)s [%(levelname)s] %(name)s: %(message)s"
|
||||
logging.basicConfig(level=logging.INFO, format=LOG_FORMAT)
|
||||
log = logging.getLogger("whisper-linux")
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# AppState
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class AppState(enum.Enum):
|
||||
IDLE = "idle"
|
||||
RECORDING = "recording"
|
||||
PROCESSING = "processing"
|
||||
LISTENING = "listening" # stream: waiting for wake word
|
||||
DICTATING = "dictating" # stream: actively typing text
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Paths and constants
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
_REPO_ROOT = Path(__file__).resolve().parent.parent.parent.parent
|
||||
|
||||
CONFIG_DIR = Path.home() / ".config" / "whisper-linux"
|
||||
CONFIG_FILE = CONFIG_DIR / "config.ini"
|
||||
PID_FILE = Path("/tmp") / "whisper-linux.pid"
|
||||
|
||||
_WHISPER_CLI_NAMES = ["whisper-cli", "main"]
|
||||
_WHISPER_SEARCH_DIRS = [
|
||||
_REPO_ROOT / "build" / "bin",
|
||||
_REPO_ROOT / "build",
|
||||
Path.home() / ".local" / "bin",
|
||||
Path("/usr/local/bin"),
|
||||
Path("/usr/bin"),
|
||||
]
|
||||
|
||||
_MODEL_SEARCH_DIRS = [
|
||||
_REPO_ROOT / "models",
|
||||
Path.home() / ".local" / "share" / "whisper-linux" / "models",
|
||||
]
|
||||
|
||||
DEFAULT_VOICE_COMMANDS = {
|
||||
"enter": "key:Return",
|
||||
"энтер": "key:Return",
|
||||
"ввод": "key:Return",
|
||||
"backspace": "backspace",
|
||||
"бэкспейс": "backspace",
|
||||
"бекспейс": "backspace",
|
||||
"назад": "backspace",
|
||||
"tab": "key:Tab",
|
||||
"таб": "key:Tab",
|
||||
"табуляция": "key:Tab",
|
||||
"escape": "key:Escape",
|
||||
"эскейп": "key:Escape",
|
||||
"стоп": "key:Escape",
|
||||
}
|
||||
|
||||
AVAILABLE_MODELS = [
|
||||
("tiny", "~75 MB"),
|
||||
("base", "~142 MB"),
|
||||
("small", "~466 MB"),
|
||||
("medium", "~1.5 GB"),
|
||||
("medium-q5_0", "~539 MB"),
|
||||
("large-v2", "~3.1 GB"),
|
||||
("large-v3", "~3.1 GB"),
|
||||
("large-v3-q5_0", "~1.1 GB"),
|
||||
("large-v3-turbo", "~1.5 GB"),
|
||||
("large-v3-turbo-q5_0", "~574 MB"),
|
||||
("large-v3-turbo-q8_0", "~834 MB"),
|
||||
]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helper functions
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _find_executable(names, search_dirs):
|
||||
"""Find first matching executable in search dirs, then PATH."""
|
||||
for d in search_dirs:
|
||||
for name in names:
|
||||
p = d / name
|
||||
if p.is_file() and os.access(p, os.X_OK):
|
||||
return str(p)
|
||||
for name in names:
|
||||
for pdir in os.environ.get("PATH", "").split(os.pathsep):
|
||||
p = Path(pdir) / name
|
||||
if p.is_file() and os.access(p, os.X_OK):
|
||||
return str(p)
|
||||
return None
|
||||
|
||||
|
||||
def _find_model(search_dirs, preferred="ggml-base.bin"):
|
||||
"""Find first .bin model file."""
|
||||
for d in search_dirs:
|
||||
p = d / preferred
|
||||
if p.is_file():
|
||||
return str(p)
|
||||
for d in search_dirs:
|
||||
if d.is_dir():
|
||||
for f in sorted(d.glob("ggml-*.bin")):
|
||||
return str(f)
|
||||
return None
|
||||
|
||||
|
||||
def _list_models(search_dirs):
|
||||
"""Return list of (name, path) for all available ggml models, sorted by size."""
|
||||
seen = set()
|
||||
models = []
|
||||
for d in search_dirs:
|
||||
if d.is_dir():
|
||||
for f in sorted(d.glob("ggml-*.bin")):
|
||||
if f.name not in seen:
|
||||
seen.add(f.name)
|
||||
name = f.stem.replace("ggml-", "")
|
||||
models.append((name, str(f)))
|
||||
models.sort(key=lambda x: Path(x[1]).stat().st_size)
|
||||
return models
|
||||
|
||||
|
||||
def _list_audio_devices():
|
||||
"""Return list of (label, device_id) for audio input devices."""
|
||||
devices = []
|
||||
if os.path.isfile("/usr/bin/pw-record"):
|
||||
devices.append(("Auto (PipeWire)", "auto"))
|
||||
else:
|
||||
devices.append(("Auto", "auto"))
|
||||
try:
|
||||
result = subprocess.run(
|
||||
["arecord", "-l"], capture_output=True, text=True, timeout=5,
|
||||
)
|
||||
for line in result.stdout.splitlines():
|
||||
m = re.match(r"card\s+(\d+):\s*(\w+).*device\s+(\d+):\s*(.*?)(?:\[|$)", line)
|
||||
if m:
|
||||
card, card_name, dev, dev_name = m.groups()
|
||||
device_id = f"plughw:{card},{dev}"
|
||||
label = dev_name.strip() if dev_name.strip() else device_id
|
||||
devices.append((label, device_id))
|
||||
except Exception:
|
||||
pass
|
||||
return devices
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Config
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@dataclass
|
||||
class Config:
|
||||
whisper_cli: str = ""
|
||||
model: str = ""
|
||||
models_dir: str = ""
|
||||
language: str = "ru"
|
||||
threads: int = 4
|
||||
gpu_device: int = -1
|
||||
display_server: str = ""
|
||||
audio_device: str = ""
|
||||
paste_keys: str = "shift+Insert"
|
||||
use_clipboard_fallback: bool = False
|
||||
notification: bool = True
|
||||
input_mode: str = "hotkey"
|
||||
output_mode: str = "batch"
|
||||
wake_word: str = "марфуша"
|
||||
wake_model: str = ""
|
||||
silence_timeout: float = 3.0
|
||||
vad_threshold: int = 300
|
||||
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):
|
||||
self._gpu_devices = []
|
||||
self.voice_commands_map: dict = dict(DEFAULT_VOICE_COMMANDS)
|
||||
self._load()
|
||||
|
||||
def _load(self):
|
||||
"""Load from config.ini, auto-detect missing values."""
|
||||
cp = configparser.ConfigParser()
|
||||
if CONFIG_FILE.exists():
|
||||
cp.read(CONFIG_FILE)
|
||||
|
||||
sec = "whisper-linux"
|
||||
if cp.has_section(sec):
|
||||
self.whisper_cli = cp.get(sec, "whisper_cli", fallback=self.whisper_cli)
|
||||
self.model = cp.get(sec, "model", fallback=self.model)
|
||||
self.models_dir = cp.get(sec, "models_dir", fallback=self.models_dir)
|
||||
self.language = cp.get(sec, "language", fallback=self.language)
|
||||
self.threads = cp.getint(sec, "threads", fallback=self.threads)
|
||||
self.gpu_device = cp.getint(sec, "gpu_device", fallback=self.gpu_device)
|
||||
self.display_server = cp.get(sec, "display_server", fallback=self.display_server)
|
||||
self.audio_device = cp.get(sec, "audio_device", fallback=self.audio_device)
|
||||
self.paste_keys = cp.get(sec, "paste_keys", fallback=self.paste_keys)
|
||||
self.use_clipboard_fallback = cp.getboolean(
|
||||
sec, "use_clipboard_fallback", fallback=self.use_clipboard_fallback
|
||||
)
|
||||
self.notification = cp.getboolean(sec, "notification", fallback=self.notification)
|
||||
old_mode = cp.get(sec, "mode", fallback=None)
|
||||
if old_mode and not cp.has_option(sec, "input_mode"):
|
||||
if old_mode == "stream":
|
||||
self.input_mode = "listen"
|
||||
self.output_mode = "stream"
|
||||
else:
|
||||
self.input_mode = "hotkey"
|
||||
self.output_mode = "batch"
|
||||
self.input_mode = cp.get(sec, "input_mode", fallback=self.input_mode)
|
||||
self.output_mode = cp.get(sec, "output_mode", fallback=self.output_mode)
|
||||
self.wake_word = cp.get(sec, "wake_word", fallback=self.wake_word)
|
||||
self.wake_model = cp.get(sec, "wake_model", fallback=self.wake_model)
|
||||
self.silence_timeout = cp.getfloat(sec, "silence_timeout", fallback=self.silence_timeout)
|
||||
self.vad_threshold = cp.getint(sec, "vad_threshold", fallback=self.vad_threshold)
|
||||
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"
|
||||
if cp.has_section(vc_sec):
|
||||
saved = dict(cp.items(vc_sec))
|
||||
# Merge: keep saved commands, add new defaults that aren't overridden
|
||||
merged = dict(DEFAULT_VOICE_COMMANDS)
|
||||
merged.update(saved)
|
||||
self.voice_commands_map = merged
|
||||
|
||||
if not self.whisper_cli:
|
||||
self.whisper_cli = _find_executable(_WHISPER_CLI_NAMES, _WHISPER_SEARCH_DIRS) or "whisper-cli"
|
||||
if not self.models_dir:
|
||||
self.models_dir = self._detect_models_dir()
|
||||
if not self.model:
|
||||
self.model = _find_model(self.model_search_dirs) or ""
|
||||
if not self.display_server:
|
||||
self.display_server = self._detect_display_server()
|
||||
if not self.audio_device:
|
||||
self.audio_device = self._detect_audio_device()
|
||||
detected_gpu = self._detect_gpu_device()
|
||||
if self.gpu_device < 0:
|
||||
self.gpu_device = detected_gpu
|
||||
|
||||
@staticmethod
|
||||
def _detect_display_server():
|
||||
xdg = os.environ.get("XDG_SESSION_TYPE", "").lower()
|
||||
if "wayland" in xdg:
|
||||
return "wayland"
|
||||
if "x11" in xdg:
|
||||
return "x11"
|
||||
if os.environ.get("WAYLAND_DISPLAY"):
|
||||
return "wayland"
|
||||
if os.environ.get("DISPLAY"):
|
||||
return "x11"
|
||||
return "x11"
|
||||
|
||||
@staticmethod
|
||||
def _detect_audio_device():
|
||||
if os.path.isfile("/usr/bin/pw-record"):
|
||||
log.info("PipeWire detected (pw-record available), using auto audio device")
|
||||
return "auto"
|
||||
try:
|
||||
result = subprocess.run(
|
||||
["arecord", "-l"], capture_output=True, text=True, timeout=5,
|
||||
)
|
||||
for line in result.stdout.splitlines():
|
||||
m = re.match(r"card\s+(\d+):.*device\s+(\d+):", line)
|
||||
if m:
|
||||
card, dev = m.group(1), m.group(2)
|
||||
device = f"plughw:{card},{dev}"
|
||||
log.info("Auto-detected ALSA audio device: %s (%s)",
|
||||
device, line.strip())
|
||||
return device
|
||||
except Exception as e:
|
||||
log.warning("Failed to detect audio device: %s", e)
|
||||
return "default"
|
||||
|
||||
@staticmethod
|
||||
def _detect_models_dir():
|
||||
for d in _MODEL_SEARCH_DIRS:
|
||||
if d.is_dir() and any(d.glob("ggml-*.bin")):
|
||||
return str(d)
|
||||
return str(_MODEL_SEARCH_DIRS[0])
|
||||
|
||||
@property
|
||||
def model_search_dirs(self):
|
||||
dirs = []
|
||||
if self.models_dir:
|
||||
dirs.append(Path(self.models_dir))
|
||||
for d in _MODEL_SEARCH_DIRS:
|
||||
if d not in dirs:
|
||||
dirs.append(d)
|
||||
return dirs
|
||||
|
||||
def _detect_gpu_device(self):
|
||||
cli = self.whisper_cli or _find_executable(_WHISPER_CLI_NAMES, _WHISPER_SEARCH_DIRS)
|
||||
model = self.model or _find_model(self.model_search_dirs)
|
||||
if not cli or not model:
|
||||
return 0
|
||||
try:
|
||||
result = subprocess.run(
|
||||
[cli, "-m", model, "-f", "/dev/null"],
|
||||
capture_output=True, text=True, timeout=10,
|
||||
)
|
||||
devices = {}
|
||||
for line in result.stderr.splitlines():
|
||||
m = re.match(r"ggml_vulkan:\s+(\d+)\s*=\s*(.+?)(?:\s*\|.*)?$", line)
|
||||
if m:
|
||||
idx, name = int(m.group(1)), m.group(2).strip()
|
||||
devices[idx] = name
|
||||
log.info("GPU device %d: %s", idx, name)
|
||||
self._gpu_devices = [(name, idx) for idx, name in sorted(devices.items())]
|
||||
for idx, name in devices.items():
|
||||
if "NVIDIA" in name.upper():
|
||||
log.info("Auto-selected NVIDIA GPU device: %d", idx)
|
||||
return idx
|
||||
for idx, name in devices.items():
|
||||
if "AMD" in name.upper() or "RADEON" in name.upper():
|
||||
log.info("Auto-selected AMD GPU device: %d", idx)
|
||||
return idx
|
||||
except Exception as e:
|
||||
log.debug("GPU device detection failed: %s", e)
|
||||
return 0
|
||||
|
||||
def save(self):
|
||||
CONFIG_DIR.mkdir(parents=True, exist_ok=True)
|
||||
cp = configparser.ConfigParser()
|
||||
sec = "whisper-linux"
|
||||
cp.add_section(sec)
|
||||
cp.set(sec, "whisper_cli", self.whisper_cli)
|
||||
cp.set(sec, "model", self.model)
|
||||
cp.set(sec, "models_dir", self.models_dir)
|
||||
cp.set(sec, "language", self.language)
|
||||
cp.set(sec, "threads", str(self.threads))
|
||||
cp.set(sec, "gpu_device", str(self.gpu_device))
|
||||
cp.set(sec, "display_server", self.display_server)
|
||||
cp.set(sec, "audio_device", self.audio_device)
|
||||
cp.set(sec, "paste_keys", self.paste_keys)
|
||||
cp.set(sec, "use_clipboard_fallback", str(self.use_clipboard_fallback))
|
||||
cp.set(sec, "notification", str(self.notification))
|
||||
cp.set(sec, "input_mode", self.input_mode)
|
||||
cp.set(sec, "output_mode", self.output_mode)
|
||||
cp.set(sec, "wake_word", self.wake_word)
|
||||
cp.set(sec, "wake_model", self.wake_model)
|
||||
cp.set(sec, "silence_timeout", str(self.silence_timeout))
|
||||
cp.set(sec, "vad_threshold", str(self.vad_threshold))
|
||||
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)
|
||||
for word, action in self.voice_commands_map.items():
|
||||
cp.set(vc_sec, word, action)
|
||||
with open(CONFIG_FILE, "w") as f:
|
||||
cp.write(f)
|
||||
log.info("Config saved to %s", CONFIG_FILE)
|
||||
|
|
@ -0,0 +1,165 @@
|
|||
"""Text injection (xdotool/ydotool/clipboard) for whisper.linux."""
|
||||
|
||||
import os
|
||||
import subprocess
|
||||
import time
|
||||
|
||||
from .config import Config, log
|
||||
|
||||
|
||||
class TextInjector:
|
||||
"""Injects text at cursor position using xdotool, ydotool, or clipboard."""
|
||||
|
||||
# X11 keysym → evdev name (for ydotool which uses evdev names)
|
||||
_YDOTOOL_KEY_MAP = {
|
||||
"Return": "Enter",
|
||||
"Escape": "Esc",
|
||||
}
|
||||
|
||||
def __init__(self, config: Config):
|
||||
self.config = config
|
||||
|
||||
def inject(self, text: str):
|
||||
if not text:
|
||||
return
|
||||
ds = self.config.display_server
|
||||
if ds == "wayland":
|
||||
self._inject_wayland(text)
|
||||
else:
|
||||
self._inject_x11(text)
|
||||
|
||||
def _inject_x11(self, text: str):
|
||||
if self.config.use_clipboard_fallback:
|
||||
self._inject_clipboard_x11(text)
|
||||
return
|
||||
try:
|
||||
subprocess.run(
|
||||
["xdotool", "type", "--delay", "12", "--clearmodifiers", "--", text],
|
||||
check=True, timeout=10,
|
||||
)
|
||||
except (subprocess.CalledProcessError, FileNotFoundError):
|
||||
log.warning("xdotool type failed, falling back to clipboard")
|
||||
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=encoded, check=True, timeout=5,
|
||||
)
|
||||
subprocess.run(
|
||||
["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,
|
||||
)
|
||||
|
||||
def _inject_wayland(self, text: str):
|
||||
try:
|
||||
subprocess.run(["wtype", "--", text], check=True, timeout=10)
|
||||
return
|
||||
except (subprocess.CalledProcessError, FileNotFoundError):
|
||||
pass
|
||||
|
||||
try:
|
||||
subprocess.run(
|
||||
["wl-copy", "--", text], check=True, timeout=5,
|
||||
)
|
||||
subprocess.run(
|
||||
["wl-copy", "--primary", "--", text], check=True, timeout=5,
|
||||
)
|
||||
except (subprocess.CalledProcessError, FileNotFoundError):
|
||||
log.warning("wl-copy failed, falling back to xclip (XWayland only)")
|
||||
self._inject_x11(text)
|
||||
return
|
||||
|
||||
paste_keys = self.config.paste_keys
|
||||
log.info("Clipboard set via wl-copy, sending %s", paste_keys)
|
||||
time.sleep(0.3)
|
||||
|
||||
try:
|
||||
subprocess.run(
|
||||
["ydotool", "key", "--delay", "100", paste_keys],
|
||||
check=True, timeout=5, capture_output=True,
|
||||
)
|
||||
log.info("Paste sent via ydotool (%s)", paste_keys)
|
||||
return
|
||||
except (subprocess.CalledProcessError, FileNotFoundError, RuntimeError) as e:
|
||||
log.debug("ydotool failed: %s", e)
|
||||
|
||||
try:
|
||||
subprocess.run(
|
||||
["xdotool", "key", "--clearmodifiers", paste_keys],
|
||||
check=True, timeout=5,
|
||||
)
|
||||
log.info("Paste sent via xdotool (%s)", paste_keys)
|
||||
return
|
||||
except (subprocess.CalledProcessError, FileNotFoundError):
|
||||
pass
|
||||
|
||||
log.warning("Text copied to clipboard (Ctrl+V to paste). "
|
||||
"For auto-paste: sudo chmod 0660 /dev/uinput && "
|
||||
"sudo chown root:$USER /dev/uinput")
|
||||
|
||||
def send_key(self, key: str):
|
||||
"""Send a key press (e.g. 'Return', 'Tab', 'ctrl+BackSpace')."""
|
||||
ds = self.config.display_server
|
||||
if ds == "wayland":
|
||||
self._send_key_wayland(key)
|
||||
else:
|
||||
self._send_key_x11(key)
|
||||
|
||||
def _send_key_x11(self, key: str):
|
||||
try:
|
||||
subprocess.run(
|
||||
["xdotool", "key", "--clearmodifiers", key],
|
||||
check=True, timeout=5,
|
||||
)
|
||||
except (subprocess.CalledProcessError, FileNotFoundError) as e:
|
||||
log.warning("xdotool key '%s' failed: %s", key, e)
|
||||
|
||||
def _send_key_wayland(self, key: str):
|
||||
# wtype supports XKB key names natively on Wayland
|
||||
try:
|
||||
if "+" in key:
|
||||
parts = key.split("+")
|
||||
cmd = ["wtype"]
|
||||
for mod in parts[:-1]:
|
||||
cmd.extend(["-M", mod.lower()])
|
||||
cmd.extend(["-k", parts[-1]])
|
||||
else:
|
||||
cmd = ["wtype", "-k", key]
|
||||
subprocess.run(cmd, check=True, timeout=5)
|
||||
return
|
||||
except (subprocess.CalledProcessError, FileNotFoundError):
|
||||
pass
|
||||
# ydotool uses evdev key names (Enter, not Return; Esc, not Escape)
|
||||
try:
|
||||
if "+" in key:
|
||||
parts = key.split("+")
|
||||
evdev_parts = [self._YDOTOOL_KEY_MAP.get(p, p) for p in parts]
|
||||
evdev_key = "+".join(evdev_parts)
|
||||
else:
|
||||
evdev_key = self._YDOTOOL_KEY_MAP.get(key, key)
|
||||
subprocess.run(
|
||||
["ydotool", "key", "--delay", "50", evdev_key],
|
||||
check=True, timeout=5, capture_output=True,
|
||||
)
|
||||
return
|
||||
except (subprocess.CalledProcessError, FileNotFoundError):
|
||||
pass
|
||||
# xdotool via XWayland
|
||||
try:
|
||||
subprocess.run(
|
||||
["xdotool", "key", "--clearmodifiers", key],
|
||||
check=True, timeout=5,
|
||||
)
|
||||
return
|
||||
except (subprocess.CalledProcessError, FileNotFoundError):
|
||||
pass
|
||||
log.warning("Failed to send key '%s' — no working key sender", key)
|
||||
|
|
@ -0,0 +1,292 @@
|
|||
"""Transcriber and WakeWordDetector for whisper.linux."""
|
||||
|
||||
import difflib
|
||||
import os
|
||||
import re
|
||||
import subprocess
|
||||
|
||||
from .config import Config, log
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Hallucination filter — whisper generates these from training data on silence
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
# Known hallucination phrases (training data leaks)
|
||||
_HALLUCINATION_PATTERNS = [
|
||||
r"субтитр",
|
||||
r"редактор\s+субтитр",
|
||||
r"перевод\w*\s+субтитр",
|
||||
r"подредактир",
|
||||
r"продолжение\s+следует",
|
||||
r"спасибо\s+за\s+(просмотр|подписк|внимание)",
|
||||
r"подписывайтесь",
|
||||
r"не\s+забудьте\s+подписаться",
|
||||
r"ставьте\s+лайк",
|
||||
r"с\s+вами\s+был[аио]?\s",
|
||||
r"а[пп]+ортизатор",
|
||||
r"добро\s+пожаловать",
|
||||
r"до\s+новых\s+встреч",
|
||||
r"до\s+свидания.*друзья",
|
||||
r"subtitles?\s+(by|made|edited|created)",
|
||||
r"thanks?\s+for\s+watching",
|
||||
r"subscribe\s+(to|and)",
|
||||
r"please\s+(like|subscribe)",
|
||||
r"don'?t\s+forget\s+to\s+subscribe",
|
||||
r"copyright\s+\d{4}",
|
||||
]
|
||||
|
||||
_HALLUCINATION_RE = re.compile(
|
||||
"|".join(_HALLUCINATION_PATTERNS), re.IGNORECASE,
|
||||
)
|
||||
|
||||
# Speech rate limits — normal speech is ~2-3 words/sec, ~12-15 chars/sec.
|
||||
# Hallucinations on short audio produce way more text than physically possible.
|
||||
_MAX_WORDS_PER_SEC = 5 # generous upper bound
|
||||
_MAX_CHARS_PER_SEC = 25 # generous upper bound
|
||||
|
||||
|
||||
def _is_hallucination(text: str, duration_s: float = 0) -> bool:
|
||||
"""Return True if text looks like a whisper hallucination.
|
||||
|
||||
Uses two layers:
|
||||
1. Pattern matching — known hallucination phrases.
|
||||
2. Speech rate check — if audio duration is known, rejects text that
|
||||
is impossibly long for the given duration (e.g. 5 words from 0.5s).
|
||||
"""
|
||||
# Layer 1: known patterns
|
||||
if _HALLUCINATION_RE.search(text):
|
||||
return True
|
||||
|
||||
# Layer 2: speech rate sanity check (only when duration is known)
|
||||
if duration_s > 0:
|
||||
words = text.split()
|
||||
word_count = len(words)
|
||||
char_count = len(text.replace(" ", ""))
|
||||
|
||||
max_words = max(2, duration_s * _MAX_WORDS_PER_SEC)
|
||||
max_chars = max(10, duration_s * _MAX_CHARS_PER_SEC)
|
||||
|
||||
if word_count > max_words:
|
||||
log.debug("Hallucination (words): %d words in %.1fs (max %.0f)",
|
||||
word_count, duration_s, max_words)
|
||||
return True
|
||||
if char_count > max_chars:
|
||||
log.debug("Hallucination (chars): %d chars in %.1fs (max %.0f)",
|
||||
char_count, duration_s, max_chars)
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# WakeWordDetector
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class WakeWordDetector:
|
||||
"""Checks transcription text for the wake word using exact + fuzzy matching."""
|
||||
|
||||
FUZZY_THRESHOLD = 0.7
|
||||
|
||||
def __init__(self, wake_word: str):
|
||||
self._wake_word = wake_word.lower().strip()
|
||||
self._variants = self._build_variants(self._wake_word)
|
||||
|
||||
@staticmethod
|
||||
def _build_variants(word):
|
||||
variants = {word}
|
||||
for suffix in ("а", "я", "ша", "жа"):
|
||||
if word.endswith(suffix) and len(word) > len(suffix) + 2:
|
||||
variants.add(word[:-len(suffix)])
|
||||
return variants
|
||||
|
||||
def contains_wake_word(self, text: str) -> bool:
|
||||
text_lower = text.lower().strip()
|
||||
if self._wake_word in text_lower:
|
||||
return True
|
||||
for word in text_lower.split():
|
||||
word = word.strip(".,!?;:-\"'()[]")
|
||||
if word and self._is_fuzzy_match(word):
|
||||
return True
|
||||
return False
|
||||
|
||||
def strip_wake_word(self, text: str) -> str:
|
||||
import re
|
||||
pattern = re.compile(re.escape(self._wake_word), re.IGNORECASE)
|
||||
if pattern.search(text):
|
||||
result = pattern.sub("", text)
|
||||
result = re.sub(r"\s+", " ", result).strip()
|
||||
return result.strip(".,!?;:- ")
|
||||
words = text.split()
|
||||
kept = []
|
||||
for w in words:
|
||||
clean = w.strip(".,!?;:-\"'()[]").lower()
|
||||
if clean and self._is_fuzzy_match(clean):
|
||||
continue
|
||||
kept.append(w)
|
||||
result = " ".join(kept).strip()
|
||||
return result.strip(".,!?;:- ")
|
||||
|
||||
def _is_fuzzy_match(self, word: str) -> bool:
|
||||
ratio = difflib.SequenceMatcher(None, self._wake_word, word).ratio()
|
||||
if ratio >= self.FUZZY_THRESHOLD:
|
||||
return True
|
||||
for variant in self._variants:
|
||||
ratio = difflib.SequenceMatcher(None, variant, word).ratio()
|
||||
if ratio >= self.FUZZY_THRESHOLD:
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Transcriber
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
PARAKEET_PREFIX = "parakeet:"
|
||||
PARAKEET_VENV_PYTHON = (
|
||||
"/mnt/82A23910A2390A65/Trade/EducationAndHack/VOICE/whisper.cpp/"
|
||||
"examples/whisper.youtube/.venv-parakeet/bin/python"
|
||||
)
|
||||
|
||||
|
||||
class Transcriber:
|
||||
"""Runs whisper-cli or Parakeet (NeMo) and returns transcribed text."""
|
||||
|
||||
def __init__(self, config: Config):
|
||||
self.config = config
|
||||
self._parakeet_proc = None
|
||||
self._parakeet_model = None
|
||||
|
||||
def transcribe(self, wav_path: str, model: str = None,
|
||||
duration_s: float = 0) -> str:
|
||||
if not os.path.isfile(wav_path):
|
||||
raise FileNotFoundError(f"WAV file not found: {wav_path}")
|
||||
|
||||
target = model or self.config.model
|
||||
if target.startswith(PARAKEET_PREFIX):
|
||||
return self._transcribe_parakeet(wav_path, target, duration_s)
|
||||
return self._transcribe_whisper(wav_path, target, duration_s)
|
||||
|
||||
def _transcribe_whisper(self, wav_path, model, duration_s):
|
||||
cmd = [
|
||||
self.config.whisper_cli,
|
||||
"-m", model,
|
||||
"-f", wav_path,
|
||||
"-nt",
|
||||
"-np",
|
||||
"-t", str(self.config.threads),
|
||||
"-l", self.config.language,
|
||||
"-dev", str(self.config.gpu_device),
|
||||
]
|
||||
log.info("Transcribing whisper (%.1fs): %s", duration_s, " ".join(cmd))
|
||||
|
||||
result = subprocess.run(
|
||||
cmd, capture_output=True, text=True, timeout=300,
|
||||
)
|
||||
|
||||
if result.returncode != 0:
|
||||
log.error("whisper-cli stderr: %s", result.stderr)
|
||||
raise RuntimeError(f"whisper-cli failed (rc={result.returncode}): {result.stderr[:200]}")
|
||||
|
||||
text = result.stdout.strip()
|
||||
text = text.replace("[BLANK_AUDIO]", "").strip()
|
||||
if text and _is_hallucination(text, duration_s):
|
||||
log.info("Hallucination filtered (%.1fs): %r", duration_s, text[:100])
|
||||
return ""
|
||||
log.info("Transcription: %r", text[:100])
|
||||
return text
|
||||
|
||||
_PARAKEET_WORKER_SCRIPT = (
|
||||
"import sys, json, os\n"
|
||||
"os.environ.setdefault('HF_HUB_DISABLE_TELEMETRY', '1')\n"
|
||||
"import nemo.collections.asr as nemo_asr\n"
|
||||
"import torch\n"
|
||||
"model_name = sys.argv[1]\n"
|
||||
"asr = nemo_asr.models.ASRModel.from_pretrained(model_name)\n"
|
||||
"if torch.cuda.is_available():\n"
|
||||
" asr = asr.cuda()\n"
|
||||
"asr.eval()\n"
|
||||
"sys.stdout.write('__PARAKEET_READY__\\n')\n"
|
||||
"sys.stdout.flush()\n"
|
||||
"for line in sys.stdin:\n"
|
||||
" wav = line.strip()\n"
|
||||
" if not wav: continue\n"
|
||||
" try:\n"
|
||||
" out = asr.transcribe([wav], timestamps=False, verbose=False)\n"
|
||||
" text = out[0].text or ''\n"
|
||||
" sys.stdout.write('__PARAKEET_RESULT__' + json.dumps({'text': text}) + '\\n')\n"
|
||||
" except Exception as e:\n"
|
||||
" sys.stdout.write('__PARAKEET_RESULT__' + json.dumps({'error': str(e)}) + '\\n')\n"
|
||||
" sys.stdout.flush()\n"
|
||||
)
|
||||
|
||||
def _ensure_parakeet_worker(self, model_name):
|
||||
"""Spawn persistent NeMo worker if not running or model changed."""
|
||||
if (self._parakeet_proc is not None
|
||||
and self._parakeet_proc.poll() is None
|
||||
and self._parakeet_model == model_name):
|
||||
return
|
||||
|
||||
if self._parakeet_proc is not None and self._parakeet_proc.poll() is None:
|
||||
log.info("Stopping parakeet worker (model change)")
|
||||
self._parakeet_proc.terminate()
|
||||
try:
|
||||
self._parakeet_proc.wait(timeout=5)
|
||||
except subprocess.TimeoutExpired:
|
||||
self._parakeet_proc.kill()
|
||||
|
||||
log.info("Starting parakeet worker for model: %s", model_name)
|
||||
self._parakeet_proc = subprocess.Popen(
|
||||
[PARAKEET_VENV_PYTHON, "-c", self._PARAKEET_WORKER_SCRIPT, model_name],
|
||||
stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=subprocess.PIPE,
|
||||
text=True, bufsize=1,
|
||||
)
|
||||
self._parakeet_model = model_name
|
||||
|
||||
# wait for ready signal
|
||||
while True:
|
||||
line = self._parakeet_proc.stdout.readline()
|
||||
if not line:
|
||||
err = self._parakeet_proc.stderr.read()
|
||||
raise RuntimeError(f"parakeet worker died at startup: {err[-500:]}")
|
||||
if line.strip() == "__PARAKEET_READY__":
|
||||
log.info("Parakeet worker ready")
|
||||
return
|
||||
|
||||
def _transcribe_parakeet(self, wav_path, model, duration_s):
|
||||
model_name = model[len(PARAKEET_PREFIX):]
|
||||
log.info("Transcribing parakeet (%.1fs): model=%s file=%s",
|
||||
duration_s, model_name, wav_path)
|
||||
|
||||
self._ensure_parakeet_worker(model_name)
|
||||
self._parakeet_proc.stdin.write(wav_path + "\n")
|
||||
self._parakeet_proc.stdin.flush()
|
||||
|
||||
line = self._parakeet_proc.stdout.readline()
|
||||
if not line:
|
||||
err = self._parakeet_proc.stderr.read()
|
||||
raise RuntimeError(f"parakeet worker died: {err[-500:]}")
|
||||
|
||||
import json
|
||||
if not line.startswith("__PARAKEET_RESULT__"):
|
||||
raise RuntimeError(f"unexpected parakeet output: {line[:200]}")
|
||||
data = json.loads(line[len("__PARAKEET_RESULT__"):])
|
||||
if "error" in data:
|
||||
raise RuntimeError(f"parakeet error: {data['error']}")
|
||||
text = data["text"].strip()
|
||||
|
||||
if text and _is_hallucination(text, duration_s):
|
||||
log.info("Hallucination filtered (%.1fs): %r", duration_s, text[:100])
|
||||
return ""
|
||||
log.info("Transcription: %r", text[:100])
|
||||
return text
|
||||
|
||||
def shutdown(self):
|
||||
"""Cleanly stop parakeet worker if running."""
|
||||
if self._parakeet_proc is not None and self._parakeet_proc.poll() is None:
|
||||
log.info("Shutting down parakeet worker")
|
||||
self._parakeet_proc.terminate()
|
||||
try:
|
||||
self._parakeet_proc.wait(timeout=5)
|
||||
except subprocess.TimeoutExpired:
|
||||
self._parakeet_proc.kill()
|
||||
|
|
@ -0,0 +1,780 @@
|
|||
"""System tray icon and menu for whisper.linux."""
|
||||
|
||||
import os
|
||||
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_raw = self._app_ref.config.model or ""
|
||||
current_model = (current_model_raw if current_model_raw.startswith("parakeet:")
|
||||
else Path(current_model_raw).name)
|
||||
downloaded = _list_models(self._app_ref.config.model_search_dirs)
|
||||
downloaded_names = {name for name, _ in downloaded}
|
||||
|
||||
if not downloaded and current_model_raw and not current_model_raw.startswith("parakeet:"):
|
||||
name = Path(current_model_raw).stem.replace("ggml-", "")
|
||||
downloaded = [(name, current_model_raw)]
|
||||
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)
|
||||
|
||||
# Parakeet V3 \u2014 separate engine (NVIDIA NeMo)
|
||||
menu.addSeparator()
|
||||
parakeet_id = "parakeet:nvidia/parakeet-tdt-0.6b-v3"
|
||||
parakeet_label = "Parakeet V3 (NVIDIA, GPU, ~600 MB)"
|
||||
if self._parakeet_available():
|
||||
a = QAction(parakeet_label, menu, checkable=True)
|
||||
a.setData(parakeet_id)
|
||||
if current_model_raw == parakeet_id:
|
||||
a.setChecked(True)
|
||||
a.triggered.connect(self._on_model_changed)
|
||||
self._model_group.addAction(a)
|
||||
menu.addAction(a)
|
||||
else:
|
||||
a = QAction(parakeet_label + " (venv missing)", menu)
|
||||
a.setEnabled(False)
|
||||
menu.addAction(a)
|
||||
self._kept_actions.append(a)
|
||||
|
||||
available = [(n, s) for n, s in AVAILABLE_MODELS if n not in downloaded_names]
|
||||
if available:
|
||||
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)
|
||||
|
||||
@staticmethod
|
||||
def _parakeet_available():
|
||||
from .transcriber import PARAKEET_VENV_PYTHON
|
||||
return os.path.isfile(PARAKEET_VENV_PYTHON) and os.access(PARAKEET_VENV_PYTHON, os.X_OK)
|
||||
|
||||
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)
|
||||
|
|
@ -0,0 +1,155 @@
|
|||
#!/usr/bin/env bash
|
||||
# install.sh — Install dependencies and set up whisper.linux
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||
REPO_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)"
|
||||
MODEL_NAME="${1:-base}"
|
||||
MODEL_FILE="ggml-${MODEL_NAME}.bin"
|
||||
CONFIG_DIR="$HOME/.config/whisper-linux"
|
||||
|
||||
echo "=== whisper.linux installer ==="
|
||||
echo ""
|
||||
|
||||
# --- 1. Install system dependencies ---
|
||||
echo "[1/4] Installing system dependencies..."
|
||||
sudo apt-get update -qq
|
||||
sudo apt-get install -y -qq \
|
||||
python3-pyqt5 \
|
||||
xdotool \
|
||||
xclip \
|
||||
wl-clipboard \
|
||||
ydotool \
|
||||
alsa-utils \
|
||||
build-essential \
|
||||
cmake
|
||||
|
||||
# Install Vulkan dev deps for GPU acceleration (optional, non-fatal)
|
||||
sudo apt-get install -y -qq libvulkan-dev glslc 2>/dev/null || true
|
||||
|
||||
echo " ✓ System dependencies installed"
|
||||
|
||||
# --- 2. Build whisper-cli ---
|
||||
echo "[2/4] Building whisper-cli..."
|
||||
BUILD_DIR="$REPO_ROOT/build"
|
||||
mkdir -p "$BUILD_DIR"
|
||||
|
||||
# Enable Vulkan GPU acceleration if available
|
||||
CMAKE_EXTRA=""
|
||||
if pkg-config --exists vulkan 2>/dev/null || [ -f /usr/include/vulkan/vulkan.h ]; then
|
||||
CMAKE_EXTRA="-DGGML_VULKAN=ON"
|
||||
echo " → Vulkan detected, building with GPU support"
|
||||
else
|
||||
echo " → Vulkan not found, building CPU-only"
|
||||
fi
|
||||
|
||||
cmake -S "$REPO_ROOT" -B "$BUILD_DIR" -DCMAKE_BUILD_TYPE=Release $CMAKE_EXTRA
|
||||
cmake --build "$BUILD_DIR" -j "$(nproc)"
|
||||
|
||||
WHISPER_CLI="$BUILD_DIR/bin/whisper-cli"
|
||||
if [ ! -f "$WHISPER_CLI" ]; then
|
||||
# Fallback: some builds put it directly in build/
|
||||
WHISPER_CLI="$BUILD_DIR/whisper-cli"
|
||||
fi
|
||||
|
||||
if [ ! -x "$WHISPER_CLI" ]; then
|
||||
echo " ✗ Failed to build whisper-cli"
|
||||
exit 1
|
||||
fi
|
||||
echo " ✓ whisper-cli built: $WHISPER_CLI"
|
||||
|
||||
# --- 3. Download model ---
|
||||
echo "[3/4] Downloading model ($MODEL_NAME)..."
|
||||
MODEL_DIR="$REPO_ROOT/models"
|
||||
MODEL_PATH="$MODEL_DIR/$MODEL_FILE"
|
||||
|
||||
if [ -f "$MODEL_PATH" ]; then
|
||||
echo " ✓ Model already exists: $MODEL_PATH"
|
||||
else
|
||||
bash "$REPO_ROOT/models/download-ggml-model.sh" "$MODEL_NAME"
|
||||
if [ ! -f "$MODEL_PATH" ]; then
|
||||
echo " ✗ Model download failed"
|
||||
exit 1
|
||||
fi
|
||||
echo " ✓ Model downloaded: $MODEL_PATH"
|
||||
fi
|
||||
|
||||
# --- 4. Create config ---
|
||||
echo "[4/4] Creating config..."
|
||||
mkdir -p "$CONFIG_DIR"
|
||||
CONFIG_FILE="$CONFIG_DIR/config.ini"
|
||||
|
||||
if [ -f "$CONFIG_FILE" ]; then
|
||||
echo " → Config exists: $CONFIG_FILE"
|
||||
# Update model and whisper_cli paths to match current build
|
||||
if grep -q "^model\s*=" "$CONFIG_FILE"; then
|
||||
sed -i "s|^model\s*=.*|model = $MODEL_PATH|" "$CONFIG_FILE"
|
||||
fi
|
||||
if grep -q "^whisper_cli\s*=" "$CONFIG_FILE"; then
|
||||
sed -i "s|^whisper_cli\s*=.*|whisper_cli = $WHISPER_CLI|" "$CONFIG_FILE"
|
||||
fi
|
||||
echo " ✓ Config updated (model=$MODEL_PATH)"
|
||||
else
|
||||
cat > "$CONFIG_FILE" <<EOF
|
||||
[whisper-linux]
|
||||
whisper_cli = $WHISPER_CLI
|
||||
model = $MODEL_PATH
|
||||
language = ru
|
||||
threads = 4
|
||||
display_server =
|
||||
use_clipboard_fallback = False
|
||||
notification = True
|
||||
EOF
|
||||
echo " ✓ Config created: $CONFIG_FILE"
|
||||
fi
|
||||
|
||||
# --- Optional: uinput access for ydotool (Wayland text injection) ---
|
||||
if [ -c /dev/uinput ]; then
|
||||
echo "[+] Setting up /dev/uinput access for ydotool..."
|
||||
# Persistent udev rule
|
||||
UINPUT_RULE="/etc/udev/rules.d/99-uinput-whisper.rules"
|
||||
if [ ! -f "$UINPUT_RULE" ]; then
|
||||
echo "KERNEL==\"uinput\", MODE=\"0660\", GROUP=\"$(id -gn)\"" | sudo tee "$UINPUT_RULE" > /dev/null
|
||||
sudo udevadm control --reload-rules
|
||||
sudo udevadm trigger /dev/uinput
|
||||
fi
|
||||
# Immediate fix for current session
|
||||
sudo chmod 0660 /dev/uinput
|
||||
sudo chown "root:$(id -gn)" /dev/uinput
|
||||
echo " ✓ /dev/uinput access configured"
|
||||
fi
|
||||
|
||||
# --- Optional: install desktop file for autostart ---
|
||||
AUTOSTART_DIR="$HOME/.config/autostart"
|
||||
DESKTOP_FILE="$SCRIPT_DIR/whisper-linux.desktop"
|
||||
if [ -f "$DESKTOP_FILE" ]; then
|
||||
mkdir -p "$AUTOSTART_DIR"
|
||||
# Update Exec path in desktop file
|
||||
sed "s|Exec=.*|Exec=python3 -m app|" \
|
||||
"$DESKTOP_FILE" > "$AUTOSTART_DIR/whisper-linux.desktop"
|
||||
echo " ✓ Autostart desktop file installed"
|
||||
fi
|
||||
|
||||
# --- Set up keyboard shortcut (GNOME) ---
|
||||
LAUNCHER="$SCRIPT_DIR/whisper-linux"
|
||||
TOGGLE_CMD="$LAUNCHER --toggle"
|
||||
echo ""
|
||||
echo "=== Installation complete ==="
|
||||
echo ""
|
||||
echo "Usage:"
|
||||
echo " $LAUNCHER # Start (tray icon)"
|
||||
echo " $LAUNCHER --toggle # Toggle recording"
|
||||
echo " $LAUNCHER --debug # Start with debug logging"
|
||||
echo " pkill -f whisper-linux # Stop"
|
||||
echo ""
|
||||
echo "=== Keyboard shortcut setup ==="
|
||||
echo " GNOME: Settings → Keyboard → Custom Shortcuts → Add:"
|
||||
echo " Name: whisper-linux"
|
||||
echo " Command: $TOGGLE_CMD"
|
||||
echo " Shortcut: Super+V (or any key you like)"
|
||||
echo ""
|
||||
echo " Or run this command to set it up automatically:"
|
||||
echo " gsettings set org.gnome.settings-daemon.plugins.media-keys custom-keybindings \"['/org/gnome/settings-daemon/plugins/media-keys/custom-keybindings/whisper-linux/']\""
|
||||
echo " gsettings set org.gnome.settings-daemon.plugins.media-keys.custom-keybinding:/org/gnome/settings-daemon/plugins/media-keys/custom-keybindings/whisper-linux/ name 'whisper-linux'"
|
||||
echo " gsettings set org.gnome.settings-daemon.plugins.media-keys.custom-keybinding:/org/gnome/settings-daemon/plugins/media-keys/custom-keybindings/whisper-linux/ command '$TOGGLE_CMD'"
|
||||
echo " gsettings set org.gnome.settings-daemon.plugins.media-keys.custom-keybinding:/org/gnome/settings-daemon/plugins/media-keys/custom-keybindings/whisper-linux/ binding 'F8'"
|
||||
|
|
@ -0,0 +1,6 @@
|
|||
#!/bin/bash
|
||||
# Run all whisper.linux tests.
|
||||
# Usage: ./run_tests.sh [-v] [--debug] [pytest-options...]
|
||||
|
||||
cd "$(dirname "$0")"
|
||||
python3 -m pytest tests/ -v "$@"
|
||||
|
|
@ -0,0 +1,133 @@
|
|||
"""Pytest fixtures for whisper.linux tests."""
|
||||
|
||||
import os
|
||||
import sys
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
# Add project root to path so we can import the 'app' package
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def tmp_config_dir(tmp_path):
|
||||
"""Temporary config directory."""
|
||||
config_dir = tmp_path / ".config" / "whisper-linux"
|
||||
config_dir.mkdir(parents=True)
|
||||
return config_dir
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def tmp_config_file(tmp_config_dir):
|
||||
"""Temporary config.ini with valid paths."""
|
||||
config_file = tmp_config_dir / "config.ini"
|
||||
config_file.write_text(
|
||||
"[whisper-linux]\n"
|
||||
"whisper_cli = /usr/bin/whisper-cli\n"
|
||||
"model = /tmp/ggml-base.bin\n"
|
||||
"models_dir = /tmp\n"
|
||||
"language = ru\n"
|
||||
"threads = 4\n"
|
||||
"display_server = x11\n"
|
||||
"audio_device = plughw:1,0\n"
|
||||
"paste_keys = shift+Insert\n"
|
||||
"use_clipboard_fallback = False\n"
|
||||
"notification = True\n"
|
||||
)
|
||||
return config_file
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_config(tmp_config_file, monkeypatch):
|
||||
"""Config that reads from temp config file."""
|
||||
import app as wl
|
||||
|
||||
monkeypatch.setattr(wl.config, "CONFIG_DIR", tmp_config_file.parent)
|
||||
monkeypatch.setattr(wl.config, "CONFIG_FILE", tmp_config_file)
|
||||
return wl.Config()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_config_wayland(mock_config):
|
||||
"""Config set to Wayland."""
|
||||
mock_config.display_server = "wayland"
|
||||
return mock_config
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def tmp_wav_file():
|
||||
"""Create a temporary WAV file for testing."""
|
||||
fd, path = tempfile.mkstemp(suffix=".wav", prefix="test-whisper-")
|
||||
os.write(fd, b"RIFF" + b"\x00" * 40) # Minimal WAV header stub
|
||||
os.close(fd)
|
||||
yield path
|
||||
if os.path.exists(path):
|
||||
os.unlink(path)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def tmp_pid_file(tmp_path, monkeypatch):
|
||||
"""Temporary PID file."""
|
||||
import app as wl
|
||||
|
||||
pid_file = tmp_path / "whisper-linux.pid"
|
||||
monkeypatch.setattr(wl.config, "PID_FILE", pid_file)
|
||||
return pid_file
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_config_stream(mock_config):
|
||||
"""Config set to listen+stream mode (old 'stream' mode)."""
|
||||
mock_config.input_mode = "listen"
|
||||
mock_config.output_mode = "stream"
|
||||
mock_config.wake_word = "дуняша"
|
||||
mock_config.silence_timeout = 3.0
|
||||
mock_config.vad_threshold = 300
|
||||
mock_config.min_speech_ms = 500
|
||||
mock_config.max_speech_s = 30.0
|
||||
return mock_config
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_config_hotkey_stream(mock_config):
|
||||
"""Config set to hotkey+stream mode."""
|
||||
mock_config.input_mode = "hotkey"
|
||||
mock_config.output_mode = "stream"
|
||||
mock_config.wake_word = "дуняша"
|
||||
mock_config.silence_timeout = 3.0
|
||||
mock_config.vad_threshold = 300
|
||||
mock_config.min_speech_ms = 500
|
||||
mock_config.max_speech_s = 30.0
|
||||
return mock_config
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_config_listen_batch(mock_config):
|
||||
"""Config set to listen+batch mode."""
|
||||
mock_config.input_mode = "listen"
|
||||
mock_config.output_mode = "batch"
|
||||
mock_config.wake_word = "дуняша"
|
||||
mock_config.silence_timeout = 3.0
|
||||
mock_config.vad_threshold = 300
|
||||
mock_config.min_speech_ms = 500
|
||||
mock_config.max_speech_s = 30.0
|
||||
return mock_config
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def silence_pcm():
|
||||
"""Return 1 second of silence (s16le mono 16kHz)."""
|
||||
return b"\x00\x00" * 16000
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def speech_pcm():
|
||||
"""Return 1 second of loud 'speech' (s16le mono 16kHz, high amplitude sine)."""
|
||||
import array, math
|
||||
samples = array.array("h")
|
||||
for i in range(16000):
|
||||
samples.append(int(10000 * math.sin(2 * math.pi * 440 * i / 16000)))
|
||||
return samples.tobytes()
|
||||
File diff suppressed because it is too large
Load Diff
|
|
@ -0,0 +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 "$@"
|
||||
|
|
@ -0,0 +1,10 @@
|
|||
[Desktop Entry]
|
||||
Type=Application
|
||||
Name=whisper.linux
|
||||
Comment=Voice typing for Linux desktop
|
||||
Exec=~/whisper.linux/whisper-linux
|
||||
Icon=audio-input-microphone
|
||||
Terminal=false
|
||||
Categories=Utility;Audio;
|
||||
StartupNotify=false
|
||||
X-GNOME-Autostart-enabled=true
|
||||
|
|
@ -0,0 +1,4 @@
|
|||
videos/
|
||||
.venv-parakeet/
|
||||
__pycache__/
|
||||
*.pyc
|
||||
|
|
@ -0,0 +1,12 @@
|
|||
#!/usr/bin/env bash
|
||||
# Parakeet V3 transcription launcher.
|
||||
# Usage:
|
||||
# ./parakeet <wav-file>
|
||||
# ./parakeet <youtube-url>
|
||||
set -euo pipefail
|
||||
DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
# numba cannot write its cache on the mounted filesystem under this dir
|
||||
# (no inode-stable locator); redirect cache to /tmp.
|
||||
export NUMBA_CACHE_DIR="${NUMBA_CACHE_DIR:-/tmp/numba-cache-$USER}"
|
||||
mkdir -p "$NUMBA_CACHE_DIR"
|
||||
exec "$DIR/.venv-parakeet/bin/python" "$DIR/parakeet_transcribe.py" "$@"
|
||||
|
|
@ -0,0 +1,152 @@
|
|||
#!/usr/bin/env python3
|
||||
"""Live microphone streaming with Parakeet-tdt-0.6b-v3.
|
||||
|
||||
Captures audio from the mic (pw-record or arecord), runs a sliding-window
|
||||
transcription, and prints text progressively as new audio arrives.
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import os
|
||||
import shutil
|
||||
import signal
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import time
|
||||
|
||||
import numpy as np
|
||||
|
||||
MODEL_NAME = "nvidia/parakeet-tdt-0.6b-v3"
|
||||
SR = 16000
|
||||
DEFAULT_WINDOW_S = 8.0
|
||||
DEFAULT_SHIFT_S = 2.5
|
||||
|
||||
|
||||
def _norm(w: str) -> str:
|
||||
return "".join(ch for ch in w.lower() if ch.isalnum())
|
||||
|
||||
|
||||
def _dedup_overlap(prev_text: str, curr_text: str) -> str:
|
||||
prev_words = prev_text.split()
|
||||
curr_words = curr_text.split()
|
||||
prev_norm = [_norm(w) for w in prev_words]
|
||||
curr_norm = [_norm(w) for w in curr_words]
|
||||
max_n = min(len(prev_norm), len(curr_norm))
|
||||
for n in range(max_n, 0, -1):
|
||||
if prev_norm[-n:] == curr_norm[:n]:
|
||||
return " ".join(curr_words[n:])
|
||||
return curr_text
|
||||
|
||||
|
||||
def _find_recorder():
|
||||
for p in ("pw-record", "/usr/bin/pw-record", "/usr/local/bin/pw-record"):
|
||||
if shutil.which(p) if not os.path.isabs(p) else os.path.isfile(p):
|
||||
return p
|
||||
return shutil.which("arecord")
|
||||
|
||||
|
||||
def _build_record_cmd():
|
||||
rec = _find_recorder()
|
||||
if not rec:
|
||||
raise RuntimeError("Neither pw-record nor arecord found")
|
||||
if "pw-record" in rec:
|
||||
return [rec, "--format", "s16", "--rate", str(SR), "--channels", "1", "-"]
|
||||
return [rec, "-f", "S16_LE", "-r", str(SR), "-c", "1", "-t", "raw"]
|
||||
|
||||
|
||||
def load_model():
|
||||
import nemo.collections.asr as nemo_asr
|
||||
from nemo.utils import logging as nemo_logging
|
||||
import torch
|
||||
import logging
|
||||
|
||||
nemo_logging.set_verbosity(nemo_logging.ERROR)
|
||||
logging.getLogger("nemo_logger").setLevel(logging.ERROR)
|
||||
print(f"Loading {MODEL_NAME} ...", flush=True)
|
||||
asr = nemo_asr.models.ASRModel.from_pretrained(MODEL_NAME)
|
||||
asr = asr.cuda() if torch.cuda.is_available() else asr
|
||||
nemo_logging.set_verbosity(nemo_logging.ERROR)
|
||||
return asr
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description=__doc__.splitlines()[0])
|
||||
parser.add_argument("--window", type=float, default=DEFAULT_WINDOW_S,
|
||||
help=f"sliding window length in seconds (default: {DEFAULT_WINDOW_S})")
|
||||
parser.add_argument("--shift", type=float, default=DEFAULT_SHIFT_S,
|
||||
help=f"window shift / step in seconds (default: {DEFAULT_SHIFT_S})")
|
||||
args = parser.parse_args()
|
||||
|
||||
if args.shift <= 0 or args.window <= 0:
|
||||
sys.exit("window and shift must be positive")
|
||||
if args.shift > args.window:
|
||||
sys.exit("shift must be <= window")
|
||||
|
||||
import soundfile as sf
|
||||
|
||||
asr = load_model()
|
||||
cmd = _build_record_cmd()
|
||||
|
||||
win_samples = int(args.window * SR)
|
||||
shift_samples = int(args.shift * SR)
|
||||
shift_bytes = shift_samples * 2 # int16 mono
|
||||
|
||||
tmp = tempfile.NamedTemporaryFile(suffix=".wav", prefix="parakeet-mic-", delete=False)
|
||||
tmp_wav = tmp.name
|
||||
tmp.close()
|
||||
|
||||
print(f"Recorder: {cmd[0]}", flush=True)
|
||||
print(f"Window: {args.window:.1f}s, shift: {args.shift:.1f}s "
|
||||
f"(overlap: {args.window - args.shift:.1f}s)", flush=True)
|
||||
print("--- speak into mic; Ctrl-C to stop ---\n", flush=True)
|
||||
|
||||
proc = subprocess.Popen(cmd, stdout=subprocess.PIPE, stderr=subprocess.DEVNULL)
|
||||
|
||||
def _shutdown(*_):
|
||||
if proc.poll() is None:
|
||||
proc.send_signal(signal.SIGINT)
|
||||
try:
|
||||
proc.wait(timeout=2)
|
||||
except subprocess.TimeoutExpired:
|
||||
proc.kill()
|
||||
if os.path.exists(tmp_wav):
|
||||
os.unlink(tmp_wav)
|
||||
print("\n--- stopped ---", flush=True)
|
||||
sys.exit(0)
|
||||
|
||||
signal.signal(signal.SIGINT, _shutdown)
|
||||
signal.signal(signal.SIGTERM, _shutdown)
|
||||
|
||||
buffer = np.zeros(0, dtype=np.int16)
|
||||
prev_text = ""
|
||||
|
||||
try:
|
||||
while True:
|
||||
new_bytes = proc.stdout.read(shift_bytes)
|
||||
if not new_bytes:
|
||||
break
|
||||
new_samples = np.frombuffer(new_bytes, dtype=np.int16)
|
||||
buffer = np.concatenate([buffer, new_samples])
|
||||
if len(buffer) > win_samples:
|
||||
buffer = buffer[-win_samples:]
|
||||
if len(buffer) < shift_samples:
|
||||
continue
|
||||
|
||||
sf.write(tmp_wav, buffer, SR)
|
||||
t0 = time.monotonic()
|
||||
out = asr.transcribe([tmp_wav], timestamps=False, verbose=False)
|
||||
elapsed = time.monotonic() - t0
|
||||
current = out[0].text.strip()
|
||||
if not current:
|
||||
continue
|
||||
|
||||
new_part = _dedup_overlap(prev_text, current) if prev_text else current
|
||||
if new_part.strip():
|
||||
print(new_part, end=" ", flush=True)
|
||||
prev_text = current
|
||||
finally:
|
||||
_shutdown()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
|
|
@ -0,0 +1,180 @@
|
|||
#!/usr/bin/env python3
|
||||
"""Transcribe audio with NVIDIA Parakeet-tdt-0.6b-v3 (multilingual).
|
||||
|
||||
Modes:
|
||||
batch — transcribe the whole audio at once, print at the end (default).
|
||||
stream — sliding window with LocalAgreement: progressive text output as
|
||||
new audio arrives. Each window of WINDOW seconds is re-transcribed
|
||||
every SHIFT seconds; tokens agreed between two consecutive windows
|
||||
are "confirmed" and printed; the rest stays uncertain until the
|
||||
next iteration confirms (or invalidates) it.
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
|
||||
MODEL_NAME = "nvidia/parakeet-tdt-0.6b-v3"
|
||||
|
||||
# Sliding-window streaming defaults (in seconds).
|
||||
# Matches whisper.cpp/examples/stream defaults.
|
||||
DEFAULT_WINDOW_S = 10.0
|
||||
DEFAULT_SHIFT_S = 3.0
|
||||
|
||||
|
||||
def download_audio(url: str, out_path: str):
|
||||
subprocess.run([
|
||||
"yt-dlp", "-x", "--audio-format", "wav",
|
||||
"-o", out_path, url,
|
||||
], check=True)
|
||||
|
||||
|
||||
def convert_wav(src: str, dst: str):
|
||||
subprocess.run([
|
||||
"ffmpeg", "-y", "-i", src,
|
||||
"-ar", "16000", "-ac", "1", "-f", "wav", dst,
|
||||
], check=True, capture_output=True)
|
||||
|
||||
|
||||
def load_model(long_audio: bool = True):
|
||||
import nemo.collections.asr as nemo_asr
|
||||
import torch
|
||||
|
||||
print(f"Loading {MODEL_NAME} ...", flush=True)
|
||||
asr = nemo_asr.models.ASRModel.from_pretrained(MODEL_NAME)
|
||||
asr = asr.cuda() if torch.cuda.is_available() else asr
|
||||
if long_audio:
|
||||
asr.change_attention_model(
|
||||
self_attention_model="rel_pos_local_attn",
|
||||
att_context_size=[256, 256],
|
||||
)
|
||||
return asr
|
||||
|
||||
|
||||
def transcribe_batch(asr, wav_path: str) -> str:
|
||||
print(f"Transcribing {wav_path} ...", flush=True)
|
||||
out = asr.transcribe([wav_path], timestamps=False)
|
||||
return out[0].text
|
||||
|
||||
|
||||
def _norm(w: str) -> str:
|
||||
"""Normalize a word for dedup comparison: lower-case, strip punctuation."""
|
||||
return "".join(ch for ch in w.lower() if ch.isalnum())
|
||||
|
||||
|
||||
def _dedup_overlap(prev_text: str, curr_text: str) -> str:
|
||||
"""Return the new portion of curr_text by removing the trailing words of
|
||||
prev_text that match the leading words of curr_text (overlap region).
|
||||
Comparison is case- and punctuation-insensitive."""
|
||||
prev_words = prev_text.split()
|
||||
curr_words = curr_text.split()
|
||||
prev_norm = [_norm(w) for w in prev_words]
|
||||
curr_norm = [_norm(w) for w in curr_words]
|
||||
max_n = min(len(prev_norm), len(curr_norm))
|
||||
for n in range(max_n, 0, -1):
|
||||
if prev_norm[-n:] == curr_norm[:n]:
|
||||
return " ".join(curr_words[n:])
|
||||
return curr_text
|
||||
|
||||
|
||||
def transcribe_sliding(asr, wav_path: str, window_s: float, shift_s: float) -> str:
|
||||
"""Sliding-window streaming: each window of WINDOW seconds is transcribed
|
||||
every SHIFT seconds; words overlapping with the previous window are
|
||||
de-duplicated and only the new tail is printed."""
|
||||
import soundfile as sf
|
||||
|
||||
if shift_s <= 0 or window_s <= 0:
|
||||
raise ValueError("window and shift must be positive")
|
||||
if shift_s > window_s:
|
||||
raise ValueError("shift must be <= window")
|
||||
|
||||
audio, sr = sf.read(wav_path)
|
||||
if sr != 16000:
|
||||
raise ValueError(f"Expected 16kHz WAV, got {sr}Hz")
|
||||
|
||||
n_samples = len(audio)
|
||||
win = int(window_s * sr)
|
||||
shift = int(shift_s * sr)
|
||||
overlap_s = window_s - shift_s
|
||||
|
||||
print(
|
||||
f"Sliding window: {window_s:.1f}s window, {shift_s:.1f}s shift "
|
||||
f"({overlap_s:.1f}s overlap), audio {n_samples / sr:.1f}s",
|
||||
flush=True,
|
||||
)
|
||||
print("--- streaming transcription ---", flush=True)
|
||||
|
||||
tmp = tempfile.NamedTemporaryFile(suffix=".wav", prefix="parakeet-slide-",
|
||||
delete=False)
|
||||
tmp_wav = tmp.name
|
||||
tmp.close()
|
||||
|
||||
full_text_parts = []
|
||||
prev_text = ""
|
||||
pos = 0
|
||||
|
||||
try:
|
||||
while pos < n_samples:
|
||||
end = min(pos + win, n_samples)
|
||||
chunk = audio[pos:end]
|
||||
sf.write(tmp_wav, chunk, sr)
|
||||
|
||||
out = asr.transcribe([tmp_wav], timestamps=False, verbose=False)
|
||||
current = out[0].text.strip()
|
||||
|
||||
new_part = _dedup_overlap(prev_text, current) if prev_text else current
|
||||
if new_part:
|
||||
print(new_part, end=" ", flush=True)
|
||||
full_text_parts.append(new_part)
|
||||
|
||||
prev_text = current
|
||||
pos += shift
|
||||
|
||||
print(flush=True)
|
||||
return " ".join(full_text_parts).strip()
|
||||
finally:
|
||||
if os.path.exists(tmp_wav):
|
||||
os.unlink(tmp_wav)
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description=__doc__.splitlines()[0])
|
||||
parser.add_argument("source", help="WAV file path or YouTube URL")
|
||||
parser.add_argument("--stream", action="store_true",
|
||||
help="streaming mode (sliding window + LocalAgreement)")
|
||||
parser.add_argument("--window", type=float, default=DEFAULT_WINDOW_S,
|
||||
help=f"sliding window length in seconds (default: {DEFAULT_WINDOW_S})")
|
||||
parser.add_argument("--shift", type=float, default=DEFAULT_SHIFT_S,
|
||||
help=f"window shift in seconds (default: {DEFAULT_SHIFT_S}); "
|
||||
"overlap = window - shift")
|
||||
args = parser.parse_args()
|
||||
|
||||
src = args.source
|
||||
|
||||
if src.startswith(("http://", "https://", "youtu", "www.")):
|
||||
tmp = tempfile.mkdtemp(prefix="parakeet-")
|
||||
raw = os.path.join(tmp, "audio.%(ext)s")
|
||||
wav = os.path.join(tmp, "audio_16k.wav")
|
||||
print(f"Downloading {src} ...", flush=True)
|
||||
download_audio(src, raw)
|
||||
downloaded = [f for f in os.listdir(tmp) if f.startswith("audio.")]
|
||||
print("Converting to 16kHz WAV ...", flush=True)
|
||||
convert_wav(os.path.join(tmp, downloaded[0]), wav)
|
||||
wav_path = wav
|
||||
else:
|
||||
wav_path = src
|
||||
|
||||
asr = load_model(long_audio=not args.stream)
|
||||
|
||||
if args.stream:
|
||||
text = transcribe_sliding(asr, wav_path, args.window, args.shift)
|
||||
else:
|
||||
text = transcribe_batch(asr, wav_path)
|
||||
print("\n=== Transcription ===")
|
||||
print(text)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
File diff suppressed because one or more lines are too long
|
|
@ -0,0 +1,82 @@
|
|||
#!/usr/bin/env python3
|
||||
"""Download audio from YouTube and transcribe with local whisper-cli."""
|
||||
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
|
||||
REPO_ROOT = Path(__file__).resolve().parent.parent.parent
|
||||
WHISPER_CLI = REPO_ROOT / "build" / "bin" / "whisper-cli"
|
||||
MODEL = REPO_ROOT / "models" / "ggml-large-v3.bin"
|
||||
|
||||
|
||||
def download_audio(url: str, out_path: str):
|
||||
"""Download audio from YouTube URL using yt-dlp."""
|
||||
subprocess.run([
|
||||
"yt-dlp", "-x", "--audio-format", "wav",
|
||||
"-o", out_path, url,
|
||||
], check=True)
|
||||
|
||||
|
||||
def convert_wav(src: str, dst: str):
|
||||
"""Convert audio to 16kHz mono WAV for whisper."""
|
||||
subprocess.run([
|
||||
"ffmpeg", "-y", "-i", src,
|
||||
"-ar", "16000", "-ac", "1", "-f", "wav", dst,
|
||||
], check=True, capture_output=True)
|
||||
|
||||
|
||||
def transcribe(wav_path: str) -> str:
|
||||
"""Run whisper-cli on a WAV file and return text."""
|
||||
result = subprocess.run([
|
||||
str(WHISPER_CLI), "-m", str(MODEL),
|
||||
"-f", wav_path, "-nt", "-np", "-l", "auto",
|
||||
], capture_output=True, text=True)
|
||||
if result.returncode != 0:
|
||||
print(f"whisper-cli error: {result.stderr[:500]}", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
return result.stdout.strip().replace("[BLANK_AUDIO]", "").strip()
|
||||
|
||||
|
||||
def main():
|
||||
if len(sys.argv) < 2:
|
||||
print(f"Usage: {sys.argv[0]} <youtube-url>")
|
||||
sys.exit(1)
|
||||
|
||||
url = sys.argv[1]
|
||||
|
||||
if not WHISPER_CLI.exists():
|
||||
print(f"whisper-cli not found: {WHISPER_CLI}", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
if not MODEL.exists():
|
||||
print(f"Model not found: {MODEL}\nRun: bash models/download-ggml-model.sh base", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
with tempfile.TemporaryDirectory(prefix="yt-whisper-") as tmpdir:
|
||||
raw_audio = os.path.join(tmpdir, "audio.%(ext)s")
|
||||
wav_16k = os.path.join(tmpdir, "audio_16k.wav")
|
||||
|
||||
print(f"Downloading audio from {url} ...")
|
||||
download_audio(url, raw_audio)
|
||||
|
||||
# find the downloaded file (yt-dlp replaces %(ext)s)
|
||||
downloaded = [f for f in os.listdir(tmpdir) if f.startswith("audio.")]
|
||||
if not downloaded:
|
||||
print("Download failed: no audio file found", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
raw_path = os.path.join(tmpdir, downloaded[0])
|
||||
|
||||
print("Converting to 16kHz WAV ...")
|
||||
convert_wav(raw_path, wav_16k)
|
||||
|
||||
print("Transcribing ...")
|
||||
text = transcribe(wav_16k)
|
||||
|
||||
print("\n=== Transcription ===")
|
||||
print(text)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Loading…
Reference in New Issue