From 3026f1ff90fa476ff7b3e92b0fbb94e5432da8ad Mon Sep 17 00:00:00 2001 From: mARTin-B78 Date: Thu, 4 Jun 2026 20:21:03 +0200 Subject: [PATCH] Add STT + LLM engine presets, rename entry to blitztext.py, macOS app icon - stt.py: local + remote (OpenAI-compatible) transcription, TCP reachability status, benchmark. Tested live against faster-whisper-server :8010. - llm.py: LLM engine presets (url/model/key/temperature) + chat + status. Tested live against local Qwen :19001 and llama-swap :28080. - config: [stt]/[[stt_engine]] and [llm]/[[llm_engine]] presets with active selectors; defaults synthesized from legacy [whisper]/[rewrite]. - daemon: transcribe via active STT engine, rewrite via active LLM engine; load local Whisper only when the active STT engine is local. - Rename package entry __main__.py -> blitztext.py (thin __main__ shim keeps `python -m blitztext` working). Remove rewrite.py (folded into llm.py). - App icon extracted from the macOS AppIcon.icns; installed at several hicolor sizes by the .deb and used as the window icon. Drop placeholder SVG. Co-Authored-By: Claude Opus 4.8 --- linux/blitztext/__main__.py | 56 +---------- linux/blitztext/blitztext.py | 62 ++++++++++++ linux/blitztext/config.py | 106 +++++++++++++++++++++ linux/blitztext/daemon.py | 52 ++++++---- linux/blitztext/gtkui.py | 4 +- linux/blitztext/llm.py | 87 +++++++++++++++++ linux/blitztext/rewrite.py | 73 -------------- linux/blitztext/stt.py | 152 ++++++++++++++++++++++++++++++ linux/packaging/blitztext.png | Bin 0 -> 6637 bytes linux/packaging/blitztext.svg | 12 --- linux/packaging/blitztext_128.png | Bin 0 -> 978 bytes linux/packaging/blitztext_256.png | Bin 0 -> 1546 bytes linux/packaging/blitztext_32.png | Bin 0 -> 607 bytes linux/packaging/blitztext_48.png | Bin 0 -> 742 bytes linux/packaging/blitztext_64.png | Bin 0 -> 839 bytes linux/packaging/build-deb.sh | 5 +- 16 files changed, 449 insertions(+), 160 deletions(-) create mode 100644 linux/blitztext/blitztext.py create mode 100644 linux/blitztext/llm.py delete mode 100644 linux/blitztext/rewrite.py create mode 100644 linux/blitztext/stt.py create mode 100644 linux/packaging/blitztext.png delete mode 100644 linux/packaging/blitztext.svg create mode 100644 linux/packaging/blitztext_128.png create mode 100644 linux/packaging/blitztext_256.png create mode 100644 linux/packaging/blitztext_32.png create mode 100644 linux/packaging/blitztext_48.png create mode 100644 linux/packaging/blitztext_64.png diff --git a/linux/blitztext/__main__.py b/linux/blitztext/__main__.py index b749e75..9528353 100644 --- a/linux/blitztext/__main__.py +++ b/linux/blitztext/__main__.py @@ -1,58 +1,6 @@ -"""CLI entrypoint: `python -m blitztext [run|transcribe|config-path]`.""" - -from __future__ import annotations - -import argparse -import sys -from pathlib import Path - -from . import __version__ -from .config import CONFIG_PATH, ensure_default, load - - -def main(argv: list[str] | None = None) -> int: - parser = argparse.ArgumentParser(prog="blitztext", description="Native dictation for Linux.") - parser.add_argument("--version", action="version", version=f"blitztext {__version__}") - sub = parser.add_subparsers(dest="cmd") - - sub.add_parser("tray", help="Run in the system tray with a workflow menu (default).") - sub.add_parser("gui", help="Open the control panel window.") - sub.add_parser("run", help="Start the headless hotkey daemon (no window/tray).") - sub.add_parser("config-path", help="Print the config file path and exit.") - p_tx = sub.add_parser("transcribe", help="Transcribe a WAV file and print the text (no hotkeys).") - p_tx.add_argument("audio", type=Path) - - args = parser.parse_args(argv) - cmd = args.cmd or "tray" - - if cmd == "config-path": - print(ensure_default(CONFIG_PATH)) - return 0 - - if cmd in ("gui", "tray"): - ensure_default(CONFIG_PATH) - from .gtkui import run_gui - - return run_gui(tray_mode=(cmd == "tray")) - - cfg = load() - - if cmd == "transcribe": - from .transcribe import Transcriber - - tx = Transcriber(cfg.model, cfg.device, cfg.compute_type, cfg.beam_size) - print(tx.transcribe(args.audio, language=cfg.language)) - return 0 - - # cmd == "run" - from .daemon import Daemon - - try: - Daemon(cfg).run() - except KeyboardInterrupt: - print("\n[blitztext] stopped.", file=sys.stderr) - return 0 +"""Module entry point so `python -m blitztext` runs the app in blitztext.py.""" +from .blitztext import main if __name__ == "__main__": raise SystemExit(main()) diff --git a/linux/blitztext/blitztext.py b/linux/blitztext/blitztext.py new file mode 100644 index 0000000..6835fe3 --- /dev/null +++ b/linux/blitztext/blitztext.py @@ -0,0 +1,62 @@ +"""Blitztext application entry point (CLI + GUI/tray launcher). + +The thin ``__main__.py`` shim imports ``main()`` from here, so the app's code +lives in ``blitztext.py`` while ``python -m blitztext`` still works. +""" + +from __future__ import annotations + +import argparse +import sys +from pathlib import Path + +from . import __version__ +from .config import CONFIG_PATH, ensure_default, load + + +def main(argv: list[str] | None = None) -> int: + parser = argparse.ArgumentParser(prog="blitztext", description="Native dictation for Linux.") + parser.add_argument("--version", action="version", version=f"blitztext {__version__}") + sub = parser.add_subparsers(dest="cmd") + + sub.add_parser("tray", help="Run in the system tray with a workflow menu (default).") + sub.add_parser("gui", help="Open the control panel window.") + sub.add_parser("run", help="Start the headless hotkey daemon (no window/tray).") + sub.add_parser("config-path", help="Print the config file path and exit.") + p_tx = sub.add_parser("transcribe", help="Transcribe a WAV file and print the text (no hotkeys).") + p_tx.add_argument("audio", type=Path) + + args = parser.parse_args(argv) + cmd = args.cmd or "tray" + + if cmd == "config-path": + print(ensure_default(CONFIG_PATH)) + return 0 + + if cmd in ("gui", "tray"): + ensure_default(CONFIG_PATH) + from .gtkui import run_gui + + return run_gui(tray_mode=(cmd == "tray")) + + cfg = load() + + if cmd == "transcribe": + from .transcribe import Transcriber + + tx = Transcriber(cfg.model, cfg.device, cfg.compute_type, cfg.beam_size) + print(tx.transcribe(args.audio, language=cfg.language)) + return 0 + + # cmd == "run" + from .daemon import Daemon + + try: + Daemon(cfg).run() + except KeyboardInterrupt: + print("\n[blitztext] stopped.", file=sys.stderr) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/linux/blitztext/config.py b/linux/blitztext/config.py index 04164ee..e2415c1 100644 --- a/linux/blitztext/config.py +++ b/linux/blitztext/config.py @@ -7,6 +7,9 @@ import tomllib from dataclasses import dataclass, field from pathlib import Path +from .llm import LLMEngine +from .stt import STTEngine + CONFIG_DIR = Path(os.environ.get("XDG_CONFIG_HOME", Path.home() / ".config")) / "blitztext" CONFIG_PATH = CONFIG_DIR / "config.toml" @@ -51,9 +54,31 @@ class Config: routing_hotkey: str = "++" routing_default: str = "" # preset name used when no keyword matches; "" = first routing_threshold: float = 0.82 + # speech-to-text engines (presets) + stt_engines: list[STTEngine] = field(default_factory=list) + stt_active: str = "" + # llm engines (presets) for the rewrite step + llm_engines: list[LLMEngine] = field(default_factory=list) + llm_active: str = "" # workflows workflows: list[Workflow] = field(default_factory=list) + @property + def active_stt(self) -> STTEngine: + e = next((x for x in self.stt_engines if x.name == self.stt_active), None) + if e: + return e + return self.stt_engines[0] if self.stt_engines else STTEngine("Local", "local", model=self.model) + + @property + def active_llm(self) -> LLMEngine: + e = next((x for x in self.llm_engines if x.name == self.llm_active), None) + if e: + return e + if self.llm_engines: + return self.llm_engines[0] + return LLMEngine("Default", self.base_url, self.rewrite_model, self.api_key_env, self.temperature) + def preset_by_name(self, name: str | None) -> "Workflow | None": if not name: return None @@ -127,6 +152,32 @@ def load(path: Path = CONFIG_PATH) -> Config: if not cfg.workflows: raise ValueError(f"No [[workflow]] entries defined in {path}") + # STT engines (default: a single local faster-whisper engine). + cfg.stt_engines = [ + STTEngine( + name=e["name"], + type=e.get("type", "local"), + url=e.get("url", "").rstrip("/"), + model=e.get("model", ""), + api_key_env=e.get("api_key_env", ""), + ) + for e in data.get("stt_engine", []) + ] or [STTEngine("Local faster-whisper", "local", model=cfg.model)] + cfg.stt_active = data.get("stt", {}).get("active", cfg.stt_engines[0].name) + + # LLM engines (default: synthesized from the legacy [rewrite] block). + cfg.llm_engines = [ + LLMEngine( + name=e["name"], + url=e.get("url", "https://api.openai.com/v1").rstrip("/"), + model=e.get("model", "gpt-4o-mini"), + api_key_env=e.get("api_key_env", ""), + temperature=float(e.get("temperature", cfg.temperature)), + ) + for e in data.get("llm_engine", []) + ] or [LLMEngine("Default", cfg.base_url, cfg.rewrite_model, cfg.api_key_env, cfg.temperature)] + cfg.llm_active = data.get("llm", {}).get("active", cfg.llm_engines[0].name) + return cfg @@ -164,6 +215,20 @@ def save(cfg: Config, path: Path = CONFIG_PATH) -> None: "default": cfg.routing_default, "threshold": cfg.routing_threshold, }, + "stt": {"active": cfg.stt_active}, + "stt_engine": [ + {k: v for k, v in { + "name": e.name, "type": e.type, "url": e.url, + "model": e.model, "api_key_env": e.api_key_env, + }.items() if v or k in ("name", "type")} + for e in cfg.stt_engines + ], + "llm": {"active": cfg.llm_active}, + "llm_engine": [ + {"name": e.name, "url": e.url, "model": e.model, + "api_key_env": e.api_key_env, "temperature": e.temperature} + for e in cfg.llm_engines + ], "workflow": [], } for wf in cfg.workflows: @@ -235,6 +300,47 @@ hotkey = "++" default = "Transcribe" # preset used when no keyword is recognised threshold = 0.82 # 0..1 fuzzy-match strictness (higher = stricter) +# ---------------------------------------------------------------------------- +# Speech-to-text engines (presets). The active one is used for transcription. +# type = "local" -> in-process faster-whisper (uses [whisper] above) +# type = "openai" -> remote OpenAI-compatible /audio/transcriptions server +# (faster-whisper-server, Groq, WhisperX, NIMs, ...) +# ---------------------------------------------------------------------------- +[stt] +active = "Local faster-whisper" + +[[stt_engine]] +name = "Local faster-whisper" +type = "local" + +# [[stt_engine]] +# name = "faster-whisper-server" +# type = "openai" +# url = "http://localhost:8010/v1" +# model = "Systran/faster-whisper-base" +# api_key_env = "" # e.g. GROQ_API_KEY for a cloud endpoint + +# ---------------------------------------------------------------------------- +# LLM engines (presets) for the rewrite step. Any OpenAI-compatible chat API +# (OpenAI, vLLM, llama-swap, Ollama /v1, LM Studio, Groq, OpenRouter, ...). +# ---------------------------------------------------------------------------- +[llm] +active = "Default" + +[[llm_engine]] +name = "Default" +url = "https://api.openai.com/v1" +model = "gpt-4o-mini" +api_key_env = "OPENAI_API_KEY" +temperature = 0.3 + +# [[llm_engine]] +# name = "Local llama-swap" +# url = "http://localhost:28080/v1" +# model = "Qwen3.5-4B" +# api_key_env = "" +# temperature = 0.3 + # ---------------------------------------------------------------------------- # Workflows / presets. mode = "transcribe" types the raw transcript. mode = # "rewrite" sends it through the LLM with `prompt` as the system prompt. diff --git a/linux/blitztext/daemon.py b/linux/blitztext/daemon.py index 092f48d..7fa0e00 100644 --- a/linux/blitztext/daemon.py +++ b/linux/blitztext/daemon.py @@ -11,11 +11,12 @@ import sys import threading from typing import Callable +from . import llm, stt from .config import Config, Workflow +from .llm import LLMError from .notify import notify from .paste import active_window_id, deliver from .recorder import Recording, detect_recorder -from .rewrite import RewriteError, rewrite from .routing import route from .transcribe import Transcriber @@ -33,6 +34,7 @@ class Daemon: self._active_workflow: Workflow | None = None self._target_window: str | None = None self._busy = False + self._prepared = False self._listener = None # Synthetic preset used by the voice-routing hotkey. self._route_workflow = Workflow(name="Voice", hotkey=cfg.routing_hotkey, mode="route") @@ -53,19 +55,27 @@ class Daemon: # -- model load (slow; call off the UI thread) ---------------------------- def prepare(self) -> None: - self._emit("loading", None, f"Loading Whisper '{self.cfg.model}'…") - self._notify("Loading model…", f"Whisper '{self.cfg.model}' ({self.cfg.device})") - self.transcriber = Transcriber( - model=self.cfg.model, - device=self.cfg.device, - compute_type=self.cfg.compute_type, - beam_size=self.cfg.beam_size, - ) + engine = self.cfg.active_stt + if engine.is_local: + model = engine.model or self.cfg.model + self._emit("loading", None, f"Loading Whisper '{model}'…") + self._notify("Loading model…", f"Whisper '{model}' ({self.cfg.device})") + self.transcriber = Transcriber( + model=model, + device=self.cfg.device, + compute_type=self.cfg.compute_type, + beam_size=self.cfg.beam_size, + ) + else: + # Remote STT engine — no local model to load. + self.transcriber = None + self._emit("loading", None, f"Using {engine.name}") + self._prepared = True self._emit("idle", None, "Ready") @property def ready(self) -> bool: - return self.transcriber is not None + return getattr(self, "_prepared", False) @property def is_recording(self) -> bool: @@ -105,7 +115,14 @@ class Daemon: self._emit("busy", label, "Transcribing…") self._notify(f"⌛ {label}", "Transcribing…") hotwords = ", ".join(self.cfg.all_keywords) if workflow.mode == "route" else "" - text = self.transcriber.transcribe(audio_path, language=self.cfg.language, hotwords=hotwords) + text = stt.transcribe( + self.cfg.active_stt, + audio_path, + language=self.cfg.language, + hotwords=hotwords, + local_transcriber=self.transcriber, + timeout=self.cfg.timeout, + ) if not text: self._emit("idle", label, "No speech detected") @@ -132,16 +149,15 @@ class Daemon: self._emit("busy", label, "Rewriting…") self._notify(f"⌛ {label}", "Rewriting…") try: - text = rewrite( - text, + text = llm.chat( + self.cfg.active_llm, target.prompt, - base_url=self.cfg.base_url, - api_key=self.cfg.api_key, - model=target.model or self.cfg.rewrite_model, - temperature=target.temperature if target.temperature is not None else self.cfg.temperature, + text, + model=target.model or None, + temperature=target.temperature, timeout=self.cfg.timeout, ) - except RewriteError as exc: + except LLMError as exc: self._emit("error", label, str(exc)) self._notify("Rewrite failed", str(exc), "critical") return diff --git a/linux/blitztext/gtkui.py b/linux/blitztext/gtkui.py index 72c6f40..6b5a908 100644 --- a/linux/blitztext/gtkui.py +++ b/linux/blitztext/gtkui.py @@ -24,8 +24,8 @@ from .config import Config, load, save # noqa: E402 from .daemon import Daemon # noqa: E402 ICON_PATHS = [ - "/usr/share/icons/hicolor/scalable/apps/blitztext.svg", - str(Path(__file__).resolve().parent.parent / "packaging" / "blitztext.svg"), + "/usr/share/icons/hicolor/256x256/apps/blitztext.png", + str(Path(__file__).resolve().parent.parent / "packaging" / "blitztext.png"), ] CSS = b""" diff --git a/linux/blitztext/llm.py b/linux/blitztext/llm.py new file mode 100644 index 0000000..4dd54dc --- /dev/null +++ b/linux/blitztext/llm.py @@ -0,0 +1,87 @@ +"""LLM engine abstraction for the rewrite step. + +Engines are user-managed presets pointing at any OpenAI-compatible chat endpoint +(OpenAI, vLLM, llama-swap, Ollama /v1, LM Studio, Groq, OpenRouter, …). Provides +reachability status (online/offline) and a chat-completion call. Uses only the +standard library so the daemon needs no extra deps. +""" + +from __future__ import annotations + +import json +import os +import urllib.error +import urllib.request +from dataclasses import dataclass + +from .stt import reachable + + +@dataclass +class LLMEngine: + name: str + url: str = "https://api.openai.com/v1" # base URL (incl. /v1) + model: str = "gpt-4o-mini" + api_key_env: str = "OPENAI_API_KEY" + temperature: float = 0.3 + + @property + def api_key(self) -> str | None: + return os.environ.get(self.api_key_env) if self.api_key_env else None + + +class LLMError(RuntimeError): + pass + + +def status(engine: LLMEngine, timeout: float = 2.0) -> bool: + """Reachable if the endpoint host:port accepts a TCP connection.""" + return reachable(engine.url, timeout) + + +def chat( + engine: LLMEngine, + system_prompt: str, + user_text: str, + *, + model: str | None = None, + temperature: float | None = None, + timeout: int = 45, +) -> str: + api_key = engine.api_key + payload = json.dumps( + { + "model": model or engine.model, + "temperature": engine.temperature if temperature is None else temperature, + "messages": [ + {"role": "system", "content": system_prompt}, + {"role": "user", "content": user_text}, + ], + } + ).encode("utf-8") + + headers = {"Content-Type": "application/json"} + if api_key: + headers["Authorization"] = f"Bearer {api_key}" + + req = urllib.request.Request( + engine.url.rstrip("/") + "/chat/completions", data=payload, headers=headers, method="POST" + ) + try: + with urllib.request.urlopen(req, timeout=timeout) as resp: + body = json.loads(resp.read().decode("utf-8")) + except urllib.error.HTTPError as exc: + detail = exc.read().decode("utf-8", "replace")[:300] + raise LLMError(f"HTTP {exc.code}: {detail}") from exc + except urllib.error.URLError as exc: + raise LLMError(f"Connection failed: {exc.reason}") from exc + + try: + content = body["choices"][0]["message"]["content"] + except (KeyError, IndexError, TypeError) as exc: + raise LLMError(f"Unexpected response: {str(body)[:300]}") from exc + + content = (content or "").strip() + if not content: + raise LLMError("Empty response from model.") + return content diff --git a/linux/blitztext/rewrite.py b/linux/blitztext/rewrite.py deleted file mode 100644 index 36e453e..0000000 --- a/linux/blitztext/rewrite.py +++ /dev/null @@ -1,73 +0,0 @@ -"""Optional LLM rewrite via an OpenAI-compatible chat endpoint. - -Uses only the standard library (urllib) so the daemon needs no extra deps for -the rewrite step. Works against OpenAI or any local server that implements the -/chat/completions API (e.g. vLLM, llama-swap). -""" - -from __future__ import annotations - -import json -import urllib.error -import urllib.request - - -class RewriteError(RuntimeError): - pass - - -def rewrite( - text: str, - system_prompt: str, - *, - base_url: str, - api_key: str | None, - model: str, - temperature: float = 0.3, - timeout: int = 45, -) -> str: - if not api_key: - raise RewriteError( - "No API key for rewrite. Set the env var named by rewrite.api_key_env, " - "or use a local endpoint that ignores auth." - ) - - payload = json.dumps( - { - "model": model, - "temperature": temperature, - "messages": [ - {"role": "system", "content": system_prompt}, - {"role": "user", "content": text}, - ], - } - ).encode("utf-8") - - req = urllib.request.Request( - f"{base_url}/chat/completions", - data=payload, - headers={ - "Authorization": f"Bearer {api_key}", - "Content-Type": "application/json", - }, - method="POST", - ) - - try: - with urllib.request.urlopen(req, timeout=timeout) as resp: - body = json.loads(resp.read().decode("utf-8")) - except urllib.error.HTTPError as exc: - detail = exc.read().decode("utf-8", "replace") - raise RewriteError(f"HTTP {exc.code}: {detail[:300]}") from exc - except urllib.error.URLError as exc: - raise RewriteError(f"Connection failed: {exc.reason}") from exc - - try: - content = body["choices"][0]["message"]["content"] - except (KeyError, IndexError, TypeError) as exc: - raise RewriteError(f"Unexpected response: {str(body)[:300]}") from exc - - content = (content or "").strip() - if not content: - raise RewriteError("Empty response from model.") - return content diff --git a/linux/blitztext/stt.py b/linux/blitztext/stt.py new file mode 100644 index 0000000..681a50e --- /dev/null +++ b/linux/blitztext/stt.py @@ -0,0 +1,152 @@ +"""Speech-to-text engine abstraction: local faster-whisper or remote endpoints. + +Engines are user-managed presets. Each is either the in-process local +faster-whisper model, or a remote OpenAI-compatible server exposing +`/audio/transcriptions` (faster-whisper-server, Groq, WhisperX, whisper.cpp's +OpenAI shim, NVIDIA NIMs, …). Provides reachability checks (online/offline) and +a benchmark helper (transcript + elapsed seconds). +""" + +from __future__ import annotations + +import json +import socket +import time +import urllib.error +import urllib.request +import uuid +from dataclasses import dataclass, field +from pathlib import Path +from urllib.parse import urlparse + + +@dataclass +class STTEngine: + name: str + type: str = "local" # "local" | "openai" + url: str = "" # base URL incl. /v1 for remote, e.g. http://localhost:8010/v1 + model: str = "" # remote model id, or local whisper size override + api_key_env: str = "" # env var holding a bearer key (optional) + + @property + def is_local(self) -> bool: + return self.type == "local" + + +class STTError(RuntimeError): + pass + + +# --- reachability ------------------------------------------------------------ +def reachable(url: str, timeout: float = 2.0) -> bool: + """True if a TCP connection to the URL's host:port succeeds.""" + host, port = _host_port(url) + if not host: + return False + try: + with socket.create_connection((host, port), timeout=timeout): + return True + except OSError: + return False + + +def status(engine: STTEngine, timeout: float = 2.0) -> bool: + """True if the engine is usable now (local always; remote = TCP reachable).""" + if engine.is_local: + return True + return reachable(engine.url, timeout) + + +def _host_port(url: str) -> tuple[str | None, int]: + try: + u = urlparse(url if "://" in url else "http://" + url) + port = u.port or (443 if u.scheme == "https" else 80) + return u.hostname, port + except ValueError: + return None, 0 + + +# --- transcription ----------------------------------------------------------- +def transcribe( + engine: STTEngine, + audio_path: Path, + *, + language: str = "", + hotwords: str = "", + local_transcriber=None, + timeout: int = 60, +) -> str: + if engine.is_local: + if local_transcriber is None: + raise STTError("Local engine selected but the model isn't loaded.") + return local_transcriber.transcribe(audio_path, language=language, hotwords=hotwords) + return _transcribe_remote(engine, audio_path, language=language, prompt=hotwords, timeout=timeout) + + +def _transcribe_remote(engine: STTEngine, audio_path: Path, *, language: str, prompt: str, timeout: int) -> str: + import os + + base = engine.url.rstrip("/") + endpoint = base + "/audio/transcriptions" + fields = {"model": engine.model or "whisper-1", "response_format": "json"} + if language: + fields["language"] = language + if prompt: + fields["prompt"] = prompt + + headers = {} + key = os.environ.get(engine.api_key_env) if engine.api_key_env else None + if key: + headers["Authorization"] = f"Bearer {key}" + + body, content_type = _multipart(fields, audio_path) + headers["Content-Type"] = content_type + req = urllib.request.Request(endpoint, data=body, headers=headers, method="POST") + try: + with urllib.request.urlopen(req, timeout=timeout) as resp: + raw = resp.read().decode("utf-8", "replace") + except urllib.error.HTTPError as exc: + detail = exc.read().decode("utf-8", "replace")[:300] + raise STTError(f"HTTP {exc.code} from {endpoint}: {detail}") from exc + except urllib.error.URLError as exc: + raise STTError(f"Cannot reach {endpoint}: {exc.reason}") from exc + + try: + return (json.loads(raw).get("text") or "").strip() + except json.JSONDecodeError: + return raw.strip() # some servers return plain text + + +def _multipart(fields: dict, audio_path: Path) -> tuple[bytes, str]: + boundary = uuid.uuid4().hex + nl = b"\r\n" + out = bytearray() + for k, v in fields.items(): + out += b"--" + boundary.encode() + nl + out += f'Content-Disposition: form-data; name="{k}"'.encode() + nl + nl + out += str(v).encode() + nl + out += b"--" + boundary.encode() + nl + out += f'Content-Disposition: form-data; name="file"; filename="{audio_path.name}"'.encode() + nl + out += b"Content-Type: audio/wav" + nl + nl + out += audio_path.read_bytes() + nl + out += b"--" + boundary.encode() + b"--" + nl + return bytes(out), f"multipart/form-data; boundary={boundary}" + + +# --- benchmark --------------------------------------------------------------- +@dataclass +class BenchResult: + engine: str + ok: bool + text: str = "" + seconds: float = 0.0 + error: str = "" + + +def benchmark(engine: STTEngine, audio_path: Path, *, language: str = "", local_transcriber=None) -> BenchResult: + t0 = time.perf_counter() + try: + text = transcribe(engine, audio_path, language=language, local_transcriber=local_transcriber) + return BenchResult(engine.name, True, text, time.perf_counter() - t0) + except Exception as exc: # noqa: BLE001 - report any failure to the UI + return BenchResult(engine.name, False, "", time.perf_counter() - t0, str(exc)) diff --git a/linux/packaging/blitztext.png b/linux/packaging/blitztext.png new file mode 100644 index 0000000000000000000000000000000000000000..d4c30a9a6d2216554c1d935218c37fb503d59172 GIT binary patch literal 6637 zcmeAS@N?(olHy`uVBq!ia0y~yU;#2&7&zE~RK2WrGXsO<7Ec$)kcv5PZyj7K7O?(xVRim_aTQZRfF8&Z z3JxH$fq@Z3F|mM14gnB3Dgg-+zw56{efvQn^6e=&_l&GSN|-IjAanio*IDxz4v1IT z&A%+nz;|*)RVf?RZe3;$%(U|NmNWb=04EAk+O`K?7VTW~QV^Ja4(y|QwjTt=ea>a( zhR0R=?*kLgzDtik&H$z(#+{(-G+O2mC~Ylb^`@KJf}-%obZ$_HjjV#XA-9U5Am;t| z>MEuW)8+b)1FNnF^2Ma*KS)dXPx>Byh6fcRD+ezB{BzGTcTm##T|{!gHGuRD>DJr^ zUw($Z*Pnm-_&?~r`~Le&P6medMFVdQ&MBb@0Q2;c1poj5 literal 0 HcmV?d00001 diff --git a/linux/packaging/blitztext.svg b/linux/packaging/blitztext.svg deleted file mode 100644 index 8a9f57f..0000000 --- a/linux/packaging/blitztext.svg +++ /dev/null @@ -1,12 +0,0 @@ - - - - - - - - - - - diff --git a/linux/packaging/blitztext_128.png b/linux/packaging/blitztext_128.png new file mode 100644 index 0000000000000000000000000000000000000000..7c36fbf6644922bce69720730c7545de54d3c539 GIT binary patch literal 978 zcmeAS@N?(olHy`uVBq!ia0vp^4Is?H1|$#LC7xzrV4mse;uumf=k2VGd5;Z5Sf;m4 z(J~WyK3k~1_)&dPSII=hxWWsLz6U0+eWAP4x`DfViS3djjF&H84sM&6DsIal+|4R* zoT($3@yJGoM-dE#It>*deb|Nlrf=3~(R?h6M+r8abM&ehii5l^I^SSf799~1 zeyA<6L++4TiR}4ZizPk1IFCOT+K{n+Bs>(||BeGi$IOfleL?mMo0@BaOd zfBy8m{(9@qV}<8E>t28O`Lpx#%a+qmoA$jw?8+gYkaUYNE-uc1$9Xc-m)ia*LRar( zurU1F$Lu4&Og@u$+0=92<~>ecU=d>NaR2*@-*OJs?uT9q8l3HCm0~P9wku|532UM@h1w)G}S$L_)xKmV|75(U7gkP3c?S5 z|F#Bl4{SN)eW7PdtH_xjJ^F%r4%(VCOutS8x@pljku$TZyf1JtT$$Sx8Jy3#JfUz! zS;MSggX~wjj)lq^&wC1GR{I_NJpDkS%*5h<-xJl|ey{El+%};p+lK8D2g8=hO|ngS zOQwm$1OqjwI_PTdSSA|Rl%{67HPE0XEiJSh5i5aOKW!7bRoS1NA_-Sir%~R z_ibQk-kIJsw=Dm#W`xWt*$4AP-fXFsHetFm8zk7gjAh-#rdpmDzFvj{Rhl;xU;JOV zL(pX7HTmNv>SZgYb^YJ*_M>3{?&OUv?aHjuPE4!b!%TD@ff(|ceTsu+JfnO3JYZgB N@O1TaS?83{1OSnZov8o- literal 0 HcmV?d00001 diff --git a/linux/packaging/blitztext_256.png b/linux/packaging/blitztext_256.png new file mode 100644 index 0000000000000000000000000000000000000000..a40fe858f1ff44e880871d680b7f3a3d2c4f18ac GIT binary patch literal 1546 zcmeAS@N?(olHy`uVBq!ia0y~yU<5K5893O0R7}x|GzJFNi=HlyAr*7p-Z_|e`Ib~e z;O`YP16vLmGoI(!6RgJ5{KWcI;Dd@{_9LhA_HSL%_xjd5@#FXYUFVm#%i*5z<@{oE zh6)+R1_QdF4a;+H%kAYo(7c0b0Bqc_+&%xmnro%CU(^cR*Oh$dJaFJ^6`14i@AuXA z+o`_N*y*2tzR`Nndt+Pf&ySDWS2xHq6(q-Ae_FLy@A>D-pTEAoF0z|H{qSSk8D=z2_^? zx%2+^0uA|SbfJ(X;XufPofWb_B)+`@8T|2OJrBsWf#|NS-MQRIol(M|_1Qj~W3wkO z-UKx7_ofNyaZCk|%d|dh&%f`(`pS04DDxa$2Muoe5$VXWU9 z{y$5@;&z6o_c!xDS7%^e#`J&h{pH%m@{BhUwLa9&cx7$x>{vd7t0q2Y>yS(pDKG5{NJXcg1n76Jx5XYWZ zbcH?Zes}FJb6~X9b8sQj-FF7Iv}%?Y^H|&k8X9jdW2*K!E;uLDI&C&r&O$zS!3OE= z!HxUt|Nr~2^1ycQQv2FpDOvjE!1RzdlPf1n-(9$&QF0m6eZRM@${Ys{=m68k-`|zD z5ynGG7GSbYn=!-su-S)gzBhX*J|Fa6K7i;;xD@JI6adj_&;LS{1gzG+CYSX6i;=sU@O1^fsBrc!UM!5`p&ZUG?#l)k1@N(r6APX!P{ zAcO$t9E6Zmzaygi$`BVgP9Ov$AOkOURQLEJ;rA#G&Qi|nriNoRWRK!CED5c<> zqup*}GMS_zkJ0P(Fq_R#EEW+((cLvq9f+a`wr!(QsibnCP$;LF^tD!tk-MUwymv!r&n-3pHZz=F&qvNhM~3_gb>K*^H?kv*lxFA zj6JkI1}PK@-;zZCRz2q&LWo42^VEUk@rXvFfzfCL&iP#jlv0pVBA3fyFc@GypTlvS z#BHk$BJBp|-z^wpw?Qe@S|8)(D^N;-5P~oaA%p;cM8WeFR4IjfgHnnh2+(Xc;rl-J z`#n6*gXekJ@Aug4cJO^4-EJ3@Qj|(17>03+-=yQ=6-X(O$z-rvt?=>jacjf4*bKt} zV+@?TUB}77rgqgXk z>E&LoT;{aeH0dv4LCKk(ujkL4DXdnjDdZTQwF5~h1Y}JiAZv0jz*Z{PT{Qm@C-8XnAPXQxU^<g_$-rwKJ zsiR@O?;Ee+I1V`Hq?>cj;W!Q$V-P}Mu~-|a z?(X3GempLxImwc0;PmtqCnqO+H!>jvEX#uHx)4G@N}2iy4gl=v=*T#Qy4@~3&-?EV zYTGtOqY5QhKRV(HQtO`&{VZB~sHk)BG znV=|&&MUzfgD8rS=Q*-0gVvf=32XyCK0Yv+OmKI1hq5fu>p3}9U$Xrji^T$Q9J37Y zO_1}jF$Vp9ACHfZ&2i}R2v}=jjDgk~d7h)+@3R{rU0htSx=7sw%d%t%Ih0?~1cAR* zaCLQs!C=4^^A$vdqA0LluR%o3_c|GatcQC&yu7^N`uZ9-H#c~FeMJxiY*E`(RfW-L zgfvZYe}9khcZNT7igQt#xVyou8kxRs2X*r>7@WRpmlY z0}=hx<-7X5;Uo&r=W|S_QzudQbJUHZsHucbdI0BPr4%-s4W`p6Zf|d$MB(i<9v&W? z%=&%!D9m}-7=thjk!2YG{OAbOU41*?)JMQY)cp?j6sV)H>n3=1cGh|M1Hw^w-*|Hs zb-#mUKoA62E|*OdJ|qa$0VhpUq-l!baOk{>+Q7aNtE2GcM|TlPH|$If!WQ z5y{NF`{GdGZe=$yEfZ&@?%BYLveD0`CB*F zhLgV!@CHZ=kj5JzEkGJ?fV2Q<{M+Kv0;KT