Speaker med stemme: replikker læses op med ElevenLabs
CI / test (push) Successful in 23s

Spilmotoren udsender begivenheder, rummet vælger en replik, indsætter navne
og talord og genererer lyden (cachet på disk ud fra tekst, stemme og
model). Storskærmen afspiller replikkerne i rækkefølge. Afsløringen venter
på lyden og får en længde pr. trin, så stemplet lander, mens speakeren
taler. Uden nøgle eller ved fejl kører spillet som før, bare uden stemme.

Spørgsmålene er gjort familievenlige, og de otte mest kluntede replikker er
skrevet om.

Claude-Session: https://claude.ai/code/session_01JmhJG6JnhXfzNiKv8f2vHu
This commit is contained in:
agw
2026-10-01 21:57:00 +02:00
parent 0573f867c9
commit d2dfeafea0
19 changed files with 922 additions and 284 deletions
+1
View File
@@ -4,3 +4,4 @@ __pycache__/
db.sqlite3
staticfiles/
.env
tts-cache/
+2 -2
View File
@@ -59,7 +59,7 @@
},
{
"category": "Dyreriget",
"prompt": "Vombatten er kendt for at lave _____ lorte.",
"prompt": "Vombatten er kendt for at lave _____ bæ.",
"answer": "terningformede",
"alternatives": [
"firkantede",
@@ -1142,7 +1142,7 @@
"Juicy Fruit"
],
"decoys": [
"cigaretter",
"kiks",
"havregryn",
"kaffe"
],
+4 -36
View File
@@ -478,22 +478,6 @@
],
"note": "På Apollo 14 slog Shepard to golfbolde med et hjemmelavet jern."
},
{
"category": "Sport",
"prompt": "Under OL i Paris i 1900 blev der afholdt skydning efter levende _____.",
"answer": "duer",
"alternatives": [
"duer",
"due",
"tamduer"
],
"decoys": [
"kaniner",
"fasaner",
"høns"
],
"note": "Det var eneste gang, dyr bevidst blev dræbt ved legene; omkring 300 duer blev skudt."
},
{
"category": "Sport",
"prompt": "OL's grundlægger Pierre de Coubertin vandt selv en guldmedalje i 1912 i _____.",
@@ -590,22 +574,6 @@
],
"note": "Badminton House i Gloucestershire tilhørte hertugen af Beaufort."
},
{
"category": "Sport",
"prompt": "Ved OL-maratonløbet i 1904 fik vinderen Thomas Hicks undervejs rottegiften _____ for at holde ham kørende.",
"answer": "stryknin",
"alternatives": [
"stryknin",
"strychnin",
"strychnine"
],
"decoys": [
"arsenik",
"cyanid",
"kviksølv"
],
"note": "Hans hjælpere gav ham stryknin blandet med æggehvide og brandy; han var tæt på at kollapse."
},
{
"category": "Sport",
"prompt": "Fred Lorz kom først i mål ved OL-maratonløbet i 1904, men havde kørt en stor del af ruten i _____.",
@@ -735,7 +703,7 @@
},
{
"category": "Historie",
"prompt": "I Boston døde 21 mennesker i 1919, da en kæmpe tank med _____ bristede.",
"prompt": "I Boston skyllede en kæmpe bølge af _____ gennem gaderne i 1919, da en tank bristede.",
"answer": "melasse",
"alternatives": [
"melasse",
@@ -743,9 +711,9 @@
"sukkersirup"
],
"decoys": [
"øl",
"mælk",
"whisky"
"olie",
"honning"
],
"note": "Den store melasseflod sendte en bølge af sirup gennem North End i Boston."
},
@@ -858,7 +826,7 @@
"decoys": [
"kaffe",
"en appelsin",
"whisky"
"is"
],
"note": "Aldrin tog nadver med medbragt brød og vin, før han gik ud på Månen."
},
+70 -5
View File
@@ -112,7 +112,14 @@ class Game:
self.likes: dict[str, set[str]] = {}
self.reveal_steps: list[dict] = []
self.final_result: dict | None = None
self.reveal_started_at: float | None = None
self.reveal_steps_ms: list[int] = []
self.reveal_tail_ms = REVEAL_TAIL_MS
self.reveal_note_audio: dict | None = None
self.version = 0
# Begivenheder til speakeren (party.narration). Motoren lægger dem her;
# rummet tømmer listen efter hver handling.
self.events: list[dict] = []
# ── hjælpere ────────────────────────────────────────────────────────────
def _next_id(self, prefix: str) -> str:
@@ -122,6 +129,18 @@ class Game:
def _changed(self):
self.version += 1
def _emit(self, kind: str, **data):
self.events.append({"type": kind, **data})
def drain_events(self) -> list[dict]:
events, self.events = self.events, []
return events
def standings(self) -> tuple[Player | None, Player | None]:
"""(førende, sidst) blandt spillerne."""
ranked = sorted(self.players.values(), key=lambda p: (-p.score, p.joined_order))
return (ranked[0], ranked[-1]) if ranked else (None, None)
def _member(self, member_id: str) -> Player | None:
return self.players.get(member_id) or self.audience.get(member_id)
@@ -169,6 +188,7 @@ class Game:
token=secrets.token_urlsafe(16), role="player" if as_player else "audience",
connected=True, joined_order=self._counter)
(self.players if as_player else self.audience)[p.id] = p
self._emit("join", name=p.name, role=p.role)
self._changed()
return p
@@ -213,6 +233,7 @@ class Game:
self._reset_scores()
self.round_idx = 0
self.q_idx = 0
self._emit("game_start", players=len(self.players))
self._begin_question(now)
def play_again(self, member_id: str | None, now: float) -> None:
@@ -229,6 +250,7 @@ class Game:
a.color = next((c for c in COLORS if c not in used), COLORS[0])
self.players[a.id] = a
self.phase = "lobby"
self._emit("play_again")
self.start(None, now)
def _to_lobby(self):
@@ -257,6 +279,10 @@ class Game:
if not cats:
raise GameError("no_questions", "Der er ingen spørgsmål i banken.")
self.round_deltas_reset_if_new_round()
if self.q_idx == 0:
leader, last = self.standings()
self._emit("round_intro", round=self.round_idx, leader=leader.name if leader else "",
last=last.name if last else "")
if self.is_final_round:
self._choose(self.rng.choice(list(cats)), now)
return
@@ -269,6 +295,7 @@ class Game:
self.category_options = [{"id": cid, "name": name} for cid, name in picks]
self.phase = "category"
self.deadline = now + CATEGORY_SECONDS
self._emit("category", chooser=chooser.name)
self._changed()
def round_deltas_reset_if_new_round(self):
@@ -298,8 +325,11 @@ class Game:
self.reveal_steps = []
for p in self.players.values():
p.last_delta = None
self.reveal_started_at = None
self.reveal_note_audio = None
self.phase = "lie"
self.deadline = now + LIE_SECONDS
self._emit("lie_start", category=self.question["category"], prompt=self.question["prompt"])
self._changed()
def _truths(self) -> list[str]:
@@ -324,6 +354,7 @@ class Game:
if not text or len(text) > MAX_LIE or not normalize(text):
raise GameError("lie_invalid", f"Løgnen skal være 1-{MAX_LIE} tegn.")
if self._is_truth(text):
self._emit("lie_was_truth")
raise GameError("lie_is_truth", "Det er faktisk sandheden! Find på en løgn i stedet.")
self.lies[member_id] = text
self._changed()
@@ -359,6 +390,7 @@ class Game:
self.answers = answers
self.phase = "guess"
self.deadline = now + GUESS_SECONDS
self._emit("guess_start")
self._changed()
def _answer(self, answer_id: str) -> Answer:
@@ -411,6 +443,7 @@ class Game:
def _maybe_finish_early(self, now: float):
active = self._active_players()
if self.phase == "lie" and active and all(p.id in self.lies for p in active):
self._emit("lie_all_done")
self._start_guess(now)
elif self.phase == "guess" and active and all(p.id in self.guesses for p in active):
self._start_reveal(now)
@@ -453,9 +486,25 @@ class Game:
steps.sort(key=lambda s: (s["is_truth"], len(s["guessed_by"]) + s["audience_votes"]))
self.reveal_steps = steps
self.phase = "reveal"
self.deadline = now + (len(steps) * REVEAL_STEP_MS + REVEAL_TAIL_MS) / 1000
self.start_reveal_timeline(now)
self._emit("reveal")
self._changed()
def start_reveal_timeline(self, now: float, steps_ms: list[int] | None = None, tail_ms: int | None = None):
"""Afsløringen kører trin for trin fra `now`. Speakeren kan give hvert trin sin egen længde."""
self.reveal_steps_ms = list(steps_ms or [REVEAL_STEP_MS] * len(self.reveal_steps))
self.reveal_tail_ms = REVEAL_TAIL_MS if tail_ms is None else tail_ms
self.reveal_started_at = now
self.deadline = now + (sum(self.reveal_steps_ms) + self.reveal_tail_ms) / 1000
self._changed()
def hold_reveal(self, now: float, max_wait: float = 10.0):
"""Vent med at starte afsløringen (speakeren laver lyd). Starter selv efter max_wait."""
if self.phase == "reveal":
self.reveal_started_at = None
self.deadline = now + max_wait
self._changed()
def _after_reveal(self, now: float):
self.q_idx += 1
if self.q_idx < self.round[2]:
@@ -465,6 +514,8 @@ class Game:
else:
self.phase = "scores"
self.deadline = now + SCORES_SECONDS
leader, last = self.standings()
self._emit("scores", leader=leader.name if leader else "", last=last.name if last else "")
self._changed()
def _after_scores(self, now: float):
@@ -484,12 +535,17 @@ class Game:
def best(attr, title, detail):
top = max(self.players.values(), key=lambda p: getattr(p, attr), default=None)
if top and getattr(top, attr) > 0:
awards.append({"title": title, "player_id": top.id, "detail": detail(getattr(top, attr))})
awards.append({"title": title, "player_id": top.id, "detail": detail(getattr(top, attr)),
"kind": attr, "count": getattr(top, attr)})
best("fooled", "Største løgner", lambda n: f"narrede andre {n} gang{'e' if n != 1 else ''}")
best("correct", "Sandhedsvidne", lambda n: f"fandt sandheden {n} gang{'e' if n != 1 else ''}")
best("likes", "Folkets favorit", lambda n: f"fik {n} like{'s' if n != 1 else ''}")
self.final_result = {"ranking": places, "awards": awards}
winner = ranking[0] if ranking else None
self._emit("final", winner=winner.name if winner else "", points=winner.score if winner else 0,
awards=[{"kind": a["kind"], "name": self.players[a["player_id"]].name, "count": a["count"]}
for a in awards])
self.phase = "final"
self.deadline = None
self._changed()
@@ -508,13 +564,19 @@ class Game:
def _advance(self, now: float):
if self.phase == "category":
self._choose(self.rng.choice(self.category_options)["id"], now)
pick = self.rng.choice(self.category_options)
chooser = self.players.get(self.chooser_id)
self._emit("category_timeout", chooser=chooser.name if chooser else "", category=pick["name"])
self._choose(pick["id"], now)
elif self.phase == "lie":
self._start_guess(now)
elif self.phase == "guess":
self._start_reveal(now)
elif self.phase == "reveal":
self._after_reveal(now)
if self.reveal_started_at is None:
self.start_reveal_timeline(now) # lyden blev aldrig klar: kør uden
else:
self._after_reveal(now)
elif self.phase == "scores":
self._after_scores(now)
@@ -551,7 +613,10 @@ class Game:
state["answers"] = [{"id": a.id, "text": a.text} for a in self.answers if hide not in a.authors]
if self.phase == "reveal":
state["reveal"] = {"steps": self.reveal_steps, "step_ms": REVEAL_STEP_MS,
"note": self.question.get("note", "") if self.question else ""}
"steps_ms": self.reveal_steps_ms, "tail_ms": self.reveal_tail_ms,
"started_at": int(self.reveal_started_at * 1000) if self.reveal_started_at else None,
"note": self.question.get("note", "") if self.question else "",
"note_audio": self.reveal_note_audio}
if self.phase == "scores":
state["deltas"] = [{"player_id": p.id, "delta": p.round_delta} for p in self.players.values()]
if self.phase == "final":
+227 -227
View File
@@ -1,228 +1,228 @@
{
"lobby_welcome": [
"Velkommen til Fup og Fakta. Find telefonen frem. Samvittigheden kan blive i lommen.",
"Her må du lyve. Vi har endda lavet en pointtavle til det.",
"Velkommen. Her bliver familiehistorier endelig vurderet efter, hvor godt de snyder.",
"[excited] Fup og Fakta! Find telefonen frem. Jeg har allerede gjort sandheden nervøs.",
"Et spil om sandhed og løgn. Altså en familiefest med lidt tydeligere regler."
],
"lobby_join": [
"{navn} er med. Så har vi en mere at stole lidt for meget på.",
"Velkommen, {navn}. Jeg stoler på dig. Det plejer at gå galt.",
"Der kom {navn}. Endnu et navn til min kommende samling af bortforklaringer.",
"{navn}, dejligt. Jeg skriver dig på listen over mistænkeligt hyggelige mennesker.",
"[suspicious] {navn} er med. Den tavshed tager jeg som en tilståelse.",
"Plads til {navn}. Telefonen er nu officielt et redskab til familieintriger.",
"{navn}, du kom præcis i tide til at tage fejl sammen med os.",
"[impressed] {navn} fandt ind. Første opgave løst. Desværre giver den ingen point.",
"Nu er {navn} her. Så kan vi snart få sandheden nedstemt.",
"{navn}, sæt dig godt. Dine forklaringer kommer til at stå på egne ben."
],
"lobby_waiting": [
"{vip}, når du er klar, kan vi begynde. Jeg har løgne, der skal luftes.",
"Hvis vi venter længere, {vip}, bliver mine løgne til gamle familiehistorier.",
"{vip}, giv os et tegn. Helst startknappen. Røgsignaler virker dårligt i stuen.",
"Jeg er klar, {vip}. Det siger jeg også før møder. Her mener jeg det.",
"[whispers] {vip}, jeg har snart brugt min eneste replik om vejret."
],
"game_start": [
"{antal_spillere} spillere og én sandhed. Det bliver en helt almindelig familiefest.",
"{antal_spillere} spillere. Gæt sandheden, og nar de andre. Begge dele giver point.",
"{antal_spillere} spillere. Tilliden i sofaen skal nu testes med helt frivillige løgne.",
"[excited] Vi er i gang! {antal_spillere} spillere, samlet om noget mistænkeligt. Helt lovligt.",
"{antal_spillere} spillere. Point for sandheden og gode løgne. Familiefortælleren får endelig lidt anerkendelse."
],
"round1_intro": [
"Første runde. Almindelige point, ualmindeligt tvivlsomme forklaringer.",
"Vi begynder med normale point. Se det som en høflig prøve på familiens tillid.",
"Runde ét. Pointene er normale. Det er resten af det her sjældent.",
"Første runde, normale point. Jeg starter med fuld tillid. Det plejer at være en kort fornøjelse.",
"Vi starter stille med normale point. I får rigelig mulighed for at råbe senere."
],
"round2_intro": [
"Nu giver vi dobbelt point. {foerende}, din føring får brug for en sikkerhedssele.",
"Runde to, dobbelt point. Nu får selv en lille løgn lidt vokseværk.",
"Vi fordobler pointene. {foerende} fører, men sofaen har endnu ikke kronet nogen.",
"Dobbelt point! Nu er det dyrere at stole på familie og venner.",
"[excited] Runde to giver dobbelt point. Jeg føler mig allerede dobbelt så vigtig."
],
"final_intro": [
"Finale med tredobbelt point. {foerende} fører, men ingen har fået lov at holde takketale.",
"{sidst}, nu giver vi tredobbelt point. Tid til at pynte på regnskabet.",
"{foerende}, finalen giver tredobbelt point. Føringer tæller bedst, når de holder hele vejen.",
"[dramatic] Tredobbelt point i finalen. Nu kan en mavefornemmelse flytte rundt på hele stillingen.",
"Sidste runde. Tre gange pointene. Jeg har allerede øvet min overraskede stemme."
],
"category_choose": [
"{navn}, vælg en kategori. Gerne noget, du kan lyve selvsikkert om.",
"Valget er dit, {navn}. Hele sofaen får naturligvis lov at brokke sig bagefter.",
"Hvad skal vi høre om, {navn}? Du har ordet. Jeg beholder opmærksomheden.",
"{navn}, peg på en kategori. Her er mavefornemmelsen faktisk kvalificeret.",
"{navn}, hvilken kategori? Du behøver kun vælge én. Gem resten til ønskesedlen."
],
"category_timeout": [
"{navn} tog betænkningstid. Vi tog {kategori}. Så kom der da en beslutning.",
"Det blev {kategori}. {navn}, stilhed er åbenbart også en måde at vælge på.",
"[sighs] {navn}, tiden løb. Nu hedder kategorien {kategori}. Den kunne heldigvis beslutte sig.",
"{kategori} er valgt. {navn}, vi kalder det delegering. Det lyder pænere.",
"{navn}, universet har valgt {kategori}. Universet arbejder åbenbart hurtigere end vores familie."
],
"lie_start": [
"Nu gælder det {kategori}. Skriv en løgn, der kunne få sit eget leksikonopslag.",
"{kategori}. Skriv en løgn, der føles som noget, man har hørt før.",
"Skriv en løgn om {kategori}. Giv den en parkeringsvagts selvtillid.",
"{kategori} er emnet. Skriv noget opdigtet. Hunden har lovet at tage skylden.",
"Opfind et svar om {kategori}. En lille detalje kan gøre løgnen stor.",
"Vi skal digte om {kategori}. Gør gerne løgnen kedelig. Det virker overraskende godt.",
"[mischievously] Nu handler det om {kategori}. Skriv en løgn, du kunne sige helt alvorligt.",
"Skriv en løgn om {kategori}. Husets dårlige ry kan godt bruge hjælp.",
"{kategori}. Opfind et svar, som selv familiens skeptiker kunne købe.",
"{kategori}. Skriv en troværdig løgn. Du må gerne rette stavefejl i virkeligheden."
],
"lie_hurry": [
"Ti sekunder. En lille løgn er bedre end en stor tænkepause.",
"Ti sekunder tilbage. Afsted med løgnen. Den behøver kun overleve ét spørgsmål.",
"Der er ti sekunder. Giv fantasien albuer, og send noget afsted.",
"Ti sekunder! Selv en halvbagt løgn kan serveres med overbevisning.",
"Kun ti sekunder. Din indre forfatter må godt springe korrekturen over."
],
"lie_all_done": [
"Alle løgne er afleveret. Sikke vi kan arbejde, når sandheden bliver valgfri.",
"Alle har skrevet. Jeg gemmer bunken til næste gang, nogen kalder sig ærlige.",
"Nu har vi alle løgnene. Det er som et familiereferat, bare hurtigere.",
"[impressed] Alle er færdige. Sikke et samarbejde om at gøre hinanden mistænksomme.",
"Tak for løgnene. Det er en hilsen, jeg sjældent bruger hos bageren."
],
"lie_was_truth": [
"Det var faktisk sandheden. Prøv igen, med lidt mindre ærlighed.",
"Sandheden kom ind ad bagdøren. Send den ud igen, og skriv en løgn.",
"[impressed] Nogen har skrevet sandheden. Så ved vi i det mindste, at den findes.",
"Det svar var sandt. En sjælden tastefejl. Prøv med en løgn.",
"Du ramte sandheden. Gem den slags talenter til gættefasen."
],
"guess_start": [
"Find sandheden. Den har klædt sig ud som endnu en dårlig idé.",
"Vælg det sande svar. Selvtillid er desværre en dårlig kildehenvisning.",
"Nu skal I gætte sandheden. Tænk efter, før familieanekdoterne tager over.",
"Hvilket svar er sandt? Lad maven bestemme. Den træffer jo allerede aftensmadsbeslutninger.",
"Vælg sandheden. Mine løgne er også med. De er lavet med kærlighed.",
"Find det rigtige svar. Virkeligheden har en ret løs opfattelse af, hvad der er rimeligt.",
"Ét af svarene passer. Vælg det. Resten sælger vi på næste loppemarked.",
"Vælg sandheden. Publikum gætter med. Nu kan endnu flere blande sig.",
"Find sandheden, og giv gerne sjove løgne et hjerte. Også bedragere har følelser.",
"[suspicious] Ét svar er sandt. Vælg med omhu. De andre har bare øvet sig."
],
"guess_hurry": [
"Fem sekunder. Vælg et svar. Det hjælper sjældent at stirre vredt på telefonen.",
"Fem sekunder tilbage. Din mavefornemmelse har nu sidste ord.",
"Der er fem sekunder. Tvivlen får desværre ingen point.",
"Fem sekunder! Vælg et svar, før din store overvejelse bliver en meget lille aflevering.",
"Kun fem sekunder. Sandheden venter. Den er åbenbart bedre opdraget end mig."
],
"reveal_lie_fooled": [
"{forfatter} opfandt {svar}. {ofre} købte den. Kvitteringen er desværre forsvundet.",
"{ofre} faldt for {svar}. {forfatter}, din fantasi har nu fået selskab.",
"Det var {forfatter}, der fandt på {svar}. {ofre} blev dagens forsøgskaniner.",
"[chuckles] {svar} var en løgn fra {forfatter}. Tak for støtten, {ofre}.",
"{forfatter} skrev {svar}, og {ofre} tog imod. Meget høfligt. Meget forkert.",
"{svar} kom fra {forfatter}. {ofre} lod sig overbevise. Selvtilliden fik åbenbart ordet først.",
"{ofre} troede på {svar}. {forfatter} har lige bevist, at en god historie kan springe køen over.",
"{forfatter}, {svar} virkede på {ofre}. Den løgn slap godt fra middagsbordet.",
"{svar}, sagde {forfatter}. Ja tak, sagde {ofre}. Og sådan fik fantasien sin belønning.",
"{svar} var en løgn fra {forfatter}. {ofre}, det bliver mellem os og hele stuen."
],
"reveal_lie_fooled_many": [
"{antal} troede på {svar}. {forfatter}, du har nok til at stifte en forening.",
"{forfatter} fik {antal} til at vælge {svar}. Sofaen har dannet fælles front mod fakta.",
"[impressed] {svar} narrede {antal}. {forfatter}, jeg bøjer mig. Mest for at gemme min jalousi.",
"{antal} hoppede på {svar}. {forfatter}, du kan starte en bølge uden at rejse dig.",
"{svar} narrede {antal}. {forfatter}, din fantasi har flere tilhængere end min madplan."
],
"reveal_lie_shared": [
"{forfattere} skrev begge {svar}. To hjerner, én fælles omvej.",
"{forfattere} leverede {svar}. To løgnere for én. Det kan tilbudsavisen lide.",
"{forfattere} fandt på {svar}. Samme løgn. Fantasien må have en kopimaskine.",
"{svar}, fra {forfattere}. En løgn med to underskrifter. Næsten klar til kommunen.",
"[suspicious] {forfattere} skrev {svar}. Jeg siger bare, at telepati burde stå i reglerne."
],
"reveal_house_lie": [
"{svar} var min løgn. Tak, {ofre}. Dejligt, at kunst bliver værdsat.",
"[mischievously] {ofre} valgte {svar}. Husets løgn. Jeg modtager gerne stående bifald fra sofaen.",
"Jeg opfandt {svar}. {ofre} troede på den. Det kalder jeg godt samarbejde.",
"{ofre}, {svar} var min. Så ved vi, hvem der bestemmer over rygterne.",
"{ofre} købte {svar}. Min opskrift. Tillid er åbenbart den hemmelige ingrediens.",
"{svar} kom fra huset. {ofre}, den forklaring kan give næste middag ekstra samtalestof.",
"{ofre} troede på {svar}. Jeg skrev den helt uden hjælp fra virkeligheden.",
"Min løgn var {svar}. {ofre}, jeg kan mærke min dårlige samvittighed tage fri.",
"{svar} snød {ofre}. Den var min. Jeg øver lige mit ydmyge nik.",
"Huset opfandt {svar}. {ofre} faldt for den. Så sparede vi penge på specialeffekter."
],
"reveal_truth_found": [
"Sandheden er {sandhed}. {navne} fandt den. Så fik virkeligheden lidt opbakning.",
"{navne} valgte sandheden, {sandhed}. Gem lige øjeblikket til næste familiediskussion.",
"[impressed] Svaret er {sandhed}. {navne} gennemskuede os. Irriterende godt arbejde.",
"{sandhed}, det er sandheden. {navne} ramte rigtigt. Meget upraktisk for løgnerne.",
"{sandhed} er svaret. {navne} har tilladelse til ét lille, bedrevidende nik.",
"{navne} ramte rigtigt med {sandhed}. Nogen har åbenbart læst bagsiden af mælkekartonen.",
"Sandheden var {sandhed}. Godt fanget, {navne}. Den viden fortjente sin egen fest.",
"{navne} valgte {sandhed}. Korrekt. Skepsissen kan lige lægge benene op.",
"{navne} fandt sandheden, {sandhed}. Mavefornemmelsen har lige fået en lønforhøjelse.",
"Svaret er {sandhed}. Point til {navne}. Jeg vidste det selvfølgelig også. Næsten."
],
"reveal_truth_nobody": [
"Svaret var {sandhed}. Ingen fandt det. Vi lader som om, det her var opvarmning.",
"Sandheden var {sandhed}. Hele sofaen tog fejl. Sikke en sjælden form for enighed.",
"[sighs] Sandheden var {sandhed}. Den stod lige der. Jeg giver virkeligheden et trøstende kram.",
"Ingen valgte {sandhed}. Det var ellers sandheden. Den må arbejde lidt på sin troværdighed.",
"Sandheden var {sandhed}. Ingen fandt den. Jeg havde ellers væddet min sidste småkage på jer."
],
"reveal_truth_everyone": [
"Svaret var {sandhed}. Alle fandt det. Jeg får åbenbart brug for bedre løgne.",
"Sandheden var {sandhed}. Enighed i sofaen. Nogen burde tage et billede.",
"[impressed] Alle valgte {sandhed}. Korrekt. Det her bliver svært at forklare som held.",
"{sandhed} var sandheden. Alle ramte rigtigt. Jeg havde ellers reserveret tid til en forklaring.",
"Alle fandt sandheden, {sandhed}. Pointtavlen bliver lige så spændende som fælles opvask."
],
"scores_intro": [
"{foerende} fører, og {sidst} ligger sidst. Pointtavlen har åbenbart fået holdninger.",
"{foerende} fører. Jubel med omtanke. Der skal stadig være plads til alle i sofaen.",
"{foerende} øverst, {sidst} nederst. Klager modtages på en serviet efter middagen.",
"{foerende} ligger først, {sidst} sidst. Jeg kontrollerede tallene med et ansvarligt nik.",
"Pointtavlen er klar. {foerende} fører. Dejligt med tal. De afbryder mig sjældent."
],
"winner": [
"{vinder} vinder med {point} point. Det skal sofaen nok høre om igen.",
"[cheering] Tillykke, {vinder}! {point} point. Æren er din. Fjernbetjeningen kræver stadig en forhandling.",
"Sejren går til {vinder}, med {point} point. Takketalen må gerne indeholde noget sandt.",
"Med {point} point vinder {vinder}. Min oplæring har tydeligvis været fuldstændig afgørende.",
"{vinder} vinder med {point} point. Præmiebudgettet gik desværre til mit meget velfortjente honorar."
],
"award_biggest_liar": [
"Største løgner, {navn}. {antal} vellykkede bedrag. Prisen overrækkes med et mistænksomt håndtryk.",
"{navn} er største løgner, med {antal} vellykkede bedrag. Tillid kan åbenbart genbruges.",
"{navn} narrede andre {antal} gange. Største løgner! Vi springer forklaringen over.",
"[impressed] Største løgner er {navn}. {antal} gange blev nogen narret. Din fantasi har fået faste kunder.",
"{antal} vellykkede bedrag. Prisen for største løgner går til {navn}. Pokalen er ægte. Tror jeg."
],
"award_truth_seeker": [
"{navn} er sandhedsjæger med {antal} rigtige gæt. En meget irriterende person at diskutere med.",
"Flest rigtige gæt, {navn}, med {antal}. Virkeligheden har åbenbart fået sin egen rådgiver.",
"Sandhedsprisen går til {navn}. {antal} rigtige gæt. Endelig kom den mærkelige viden til nytte.",
"{navn} fandt sandheden {antal} gange. Vi springer høfligt diskussionen om held over.",
"{navn} er sandhedsjæger. {antal} rigtige gæt. Den titel har vi faktisk undersøgt, før vi fandt på den."
],
"award_most_liked": [
"Hjerteprisen går til {navn}, med {antal}. Løgn med charme er åbenbart en disciplin.",
"{antal} hjerter til {navn}. Sofaens yndling. Blomsterhandleren har lige mistet en kunde.",
"{navn} får hjerteprisen med {antal}. Anbefalingen bliver mærkelig. Lyver virkelig underholdende.",
"[cheering] Flest hjerter, {navn}, med {antal}. Vi har målt din morsomhed med tommelfingre.",
"{navn} vandt {antal} hjerter. Sjældent har en bunke løgne fået så varm en modtagelse."
],
"play_again": [
"En omgang mere! Vi nulstiller pointene. Mistanken gemmer familien selv.",
"I vil spille igen. Det tager jeg som ros og et meget svagt ønske om sandhed.",
"[excited] Så tager vi en til. Nye løgne, samme sofa. Den har efterhånden hørt lidt af hvert.",
"Vi spiller igen. Jeg henter flere fakta. I kan bare genbruge jeres selvtillid.",
"En ny omgang. Jeg er glad for tilliden. Især fordi jeg tydeligvis ikke har fortjent den."
]
}
"lobby_welcome": [
"Velkommen til Fup og Fakta. Find telefonen frem. Samvittigheden kan blive i lommen.",
"Her må du lyve. Vi har endda lavet en pointtavle til det.",
"Velkommen. Her bliver familiehistorier endelig vurderet efter, hvor godt de snyder.",
"[excited] Fup og Fakta! Find telefonen frem. Jeg har allerede gjort sandheden nervøs.",
"Et spil om sandhed og løgn. Altså en familiefest med lidt tydeligere regler."
],
"lobby_join": [
"{navn} er med. Så har vi en mere at stole lidt for meget på.",
"Velkommen, {navn}. Jeg stoler på dig. Det plejer at gå galt.",
"Der kom {navn}. Endnu et navn til min kommende samling af bortforklaringer.",
"{navn}, dejligt. Jeg skriver dig på listen over mistænkeligt hyggelige mennesker.",
"[suspicious] {navn} er med. Den tavshed tager jeg som en tilståelse.",
"Plads til {navn}. Telefonen er nu officielt et redskab til familieintriger.",
"{navn}, du kom præcis i tide til at tage fejl sammen med os.",
"[impressed] {navn} fandt ind. Første opgave løst. Desværre giver den ingen point.",
"Nu er {navn} her. Så kan vi snart få sandheden nedstemt.",
"[suspicious] {navn} er landet. Jeg holder øje med dig."
],
"lobby_waiting": [
"{vip}, når du er klar, kan vi begynde. Jeg har løgne, der skal luftes.",
"Hvis vi venter længere, {vip}, bliver mine løgne til gamle familiehistorier.",
"{vip}, giv os et tegn. Helst startknappen. Røgsignaler virker dårligt i stuen.",
"Jeg er klar, {vip}. Det siger jeg også før møder. Her mener jeg det.",
"[whispers] {vip}, jeg har snart brugt min eneste replik om vejret."
],
"game_start": [
"{antal_spillere} spillere og én sandhed. Det bliver en helt almindelig familiefest.",
"{antal_spillere} spillere. Gæt sandheden, og nar de andre. Begge dele giver point.",
"{antal_spillere} spillere. Tilliden i sofaen skal nu testes med helt frivillige løgne.",
"[excited] Vi er i gang! {antal_spillere} spillere, samlet om noget mistænkeligt. Helt lovligt.",
"{antal_spillere} spillere. Point for sandheden og gode løgne. Familiefortælleren får endelig lidt anerkendelse."
],
"round1_intro": [
"Første runde. Almindelige point, ualmindeligt tvivlsomme forklaringer.",
"Vi begynder med normale point. Se det som en høflig prøve på familiens tillid.",
"Runde ét. Pointene er normale. Det er resten af det her sjældent.",
"Første runde, normale point. Jeg starter med fuld tillid. Det plejer at være en kort fornøjelse.",
"Vi starter stille med normale point. I får rigelig mulighed for at råbe senere."
],
"round2_intro": [
"Nu giver vi dobbelt point. {foerende}, din føring får brug for en sikkerhedssele.",
"Runde to, dobbelt point. Nu får selv en lille løgn lidt vokseværk.",
"Vi fordobler pointene. {foerende} fører, men sofaen har endnu ikke kronet nogen.",
"Dobbelt point! Nu er det dyrere at stole på familie og venner.",
"[excited] Runde to giver dobbelt point. Jeg føler mig allerede dobbelt så vigtig."
],
"final_intro": [
"Finale med tredobbelt point. {foerende} fører, men ingen har fået lov at holde takketale.",
"{sidst}, nu giver vi tredobbelt point. Tid til at pynte på regnskabet.",
"{foerende}, finalen giver tredobbelt point. Føringer tæller bedst, når de holder hele vejen.",
"[dramatic] Tredobbelt point i finalen. Nu kan en mavefornemmelse flytte rundt på hele stillingen.",
"Sidste runde. Tre gange pointene. Jeg har allerede øvet min overraskede stemme."
],
"category_choose": [
"{navn}, vælg en kategori. Gerne noget, du kan lyve selvsikkert om.",
"Valget er dit, {navn}. Hele sofaen får naturligvis lov at brokke sig bagefter.",
"Hvad skal vi høre om, {navn}? Du har ordet. Jeg beholder opmærksomheden.",
"{navn}, peg på en kategori. Her er mavefornemmelsen faktisk kvalificeret.",
"{navn}, hvilken kategori? Du behøver kun vælge én. Gem resten til ønskesedlen."
],
"category_timeout": [
"{navn} tog betænkningstid. Vi tog {kategori}. Så kom der da en beslutning.",
"Det blev {kategori}. {navn}, stilhed er åbenbart også en måde at vælge på.",
"[sighs] {navn}, tiden løb. Nu hedder kategorien {kategori}. Den kunne heldigvis beslutte sig.",
"{kategori} er valgt. {navn}, vi kalder det delegering. Det lyder pænere.",
"{navn}, universet har valgt {kategori}. Universet arbejder åbenbart hurtigere end vores familie."
],
"lie_start": [
"Nu gælder det {kategori}. Skriv en løgn, der kunne få sit eget leksikonopslag.",
"{kategori}. Skriv en løgn, der føles som noget, man har hørt før.",
"Skriv en løgn om {kategori}. Giv den en parkeringsvagts selvtillid.",
"{kategori} er emnet. Skriv noget opdigtet. Hunden har lovet at tage skylden.",
"Opfind et svar om {kategori}. En lille detalje kan gøre løgnen stor.",
"Vi skal digte om {kategori}. Gør gerne løgnen kedelig. Det virker overraskende godt.",
"[mischievously] Nu handler det om {kategori}. Skriv en løgn, du kunne sige helt alvorligt.",
"Skriv en løgn om {kategori}. Husets dårlige ry kan godt bruge hjælp.",
"{kategori}. Opfind et svar, som selv familiens skeptiker kunne købe.",
"{kategori}. Skriv en troværdig løgn. Du må gerne rette stavefejl i virkeligheden."
],
"lie_hurry": [
"Ti sekunder. En lille løgn er bedre end en stor tænkepause.",
"Ti sekunder tilbage. Afsted med løgnen. Den behøver kun overleve ét spørgsmål.",
"Ti sekunder. Det behøver ikke være godt. Bare forkert.",
"Ti sekunder! Selv en halvbagt løgn kan serveres med overbevisning.",
"Kun ti sekunder. Din indre forfatter må godt springe korrekturen over."
],
"lie_all_done": [
"Alle løgne er afleveret. Sikke vi kan arbejde, når sandheden bliver valgfri.",
"Alle har skrevet. Jeg gemmer bunken til næste gang, nogen kalder sig ærlige.",
"Nu har vi alle løgnene. Det er som et familiereferat, bare hurtigere.",
"[impressed] Alle er færdige. Sikke et samarbejde om at gøre hinanden mistænksomme.",
"Alle har løjet. Det gik hurtigere, end jeg havde frygtet."
],
"lie_was_truth": [
"Det var faktisk sandheden. Prøv igen, med lidt mindre ærlighed.",
"Sandheden kom ind ad bagdøren. Send den ud igen, og skriv en løgn.",
"Nogen skrev sandheden. Det er snyd. Bare den omvendte slags.",
"Det svar var sandt. En sjælden tastefejl. Prøv med en løgn.",
"Du ramte sandheden. Gem den slags talenter til gættefasen."
],
"guess_start": [
"Én af dem er sand. Held og lykke. I får brug for det.",
"Vælg det sande svar. Selvtillid er desværre en dårlig kildehenvisning.",
"Nu skal I gætte sandheden. Tænk efter, før familieanekdoterne tager over.",
"Hvilket svar er sandt? Lad maven bestemme. Den træffer jo allerede aftensmadsbeslutninger.",
"Vælg sandheden. Mine løgne er også med. De er lavet med kærlighed.",
"Find det rigtige svar. Virkeligheden har en ret løs opfattelse af, hvad der er rimeligt.",
"Ét af svarene passer. Vælg det. Resten sælger vi på næste loppemarked.",
"Vælg sandheden. Publikum gætter med. Nu kan endnu flere blande sig.",
"Find sandheden, og giv gerne sjove løgne et hjerte. Også bedragere har følelser.",
"[suspicious] Ét svar er sandt. Vælg med omhu. De andre har bare øvet sig."
],
"guess_hurry": [
"Fem sekunder. Vælg et svar. Det hjælper sjældent at stirre vredt på telefonen.",
"Fem sekunder tilbage. Din mavefornemmelse har nu sidste ord.",
"Der er fem sekunder. Tvivlen får desværre ingen point.",
"Fem sekunder! Vælg et svar, før din store overvejelse bliver en meget lille aflevering.",
"Kun fem sekunder. Sandheden venter. Den er åbenbart bedre opdraget end mig."
],
"reveal_lie_fooled": [
"{forfatter} opfandt {svar}. {ofre} købte den. Kvitteringen er desværre forsvundet.",
"{ofre} faldt for {svar}. {forfatter}, din fantasi har nu fået selskab.",
"Det var {forfatter}, der fandt på {svar}. {ofre} hoppede lige i.",
"[chuckles] {svar} var en løgn fra {forfatter}. Tak for støtten, {ofre}.",
"{forfatter} skrev {svar}, og {ofre} tog imod. Meget høfligt. Meget forkert.",
"{svar} kom fra {forfatter}. {ofre} lod sig overbevise. Selvtilliden fik åbenbart ordet først.",
"{ofre} troede på {svar}. {forfatter} har lige bevist, at en god historie kan springe køen over.",
"{forfatter}, {svar} virkede på {ofre}. Den løgn slap godt fra middagsbordet.",
"{svar}, sagde {forfatter}. Ja tak, sagde {ofre}. Og sådan fik fantasien sin belønning.",
"{svar} var en løgn fra {forfatter}. {ofre}, det bliver mellem os og hele stuen."
],
"reveal_lie_fooled_many": [
"{antal} troede på {svar}. {forfatter}, du har nok til at stifte en forening.",
"{forfatter} fik {antal} til at vælge {svar}. Sofaen har dannet fælles front mod fakta.",
"[impressed] {svar} narrede {antal}. {forfatter}, jeg bøjer mig. Mest for at gemme min jalousi.",
"{antal} hoppede på {svar}. {forfatter}, du kan starte en bølge uden at rejse dig.",
"{svar} narrede {antal}. {forfatter}, din fantasi har flere tilhængere end min madplan."
],
"reveal_lie_shared": [
"{forfattere} skrev begge {svar}. To hjerner, én fælles omvej.",
"{forfattere} leverede {svar}. To løgnere for én. Det kan tilbudsavisen lide.",
"{forfattere} fandt på {svar}. Samme løgn. Fantasien må have en kopimaskine.",
"{svar}, fra {forfattere}. En løgn med to underskrifter. Næsten klar til kommunen.",
"[suspicious] {forfattere} skrev {svar}. Jeg siger bare, at telepati burde stå i reglerne."
],
"reveal_house_lie": [
"{svar} var min løgn. Tak, {ofre}. Dejligt, at kunst bliver værdsat.",
"[mischievously] {ofre} valgte {svar}. Husets løgn. Jeg modtager gerne stående bifald fra sofaen.",
"Jeg opfandt {svar}. {ofre} troede på den. Det kalder jeg godt samarbejde.",
"{ofre}, {svar} var min. Så ved vi, hvem der bestemmer over rygterne.",
"{ofre} købte {svar}. Min opskrift. Tillid er åbenbart den hemmelige ingrediens.",
"{svar} kom fra huset. {ofre}, den forklaring kan give næste middag ekstra samtalestof.",
"{ofre} troede på {svar}. Jeg skrev den helt uden hjælp fra virkeligheden.",
"Min løgn var {svar}. {ofre}, jeg kan mærke min dårlige samvittighed tage fri.",
"{svar} snød {ofre}. Den var min. Jeg øver lige mit ydmyge nik.",
"Huset opfandt {svar}. {ofre} faldt for den. Så sparede vi penge på specialeffekter."
],
"reveal_truth_found": [
"Sandheden er {sandhed}. {navne} fandt den. Så fik virkeligheden lidt opbakning.",
"{navne} valgte sandheden, {sandhed}. Gem lige øjeblikket til næste familiediskussion.",
"[impressed] Svaret er {sandhed}. {navne} gennemskuede os. Irriterende godt arbejde.",
"{sandhed}, det er sandheden. {navne} ramte rigtigt. Meget upraktisk for løgnerne.",
"{sandhed} er svaret. {navne} har tilladelse til ét lille, bedrevidende nik.",
"{navne} ramte rigtigt med {sandhed}. Nogen har åbenbart læst bagsiden af mælkekartonen.",
"Sandheden var {sandhed}. Godt fanget, {navne}. Den viden fortjente sin egen fest.",
"{navne} valgte {sandhed}. Korrekt. Skepsissen kan lige lægge benene op.",
"{navne} fandt sandheden, {sandhed}. Mavefornemmelsen har lige fået en lønforhøjelse.",
"Svaret er {sandhed}. Point til {navne}. Jeg vidste det selvfølgelig også. Næsten."
],
"reveal_truth_nobody": [
"Svaret var {sandhed}. Ingen fandt det. Vi lader som om, det her var opvarmning.",
"Sandheden var {sandhed}. Hele sofaen tog fejl. Sikke en sjælden form for enighed.",
"[sighs] Sandheden var {sandhed}. Den stod lige der. Jeg giver virkeligheden et trøstende kram.",
"Ingen valgte {sandhed}. Det var ellers sandheden. Den må arbejde lidt på sin troværdighed.",
"Sandheden var {sandhed}. Ingen fandt den. Jeg havde ellers væddet min sidste småkage på jer."
],
"reveal_truth_everyone": [
"Svaret var {sandhed}. Alle fandt det. Jeg får åbenbart brug for bedre løgne.",
"Sandheden var {sandhed}. Enighed i sofaen. Nogen burde tage et billede.",
"[impressed] Alle valgte {sandhed}. Korrekt. Det her bliver svært at forklare som held.",
"{sandhed} var sandheden. Alle ramte rigtigt. Jeg havde ellers reserveret tid til en forklaring.",
"Alle fandt sandheden, {sandhed}. Pointtavlen bliver lige så spændende som fælles opvask."
],
"scores_intro": [
"{foerende} fører, og {sidst} ligger sidst. Pointtavlen har åbenbart fået holdninger.",
"{foerende} fører. Jubel med omtanke. Der skal stadig være plads til alle i sofaen.",
"{foerende} øverst, {sidst} nederst. Klager modtages på en serviet efter middagen.",
"{foerende} ligger først, {sidst} sidst. Jeg kontrollerede tallene med et ansvarligt nik.",
"Pointtavlen er klar. {foerende} fører. Dejligt med tal. De afbryder mig sjældent."
],
"winner": [
"{vinder} vinder med {point} point. Det skal sofaen nok høre om igen.",
"[cheering] Tillykke, {vinder}! {point} point. Æren er din. Fjernbetjeningen kræver stadig en forhandling.",
"Sejren går til {vinder}, med {point} point. Takketalen må gerne indeholde noget sandt.",
"Med {point} point vinder {vinder}. Min oplæring har tydeligvis været fuldstændig afgørende.",
"{vinder} vinder med {point} point. Præmiebudgettet gik desværre til mit meget velfortjente honorar."
],
"award_biggest_liar": [
"Største løgner, {navn}. {antal} vellykkede bedrag. Prisen overrækkes med et mistænksomt håndtryk.",
"{navn} narrede jer {antal} gange. Tjek lige jeres lommer.",
"{navn} narrede andre {antal} gange. Største løgner! Vi springer forklaringen over.",
"[impressed] Største løgner er {navn}. {antal} gange blev nogen narret. Din fantasi har fået faste kunder.",
"{antal} vellykkede bedrag. Prisen for største løgner går til {navn}. Pokalen er ægte. Tror jeg."
],
"award_truth_seeker": [
"{navn} er sandhedsjæger med {antal} rigtige gæt. En meget irriterende person at diskutere med.",
"Flest rigtige gæt, {navn}, med {antal}. Virkeligheden har åbenbart fået sin egen rådgiver.",
"Sandhedsprisen går til {navn}. {antal} rigtige gæt. Endelig kom den mærkelige viden til nytte.",
"{navn} fandt sandheden {antal} gange. Vi springer høfligt diskussionen om held over.",
"{navn} er sandhedsjæger. {antal} rigtige gæt. Den titel har vi faktisk undersøgt, før vi fandt på den."
],
"award_most_liked": [
"Hjerteprisen går til {navn}, med {antal}. Løgn med charme er åbenbart en disciplin.",
"{antal} hjerter til {navn}. Sofaens yndling. Blomsterhandleren har lige mistet en kunde.",
"Hjerteprisen går til {navn}. Folkets yndlingsløgner. Sæt det ikke på dit CV.",
"[cheering] Flest hjerter, {navn}, med {antal}. Vi har målt din morsomhed med tommelfingre.",
"{navn} vandt {antal} hjerter. Sjældent har en bunke løgne fået så varm en modtagelse."
],
"play_again": [
"En omgang mere! Vi nulstiller pointene. Mistanken gemmer familien selv.",
"I vil spille igen. Det tager jeg som ros og et meget svagt ønske om sandhed.",
"[excited] Så tager vi en til. Nye løgne, samme sofa. Den har efterhånden hørt lidt af hvert.",
"Vi spiller igen. Jeg henter flere fakta. I kan bare genbruge jeres selvtillid.",
"En ny omgang. Jeg er glad for tilliden. Især fordi jeg tydeligvis ikke har fortjent den."
]
}
+6 -1
View File
@@ -63,6 +63,8 @@ class _RoomConsumer(AsyncJsonWebsocketConsumer):
await self.send_error("bad_request", "Ugyldig besked.")
if self.room.game.version != version:
self.room.changed()
else:
self.room.handle_events() # fx "det var sandheden", der afvises men kommenteres
async def handle(self, action, content, now):
raise NotImplementedError
@@ -76,10 +78,13 @@ class HostConsumer(_RoomConsumer):
return scope_identity(self.scope) is not None
async def on_open(self):
self.room.host_opened()
await self.send_state()
async def send_state(self):
await self.send_json({"type": "state", "state": self.room.game.snapshot(time.time(), host=True)})
state = self.room.game.snapshot(time.time(), host=True)
state["narration"] = self.room.narration
await self.send_json({"type": "state", "state": state})
async def handle(self, action, content, now):
g = self.room.game
View File
+18
View File
@@ -0,0 +1,18 @@
import re
from django.core.management.base import BaseCommand
from party import tts
from party.narrator import LINES
class Command(BaseCommand):
help = "Genererer speakerens faste replikker (dem uden pladsholdere) på forhånd, så første spil ikke venter."
def handle(self, *args, **opts):
if not tts.enabled():
self.stdout.write("ElevenLabs er ikke sat op (ELEVENLABS_API_KEY/ELEVENLABS_VOICE_ID). Springer over.")
return
static = [line for variants in LINES.values() for line in variants if not re.search(r"\{\w+\}", line)]
ok = sum(1 for line in static if tts.synth_sync(line))
self.stdout.write(self.style.SUCCESS(f"{ok} af {len(static)} faste replikker ligger i cachen."))
+127
View File
@@ -0,0 +1,127 @@
"""Speakeren: oversætter spillets begivenheder til replikker fra fupogfakta/narrator/lines.json."""
import json
import random
import re
from pathlib import Path
LINES = json.loads((Path(__file__).resolve().parents[1] / "fupogfakta" / "narrator" / "lines.json")
.read_text(encoding="utf-8"))
ONES = ["nul", "én", "to", "tre", "fire", "fem", "seks", "syv", "otte", "ni", "ti", "elleve", "tolv",
"tretten", "fjorten", "femten", "seksten", "sytten", "atten", "nitten"]
TENS = {20: "tyve", 30: "tredive", 40: "fyrre", 50: "halvtreds", 60: "tres", 70: "halvfjerds", 80: "firs", 90: "halvfems"}
def number_words(n: int) -> str:
"""Danske talord, så oplæsningen ikke snubler over cifre og tusindtalspunktummer."""
n = int(n)
if n < 0:
return "minus " + number_words(-n)
if n < 20:
return ONES[n]
if n < 100:
tens, one = divmod(n, 10)
return TENS[tens * 10] if not one else f"{'en' if one == 1 else ONES[one]}og{TENS[tens * 10]}"
if n < 1000:
h, rest = divmod(n, 100)
head = "et hundrede" if h == 1 else f"{ONES[h]} hundrede"
return head if not rest else f"{head} og {number_words(rest)}"
if n < 1_000_000:
t, rest = divmod(n, 1000)
head = "et tusind" if t == 1 else f"{number_words(t)} tusind"
if not rest:
return head
return f"{head} {'og ' if rest < 100 else ''}{number_words(rest)}"
return str(n)
def join_names(names: list[str]) -> str:
names = [n for n in names if n]
if len(names) <= 1:
return "".join(names)
return ", ".join(names[:-1]) + " og " + names[-1]
def safe(text: str) -> str:
"""Spillertekst (navne, løgne) må ikke kunne sende lyd-tags til ElevenLabs."""
return re.sub(r"[\[\]{}<>]", "", str(text)).strip()
def speakable_prompt(prompt: str) -> str:
return re.sub(r"_{3,}", "blank", prompt)
class Narrator:
def __init__(self, rng: random.Random | None = None):
self.rng = rng or random.Random()
self._recent: dict[str, list[int]] = {}
def line(self, event: str, **values) -> str | None:
variants = LINES.get(event)
if not variants:
return None
recent = self._recent.setdefault(event, [])
fresh = [i for i in range(len(variants)) if i not in recent] or list(range(len(variants)))
i = self.rng.choice(fresh)
recent.append(i)
del recent[:-max(1, len(variants) // 2)] # undgå gentagelser i den seneste halvdel
filled = {k: (number_words(v) if isinstance(v, int) else safe(v)) for k, v in values.items()}
return re.sub(r"\{(\w+)\}", lambda m: filled.get(m.group(1), ""), variants[i])
# ── afsløringen ──────────────────────────────────────────────────────────
def reveal_step(self, step: dict, names: dict[str, str], n_players: int) -> str | None:
picked = [names[p] for p in step["guessed_by"] if p in names]
if step["audience_votes"]:
picked.append("publikum")
if step["is_truth"]:
if not step["guessed_by"]:
return self.line("reveal_truth_nobody", sandhed=step["text"])
if n_players >= 2 and len(step["guessed_by"]) >= n_players:
return self.line("reveal_truth_everyone", sandhed=step["text"])
return self.line("reveal_truth_found", sandhed=step["text"], navne=join_names(picked))
if not picked:
return None
if step["house"]:
return self.line("reveal_house_lie", svar=step["text"], ofre=join_names(picked))
authors = [names[a] for a in step["authors"] if a in names]
if len(authors) >= 2:
return self.line("reveal_lie_shared", svar=step["text"], forfattere=join_names(authors))
if len(step["guessed_by"]) >= 3:
return self.line("reveal_lie_fooled_many", svar=step["text"], forfatter=join_names(authors),
antal=len(step["guessed_by"]))
return self.line("reveal_lie_fooled", svar=step["text"], forfatter=join_names(authors),
ofre=join_names(picked))
# ── alt andet ────────────────────────────────────────────────────────────
def for_event(self, ev: dict, game) -> list[str]:
t = ev["type"]
vip = game.players.get(game.vip_id()) if game.vip_id() else None
if t == "host_open":
return [self.line("lobby_welcome")]
if t == "join" and ev["role"] == "player" and game.phase == "lobby":
return [self.line("lobby_join", navn=ev["name"])]
if t == "lobby_waiting" and vip:
return [self.line("lobby_waiting", vip=vip.name)]
if t == "game_start":
return [self.line("game_start", antal_spillere=ev["players"])]
if t == "round_intro":
key = ["round1_intro", "round2_intro", "final_intro"][min(ev["round"], 2)]
return [self.line(key, foerende=ev["leader"], sidst=ev["last"])]
if t == "category":
return [self.line("category_choose", navn=ev["chooser"])]
if t == "category_timeout":
return [self.line("category_timeout", navn=ev["chooser"], kategori=ev["category"])]
if t == "lie_start":
return [self.line("lie_start", kategori=ev["category"]), speakable_prompt(ev["prompt"])]
if t in ("lie_hurry", "lie_all_done", "lie_was_truth", "guess_start", "guess_hurry", "play_again"):
return [self.line(t)]
if t == "scores":
return [self.line("scores_intro", foerende=ev["leader"], sidst=ev["last"])]
if t == "final":
out = [self.line("winner", vinder=ev["winner"], point=ev["points"])]
keys = {"fooled": "award_biggest_liar", "correct": "award_truth_seeker", "likes": "award_most_liked"}
for a in ev["awards"]:
out.append(self.line(keys[a["kind"]], navn=a["name"], antal=a["count"]))
return out
return []
+136
View File
@@ -15,10 +15,18 @@ import time
from fupogfakta.engine import Game
from . import tts
from .narrator import Narrator
log = logging.getLogger(__name__)
CODE_LETTERS = "BCDFGHJKLMNPQRSTVWXZ"
IDLE_TIMEOUT = 3 * 3600
NARRATION_KEEP = 12 # så mange seneste replikker følger med i værtens snapshot
LOBBY_WAITING_AFTER = 40 # sekunder i lobbyen med nok spillere, før speakeren rykker VIP'en
REVEAL_AUDIO_WAIT = 8 # længst vi venter på lyd til afsløringen
REVEAL_MIN_STEP_MS = 3500
REVEAL_PAD_MS = 900 # luft efter hver replik i afsløringen
_rooms: dict[str, "Room"] = {}
@@ -32,6 +40,14 @@ class Room:
self.last_activity = time.time()
self._timer: asyncio.TimerHandle | None = None
self._broadcast_pending = False
self.narrator = Narrator()
self.narration: list[dict] = [] # [{id, url, ms, at, text}] til værtens storskærm
self._narr_seq = 0
self._voice_queue: asyncio.Queue | None = None
self._side_timers: list[asyncio.TimerHandle] = []
self._welcomed = False
self._last_join_line = 0.0
self._lobby_waiting_said = False
def touch(self):
self.last_activity = time.time()
@@ -39,7 +55,11 @@ class Room:
def changed(self):
"""Kald efter hver ændring: planlæg timer og send nyt snapshot til alle."""
self.touch()
self.handle_events()
self._schedule_timer()
self.broadcast()
def broadcast(self):
if not self._broadcast_pending:
self._broadcast_pending = True
asyncio.get_running_loop().create_task(self._broadcast())
@@ -78,6 +98,122 @@ class Room:
def close(self):
if self._timer:
self._timer.cancel()
for h in self._side_timers:
h.cancel()
# ── speakeren ────────────────────────────────────────────────────────────
def handle_events(self):
"""Tøm motorens begivenheder og lad speakeren reagere. Kaldes også efter afviste handlinger."""
events = self.game.drain_events()
if not tts.enabled():
return
skip_start = any(e["type"] == "play_again" for e in events)
for ev in events:
if ev["type"] == "game_start" and skip_start:
continue
if ev["type"] == "reveal":
asyncio.get_running_loop().create_task(self._narrate_reveal())
continue
if ev["type"] == "join":
now = time.time()
if now - self._last_join_line < 2.5: # otte på én gang skal ikke alle kommenteres
continue
self._last_join_line = now
self._maybe_schedule_lobby_waiting()
for text in self.narrator.for_event(ev, self.game):
self.say(text)
if ev["type"] == "lie_start":
self._at_deadline_minus(10, "lie", "lie_hurry")
elif ev["type"] == "guess_start":
self._at_deadline_minus(5, "guess", "guess_hurry")
def host_opened(self):
if tts.enabled() and not self._welcomed and self.game.phase == "lobby" and not self.game.players:
self._welcomed = True
for text in self.narrator.for_event({"type": "host_open"}, self.game):
self.say(text)
def say(self, text: str | None):
"""Læg en replik i kø. Lyden genereres med det samme, men udgives i rækkefølge."""
if not text:
return
if self._voice_queue is None:
self._voice_queue = asyncio.Queue()
asyncio.get_running_loop().create_task(self._voice_worker())
task = asyncio.get_running_loop().create_task(tts.synth(text))
self._voice_queue.put_nowait((text, task))
async def _voice_worker(self):
while True:
text, task = await self._voice_queue.get()
audio = await task
if audio:
self._publish(text, audio)
def _publish(self, text: str, audio: dict):
self._narr_seq += 1
self.narration.append({"id": self._narr_seq, "url": audio["url"], "ms": audio["ms"],
"at": int(time.time() * 1000), "text": text})
del self.narration[:-NARRATION_KEEP]
self.broadcast()
def _at_deadline_minus(self, seconds: int, phase: str, event: str):
g = self.game
if g.deadline is None:
return
ident = (g.round_idx, g.q_idx, phase)
delay = g.deadline - seconds - time.time()
if delay <= 1:
return
def fire():
if (g.round_idx, g.q_idx, g.phase) != ident:
return
if phase == "lie" and all(p.id in g.lies for p in g.players.values() if p.connected):
return
for text in self.narrator.for_event({"type": event}, g):
self.say(text)
self._side_timers.append(asyncio.get_running_loop().call_later(delay, fire))
self._side_timers = [h for h in self._side_timers if not h.cancelled()][-20:]
def _maybe_schedule_lobby_waiting(self):
g = self.game
if self._lobby_waiting_said or g.phase != "lobby" or len(g.players) < 2:
return
self._lobby_waiting_said = True
def fire():
if g.phase == "lobby" and len(g.players) >= 2:
for text in self.narrator.for_event({"type": "lobby_waiting"}, g):
self.say(text)
self._side_timers.append(asyncio.get_running_loop().call_later(LOBBY_WAITING_AFTER, fire))
async def _narrate_reveal(self):
"""Lav lyd til hvert trin, og lad afsløringens tempo følge replikkerne."""
g = self.game
ident = (g.round_idx, g.q_idx)
g.hold_reveal(time.time(), max_wait=REVEAL_AUDIO_WAIT + 2)
self.changed()
names = {p.id: p.name for p in g.players.values()}
texts = [self.narrator.reveal_step(step, names, len(g.players)) for step in g.reveal_steps]
note = (g.question or {}).get("note", "")
jobs = [tts.synth(t) if t else asyncio.sleep(0, None) for t in texts]
jobs.append(tts.synth(note) if note else asyncio.sleep(0, None))
try:
results = await asyncio.wait_for(asyncio.gather(*jobs), timeout=REVEAL_AUDIO_WAIT)
except asyncio.TimeoutError:
results = [None] * len(jobs)
if g.phase != "reveal" or (g.round_idx, g.q_idx) != ident or g.reveal_started_at is not None:
return # værten sprang over, eller tiden løb ud
steps_ms = []
for step, text, audio in zip(g.reveal_steps, texts, results[:-1]):
step["audio"] = {"url": audio["url"], "ms": audio["ms"], "text": text} if audio else None
steps_ms.append(max(REVEAL_MIN_STEP_MS, (audio["ms"] + REVEAL_PAD_MS) if audio else 0))
note_audio = results[-1]
g.reveal_note_audio = {"url": note_audio["url"], "ms": note_audio["ms"]} if note_audio else None
tail = 3000 + (note_audio["ms"] + REVEAL_PAD_MS if note_audio else 0)
g.start_reveal_timeline(time.time(), steps_ms, tail)
self.changed()
def new_code() -> str:
+35 -1
View File
@@ -317,6 +317,40 @@
}
});
['pointerdown', 'keydown', 'touchstart'].forEach(function (ev) {
window.addEventListener(ev, function () { sound.unlock(); }, { passive: true });
window.addEventListener(ev, function () { sound.unlock(); voice.unblock(); }, { passive: true });
});
/* ---------- speakeren (lydfiler fra serveren, én ad gangen) ---------- */
var voice = (WPP.voice = {
queue: [], current: null, blocked: false, onchange: null,
enqueue: function (url) { if (!url || sound.muted) return; voice.queue.push(url); if (!voice.current) voice._next(); },
now: function (urls) { voice.stop(); (urls || []).forEach(function (u) { if (u && !sound.muted) voice.queue.push(u); }); voice._next(); },
stop: function () {
voice.queue = [];
if (voice.current) { try { voice.current.pause(); } catch (e) {} voice.current = null; }
},
unblock: function () {
if (!voice.blocked) return;
voice.blocked = false;
if (voice.onchange) voice.onchange();
if (!voice.current) voice._next();
},
_next: function () {
var url = voice.queue.shift();
if (!url) { voice.current = null; return; }
var a = new Audio(url);
voice.current = a;
a.onended = a.onerror = function () { if (voice.current === a) voice._next(); };
var p = a.play();
if (p && p.catch) p.catch(function (e) {
if (voice.current !== a) return;
voice.current = null;
if (e && e.name === 'NotAllowedError') { // browseren kræver et klik før lyd
voice.queue.unshift(url);
voice.blocked = true;
if (voice.onchange) voice.onchange();
} else voice._next();
});
}
});
})();
+49 -11
View File
@@ -24,24 +24,27 @@
return (state.players || []).slice().sort(function (a, b) { return (b.score || 0) - (a.score || 0); });
}
/* Reveal-tidslinje beregnes fra snapshot: start = deadline − (trin × step_ms + 3 s) */
/* Reveal-tidslinje: serveren sender started_at og en længde pr. trin (speakerens replik styrer tempoet).
started_at = null betyder, at serveren stadig laver lyd: vis en kort "trommehvirvel". */
function revealProgress(state, u) {
var r = state.reveal || {};
var steps = r.steps || [];
var ms = r.step_ms || 4000;
var total = steps.length * ms + 3000;
var start;
var key = (state.round ? state.round.number + '.' + state.round.question_number : '') + ':' + ((state.question || {}).prompt || '');
if (state.deadline) start = state.deadline - total;
if (state.reveal && r.started_at === null) return { pending: true, done: false, i: 0, stage: -1, n: steps.length, key: key + '|pending' };
var durs = steps.map(function (s, i) { return (r.steps_ms && r.steps_ms[i]) || r.step_ms || 4000; });
var total = durs.reduce(function (a, b) { return a + b; }, 0) + (r.tail_ms == null ? 3000 : r.tail_ms);
var start;
if (r.started_at) start = r.started_at;
else if (state.deadline) start = state.deadline - total;
else {
if (!u.revealSeen[key]) u.revealSeen[key] = WPP.now();
start = u.revealSeen[key];
}
var el = Math.max(0, WPP.now() - start);
var i = Math.floor(el / ms);
var el = Math.max(0, WPP.now() - start), i = 0;
while (i < steps.length && el >= durs[i]) { el -= durs[i]; i++; }
if (i >= steps.length) return { done: true, i: steps.length, n: steps.length, key: key + '|done' };
var f = (el % ms) / ms;
var stage = f < 0.28 ? 0 : f < 0.55 ? 1 : 2;
/* svaret først, så hvem der valgte det, så stemplet (faste tider, så lange replikker ikke forsinker stemplet) */
var stage = el < Math.min(1100, durs[i] * 0.28) ? 0 : el < Math.min(2300, durs[i] * 0.55) ? 1 : 2;
return { done: false, i: i, stage: stage, n: steps.length, key: key + '|' + i + '.' + stage };
}
@@ -75,11 +78,12 @@
}).join('');
}
var skip = TIMED[state.phase] ? '<button class="hbtn" data-act="skip" title="Afslut fasen nu">Spring over »</button>' : '';
var mute = '<button class="hbtn ghost" data-act="mute" aria-pressed="' + (WPP.sound.muted ? 'true' : 'false') + '">' +
var mute = (WPP.voice.blocked ? '<button class="hbtn voice-blocked" data-act="unblock">Klik for at høre speakeren</button>' : '') +
'<button class="hbtn ghost" data-act="mute" aria-pressed="' + (WPP.sound.muted ? 'true' : 'false') + '">' +
(WPP.sound.muted ? 'Lyd fra' : 'Lyd til') + '</button>';
return '<footer class="hs-foot" data-k="foot" data-patch>' +
'<div class="pchips" data-k="chips:' + esc(chipsHtml) + '">' + chipsHtml + '</div>' +
'<div class="ctl" data-k="ctl:' + state.phase + (WPP.sound.muted ? 1 : 0) + '">' + mute + skip + '</div></footer>';
'<div class="ctl" data-k="ctl:' + state.phase + (WPP.sound.muted ? 1 : 0) + (WPP.voice.blocked ? 'b' : '') + '">' + mute + skip + '</div></footer>';
}
/* ---------- faser ---------- */
@@ -163,6 +167,10 @@
var steps = r.steps || [];
var players = WPP.playerMap(state);
var pr = revealProgress(state, u);
if (pr.pending) {
return questionBlock(state, 'small rv', '') +
'<section class="rv-wait" data-k="rvwait"><div class="drum">Lad os se, hvem der narrede hvem…</div></section>';
}
var truth = steps.filter(function (s) { return s.is_truth; })[0];
var truthShown = truth && (pr.done || (steps[pr.i] === truth && pr.stage >= 2));
var out = questionBlock(state, 'small rv', truthShown ? truth.text : '');
@@ -273,9 +281,11 @@
WPP.sound.unlock();
if (a === 'mute') {
WPP.sound.setMuted(!WPP.sound.muted);
if (WPP.sound.muted) WPP.voice.stop();
if (u.state) renderHost(root, u.state, ctx);
return;
}
if (a === 'unblock') { WPP.voice.unblock(); if (u.state) renderHost(root, u.state, ctx); return; }
if (!ctx.send) return;
if (a === 'kick') {
var nm = b.getAttribute('data-name') || '';
@@ -296,6 +306,17 @@
if (!s || !root.isConnected) return;
if (s.phase === 'reveal') {
var pr = revealProgress(s, u);
/* speakeren: hvert trins replik starter med trinnet, forklaringen efter sandheden */
if (!pr.done && !pr.pending && !(u.ctx && u.ctx.harness)) {
var vkey = pr.key.split('|')[0] + '|' + pr.i;
if (u.voiceStep !== vkey) {
u.voiceStep = vkey;
var stp = (s.reveal.steps || [])[pr.i] || {};
var urls = [stp.audio && stp.audio.url];
if (stp.is_truth && s.reveal.note_audio) urls.push(s.reveal.note_audio.url);
if (urls[0] || urls[1]) WPP.voice.now(urls);
}
}
if (pr.key !== u.revealKey) {
var prev = u.revealKey;
renderHost(root, s, u.ctx);
@@ -314,6 +335,21 @@
}
}
/* Speakerens replikker uden for afsløringen: afspil nye i rækkefølge. Ved første snapshot
(fx genindlæsning) afspilles kun det, der er sagt de sidste par sekunder. */
function queueNarration(u, state, ctx) {
var items = state.narration;
if (!items || ctx.harness) return;
if (u.narrLast == null) {
var cutoff = (state.server_time || 0) - 4000;
u.narrLast = 0;
items.forEach(function (n) { if (n.at < cutoff) u.narrLast = Math.max(u.narrLast, n.id); });
}
items.forEach(function (n) {
if (n.id > u.narrLast) { u.narrLast = n.id; WPP.voice.enqueue(n.url); }
});
}
/* ---------- offentlig render ---------- */
function renderHost(root, state, ctx) {
ctx = ctx || {};
@@ -321,6 +357,7 @@
var u = ui(root);
u.state = state; u.ctx = ctx;
bind(root);
queueNarration(u, state, ctx);
if (!root._ticking) { root._ticking = true; WPP.onTick(function () { hostTick(root); }); }
root.classList.add('host-root');
var sh = WPP.shell(root);
@@ -350,6 +387,7 @@
var conn = null;
var ctx = { send: function (m) { return conn ? conn.send(m) : false; } };
renderHost(root, { phase: 'lobby', code: code, players: [], audience_count: 0, min_players: 2, max_players: 8, join_url: location.origin + '/?code=' + code }, ctx);
WPP.voice.onchange = function () { var u = ui(root); if (u.state) renderHost(root, u.state, u.ctx); };
conn = WPP.connect('/ws/host/' + encodeURIComponent(code) + '/', {
root: root,
onMessage: function (msg) {
+6
View File
@@ -498,3 +498,9 @@ button, input { font-family: inherit; }
/* forklaringen under sandheden */
.rv-res.truth .note { margin: 18px auto 0; max-width: 1100px; font-size: clamp(18px, 1.6vw, 30px); line-height: 1.35; opacity: .85; font-weight: 500; }
/* speakeren */
.rv-wait { display: grid; place-items: center; min-height: 40vh; }
.rv-wait .drum { font-size: clamp(28px, 3.2vw, 56px); font-weight: 800; opacity: .9; animation: drumpulse .5s ease-in-out infinite alternate; }
@keyframes drumpulse { from { transform: scale(1); } to { transform: scale(1.04); } }
.hbtn.voice-blocked { background: #ffd166; color: #1b1035; font-weight: 800; animation: drumpulse .7s ease-in-out infinite alternate; }
+128
View File
@@ -0,0 +1,128 @@
import asyncio
import random
from unittest import mock
from channels.testing.websocket import WebsocketCommunicator
from django.test import SimpleTestCase, TransactionTestCase, override_settings
from fupogfakta import engine
from fupogfakta.engine import Game
from party import rooms, tts
from party.narrator import Narrator, join_names, number_words, safe, speakable_prompt
from partyhub.asgi import application
from .test_ws import HOST_HEADERS, make_bank, recv_until
class NarratorTests(SimpleTestCase):
def test_number_words(self):
cases = {0: "nul", 1: "én", 3: "tre", 21: "enogtyve", 47: "syvogfyrre", 100: "et hundrede",
1500: "et tusind fem hundrede", 10500: "ti tusind fem hundrede", 2000: "to tusind"}
for n, words in cases.items():
self.assertEqual(number_words(n), words)
def test_names_and_safety(self):
self.assertEqual(join_names(["A"]), "A")
self.assertEqual(join_names(["A", "B", "C"]), "A, B og C")
self.assertEqual(safe("Bo[laughs]"), "Bolaughs")
self.assertEqual(speakable_prompt("En flok _____ kaldes."), "En flok blank kaldes.")
def test_lines_are_filled_and_vary(self):
n = Narrator(random.Random(1))
seen = {n.line("lobby_join", navn="Sus") for _ in range(6)}
self.assertGreater(len(seen), 3)
self.assertTrue(all("Sus" in s and "{" not in s for s in seen))
self.assertNotIn("{", n.line("winner", vinder="Bo", point=10500))
def test_reveal_step_choice(self):
n = Narrator(random.Random(2))
names = {"p1": "Asger", "p2": "Sus", "p3": "Jørn"}
lie = {"text": "katte", "is_truth": False, "house": False, "authors": ["p1"], "guessed_by": ["p2"], "audience_votes": 0}
self.assertIn("Asger", n.reveal_step(lie, names, 3))
house = dict(lie, authors=[], house=True)
self.assertIn("katte", n.reveal_step(house, names, 3))
nobody = {"text": "ugler", "is_truth": True, "house": False, "authors": [], "guessed_by": [], "audience_votes": 0}
self.assertIn("ugler", n.reveal_step(nobody, names, 3))
unpicked = dict(lie, guessed_by=[])
self.assertIsNone(n.reveal_step(unpicked, names, 3))
class EngineEventTests(SimpleTestCase):
def test_events_and_reveal_hold(self):
from fupogfakta.tests.test_engine import bank
g = Game("TEST", bank(), rng=random.Random(3))
a, b = g.join("A"), g.join("B")
g.start(None, 0)
types = [e["type"] for e in g.drain_events()]
self.assertEqual(types[:4], ["join", "join", "game_start", "round_intro"])
self.assertIn("category", types)
g.tick(100) # kategorien vælges af tiden
self.assertEqual([e["type"] for e in g.drain_events()], ["category_timeout", "lie_start"])
g.submit_lie(a.id, "katte", 100)
with self.assertRaises(engine.GameError):
g.submit_lie(b.id, g.question["answer"], 100)
self.assertEqual([e["type"] for e in g.drain_events()], ["lie_was_truth"])
g.submit_lie(b.id, "hunde", 100)
truth = next(x.id for x in g.answers if x.is_truth)
g.guess(a.id, truth, 100)
g.guess(b.id, truth, 100)
self.assertEqual(g.phase, "reveal")
g.hold_reveal(100, max_wait=5)
self.assertIsNone(g.snapshot(100, host=True)["reveal"]["started_at"])
g.tick(106) # lyden kom aldrig: afsløringen starter alligevel
self.assertEqual(g.phase, "reveal")
self.assertIsNotNone(g.reveal_started_at)
g.start_reveal_timeline(106, [5000] * len(g.reveal_steps), 4000)
self.assertAlmostEqual(g.deadline, 106 + (5 * len(g.reveal_steps) + 4))
async def fake_synth(text):
return {"url": f"/tts/{abs(hash(text)) % 10**32:032d}.mp3", "ms": 2000 + len(text) * 10}
@override_settings(TRUSTED_PROXIES=["127.0.0.1"], DEV_HOST_USER=None)
class NarrationOverWebSocketTests(TransactionTestCase):
def setUp(self):
rooms.all_rooms().clear()
make_bank()
async def test_host_gets_narration_and_reveal_audio(self):
from asgiref.sync import sync_to_async
from fupogfakta.models import load_game_questions
room = rooms.create_room(await sync_to_async(load_game_questions)(), "agw", "http://testserver")
with mock.patch.object(tts, "enabled", return_value=True), mock.patch.object(tts, "synth", fake_synth):
host = WebsocketCommunicator(application, f"/ws/host/{room.code}/", headers=HOST_HEADERS)
host.scope["client"] = ("127.0.0.1", 5000)
await host.connect()
phones = []
for name in ["Asger", "Sus"]:
c = WebsocketCommunicator(application, f"/ws/play/{room.code}/")
await c.connect()
await c.send_json_to({"action": "join", "name": name})
await recv_until(c, "joined")
phones.append(c)
await phones[0].send_json_to({"action": "start"})
st = (await recv_until(host, "state", "category"))["state"]
chooser = phones[0] if st["category"]["chooser_id"] == "p1" else phones[1]
await chooser.send_json_to({"action": "choose_category", "category_id": st["category"]["options"][0]["id"]})
for i, c in enumerate(phones):
await recv_until(c, "state", "lie")
await c.send_json_to({"action": "submit_lie", "text": f"løgn {i}"})
for c in phones:
mine = (await recv_until(c, "state", "guess"))["state"]
await c.send_json_to({"action": "guess", "answer_id": mine["answers"][0]["id"]})
# Først venter afsløringen på lyden, så starter den med en længde pr. trin.
for _ in range(30):
st = (await recv_until(host, "state", "reveal"))["state"]
if st["reveal"]["started_at"]:
break
r = st["reveal"]
self.assertTrue(all(s.get("audio") for s in r["steps"]))
self.assertEqual(len(r["steps_ms"]), len(r["steps"]))
self.assertGreaterEqual(min(r["steps_ms"]), 3500)
texts = [n["text"] for n in st["narration"]]
self.assertTrue(any("Asger" in t or "Sus" in t for t in texts), texts) # join/kategori-replik
self.assertTrue(any("blank" in t for t in texts), texts) # spørgsmålet blev læst op
for c in phones:
await c.disconnect()
await host.disconnect()
+94
View File
@@ -0,0 +1,94 @@
"""Oplæsning med ElevenLabs, cachet på disk.
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.
"""
import asyncio
import hashlib
import json
import logging
import urllib.error
import urllib.request
from pathlib import Path
from django.conf import settings
log = logging.getLogger(__name__)
API = "https://api.elevenlabs.io/v1/text-to-speech/{voice}?output_format=mp3_44100_128"
BITRATE = 128_000
_inflight: dict[str, asyncio.Future] = {}
def enabled() -> bool:
return bool(settings.ELEVENLABS_API_KEY and settings.ELEVENLABS_VOICE_ID)
def _digest(text: str) -> str:
raw = f"{settings.ELEVENLABS_MODEL}|{settings.ELEVENLABS_VOICE_ID}|{text}"
return hashlib.sha256(raw.encode()).hexdigest()[:32]
def cache_path(digest: str) -> Path:
return Path(settings.TTS_CACHE_DIR) / f"{digest}.mp3"
def _result(digest: str) -> dict:
size = cache_path(digest).stat().st_size
return {"url": f"/tts/{digest}.mp3", "ms": int(size * 8 * 1000 / BITRATE)}
def _fetch(text: str, path: Path) -> None:
body = json.dumps({"text": text, "model_id": settings.ELEVENLABS_MODEL, "language_code": "da"}).encode()
req = urllib.request.Request(API.format(voice=settings.ELEVENLABS_VOICE_ID), 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()
path.parent.mkdir(parents=True, exist_ok=True)
tmp = path.with_suffix(".part")
tmp.write_bytes(data)
tmp.replace(path)
def synth_sync(text: str) -> dict | None:
"""Til management-kommandoer: generér (eller find i cache) synkront."""
if not enabled() or not text.strip():
return None
digest = _digest(text)
if not cache_path(digest).exists():
try:
_fetch(text, cache_path(digest))
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:
if not enabled() or not text.strip():
return None
digest = _digest(text)
if cache_path(digest).exists():
return _result(digest)
fut = _inflight.get(digest)
if fut is None:
fut = asyncio.get_running_loop().create_task(asyncio.to_thread(_fetch, text, cache_path(digest)))
_inflight[digest] = fut
fut.add_done_callback(lambda _f: _inflight.pop(digest, None))
try:
await asyncio.shield(fut)
except Exception as e: # noqa: BLE001
log.warning("TTS fejlede for %r: %s", text[:60], _describe(e))
return None
return _result(digest)
def _describe(e: Exception) -> str:
if isinstance(e, urllib.error.HTTPError):
try:
return f"HTTP {e.code}: {e.read()[:200].decode('utf-8', 'replace')}"
except Exception: # noqa: BLE001
return f"HTTP {e.code}"
return repr(e)
+1
View File
@@ -10,5 +10,6 @@ urlpatterns = [
path("host/new", views.host_new, name="host_new"),
path("host/<str:code>/", views.host, name="host"),
path("qr/<str:code>.svg", views.qr, name="qr"),
path("tts/<str:digest>.mp3", views.tts_audio, name="tts"),
path("healthz", views.healthz, name="healthz"),
]
+12 -1
View File
@@ -4,12 +4,13 @@ import re
import segno
from asgiref.sync import sync_to_async
from django.conf import settings
from django.http import Http404, HttpResponse, HttpResponseForbidden
from django.http import FileResponse, Http404, HttpResponse, HttpResponseForbidden
from django.shortcuts import redirect, render
from django.views.decorators.http import require_GET, require_POST
from fupogfakta.models import load_game_questions
from . import tts
from .rooms import create_room, get_room
CODE_RE = re.compile(r"^[A-Za-z]{4}$")
@@ -66,3 +67,13 @@ def qr(request, code):
@require_GET
def healthz(request):
return HttpResponse("ok", content_type="text/plain")
@require_GET
def tts_audio(request, digest):
path = tts.cache_path(digest)
if not re.fullmatch(r"[0-9a-f]{32}", digest) or not path.exists():
raise Http404
resp = FileResponse(path.open("rb"), content_type="audio/mpeg")
resp["Cache-Control"] = "public, max-age=31536000, immutable"
return resp
+6
View File
@@ -26,6 +26,12 @@ SESSION_COOKIE_SECURE = CSRF_COOKIE_SECURE = PUBLIC_URL.startswith("https://")
# Headerne stoles kun på, når forespørgslen kommer fra disse adresser.
TRUSTED_PROXIES = [h for h in env("TRUSTED_PROXIES", "127.0.0.1").split(",") if h]
HOST_ADMIN_GROUP = env("HOST_ADMIN_GROUP", "authentik Admins")
# Speaker (ElevenLabs). Uden nøgle kører spillet uden stemme.
ELEVENLABS_API_KEY = env("ELEVENLABS_API_KEY", "")
ELEVENLABS_VOICE_ID = env("ELEVENLABS_VOICE_ID", "")
ELEVENLABS_MODEL = env("ELEVENLABS_MODEL", "eleven_v4")
TTS_CACHE_DIR = env("TTS_CACHE_DIR", str(BASE_DIR / "tts-cache"))
# Kun til lokal udvikling: lader som om denne bruger er logget ind som vært.
DEV_HOST_USER = env("DEV_HOST_USER") if DEBUG else None