Speaker: forvarmede generiske replikker, når stemmen ikke kan laves live

WPP_TTS_LIVE (settings.TTS_LIVE): auto (standard) eller off.

Én søm i BaseRoom._synth: kan en replik hverken findes i cachen eller laves
live, og kender den en generisk afløser (narrator.Line), siges en af de
generiske, der ligger i cachen. Rummets kø, afsløringens og oplæsningens
tidslinjer og drillerierne går alle igennem den, og storskærmen (og
telefonens drilleri) viser det, der faktisk siges. Et nyt spil får det ved
at bruge Narrator med sin egen generic.json.

tts:
- enabled() = der er en stemme (cachede klip kan spilles uden nøgle);
  can_fetch() = nøgle og stemme (tts_warm og nye klip).
- off: kun cachen, aldrig et kald.
- auto: efter 401, 402, 429 eller timeout holdes der pause med live-kald i
  fem minutter; saldoen tjekkes højst hvert femte minut i baggrunden, og
  under LIVE_MIN_CREDITS laves intet live. Et spil venter aldrig på det.

Hjernen: dens replikker caches aldrig. Kan de ikke siges, siges den faste
replik, hjernen erstattede (hvis den er cachet), ellers dens generiske
udgave; drillerier falder tilbage til de faste drillerier. Hjernen selv
kaldes som før. Tegn og Gæt: afsløringens intro plus første kæde har en
generisk afløser med samme intro.

Tavse, når de ikke er forvarmet: oplæsningen af spørgsmålet og dets note,
oplæsningen af spillernes svar og hjernens slutanmeldelse.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
agw
2026-10-20 21:59:18 +02:00
co-authored by Claude Opus 5.5
parent 9ebbdfdc4d
commit cf6d484422
9 changed files with 321 additions and 16 deletions
+2
View File
@@ -36,6 +36,8 @@ export DJANGO_DEBUG=true DEV_HOST_USER=dev
`deploy/bots.py` spiller et helt spil mod en kørende server (`--game tegn` for Tegn og Gæt) og kan også fylde et spil op med bots (`--code`). Alle skærme kan ses uden server i `party/static/party/dev.html` (`?game=tegn&phase=draw` for Tegn og Gæt, `?game=menu` for menuen og introerne).
**Speakeren** (ElevenLabs) laver replikker med navne live. `manage.py tts_warm` forvarmer de faste replikker og, under `--budget`, menuens intro, de generiske replikker uden navne og resten. Med `WPP_TTS_LIVE=off` bruges kun forvarmede klip; med `auto` (standard) siges en forvarmet generisk replik, når en replik ikke kan laves live (ingen nøgle, afvist, timeout eller lav saldo). Spørgsmålenes oplæsning og spillernes egne ord har ingen generisk udgave og er tavse, når de ikke er forvarmet. Et nyt spils generiske replikker står i dets `narrator/generic.json`, nøglet på begivenheden.
Et nyt spil registreres i `party/games.py` (navn, tagline, trin, ikon, indstillinger, rum og JS); menuen, `/host/new`, storskærmen og telefonen finder det dér. Kør `WPP_WRITE_FIXTURE=1 DJANGO_DEBUG=true .venv/bin/python manage.py test party.tests.test_games` bagefter, så `dev.html` får det med.
## Spørgsmål
+3
View File
@@ -7,6 +7,9 @@ DB_PATH=/path/to/db.sqlite3
STATIC_ROOT=/path/to/static
# Valgfrit: oplæsning
ELEVENLABS_API_KEY=
# auto (standard): replikker laves live, og fejler ElevenLabs (ingen nøgle, 401/402/429, timeout, lav saldo),
# siges en forvarmet generisk replik i stedet. off: kun forvarmede klip (manage.py tts_warm), aldrig live.
# WPP_TTS_LIVE=auto
# Spillets "hjerne" (valgfri; uden den bruges faste replikker). Se party/brain/providers.py.
# BRAIN_PROVIDER=anthropic
+1 -1
View File
@@ -36,7 +36,7 @@ class Command(BaseCommand):
help="tæl klip og tegn uden at kalde ElevenLabs (virker også uden nøgle)")
def handle(self, *args, budget, reserve, only=None, dry_run=False, **opts):
if not dry_run and not tts.enabled():
if not dry_run and not tts.can_fetch():
self.stdout.write("ElevenLabs er ikke sat op (ELEVENLABS_API_KEY/ELEVENLABS_VOICE_ID). Springer over.")
return
fest = settings.FEST_VOICE_ID or None
+18
View File
@@ -114,6 +114,24 @@ def with_generic(text: str | None, generic) -> Line | None:
return line
def instead_of(text: str | None, fixed: Line | None) -> Line | None:
"""Hjernens replik i stedet for en fast: kan den ikke laves, siges den faste (hvis den er cachet), ellers den
faste replik's generiske udgave. Hjernens tekst caches aldrig, så uden live-stemme er det altid afløseren."""
def generic(accept=None):
if fixed and (accept is None or accept(fixed)):
return str(fixed)
return fixed.generic(accept) if isinstance(fixed, Line) else None
return with_generic(text, generic)
def one_of(text: str | None, choices: list[str], rng: random.Random) -> Line | None:
"""En replik (fx hjernens drilleri), hvis afløser er en af de faste choices."""
def generic(accept=None):
ok = [c for c in choices if accept is None or accept(c)]
return rng.choice(ok) if ok else None
return with_generic(text, generic)
class Narrator:
def __init__(self, rng: random.Random | None = None, fest: bool = False, lines: dict | None = None,
generic: dict | None = None):
+24 -11
View File
@@ -23,7 +23,7 @@ from fupogfakta.engine import Game
from . import stt, tts
from .assets import tease_images
from .brain import get_brain
from .narrator import TEASES, TEASES_FEST, Narrator, untag
from .narrator import TEASES, TEASES_FEST, Line, Narrator, instead_of, one_of, untag
log = logging.getLogger(__name__)
@@ -211,8 +211,17 @@ class BaseRoom:
await conn.send_error("bad_request", "Ukendt handling.")
# ── speakerens kø ────────────────────────────────────────────────────────
def _synth(self, text: str, voice: str | None = None):
return tts.synth(text, voice or self.voice)
async def _synth(self, text: str, voice: str | None = None) -> dict | None:
"""Lyden til en replik: fra cachen eller live (tts.synth). Kan den ikke laves, og kender replikken en
generisk afløser (narrator.Line), siges en af dem, der ligger i cachen. Det er det eneste sted, valget
træffes, så alle spil får det. audio["text"] er det, der faktisk siges."""
audio = await tts.synth(text, voice or self.voice)
if audio is None and isinstance(text, Line):
alt = text.generic(lambda t: tts.is_cached(t, self.voice))
audio = tts.cached(alt, self.voice) if alt else None
if audio:
audio = {**audio, "text": alt}
return audio
def say(self, text: str | None, blocking: bool = True, voice: str | None = None):
"""Læg en replik i kø. Lyden genereres med det samme, men udgives i rækkefølge.
@@ -244,7 +253,7 @@ class BaseRoom:
self._pending.pop(key, None)
# En replik til en skærm, der allerede er væk, afspilles ikke.
if audio and key == self.game.phase_key():
self._publish(text, audio, key, blocking=started is not None)
self._publish(audio.get("text") or text, audio, key, blocking=started is not None)
self._update_hold()
self._schedule_timer()
@@ -460,12 +469,13 @@ class Room(BaseRoom):
info = {"navn": p.name, "sekunder_om_det": round(now - (g.lie_deadline - engine.LIE_SECONDS)),
"forsøg": self._attempts.get(pid, 1), "færdige": f"{len(g.lies)} af {len(g.players)}",
"point_indtil_nu": p.score}
# faste replikker med navnet: lyden caches, efterhånden som navnene går igen
key = ("lie_progress_retry" if info["forsøg"] > 1 else
"lie_progress_fast" if info["sekunder_om_det"] < 15 else "lie_progress")
if self.brain and settings.BRAIN_PROGRESS:
line = await self.brain.lie_progress(info, list(self.memory))
line = self.narrator.clean_ai(line) if line else line
else: # faste replikker med navnet: lyden caches, efterhånden som navnene går igen
key = ("lie_progress_retry" if info["forsøg"] > 1 else
"lie_progress_fast" if info["sekunder_om_det"] < 15 else "lie_progress")
line = instead_of(self.narrator.clean_ai(line), self.narrator.line(key, navn=p.name)) if line else line
else:
line = self.narrator.line(key, navn=p.name)
if line and g.phase == "lie":
self.say(line, blocking=False)
@@ -585,7 +595,8 @@ class Room(BaseRoom):
ai_lines = await self._brain_reveal_lines(names)
else:
tease_job, ai_lines = None, {}
texts = [(self.narrator.clean_ai(ai_lines[step["answer_id"]]) if step["answer_id"] in ai_lines
texts = [(instead_of(self.narrator.clean_ai(ai_lines[step["answer_id"]]),
self.narrator.reveal_step(step, names, len(g.players))) if step["answer_id"] in ai_lines
else self.narrator.reveal_step(step, names, len(g.players))) for step in g.reveal_steps]
note = (g.question or {}).get("note", "")
picked = {aid for aid in g.guesses.values()} | {aid for aid in g.audience_votes.values()}
@@ -710,7 +721,8 @@ class Room(BaseRoom):
for p in st["guessed_by"] if p in names]
if not victims:
return
self._tease_texts = {pid: self.narrator.clean_ai(t) for pid, t in
fixed = TEASES_FEST if self.game.fest else TEASES # uden live-stemme siges (og vises) en fast i stedet
self._tease_texts = {pid: one_of(self.narrator.clean_ai(t), fixed, self.narrator.rng) for pid, t in
(await self.brain.teases(victims, list(self.memory))).items()}
for text in self._tease_texts.values():
asyncio.get_running_loop().create_task(self._synth(text))
@@ -741,7 +753,8 @@ class Room(BaseRoom):
async def _send_tease(self, pid: str, text: str):
audio = await self._synth(text) # forudgenereret af tts_warm, så det er straks
msg = {"type": "tease", "text": untag(text), "image": self.narrator.rng.choice(tease_images()),
said = (audio or {}).get("text") or text # en fast replik, hvis hjernens ikke kunne siges
msg = {"type": "tease", "text": untag(said), "image": self.narrator.rng.choice(tease_images()),
"sound": audio["url"] if audio else None}
for conn in list(self.connections):
if getattr(conn, "member_id", None) == pid:
+10 -2
View File
@@ -19,7 +19,7 @@ from tegnoggaet.engine import EMPTY_GUESS, MAX_PLAYERS, MIN_PLAYERS, RULES, RULE
from tegnoggaet.themes import load_themes
from . import rooms, tts
from .narrator import Narrator, untag
from .narrator import Narrator, one_of, untag
log = logging.getLogger(__name__)
@@ -203,7 +203,10 @@ class TegnRoom(rooms.BaseRoom):
chain = g.chains[b["chain"]]
if b["entry"] == -1:
intro = self.narrator.line("chain_intro", navn=names.get(g.order[b["chain"]], ""))
texts.append(f"{self.narrator.line('reveal_intro')} {intro}" if i == 0 else intro)
if i == 0: # afsløringens intro og første kæde siges i ét; afløseren er de to uden navn
ri = self.narrator.line("reveal_intro")
intro = one_of(f"{ri} {intro}", first_chain_generics(self.narrator, ri), self.narrator.rng)
texts.append(intro)
elif b["entry"] == len(chain):
first, last = chain[0]["text"], g._last_guess(chain) or EMPTY_GUESS
if g.survived[b["chain"]]:
@@ -272,6 +275,11 @@ def warm_texts(themes: list[dict] | None = None) -> list[str]:
return list(dict.fromkeys(out))
def first_chain_generics(narrator: Narrator, reveal_intro: str) -> list[str]:
"""Afsløringens intro efterfulgt af en generisk intro til første kæde, som rummet siger dem."""
return [f"{reveal_intro} {v}" for v in narrator.generic_variants("chain_intro_first")]
def create_tegn_room(host_username: str, public_url: str, code: str | None = None,
show_rules: bool = True) -> TegnRoom:
"""code: et kørende rums kode = skift spil i det rum (se rooms.add_room)."""
+192
View File
@@ -0,0 +1,192 @@
"""Speakeren uden live-stemme: WPP_TTS_LIVE=off og auto, der falder tilbage til cachede, generiske replikker.
ElevenLabs kaldes aldrig: _fetch er falsk, og cachen er en midlertidig mappe."""
import asyncio
import random
import socket
import tempfile
import time
import urllib.error
from unittest import mock
from django.test import SimpleTestCase, override_settings
from fupogfakta.engine import Game as FofGame
from party import narrator, rooms, tts
from party.narrator import Narrator, instead_of, one_of
from party.rooms import Room
from party.tegn import TegnRoom
from tegnoggaet.engine import Game as TegnGame
from tegnoggaet.themes import load_themes
QUESTIONS = [{"id": i, "category": "Kat", "prompt": f"Hvad _____ {i}?", "answer": f"svar{i}"} for i in range(9)]
def http_error(code):
return urllib.error.HTTPError("https://api.elevenlabs.io", code, "fejl", {}, None)
class CacheMixin:
def setUp(self):
self.tmp = tempfile.TemporaryDirectory()
self.addCleanup(self.tmp.cleanup)
over = override_settings(TTS_CACHE_DIR=self.tmp.name, ELEVENLABS_VOICE_ID="stemme", ELEVENLABS_API_KEY="nøgle",
TTS_LIVE="auto", FEST_VOICE_ID="")
over.enable()
self.addCleanup(over.disable)
tts._live_paused_until = 0.0
tts._credits.update(at=time.time(), left=None, task=None) # ingen saldo-tjek, medmindre testen beder om det
self.fetched = []
def put(self, *texts, voice=None):
"""Læg klip i cachen, som tts_warm ville have gjort."""
for t in texts:
path = tts.cache_path(tts._digest(t, voice))
path.parent.mkdir(parents=True, exist_ok=True)
path.write_bytes(b"\0" * 16000)
def fake_fetch(self, error=None):
def fetch(text, path, voice=None):
self.fetched.append(text)
if error:
raise error
path.parent.mkdir(parents=True, exist_ok=True)
path.write_bytes(b"\0" * 16000)
return mock.patch.object(tts, "_fetch", side_effect=fetch)
class TtsLiveTests(CacheMixin, SimpleTestCase):
def test_off_never_calls_elevenlabs(self):
self.put("i cachen")
with override_settings(TTS_LIVE="off"), self.fake_fetch():
self.assertIsNotNone(asyncio.run(tts.synth("i cachen")))
self.assertIsNone(asyncio.run(tts.synth("ikke i cachen")))
self.assertEqual(self.fetched, [])
def test_no_key_plays_cached_clips_only(self):
self.put("i cachen")
with override_settings(ELEVENLABS_API_KEY=""), self.fake_fetch():
self.assertTrue(tts.enabled())
self.assertIsNotNone(asyncio.run(tts.synth("i cachen")))
self.assertIsNone(asyncio.run(tts.synth("ikke i cachen")))
self.assertEqual(self.fetched, [])
def test_auto_makes_lines_live(self):
with self.fake_fetch():
self.assertIsNotNone(asyncio.run(tts.synth("ny replik")))
self.assertEqual(self.fetched, ["ny replik"])
def test_auto_pauses_live_after_401_402_429_and_timeout(self):
errors = [http_error(401), http_error(402), http_error(429), urllib.error.URLError(socket.timeout("timed out")),
TimeoutError("timed out")]
for err in errors:
with self.subTest(error=repr(err)), mock.patch.object(tts, "RETRIES_ON_429", 0):
tts._live_paused_until, self.fetched = 0.0, []
with self.fake_fetch(err):
self.assertIsNone(asyncio.run(tts.synth("første")))
self.assertIsNone(asyncio.run(tts.synth("anden"))) # i pausen: intet kald
self.assertEqual(self.fetched, ["første"])
self.assertFalse(tts.live_ok())
def test_other_errors_do_not_pause(self):
with self.fake_fetch(http_error(500)):
asyncio.run(tts.synth("første"))
asyncio.run(tts.synth("anden"))
self.assertEqual(self.fetched, ["første", "anden"])
def test_low_balance_stops_live_without_blocking(self):
tts._credits.update(at=0.0)
calls = []
async def run():
with mock.patch.object(tts, "credits_left", side_effect=lambda: calls.append(1) or 100):
self.assertTrue(tts.live_ok()) # svaret er ikke kommet endnu: spillet venter ikke på det
await asyncio.sleep(0.05)
self.assertFalse(tts.live_ok()) # under LIVE_MIN_CREDITS
self.assertFalse(tts.live_ok())
asyncio.run(run())
self.assertEqual(calls, [1]) # højst ét tjek pr. CREDITS_EVERY
class FallbackSeamTests(CacheMixin, SimpleTestCase):
def room(self, fest=False):
g = FofGame("TEST", QUESTIONS, rng=random.Random(1), fest=fest)
r = Room("TEST", g, "agw")
r.narrator.rng = random.Random(2)
return r
def test_name_line_falls_back_to_a_cached_generic(self):
r = self.room()
line = r.narrator.line("lobby_join", navn="Qxa")
cached = narrator.GENERIC_COMMON["lobby_join"][3]
self.put(cached)
with override_settings(TTS_LIVE="off"), self.fake_fetch():
audio = asyncio.run(r._synth(line))
self.assertEqual(audio["text"], cached) # den eneste generiske, der ligger i cachen
self.assertEqual(self.fetched, [])
def test_auto_falls_back_when_elevenlabs_fails(self):
r = self.room()
self.put(*narrator.GENERIC["winner"])
with self.fake_fetch(http_error(402)):
audio = asyncio.run(r._synth(r.narrator.line("winner", vinder="Qxa", point=1200)))
self.assertIn(audio["text"], narrator.GENERIC["winner"])
self.assertEqual(len(self.fetched), 1)
def test_the_line_itself_wins_when_it_can_be_made(self):
r = self.room()
self.put(*narrator.GENERIC["winner"])
with self.fake_fetch():
audio = asyncio.run(r._synth(r.narrator.line("winner", vinder="Qxa", point=1200)))
self.assertNotIn("text", audio)
def test_nothing_cached_is_silent(self):
r = self.room()
with override_settings(TTS_LIVE="off"), self.fake_fetch():
self.assertIsNone(asyncio.run(r._synth(r.narrator.line("lobby_join", navn="Qxa"))))
self.assertIsNone(asyncio.run(r._synth("Hvad blank 1?"))) # spørgsmålet: ingen generisk
self.assertEqual(self.fetched, [])
@override_settings(FEST_VOICE_ID="fest")
def test_fest_uses_fest_lines_in_the_fest_voice(self):
r = self.room(fest=True)
self.put(*narrator.GENERIC_COMMON["lobby_join_fest"], voice="fest")
self.put(*narrator.GENERIC_COMMON["lobby_join"]) # standardstemmen: må ikke bruges
with override_settings(TTS_LIVE="off"):
audio = asyncio.run(r._synth(r.narrator.line("lobby_join", navn="Qxa")))
self.assertIn(audio["text"], narrator.GENERIC_COMMON["lobby_join_fest"])
def test_brain_lines_fall_back_to_the_fixed_line_then_its_generic(self):
n = Narrator(rng=random.Random(4))
fixed = n.line("lie_progress", navn="Qxa")
brain = instead_of("Qxa skriver som en gud i dag.", fixed)
self.assertEqual(brain.generic(lambda t: t == fixed), fixed) # den faste, hvis den er cachet
self.assertIn(brain.generic(lambda t: t != fixed), narrator.GENERIC["lie_progress"])
tease = one_of("Qxa, du er cooked.", narrator.TEASES, random.Random(1))
self.assertIn(tease.generic(), narrator.TEASES)
self.assertIsNone(one_of("x", narrator.TEASES, random.Random(1)).generic(lambda t: False))
def test_room_says_the_generic_and_shows_it(self):
"""Hele vejen gennem rummets kø: en spiller joiner i off, og storskærmen får den generiske velkomst."""
self.put(*narrator.GENERIC_COMMON["lobby_join"])
async def run():
r = self.room()
r.game.join("Qxa")
r.handle_events()
await asyncio.sleep(0.05)
r.close()
return r.narration
with override_settings(TTS_LIVE="off"), self.fake_fetch(), mock.patch.object(rooms, "MAX_HOLD", 0):
said = asyncio.run(run())
self.assertEqual(len(said), 1)
self.assertIn(said[0]["text"], [narrator.untag(t) for t in narrator.GENERIC_COMMON["lobby_join"]])
self.assertEqual(self.fetched, [])
def test_tegn_first_chain_falls_back_to_intro_plus_generic(self):
g = TegnGame("TEST", load_themes(), rng=random.Random(1))
r = TegnRoom("TEST", g, "agw")
from party.tegn import first_chain_generics
combos = first_chain_generics(r.narrator, r.narrator.variants("reveal_intro")[0])
self.assertEqual(len(combos), len(r.narrator.generic_variants("chain_intro_first")))
self.assertTrue(all(c.startswith(r.narrator.variants("reveal_intro")[0] + " ") for c in combos))
+68 -2
View File
@@ -2,13 +2,20 @@
Samme tekst med samme stemme og model genereres kun én gang: filen hedder en
hash af (model, stemme, tekst) og serveres fra /tts/<hash>.mp3. Fejler
ElevenLabs, returneres None, og spillet fortsætter uden stemme.
ElevenLabs, returneres None, og spillet fortsætter uden stemme (eller med en
cachet generisk replik, se rooms.BaseRoom._synth).
WPP_TTS_LIVE=off: kun cachede klip. auto: live-kald, men efter 401/402/429 eller
timeout holdes der pause med live-kald et stykke tid, og saldoen tjekkes højst
hvert par minutter i baggrunden (aldrig mens et spil venter).
"""
import asyncio
import hashlib
import json
import logging
import socket
import time
import urllib.error
import urllib.request
from pathlib import Path
@@ -21,14 +28,60 @@ API = "https://api.elevenlabs.io/v1/text-to-speech/{voice}?output_format=mp3_441
BITRATE = 128_000
CONCURRENCY = 3 # ElevenLabs begrænser samtidige kald pr. abonnement (Creator: få)
RETRIES_ON_429 = 3
LIVE_PAUSE = 300 # sekunder uden live-kald efter 401/402/429 eller timeout
CREDITS_EVERY = 300 # saldoen tjekkes højst så tit
LIVE_MIN_CREDITS = 2000 # under så mange credits laves intet live (de skal bruges på forvarmning)
PAUSE_CODES = (401, 402, 429)
_inflight: dict[str, asyncio.Future] = {}
_sem: asyncio.Semaphore | None = None
_live_paused_until = 0.0
_credits = {"at": 0.0, "left": None, "task": None}
def enabled() -> bool:
"""Der kan komme stemme: cachede klip (stemmen er en del af cache-nøglen) eller live-kald."""
return bool(settings.ELEVENLABS_VOICE_ID)
def can_fetch() -> bool:
"""Der kan laves nye klip hos ElevenLabs (nøgle og stemme)."""
return bool(settings.ELEVENLABS_API_KEY and settings.ELEVENLABS_VOICE_ID)
def live_ok() -> bool:
"""Må et spil lave en replik live lige nu? Nej i off, uden nøgle, i en pause efter en fejl og ved lav saldo."""
if settings.TTS_LIVE == "off" or not can_fetch() or time.time() < _live_paused_until:
return False
_refresh_credits()
left = _credits["left"]
return left is None or left >= LIVE_MIN_CREDITS
def pause_live(reason: str):
global _live_paused_until
_live_paused_until = time.time() + LIVE_PAUSE
log.warning("Ingen live-stemme de næste %d sekunder (%s); cachede klip bruges", LIVE_PAUSE, reason)
def _refresh_credits():
"""Saldoen tjekkes i baggrunden; indtil svaret kommer, gælder det forrige."""
now = time.time()
if now - _credits["at"] < CREDITS_EVERY or _credits["task"]:
return
try:
loop = asyncio.get_running_loop()
except RuntimeError:
return
_credits["at"] = now
async def check():
try:
_credits["left"] = await asyncio.to_thread(credits_left)
finally:
_credits["task"] = None
_credits["task"] = loop.create_task(check())
def voice_settings() -> dict:
"""Lav stability og høj style giver en mere levende speaker. Ændres de, laves lyden på ny (ny cache-nøgle)."""
return {"stability": settings.TTS_STABILITY, "similarity_boost": settings.TTS_SIMILARITY,
@@ -86,9 +139,14 @@ def is_cached(text: str, voice: str | None = None) -> bool:
return bool(text.strip()) and cache_path(_digest(text, voice)).exists()
def cached(text: str, voice: str | None = None) -> dict | None:
"""Klippet, hvis det ligger i cachen; aldrig et kald."""
return _result(_digest(text, voice)) if enabled() and is_cached(text, voice) else None
def synth_sync(text: str, voice: str | None = None) -> dict | None:
"""Til management-kommandoer: generér (eller find i cache) synkront."""
if not enabled() or not text.strip():
if not can_fetch() or not text.strip():
return None
digest = _digest(text, voice)
if not cache_path(digest).exists():
@@ -101,11 +159,14 @@ def synth_sync(text: str, voice: str | None = None) -> dict | None:
async def synth(text: str, voice: str | None = None) -> dict | None:
"""Klippet fra cachen, ellers live (når live_ok), ellers None."""
if not enabled() or not text.strip():
return None
digest = _digest(text, voice)
if cache_path(digest).exists():
return _result(digest)
if not live_ok():
return None
fut = _inflight.get(digest)
if fut is None:
fut = asyncio.get_running_loop().create_task(_fetch_limited(text, cache_path(digest), voice))
@@ -115,6 +176,11 @@ async def synth(text: str, voice: str | None = None) -> dict | None:
await asyncio.shield(fut)
except Exception as e: # noqa: BLE001
log.warning("TTS fejlede for %r: %s", text[:60], _describe(e))
if isinstance(e, urllib.error.HTTPError) and e.code in PAUSE_CODES:
pause_live(f"HTTP {e.code}")
elif isinstance(e, (TimeoutError, socket.timeout)) or \
isinstance(getattr(e, "reason", None), (TimeoutError, socket.timeout)):
pause_live("timeout")
return None
return _result(digest)
+3
View File
@@ -30,6 +30,9 @@ HOST_ADMIN_GROUP = env("HOST_ADMIN_GROUP", "authentik Admins")
ELEVENLABS_API_KEY = env("ELEVENLABS_API_KEY", "")
ELEVENLABS_VOICE_ID = env("ELEVENLABS_VOICE_ID", "")
ELEVENLABS_MODEL = env("ELEVENLABS_MODEL", "eleven_v4")
# auto: lav replikker live hos ElevenLabs og fald tilbage til cachede (generiske) klip, når det fejler;
# off: kun klip, der ligger i cachen (tts_warm), aldrig et live-kald
TTS_LIVE = "off" if env("WPP_TTS_LIVE", "auto").strip().lower() == "off" else "auto"
FEST_VOICE_ID = env("FEST_VOICE_ID", "") # speakeren i fest-spil (tom = den almindelige speaker)
TTS_CACHE_DIR = env("TTS_CACHE_DIR", str(BASE_DIR / "tts-cache"))
TTS_STABILITY = float(env("TTS_STABILITY", "0.3")) # lavere = mere udsving i stemmen