From cdbcfb6603f93de32b6a8111ce2e7f7f1c425032 Mon Sep 17 00:00:00 2001 From: nnnet Date: Mon, 11 May 2026 21:38:02 +0300 Subject: [PATCH] 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) --- examples/whisper.linux/app/config.py | 16 ++- examples/whisper.linux/app/transcriber.py | 116 +++++++++++++++++++++- examples/whisper.linux/app/tray.py | 36 ++++++- 3 files changed, 155 insertions(+), 13 deletions(-) diff --git a/examples/whisper.linux/app/config.py b/examples/whisper.linux/app/config.py index 56ace4739..9b9e5af7d 100644 --- a/examples/whisper.linux/app/config.py +++ b/examples/whisper.linux/app/config.py @@ -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"), ] diff --git a/examples/whisper.linux/app/transcriber.py b/examples/whisper.linux/app/transcriber.py index af66a464d..c211bcafe 100644 --- a/examples/whisper.linux/app/transcriber.py +++ b/examples/whisper.linux/app/transcriber.py @@ -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() diff --git a/examples/whisper.linux/app/tray.py b/examples/whisper.linux/app/tray.py index dab309fa1..4d0e5a523 100644 --- a/examples/whisper.linux/app/tray.py +++ b/examples/whisper.linux/app/tray.py @@ -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