"""Configuration loading and the default config template.""" from __future__ import annotations import os 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" @dataclass class Workflow: name: str hotkey: str mode: str # "transcribe" | "rewrite" prompt: str = "" # Spoken trigger phrases for voice routing (matched at start/end of speech). keywords: list[str] = field(default_factory=list) # Optional per-workflow overrides of the [rewrite] defaults. model: str | None = None temperature: float | None = None # Cosmetic, used by the GUI. description: str = "" icon: str = "⚡" @dataclass class Config: # general recorder: str = "auto" mic: str = "" # pactl/pipewire source name; "" = default device output: str = "type" # type | paste type_delay_ms: int = 12 notify: bool = True language: str = "de" # whisper hint; "" = autodetect # input scheme input_mode: str = "modifiers" # "modifiers" (Ctrl+Win/Ctrl/Alt/Esc) | "hotkeys" (combos) push_to_talk: bool = False key_start: str = "+" # start recording (Ctrl+Win) key_stop: str = "" # stop -> paste key_send: str = "" # stop -> paste -> Enter key_cancel: str = "" # discard # quality gate min_speech_seconds: float = 0.4 silence_rms: float = 150.0 reject_hallucinations: bool = True strip_trailing_punctuation: bool = False # whisper model: str = "small" device: str = "auto" # auto | cuda | cpu compute_type: str = "auto" # auto | int8 | float16 | int8_float16 beam_size: int = 5 # rewrite (OpenAI-compatible) base_url: str = "https://api.openai.com/v1" api_key_env: str = "OPENAI_API_KEY" rewrite_model: str = "gpt-4o-mini" temperature: float = 0.3 timeout: int = 45 # voice-keyword routing routing_enabled: bool = True 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 return next((w for w in self.workflows if w.name == name), None) @property def default_preset(self) -> "Workflow | None": return self.preset_by_name(self.routing_default) or (self.workflows[0] if self.workflows else None) @property def all_keywords(self) -> list[str]: out: list[str] = [] for w in self.workflows: out.extend(w.keywords) return out @property def api_key(self) -> str | None: return os.environ.get(self.api_key_env) or None def load(path: Path = CONFIG_PATH) -> Config: """Load config from TOML, creating a default file on first run.""" if not path.exists(): ensure_default(path) with path.open("rb") as fh: data = tomllib.load(fh) g = data.get("general", {}) w = data.get("whisper", {}) r = data.get("rewrite", {}) rt = data.get("routing", {}) inp = data.get("input", {}) q = data.get("quality", {}) cfg = Config( recorder=g.get("recorder", "auto"), mic=g.get("mic", ""), output=g.get("output", "type"), type_delay_ms=int(g.get("type_delay_ms", 4)), notify=bool(g.get("notify", True)), language=g.get("language", "de"), model=w.get("model", "small"), device=w.get("device", "auto"), compute_type=w.get("compute_type", "auto"), beam_size=int(w.get("beam_size", 5)), base_url=r.get("base_url", "https://api.openai.com/v1").rstrip("/"), api_key_env=r.get("api_key_env", "OPENAI_API_KEY"), rewrite_model=r.get("model", "gpt-4o-mini"), temperature=float(r.get("temperature", 0.3)), timeout=int(r.get("timeout", 45)), routing_enabled=bool(rt.get("enabled", True)), routing_hotkey=rt.get("hotkey", "++"), routing_default=rt.get("default", ""), routing_threshold=float(rt.get("threshold", 0.82)), input_mode=inp.get("mode", "modifiers"), push_to_talk=bool(inp.get("push_to_talk", False)), key_start=inp.get("start", "+"), key_stop=inp.get("stop", ""), key_send=inp.get("send", ""), key_cancel=inp.get("cancel", ""), min_speech_seconds=float(q.get("min_speech_seconds", 0.4)), silence_rms=float(q.get("silence_rms", 150.0)), reject_hallucinations=bool(q.get("reject_hallucinations", True)), strip_trailing_punctuation=bool(q.get("strip_trailing_punctuation", False)), ) for entry in data.get("workflow", []): cfg.workflows.append( Workflow( name=entry["name"], hotkey=entry.get("hotkey", ""), mode=entry.get("mode", "transcribe"), prompt=entry.get("prompt", ""), keywords=list(entry.get("keywords", [])), model=entry.get("model"), temperature=entry.get("temperature"), description=entry.get("description", ""), icon=entry.get("icon", "⚡"), ) ) 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 def save(cfg: Config, path: Path = CONFIG_PATH) -> None: """Write the config back to TOML (used by the settings UI). Note: inline comments from the template are not preserved on save. """ import tomli_w data: dict = { "general": { "recorder": cfg.recorder, "mic": cfg.mic, "output": cfg.output, "type_delay_ms": cfg.type_delay_ms, "notify": cfg.notify, "language": cfg.language, }, "whisper": { "model": cfg.model, "device": cfg.device, "compute_type": cfg.compute_type, "beam_size": cfg.beam_size, }, "rewrite": { "base_url": cfg.base_url, "api_key_env": cfg.api_key_env, "model": cfg.rewrite_model, "temperature": cfg.temperature, "timeout": cfg.timeout, }, "input": { "mode": cfg.input_mode, "push_to_talk": cfg.push_to_talk, "start": cfg.key_start, "stop": cfg.key_stop, "send": cfg.key_send, "cancel": cfg.key_cancel, }, "routing": { "enabled": cfg.routing_enabled, "hotkey": cfg.routing_hotkey, "default": cfg.routing_default, "threshold": cfg.routing_threshold, }, "quality": { "min_speech_seconds": cfg.min_speech_seconds, "silence_rms": cfg.silence_rms, "reject_hallucinations": cfg.reject_hallucinations, "strip_trailing_punctuation": cfg.strip_trailing_punctuation, }, "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: entry: dict = {"name": wf.name, "hotkey": wf.hotkey, "mode": wf.mode} if wf.keywords: entry["keywords"] = wf.keywords if wf.prompt: entry["prompt"] = wf.prompt if wf.model: entry["model"] = wf.model if wf.temperature is not None: entry["temperature"] = wf.temperature if wf.description: entry["description"] = wf.description if wf.icon and wf.icon != "⚡": entry["icon"] = wf.icon data["workflow"].append(entry) path.parent.mkdir(parents=True, exist_ok=True) with path.open("wb") as fh: tomli_w.dump(data, fh) def ensure_default(path: Path = CONFIG_PATH) -> Path: path.parent.mkdir(parents=True, exist_ok=True) if not path.exists(): path.write_text(DEFAULT_CONFIG, encoding="utf-8") return path # Hotkey syntax is pynput's GlobalHotKeys format, e.g. "++space". DEFAULT_CONFIG = """\ # Blitztext for Linux — configuration # Hotkey format follows pynput: + a letter/keyname. # Each hotkey TOGGLES recording: press to start speaking, press again to finish. [general] recorder = "auto" # auto | pw-record | parecord | arecord output = "type" # "type" = xdotool types it; "paste" = clipboard + Ctrl+V type_delay_ms = 12 # per-keystroke delay for xdotool type (raise if chars drop) notify = true # desktop notifications for each phase language = "de" # Whisper language hint; "" = autodetect [input] # How you start/stop dictation. # mode = "modifiers" -> Ctrl+Win start | Ctrl stop+paste | Alt stop+paste+Enter | Esc cancel # mode = "hotkeys" -> the per-preset combos + the [routing] hotkey below # Voice-keyword routing still applies to what you say in either mode. mode = "modifiers" push_to_talk = false # modifiers mode: hold Start to record, release to stop+paste start = "+" # = the Super/Windows key stop = "" send = "" cancel = "" [quality] # Reject silence/too-short clips and the stock phrases Whisper invents on # silence (e.g. "Thank you.", "Untertitel ...") so you don't paste garbage. min_speech_seconds = 0.4 # discard clips shorter than this silence_rms = 150.0 # discard clips quieter than this RMS (0..32767) reject_hallucinations = true strip_trailing_punctuation = false [whisper] model = "small" # tiny | base | small | medium | large-v3, or a local path device = "auto" # auto | cuda | cpu (auto tries cuda, falls back to cpu) compute_type = "auto" # auto | int8 | float16 | int8_float16 beam_size = 5 [rewrite] # OpenAI-compatible chat endpoint. Define your own provider/API/model here. # Point base_url at OpenAI, OR any local server that speaks the OpenAI chat API # (vLLM, llama-swap, Ollama's /v1, LM Studio, ...). Examples: # base_url = "https://api.openai.com/v1" (OpenAI) # base_url = "http://localhost:8000/v1" (local vLLM / llama-swap) # api_key_env names the ENV VAR holding the key (local servers often ignore it). base_url = "https://api.openai.com/v1" api_key_env = "OPENAI_API_KEY" model = "gpt-4o-mini" # default model for rewrite workflows temperature = 0.3 timeout = 45 [routing] # Voice-keyword routing: ONE hotkey to dictate. Say a preset's keyword at the # START or END of your speech and that preset is applied; otherwise the default # preset is used. (Per-preset hotkeys below still work as direct shortcuts.) enabled = true 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. # keywords = spoken trigger phrases for voice routing (start or end of speech) # hotkey = optional direct global hotkey ("" = none; voice routing is primary) # A workflow may override the [rewrite] defaults with its own model/temperature. # ---------------------------------------------------------------------------- [[workflow]] name = "Transcribe" icon = "⚡" description = "Speak, get plain text." hotkey = "" mode = "transcribe" [[workflow]] name = "Nicer email" icon = "✉" description = "Rough notes → polished email." keywords = ["nicer email", "bessere email", "schöne mail"] hotkey = "++e" mode = "rewrite" prompt = '''Du bist ein Schreibassistent fuer E-Mails. Du erhaeltst ein gesprochenes Transkript. Schreibe daraus eine freundliche, gut formulierte und etwas ausfuehrlichere E-Mail: - Korrigiere Rechtschreibung und Grammatik - Formuliere hoeflich, klar und professionell - Ergaenze sinnvolle Hoeflichkeitsfloskeln (Anrede/Gruss), wenn passend - Behalte die urspruengliche Aussage und Absicht bei, erfinde keine Fakten - Antworte in der Sprache des Transkripts - Gib NUR den E-Mail-Text zurueck, keine Erklaerungen''' [[workflow]] name = "Improve text" icon = "✨" description = "Speak → cleaner writing." keywords = ["improve text", "verbessere text", "bessere schreibweise"] hotkey = "++i" mode = "rewrite" prompt = '''Du bist ein Lektor und Schreibassistent. Verbessere den folgenden gesprochenen Text: - Korrigiere Rechtschreibung und Grammatik - Verbessere Formulierung und Lesefluss, behalte die Bedeutung bei - Antworte in der Sprache des Transkripts - Gib NUR den verbesserten Text zurueck, keine Erklaerungen''' [[workflow]] name = "Calm down" icon = "☺" description = "Frustrated in → calm out." keywords = ["calm down", "beruhige das", "entspannte nachricht"] hotkey = "++c" mode = "rewrite" prompt = '''Du erhaeltst ein gesprochenes, frustriertes oder veraergertes Transkript. Formuliere es in eine ruhige, sachliche und hoefliche Nachricht um, die dasselbe Anliegen professionell vermittelt. Antworte in der Sprache des Transkripts. Gib NUR die umformulierte Nachricht zurueck, keine Erklaerungen.''' [[workflow]] name = "Add emojis" icon = "✿" description = "Text in → emojis out." keywords = ["add emojis", "mit emojis", "emojis dazu"] hotkey = "++j" mode = "rewrite" prompt = '''Du erhaeltst ein gesprochenes Transkript. Gib den Text moeglichst originalgetreu zurueck, fuege aber regelmaessig passende Emojis ein (etwa alle 1-2 Saetze). Korrigiere offensichtliche Fehler, behalte Stil und Bedeutung bei. Gib NUR den Text mit Emojis zurueck, keine Erklaerungen.''' """