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:
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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
@@ -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)."""
|
||||
|
||||
@@ -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
@@ -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)
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user