Udbyder-agnostisk spilhjerne, rummets mikrofon og rigtig musik
CI / test (push) Failing after 39s

Hjernen (party/brain) skriver kommentarer til løgnene, dømmer løgne
(sandheden eller upassende), personlige drillerier og en slut-anmeldelse,
alt i baggrunden med faste replikker som fallback. Udbyderen vælges med
BRAIN_PROVIDER: anthropic (Claude), openai (OpenAI-kompatibel, fx Codex)
eller ollama (fx Gemma). Rummets mikrofon er eksperimentel: storskærmen
sender korte lydstumper til talegenkendelse (STT_PROVIDER), og kun et kort
resumé af latter og udsagn huskes til hjernen. Musikken er nu lavet med
en musikmodel.

Claude-Session: https://claude.ai/code/session_01JmhJG6JnhXfzNiKv8f2vHu
This commit is contained in:
agw
2026-10-01 21:59:50 +02:00
parent 16054a9b67
commit d149aa323e
22 changed files with 772 additions and 8 deletions
+15 -1
View File
@@ -127,6 +127,7 @@ class Game:
# (tid udløbet, alle har svaret, kategori valgt). Rummet sætter den ud fra talens længde.
self.hold_until: float | None = None
self.chosen_category = None
self.lie_deadline: float | None = None
# "Lyv for mig": forslag givet pr. spiller, og hvem der afleverede et uændret forslag (halve point)
self.suggestions: dict[str, set[str]] = {}
self.assisted: set[str] = set()
@@ -365,7 +366,7 @@ class Game:
def _start_lie(self, now: float):
self.phase = "lie"
self.deadline = now + LIE_SECONDS
self.deadline = self.lie_deadline = now + LIE_SECONDS
self._emit("lie_start", category=self.question["category"], prompt=self.question["prompt"])
self._changed()
@@ -396,9 +397,22 @@ class Game:
self.lies[member_id] = text
if normalize(text) in self.suggestions.get(member_id, ()):
self.assisted.add(member_id) # husets forslag: halve point for at narre
self._emit("lie_submitted", player=member_id, text=text)
self._changed()
self._maybe_finish_early(now)
def reject_lie(self, member_id: str, text: str) -> bool:
"""Dommeren afviste løgnen (sandheden eller upassende): spilleren skal skrive en ny."""
if self.phase != "lie" or self.lies.get(member_id) != text:
return False
del self.lies[member_id]
self.assisted.discard(member_id)
# Var fasen sat til at slutte tidligt, fordi alle havde løjet, får spilleren sin tid tilbage.
if self.deadline is not None and self.lie_deadline and self.deadline < self.lie_deadline:
self.deadline = self.lie_deadline
self._changed()
return True
def lie_for_me(self, member_id: str) -> str:
self._require_phase("lie")
self._require_player(member_id)
+1
View File
@@ -0,0 +1 @@
from .game import GameBrain, get_brain # noqa: F401
+109
View File
@@ -0,0 +1,109 @@
"""Spilhjernen: kommentarer, dommer, drillerier og slut-anmeldelse. Udbyder-agnostisk (se providers.py).
Alt her er valgfrit og asynkront. Spillet kalder hjernen i baggrunden og bruger svaret, hvis det
kommer i tide; ellers bruges de faste replikker. Hjernen ser kun spillets egne data og et kort
resumé af aftenen (rummets hukommelse), aldrig lyd.
"""
from __future__ import annotations
import json
from functools import lru_cache
from django.conf import settings
from .providers import Provider, build_provider
SYSTEM = """Du er speakeren i "Fup og Fakta", et dansk familie-partyspil i stil med Fibbage fra Jackbox.
Spillet: et kuriøst, sandt faktum vises med et hul. Alle skriver en troværdig løgn. Løgnene blandes med
sandheden, og man skal gætte sandheden. Man får point for at gætte rigtigt og for at narre andre.
Din stil: kæk, tør og kærligt drillende, som Cookie Masterson fra Fibbage, men med dansk humor.
Du driller med varme, aldrig ondt. Spillerne er familie og venner, også børn fra 10 år.
Regler for alt, du skriver:
- Dansk, mundret, skrevet til at blive LÆST HØJT af en stemme. Korte sætninger.
- Aldrig grimt, aldrig om udseende, køn, alder, krop eller lignende. Intet groft, intet om alkohol.
- Ingen tankestreger (hverken — eller - som indskud). Brug komma eller punktum.
- Skriv tal med bogstaver ("tre gange", ikke "3 gange").
- Én pointe pr. replik. Konkret frem for abstrakt. Hellere kort og skarp end lang og forklarende.
- Gode eksempler på tonen: "Velkommen, Sus. Jeg stoler på dig. Det plejer at gå galt."
"Hele sofaen tog fejl. Sikke en sjælden form for enighed." "Ti sekunder. Det behøver ikke være godt. Bare forkert."
- Brug gerne aftenens hukommelse til en pointe, der bygger videre på noget, der er sket tidligere."""
def _schema(props: dict, required: list[str] | None = None) -> dict:
return {"type": "object", "properties": props, "required": required or list(props), "additionalProperties": False}
COMMENTS = _schema({"comments": {"type": "array", "items": _schema({"id": {"type": "string"}, "line": {"type": "string"}})}})
JUDGE = _schema({"is_truth": {"type": "boolean"}, "inappropriate": {"type": "boolean"}, "message": {"type": "string"}})
TEASES = _schema({"teases": {"type": "array", "items": _schema({"player_id": {"type": "string"}, "text": {"type": "string"}})}})
REVIEW = _schema({"lines": {"type": "array", "items": _schema({"player_id": {"type": "string"}, "line": {"type": "string"}})}})
class GameBrain:
def __init__(self, provider: Provider):
self.provider = provider
async def _ask(self, task: str, payload: dict, schema: dict, *, max_tokens: int, timeout: float):
user = f"{task}\n\nData (JSON):\n{json.dumps(payload, ensure_ascii=False)}"
return await self.provider.complete_json(SYSTEM, user, schema, max_tokens=max_tokens, timeout=timeout)
async def comment_answers(self, question: dict, answers: list[dict], memory: list[str]) -> dict[str, str]:
"""{answer_id: kommentar}. answers: [{id, text, kind}] med kind = løgn | husets løgn | sandheden."""
res = await self._ask(
"Skriv én kort kommentar (højst tolv ord) til HVERT svar. Kommentaren læses op, lige når svaret "
"afsløres som løgn eller sandhed. Kommentér selve svaret, ikke hvem der faldt for det (det ved du "
"ikke endnu), og nævn ikke hvem der skrev det. Til sandheden: reagér på hvor vildt faktummet er.",
{"spørgsmål": question.get("prompt"), "sandheden": question.get("answer"),
"forklaring": question.get("note", ""), "svar": answers, "aftenens_hukommelse": memory[-15:]},
COMMENTS, max_tokens=900, timeout=settings.BRAIN_TIMEOUT_SLOW)
if not res:
return {}
ids = {a["id"] for a in answers}
return {c["id"]: c["line"].strip() for c in res.get("comments", []) if c.get("id") in ids and c.get("line")}
async def judge_lie(self, question: dict, lie: str) -> dict | None:
"""{is_truth, inappropriate, message} eller None, hvis dommeren ikke svarede."""
return await self._ask(
"En spiller har afleveret en løgn. Afgør to ting. is_truth: er løgnen reelt sandheden (samme betydning, "
"synonym, bøjning, stavefejl eller omskrivning af sandheden eller en af dens stavemåder)? En anden, "
"forkert ting er IKKE sandheden. inappropriate: er løgnen upassende for et familiespil med børn "
"(grov, seksuel, nedsættende, voldelig)? Fjollet og frækt-sjovt er fint. message: hvis en af dem er "
"sand, en kort, venlig besked til spilleren på dansk om at skrive en ny løgn; ellers tom streng.",
{"spørgsmål": question.get("prompt"), "sandheden": question.get("answer"),
"andre_stavemåder": question.get("alternatives", []), "løgn": lie},
JUDGE, max_tokens=200, timeout=settings.BRAIN_TIMEOUT_FAST)
async def teases(self, victims: list[dict], memory: list[str]) -> dict[str, str]:
"""{player_id: drilleri}. victims: [{player_id, name, valgte, skrevet_af}]."""
res = await self._ask(
"Disse spillere er lige faldet for en løgn. Skriv en meget kort drilleri-replik (højst seks ord) til "
"hver, som vises og læses op på deres egen telefon. Personlig og kærlig, gerne med navnet og gerne "
"med en henvisning til aftenens hukommelse, hvis det passer.",
{"narret": victims, "aftenens_hukommelse": memory[-15:]},
TEASES, max_tokens=300, timeout=settings.BRAIN_TIMEOUT_FAST)
if not res:
return {}
ids = {v["player_id"] for v in victims}
return {t["player_id"]: t["text"].strip() for t in res.get("teases", []) if t.get("player_id") in ids and t.get("text")}
async def final_review(self, players: list[dict], memory: list[str]) -> list[tuple[str, str]]:
"""[(player_id, replik)] i rækkefølge efter placering."""
res = await self._ask(
"Spillet er slut. Skriv én personlig anmeldelse (højst femten ord) til hver spiller, ud fra deres "
"placering, statistik og aftenens hukommelse. Kærligt drillende, gerne med et konkret minde fra aftenen.",
{"spillere": players, "aftenens_hukommelse": memory[-30:]},
REVIEW, max_tokens=900, timeout=settings.BRAIN_TIMEOUT_SLOW)
if not res:
return []
ids = {p["player_id"] for p in players}
return [(x["player_id"], x["line"].strip()) for x in res.get("lines", []) if x.get("player_id") in ids and x.get("line")]
@lru_cache(maxsize=1)
def get_brain() -> GameBrain | None:
provider = build_provider(settings.BRAIN_PROVIDER, settings.BRAIN_MODEL, settings.BRAIN_API_KEY,
settings.BRAIN_BASE_URL, settings.BRAIN_EFFORT)
return GameBrain(provider) if provider else None
+124
View File
@@ -0,0 +1,124 @@
"""Sprogmodel-udbydere bag ét fælles interface: `await provider.complete_json(system, user, schema)`.
Spillet kender kun dette interface, så modellen kan skiftes med miljøvariabler:
BRAIN_PROVIDER=anthropic BRAIN_MODEL=claude-opus-5 BRAIN_API_KEY=...
BRAIN_PROVIDER=openai BRAIN_MODEL=<model> BRAIN_API_KEY=... (OpenAI/Codex, vLLM, LM Studio, Ollama /v1)
BRAIN_PROVIDER=ollama BRAIN_MODEL=gemma4:12b-it-qat BRAIN_BASE_URL=http://<ollama>:11434
Alle returnerer et dict efter skemaet, eller None ved timeout/fejl/afvisning. Spillet falder så tilbage
til de faste replikker; en model må aldrig kunne stoppe et spil.
"""
from __future__ import annotations
import asyncio
import json
import logging
import urllib.error
import urllib.request
log = logging.getLogger(__name__)
class Provider:
name = "base"
def __init__(self, model: str, api_key: str = "", base_url: str = "", effort: str = "low"):
self.model, self.api_key, self.base_url, self.effort = model, api_key, base_url.rstrip("/"), effort
async def complete_json(self, system: str, user: str, schema: dict, *, max_tokens: int = 1500,
timeout: float = 8.0) -> dict | None:
try:
return await asyncio.wait_for(self._complete(system, user, schema, max_tokens, timeout), timeout)
except asyncio.TimeoutError:
log.info("%s: timeout efter %.1fs", self.name, timeout)
except Exception: # noqa: BLE001 — modellen må aldrig vælte spillet
log.exception("%s: kald fejlede", self.name)
return None
async def _complete(self, system, user, schema, max_tokens, timeout) -> dict | None:
raise NotImplementedError
class AnthropicProvider(Provider):
"""Claude via den officielle SDK med struktureret output og server-side fallback ved afvisning."""
name = "anthropic"
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
import anthropic # kun nødvendig, når denne udbyder er valgt
self._anthropic = anthropic
self.client = anthropic.AsyncAnthropic(api_key=self.api_key or None, max_retries=1)
async def _complete(self, system, user, schema, max_tokens, timeout):
resp = await self.client.with_options(timeout=timeout).beta.messages.create(
model=self.model,
max_tokens=max_tokens,
betas=["server-side-fallback-2026-07-01"],
fallbacks="default",
system=[{"type": "text", "text": system, "cache_control": {"type": "ephemeral"}}],
output_config={"effort": self.effort, "format": {"type": "json_schema", "schema": schema}},
messages=[{"role": "user", "content": user}],
)
if resp.stop_reason == "refusal":
log.info("anthropic: afvist (%s)", getattr(resp.stop_details, "category", None))
return None
text = next((b.text for b in resp.content if b.type == "text"), None)
return json.loads(text) if text else None
def _post_json(url: str, body: dict, headers: dict, timeout: float) -> dict:
req = urllib.request.Request(url, json.dumps(body).encode(), {"Content-Type": "application/json", **headers})
with urllib.request.urlopen(req, timeout=timeout) as r:
return json.load(r)
class OpenAICompatibleProvider(Provider):
"""Alt der taler OpenAI's chat completions: OpenAI/Codex, vLLM, LM Studio, Ollamas /v1 m.fl."""
name = "openai"
async def _complete(self, system, user, schema, max_tokens, timeout):
url = (self.base_url or "https://api.openai.com/v1") + "/chat/completions"
headers = {"Authorization": f"Bearer {self.api_key}"} if self.api_key else {}
body = {"model": self.model, "max_tokens": max_tokens,
"messages": [{"role": "system", "content": system}, {"role": "user", "content": user}],
"response_format": {"type": "json_schema",
"json_schema": {"name": "svar", "schema": schema, "strict": True}}}
try:
data = await asyncio.to_thread(_post_json, url, body, headers, timeout)
except urllib.error.HTTPError as e:
if e.code not in (400, 422):
raise
# Ældre servere kender ikke json_schema: bed om JSON og læg skemaet i prompten.
body["response_format"] = {"type": "json_object"}
body["messages"][0]["content"] += "\n\nSvar kun med JSON efter dette skema:\n" + json.dumps(schema)
data = await asyncio.to_thread(_post_json, url, body, headers, timeout)
return json.loads(data["choices"][0]["message"]["content"])
class OllamaProvider(Provider):
"""Ollamas eget API: skema-styret output og ingen 'tænkning' (Gemma tænker ellers i minutter)."""
name = "ollama"
async def _complete(self, system, user, schema, max_tokens, timeout):
url = (self.base_url or "http://localhost:11434") + "/api/chat"
body = {"model": self.model, "stream": False, "think": False, "format": schema, "keep_alive": "30m",
"options": {"num_predict": max_tokens, "temperature": 0.8},
"messages": [{"role": "system", "content": system}, {"role": "user", "content": user}]}
data = await asyncio.to_thread(_post_json, url, body, {}, timeout)
return json.loads(data["message"]["content"])
PROVIDERS = {"anthropic": AnthropicProvider, "openai": OpenAICompatibleProvider, "ollama": OllamaProvider}
def build_provider(kind: str, model: str, api_key: str = "", base_url: str = "", effort: str = "low") -> Provider | None:
cls = PROVIDERS.get((kind or "").lower())
if not cls or not model:
return None
return cls(model, api_key=api_key, base_url=base_url, effort=effort)
+3
View File
@@ -7,6 +7,7 @@ from channels.generic.websocket import AsyncJsonWebsocketConsumer
from fupogfakta.engine import GameError
from . import stt
from .auth import scope_identity
from .rooms import get_room
@@ -85,6 +86,7 @@ class HostConsumer(_RoomConsumer):
state = self.room.game.snapshot(time.time(), host=True)
state["narration"] = self.room.narration
state["phase_key"] = self.room.game.phase_key()
state["mic"] = {"on": self.room.mic_on(), "laughs": self.room.mic_laughs, "available": stt.enabled()}
await self.send_json({"type": "state", "state": state})
async def handle(self, action, content, now):
@@ -154,6 +156,7 @@ class PlayConsumer(_RoomConsumer):
elif action == "choose_category":
g.choose_category(mid, int(content["category_id"]), now)
elif action == "submit_lie":
self.room.prepare_judging()
g.submit_lie(mid, str(content.get("text", "")), now)
elif action == "lie_for_me":
await self.send_json({"type": "suggestion", "text": g.lie_for_me(mid)})
+24 -1
View File
@@ -81,10 +81,12 @@ class Narrator:
return capitalize(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:
def reveal_step(self, step: dict, names: dict[str, str], n_players: int, comment: str | None = None) -> str | None:
picked = [names[p] for p in step["guessed_by"] if p in names]
if step["audience_votes"]:
picked.append("publikum")
if comment:
return self._reveal_with_comment(step, names, picked, n_players, comment)
if step["is_truth"]:
if not step["guessed_by"]:
return self.line("reveal_truth_nobody", sandhed=step["text"])
@@ -104,6 +106,27 @@ class Narrator:
return self.line("reveal_lie_fooled", svar=step["text"], forfatter=join_names(authors),
ofre=join_names(picked))
def _reveal_with_comment(self, step, names, picked, n_players, comment) -> str | None:
"""Hjernen har skrevet pointen; her lægges fakta (hvem skrev, hvem faldt) foran."""
comment = re.sub(r"[{}<>]", "", comment).strip()
svar = safe(step["text"])
if step["is_truth"]:
if not step["guessed_by"]:
head = f"Sandheden er {svar}, og ingen fandt den."
elif n_players >= 2 and len(step["guessed_by"]) >= n_players:
head = f"Alle fandt sandheden, {svar}."
else:
head = f"Sandheden er {svar}. {join_names(picked)} fandt den."
return capitalize(f"{head} {comment}")
if not picked:
return None
if step["house"]:
return capitalize(f"{svar}. Husets løgn! {join_names(picked)} faldt for den. {comment}")
authors = join_names([names[a] for a in step["authors"] if a in names])
if len(step["authors"]) >= 2:
return capitalize(f"{svar}. Det skrev både {authors}, og {join_names(picked)} hoppede i. {comment}")
return capitalize(f"{svar}. Det skrev {authors}, og {join_names(picked)} hoppede i. {comment}")
# ── alt andet ────────────────────────────────────────────────────────────
def for_event(self, ev: dict, game) -> list[str]:
t = ev["type"]
+174 -4
View File
@@ -16,8 +16,9 @@ import time
from fupogfakta import engine
from fupogfakta.engine import Game
from . import tts
from . import stt, tts
from .assets import tease_images
from .brain import get_brain
from .narrator import TEASES, Narrator
log = logging.getLogger(__name__)
@@ -35,6 +36,9 @@ PENDING_HOLD_MAX_AGE = 8 # en replik, der stadig genereres efter så længe,
TEASE_CHANCE = 0.45 # sandsynlighed for at en narret spillers telefon bliver drillet
TEASES_PER_QUESTION = 2
TEASE_AT_MS = 2400 # når stemplet lander i et trin (svar → hvem → stempel)
MEMORY_KEEP = 60 # linjer i rummets hukommelse (til hjernen)
COMMENT_WAIT = 4 # så længe afsløringen højst venter på hjernens kommentarer
MIC_ACTIVE_FOR = 25 # mikrofonen regnes som tændt så længe efter sidste lydstump
_rooms: dict[str, "Room"] = {}
@@ -60,6 +64,15 @@ class Room:
self.speech_end = 0.0
self._phase_key = game.phase_key()
self._pending: dict[str, list[float]] = {} # fasenøgle -> starttider for replikker under generering
# Hjernen og hukommelsen
self.brain = get_brain()
self.memory: list[str] = []
self._comment_tasks: dict[tuple, asyncio.Task] = {}
self._tease_texts: dict[str, str] = {}
# Rummets mikrofon (eksperimentel)
self.mic_last = 0.0
self.mic_laughs = 0
self._mic_busy = False
def touch(self):
self.last_activity = time.time()
@@ -137,6 +150,8 @@ class Room:
def handle_events(self):
"""Tøm motorens begivenheder og lad speakeren reagere. Kaldes også efter afviste handlinger."""
events = self.game.drain_events()
for ev in events:
self._think(ev)
if not tts.enabled():
if any(e["type"] == "reveal" for e in events):
self._schedule_teases() # uden stemme starter afsløringen med det samme
@@ -164,6 +179,134 @@ class Room:
elif ev["type"] == "guess_start":
self._at_deadline_minus(5, "guess", "guess_hurry")
# ── hjernen og hukommelsen ───────────────────────────────────────────────
def remember(self, line: str):
self.memory.append(line)
del self.memory[:-MEMORY_KEEP]
def _hold_for(self, coro):
"""Kør en baggrundsopgave, som den aktuelle skærm skal vente på (fx dommeren)."""
key, started = self.game.phase_key(), time.time()
self._pending.setdefault(key, []).append(started)
self._update_hold()
async def run():
try:
await coro
finally:
lst = self._pending.get(key, [])
if started in lst:
lst.remove(started)
if not lst:
self._pending.pop(key, None)
self._update_hold()
self._schedule_timer()
asyncio.get_running_loop().create_task(run())
def prepare_judging(self):
"""Kaldes før en løgn afleveres: hold skærmen et øjeblik, så dommeren når at se den sidste løgn."""
if self.brain:
self.game.hold_until = max(self.game.hold_until or 0, time.time() + 1.0)
def _think(self, ev: dict):
g, t = self.game, ev["type"]
names = {p.id: p.name for p in g.players.values()}
if t == "lie_submitted" and self.brain:
self._hold_for(self._judge(ev["player"], ev["text"], dict(g.question or {})))
elif t == "guess_start" and self.brain and g.question:
answers = [{"id": a.id, "text": a.text,
"kind": "sandheden" if a.is_truth else "husets løgn" if a.house else "løgn"} for a in g.answers]
task = asyncio.get_running_loop().create_task(
self.brain.comment_answers(dict(g.question), answers, list(self.memory)))
self._comment_tasks = {(g.round_idx, g.q_idx): task}
elif t == "reveal":
for st in g.reveal_steps:
fooled = [names[p] for p in st["guessed_by"] if p in names]
if st["is_truth"]:
self.remember(f"Sandheden var '{st['text']}' ({g.question['prompt']}). "
+ (f"{', '.join(fooled)} fandt den." if fooled else "Ingen fandt den."))
elif fooled:
who = "husets løgn" if st["house"] else f"{', '.join(names.get(a, '?') for a in st['authors'])}s løgn"
self.remember(f"{', '.join(fooled)} faldt for {who} '{st['text']}'.")
elif t == "scores":
self.remember(f"Efter runden fører {ev['leader']}, og {ev['last']} ligger sidst.")
elif t == "final" and self.brain:
players = [{"player_id": p.id, "navn": p.name, "plads": i + 1, "point": p.score, "narrede_andre": p.fooled,
"fandt_sandheden": p.correct, "hjerter": p.likes}
for i, p in enumerate(sorted(g.players.values(), key=lambda p: -p.score))]
asyncio.get_running_loop().create_task(self._final_review(players))
async def _judge(self, pid: str, text: str, question: dict):
verdict = await self.brain.judge_lie(question, text)
if not verdict or not (verdict.get("is_truth") or verdict.get("inappropriate")):
return
if not self.game.reject_lie(pid, text):
return
msg = (verdict.get("message") or "").strip() or (
"Det er faktisk sandheden! Find på en løgn i stedet." if verdict.get("is_truth")
else "Den løgn er lidt for meget til familiehygge. Prøv en anden.")
for conn in list(self.connections):
if getattr(conn, "member_id", None) == pid:
await conn.send_error("lie_is_truth" if verdict.get("is_truth") else "lie_invalid", msg)
if verdict.get("is_truth") and tts.enabled():
for line in self.narrator.for_event({"type": "lie_was_truth"}, self.game):
self.say(line)
self.changed()
async def _final_review(self, players: list[dict]):
lines = await self.brain.final_review(players, list(self.memory))
for _pid, line in lines:
self.say(line)
async def _comments_for_current(self) -> dict:
g = self.game
task = self._comment_tasks.get((g.round_idx, g.q_idx))
if not task:
return {}
try:
return await asyncio.wait_for(asyncio.shield(task), timeout=COMMENT_WAIT)
except (asyncio.TimeoutError, Exception): # noqa: BLE001
return {}
# ── rummets mikrofon (eksperimentel) ─────────────────────────────────────
def mic_on(self) -> bool:
return time.time() - self.mic_last < MIC_ACTIVE_FOR
def _what_is_on_screen(self) -> str:
g = self.game
if g.phase == "reveal" and g.reveal_started_at:
el = (time.time() - g.reveal_started_at) * 1000
for st, ms in zip(g.reveal_steps, g.reveal_steps_ms):
if el < ms:
return f"mens '{st['text']}' blev afsløret"
el -= ms
return "under opsummeringen"
return {"lobby": "i lobbyen", "category": "mens kategorien blev valgt", "intro": "under kategori-introen",
"lie": "mens der blev skrevet løgne", "guess": "mens der blev gættet", "scores": "ved pointtavlen",
"final": "ved slutresultatet"}.get(g.phase, "")
async def hear(self, audio: bytes, content_type: str) -> dict:
"""En lydstump fra storskærmens mikrofon. Lyden gemmes aldrig; kun et kort resumé i hukommelsen."""
self.mic_last = time.time()
if self._mic_busy: # er vi bagud, springer vi stumper over i stedet for at hobe dem op
return {"skipped": True}
self._mic_busy = True
try:
res = await stt.transcribe(audio, content_type)
finally:
self._mic_busy = False
if not res:
return {"heard": False}
where = self._what_is_on_screen()
if res["laughter"]:
self.mic_laughs += 1
self.remember(f"Rummet grinede højt {where}.")
text = res["text"]
if len(text.split()) >= 3:
self.remember(f"Nogen i rummet sagde {where}: \"{text[:140]}\"")
self.broadcast()
return {"heard": True, "laughter": res["laughter"]}
def host_opened(self):
if tts.enabled() and not self._welcomed and self.game.phase == "lobby" and not self.game.players:
self._welcomed = True
@@ -247,10 +390,16 @@ class Room:
"""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 + max(0.0, self.speech_end - time.time()))
g.hold_reveal(time.time(), max_wait=COMMENT_WAIT + REVEAL_AUDIO_WAIT + 2 + max(0.0, self.speech_end - time.time()))
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]
if self.brain:
tease_job = asyncio.get_running_loop().create_task(self._brain_teases(names))
comments = await self._comments_for_current()
else:
tease_job, comments = None, {}
texts = [self.narrator.reveal_step(step, names, len(g.players), comments.get(step["answer_id"]))
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))
@@ -273,10 +422,31 @@ class Room:
# men opsummeringen skal have sin fulde tid, efter at speakeren er færdig.
truth_ms = next((ms for st, ms in zip(g.reveal_steps, steps_ms) if st["is_truth"]), 0)
tail = engine.REVEAL_TAIL_MS + max(0, (note_audio["ms"] + REVEAL_PAD_MS - truth_ms) if note_audio else 0)
if tease_job:
try:
await asyncio.wait_for(asyncio.shield(tease_job), timeout=0.1)
except (asyncio.TimeoutError, Exception): # noqa: BLE001
pass
g.start_reveal_timeline(time.time(), steps_ms, tail)
self.changed()
self._schedule_teases()
async def _brain_teases(self, names: dict):
"""Personlige drillerier til de narrede; lyden laves med det samme, så den er klar, når stemplet lander."""
g = self.game
victims = []
for st in g.reveal_steps:
if st["is_truth"]:
continue
author = "huset" if st["house"] else ", ".join(names.get(a, "?") for a in st["authors"])
victims += [{"player_id": p, "name": names[p], "valgte": st["text"], "skrevet_af": author}
for p in st["guessed_by"] if p in names]
if not victims:
return
self._tease_texts = await self.brain.teases(victims, list(self.memory))
for text in self._tease_texts.values():
asyncio.get_running_loop().create_task(tts.synth(text))
# ── drillerier på telefonen ──────────────────────────────────────────────
def _schedule_teases(self):
"""Når stemplet "LØGN" lander på TV'et, kan den narrede spillers telefon sige buh."""
@@ -298,7 +468,7 @@ class Room:
g = self.game
if g.phase != "reveal" or (g.round_idx, g.q_idx) != ident:
return
text = self.narrator.rng.choice(TEASES)
text = self._tease_texts.get(pid) or self.narrator.rng.choice(TEASES)
asyncio.get_running_loop().create_task(self._send_tease(pid, text))
async def _send_tease(self, pid: str, text: str):
+79
View File
@@ -0,0 +1,79 @@
/* Rummets mikrofon (eksperimentel) på storskærmen.
Værten slår den til med knappen nederst til venstre. Lyden optages i stumper på 8 sekunder og sendes til
serveren, som kun gemmer et kort resumé (hvem grinede, hvad der blev sagt) til speakerens hjerne.
Selve lyden gemmes aldrig. Mikrofonen er altid slukket, når siden åbnes. */
(function () {
'use strict';
var CHUNK_MS = 8000;
var root = document.getElementById('host');
if (!root || !navigator.mediaDevices || !window.MediaRecorder) return;
var code = (root.getAttribute('data-code') || '').toUpperCase();
var csrf = (document.querySelector('input[name=csrfmiddlewaretoken]') || {}).value || '';
var stream = null, rec = null, on = false, failures = 0;
var box = document.createElement('div');
box.className = 'mic-box';
box.innerHTML = '<button type="button" class="hbtn ghost mic-btn">🎤 Lyt med <small>(eksperimentel)</small></button>' +
'<span class="mic-live" hidden><i></i>Lytter</span><span class="mic-laugh" hidden>😂</span>';
document.body.appendChild(box);
var btn = box.querySelector('.mic-btn'), live = box.querySelector('.mic-live'), laugh = box.querySelector('.mic-laugh');
function setOn(v) {
on = v;
live.hidden = !v;
btn.innerHTML = v ? '🎤 Stop lytning' : '🎤 Lyt med <small>(eksperimentel)</small>';
box.classList.toggle('on', v);
}
function popLaugh() {
laugh.hidden = false;
laugh.classList.remove('pop'); void laugh.offsetWidth; laugh.classList.add('pop');
setTimeout(function () { laugh.hidden = true; }, 1600);
}
function send(blob) {
if (!blob || blob.size < 2000) return;
fetch('/host/' + encodeURIComponent(code) + '/mic', {
method: 'POST', body: blob, credentials: 'same-origin',
headers: { 'Content-Type': blob.type || 'audio/webm', 'X-CSRFToken': csrf }
}).then(function (r) { return r.json(); }).then(function (res) {
if (res.laughter) popLaugh();
failures = res.heard === false ? failures + 1 : 0;
if (failures >= 4) {
stop();
alert('Mikrofonen er slået fra igen: serveren kunne ikke forstå lyden (er talegenkendelse sat op?).');
}
}).catch(function () {});
}
function cycle() {
if (!on || !stream) return;
var mime = MediaRecorder.isTypeSupported('audio/webm;codecs=opus') ? 'audio/webm;codecs=opus' : '';
var chunks = [];
rec = new MediaRecorder(stream, mime ? { mimeType: mime, audioBitsPerSecond: 32000 } : {});
rec.ondataavailable = function (e) { if (e.data && e.data.size) chunks.push(e.data); };
rec.onstop = function () { send(new Blob(chunks, { type: rec.mimeType || 'audio/webm' })); cycle(); };
rec.start();
setTimeout(function () { if (rec && rec.state === 'recording') rec.stop(); }, CHUNK_MS);
}
function start() {
var ok = window.confirm('Rummets mikrofon (eksperimentel)\n\nSpeakeren lytter med gennem denne computers mikrofon, ' +
'så den kan høre, hvornår I griner, og drille jer med det, I siger. Lyden gemmes ikke; kun et kort resumé ' +
'bruges i dette spil og glemmes bagefter.\n\nVil du slå den til?');
if (!ok) return;
navigator.mediaDevices.getUserMedia({ audio: { echoCancellation: true, noiseSuppression: false } })
.then(function (s) { stream = s; failures = 0; setOn(true); cycle(); })
.catch(function () { alert('Mikrofonen kunne ikke startes. Har browseren adgang til en mikrofon?'); });
}
function stop() {
setOn(false);
if (rec && rec.state === 'recording') { rec.onstop = null; rec.stop(); }
if (stream) stream.getTracks().forEach(function (t) { t.stop(); });
stream = null; rec = null;
}
btn.addEventListener('click', function () { if (on) stop(); else start(); });
window.addEventListener('pagehide', stop);
})();
+11
View File
@@ -808,3 +808,14 @@ button, input { font-family: inherit; }
60% { translate: -5px 2px; rotate: -.6deg; }
80% { translate: 3px -1px; rotate: 0deg; }
}
/* rummets mikrofon (eksperimentel) */
.mic-box { position: fixed; left: 18px; bottom: 16px; z-index: 50; display: flex; align-items: center; gap: 10px; font-size: 14px; }
.mic-box .mic-btn small { opacity: .7; font-weight: 500; }
.mic-box .mic-live { display: inline-flex; align-items: center; gap: 6px; padding: 4px 10px; border-radius: 999px; background: #c0392b; color: #fff; font-weight: 800; }
.mic-box .mic-live i { width: 9px; height: 9px; border-radius: 50%; background: #fff; animation: micpulse 1s ease-in-out infinite; }
.mic-box .mic-laugh { font-size: 34px; }
.mic-box .mic-laugh.pop { animation: miclaugh 1.6s cubic-bezier(.3, 1.6, .5, 1) both; }
@keyframes micpulse { 50% { opacity: .25; } }
@keyframes miclaugh { 0% { transform: scale(.2) translateY(10px); opacity: 0; } 25% { transform: scale(1.4); opacity: 1; } 100% { transform: scale(1) translateY(-30px); opacity: 0; } }
@media (prefers-reduced-motion: reduce) { .mic-box .mic-live i, .mic-box .mic-laugh.pop { animation: none; } }
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+68
View File
@@ -0,0 +1,68 @@
"""Talegenkendelse til rummets mikrofon (eksperimentel). Udbyder-agnostisk som hjernen.
STT_PROVIDER=elevenlabs (Scribe; dansk; markerer selv latter som "(griner)"/"(laughter)")
STT_PROVIDER=openai (Whisper-kompatibelt /audio/transcriptions, fx OpenAI eller en lokal server)
Lyden behandles i hukommelsen og gemmes aldrig. Resultatet er kun tekst + om der blev grinet.
"""
from __future__ import annotations
import asyncio
import json
import logging
import re
import urllib.request
import uuid
from django.conf import settings
log = logging.getLogger(__name__)
LAUGH = re.compile(r"\((?:[^)]*(?:grin|griner|latter|laugh|chuckl|giggl|fnis)[^)]*)\)", re.I)
EVENT = re.compile(r"\([^)]*\)")
def enabled() -> bool:
return bool(_key()) and settings.STT_PROVIDER in ("elevenlabs", "openai")
def _key() -> str:
if settings.STT_API_KEY:
return settings.STT_API_KEY
return settings.ELEVENLABS_API_KEY if settings.STT_PROVIDER == "elevenlabs" else ""
def _multipart(fields: dict, file_bytes: bytes, content_type: str) -> tuple[bytes, str]:
boundary = uuid.uuid4().hex
out = b""
for k, v in fields.items():
out += f"--{boundary}\r\nContent-Disposition: form-data; name=\"{k}\"\r\n\r\n{v}\r\n".encode()
out += (f"--{boundary}\r\nContent-Disposition: form-data; name=\"file\"; filename=\"rum.webm\"\r\n"
f"Content-Type: {content_type}\r\n\r\n").encode() + file_bytes + f"\r\n--{boundary}--\r\n".encode()
return out, f"multipart/form-data; boundary={boundary}"
def _transcribe_sync(audio: bytes, content_type: str) -> str:
if settings.STT_PROVIDER == "elevenlabs":
body, ctype = _multipart({"model_id": settings.STT_MODEL or "scribe_v1", "language_code": "da",
"tag_audio_events": "true"}, audio, content_type)
req = urllib.request.Request(settings.STT_BASE_URL or "https://api.elevenlabs.io/v1/speech-to-text", body,
{"xi-api-key": _key(), "Content-Type": ctype})
else:
body, ctype = _multipart({"model": settings.STT_MODEL or "whisper-1", "language": "da"}, audio, content_type)
req = urllib.request.Request((settings.STT_BASE_URL or "https://api.openai.com/v1") + "/audio/transcriptions",
body, {"Authorization": f"Bearer {_key()}", "Content-Type": ctype})
with urllib.request.urlopen(req, timeout=30) as r:
return json.load(r).get("text", "")
async def transcribe(audio: bytes, content_type: str = "audio/webm") -> dict | None:
"""{"text": tale uden lyd-tags, "laughter": bool} eller None ved fejl."""
if not enabled() or len(audio) < 2000:
return None
try:
raw = await asyncio.to_thread(_transcribe_sync, audio, content_type)
except Exception as e: # noqa: BLE001
log.warning("STT fejlede: %r", e)
return None
return {"text": re.sub(r"\s+", " ", EVENT.sub(" ", raw)).strip(), "laughter": bool(LAUGH.search(raw))}
+2
View File
@@ -4,9 +4,11 @@
{% block body_class %}page-host{% endblock %}
{% block content %}
<div id="host" data-code="{{ code }}"></div>
{% csrf_token %}
{% endblock %}
{% block scripts %}
{{ assets|json_script:"wpp-assets" }}
<script src="{% static 'party/common.js' %}" defer></script>
<script src="{% static 'party/host.js' %}" defer></script>
<script src="{% static 'party/mic.js' %}" defer></script>
{% endblock %}
+125
View File
@@ -0,0 +1,125 @@
import asyncio
from unittest import mock
from asgiref.sync import sync_to_async
from channels.testing.websocket import WebsocketCommunicator
from django.test import SimpleTestCase, TransactionTestCase, override_settings
from party import rooms, stt, tts
from party.brain import GameBrain
from party.brain.providers import OllamaProvider, OpenAICompatibleProvider, build_provider
from partyhub.asgi import application
from .test_narration import fake_synth
from .test_ws import HOST_HEADERS, make_bank, recv_until
class FakeProvider:
"""Svarer som en sprogmodel ville, ud fra hvilket skema der bedes om."""
name = "fake"
def __init__(self):
self.calls = []
async def complete_json(self, system, user, schema, **kw):
self.calls.append(user)
props = schema["properties"]
if "is_truth" in props:
bad = '"løgn": "grim løgn"' in user
return {"is_truth": False, "inappropriate": bad, "message": "Prøv en pænere løgn." if bad else ""}
if "comments" in props:
import json, re
data = json.loads(user.split("Data (JSON):\n", 1)[1])
return {"comments": [{"id": a["id"], "line": f"KOMMENTAR {a['text']}"} for a in data["svar"]]}
if "teases" in props:
return {"teases": []}
if "lines" in props:
return {"lines": []}
return None
class ProviderFactoryTests(SimpleTestCase):
def test_build(self):
self.assertIsNone(build_provider("", "x"))
self.assertIsNone(build_provider("ollama", ""))
self.assertIsInstance(build_provider("ollama", "gemma"), OllamaProvider)
self.assertIsInstance(build_provider("openai", "gpt"), OpenAICompatibleProvider)
def test_timeout_and_errors_return_none(self):
class Slow(OllamaProvider):
async def _complete(self, *a):
await asyncio.sleep(5)
class Broken(OllamaProvider):
async def _complete(self, *a):
raise RuntimeError("nede")
self.assertIsNone(asyncio.run(Slow("m").complete_json("s", "u", {}, timeout=0.05)))
self.assertIsNone(asyncio.run(Broken("m").complete_json("s", "u", {})))
@override_settings(TRUSTED_PROXIES=["127.0.0.1"], DEV_HOST_USER=None)
class BrainInGameTests(TransactionTestCase):
def setUp(self):
rooms.all_rooms().clear()
make_bank()
from fupogfakta import engine as _engine
self._intro, _engine.INTRO_SECONDS = _engine.INTRO_SECONDS, 0.2
self.addCleanup(setattr, _engine, "INTRO_SECONDS", self._intro)
self.fake = FakeProvider()
async def _room(self):
from fupogfakta.models import load_game_questions
with mock.patch.object(rooms, "get_brain", return_value=GameBrain(self.fake)):
return rooms.create_room(await sync_to_async(load_game_questions)(), "agw", "http://testserver")
async def test_judge_rejects_lie_and_comments_reach_reveal(self):
room = await self._room()
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 c in phones:
await recv_until(c, "state", "lie")
await phones[0].send_json_to({"action": "submit_lie", "text": "grim løgn"})
err = await recv_until(phones[0], "error")
self.assertEqual(err["message"], "Prøv en pænere løgn.")
self.assertNotIn("p1", room.game.lies) # afvist og fjernet
await phones[0].send_json_to({"action": "submit_lie", "text": "pæn løgn"})
await phones[1].send_json_to({"action": "submit_lie", "text": "anden løgn"})
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"]})
for _ in range(40):
st = (await recv_until(host, "state", "reveal"))["state"]
if st["reveal"]["started_at"]:
break
texts = [s["audio"]["text"] for s in st["reveal"]["steps"] if s.get("audio")]
self.assertTrue(any("KOMMENTAR" in t for t in texts), texts) # hjernens kommentar blev læst op
self.assertTrue(room.memory) # afsløringen blev husket
for c in phones:
await c.disconnect()
await host.disconnect()
async def test_mic_feeds_memory(self):
room = await self._room()
async def fake_transcribe(audio, ctype):
return {"text": "det er helt sikkert ugler", "laughter": True}
with mock.patch.object(stt, "transcribe", fake_transcribe):
res = await room.hear(b"x" * 5000, "audio/webm")
self.assertTrue(res["laughter"])
self.assertEqual(room.mic_laughs, 1)
self.assertTrue(any("grinede" in m for m in room.memory))
self.assertTrue(any("helt sikkert ugler" in m for m in room.memory))
self.assertTrue(room.mic_on())
+1 -1
View File
@@ -68,7 +68,7 @@ class EngineEventTests(SimpleTestCase):
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"])
self.assertEqual([e["type"] for e in g.drain_events()], ["lie_submitted", "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)
+1
View File
@@ -9,6 +9,7 @@ urlpatterns = [
path("host/", views.host_new_page, name="host_new_page"),
path("host/new", views.host_new, name="host_new"),
path("host/<str:code>/", views.host, name="host"),
path("host/<str:code>/mic", views.host_mic, name="host_mic"),
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"),
+19 -1
View File
@@ -4,7 +4,7 @@ import re
import segno
from asgiref.sync import sync_to_async
from django.conf import settings
from django.http import FileResponse, Http404, HttpResponse, HttpResponseForbidden
from django.http import FileResponse, Http404, HttpResponse, HttpResponseForbidden, JsonResponse
from django.shortcuts import redirect, render
from django.views.decorators.http import require_GET, require_POST
@@ -79,3 +79,21 @@ def tts_audio(request, digest):
resp = FileResponse(path.open("rb"), content_type="audio/mpeg")
resp["Cache-Control"] = "public, max-age=31536000, immutable"
return resp
MIC_MAX_BYTES = 2_000_000
@require_POST
async def host_mic(request, code):
"""Lydstump fra storskærmens mikrofon (eksperimentel). Kun værten; lyden gemmes aldrig."""
if not request.host_identity:
return _forbidden()
room = get_room(code)
if not room:
raise Http404
body = request.body
if not body or len(body) > MIC_MAX_BYTES:
return JsonResponse({"error": "tom eller for stor lydstump"}, status=400)
ctype = (request.content_type or "audio/webm").split(";")[0]
return JsonResponse(await room.hear(body, ctype))
+15
View File
@@ -32,6 +32,21 @@ 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"))
# Spilhjernen (valgfri, udbyder-agnostisk, se party/brain/providers.py): anthropic | openai | ollama
BRAIN_PROVIDER = env("BRAIN_PROVIDER", "")
BRAIN_MODEL = env("BRAIN_MODEL", "")
BRAIN_API_KEY = env("BRAIN_API_KEY", "")
BRAIN_BASE_URL = env("BRAIN_BASE_URL", "")
BRAIN_EFFORT = env("BRAIN_EFFORT", "low") # kun Anthropic
BRAIN_TIMEOUT_FAST = float(env("BRAIN_TIMEOUT_FAST", "6")) # dommer, drillerier
BRAIN_TIMEOUT_SLOW = float(env("BRAIN_TIMEOUT_SLOW", "25")) # kommentarer, anmeldelse
# Rummets mikrofon (eksperimentel, slås til på storskærmen): elevenlabs | openai (Whisper-kompatibel)
STT_PROVIDER = env("STT_PROVIDER", "elevenlabs")
STT_API_KEY = env("STT_API_KEY", "") # tom = genbrug ELEVENLABS_API_KEY for elevenlabs
STT_BASE_URL = env("STT_BASE_URL", "")
STT_MODEL = env("STT_MODEL", "")
# 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
+1
View File
@@ -3,3 +3,4 @@ channels>=4.2,<5
uvicorn[standard]>=0.30
whitenoise>=6.7
segno>=1.6
anthropic>=1.11,<2