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:
parent
3ea11b4e5b
commit
cdbcfb6603
|
|
@ -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"),
|
||||
]
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
Loading…
Reference in New Issue