Kategorier kan have deres egen oplæserstemme
CI / test (push) Successful in 58s

Spørgsmål og forklaring i en kategori med voice_id læses af den stemme; resten af spillet bruger speakeren. Forvarmningen og cachen følger stemmen.

Claude-Session: https://claude.ai/code/session_01T4tydM7urcwizGC8jeJU1K
This commit is contained in:
agw
2026-10-18 09:37:08 +02:00
parent 347ef68038
commit 9f6d134288
8 changed files with 71 additions and 30 deletions
+1 -1
View File
@@ -5,7 +5,7 @@ from .models import Category, Question
@admin.register(Category)
class CategoryAdmin(admin.ModelAdmin):
list_display = ["name", "packs", "active", "question_count"]
list_display = ["name", "packs", "voice_id", "active", "question_count"]
list_editable = ["active"]
@admin.display(description="spørgsmål")
@@ -0,0 +1,14 @@
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [("fupogfakta", "0004_question_pitch")]
operations = [
migrations.AddField(
model_name="category",
name="voice_id",
field=models.CharField(blank=True, max_length=64, verbose_name="oplæserstemme",
help_text="ElevenLabs-stemme-id til spørgsmål og forklaring i denne kategori. Tom = speakeren."),
),
]
+4 -1
View File
@@ -11,6 +11,8 @@ class Category(models.Model):
active = models.BooleanField("aktiv", default=True)
packs = models.JSONField("pakker", default=list, blank=True,
help_text='Fx ["familie"], ["nørd"] eller ["nørd", "arbejde"]. Tom = familie.')
voice_id = models.CharField("oplæserstemme", max_length=64, blank=True,
help_text="ElevenLabs-stemme-id til spørgsmål og forklaring i denne kategori. Tom = speakeren.")
def in_packs(self, wanted) -> bool:
return bool(set(self.packs or ["familie"]) & set(wanted))
@@ -52,7 +54,8 @@ class Question(models.Model):
def as_game_dict(self):
return {"id": self.id, "category_id": self.category_id, "category": self.category.name,
"prompt": self.prompt, "answer": self.answer, "alternatives": self.alternatives,
"decoys": self.decoys, "note": self.note, "times_used": self.times_used, "pitch": self.pitch}
"decoys": self.decoys, "note": self.note, "times_used": self.times_used, "pitch": self.pitch,
"voice": self.category.voice_id or None}
TOO_EASY = 2 # så mange gange "alle fandt sandheden", før spørgsmålet tages ud
+9 -8
View File
@@ -48,26 +48,27 @@ class Command(BaseCommand):
# Spørgsmålene i den rækkefølge, spillet vælger dem (mindst brugte først), skiftevis med
# kategori-replikkerne, så begge dele bliver dækket over et par nætter.
narrator = Narrator()
questions = []
questions = [] # (tekst, stemme); None = speakeren
for q in Question.objects.filter(active=True).select_related("category").order_by("times_used", "id"):
questions += [speakable_prompt(q.prompt)] + ([q.note] if q.note else [])
voice = q.category.voice_id or None
questions += [(speakable_prompt(q.prompt), voice)] + ([(q.note, voice)] if q.note else [])
# oplæsningen i runde 2/finalen: sandheden og husets løgne med deres forudskrevne kommentar
questions += [narrator.present_answer(svar, comment) for svar, comment in (q.pitch or {}).items() if comment]
questions += [(narrator.present_answer(svar, comment), None) for svar, comment in (q.pitch or {}).items() if comment]
categories = sorted(set(Question.objects.filter(active=True).values_list("category__name", flat=True)))
per_category = _expand_categories(LINES.get("lie_start", []), categories)
per_category = [(t, None) for t in _expand_categories(LINES.get("lie_start", []), categories)]
queue = [t for pair in zip(questions, per_category) for t in pair]
queue += questions[len(per_category):] + per_category[len(questions):]
spent = made = cached = 0
for text in queue:
if tts.is_cached(text):
for text, voice in queue:
if tts.is_cached(text, voice):
cached += 1
continue
if spent + len(text) > budget:
break
if tts.synth_sync(text):
if tts.synth_sync(text, voice):
spent += len(text)
made += 1
left = sum(1 for t in queue if not tts.is_cached(t))
left = sum(1 for t, v in queue if not tts.is_cached(t, v))
self.stdout.write(self.style.SUCCESS(
f"{made} nye replikker ({spent} tegn); {cached} lå allerede i cachen; {left} mangler endnu."))
+10 -5
View File
@@ -192,13 +192,15 @@ class Room:
continue
self._last_join_line = now
self._maybe_schedule_lobby_waiting()
# spørgsmålet læses af kategoriens egen oplæser, hvis den har en
voice = self._question_voice() if ev["type"] == "lie_start" else None
for text in self.narrator.for_event(ev, self.game):
self.say(text, blocking=ev["type"] != "lie_was_truth")
self.say(text, blocking=ev["type"] != "lie_was_truth", voice=voice)
if ev["type"] == "lie_start":
self._at_deadline_minus(10, "lie", "lie_hurry")
note = (self.game.question or {}).get("note")
if note: # forklaringen kendes allerede nu; lav den, mens der løjes
asyncio.get_running_loop().create_task(tts.synth(note))
asyncio.get_running_loop().create_task(tts.synth(note, voice))
elif ev["type"] == "guess_start":
self._at_deadline_minus(5, "guess", "guess_hurry")
@@ -381,7 +383,10 @@ class Room:
for text in self.narrator.for_event({"type": "host_open"}, self.game):
self.say(text)
def say(self, text: str | None, blocking: bool = True):
def _question_voice(self) -> str | None:
return (self.game.question or {}).get("voice")
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.
blocking=False: en sidebemærkning, som ikke holder skærmen tilbage (fx "det var sandheden")."""
if not text:
@@ -393,7 +398,7 @@ class Room:
started = time.time() if blocking else None
if blocking:
self._pending.setdefault(key, []).append(started)
task = asyncio.get_running_loop().create_task(tts.synth(text))
task = asyncio.get_running_loop().create_task(tts.synth(text, voice))
self._voice_queue.put_nowait((text, task, key, started))
self._update_hold()
@@ -481,7 +486,7 @@ class Room:
jobs = [tts.synth(t) if t else asyncio.sleep(0, None) for t in texts]
jobs.append(tts.synth(cleanup_text) if cleanup_text else asyncio.sleep(0, None))
jobs.append(tts.synth(points_text))
jobs.append(tts.synth(note) if note else asyncio.sleep(0, None))
jobs.append(tts.synth(note, self._question_voice()) if note else asyncio.sleep(0, None))
try:
results = await asyncio.wait_for(asyncio.gather(*jobs), timeout=REVEAL_AUDIO_WAIT)
except asyncio.TimeoutError:
+1 -1
View File
@@ -94,7 +94,7 @@ class EngineEventTests(SimpleTestCase):
self.assertAlmostEqual(g.deadline, 106 + (5 * len(g.reveal_steps) + 4))
async def fake_synth(text):
async def fake_synth(text, voice=None):
return {"url": f"/tts/{abs(hash(text)) % 10**32:032d}.mp3", "ms": 40}
+13
View File
@@ -0,0 +1,13 @@
from django.test import SimpleTestCase, override_settings
from party import tts
@override_settings(ELEVENLABS_VOICE_ID="speaker", ELEVENLABS_MODEL="m")
class VoiceCacheTests(SimpleTestCase):
def test_default_voice_keeps_the_old_cache_key(self):
self.assertEqual(tts._digest("Hej"), tts._digest("Hej", None))
self.assertEqual(tts._digest("Hej"), tts._digest("Hej", "speaker"))
def test_category_voice_gets_its_own_cache_key(self):
self.assertNotEqual(tts._digest("Hej"), tts._digest("Hej", "oplaeser"))
+19 -14
View File
@@ -35,9 +35,14 @@ def voice_settings() -> dict:
"style": settings.TTS_STYLE, "use_speaker_boost": True}
def _digest(text: str) -> str:
def _voice(voice: str | None) -> str:
"""En kategori kan have sin egen oplæser; ellers speakeren fra indstillingerne."""
return voice or settings.ELEVENLABS_VOICE_ID
def _digest(text: str, voice: str | None = None) -> str:
vs = voice_settings()
raw = (f"{settings.ELEVENLABS_MODEL}|{settings.ELEVENLABS_VOICE_ID}|"
raw = (f"{settings.ELEVENLABS_MODEL}|{_voice(voice)}|"
f"{vs['stability']}|{vs['similarity_boost']}|{vs['style']}|{text}")
return hashlib.sha256(raw.encode()).hexdigest()[:32]
@@ -51,10 +56,10 @@ def _result(digest: str) -> dict:
return {"url": f"/tts/{digest}.mp3", "ms": int(size * 8 * 1000 / BITRATE)}
def _fetch(text: str, path: Path) -> None:
def _fetch(text: str, path: Path, voice: str | None = None) -> None:
body = json.dumps({"text": text, "model_id": settings.ELEVENLABS_MODEL, "language_code": "da",
"voice_settings": voice_settings()}).encode()
req = urllib.request.Request(API.format(voice=settings.ELEVENLABS_VOICE_ID), data=body, headers={
req = urllib.request.Request(API.format(voice=_voice(voice)), data=body, headers={
"xi-api-key": settings.ELEVENLABS_API_KEY, "Content-Type": "application/json", "Accept": "audio/mpeg"})
with urllib.request.urlopen(req, timeout=25) as resp:
data = resp.read()
@@ -77,33 +82,33 @@ def credits_left() -> int | None:
return None
def is_cached(text: str) -> bool:
return bool(text.strip()) and cache_path(_digest(text)).exists()
def is_cached(text: str, voice: str | None = None) -> bool:
return bool(text.strip()) and cache_path(_digest(text, voice)).exists()
def synth_sync(text: str) -> dict | 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():
return None
digest = _digest(text)
digest = _digest(text, voice)
if not cache_path(digest).exists():
try:
_fetch(text, cache_path(digest))
_fetch(text, cache_path(digest), voice)
except Exception as e: # noqa: BLE001 — stemmen må aldrig vælte spillet
log.warning("TTS fejlede for %r: %s", text[:60], _describe(e))
return None
return _result(digest)
async def synth(text: str) -> dict | None:
async def synth(text: str, voice: str | None = None) -> dict | None:
if not enabled() or not text.strip():
return None
digest = _digest(text)
digest = _digest(text, voice)
if cache_path(digest).exists():
return _result(digest)
fut = _inflight.get(digest)
if fut is None:
fut = asyncio.get_running_loop().create_task(_fetch_limited(text, cache_path(digest)))
fut = asyncio.get_running_loop().create_task(_fetch_limited(text, cache_path(digest), voice))
_inflight[digest] = fut
fut.add_done_callback(lambda _f: _inflight.pop(digest, None))
try:
@@ -114,14 +119,14 @@ async def synth(text: str) -> dict | None:
return _result(digest)
async def _fetch_limited(text: str, path: Path) -> None:
async def _fetch_limited(text: str, path: Path, voice: str | None = None) -> None:
global _sem
if _sem is None:
_sem = asyncio.Semaphore(CONCURRENCY)
for attempt in range(RETRIES_ON_429 + 1):
async with _sem:
try:
await asyncio.to_thread(_fetch, text, path)
await asyncio.to_thread(_fetch, text, path, voice)
return
except urllib.error.HTTPError as e:
if e.code != 429 or attempt == RETRIES_ON_429: