Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion README.de.md
Original file line number Diff line number Diff line change
Expand Up @@ -455,7 +455,7 @@ Der Einstellungs-Dialog enthält Spracherkennung, Text & KI, Allgemein und Updat
- **model**: Whisper-Modellgröße (`tiny`, `base`, `small`, `medium`, `large`, `large-v2`, `large-v3`, `large-v3-turbo`). Standard: `base`.
- **language**: Transkriptions-Sprache (`de`, `en`) oder `auto`.
- **ui_language**: Sprache der App-Oberfläche (`de` oder `en`). Standard: `de`. Änderungen greifen nach einem Neustart.
- **backend**: `openai-whisper` oder `faster-whisper`.
- **backend**: `openai-whisper` oder `faster-whisper` (lokal), oder `openrouter` / `openai-cloud` (Cloud-Transkription mit `openai/gpt-4o-transcribe` / `gpt-4o-transcribe`). **Datenschutz:** Bei einem Cloud-Backend wird die Aufnahme an OpenRouter bzw. OpenAI gesendet; der Schlüssel kommt aus `OPENROUTER_API_KEY` / `OPENAI_API_KEY` (oder den konfigurierten Variablennamen) und wird nie in der Config gespeichert. Der Workflow *Lokal* transkribiert immer lokal mit `faster-whisper`, auch wenn ein Cloud-Backend eingestellt ist.
- **hotkey_mode**:
- `toggle`: Einmal drücken startet, erneutes Drücken beendet.
- `hold`: Aufnahme läuft solange der Hotkey gedrückt wird. Tastendrücke unter 150 ms werden verworfen; leere Aufnahmen kehren ohne dauerhaften Fehlerzustand zu Bereit zurück.
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -453,7 +453,7 @@ The settings dialog includes Speech Recognition, Text & AI, General, and Updates
- **model**: Whisper model size (`tiny`, `base`, `small`, `medium`, `large`, `large-v2`, `large-v3`, `large-v3-turbo`). Default: `base`.
- **language**: Transcription language (`de`, `en`) or `auto`.
- **ui_language**: Language of the app interface (`de` or `en`). Default: `de`. Changes take effect after a restart.
- **backend**: `openai-whisper` or `faster-whisper`.
- **backend**: `openai-whisper` or `faster-whisper` (local), or `openrouter` / `openai-cloud` (cloud transcription with `openai/gpt-4o-transcribe` / `gpt-4o-transcribe`). **Privacy:** with a cloud backend the recorded audio is sent to OpenRouter or OpenAI; the key is read from `OPENROUTER_API_KEY` / `OPENAI_API_KEY` (or your configured variable names), never stored in the config. The *Local* workflow always transcribes locally with `faster-whisper`, even if a cloud backend is configured.
- **hotkey_mode**:
- `toggle`: press once to start, press again to stop.
- `hold`: recording runs as long as the hotkey is held. Presses below 150 ms are discarded; empty recordings return to ready without a persistent error state.
Expand Down
19 changes: 17 additions & 2 deletions app/blitztext_linux.py
Original file line number Diff line number Diff line change
Expand Up @@ -231,7 +231,7 @@ def init_ui(self) -> None:
self.combo_model.setCurrentText(self.config.model)

self.combo_backend = QComboBox()
self.combo_backend.addItems(["openai-whisper", "faster-whisper"])
self.combo_backend.addItems(["openai-whisper", "faster-whisper", "openrouter", "openai-cloud"])
self.combo_backend.setCurrentText(self.config.backend)

self.edit_language = QLineEdit()
Expand Down Expand Up @@ -716,6 +716,7 @@ def __init__(
skip_llm: bool = False,
replacements: Optional[list[dict[str, str]]] = None,
cleanup_wav: Callable[[], None] | None = None,
transcription_api_key: str = "",
) -> None:
super().__init__()
self.signals = _WorkerSignals()
Expand All @@ -732,6 +733,7 @@ def __init__(
self.replacements = list(replacements or [])
self.route_to_compose = route_to_compose
self.skip_llm = skip_llm or route_to_compose
self.transcription_api_key = transcription_api_key

def _emit(self, signal_name: str, *args) -> None:
try:
Expand All @@ -748,6 +750,7 @@ def run(self) -> None:
language=self.language,
backend=self.backend,
custom_terms=self.custom_terms,
api_key=self.transcription_api_key,
)
transcript = clean_transcript(transcript, self.custom_terms, self.replacements, self.language)

Expand Down Expand Up @@ -1406,12 +1409,23 @@ def _stop_recording_and_process(self) -> None:
self.paste_service.autopaste = self.config.autopaste
self.paste_service.key_delay_ms = self.config.paste_key_delay_ms

effective_backend = (
"faster-whisper"
if self.current_workflow == WorkflowType.LOCAL and self.config.backend in {"openrouter", "openai-cloud"}
else self.config.backend
Comment on lines +1414 to +1415

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Preserve offline transcription when the GUI selects Local mode

When a cloud backend is configured, clicking Local in the main window still sends the recording to that cloud provider. In the inspected GUI flow, MainWindow._set_ai_mode(False) selects WorkflowType.TRANSCRIPTION (app/main_window.py:566), but this fallback protects only WorkflowType.LOCAL. The main window consequently displays “Local · Whisper …” and promises in its tooltip that nothing is sent to a cloud service (app/i18n.py:482), while the worker uploads the audio. Make the GUI's Local selection use the offline workflow, or otherwise enforce the offline guarantee for that recording path without disabling intentional cloud dictation.

Useful? React with 👍 / 👎.

)
transcription_api_key = (
self.config.resolve_transcription_api_key()
if effective_backend in {"openrouter", "openai-cloud"}
else ""
)

# Create the transcribe worker
worker = _TranscribeWorker(
wav_file=wav_path,
model=self.config.model,
language=self.config.language,
backend=self.config.backend,
backend=effective_backend,
workflow=self.current_workflow,
llm_service=self.llm_service,
autopaste=self.config.autopaste,
Expand All @@ -1421,6 +1435,7 @@ def _stop_recording_and_process(self) -> None:
route_to_compose=route_to_compose,
skip_llm=skip_llm,
cleanup_wav=lambda: AudioRecorder.cleanup_recording(wav_path),
transcription_api_key=transcription_api_key,
)

worker.signals.status_changed.connect(self._on_worker_status_changed)
Expand Down
18 changes: 17 additions & 1 deletion app/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -70,7 +70,7 @@
}

VALID_MODELS = {"tiny", "base", "small", "medium", "large", "large-v2", "large-v3", "large-v3-turbo"}
VALID_BACKENDS = {"openai-whisper", "faster-whisper"}
VALID_BACKENDS = {"openai-whisper", "faster-whisper", "openrouter", "openai-cloud"}
VALID_HOTKEY_MODES = {"toggle", "hold"}
VALID_TONES = {"formal", "neutral", "locker"}
VALID_EMOJI_DENSITIES = {"wenig", "mittel", "viel"}
Expand Down Expand Up @@ -275,6 +275,22 @@ def resolve_llm_api_key(self) -> str:
return os.environ.get(self.openai_api_key_env, "").strip()
return self.resolve_openai_api_key()

def resolve_transcription_api_key(self) -> str:
"""Return the API key for cloud transcription backends without cross-provider leaks."""
if self.backend == "openrouter":
env_name = (
self.openai_api_key_env
if self.llm_provider == "openrouter" and self.openai_api_key_env != DEFAULTS["openai_api_key_env"]
else self.openrouter_provider_key_env or "OPENROUTER_API_KEY"
)
return os.environ.get(env_name, "").strip()
if self.backend == "openai-cloud":
if self.llm_provider == "openai":
return self.resolve_openai_api_key()
env_name = self.openai_provider_key_env or DEFAULTS["openai_api_key_env"]
return os.environ.get(env_name, "").strip() or self._legacy_openai_api_key_value
return ""

@property
def has_legacy_openai_api_key(self) -> bool:
return self._legacy_openai_api_key_present
Expand Down
170 changes: 156 additions & 14 deletions app/transcribe.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,11 +9,15 @@
"""
from __future__ import annotations

import base64
import gc
import json
import logging
import os
import sys
import threading
import urllib.error
import urllib.request
import warnings
import wave
from pathlib import Path
Expand All @@ -25,7 +29,11 @@
"tiny", "base", "small", "medium",
"large", "large-v2", "large-v3", "large-v3-turbo",
}
VALID_BACKENDS = {"openai-whisper", "faster-whisper"}
VALID_BACKENDS = {"openai-whisper", "faster-whisper", "openrouter", "openai-cloud"}
DEFAULT_OPENROUTER_TRANSCRIBE_MODEL = "openai/gpt-4o-transcribe"
DEFAULT_OPENAI_CLOUD_TRANSCRIBE_MODEL = "gpt-4o-transcribe"
_OPENROUTER_TRANSCRIPTIONS_URL = "https://openrouter.ai/api/v1/audio/transcriptions"
_CLOUD_REQUEST_TIMEOUT = 45.0
# A WAV with only its header carries no samples (e.g. an accidental short press).
_WAV_HEADER_BYTES = 44
# As in the original Blitztext app: vocabulary hints only for recordings of at
Expand Down Expand Up @@ -56,15 +64,17 @@
language: str = "de",
backend: str = "openai-whisper",
custom_terms: list[str] | None = None,
api_key: str = "",
) -> str:
"""Transkribiert eine WAV-Datei und gibt den Text zurueck.

Args:
wav_file: Pfad zur WAV-Datei (16 kHz, mono).
model: Whisper-Modellname (z. B. 'base', 'small').
model: Whisper-Modellname (z. B. 'base', 'small') oder Cloud-Modell-ID.
language: Sprachcode ('de', 'en', ...) oder 'auto' fuer Autodetect.
backend: 'openai-whisper' | 'faster-whisper'
backend: 'openai-whisper' | 'faster-whisper' | 'openrouter' | 'openai-cloud'
custom_terms: Optionale Eigennamen/Fachbegriffe als Transkriptions-Hint.
api_key: Optionaler API-Schluessel fuer Cloud-Backends (Fallback: Env).

Returns:
Transkribierter Text (stripped). Leer wenn nichts erkannt.
Expand All @@ -75,14 +85,14 @@
"""
wav_file = Path(wav_file).resolve()

if model not in VALID_MODEL_NAMES:
raise TranscribeError(
f"Ungültiger Modellname {model!r}. Gültig: {sorted(VALID_MODEL_NAMES)}"
)
if backend not in VALID_BACKENDS:
raise TranscribeError(
f"Ungültiger Backend {backend!r}. Gültig: {sorted(VALID_BACKENDS)}"
)
if backend in {"openai-whisper", "faster-whisper"} and model not in VALID_MODEL_NAMES:
raise TranscribeError(
f"Ungültiger Modellname {model!r}. Gültig: {sorted(VALID_MODEL_NAMES)}"
)
if not wav_file.is_file():
raise TranscribeError(f"WAV-Datei nicht gefunden: {wav_file}")
if wav_file.stat().st_size <= _WAV_HEADER_BYTES:
Expand All @@ -95,9 +105,15 @@
try:
hint = _build_transcription_hint(custom_terms)
hotwords = _build_hotwords(custom_terms)
if backend == "openrouter":
return _transcribe_openrouter(str(wav_file), model, language, hint=hint, api_key=api_key)
if backend == "openai-cloud":
return _transcribe_openai_cloud(str(wav_file), model, language, hint=hint, api_key=api_key)
if backend == "faster-whisper":
return _transcribe_faster(str(wav_file), model, language, hint=hint, hotwords=hotwords)
return _transcribe_openai(str(wav_file), model, language, hint=hint)
except TranscribeError:
raise
except ImportError as exc:
raise TranscribeError(str(exc)) from exc
except Exception as exc:
Expand Down Expand Up @@ -299,6 +315,135 @@
return " ".join(parts).strip()


def _resolve_openrouter_model(model_name: str) -> str:
cleaned = (model_name or "").strip()
if not cleaned or cleaned in VALID_MODEL_NAMES:
return DEFAULT_OPENROUTER_TRANSCRIBE_MODEL
if "/" not in cleaned:
return f"openai/{cleaned}"
return cleaned


def _resolve_openai_cloud_model(model_name: str) -> str:
cleaned = (model_name or "").strip()
if not cleaned or cleaned in VALID_MODEL_NAMES:
return DEFAULT_OPENAI_CLOUD_TRANSCRIBE_MODEL
if cleaned.startswith("openai/"):
return cleaned.split("/", 1)[1]
return cleaned


def _transcribe_openrouter(
wav_file: str,
model_name: str,
language: str,
hint: str | None = None,
api_key: str = "",
) -> str:
effective_key = (
api_key.strip()
or os.environ.get("OPENROUTER_API_KEY", "").strip()
)
if not effective_key:
raise TranscribeError(
"OpenRouter API-Key nicht gesetzt. Bitte OPENROUTER_API_KEY in "
"~/.config/blitztext-linux/secrets.env setzen."
)
target_model = _resolve_openrouter_model(model_name)
raw_bytes = Path(wav_file).read_bytes()
Comment thread
TimInTech marked this conversation as resolved.
Dismissed
b64_audio = base64.b64encode(raw_bytes).decode("ascii")
payload: dict[str, Any] = {
"model": target_model,
"input_audio": {
"data": b64_audio,
"format": "wav",
},
}
norm_lang = _normalize_language(language)
if norm_lang:
payload["language"] = norm_lang
if hint:
payload["prompt"] = hint

req = urllib.request.Request(
_OPENROUTER_TRANSCRIPTIONS_URL,
data=json.dumps(payload).encode("utf-8"),
headers={
"Authorization": f"Bearer {effective_key}",
"Content-Type": "application/json",
"HTTP-Referer": "https://github.com/TimInTech/blitztext-linux",
"X-Title": "BlitztextLinux",
},
method="POST",
)
try:
with urllib.request.urlopen(req, timeout=_CLOUD_REQUEST_TIMEOUT) as resp:
body = resp.read().decode("utf-8")
except urllib.error.HTTPError as exc:
detail = ""
try:
err_body = exc.read().decode("utf-8", errors="replace")
err_json = json.loads(err_body)
if isinstance(err_json, dict) and isinstance(err_json.get("error"), dict):
detail = str(err_json["error"].get("message") or "")
except Exception:
detail = ""
msg = f"OpenRouter STT HTTP {exc.code}"
if detail:
msg = f"{msg}: {detail}"
raise TranscribeError(msg) from exc

data = json.loads(body)
if not isinstance(data, dict):
return ""
if "error" in data:
err = data["error"]
err_msg = err.get("message") if isinstance(err, dict) else str(err)
raise TranscribeError(f"OpenRouter STT Fehler: {err_msg}")
text = data.get("text")
return text.strip() if isinstance(text, str) else ""


def _transcribe_openai_cloud(
wav_file: str,
model_name: str,
language: str,
hint: str | None = None,
api_key: str = "",
) -> str:
effective_key = (
api_key.strip()
or os.environ.get("OPENAI_API_KEY", "").strip()
)
if not effective_key:
raise TranscribeError(
"OpenAI API-Key nicht gesetzt. Bitte OPENAI_API_KEY in "
"~/.config/blitztext-linux/secrets.env setzen."
)
try:
import openai # noqa: PLC0415
except ImportError as exc:
raise ImportError("openai-Paket nicht installiert. Bitte: pip install openai") from exc

target_model = _resolve_openai_cloud_model(model_name)
client = openai.OpenAI(api_key=effective_key, max_retries=1, timeout=_CLOUD_REQUEST_TIMEOUT)
kwargs: dict[str, Any] = {"model": target_model}
norm_lang = _normalize_language(language)
if norm_lang:
kwargs["language"] = norm_lang
if hint:
kwargs["prompt"] = hint

with open(wav_file, "rb") as audio_file:
Comment thread
TimInTech marked this conversation as resolved.
Dismissed
response = client.audio.transcriptions.create(file=audio_file, **kwargs)
text = getattr(response, "text", None)
if isinstance(text, str):
return text.strip()
if isinstance(response, dict) and isinstance(response.get("text"), str):
return response["text"].strip()
return ""


# ---------------------------------------------------------------------------
# CLI
# ---------------------------------------------------------------------------
Expand All @@ -313,12 +458,12 @@
language = sys.argv[3] if len(sys.argv) > 3 else "de"
backend = sys.argv[4] if len(sys.argv) > 4 else "openai-whisper"

if model_name not in VALID_MODEL_NAMES:
logger.error("Invalid model name '%s'. Valid: %s", model_name, sorted(VALID_MODEL_NAMES))
return 1
if backend not in VALID_BACKENDS:
logger.error("Invalid backend '%s'. Valid: %s", backend, sorted(VALID_BACKENDS))
return 1
if backend in {"openai-whisper", "faster-whisper"} and model_name not in VALID_MODEL_NAMES:
logger.error("Invalid model name '%s'. Valid: %s", model_name, sorted(VALID_MODEL_NAMES))
return 1
if not os.path.isfile(wav_file):
logger.error("WAV file not found: %s", wav_file)
return 1
Expand All @@ -327,10 +472,7 @@
return 1

try:
if backend == "faster-whisper":
text = _transcribe_faster(wav_file, model_name, language)
else:
text = _transcribe_openai(wav_file, model_name, language)
text = transcribe(wav_file, model=model_name, language=language, backend=backend)
except ImportError as exc:
logger.error("Required dependency missing: %s", exc)
return 1
Expand Down
24 changes: 24 additions & 0 deletions tests/test_config.py
Original file line number Diff line number Diff line change
Expand Up @@ -928,3 +928,27 @@ def test_openrouter_provider_key_env_is_a_persisted_name(tmp_path):
assert BlitztextConfig(config_dir=tmp_path).openrouter_provider_key_env == "MY_ROUTER_KEY"
(tmp_path / "config.json").write_text(json.dumps({"openrouter_provider_key_env": 5}), encoding="utf-8")
assert BlitztextConfig(config_dir=tmp_path).openrouter_provider_key_env == ""


class TestTranscriptionApiKey:
def test_local_backend_returns_no_key(self, config, monkeypatch):
monkeypatch.setenv("OPENAI_API_KEY", "test-key")
assert config.resolve_transcription_api_key() == ""

def test_openrouter_backend_uses_openrouter_env(self, config, monkeypatch):
monkeypatch.setenv("OPENROUTER_API_KEY", "test-or")
monkeypatch.setenv("OPENAI_API_KEY", "test-oa")
config.backend = "openrouter"
assert config.resolve_transcription_api_key() == "test-or"

def test_openai_cloud_uses_openai_env_when_llm_is_openrouter(self, config, monkeypatch):
monkeypatch.setenv("OPENROUTER_API_KEY", "test-or")
monkeypatch.setenv("OPENAI_API_KEY", "test-oa")
config.backend = "openai-cloud"
config.llm_provider = "openrouter"
assert config.resolve_transcription_api_key() == "test-oa"

def test_openai_cloud_without_env_returns_empty(self, config, monkeypatch):
monkeypatch.delenv("OPENAI_API_KEY", raising=False)
config.backend = "openai-cloud"
assert config.resolve_transcription_api_key() == ""
Loading
Loading