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
8 changes: 7 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`.
Expand All @@ -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
Expand Down Expand Up @@ -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`).
</details>

Expand Down
2 changes: 2 additions & 0 deletions ROADMAP.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
53 changes: 53 additions & 0 deletions app/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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",
Expand Down Expand Up @@ -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", "")
Expand All @@ -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))
Expand Down Expand Up @@ -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))

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Treat non-boolean TTS consent as false

When a hand-edited or migrated config contains a string such as "false" or "no", bool(...) converts it to True, so _ensure_openai_consent() will skip the privacy dialog and Cloud-TTS can send text to OpenAI without an explicit opt-in. Please only preserve the consent flag when the stored value is the literal JSON boolean true and reset all other types to False.

Useful? React with 👍 / 👎.


self._data["openai_api_key_env"] = _normalize_env_var_name(
self._data.get("openai_api_key_env", DEFAULTS["openai_api_key_env"])
Expand Down
Loading