diff --git a/fupogfakta/admin.py b/fupogfakta/admin.py index c1896f2..8173f11 100644 --- a/fupogfakta/admin.py +++ b/fupogfakta/admin.py @@ -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") diff --git a/fupogfakta/migrations/0005_category_voice_id.py b/fupogfakta/migrations/0005_category_voice_id.py new file mode 100644 index 0000000..0f5f1ba --- /dev/null +++ b/fupogfakta/migrations/0005_category_voice_id.py @@ -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."), + ), + ] diff --git a/fupogfakta/models.py b/fupogfakta/models.py index df7d250..481a8ac 100644 --- a/fupogfakta/models.py +++ b/fupogfakta/models.py @@ -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 diff --git a/party/management/commands/tts_warm.py b/party/management/commands/tts_warm.py index 2f0f65f..3148055 100644 --- a/party/management/commands/tts_warm.py +++ b/party/management/commands/tts_warm.py @@ -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.")) diff --git a/party/rooms.py b/party/rooms.py index fec5e1a..6dfbc42 100644 --- a/party/rooms.py +++ b/party/rooms.py @@ -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: diff --git a/party/tests/test_narration.py b/party/tests/test_narration.py index 3f6fe1e..09e96bf 100644 --- a/party/tests/test_narration.py +++ b/party/tests/test_narration.py @@ -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} diff --git a/party/tests/test_tts_voice.py b/party/tests/test_tts_voice.py new file mode 100644 index 0000000..40e63f3 --- /dev/null +++ b/party/tests/test_tts_voice.py @@ -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")) diff --git a/party/tts.py b/party/tts.py index 155351b..98c41d0 100644 --- a/party/tts.py +++ b/party/tts.py @@ -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: