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]`."""
|
"""Module entry point so `python -m blitztext` runs the app in blitztext.py."""
|
||||||
|
|
||||||
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
|
|
||||||
|
|
||||||
|
from .blitztext import main
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
raise SystemExit(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 dataclasses import dataclass, field
|
||||||
from pathlib import Path
|
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_DIR = Path(os.environ.get("XDG_CONFIG_HOME", Path.home() / ".config")) / "blitztext"
|
||||||
CONFIG_PATH = CONFIG_DIR / "config.toml"
|
CONFIG_PATH = CONFIG_DIR / "config.toml"
|
||||||
|
|
||||||
@ -51,9 +54,31 @@ class Config:
|
|||||||
routing_hotkey: str = "<ctrl>+<alt>+<space>"
|
routing_hotkey: str = "<ctrl>+<alt>+<space>"
|
||||||
routing_default: str = "" # preset name used when no keyword matches; "" = first
|
routing_default: str = "" # preset name used when no keyword matches; "" = first
|
||||||
routing_threshold: float = 0.82
|
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
|
||||||
workflows: list[Workflow] = field(default_factory=list)
|
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":
|
def preset_by_name(self, name: str | None) -> "Workflow | None":
|
||||||
if not name:
|
if not name:
|
||||||
return None
|
return None
|
||||||
@ -127,6 +152,32 @@ def load(path: Path = CONFIG_PATH) -> Config:
|
|||||||
if not cfg.workflows:
|
if not cfg.workflows:
|
||||||
raise ValueError(f"No [[workflow]] entries defined in {path}")
|
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
|
return cfg
|
||||||
|
|
||||||
|
|
||||||
@ -164,6 +215,20 @@ def save(cfg: Config, path: Path = CONFIG_PATH) -> None:
|
|||||||
"default": cfg.routing_default,
|
"default": cfg.routing_default,
|
||||||
"threshold": cfg.routing_threshold,
|
"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": [],
|
"workflow": [],
|
||||||
}
|
}
|
||||||
for wf in cfg.workflows:
|
for wf in cfg.workflows:
|
||||||
@ -235,6 +300,47 @@ hotkey = "<ctrl>+<alt>+<space>"
|
|||||||
default = "Transcribe" # preset used when no keyword is recognised
|
default = "Transcribe" # preset used when no keyword is recognised
|
||||||
threshold = 0.82 # 0..1 fuzzy-match strictness (higher = stricter)
|
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 =
|
# Workflows / presets. mode = "transcribe" types the raw transcript. mode =
|
||||||
# "rewrite" sends it through the LLM with `prompt` as the system prompt.
|
# "rewrite" sends it through the LLM with `prompt` as the system prompt.
|
||||||
|
|||||||
@ -11,11 +11,12 @@ import sys
|
|||||||
import threading
|
import threading
|
||||||
from typing import Callable
|
from typing import Callable
|
||||||
|
|
||||||
|
from . import llm, stt
|
||||||
from .config import Config, Workflow
|
from .config import Config, Workflow
|
||||||
|
from .llm import LLMError
|
||||||
from .notify import notify
|
from .notify import notify
|
||||||
from .paste import active_window_id, deliver
|
from .paste import active_window_id, deliver
|
||||||
from .recorder import Recording, detect_recorder
|
from .recorder import Recording, detect_recorder
|
||||||
from .rewrite import RewriteError, rewrite
|
|
||||||
from .routing import route
|
from .routing import route
|
||||||
from .transcribe import Transcriber
|
from .transcribe import Transcriber
|
||||||
|
|
||||||
@ -33,6 +34,7 @@ class Daemon:
|
|||||||
self._active_workflow: Workflow | None = None
|
self._active_workflow: Workflow | None = None
|
||||||
self._target_window: str | None = None
|
self._target_window: str | None = None
|
||||||
self._busy = False
|
self._busy = False
|
||||||
|
self._prepared = False
|
||||||
self._listener = None
|
self._listener = None
|
||||||
# Synthetic preset used by the voice-routing hotkey.
|
# Synthetic preset used by the voice-routing hotkey.
|
||||||
self._route_workflow = Workflow(name="Voice", hotkey=cfg.routing_hotkey, mode="route")
|
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) ----------------------------
|
# -- model load (slow; call off the UI thread) ----------------------------
|
||||||
def prepare(self) -> None:
|
def prepare(self) -> None:
|
||||||
self._emit("loading", None, f"Loading Whisper '{self.cfg.model}'…")
|
engine = self.cfg.active_stt
|
||||||
self._notify("Loading model…", f"Whisper '{self.cfg.model}' ({self.cfg.device})")
|
if engine.is_local:
|
||||||
self.transcriber = Transcriber(
|
model = engine.model or self.cfg.model
|
||||||
model=self.cfg.model,
|
self._emit("loading", None, f"Loading Whisper '{model}'…")
|
||||||
device=self.cfg.device,
|
self._notify("Loading model…", f"Whisper '{model}' ({self.cfg.device})")
|
||||||
compute_type=self.cfg.compute_type,
|
self.transcriber = Transcriber(
|
||||||
beam_size=self.cfg.beam_size,
|
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")
|
self._emit("idle", None, "Ready")
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def ready(self) -> bool:
|
def ready(self) -> bool:
|
||||||
return self.transcriber is not None
|
return getattr(self, "_prepared", False)
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def is_recording(self) -> bool:
|
def is_recording(self) -> bool:
|
||||||
@ -105,7 +115,14 @@ class Daemon:
|
|||||||
self._emit("busy", label, "Transcribing…")
|
self._emit("busy", label, "Transcribing…")
|
||||||
self._notify(f"⌛ {label}", "Transcribing…")
|
self._notify(f"⌛ {label}", "Transcribing…")
|
||||||
hotwords = ", ".join(self.cfg.all_keywords) if workflow.mode == "route" else ""
|
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:
|
if not text:
|
||||||
self._emit("idle", label, "No speech detected")
|
self._emit("idle", label, "No speech detected")
|
||||||
@ -132,16 +149,15 @@ class Daemon:
|
|||||||
self._emit("busy", label, "Rewriting…")
|
self._emit("busy", label, "Rewriting…")
|
||||||
self._notify(f"⌛ {label}", "Rewriting…")
|
self._notify(f"⌛ {label}", "Rewriting…")
|
||||||
try:
|
try:
|
||||||
text = rewrite(
|
text = llm.chat(
|
||||||
text,
|
self.cfg.active_llm,
|
||||||
target.prompt,
|
target.prompt,
|
||||||
base_url=self.cfg.base_url,
|
text,
|
||||||
api_key=self.cfg.api_key,
|
model=target.model or None,
|
||||||
model=target.model or self.cfg.rewrite_model,
|
temperature=target.temperature,
|
||||||
temperature=target.temperature if target.temperature is not None else self.cfg.temperature,
|
|
||||||
timeout=self.cfg.timeout,
|
timeout=self.cfg.timeout,
|
||||||
)
|
)
|
||||||
except RewriteError as exc:
|
except LLMError as exc:
|
||||||
self._emit("error", label, str(exc))
|
self._emit("error", label, str(exc))
|
||||||
self._notify("Rewrite failed", str(exc), "critical")
|
self._notify("Rewrite failed", str(exc), "critical")
|
||||||
return
|
return
|
||||||
|
|||||||
@ -24,8 +24,8 @@ from .config import Config, load, save # noqa: E402
|
|||||||
from .daemon import Daemon # noqa: E402
|
from .daemon import Daemon # noqa: E402
|
||||||
|
|
||||||
ICON_PATHS = [
|
ICON_PATHS = [
|
||||||
"/usr/share/icons/hicolor/scalable/apps/blitztext.svg",
|
"/usr/share/icons/hicolor/256x256/apps/blitztext.png",
|
||||||
str(Path(__file__).resolve().parent.parent / "packaging" / "blitztext.svg"),
|
str(Path(__file__).resolve().parent.parent / "packaging" / "blitztext.png"),
|
||||||
]
|
]
|
||||||
|
|
||||||
CSS = b"""
|
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
|
EOF
|
||||||
|
|
||||||
install -Dm644 "$HERE/$PKG.desktop" "$ROOT/usr/share/applications/$PKG.desktop"
|
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"
|
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 ------------------------------------
|
# 3) DEBIAN control + maintainer scripts ------------------------------------
|
||||||
SIZE_KB="$(du -sk "$ROOT" | cut -f1)"
|
SIZE_KB="$(du -sk "$ROOT" | cut -f1)"
|
||||||
|
|||||||