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