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 <noreply@anthropic.com>
@ -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())
|
||||
|
||||
62
linux/blitztext/blitztext.py
Normal file
@ -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())
|
||||
@ -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 = "<ctrl>+<alt>+<space>"
|
||||
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 = "<ctrl>+<alt>+<space>"
|
||||
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.
|
||||
|
||||
@ -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})")
|
||||
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=self.cfg.model,
|
||||
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
|
||||
|
||||
@ -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"""
|
||||
|
||||
87
linux/blitztext/llm.py
Normal file
@ -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
|
||||
@ -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
|
||||
152
linux/blitztext/stt.py
Normal file
@ -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))
|
||||
BIN
linux/packaging/blitztext.png
Normal file
|
After Width: | Height: | Size: 6.5 KiB |
@ -1,12 +0,0 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<svg width="128" height="128" viewBox="0 0 128 128" xmlns="http://www.w3.org/2000/svg">
|
||||
<defs>
|
||||
<linearGradient id="bg" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="0" stop-color="#2b8cff"/>
|
||||
<stop offset="1" stop-color="#0a5cf0"/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<rect x="8" y="8" width="112" height="112" rx="28" fill="url(#bg)"/>
|
||||
<path d="M70 26 L42 70 H60 L56 102 L86 56 H66 Z"
|
||||
fill="#ffffff" stroke="#ffffff" stroke-width="2" stroke-linejoin="round"/>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 529 B |
BIN
linux/packaging/blitztext_128.png
Normal file
|
After Width: | Height: | Size: 978 B |
BIN
linux/packaging/blitztext_256.png
Normal file
|
After Width: | Height: | Size: 1.5 KiB |
BIN
linux/packaging/blitztext_32.png
Normal file
|
After Width: | Height: | Size: 607 B |
BIN
linux/packaging/blitztext_48.png
Normal file
|
After Width: | Height: | Size: 742 B |
BIN
linux/packaging/blitztext_64.png
Normal file
|
After Width: | Height: | Size: 839 B |
@ -52,8 +52,11 @@ exec /opt/blitztext/venv/bin/python -m blitztext "$@"
|
||||
EOF
|
||||
|
||||
install -Dm644 "$HERE/$PKG.desktop" "$ROOT/usr/share/applications/$PKG.desktop"
|
||||
install -Dm644 "$HERE/$PKG.svg" "$ROOT/usr/share/icons/hicolor/scalable/apps/$PKG.svg"
|
||||
install -Dm644 "$HERE/copyright" "$ROOT/usr/share/doc/$PKG/copyright"
|
||||
# App icon (extracted from the macOS AppIcon) at several hicolor sizes.
|
||||
for s in 32 48 64 128 256; do
|
||||
install -Dm644 "$HERE/${PKG}_${s}.png" "$ROOT/usr/share/icons/hicolor/${s}x${s}/apps/${PKG}.png"
|
||||
done
|
||||
|
||||
# 3) DEBIAN control + maintainer scripts ------------------------------------
|
||||
SIZE_KB="$(du -sk "$ROOT" | cut -f1)"
|
||||
|
||||