From 6eaf74bc7170b0ad9c5f8190aebe7331b5e518bc Mon Sep 17 00:00:00 2001 From: gummiflip Date: Fri, 19 Jun 2026 16:05:55 +0200 Subject: [PATCH 1/3] feat: OpenAI Cloud-TTS als optionale Vorlese-Engine Ergaenzt das Vorlese-Fenster um einen optionalen OpenAI-Cloud-TTS-Anbieter neben dem lokalen Piper (Default). Synthese laeuft non-blocking in einem QThread mit 30s-Timeout; Wiedergabe ueber die bestehende paplay/aplay-Kette. - config: tts_provider (piper|openai), tts_openai_model, tts_openai_voice, tts_openai_consent inkl. Validierung + Sanitize - tts_window: CloudTtsService, _CloudTtsWorker, Anbieter-/Modell-UI, einmaliger Datenschutz-Dialog (persistente Zustimmung), secret-sichere Fehlertexte, robuster Thread-Abbruch mit referenzgehaltenem Detach (kein "QThread destroyed while running") - Tests: +14 (Speed-Mapping, Synthese, Consent-Gate, Secret-Scrub, Detach-Timeout-Pfad) - README: Anbieterwahl und neue Config-Felder dokumentiert Key kommt ausschliesslich aus OPENAI_API_KEY (secrets.env), nie aus config.json. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01AHu5E1iQWEa6LViTcdo3o7 --- README.md | 8 +- app/config.py | 53 ++++ app/tts_window.py | 540 +++++++++++++++++++++++++++++++++++------ tests/test_features.py | 193 ++++++++++++++- 4 files changed, 724 insertions(+), 70 deletions(-) diff --git a/README.md b/README.md index 4c44b69..b67de95 100644 --- a/README.md +++ b/README.md @@ -244,7 +244,7 @@ Zusätzlich zu den Workflows bietet das Tool drei Komfort-Funktionen: | :--- | :--- | | **Diktat-Modus** | Umschalter. Ist er aktiv, werden alle Transkripte als Diktat-Einträge gesammelt und einzeln als Markdown-Datei gespeichert. Im Verlauf erscheint dann eine Schaltfläche **Zusammenführen**, die alle Einträge kombiniert und in die Zwischenablage kopiert. | | **Verlauf…** | Öffnet ein Fenster mit den letzten Transkripten. Pro Eintrag: In Zwischenablage kopieren oder löschen. | -| **Vorlesen…** | Lässt dir beliebigen Text per **Piper TTS** vorlesen (inklusive Stimmen-Auswahl)! | +| **Vorlesen…** | Lässt dir beliebigen Text vorlesen — lokal per **Piper TTS** (Standard) oder optional über **OpenAI Cloud-TTS** (inklusive Anbieter-, Stimmen- und Modellauswahl)! | > [!NOTE] > **Diktat-Notizen** werden ausschließlich in einen Ordner **innerhalb des Home-Verzeichnisses** geschrieben (Schutz gegen Pfad-Ausbruch), mit Berechtigungen `0o600`. @@ -257,6 +257,9 @@ Zusätzlich zu den Workflows bietet das Tool drei Komfort-Funktionen: > ``` > Fehlt Piper oder eine Stimme, zeigt das Vorlese-Fenster einen Installationshinweis; alle übrigen Funktionen bleiben nutzbar. Optionale Desktop-Benachrichtigungen nutzen `notify-send` (Paket `libnotify-bin`). +> [!NOTE] +> **OpenAI Cloud-TTS** ist eine optionale Alternative zu Piper. Voraussetzung: das `openai`-Paket (`.venv/bin/pip install openai`) und ein gültiger Key in der Umgebungsvariable `OPENAI_API_KEY` (siehe `secrets.env` unten). Beim ersten Umschalten auf den Anbieter „OpenAI Cloud" fragt das Vorlese-Fenster einmalig nach Bestätigung, da der eingegebene Text zur Synthese an die OpenAI-Server gesendet wird. Piper bleibt Standard und arbeitet vollständig lokal. + --- ## Konfiguration @@ -301,6 +304,9 @@ Alles wird lokal und sicher unter `~/.config/blitztext-linux/config.json` gespei - Der eigentliche Key liegt nicht in `config.json`, sondern in `~/.config/blitztext-linux/secrets.env` oder einer bereits gesetzten Umgebungsvariable. - **autopaste**: Fügt per `ydotool` ein. - **audio_device**: Name der Audioquelle. +- **tts_provider**: TTS-Anbieter für „Vorlesen" — `piper` (lokal, Standard) oder `openai` (Cloud). +- **tts_openai_model** / **tts_openai_voice**: Modell und Stimme für OpenAI Cloud-TTS (Standard: `gpt-4o-mini-tts`, `marin`). +- **tts_openai_consent**: `true`, sobald die einmalige Datenschutz-Bestätigung für Cloud-TTS erteilt wurde. Standard: `false`. - **workflows**: Feintuning von Tonalität (`text_improver_tone`), Schreibstil-Vorlage (`writing_preset`), Emojis (`emoji_density`) und dem Dampf-Prompt (`dampf_system_prompt`). diff --git a/app/config.py b/app/config.py index 57dfc1f..6610513 100644 --- a/app/config.py +++ b/app/config.py @@ -32,7 +32,11 @@ "audio_device": "@DEFAULT_SOURCE@", "notes_folder": str(Path.home() / "Blitztext-Notizen"), "history_size": 50, + "tts_provider": "piper", "tts_voice": "", + "tts_openai_model": "gpt-4o-mini-tts", + "tts_openai_voice": "marin", + "tts_openai_consent": False, "tts_speed": 1.0, "workflows": { "text_improver_tone": "neutral", @@ -50,6 +54,8 @@ VALID_EMOJI_DENSITIES = {"wenig", "mittel", "viel"} VALID_WRITING_PRESETS = set(WRITING_PRESET_KEYS) VALID_LLM_PROVIDERS = {"openai", "openrouter", "custom"} +VALID_TTS_PROVIDERS = {"piper", "openai"} +VALID_OPENAI_TTS_VOICES = {"alloy", "ash", "ballad", "coral", "echo", "fable", "nova", "onyx", "sage", "shimmer", "verse", "marin", "cedar"} BASE_URL_RE = re.compile(r"^https?://", re.IGNORECASE) VALID_HOTKEY_KEYS = { "KEY_LEFTALT", "KEY_RIGHTALT", "KEY_RIGHTCTRL", "KEY_LEFTCTRL", @@ -255,6 +261,17 @@ def history_size(self) -> int: def history_size(self, value: int) -> None: self._data["history_size"] = max(10, min(100, int(value))) + @property + def tts_provider(self) -> str: + value = self._data.get("tts_provider", DEFAULTS["tts_provider"]) + return value if value in VALID_TTS_PROVIDERS else DEFAULTS["tts_provider"] + + @tts_provider.setter + def tts_provider(self, value: str) -> None: + if value not in VALID_TTS_PROVIDERS: + raise ValueError(f"Ungueltiger TTS-Anbieter: {value!r}. Gueltig: {sorted(VALID_TTS_PROVIDERS)}") + self._data["tts_provider"] = value + @property def tts_voice(self) -> str: return self._data.get("tts_voice", "") @@ -263,6 +280,35 @@ def tts_voice(self) -> str: def tts_voice(self, value: str) -> None: self._data["tts_voice"] = value + @property + def tts_openai_model(self) -> str: + value = self._data.get("tts_openai_model", DEFAULTS["tts_openai_model"]) + return value if isinstance(value, str) and value.strip() else DEFAULTS["tts_openai_model"] + + @tts_openai_model.setter + def tts_openai_model(self, value: str) -> None: + self._data["tts_openai_model"] = value.strip() if isinstance(value, str) and value.strip() else DEFAULTS["tts_openai_model"] + + @property + def tts_openai_voice(self) -> str: + value = self._data.get("tts_openai_voice", DEFAULTS["tts_openai_voice"]) + return value if value in VALID_OPENAI_TTS_VOICES else DEFAULTS["tts_openai_voice"] + + @tts_openai_voice.setter + def tts_openai_voice(self, value: str) -> None: + if value not in VALID_OPENAI_TTS_VOICES: + raise ValueError(f"Ungueltige OpenAI-TTS-Stimme: {value!r}. Gueltig: {sorted(VALID_OPENAI_TTS_VOICES)}") + self._data["tts_openai_voice"] = value + + @property + def tts_openai_consent(self) -> bool: + """Einmalige Nutzer-Bestaetigung, dass lokale Texte an OpenAI gesendet werden duerfen.""" + return bool(self._data.get("tts_openai_consent", DEFAULTS["tts_openai_consent"])) + + @tts_openai_consent.setter + def tts_openai_consent(self, value: bool) -> None: + self._data["tts_openai_consent"] = bool(value) + @property def tts_speed(self) -> float: return float(self._data.get("tts_speed", 1.0)) @@ -344,8 +390,15 @@ def _validate_and_sanitize(self) -> None: self._data["tts_speed"] = 1.0 if not isinstance(self._data.get("notes_folder", ""), str): self._data["notes_folder"] = "" + if self._data.get("tts_provider") not in VALID_TTS_PROVIDERS: + self._data["tts_provider"] = DEFAULTS["tts_provider"] if not isinstance(self._data.get("tts_voice", ""), str): self._data["tts_voice"] = "" + if not isinstance(self._data.get("tts_openai_model", ""), str): + self._data["tts_openai_model"] = DEFAULTS["tts_openai_model"] + if self._data.get("tts_openai_voice") not in VALID_OPENAI_TTS_VOICES: + self._data["tts_openai_voice"] = DEFAULTS["tts_openai_voice"] + self._data["tts_openai_consent"] = bool(self._data.get("tts_openai_consent", False)) self._data["openai_api_key_env"] = _normalize_env_var_name( self._data.get("openai_api_key_env", DEFAULTS["openai_api_key_env"]) diff --git a/app/tts_window.py b/app/tts_window.py index 7f534d6..5a9c89c 100644 --- a/app/tts_window.py +++ b/app/tts_window.py @@ -1,28 +1,22 @@ -"""Vorlese-Fenster (Text-to-Speech) auf Basis von Piper TTS. - -Portiert aus whisper-dictation app/gui/tts_window.py, angepasst an die -Blitztext-Config (Config-Objekt statt Modul-Config). - -Piper ist eine optionale Abhaengigkeit. Fehlt es, wird das Fenster mit einem -Installationshinweis angezeigt und die Vorlese-Funktion deaktiviert. -""" +"""Vorlese-Fenster (Text-to-Speech) mit lokalem Piper und optionalem OpenAI Cloud-TTS.""" from __future__ import annotations import os import shutil import signal -import subprocess import tempfile from pathlib import Path from typing import Optional -from PyQt6.QtCore import QProcess, QTimer, pyqtSlot +from PyQt6.QtCore import QObject, QProcess, QThread, QTimer, pyqtSignal, pyqtSlot from PyQt6.QtGui import QCloseEvent from PyQt6.QtWidgets import ( QComboBox, QDialog, QHBoxLayout, QLabel, + QLineEdit, + QMessageBox, QPushButton, QTextEdit, QVBoxLayout, @@ -31,14 +25,46 @@ PIPER_VENV_PATH = str(Path(__file__).resolve().parents[1] / ".venv" / "bin" / "piper") VOICES_DIR = Path.home() / ".local" / "share" / "piper-voices" -TTS_WAV = os.path.join( - os.environ.get("XDG_RUNTIME_DIR", tempfile.gettempdir()), - "blitztext-tts.wav", -) +TTS_WAV = os.path.join(os.environ.get("XDG_RUNTIME_DIR", tempfile.gettempdir()), "blitztext-tts.wav") PIPER_INSTALL_HINT = ( "Piper nicht gefunden. Installieren: pip install piper-tts und Stimmen nach " "~/.local/share/piper-voices legen." ) +OPENAI_TTS_INSTALL_HINT = ( + "OpenAI Cloud-TTS ist nicht verfuegbar. Bitte OPENAI_API_KEY in " + "~/.config/blitztext-linux/secrets.env setzen." +) +OPENAI_TTS_MODEL_DEFAULT = "gpt-4o-mini-tts" +OPENAI_TTS_VOICE_DEFAULT = "marin" +OPENAI_TTS_VOICES = [ + "alloy", + "ash", + "ballad", + "coral", + "echo", + "fable", + "nova", + "onyx", + "sage", + "shimmer", + "verse", + "marin", + "cedar", +] +OPENAI_TTS_MODEL_OPTIONS = [OPENAI_TTS_MODEL_DEFAULT, "tts-1", "tts-1-hd"] +OPENAI_TTS_TIMEOUT = 30.0 +OPENAI_TTS_CONSENT_TEXT = ( + "OpenAI Cloud-TTS sendet den eingegebenen Text zur Sprachsynthese an die " + "OpenAI-Server. Lokale Texte verlassen damit deinen Rechner.\n\n" + "Moechtest du Cloud-TTS aktivieren?" +) + + +def _scrub_secret(text: str, secret: str) -> str: + """Entfernt einen API-Key aus Fehlertexten, damit er nie in der UI/Logs erscheint.""" + if secret and text: + return text.replace(secret, "***") + return text def _find_piper() -> Optional[str]: @@ -61,28 +87,157 @@ def list_voices() -> list[tuple[str, str]]: return voices +def list_openai_voices() -> list[str]: + return list(OPENAI_TTS_VOICES) + + +def _tts_speed_to_openai_speed(speed: float) -> float: + speed = max(0.5, min(2.0, float(speed))) + return round(1.0 / speed, 3) + + def _playback_command(wav_path: str) -> tuple[str, list[str]]: - """paplay (PipeWire-nativ) vor aplay bevorzugen, um dmix-Konflikte zu meiden.""" if shutil.which("paplay"): return "paplay", [wav_path] return "aplay", ["-D", "pipewire", wav_path] +class CloudTtsServiceError(Exception): + """Raised when OpenAI Cloud-TTS cannot be synthesized.""" + + +class CloudTtsService: + def __init__(self, config, client: Optional[object] = None) -> None: + self.config = config + self.api_key = config.resolve_openai_api_key() + self.api_key_env = config.openai_api_key_env + self._openai_installed = True + self._client_is_fallback_mock = False + if client is not None: + self.client = client + else: + if not self.api_key: + from unittest.mock import MagicMock + + self._client_is_fallback_mock = True + self.client = MagicMock() + else: + try: + import openai + except ImportError: + self._openai_installed = False + self._client_is_fallback_mock = True + from unittest.mock import MagicMock + + self.client = MagicMock() + else: + self.client = openai.OpenAI(api_key=self.api_key) + + def is_available(self) -> bool: + return self.config.tts_provider == "openai" and bool(self.api_key) + + def _missing_key_message(self) -> str: + return ( + f"OpenAI API-Key nicht gesetzt. Bitte die Umgebungsvariable " + f"{self.api_key_env} in ~/.config/blitztext-linux/secrets.env setzen." + ) + + def _check_ready(self) -> None: + if self.config.tts_provider != "openai": + raise CloudTtsServiceError("OpenAI Cloud-TTS ist nicht aktiviert.") + if not self.api_key: + raise CloudTtsServiceError(self._missing_key_message()) + if not self._openai_installed and self._client_is_fallback_mock: + raise CloudTtsServiceError("openai-Paket nicht installiert. Bitte: pip install openai") + + def synthesize(self, text: str, output_path: str = TTS_WAV) -> str: + self._check_ready() + if not text or not text.strip(): + raise ValueError("text must not be empty") + + response = self.client.audio.speech.create( + model=self.config.tts_openai_model, + voice=self.config.tts_openai_voice, + input=text.strip(), + response_format="wav", + speed=_tts_speed_to_openai_speed(self.config.tts_speed), + timeout=OPENAI_TTS_TIMEOUT, + ) + + if hasattr(response, "stream_to_file"): + response.stream_to_file(output_path) + elif hasattr(response, "write_to_file"): + response.write_to_file(output_path) + else: + data = None + if hasattr(response, "read"): + data = response.read() + elif hasattr(response, "content"): + data = response.content + elif hasattr(response, "body"): + data = response.body + if data is None: + raise CloudTtsServiceError("OpenAI-Antwort kann nicht in eine Datei geschrieben werden.") + if isinstance(data, str): + data = data.encode("utf-8") + Path(output_path).write_bytes(data) + return output_path + + +class _CloudTtsWorker(QObject): + finished = pyqtSignal(str) + error = pyqtSignal(str) + + def __init__(self, service: CloudTtsService, text: str, output_path: str) -> None: + super().__init__() + self._service = service + self._text = text + self._output_path = output_path + self._cancelled = False + + def request_cancel(self) -> None: + self._cancelled = True + + @pyqtSlot() + def run(self) -> None: + try: + if self._cancelled or QThread.currentThread().isInterruptionRequested(): + return + result = self._service.synthesize(self._text, output_path=self._output_path) + if self._cancelled or QThread.currentThread().isInterruptionRequested(): + try: + Path(result).unlink(missing_ok=True) + except OSError: + pass + return + self.finished.emit(result) + except Exception as exc: # pragma: no cover - defensive + if not self._cancelled: + secret = getattr(self._service, "api_key", "") + self.error.emit(_scrub_secret(str(exc), secret)) + + class TtsWindow(QDialog): - """Text -> Piper TTS. Non-blocking via QProcess, mit Abbruch + Stimmenwahl.""" + """Text -> TTS. Non-blocking via QProcess bzw. QThread, mit Abbruch + Stimmenwahl.""" def __init__(self, config, parent: Optional[QWidget] = None) -> None: super().__init__(parent) self._config = config self.setWindowTitle("Vorlesen") - self.resize(380, 280) + self.resize(430, 340) self._piper_proc: Optional[QProcess] = None self._aplay_proc: Optional[QProcess] = None + self._cloud_thread: Optional[QThread] = None + self._cloud_worker: Optional[_CloudTtsWorker] = None + # Noch laufende, vom Dialog geloeste Cloud-Threads bis 'finished' referenziert + # halten, damit PyQt keinen laufenden QThread zerstoert ("destroyed while running"). + self._detached_cloud_threads: list[QThread] = [] self._piper_path = _find_piper() self._last_tts_text = "" self._is_paused = False self._setup_ui() self._populate_voices() + self._refresh_status_hint() def _setup_ui(self) -> None: layout = QVBoxLayout(self) @@ -93,30 +248,51 @@ def _setup_ui(self) -> None: self._text_edit.setPlaceholderText("Text zum Vorlesen eingeben…") layout.addWidget(self._text_edit, 1) + provider_row = QHBoxLayout() + provider_row.setSpacing(6) + provider_row.addWidget(QLabel("Anbieter:")) + self._provider_combo = QComboBox() + self._provider_combo.addItem("Piper lokal", "piper") + self._provider_combo.addItem("OpenAI Cloud", "openai") + self._provider_combo.currentIndexChanged.connect(self._on_provider_changed) + provider_row.addWidget(self._provider_combo, 1) + layout.addLayout(provider_row) + voice_row = QHBoxLayout() voice_row.setSpacing(6) voice_row.addWidget(QLabel("Stimme:")) self._voice_combo = QComboBox() self._voice_combo.currentIndexChanged.connect(self._on_voice_changed) voice_row.addWidget(self._voice_combo, 1) + layout.addLayout(voice_row) - voice_row.addWidget(QLabel("Tempo:")) + model_row = QHBoxLayout() + model_row.setSpacing(6) + model_row.addWidget(QLabel("Modell:")) + self._model_edit = QLineEdit() + self._model_edit.setPlaceholderText(OPENAI_TTS_MODEL_DEFAULT) + self._model_edit.editingFinished.connect(self._on_model_changed) + model_row.addWidget(self._model_edit, 1) + layout.addLayout(model_row) + + speed_row = QHBoxLayout() + speed_row.setSpacing(6) + speed_row.addWidget(QLabel("Tempo:")) self._speed_combo = QComboBox() self._speed_combo.addItem("Sehr Schnell", 0.6) self._speed_combo.addItem("Schnell", 0.8) self._speed_combo.addItem("Normal", 1.0) self._speed_combo.addItem("Langsam", 1.25) self._speed_combo.addItem("Sehr Langsam", 1.5) - saved_speed = float(self._config.tts_speed) idx = self._speed_combo.findData(saved_speed) self._speed_combo.setCurrentIndex(idx if idx >= 0 else 2) self._speed_combo.currentIndexChanged.connect(self._on_speed_changed) - voice_row.addWidget(self._speed_combo) - - layout.addLayout(voice_row) + speed_row.addWidget(self._speed_combo) + layout.addLayout(speed_row) self._status_label = QLabel("") + self._status_label.setWordWrap(True) layout.addWidget(self._status_label) btn_row = QHBoxLayout() @@ -126,7 +302,7 @@ def _setup_ui(self) -> None: self._btn_close.clicked.connect(self.accept) btn_row.addWidget(self._btn_close) - self._btn_replay = QPushButton("\U0001f501 Nochmal") + self._btn_replay = QPushButton("🔁 Nochmal") self._btn_replay.clicked.connect(self._on_replay_clicked) self._btn_replay.setEnabled(False) btn_row.addWidget(self._btn_replay) @@ -142,51 +318,162 @@ def _setup_ui(self) -> None: layout.addLayout(btn_row) + self._provider_combo.setCurrentIndex(0 if self._config.tts_provider == "piper" else 1) + self._model_edit.setText(self._config.tts_openai_model) + def set_text(self, text: str) -> None: self._text_edit.setPlainText(text) def _populate_voices(self) -> None: - if not self._piper_path: - self._voice_combo.setEnabled(False) - self._btn_speak.setEnabled(False) - self._status_label.setText(PIPER_INSTALL_HINT) - self._status_label.setStyleSheet("color: #f44336;") - return - - voices = list_voices() + provider = self._current_provider() self._voice_combo.blockSignals(True) self._voice_combo.clear() - if not voices: - self._voice_combo.addItem("(keine Stimmen gefunden)") - self._voice_combo.setEnabled(False) - self._btn_speak.setEnabled(False) - self._status_label.setText( - "Keine Stimmen in ~/.local/share/piper-voices gefunden." - ) - self._status_label.setStyleSheet("color: #f44336;") - self._voice_combo.blockSignals(False) - return - for label, path in voices: - self._voice_combo.addItem(label, userData=path) + if provider == "openai": + for voice in list_openai_voices(): + self._voice_combo.addItem(voice, userData=voice) + selected = self._voice_combo.findData(self._config.tts_openai_voice) + self._voice_combo.setCurrentIndex(selected if selected >= 0 else self._voice_combo.findData(OPENAI_TTS_VOICE_DEFAULT)) + self._voice_combo.setEnabled(True) + else: + voices = list_voices() + if not voices: + self._voice_combo.addItem("(keine Stimmen gefunden)") + self._voice_combo.setEnabled(False) + else: + for label, path in voices: + self._voice_combo.addItem(label, userData=path) + selected = self._voice_combo.findData(self._config.tts_voice) + self._voice_combo.setCurrentIndex(selected if selected >= 0 else 0) + self._voice_combo.setEnabled(True) - saved_voice = str(self._config.tts_voice or "") - selected = self._voice_combo.findData(saved_voice) if saved_voice else -1 - self._voice_combo.setCurrentIndex(selected if selected >= 0 else 0) self._voice_combo.blockSignals(False) + self._model_edit.setEnabled(provider == "openai") + self._speed_combo.setEnabled(True) + self._update_speak_button_state() + + def _refresh_status_hint(self) -> None: + provider = self._current_provider() + if provider == "openai": + service = CloudTtsService(self._config) + if service.is_available(): + self._status_label.setText("OpenAI Cloud-TTS bereit.") + self._status_label.setStyleSheet("color: #4caf50;") + else: + self._status_label.setText(OPENAI_TTS_INSTALL_HINT) + self._status_label.setStyleSheet("color: #f44336;") + else: + if self._piper_path: + self._status_label.setText("Piper bereit.") + self._status_label.setStyleSheet("color: #4caf50;") + else: + self._status_label.setText(PIPER_INSTALL_HINT) + self._status_label.setStyleSheet("color: #f44336;") + self._update_speak_button_state() + + def _update_speak_button_state(self) -> None: + provider = self._current_provider() + if provider == "openai": + self._btn_speak.setEnabled(CloudTtsService(self._config).is_available() or self._cloud_is_running()) + else: + self._btn_speak.setEnabled(bool(self._piper_path) or self._cloud_is_running()) - @pyqtSlot(int) - def _on_voice_changed(self, _idx: int) -> None: + def _current_provider(self) -> str: + provider = self._provider_combo.currentData() + return provider if isinstance(provider, str) else "piper" + + def _current_voice(self) -> str: voice = self._voice_combo.currentData() - if not isinstance(voice, str) or not voice: - return - if self._config.tts_voice == voice: - return + if isinstance(voice, str) and voice: + return voice + if self._current_provider() == "openai": + return self._config.tts_openai_voice + voices = list_voices() + return voices[0][1] if voices else "" + + def _current_tts_text(self) -> str: + return self._text_edit.toPlainText().strip() + + def _cloud_is_running(self) -> bool: + return self._cloud_thread is not None and self._cloud_thread.isRunning() + + def _ensure_openai_consent(self) -> bool: + """Einmalige Datenschutz-Bestaetigung fuer Cloud-TTS. True, wenn erteilt.""" + if self._config.tts_openai_consent: + return True + answer = QMessageBox.question( + self, + "OpenAI Cloud-TTS aktivieren?", + OPENAI_TTS_CONSENT_TEXT, + QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No, + QMessageBox.StandardButton.No, + ) + if answer != QMessageBox.StandardButton.Yes: + return False + self._config.tts_openai_consent = True try: - self._config.tts_voice = voice self._config.save() except Exception: pass + return True + + def _revert_provider_to_piper(self) -> None: + self._provider_combo.blockSignals(True) + self._provider_combo.setCurrentIndex(0) + self._provider_combo.blockSignals(False) + if self._config.tts_provider != "piper": + self._config.tts_provider = "piper" + try: + self._config.save() + except Exception: + pass + + @pyqtSlot(int) + def _on_provider_changed(self, _idx: int) -> None: + provider = self._current_provider() + if provider == "openai" and not self._ensure_openai_consent(): + self._revert_provider_to_piper() + self._populate_voices() + self._refresh_status_hint() + return + if self._config.tts_provider != provider: + self._config.tts_provider = provider + try: + self._config.save() + except Exception: + pass + self._populate_voices() + self._refresh_status_hint() + + @pyqtSlot(int) + def _on_voice_changed(self, _idx: int) -> None: + voice = self._voice_combo.currentData() + provider = self._current_provider() + if provider == "openai": + if isinstance(voice, str) and voice and self._config.tts_openai_voice != voice: + self._config.tts_openai_voice = voice + try: + self._config.save() + except Exception: + pass + else: + if isinstance(voice, str) and voice and self._config.tts_voice != voice: + self._config.tts_voice = voice + try: + self._config.save() + except Exception: + pass + self._refresh_status_hint() + + def _on_model_changed(self) -> None: + model = self._model_edit.text().strip() or OPENAI_TTS_MODEL_DEFAULT + if self._config.tts_openai_model != model: + self._config.tts_openai_model = model + try: + self._config.save() + except Exception: + pass + self._refresh_status_hint() @pyqtSlot(int) def _on_speed_changed(self, _idx: int) -> None: @@ -201,18 +488,11 @@ def _on_speed_changed(self, _idx: int) -> None: except Exception: pass - def _current_voice(self) -> str: - voice = self._voice_combo.currentData() - if isinstance(voice, str) and voice: - return voice - voices = list_voices() - return voices[0][1] if voices else "" - def _is_speaking(self) -> bool: for proc in (self._piper_proc, self._aplay_proc): if proc is not None and proc.state() != QProcess.ProcessState.NotRunning: return True - return False + return self._cloud_is_running() @pyqtSlot() def _on_speak_clicked(self) -> None: @@ -252,27 +532,37 @@ def _on_pause_clicked(self) -> None: def _start_tts(self, text: Optional[str] = None) -> None: if text is None: - text = self._text_edit.toPlainText().strip() + text = self._current_tts_text() if not text: self._status_label.setText("Kein Text.") self._status_label.setStyleSheet("color: #f44336;") return self._last_tts_text = text self._btn_replay.setEnabled(True) + + provider = self._current_provider() + if provider == "openai": + self._start_cloud_tts(text) + else: + self._start_piper_tts(text) + + def _start_piper_tts(self, text: str) -> None: if not self._piper_path: + self._status_label.setText(PIPER_INSTALL_HINT) + self._status_label.setStyleSheet("color: #f44336;") return model_path = self._current_voice() if not model_path: + self._status_label.setText("Keine Stimme in ~/.local/share/piper-voices gefunden.") + self._status_label.setStyleSheet("color: #f44336;") return - speed = float(self._config.tts_speed) - proc = QProcess(self) proc.setProgram(self._piper_path) proc.setArguments([ "--model", model_path, - "--length_scale", str(speed), + "--length_scale", str(float(self._config.tts_speed)), "--output_file", TTS_WAV, ]) proc.finished.connect(self._on_piper_finished) @@ -282,11 +572,45 @@ def _start_tts(self, text: Optional[str] = None) -> None: self._status_label.setText("Synthese…") self._status_label.setStyleSheet("") self._btn_speak.setText("Stopp") + self._update_speak_button_state() proc.start() proc.write(text.encode("utf-8")) proc.closeWriteChannel() + def _start_cloud_tts(self, text: str) -> None: + if not self._config.tts_openai_consent: + self._status_label.setText("OpenAI Cloud-TTS wurde nicht bestaetigt.") + self._status_label.setStyleSheet("color: #f44336;") + self._update_speak_button_state() + return + service = CloudTtsService(self._config) + if not service.is_available(): + self._status_label.setText(OPENAI_TTS_INSTALL_HINT) + self._status_label.setStyleSheet("color: #f44336;") + self._update_speak_button_state() + return + + thread = QThread(self) + worker = _CloudTtsWorker(service, text, TTS_WAV) + worker.moveToThread(thread) + thread.started.connect(worker.run) + worker.finished.connect(self._on_cloud_finished) + worker.error.connect(self._on_cloud_error) + worker.finished.connect(thread.quit) + worker.error.connect(thread.quit) + thread.finished.connect(worker.deleteLater) + thread.finished.connect(self._on_cloud_thread_finished) + self._cloud_thread = thread + self._cloud_worker = worker + + self._status_label.setText("Cloud-Synthese…") + self._status_label.setStyleSheet("") + self._btn_speak.setText("Stopp") + self._btn_pause.setEnabled(False) + thread.start() + self._update_speak_button_state() + def _stop_tts(self) -> None: for attr in ("_piper_proc", "_aplay_proc"): proc = getattr(self, attr, None) @@ -313,14 +637,90 @@ def _stop_tts(self) -> None: pass proc.deleteLater() setattr(self, attr, None) + + self._detach_cloud_thread() + self._btn_speak.setText("Vorlesen") self._btn_pause.setEnabled(False) self._is_paused = False self._btn_pause.setText("Pause") self._status_label.setText("Abgebrochen.") self._status_label.setStyleSheet("color: #ff9800;") + self._update_speak_button_state() QTimer.singleShot(2000, self._clear_status) + def _cleanup_cloud_state(self) -> None: + self._cloud_worker = None + self._cloud_thread = None + + def _detach_cloud_thread(self) -> None: + """Bricht den Cloud-Thread ab. Beendet er sich nicht rechtzeitig (blockierender + Netzwerkaufruf), wird er vom Dialog entkoppelt, aber bis 'finished' in + ``self._detached_cloud_threads`` referenziert gehalten -- sonst koennte PyQt den + noch laufenden QThread zerstoeren ("destroyed while running"). Idempotent: nach dem + Detach ist der aktive Slot None, ein erneuter Aufruf ist ein No-op.""" + worker = self._cloud_worker + thread = self._cloud_thread + if worker is not None: + worker.request_cancel() + if thread is not None: + thread.requestInterruption() + thread.quit() + if not thread.wait(1500): + # Thread haengt noch (z. B. im Netzwerkaufruf): vom Dialog loesen und + # referenziert halten, bis er 'finished' meldet. Erst dann deleteLater. + try: + thread.setParent(None) + except Exception: + pass + # Worker am Thread festhalten, damit auch seine Python-Referenz bleibt. + thread._detached_worker = worker # type: ignore[attr-defined] + self._detached_cloud_threads.append(thread) + thread.finished.connect( + lambda t=thread: self._on_detached_thread_finished(t) + ) + self._cleanup_cloud_state() + + def _on_detached_thread_finished(self, thread: QThread) -> None: + """Gibt einen detachten Cloud-Thread erst frei, nachdem er wirklich beendet ist.""" + self._detached_cloud_threads = [ + t for t in self._detached_cloud_threads if t is not thread + ] + thread.deleteLater() + + @pyqtSlot(str) + def _on_cloud_finished(self, wav_path: str) -> None: + self._cleanup_cloud_state() + self._status_label.setText("Wiedergabe…") + program, args = _playback_command(wav_path) + aplay = QProcess(self) + aplay.setProgram(program) + aplay.setArguments(args) + aplay.finished.connect(self._on_aplay_finished) + aplay.errorOccurred.connect(self._on_tts_error) + self._aplay_proc = aplay + self._is_paused = False + self._btn_pause.setText("Pause") + self._btn_pause.setEnabled(True) + self._btn_speak.setText("Stopp") + aplay.start() + + @pyqtSlot(str) + def _on_cloud_error(self, message: str) -> None: + self._cleanup_cloud_state() + self._status_label.setText(f"Fehler: {message}") + self._status_label.setStyleSheet("color: #f44336;") + self._btn_speak.setText("Vorlesen") + self._btn_pause.setEnabled(False) + self._is_paused = False + self._btn_pause.setText("Pause") + self._update_speak_button_state() + QTimer.singleShot(2500, self._clear_status) + + def _on_cloud_thread_finished(self) -> None: + self._cleanup_cloud_state() + self._update_speak_button_state() + @pyqtSlot(int, QProcess.ExitStatus) def _on_piper_finished(self, exit_code: int, exit_status: QProcess.ExitStatus) -> None: proc = self._piper_proc @@ -335,6 +735,7 @@ def _on_piper_finished(self, exit_code: int, exit_status: QProcess.ExitStatus) - self._status_label.setText(f"Fehler: {msg}") self._status_label.setStyleSheet("color: #f44336;") self._btn_speak.setText("Vorlesen") + self._update_speak_button_state() QTimer.singleShot(2500, self._clear_status) return if proc is not None: @@ -373,12 +774,13 @@ def _on_aplay_finished(self, exit_code: int, exit_status: QProcess.ExitStatus) - self._status_label.setStyleSheet("color: #f44336;") if proc is not None: proc.deleteLater() + self._update_speak_button_state() QTimer.singleShot(2500, self._clear_status) @pyqtSlot(QProcess.ProcessError) def _on_tts_error(self, error: QProcess.ProcessError) -> None: if error == QProcess.ProcessError.FailedToStart: - self._status_label.setText(PIPER_INSTALL_HINT) + self._status_label.setText(PIPER_INSTALL_HINT if self._current_provider() == "piper" else OPENAI_TTS_INSTALL_HINT) else: self._status_label.setText(f"Fehler: {error.name}") self._status_label.setStyleSheet("color: #f44336;") @@ -391,13 +793,15 @@ def _on_tts_error(self, error: QProcess.ProcessError) -> None: if proc is not None: proc.deleteLater() setattr(self, attr, None) + self._update_speak_button_state() def _clear_status(self) -> None: if not self._is_speaking(): - self._status_label.setText("") - self._status_label.setStyleSheet("") + self._refresh_status_hint() def closeEvent(self, event: QCloseEvent) -> None: if self._is_speaking(): self._stop_tts() + # Sicherstellen, dass kein Cloud-Thread mehr am Dialog haengt, bevor er zerstoert wird. + self._detach_cloud_thread() super().closeEvent(event) diff --git a/tests/test_features.py b/tests/test_features.py index 4cae0e7..4b6d26d 100644 --- a/tests/test_features.py +++ b/tests/test_features.py @@ -4,7 +4,8 @@ import shutil import tempfile from pathlib import Path -from unittest.mock import patch +from types import SimpleNamespace +from unittest.mock import MagicMock, patch import pytest @@ -30,6 +31,9 @@ def test_defaults(self, tmp_path): assert cfg.history_size == 50 assert cfg.tts_speed == 1.0 assert cfg.tts_voice == "" + assert cfg.tts_provider == "piper" + assert cfg.tts_openai_model == "gpt-4o-mini-tts" + assert cfg.tts_openai_voice == "marin" assert cfg.notes_folder.endswith("Blitztext-Notizen") def test_history_size_clamped(self, tmp_path): @@ -50,11 +54,17 @@ def test_roundtrip_save_load(self, tmp_path): path = tmp_path / "config.json" cfg = Config.load(path) cfg.tts_voice = "de_DE-thorsten-medium.onnx" + cfg.tts_provider = "openai" + cfg.tts_openai_model = "gpt-4o-mini-tts" + cfg.tts_openai_voice = "nova" cfg.notes_folder = str(Path.home() / "Notizen") cfg.history_size = 25 cfg.save() cfg2 = Config.load(path) assert cfg2.tts_voice == "de_DE-thorsten-medium.onnx" + assert cfg2.tts_provider == "openai" + assert cfg2.tts_openai_model == "gpt-4o-mini-tts" + assert cfg2.tts_openai_voice == "nova" assert cfg2.history_size == 25 assert cfg2.notes_folder.endswith("Notizen") @@ -223,3 +233,184 @@ def test_is_piper_available_false_when_missing(self): def test_list_voices_empty_when_no_dir(self): with patch.object(tts_window, "VOICES_DIR", Path("/nonexistent/piper-voices")): assert tts_window.list_voices() == [] + + def test_openai_speed_mapping_inverts_tts_scale(self): + assert tts_window._tts_speed_to_openai_speed(0.5) == 2.0 + assert tts_window._tts_speed_to_openai_speed(1.0) == 1.0 + assert tts_window._tts_speed_to_openai_speed(2.0) == 0.5 + + def test_openai_cloud_service_streams_wav_with_mapped_speed(self, tmp_path, monkeypatch): + monkeypatch.setenv("OPENAI_API_KEY", "sk-test") + cfg = Config.load(tmp_path / "config.json") + cfg.tts_provider = "openai" + cfg.tts_openai_model = "gpt-4o-mini-tts" + cfg.tts_openai_voice = "marin" + cfg.tts_speed = 0.5 + + response = MagicMock() + output_path = tmp_path / "cloud-tts.wav" + + def _write_file(path): + Path(path).write_bytes(b"RIFF\x00WAVE") + + response.stream_to_file.side_effect = _write_file + client = MagicMock() + client.audio.speech.create.return_value = response + + service = tts_window.CloudTtsService(cfg, client=client) + result = service.synthesize("Hallo Welt", output_path=str(output_path)) + + assert result == str(output_path) + client.audio.speech.create.assert_called_once_with( + model="gpt-4o-mini-tts", + voice="marin", + input="Hallo Welt", + response_format="wav", + speed=2.0, + timeout=tts_window.OPENAI_TTS_TIMEOUT, + ) + assert output_path.read_bytes().startswith(b"RIFF") + + def test_openai_cloud_service_raises_without_key(self, tmp_path, monkeypatch): + monkeypatch.delenv("OPENAI_API_KEY", raising=False) + cfg = Config.load(tmp_path / "config.json") + cfg.tts_provider = "openai" + service = tts_window.CloudTtsService(cfg) + with pytest.raises(tts_window.CloudTtsServiceError): + service.synthesize("Hallo Welt", output_path=str(tmp_path / "x.wav")) + + def test_openai_cloud_service_propagates_timeout_error(self, tmp_path, monkeypatch): + monkeypatch.setenv("OPENAI_API_KEY", "sk-test") + cfg = Config.load(tmp_path / "config.json") + cfg.tts_provider = "openai" + client = MagicMock() + client.audio.speech.create.side_effect = TimeoutError("request timed out") + service = tts_window.CloudTtsService(cfg, client=client) + with pytest.raises(TimeoutError): + service.synthesize("Hallo Welt", output_path=str(tmp_path / "x.wav")) + + def test_scrub_secret_removes_api_key(self): + msg = tts_window._scrub_secret("Fehler mit sk-secret123 im Text", "sk-secret123") + assert "sk-secret123" not in msg + assert "***" in msg + + def test_scrub_secret_noop_without_secret(self): + assert tts_window._scrub_secret("kein key", "") == "kein key" + + def test_consent_defaults_false_and_persists(self, tmp_path): + path = tmp_path / "config.json" + cfg = Config.load(path) + assert cfg.tts_openai_consent is False + cfg.tts_openai_consent = True + cfg.save() + assert Config.load(path).tts_openai_consent is True + + +class TestCloudTtsConsentGate: + """Consent-Logik GUI-frei via Fake-self (Muster wie test_settings_dialog).""" + + def _fake_window(self, tmp_path): + cfg = Config.load(tmp_path / "config.json") + return SimpleNamespace(_config=cfg) + + def test_consent_already_granted_skips_dialog(self, tmp_path): + fake = self._fake_window(tmp_path) + fake._config.tts_openai_consent = True + with patch.object(tts_window, "QMessageBox") as box: + result = tts_window.TtsWindow._ensure_openai_consent(fake) + assert result is True + box.question.assert_not_called() + + def test_consent_granted_when_user_accepts(self, tmp_path): + fake = self._fake_window(tmp_path) + assert fake._config.tts_openai_consent is False + with patch.object(tts_window, "QMessageBox") as box: + box.question.return_value = box.StandardButton.Yes + result = tts_window.TtsWindow._ensure_openai_consent(fake) + assert result is True + assert fake._config.tts_openai_consent is True + + def test_consent_declined_when_user_rejects(self, tmp_path): + fake = self._fake_window(tmp_path) + with patch.object(tts_window, "QMessageBox") as box: + box.question.return_value = box.StandardButton.No + result = tts_window.TtsWindow._ensure_openai_consent(fake) + assert result is False + assert fake._config.tts_openai_consent is False + + def test_revert_provider_to_piper_persists(self, tmp_path): + cfg = Config.load(tmp_path / "config.json") + cfg.tts_provider = "openai" + combo = MagicMock() + fake = SimpleNamespace(_config=cfg, _provider_combo=combo) + tts_window.TtsWindow._revert_provider_to_piper(fake) + assert cfg.tts_provider == "piper" + combo.setCurrentIndex.assert_called_once_with(0) + + def test_start_cloud_tts_blocks_without_consent(self, tmp_path): + cfg = Config.load(tmp_path / "config.json") + cfg.tts_provider = "openai" + assert cfg.tts_openai_consent is False + fake = SimpleNamespace( + _config=cfg, + _status_label=MagicMock(), + _update_speak_button_state=MagicMock(), + ) + with patch.object(tts_window, "CloudTtsService") as service: + tts_window.TtsWindow._start_cloud_tts(fake, "Hallo Welt") + service.assert_not_called() + fake._status_label.setText.assert_called_once() + + +class TestDetachCloudThread: + """Detach-Timeout-Pfad GUI-frei via Fake-self und gemockten QThreads.""" + + def _fake_window(self): + fake = SimpleNamespace( + _cloud_worker=MagicMock(), + _cloud_thread=MagicMock(), + _detached_cloud_threads=[], + ) + + def _cleanup(): + fake._cloud_worker = None + fake._cloud_thread = None + + fake._cleanup_cloud_state = _cleanup + return fake + + def test_hanging_thread_is_retained_not_destroyed(self): + fake = self._fake_window() + thread = fake._cloud_thread + worker = fake._cloud_worker + thread.wait.return_value = False # Timeout -> Thread haengt noch + tts_window.TtsWindow._detach_cloud_thread(fake) + # Referenz wird gehalten, Thread NICHT sofort zerstoert: + assert thread in fake._detached_cloud_threads + thread.deleteLater.assert_not_called() + thread.finished.connect.assert_called_once() + worker.request_cancel.assert_called_once() + # Aktiver Slot ist frei, laufender detached Thread bleibt erhalten: + assert fake._cloud_thread is None + + def test_quick_thread_is_not_detached(self): + fake = self._fake_window() + thread = fake._cloud_thread + thread.wait.return_value = True # beendet sich rechtzeitig + tts_window.TtsWindow._detach_cloud_thread(fake) + assert fake._detached_cloud_threads == [] + thread.setParent.assert_not_called() + + def test_detach_is_idempotent_without_active_thread(self): + fake = self._fake_window() + fake._cloud_worker = None + fake._cloud_thread = None + tts_window.TtsWindow._detach_cloud_thread(fake) # darf nicht crashen + assert fake._detached_cloud_threads == [] + + def test_finished_releases_detached_thread(self): + thread = MagicMock() + fake = SimpleNamespace(_detached_cloud_threads=[thread]) + tts_window.TtsWindow._on_detached_thread_finished(fake, thread) + assert thread not in fake._detached_cloud_threads + thread.deleteLater.assert_called_once() From c24bd669b8d4de858c68196a1f26c4faba12a1f0 Mon Sep 17 00:00:00 2001 From: gummiflip Date: Fri, 19 Jun 2026 16:29:53 +0200 Subject: [PATCH 2/3] docs: Roadmap um Audio-Export und Launch-Smoke-Test ergaenzen - Audio-Export transkribierter Texte als versandfreundliche Datei (OGG/Opus bzw. MP3) fuer Sprachnachrichten (WhatsApp o.Ae.) - Leichter Launch-Smoke-Test, damit CI den realen App-Start bestaetigt Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01AHu5E1iQWEa6LViTcdo3o7 --- ROADMAP.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/ROADMAP.md b/ROADMAP.md index fec3d75..281f54a 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -22,6 +22,8 @@ It is a planning note, not a promise. - Keep the installer and verify script in sync with the dependencies they check - Add more regression coverage around startup, config, and transcription edge cases - Replace the current `evdev`/`input` global-hotkey path with a desktop-native XDG GlobalShortcuts integration when it is practical for KDE/Wayland +- Add a lightweight launch smoke test (boot the app offscreen and exit cleanly) so CI confirms the GUI actually starts, not just that mocked logic passes +- Export transcribed text as a shareable audio file (e.g. OGG/Opus or MP3) so longer transcripts can be sent as a voice message via WhatsApp or similar (builds on the TTS pipeline) ## Not in scope From 33c967ae38fe1dcfe8f76af9de679ec500e7215f Mon Sep 17 00:00:00 2001 From: gummiflip Date: Fri, 19 Jun 2026 21:41:20 +0200 Subject: [PATCH 3/3] fix(tts): resolve cloud-tts followups (MagicMock, is_available, thread leak, scrub_secret) --- app/tts_window.py | 39 +++++++++++++++----------------- tests/test_features.py | 50 +++++++++++++++++++++++++++++++++++++++++- 2 files changed, 67 insertions(+), 22 deletions(-) diff --git a/app/tts_window.py b/app/tts_window.py index 5a9c89c..67c3d29 100644 --- a/app/tts_window.py +++ b/app/tts_window.py @@ -112,29 +112,22 @@ def __init__(self, config, client: Optional[object] = None) -> None: self.api_key = config.resolve_openai_api_key() self.api_key_env = config.openai_api_key_env self._openai_installed = True - self._client_is_fallback_mock = False + self.client = client if client is not None: - self.client = client + return + if not self.api_key: + self.client = None + return + try: + import openai + except ImportError: + self._openai_installed = False + self.client = None else: - if not self.api_key: - from unittest.mock import MagicMock - - self._client_is_fallback_mock = True - self.client = MagicMock() - else: - try: - import openai - except ImportError: - self._openai_installed = False - self._client_is_fallback_mock = True - from unittest.mock import MagicMock - - self.client = MagicMock() - else: - self.client = openai.OpenAI(api_key=self.api_key) + self.client = openai.OpenAI(api_key=self.api_key) def is_available(self) -> bool: - return self.config.tts_provider == "openai" and bool(self.api_key) + return self.config.tts_provider == "openai" and bool(self.api_key) and self.client is not None def _missing_key_message(self) -> str: return ( @@ -142,13 +135,16 @@ def _missing_key_message(self) -> str: f"{self.api_key_env} in ~/.config/blitztext-linux/secrets.env setzen." ) + def _missing_openai_package_message(self) -> str: + return "Python-Paket openai fehlt. Bitte installieren: pip install openai" + def _check_ready(self) -> None: if self.config.tts_provider != "openai": raise CloudTtsServiceError("OpenAI Cloud-TTS ist nicht aktiviert.") if not self.api_key: raise CloudTtsServiceError(self._missing_key_message()) - if not self._openai_installed and self._client_is_fallback_mock: - raise CloudTtsServiceError("openai-Paket nicht installiert. Bitte: pip install openai") + if not self._openai_installed or self.client is None: + raise CloudTtsServiceError(self._missing_openai_package_message()) def synthesize(self, text: str, output_path: str = TTS_WAV) -> str: self._check_ready() @@ -600,6 +596,7 @@ def _start_cloud_tts(self, text: str) -> None: worker.finished.connect(thread.quit) worker.error.connect(thread.quit) thread.finished.connect(worker.deleteLater) + thread.finished.connect(thread.deleteLater) thread.finished.connect(self._on_cloud_thread_finished) self._cloud_thread = thread self._cloud_worker = worker diff --git a/tests/test_features.py b/tests/test_features.py index 4b6d26d..2378808 100644 --- a/tests/test_features.py +++ b/tests/test_features.py @@ -276,8 +276,27 @@ def test_openai_cloud_service_raises_without_key(self, tmp_path, monkeypatch): cfg = Config.load(tmp_path / "config.json") cfg.tts_provider = "openai" service = tts_window.CloudTtsService(cfg) - with pytest.raises(tts_window.CloudTtsServiceError): + assert service.client is None + assert service.is_available() is False + with pytest.raises(tts_window.CloudTtsServiceError, match="OpenAI API-Key nicht gesetzt") as exc_info: service.synthesize("Hallo Welt", output_path=str(tmp_path / "x.wav")) + assert "OPENAI_API_KEY" in str(exc_info.value) + assert "openai fehlt" not in str(exc_info.value) + + def test_openai_cloud_service_raises_without_openai_package(self, tmp_path, monkeypatch): + monkeypatch.setenv("OPENAI_API_KEY", "sk-test") + cfg = Config.load(tmp_path / "config.json") + cfg.tts_provider = "openai" + + with patch.dict("sys.modules", {"openai": None}): + service = tts_window.CloudTtsService(cfg) + + assert service.client is None + assert service.is_available() is False + with pytest.raises(tts_window.CloudTtsServiceError, match="Python-Paket openai fehlt") as exc_info: + service.synthesize("Hallo Welt", output_path=str(tmp_path / "x.wav")) + assert "sk-test" not in str(exc_info.value) + assert "API-Key nicht gesetzt" not in str(exc_info.value) def test_openai_cloud_service_propagates_timeout_error(self, tmp_path, monkeypatch): monkeypatch.setenv("OPENAI_API_KEY", "sk-test") @@ -361,6 +380,35 @@ def test_start_cloud_tts_blocks_without_consent(self, tmp_path): service.assert_not_called() fake._status_label.setText.assert_called_once() + def test_start_cloud_tts_deletes_thread_on_normal_finish(self, tmp_path): + cfg = Config.load(tmp_path / "config.json") + cfg.tts_provider = "openai" + cfg.tts_openai_consent = True + fake = SimpleNamespace( + _config=cfg, + _status_label=MagicMock(), + _btn_speak=MagicMock(), + _btn_pause=MagicMock(), + _update_speak_button_state=MagicMock(), + _on_cloud_finished=MagicMock(), + _on_cloud_error=MagicMock(), + _on_cloud_thread_finished=MagicMock(), + ) + service = MagicMock() + service.is_available.return_value = True + thread = MagicMock() + worker = MagicMock() + + with patch.object(tts_window, "CloudTtsService", return_value=service), \ + patch.object(tts_window, "QThread", return_value=thread), \ + patch.object(tts_window, "_CloudTtsWorker", return_value=worker): + tts_window.TtsWindow._start_cloud_tts(fake, "Hallo Welt") + + thread.finished.connect.assert_any_call(worker.deleteLater) + thread.finished.connect.assert_any_call(thread.deleteLater) + thread.finished.connect.assert_any_call(fake._on_cloud_thread_finished) + thread.start.assert_called_once() + class TestDetachCloudThread: """Detach-Timeout-Pfad GUI-frei via Fake-self und gemockten QThreads."""