feat(voices): archetype catalog and API
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
c0a5d54763
commit
fece9122d1
169
core/voice_archetypes.py
Normal file
169
core/voice_archetypes.py
Normal 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
132
routes/archetypes.py
Normal 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"]}
|
||||||
@ -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.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.config import _load_settings, _ensure_external_api_key
|
||||||
from core.voice_index import refresh_voice_index_background
|
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")
|
logger = logging.getLogger("uvicorn.error")
|
||||||
|
|
||||||
@ -348,6 +348,7 @@ app.include_router(history.router)
|
|||||||
app.include_router(jobs.router)
|
app.include_router(jobs.router)
|
||||||
app.include_router(magazine.router)
|
app.include_router(magazine.router)
|
||||||
app.include_router(magazine_sources.router)
|
app.include_router(magazine_sources.router)
|
||||||
|
app.include_router(archetypes.router)
|
||||||
|
|
||||||
# ── Static files ──────────────────────────────────────────────────────────────
|
# ── Static files ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|||||||
96
tests/test_voice_archetypes.py
Normal file
96
tests/test_voice_archetypes.py
Normal 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()
|
||||||
Loading…
Reference in New Issue
Block a user