From 6bf281fc30d5357647237f0b0b09f517e715e840 Mon Sep 17 00:00:00 2001 From: dschlueter Date: Sat, 27 Jun 2026 03:13:27 +0200 Subject: [PATCH] fix(tts): voice_gender im UserPrefs-Schema -> Stimmwahl wirkt endlich MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Eigentliche Ursache für 'immer weibliche Stimme, egal welcher Provider': UserPrefs kannte kein Feld voice_gender, daher verwarf pydantic in PUT /api/me/prefs die Geschlechtswahl still. resolve_route sah immer 'any' -> Piper/Cartesia lieferten die weibliche Stimme (kerstin bzw. Alina). - voice_gender zum Schema hinzugefügt; Frontend sendet es bereits, resolve_route liest es bereits (ROUTE_KEYS), voice_for_route mappt m/f bereits. - Regressions-Test: voice_gender persistiert über /api/me/prefs. - Docs/stimmen-inventar.md: zentrale Übersicht aller Stimmen je Provider/ Sprache/Geschlecht (Name, Datei, UUID) inkl. Fundstellen im Code. Live verifiziert (Profil jamulix, de): m -> de_DE-thorsten-high, f -> de_DE-kerstin-low. Co-Authored-By: Claude Opus 4.8 --- Docs/stimmen-inventar.md | 111 +++++++++++++++++++++++++++++++++++++++ app/schemas.py | 1 + tests/test_auth.py | 11 ++++ 3 files changed, 123 insertions(+) create mode 100644 Docs/stimmen-inventar.md diff --git a/Docs/stimmen-inventar.md b/Docs/stimmen-inventar.md new file mode 100644 index 0000000..adf162b --- /dev/null +++ b/Docs/stimmen-inventar.md @@ -0,0 +1,111 @@ +# Stimmen-Inventar (TTS) — eine Quelle der Wahrheit + +Diese Datei listet **alle** TTS-Stimmen je Provider, Sprache und Geschlecht: Stimmenname, +Datei bzw. UUID und die Stelle im Code/Config, an der sie definiert ist. Ziel: Schluss mit +dem Suchen über mehrere Dateien. + +> **Wer wählt die Stimme?** `app/dependencies.py::voice_for_route(tts_provider, language, voice_gender)`. +> Es liest `voice_gender` aus der aufgelösten Route (`resolve_route`), die es wiederum aus den +> Nutzer-Prefs zieht. Die Geschlechtswahl im Web-UI wird über `PUT /api/me/prefs` gespeichert — +> dafür **muss** `voice_gender` im Schema `app/schemas.py::UserPrefs` stehen (sonst verwirft +> pydantic das Feld still → Nutzer hört immer „any" = weiblich). + +Legende: ♀ = weiblich, ♂ = männlich · „—" = nicht vorhanden/keine Geschlechtswahl. + +--- + +## 1. Piper (lokal, Standard im Profil `jamulix`) + +- **Definition Geschlecht→Stimme:** `app/dependencies.py` → `PIPER_VOICE_GENDERED` (mit Geschlecht) + und `LANG_TO_PIPER_VOICE` (geschlechtsloser Fallback). +- **Modelldateien:** `config/voices/.onnx` (+ `.onnx.json`); Verzeichnis aus + `config/voice-assistant.toml::piper_voices_dir`. + +| Sprache | ♀ Stimme | Datei (.onnx) | ♂ Stimme | Datei (.onnx) | +|---|---|---|---|---| +| de | de_DE-kerstin-low | `de_DE-kerstin-low` | de_DE-thorsten-high | `de_DE-thorsten-high` | +| en | en_US-amy-medium | `en_US-amy-medium` | en_US-lessac-high | `en_US-lessac-high` | +| fr | fr_FR-siwis-medium | `fr_FR-siwis-medium` | fr_FR-tom-medium | `fr_FR-tom-medium` | +| es | es_ES-sharvard-medium **#1** | `es_ES-sharvard-medium` (Speaker 1=F) | es_ES-sharvard-medium **#0** | `es_ES-sharvard-medium` (Speaker 0=M) | +| it | it_IT-paola-medium | `it_IT-paola-medium` | it_IT-riccardo-x_low | `it_IT-riccardo-x_low` | +| ru | ru_RU-irina-medium | `ru_RU-irina-medium` | ru_RU-ruslan-medium | `ru_RU-ruslan-medium` | +| pl | pl_PL-gosia-medium | `pl_PL-gosia-medium` | pl_PL-darkman-medium | `pl_PL-darkman-medium` | +| pt | — *(keine ♀ in Piper)* | — | pt_BR-faber-medium | `pt_BR-faber-medium` | +| ar | — *(keine ♀ in Piper)* | — | ar_JO-kareem-medium | `ar_JO-kareem-medium` | +| zh / cmn | zh_CN-huayan-medium | `zh_CN-huayan-medium` | — *(kein ♂ nutzbar)* | — | + +Hinweise: +- `es` ist ein Multi-Speaker-Modell: Speaker-Index `#0`=männlich, `#1`=weiblich (`speaker_id_map`). +- `pt`/`ar`/`zh` haben in Piper **keine** Geschlechts-Varianten → beide Schalterstellungen liefern + dieselbe Stimme (Fallback aus `LANG_TO_PIPER_VOICE`). +- **zh ♂** wäre nur über `zh_CN-chaowen-medium` möglich, das aber `phoneme_type=pinyin` (g2pW) + braucht — in dieser Installation **nicht** lauffähig (erzeugt kein Audio). + +--- + +## 2. Cartesia (Cloud, Modell sonic-turbo) + +- **Definition:** `config/voice-assistant.toml::cartesia_voices`, Format `lang:gender:uuid:name`. +- **Auflösung UUID:** `app/providers/tts/cartesia.py::_resolve_voice_id` + (Fallback-Kette f→m→any→`cartesia_voice_id`). + +| Sprache | Geschlecht | Name | UUID | +|---|---|---|---| +| de | ♂ | Sebastian | `b7187e84-fe22-4344-ba4a-bc013fcb533e` | +| de | ♀ | Alina | `38aabb6a-f52b-4fb0-a3d1-988518f4dc06` | +| en | ♂ | Caspar | `4f7f1324-1853-48a6-b294-4e78e8036a83` | +| en | ♀ | Gemma | `62ae83ad-4f6a-430b-af41-a9bede9286ca` | +| fr | ♂ | Antoine | `0418348a-0ca2-4e90-9986-800fb8b3bbc0` | +| fr | ♀ | Amélie | `7c58f4a4-a72c-42fa-a503-41b9408820f3` | +| es | ♂ | Augustin (mx) | `2695b6b5-5543-4be1-96d9-3967fb5e7fec` | +| es | ♀ | Daniela (mx) | `5c5ad5e7-1020-476b-8b91-fdcbe9cc313c` | +| pt | ♂ | Bruno | `b603811e-54c2-4a0a-8854-09eab9ffa63f` | +| pt | ♀ | Isabella | `c9611be8-aae9-4a93-bb1c-98dd6b7d52a4` | +| it | — | sonic-turbo unterstützt IT nicht (auskommentiert) | — | +| pl | — | sonic-turbo unterstützt PL nicht (auskommentiert) | — | +| ru | — | sonic-turbo unterstützt RU nicht (auskommentiert) | — | +| ar | — | Cartesia unterstützt Arabic-TTS nicht | — | +| zh | — | nicht konfiguriert | — | + +> Für it/pl/ru/ar/zh greift bei Cartesia die globale Fallback-Kette → letztlich `tts_fallback` +> (`config/voice-assistant.toml`, aktuell `piper`). + +--- + +## 3. OpenRouter (Cloud, Modell `openai/gpt-4o-mini-tts`) + +- **Definition:** `app/config.py::openrouter_tts_voice` (Default `alloy`), `openrouter_tts_model`. +- **Keine** Geschlechts- oder Sprachauswahl: **eine** feste Stimme für alle Sprachen. + `voice_for_route` gibt für OpenRouter `None` zurück → Default-Stimme. + +| Sprache | ♀ | ♂ | +|---|---|---| +| alle | — (eine feste Stimme `alloy`) | — | + +--- + +## 4. Chatterbox (lokaler GPU-Dienst, Voice-Cloning) + +- **Definition:** `app/config.py::chatterbox_*`; Referenz-WAV je Sprache unter + `chatterbox_voices_dir` als `config/voices/.wav`. +- **Keine** Geschlechtswahl: pro Sprache **eine** geklonte Stimme (Geschlecht = Aufnahme). + +| vorhandene Referenz-WAVs | en, es, fr, it, nl, ru, zh | +|---|---| +| ohne `.wav` → globale Default-Stimme | de, pt, pl, ar, … | + +--- + +## Lücken-Überblick (welche Kombination fehlt) + +| Sprache | Piper | Cartesia | OpenRouter | Chatterbox | +|---|---|---|---|---| +| de, en, fr, es | ♀+♂ | ♀+♂ | nur 1 fix | 1 (Klon) | +| pt | nur ♂ | ♀+♂ | nur 1 fix | Default | +| it, pl, ru | ♀+♂ | — | nur 1 fix | teils 1 | +| ar | nur ♂ | — | nur 1 fix | Default | +| zh | nur ♀ | — | nur 1 fix | 1 (Klon) | + +Für die echten Lücken (pt ♀ / ar ♀ / zh ♂) braucht es **sprach-/geschlechtsabhängiges +Provider-Routing** (z. B. pt ♀ → Cartesia Isabella) plus UI-Ausgrauen nicht verfügbarer +Kombinationen. Siehe `MULTITOOL_AUSBAU_PLAN.md` / offene Aufgaben. diff --git a/app/schemas.py b/app/schemas.py index ec6ab5f..4d3baa0 100644 --- a/app/schemas.py +++ b/app/schemas.py @@ -95,6 +95,7 @@ class UserPrefs(BaseModel): llm_provider: str | None = None tts_provider: str | None = None language: str | None = None + voice_gender: str | None = None # "m" | "f" | "any" — sonst verwirft pydantic die Stimmwahl class MemoryCreate(BaseModel): diff --git a/tests/test_auth.py b/tests/test_auth.py index 2a62e53..8fbd4e8 100644 --- a/tests/test_auth.py +++ b/tests/test_auth.py @@ -88,6 +88,17 @@ def test_user_prefs_applied_to_route(monkeypatch): assert resp.headers["X-Output-Endpoint"] == "loopback" +def test_voice_gender_pref_persists(monkeypatch): + # Regression: voice_gender muss im UserPrefs-Schema deklariert sein, sonst verwirft + # pydantic die Stimmwahl still und der Nutzer hoert immer dieselbe (weibliche) Stimme. + _enable_auth(monkeypatch) + auth = {"Authorization": f"Bearer {_create_user('Gender')}"} + + client.put("/api/me/prefs", headers=auth, json={"voice_gender": "m"}) + prefs = client.get("/api/me", headers=auth).json()["prefs"] + assert prefs.get("voice_gender") == "m" + + def test_memories_crud(monkeypatch): _enable_auth(monkeypatch) auth = {"Authorization": f"Bearer {_create_user('Mem')}"}