whisper.linux: integrate Parakeet V3 as alternative ASR engine

- transcriber.py: route models prefixed with "parakeet:" to a persistent
  NeMo worker subprocess (spawned in .venv-parakeet); stdin/stdout JSON
  protocol keeps the model loaded between transcribe() calls
- tray.py: add "Parakeet V3 (NVIDIA, GPU)" to the Model menu; greys out
  when the parakeet venv is missing
- config.py: list more whisper variants (large-v2, large-v3-turbo and
  q5_0/q8_0 quantized variants)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
nnnet 2026-05-11 21:38:02 +03:00
parent 3ea11b4e5b
commit cdbcfb6603
3 changed files with 155 additions and 13 deletions

View File

@ -66,11 +66,17 @@ DEFAULT_VOICE_COMMANDS = {
}
AVAILABLE_MODELS = [
("tiny", "~75 MB"),
("base", "~142 MB"),
("small", "~466 MB"),
("medium", "~1.5 GB"),
("large-v3", "~3.1 GB"),
("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"),
]

View File

@ -142,20 +142,35 @@ class WakeWordDetector:
# 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 and returns transcribed text."""
"""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 or self.config.model,
"-m", model,
"-f", wav_path,
"-nt",
"-np",
@ -163,7 +178,7 @@ class Transcriber:
"-l", self.config.language,
"-dev", str(self.config.gpu_device),
]
log.info("Transcribing (%.1fs): %s", duration_s, " ".join(cmd))
log.info("Transcribing whisper (%.1fs): %s", duration_s, " ".join(cmd))
result = subprocess.run(
cmd, capture_output=True, text=True, timeout=300,
@ -180,3 +195,98 @@ class Transcriber:
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()

View File

@ -1,5 +1,6 @@
"""System tray icon and menu for whisper.linux."""
import os
import subprocess
import threading
from pathlib import Path
@ -180,13 +181,15 @@ class TrayIcon:
for a in self._model_group.actions():
self._model_group.removeAction(a)
current_model = Path(self._app_ref.config.model).name if self._app_ref.config.model else ""
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 self._app_ref.config.model:
name = Path(self._app_ref.config.model).stem.replace("ggml-", "")
downloaded = [(name, self._app_ref.config.model)]
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:
@ -200,8 +203,26 @@ class TrayIcon:
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 and downloaded:
if available:
menu.addSeparator()
for name, size in available:
@ -215,6 +236,11 @@ class TrayIcon:
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