feat(voices): archetype catalog and API

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
mARTin-B78 2026-10-04 18:10:35 +02:00
parent c0a5d54763
commit fece9122d1
4 changed files with 399 additions and 1 deletions

169
core/voice_archetypes.py Normal file
View File

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

132
routes/archetypes.py Normal file
View File

@ -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"]}

View File

@ -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 ──────────────────────────────────────────────────────────────

View File

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