fix(tts): voice_gender im UserPrefs-Schema -> Stimmwahl wirkt endlich

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 <noreply@anthropic.com>
This commit is contained in:
Dieter Schlüter 2026-06-27 03:13:27 +02:00
commit 6bf281fc30
3 changed files with 123 additions and 0 deletions

111
Docs/stimmen-inventar.md Normal file
View file

@ -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/<name>.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/<lang>.wav`.
- **Keine** Geschlechtswahl: pro Sprache **eine** geklonte Stimme (Geschlecht = Aufnahme).
| vorhandene Referenz-WAVs | en, es, fr, it, nl, ru, zh |
|---|---|
| ohne `<lang>.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.

View file

@ -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):

View file

@ -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')}"}