From fece9122d19765832fa6edc2f7fa659a5d52dc5e Mon Sep 17 00:00:00 2001 From: mARTin-B78 Date: Sun, 4 Oct 2026 18:10:35 +0200 Subject: [PATCH] feat(voices): archetype catalog and API Co-Authored-By: Claude Sonnet 5.5 --- core/voice_archetypes.py | 169 +++++++++++++++++++++++++++++++++ routes/archetypes.py | 132 +++++++++++++++++++++++++ server.py | 3 +- tests/test_voice_archetypes.py | 96 +++++++++++++++++++ 4 files changed, 399 insertions(+), 1 deletion(-) create mode 100644 core/voice_archetypes.py create mode 100644 routes/archetypes.py create mode 100644 tests/test_voice_archetypes.py diff --git a/core/voice_archetypes.py b/core/voice_archetypes.py new file mode 100644 index 0000000..141e1b7 --- /dev/null +++ b/core/voice_archetypes.py @@ -0,0 +1,169 @@ +"""Own German-first voice archetype catalog (hand-authored; no third-party data). + +Each archetype is a free-text Qwen3 VoiceDesign description plus facets that reuse the +DESIGN_CHIPS taxonomy of static/js/voice-clone.js. Validated at import. +""" +from __future__ import annotations + +FACET_VOCAB = { + "gender": {"male", "female", "neutral"}, + "age": {"child", "teenager", "young adult", "middle-aged", "elderly"}, + "pitch": {"high", "mid", "low"}, + "accent": {"none", "british", "american", "australian", "irish", "german", "southern"}, + "tone": {"warm", "husky", "crisp", "smooth", "rich", "magnetic", "authoritative"}, +} +CATEGORIES = ("Narration & Story", "Conversational", "Characters & Animation", "Social Media", + "Entertainment & TV", "Advertisement", "Informative & Educational") +LANGUAGES = {"German", "English"} +SEED = 42 +_ID_OK = set("abcdefghijklmnopqrstuvwxyz0123456789_") + +_SCRIPTS = { # per category; (de, en) + "Narration & Story": ("Der Nebel lag noch ueber dem Tal, als sie die alte Muehle erreichte und leise die Tuer oeffnete.", + "Mist still lay over the valley as she reached the old mill and quietly opened the door."), + "Conversational": ("Hallo, schoen dass du da bist. Ich zeige dir kurz, wie alles funktioniert, Schritt fuer Schritt.", + "Hi, great to have you here. Let me quickly show you how everything works, step by step."), + "Characters & Animation": ("Halt! Wer wagt es, meine Hoehle zu betreten? Na gut, setz dich, der Tee wird kalt.", + "Halt! Who dares enter my cave? Oh, fine, sit down, the tea is getting cold."), + "Social Media": ("Okay Leute, heute zeige ich euch drei Tricks, die euren Alltag wirklich einfacher machen. Los geht's!", + "Okay everyone, today I'm showing you three tricks that will really make your day easier. Let's go!"), + "Entertainment & TV": ("Guten Abend, meine Damen und Herren, und herzlich willkommen zu einer ganz besonderen Sendung.", + "Good evening, ladies and gentlemen, and welcome to a very special show."), + "Advertisement": ("Jetzt neu: der Geschmack, auf den Sie gewartet haben. Probieren Sie es noch heute.", + "Now new: the taste you have been waiting for. Try it today."), + "Informative & Educational": ("Heute lernen wir, wie Pflanzen aus Licht Energie gewinnen. Das nennt man Photosynthese.", + "Today we learn how plants turn light into energy. This is called photosynthesis."), +} +_N, _C, _CH, _S, _E, _A, _I = CATEGORIES + +# id, name_de, name_en, category, language, gender, age, pitch, accent, tone, instruct +_ROWS = [ + ("erzaehler_tief", "Der Chronist", "The Chronicler", _N, "German", "male", "middle-aged", "low", "none", "rich", + "Tiefe maennliche deutsche Erzaehlerstimme, mittleres Alter, voll und resonant, langsam und gelassen, klare Artikulation, ruhig und fesselnd, ideal fuer Hoerbuecher."), + ("erzaehlerin_warm", "Die Geschichtenerzaehlerin", "The Storyteller", _N, "German", "female", "middle-aged", "mid", "none", "warm", + "Warme weibliche deutsche Erzaehlerstimme, mittleres Alter, weich und einfuehlsam, ruhiges Tempo, sanfte Betonung, lebendig bei Spannungsbogen."), + ("grossvater_maerchen", "Opa Maerchen", "Grandpa Tales", _N, "German", "male", "elderly", "low", "none", "warm", + "Aelterer deutscher Grossvater, tiefe raue warme Stimme, sehr langsam, liebevoll schmunzelnd, erzaehlt Gutenachtgeschichten."), + ("oma_vorleserin", "Oma Vorlese", "Granny Reads", _N, "German", "female", "elderly", "mid", "none", "warm", + "Aeltere deutsche Frau, sanfte brueckige warme Stimme, langsames beruhigendes Tempo, muetterlich und geduldig."), + ("krimi_erzaehler", "Nachtschicht", "Night Shift", _N, "German", "male", "middle-aged", "low", "none", "husky", + "Tiefe heisere maennliche deutsche Stimme, trocken und nuechtern, langsames Tempo, duesterer Krimi-Ton mit leiser Spannung."), + ("fantasy_barde", "Der Barde", "The Bard", _N, "German", "male", "young adult", "mid", "none", "magnetic", + "Junger maennlicher deutscher Geschichtenerzaehler, magnetisch und theatralisch, ausdrucksstarke Betonung, wechselndes Tempo, Fantasy-Abenteuer."), + ("literatur_sprecherin", "Literaturabend", "Literary Evening", _N, "German", "female", "young adult", "low", "none", "smooth", + "Junge weibliche deutsche Stimme, tief und glatt, ruhig und intim, klare Aussprache, wie bei einer Lesung."), + ("british_narrator", "British Narrator", "British Narrator", _N, "English", "male", "middle-aged", "low", "british", "rich", + "Middle-aged male British narrator, deep and rich, slow measured pace, calm and authoritative, polished RP accent, suited for audiobooks."), + ("assistent_freundlich", "Freundliche Assistentin", "Friendly Assistant", _C, "German", "female", "young adult", "mid", "none", "warm", + "Junge weibliche deutsche Stimme, hell und freundlich, natuerliche Sprechgeschwindigkeit, klare Aussprache, nahbar und hilfsbereit."), + ("assistent_sachlich", "Sachlicher Assistent", "Matter-of-Fact Assistant", _C, "German", "male", "young adult", "mid", "none", "crisp", + "Junge maennliche deutsche Stimme, klar und sachlich, mittleres Tempo, neutral und zuverlaessig, praezise Aussprache."), + ("nachbarin_herzlich", "Die Nachbarin", "The Neighbour", _C, "German", "female", "middle-aged", "mid", "none", "warm", + "Herzliche weibliche deutsche Stimme, mittleres Alter, locker und plaudernd, schmunzelnd, natuerlicher Alltagston."), + ("kumpel_entspannt", "Der Kumpel", "The Buddy", _C, "German", "male", "young adult", "mid", "none", "smooth", + "Entspannte junge maennliche deutsche Stimme, lockerer Umgangston, freundlich und humorvoll, natuerliches Tempo."), + ("coach_ruhig", "Ruhiger Coach", "Calm Coach", _C, "German", "neutral", "middle-aged", "mid", "none", "smooth", + "Ruhige neutrale deutsche Stimme, mittleres Alter, sanft und bestaerkend, langsames Tempo, einfuehlsam, wie ein Meditationsguide."), + ("us_friend", "Friendly American", "Friendly American", _C, "English", "female", "young adult", "mid", "american", "warm", + "Young adult female American voice, warm and upbeat, natural conversational pace, friendly and clear."), + ("drachen_alt", "Der alte Drache", "The Old Dragon", _CH, "German", "male", "elderly", "low", "none", "authoritative", + "Uralte maennliche deutsche Stimme, extrem tief und grollend, sehr langsam, majestaetisch und bedrohlich, mit trockenem Humor."), + ("hexe_kratzig", "Die Kraeuterhexe", "The Herb Witch", _CH, "German", "female", "elderly", "mid", "none", "husky", + "Alte weibliche deutsche Stimme, kratzig und heiser, kichernd, schelmisch und geheimnisvoll, wechselndes Tempo."), + ("kind_neugierig", "Kleiner Entdecker", "Little Explorer", _CH, "German", "neutral", "child", "high", "none", "crisp", + "Kindliche deutsche Stimme, hoch und hell, neugierig und aufgeregt, schnelles Tempo, begeistert."), + ("teen_trotzig", "Der Trotzkopf", "The Rebel", _CH, "German", "female", "teenager", "high", "none", "crisp", + "Weibliche deutsche Teenagerstimme, hell, schnippisch und trotzig, schnelles Tempo, genervt aber liebenswert."), + ("held_mutig", "Der Held", "The Hero", _CH, "German", "male", "young adult", "mid", "none", "authoritative", + "Junger maennlicher deutscher Held, kraeftig und entschlossen, klare feste Stimme, mutig und aufrichtig."), + ("schurke_glatt", "Der Schurke", "The Villain", _CH, "German", "male", "middle-aged", "low", "none", "smooth", + "Glatte tiefe maennliche deutsche Stimme, kalt und hoeflich, langsames Tempo, bedrohlich leise, selbstgefaellig."), + ("roboter_freundlich", "Robo-Freund", "Robo Pal", _CH, "German", "neutral", "young adult", "mid", "none", "crisp", + "Neutrale deutsche Robotersaestimme, leicht metallisch und praezise, gleichmaessiges Tempo, freundlich und kindlich naiv."), + ("pirat_rau", "Kapitaen Salzbart", "Captain Saltbeard", _CH, "English", "male", "elderly", "low", "southern", "husky", + "Elderly male pirate captain, gravelly and husky, slow rolling pace, boisterous and gruff, salty seafaring character."), + ("irish_elf", "Irish Sprite", "Irish Sprite", _CH, "English", "female", "young adult", "high", "irish", "warm", + "Young adult female voice with a light Irish accent, playful and bright, quick lilting pace, mischievous fairy character."), + ("influencer_energie", "Energie-Influencerin", "Energy Influencer", _S, "German", "female", "young adult", "high", "none", "crisp", + "Junge weibliche deutsche Stimme, hoch, schnell und energiegeladen, begeistert, direkt an die Kamera gesprochen, Social-Media-Ton."), + ("streamer_locker", "Der Streamer", "The Streamer", _S, "German", "male", "young adult", "mid", "none", "crisp", + "Junger maennlicher deutscher Streamer, lockere schnelle Sprechweise, witzig und enthusiastisch, umgangssprachlich."), + ("kurzvideo_trocken", "Trockener Kommentar", "Deadpan Take", _S, "German", "neutral", "young adult", "mid", "none", "smooth", + "Neutrale junge deutsche Stimme, trocken und ironisch, ruhiges Tempo, nuechterner Humor fuer Kurzvideos."), + ("reel_sprecher", "Reel-Sprecher", "Reel Voice", _S, "English", "male", "young adult", "mid", "american", "magnetic", + "Young adult male American voice, punchy and magnetic, fast energetic pace, hooky delivery for short videos."), + ("moderator_abend", "Abendmoderator", "Evening Host", _E, "German", "male", "middle-aged", "mid", "none", "magnetic", + "Maennliche deutsche Moderatorenstimme, mittleres Alter, charmant und souveraen, magnetisch, mittleres Tempo, Fernsehshow."), + ("moderatorin_quiz", "Quiz-Moderatorin", "Quiz Host", _E, "German", "female", "young adult", "mid", "none", "crisp", + "Weibliche deutsche Quizshow-Moderatorin, jung, frisch und lebhaft, klare Betonung, spannungsvolle Pausen."), + ("trailer_donner", "Kino-Trailer", "Movie Trailer", _E, "German", "male", "middle-aged", "low", "none", "authoritative", + "Sehr tiefe maennliche deutsche Trailerstimme, donnernd und dramatisch, langsam mit starken Pausen, autoritaer."), + ("sportreporter", "Sportreporter", "Sports Commentator", _E, "German", "male", "middle-aged", "mid", "none", "crisp", + "Maennliche deutsche Sportreporterstimme, schnell und mitreissend, laut, steigende Spannung bei Hoehepunkten."), + ("aussie_host", "Aussie Host", "Aussie Host", _E, "English", "male", "young adult", "mid", "australian", "warm", + "Young adult male Australian host, laid-back and cheerful, natural conversational pace, charming."), + ("werbung_dynamisch", "Werbesprecher", "Ad Voice", _A, "German", "male", "young adult", "mid", "none", "smooth", + "Junge maennliche deutsche Werbestimme, glatt und selbstbewusst, dynamisches Tempo, ueberzeugend und sympathisch."), + ("werbung_premium", "Premium-Stimme", "Premium Voice", _A, "German", "female", "middle-aged", "low", "none", "rich", + "Tiefe weibliche deutsche Stimme, mittleres Alter, samtig und elegant, langsames Tempo, exklusiver Luxus-Ton."), + ("werbung_regional", "Der Landmetzger", "The Country Butcher", _A, "German", "male", "middle-aged", "mid", "southern", "warm", + "Herzlicher maennlicher deutscher Sprecher mit suedlichem Akzent, bodenstaendig und freundlich, gemuetliches Tempo, regionale Werbung."), + ("dozentin_klar", "Die Dozentin", "The Lecturer", _I, "German", "female", "middle-aged", "mid", "none", "crisp", + "Weibliche deutsche Dozentenstimme, mittleres Alter, klar und strukturiert, mittleres Tempo, geduldig erklaerend, deutliche Artikulation."), + ("doku_sprecher", "Dokumentation", "Documentary", _I, "German", "male", "elderly", "low", "none", "authoritative", + "Aeltere maennliche deutsche Stimme, tief und resonant, langsam und gelassen, ruhig und dokumentarisch, serioese vertrauensvolle Praesenz."), + ("nachrichten_sprecherin", "Nachrichtensprecherin", "News Anchor", _I, "German", "female", "middle-aged", "mid", "none", "authoritative", + "Weibliche deutsche Nachrichtensprecherin, sachlich und souveraen, gleichmaessiges Tempo, klare Standardsprache."), + ("kinder_lehrer", "Lehrer Lampe", "Teacher Lamp", _I, "German", "male", "young adult", "mid", "none", "warm", + "Junger maennlicher deutscher Lehrer fuer Kinder, freundlich und begeisternd, deutlich und langsam, lobend."), + ("science_en", "Science Explainer", "Science Explainer", _I, "English", "female", "young adult", "mid", "british", "crisp", + "Young adult female British voice, clear and curious, measured pace, enthusiastic explainer for science content."), +] + + +def _build() -> tuple[dict, ...]: + out, seen = [], set() + for (i, de, en, cat, lang, g, a, p, ac, tone, ins) in _ROWS: + script = _SCRIPTS[cat] + e = {"id": i, "name_de": de, "name_en": en, "instruct": ins, "category": cat, "language": lang, + "facets": {"gender": g, "age": a, "pitch": p, "accent": ac, "tone": tone}, + "sample_script_de": script[0], "sample_script_en": script[1]} + if i in seen or not i or set(i) - _ID_OK: + raise ValueError(f"archetype id invalid or duplicate: {i!r}") + seen.add(i) + if cat not in CATEGORIES or lang not in LANGUAGES: + raise ValueError(f"{i}: bad category/language") + for k, v in e["facets"].items(): + if v not in FACET_VOCAB[k]: + raise ValueError(f"{i}: unknown {k} facet {v!r}") + if not 20 <= len(ins) <= 600 or not de or not en: + raise ValueError(f"{i}: bad name/instruct length") + out.append(e) + return tuple(out) + + +ARCHETYPES = _build() +BY_ID = {a["id"]: a for a in ARCHETYPES} +MAX_LIMIT = 100 + + +def list_archetypes(gender="", age="", pitch="", accent="", language="", category="", q="", + offset=0, limit=24) -> dict: + flt = {"gender": gender, "age": age, "pitch": pitch, "accent": accent} + ql = (q or "").strip().lower()[:100] + rows = [] + for a in ARCHETYPES: + if any(v and a["facets"][k] != v for k, v in flt.items()): + continue + if language and a["language"] != language or category and a["category"] != category: + continue + if ql and ql not in f'{a["name_de"]} {a["name_en"]} {a["instruct"]} {a["category"]}'.lower(): + continue + rows.append(a) + offset = max(0, int(offset)); limit = max(1, min(MAX_LIMIT, int(limit))) + return {"total": len(rows), "offset": offset, "limit": limit, "items": rows[offset:offset + limit]} + + +def preview_key(a: dict, engine: str, seed: int = SEED) -> str: + import hashlib + return hashlib.sha256(f'{a["instruct"]}|{a["language"]}|{engine}|{seed}'.encode()).hexdigest() diff --git a/routes/archetypes.py b/routes/archetypes.py new file mode 100644 index 0000000..384ceab --- /dev/null +++ b/routes/archetypes.py @@ -0,0 +1,132 @@ +"""Voice archetype gallery: catalog, cached preview rendering, save-as-designed-voice.""" +from __future__ import annotations + +import asyncio +import io +import logging +import tempfile +from pathlib import Path + +from fastapi import APIRouter, HTTPException, Query +from fastapi.responses import FileResponse + +from core import voice_archetypes as va +from core.audio import _export_normalized_wav +from core.config import _load_settings +from core.constants import CONFIG_DIR, _VOICE_DESIGN_MODEL, _VOICES_DIR_DEFAULT +from core.provenance import validate_provenance +from core.voice import _active_voices_dir, _find_voice_audio, _load_meta, _save_meta +from core.voice_index import upsert_voice_in_index + +logger = logging.getLogger("uvicorn.error") +router = APIRouter() + +RENDER_TIMEOUT_S = 240 +MAX_PREVIEW_BYTES = 8 * 1024 * 1024 +_LOCKS: dict[str, asyncio.Lock] = {} + + +def cache_dir() -> Path: + return CONFIG_DIR / "archetype_previews" # config/ is git-ignored; catalog is small so the cache is bounded + + +def _get(aid: str) -> dict: + a = va.BY_ID.get(aid) + if a is None: + raise HTTPException(404, "Unknown archetype") + return a + + +def _sample_text(a: dict) -> str: + return a["sample_script_de"] if a["language"] == "German" else a["sample_script_en"] + + +def _is_blank(audio: bytes) -> bool: + from pydub import AudioSegment + seg = AudioSegment.from_file(io.BytesIO(audio)) + return len(seg) < 300 or seg.max_possible_amplitude == 0 or seg.rms < 20 + + +def _render(a: dict, settings: dict) -> bytes: + """Blocking synthesis through the existing voice-design path, seed pinned.""" + from core.tts_helpers import _voice_design_request_audio + pinned = {**settings, "tts_stability_enabled": True, + "tts_extra_params_by_backend": {"voice_design": {"seed": va.SEED}}} + gender = {"male": "M", "female": "F"}.get(a["facets"]["gender"], "") + audio, _ = _voice_design_request_audio(a["instruct"], _sample_text(a), a["language"], pinned, gender) + return audio + + +async def _cached_preview(a: dict) -> Path: + key = va.preview_key(a, _VOICE_DESIGN_MODEL) + path = cache_dir() / f"{key[:32]}.wav" + if path.exists(): + return path + lock = _LOCKS.setdefault(key, asyncio.Lock()) + async with lock: + if path.exists(): + return path + from routes.tts import _bounded_tts_call + try: + audio = await asyncio.wait_for(_bounded_tts_call(_render, a, _load_settings()), RENDER_TIMEOUT_S) + except HTTPException: + raise + except asyncio.TimeoutError: + raise HTTPException(504, "Preview rendering timed out") + except Exception as exc: + logger.warning("archetype preview failed (%s): %s", a["id"], exc) + raise HTTPException(502, "Preview rendering failed") + if not audio or len(audio) > MAX_PREVIEW_BYTES or _is_blank(audio): + raise HTTPException(502, "Engine returned blank or oversized audio") + path.parent.mkdir(parents=True, exist_ok=True) + fd, tmp = tempfile.mkstemp(dir=path.parent, suffix=".tmp") + with open(fd, "wb") as fh: + fh.write(audio) + Path(tmp).replace(path) + return path + + +@router.get("/api/archetypes") +async def archetypes_list(gender: str = "", age: str = "", pitch: str = "", accent: str = "", + language: str = "", category: str = "", q: str = "", + offset: int = Query(0, ge=0), limit: int = Query(24, ge=1, le=va.MAX_LIMIT)): + res = va.list_archetypes(gender, age, pitch, accent, language, category, q, offset, limit) + res["categories"] = list(va.CATEGORIES) + res["cached"] = [a["id"] for a in res["items"] if (cache_dir() / f"{va.preview_key(a, _VOICE_DESIGN_MODEL)[:32]}.wav").exists()] + return res + + +@router.post("/api/archetypes/{aid}/preview") +async def archetype_preview(aid: str): + return FileResponse(await _cached_preview(_get(aid)), media_type="audio/wav") + + +@router.post("/api/archetypes/{aid}/use") +async def archetype_use(aid: str): + a = _get(aid) + voice_id = f"archetype_{aid}" + settings = _load_settings() + existing = _find_voice_audio(voice_id, Path(settings.get("voices_scan_dir", _VOICES_DIR_DEFAULT))) + if existing is not None: # idempotent per archetype id + return {"voice_id": voice_id, "created": False, "instruct": a["instruct"], "language": a["language"]} + src = await _cached_preview(a) + out_dir = _active_voices_dir(settings) + out_dir.mkdir(parents=True, exist_ok=True) + wav = out_dir / f"{voice_id}.wav" + text = _sample_text(a) + await asyncio.to_thread(_export_normalized_wav, src, wav) + wav.with_suffix(".reference.txt").write_text(text, encoding="utf-8") + meta = _load_meta(wav) + meta.update({ + "enabled": True, "origin": "designed", "name": a["name_de"], "gender": a["facets"]["gender"][:1].upper(), + "lang": a["language"], "transcript": text, "voice_design_prompt": a["instruct"], + "note": "Voice Archetype: " + a["instruct"][:240], + # no 'synthetic' basis exists in core.provenance.BASES; 'unknown' is the closest honest value + # (no real person, no consent/licence needed). ':' is avoided in source because + # validate_provenance parses 'x:' as a URL scheme. + "provenance": validate_provenance( + {"basis": "unknown", "source": f"archetype/{aid}", "note": "synthetic designed voice"}), + }) + _save_meta(wav, meta) + await asyncio.to_thread(upsert_voice_in_index, settings, wav) + return {"voice_id": voice_id, "created": True, "instruct": a["instruct"], "language": a["language"]} diff --git a/server.py b/server.py index 170e3e3..0968923 100644 --- a/server.py +++ b/server.py @@ -19,7 +19,7 @@ from starlette.datastructures import MutableHeaders from core.constants import STATIC_DIR, _BufferHandler, _MAX_JSON_BYTES, _MAX_UPLOAD_BYTES, _MAX_RAW_REQUEST_BYTES, _REQUIRE_EXTERNAL_API_KEY from core.config import _load_settings, _ensure_external_api_key from core.voice_index import refresh_voice_index_background -from routes import admin, settings, library, stt, sources, docker, tts, conversation, reader, characters, rehearsals_db, recovery, history, jobs, magazine, magazine_sources +from routes import admin, settings, library, stt, sources, docker, tts, conversation, reader, characters, rehearsals_db, recovery, history, jobs, magazine, magazine_sources, archetypes logger = logging.getLogger("uvicorn.error") @@ -348,6 +348,7 @@ app.include_router(history.router) app.include_router(jobs.router) app.include_router(magazine.router) app.include_router(magazine_sources.router) +app.include_router(archetypes.router) # ── Static files ────────────────────────────────────────────────────────────── diff --git a/tests/test_voice_archetypes.py b/tests/test_voice_archetypes.py new file mode 100644 index 0000000..c66df3a --- /dev/null +++ b/tests/test_voice_archetypes.py @@ -0,0 +1,96 @@ +"""Archetype catalog validity, filtering, cache key, idempotent /use (generation mocked, temp dirs).""" +import asyncio +import io +import os +import sys +import tempfile +import unittest +from pathlib import Path +from unittest import mock + +sys.path.insert(0, str(Path(__file__).resolve().parents[1])) +os.environ.setdefault("CONFIG_DIR", tempfile.mkdtemp(prefix="ttvc-arch-config-")) +from core import voice_archetypes as va +import routes.archetypes as ra +from fastapi import HTTPException + + +def wav_bytes(ms=1000, amp=True): + from pydub import AudioSegment + from pydub.generators import Sine + seg = Sine(220).to_audio_segment(duration=ms) if amp else AudioSegment.silent(ms) + buf = io.BytesIO(); seg.export(buf, format="wav"); return buf.getvalue() + + +class Catalog(unittest.TestCase): + def test_valid(self): + self.assertGreaterEqual(len(va.ARCHETYPES), 40) + self.assertEqual(len(va.BY_ID), len(va.ARCHETYPES)) + for a in va.ARCHETYPES: + self.assertIn(a["category"], va.CATEGORIES) + + def test_bad_facet_and_duplicate_rejected(self): + row = va._ROWS[0] + for rows in ([row[:6] + ("ancient",) + row[7:]], [row, row]): + with mock.patch.object(va, "_ROWS", rows): + with self.assertRaises(ValueError): + va._build() + + def test_filter_and_paging(self): + r = va.list_archetypes(gender="female", language="German", limit=5) + self.assertTrue(r["total"] >= 5 and len(r["items"]) == 5) + self.assertTrue(all(i["facets"]["gender"] == "female" for i in r["items"])) + self.assertEqual(va.list_archetypes(q="drache")["total"], 1) + self.assertEqual(va.list_archetypes(limit=9999)["limit"], va.MAX_LIMIT) + self.assertEqual(va.list_archetypes(gender="x")["total"], 0) + + def test_cache_key_stable(self): + a = va.ARCHETYPES[0] + self.assertEqual(va.preview_key(a, "m"), va.preview_key(dict(a), "m")) + self.assertNotEqual(va.preview_key(a, "m"), va.preview_key(a, "m2")) + self.assertNotEqual(va.preview_key(a, "m"), va.preview_key(a, "m", 7)) + + +class UseRoute(unittest.TestCase): + def setUp(self): + self.tmp = tempfile.TemporaryDirectory(); self.addCleanup(self.tmp.cleanup) + t = Path(self.tmp.name) + self.settings = {"output_dir": str(t / "voices"), "voices_scan_dir": str(t / "voices")} + for p in (mock.patch.object(ra, "cache_dir", lambda: t / "cache"), + mock.patch.object(ra, "_load_settings", lambda: self.settings), + mock.patch.object(ra, "upsert_voice_in_index", lambda *a, **k: {})): + p.start(); self.addCleanup(p.stop) + self.calls = 0 + + def render(self, audio): + def f(a, s): + self.calls += 1; return audio + return mock.patch.object(ra, "_render", f) + + def test_use_idempotent_and_cached(self): + async def go(): + r1 = await ra.archetype_use("drachen_alt") + r2 = await ra.archetype_use("drachen_alt") + return r1, r2 + with self.render(wav_bytes()), mock.patch("routes.tts._bounded_tts_call", lambda fn, *a: asyncio.to_thread(fn, *a)): + r1, r2 = asyncio.run(go()) + self.assertTrue(r1["created"]); self.assertFalse(r2["created"]); self.assertEqual(self.calls, 1) + wav = Path(self.settings["output_dir"]) / "archetype_drachen_alt.wav" + self.assertTrue(wav.exists()) + from core.voice import _load_meta + m = _load_meta(wav) + self.assertEqual(m["origin"], "designed"); self.assertEqual(m["provenance"]["basis"], "unknown") + self.assertEqual(m["provenance"]["source"], "archetype/drachen_alt") + + def test_blank_audio_rejected_and_unknown_404(self): + with self.render(wav_bytes(amp=False)), mock.patch("routes.tts._bounded_tts_call", lambda fn, *a: asyncio.to_thread(fn, *a)): + with self.assertRaises(HTTPException) as c: + asyncio.run(ra.archetype_preview("drachen_alt")) + self.assertEqual(c.exception.status_code, 502) + with self.assertRaises(HTTPException) as c: + asyncio.run(ra.archetype_preview("nope")) + self.assertEqual(c.exception.status_code, 404) + + +if __name__ == "__main__": + unittest.main()