Flere anlegg – hovedscene, resepsjon, crew – og ett sted å sende fra, med avkryssing for hvilke soner meldingen skal til. Sentralen er en vanlig announcer med [zones] i config. En sone er en vanlig announcer med ANNOUNCER_MESH_SECRET. Samme image, ingen mode, og en sone kan brukes alene hvis sentralen faller ut. Hver avhengighet med C-kode har aarch64-wheel i uv.lock, så det samme bildet går på en Raspberry Pi uten å kompilere noe. Sentralen syntetiserer én gang og sender ferdig WAV (POST /api/play, multipart). Sonen trenger da verken nett, edge-tts eller Pipers 670 MB for det den får – Item.wav er hele endringen i avspillingen. Køene forblir per sone: dispatcheren har egen tråd og egen kø, leverer til hver sone i hver sin tråd med timeout, og går videre. En opptatt hovedscene holder ikke igjen resepsjonen, og en sone som er nede feiler alene – synlig per sone i UI og revisjonslogg. Auth gjenbrukes: ny leverandør mesh på header X-Mesh-Key (ikke Bearer – den leses av JWT-leverandøren, som avviser alt som ikke er et JWT før neste får prøve), med én handling, play. To regler som fulgte med: - play følger aldri med «alt åpent». Uten det kunne hvem som helst POSTe en WAV til en åpen sone og få den spilt. Testen for åpen modus fanget det. - En legitimasjon som vises fram og er feil gir 401 også der gjester ellers har lov. Før slapp en feil PIN i åpen modus stille gjennom som gjest. mesh teller ikke som innlogging for folk: en sone med bare nøkkelen har fortsatt åpen side lokalt. Planlagte sendinger husker sonene sine og fordeles gjennom dispatcheren; planen ender som sent hvis minst én sone tok imot, med «hovedscene: kø #3, crew: nede» som note. Hodeløs Pi: install.sh kjører loginctl enable-linger, ellers finnes ikke lydsocketen når ingen logger inn. docs/mesh.md tar også raspi-config for jack framfor HDMI. 38 nye tester, 148 totalt. Fordelingen testes uten nett med stubbet transport; selve HTTP-en testes mot en levende werkzeug-server, så den håndrullede multiparten møter Flask sin parser. Verifisert i compose med sentral og to soner: til alle, bare til én, og én sone stoppet. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
1003 lines
38 KiB
Python
1003 lines
38 KiB
Python
#!/usr/bin/env python3
|
||
"""Announcer – tekst inn, forhåndslytt i nettleseren, les ut på PC-ens lydutgang.
|
||
|
||
Kjøres på PC-en som er koblet til høyttaleranlegget. Selve avspillingen skjer
|
||
alltid lokalt på denne maskinen via pw-play (PipeWire) eller paplay.
|
||
|
||
Tittel, tekst og hurtigfraser kommer fra config.py, så appen ikke er bundet til
|
||
ett arrangement. Innlogging er valgfri og settes opp i auth.py; uten
|
||
konfigurasjon er alt åpent, som før.
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import argparse
|
||
import hashlib
|
||
import json
|
||
import logging
|
||
import math
|
||
import os
|
||
import queue
|
||
import re
|
||
import secrets
|
||
import shutil
|
||
import struct
|
||
import subprocess
|
||
import threading
|
||
import time
|
||
import wave
|
||
from dataclasses import dataclass, field
|
||
from pathlib import Path
|
||
|
||
from flask import Flask, Response, abort, jsonify, request, send_file
|
||
|
||
import auth
|
||
import config
|
||
import mesh
|
||
import schedule as sched
|
||
from auth import require
|
||
|
||
HERE = Path(__file__).resolve().parent
|
||
CFG = config.load()
|
||
VOICE_DIR = CFG.path("voices")
|
||
CACHE_DIR = CFG.path("cache")
|
||
LOG_PATH = CFG.path("log")
|
||
SCHEDULE_PATH = CFG.path("schedule")
|
||
SINK = str(CFG.get("audio", "sink", "")).strip() # pw-play --target
|
||
MAX_CHARS = int(CFG.app["max_chars"])
|
||
|
||
# Stemmene som tilbys i menyen. To motorer:
|
||
# edge – Microsofts nevrale stemmer via edge-tts (gratis, krever internett,
|
||
# klart mest naturlig; ~2–3 s per melding)
|
||
# piper – lokalt, ingen nett, robotaktig; reserve hvis nettet faller ut.
|
||
# Piper-modellene lastes ned fra rhasspy/piper-voices ved første oppstart.
|
||
# «speaker» er bare for Piper-flerstemmemodeller (nvcc har ti norske talere).
|
||
def _edge(id: str, name: str, group: str, voice: str) -> dict:
|
||
return {"id": id, "name": name, "group": group, "engine": "edge", "model": voice, "speaker": None}
|
||
|
||
|
||
def _piper(id: str, name: str, group: str, model: str, speaker: int | None = None) -> dict:
|
||
return {"id": id, "name": name, "group": group, "engine": "piper", "model": model, "speaker": speaker}
|
||
|
||
|
||
VOICES_LIST: list[dict] = [
|
||
_edge("finn", "Finn (mann)", "Norsk", "nb-NO-FinnNeural"),
|
||
_edge("pernille", "Pernille (kvinne)", "Norsk", "nb-NO-PernilleNeural"),
|
||
_edge("andrew", "Andrew (US, mann)", "English", "en-US-AndrewMultilingualNeural"),
|
||
_edge("ava", "Ava (US, kvinne)", "English", "en-US-AvaMultilingualNeural"),
|
||
_edge("brian", "Brian (US, mann)", "English", "en-US-BrianMultilingualNeural"),
|
||
_edge("emma", "Emma (US, kvinne)", "English", "en-US-EmmaMultilingualNeural"),
|
||
_edge("ryan-uk", "Ryan (UK, mann)", "English", "en-GB-RyanNeural"),
|
||
_edge("sonia", "Sonia (UK, kvinne)", "English", "en-GB-SoniaNeural"),
|
||
_edge("sv", "Svensk – Mattias", "Nabospråk", "sv-SE-MattiasNeural"),
|
||
_edge("da", "Dansk – Christel", "Nabospråk", "da-DK-ChristelNeural"),
|
||
_piper("no-tale", "Talesyntese (nøytral)", "Lokal reserve (uten nett)", "no_NO-talesyntese-medium"),
|
||
_piper("no-knn", "Kvinne – KNN", "Lokal reserve (uten nett)", "no_NO-nvcc-medium", 0),
|
||
_piper("no-msv", "Mann – MSV", "Lokal reserve (uten nett)", "no_NO-nvcc-medium", 5),
|
||
_piper("en-ryan", "Ryan (US)", "Lokal reserve (uten nett)", "en_US-ryan-high"),
|
||
_piper("en-jenny", "Jenny (UK)", "Lokal reserve (uten nett)", "en_GB-jenny_dioco-medium"),
|
||
]
|
||
VOICES: dict[str, dict] = {v["id"]: v for v in VOICES_LIST}
|
||
EDGE_RATE = 24000
|
||
|
||
# Konfigurerte standardvalg, men bare hvis de finnes – en skrivefeil i
|
||
# config.toml skal ikke gjøre sida ubrukelig.
|
||
DEFAULT_VOICE = CFG.app["default_voice"] if CFG.app["default_voice"] in VOICES else "finn"
|
||
DEFAULT_CHIME = CFG.app["default_chime"]
|
||
|
||
log = logging.getLogger("announcer")
|
||
app = Flask(__name__, static_folder=None)
|
||
# En WAV fra sentralen: et par MB for en normal melding. Grensen er romslig
|
||
# bare for å stoppe det som åpenbart ikke er lyd.
|
||
app.config["MAX_CONTENT_LENGTH"] = 64 * 1024 * 1024
|
||
AUTH = auth.install(app, CFG)
|
||
|
||
|
||
# --------------------------------------------------------------------------
|
||
# TTS
|
||
# --------------------------------------------------------------------------
|
||
|
||
_voice_cache: dict[str, object] = {}
|
||
_voice_lock = threading.Lock()
|
||
|
||
|
||
def ensure_voices() -> None:
|
||
import piper.download_voices as dl
|
||
|
||
VOICE_DIR.mkdir(parents=True, exist_ok=True)
|
||
for name in sorted({v["model"] for v in VOICES_LIST if v["engine"] == "piper"}):
|
||
if not (VOICE_DIR / f"{name}.onnx").exists():
|
||
log.info("laster ned stemme %s", name)
|
||
dl.download_voice(name, VOICE_DIR)
|
||
|
||
|
||
def load_voice(name: str):
|
||
from piper import PiperVoice
|
||
|
||
with _voice_lock:
|
||
if name not in _voice_cache:
|
||
_voice_cache[name] = PiperVoice.load(VOICE_DIR / f"{name}.onnx")
|
||
return _voice_cache[name]
|
||
|
||
|
||
# --------------------------------------------------------------------------
|
||
# Jingler (chimes). Alle de innebygde genereres i Python; egne lydfiler
|
||
# (wav/mp3/ogg/flac) i CHIME_DIR dukker opp automatisk i menyen.
|
||
# --------------------------------------------------------------------------
|
||
|
||
CHIME_DIR = CFG.path("chimes")
|
||
|
||
|
||
def _tone(rate: int, freq: float, dur: float, vol: float = 0.35, wave_: str = "sine",
|
||
decay: float = 5.5, attack: float = 0.005, harm2: float = 0.0, slide: float = 0.0) -> list[float]:
|
||
"""Én tone som liste av floats. slide = frekvensendring (multiplikator) over tonen."""
|
||
n = int(rate * dur)
|
||
out = []
|
||
phase = 0.0
|
||
for i in range(n):
|
||
t = i / rate
|
||
f = freq * (slide ** (t / dur)) if slide else freq
|
||
phase += 2 * math.pi * f / rate
|
||
if wave_ == "square":
|
||
v = 1.0 if math.sin(phase) >= 0 else -1.0
|
||
elif wave_ == "saw":
|
||
v = 2.0 * ((phase / (2 * math.pi)) % 1.0) - 1.0
|
||
else:
|
||
v = math.sin(phase) + harm2 * math.sin(2 * phase)
|
||
env = min(1.0, t / attack) * math.exp(-decay * t)
|
||
out.append(v * env * vol)
|
||
return out
|
||
|
||
|
||
def _mix(*parts: list[float], gap: float = 0.0, rate: int = 22050) -> list[float]:
|
||
out: list[float] = []
|
||
for p in parts:
|
||
out += p
|
||
out += [0.0] * int(rate * gap)
|
||
return out
|
||
|
||
|
||
def _overlay(base: list[float], layer: list[float], at: float, rate: int) -> list[float]:
|
||
start = int(at * rate)
|
||
if len(base) < start + len(layer):
|
||
base = base + [0.0] * (start + len(layer) - len(base))
|
||
for i, v in enumerate(layer):
|
||
base[start + i] += v
|
||
return base
|
||
|
||
|
||
def _dingdong(rate: int) -> list[float]:
|
||
return _mix(_tone(rate, 784.0, 0.28, harm2=0.3), _tone(rate, 523.25, 0.42, harm2=0.3), rate=rate)
|
||
|
||
|
||
def _airport(rate: int) -> list[float]:
|
||
"""Tre stigende toner med lang klang, som på en flyplass."""
|
||
out: list[float] = []
|
||
for i, f in enumerate((523.25, 659.25, 783.99)):
|
||
out = _overlay(out, _tone(rate, f, 1.2, vol=0.3, decay=2.2, harm2=0.2), i * 0.38, rate)
|
||
return out
|
||
|
||
|
||
def _chiptune(rate: int) -> list[float]:
|
||
"""8-bit arpeggio, som et power-up i et gammelt spill."""
|
||
notes = (523.25, 659.25, 783.99, 1046.5, 1318.5, 1567.98)
|
||
parts = [_tone(rate, f, 0.09, vol=0.3, wave_="square", decay=3.0) for f in notes]
|
||
parts.append(_tone(rate, 2093.0, 0.35, vol=0.3, wave_="square", decay=6.0))
|
||
return _mix(*parts, rate=rate)
|
||
|
||
|
||
def _scifi(rate: int) -> list[float]:
|
||
"""Sveip opp + to blipp, som en kommunikator."""
|
||
sweep = _tone(rate, 300.0, 0.35, vol=0.3, decay=1.5, slide=5.0)
|
||
blips = _mix(_tone(rate, 1800.0, 0.07, vol=0.25, decay=10), _tone(rate, 2400.0, 0.12, vol=0.25, decay=10), gap=0.03, rate=rate)
|
||
return _mix(sweep, blips, rate=rate)
|
||
|
||
|
||
def _horn(rate: int) -> list[float]:
|
||
"""Air horn: to lag av sagtann med litt vibrato, derav grovheten."""
|
||
out: list[float] = []
|
||
for f, v in ((233.08, 0.22), (349.23, 0.16), (466.16, 0.1)):
|
||
out = _overlay(out, _tone(rate, f, 0.9, vol=v, wave_="saw", decay=1.3, attack=0.03), 0.0, rate)
|
||
return out
|
||
|
||
|
||
def _gong(rate: int) -> list[float]:
|
||
"""Dyp gong med uharmoniske overtoner."""
|
||
out: list[float] = []
|
||
for f, v, d in ((110.0, 0.3, 1.0), (164.8, 0.18, 1.4), (263.0, 0.12, 2.0), (411.0, 0.07, 3.0), (650.0, 0.04, 4.0)):
|
||
out = _overlay(out, _tone(rate, f, 2.5, vol=v, decay=d, attack=0.004), 0.0, rate)
|
||
return out
|
||
|
||
|
||
BUILTIN_CHIMES: dict[str, tuple[str, object]] = {
|
||
"dingdong": ("Ding-dong", _dingdong),
|
||
"airport": ("Flyplass (tre toner)", _airport),
|
||
"chiptune": ("8-bit power-up", _chiptune),
|
||
"scifi": ("Sci-fi kommunikator", _scifi),
|
||
"horn": ("Air horn", _horn),
|
||
"gong": ("Gong", _gong),
|
||
}
|
||
CHIME_EXT = {".wav", ".mp3", ".ogg", ".flac", ".m4a"}
|
||
|
||
|
||
def file_chimes() -> dict[str, Path]:
|
||
if not CHIME_DIR.is_dir():
|
||
return {}
|
||
return {f"file:{p.stem}": p for p in sorted(CHIME_DIR.iterdir()) if p.suffix.lower() in CHIME_EXT}
|
||
|
||
|
||
def list_chimes() -> list[dict]:
|
||
out = [{"id": "none", "name": "Ingen"}]
|
||
out += [{"id": k, "name": v[0]} for k, v in BUILTIN_CHIMES.items()]
|
||
out += [{"id": k, "name": f"{p.stem} (egen fil)"} for k, p in file_chimes().items()]
|
||
return out
|
||
|
||
|
||
def chime_pcm(chime: str, rate: int) -> bytes:
|
||
"""Jingle som 16-bit mono PCM i gitt samplerate, etterfulgt av en kort pause."""
|
||
if chime == "none":
|
||
return b""
|
||
if chime in BUILTIN_CHIMES:
|
||
samples = BUILTIN_CHIMES[chime][1](rate)
|
||
pcm = b"".join(struct.pack("<h", int(max(-1.0, min(1.0, v)) * 32767)) for v in samples)
|
||
else:
|
||
path = file_chimes().get(chime)
|
||
if path is None:
|
||
raise ValueError("Ukjent jingle")
|
||
pcm = subprocess.run(
|
||
["ffmpeg", "-v", "error", "-i", str(path), "-t", "6", "-f", "s16le", "-ac", "1", "-ar", str(rate),
|
||
"-af", "loudnorm=I=-18,afade=t=out:st=5:d=1", "-"],
|
||
capture_output=True, check=True,
|
||
).stdout
|
||
return pcm + b"\x00\x00" * int(rate * 0.35)
|
||
|
||
|
||
def edge_pcm(text: str, voice: str, speed: float) -> bytes:
|
||
"""Henter MP3 fra edge-tts og dekoder til 16-bit mono PCM i EDGE_RATE."""
|
||
import asyncio
|
||
import tempfile
|
||
|
||
import edge_tts
|
||
|
||
rate_pct = int(round((speed - 1.0) * 100))
|
||
rate = f"{rate_pct:+d}%"
|
||
with tempfile.NamedTemporaryFile(suffix=".mp3", delete=False, dir=CACHE_DIR) as tmp:
|
||
mp3 = Path(tmp.name)
|
||
try:
|
||
asyncio.run(edge_tts.Communicate(text, voice, rate=rate).save(str(mp3)))
|
||
out = subprocess.run(
|
||
["ffmpeg", "-v", "error", "-i", str(mp3), "-f", "s16le", "-ac", "1", "-ar", str(EDGE_RATE), "-"],
|
||
capture_output=True,
|
||
check=True,
|
||
)
|
||
return out.stdout
|
||
finally:
|
||
mp3.unlink(missing_ok=True)
|
||
|
||
|
||
def synth_key(text: str, voice: str, speed: float, chime: str) -> str:
|
||
h = hashlib.sha1(f"{voice}|{speed:.2f}|{chime}|{text}".encode()).hexdigest()
|
||
return h[:20]
|
||
|
||
|
||
def synthesize(text: str, voice_name: str, speed: float, chime: str) -> Path:
|
||
"""Returnerer sti til ferdig WAV (bufres på disk etter innhold)."""
|
||
from piper.config import SynthesisConfig
|
||
|
||
CACHE_DIR.mkdir(parents=True, exist_ok=True)
|
||
path = CACHE_DIR / f"{synth_key(text, voice_name, speed, chime)}.wav"
|
||
if path.exists():
|
||
return path
|
||
|
||
spec = VOICES[voice_name]
|
||
pcm = bytearray()
|
||
if spec["engine"] == "edge":
|
||
rate = EDGE_RATE
|
||
pcm += chime_pcm(chime, rate)
|
||
pcm += edge_pcm(text, spec["model"], speed)
|
||
else:
|
||
voice = load_voice(spec["model"])
|
||
rate = voice.config.sample_rate
|
||
cfg = SynthesisConfig(length_scale=1.0 / speed, speaker_id=spec["speaker"])
|
||
pcm += chime_pcm(chime, rate)
|
||
for chunk in voice.synthesize(text, cfg):
|
||
pcm += chunk.audio_int16_bytes
|
||
pcm += b"\x00\x00" * int(rate * 0.3)
|
||
|
||
# Unik tmp-fil per kall. Med et fast navn ville to som forhåndslytter
|
||
# samme tekst samtidig skrevet til samme fil, og den ene rename feilet.
|
||
# Taper den ene kappløpet om selve path, er innholdet uansett likt.
|
||
import tempfile
|
||
|
||
with tempfile.NamedTemporaryFile(suffix=".tmp", dir=CACHE_DIR, delete=False) as fh:
|
||
tmp = Path(fh.name)
|
||
with wave.open(str(tmp), "wb") as w:
|
||
w.setnchannels(1)
|
||
w.setsampwidth(2)
|
||
w.setframerate(rate)
|
||
w.writeframes(bytes(pcm))
|
||
os.replace(tmp, path)
|
||
return path
|
||
|
||
|
||
# --------------------------------------------------------------------------
|
||
# Avspillingskø
|
||
# --------------------------------------------------------------------------
|
||
|
||
|
||
@dataclass
|
||
class Item:
|
||
id: int
|
||
text: str
|
||
voice: str
|
||
speed: float
|
||
chime: str
|
||
sender: str # IP-adressen forespørselen kom fra
|
||
user: str = "" # visningsnavnet til den som sendte
|
||
subject: str = "" # stabil id, f.eks. "jwt:kent"
|
||
method: str = "none"
|
||
created: float = field(default_factory=time.time)
|
||
# queued -> preparing (syntese) -> playing -> done | stopped | failed
|
||
status: str = "queued"
|
||
error: str = ""
|
||
schedule_id: str = "" # satt når meldingen kom fra planleggeren
|
||
wav: Path | None = None # ferdig lyd fra sentralen (mesh); ingen syntese
|
||
|
||
@property
|
||
def active(self) -> bool:
|
||
return self.status in ("preparing", "playing")
|
||
|
||
def public(self, *, show_ip: bool = False) -> dict:
|
||
return {
|
||
"id": self.id,
|
||
"text": self.text,
|
||
"voice": VOICES[self.voice]["name"] if self.voice in VOICES else self.voice,
|
||
"user": self.user,
|
||
# IP-en er bare interessant for en admin som skal finne ut hvem
|
||
# som rotet; alle andre ser navnet.
|
||
"sender": self.sender if show_ip else "",
|
||
"created": self.created,
|
||
"status": self.status,
|
||
"error": self.error,
|
||
}
|
||
|
||
|
||
def player_cmd(path: Path) -> list[str]:
|
||
"""pw-play når PipeWire kjører, ellers paplay (PulseAudio, f.eks. Linux Mint 21)."""
|
||
runtime = Path(os.environ.get("XDG_RUNTIME_DIR", f"/run/user/{os.getuid()}"))
|
||
if shutil.which("pw-play") and (runtime / "pipewire-0").exists():
|
||
cmd = ["pw-play"]
|
||
if SINK:
|
||
cmd += ["--target", SINK]
|
||
elif shutil.which("paplay"):
|
||
cmd = ["paplay"]
|
||
if SINK:
|
||
cmd += ["--device", SINK]
|
||
else:
|
||
raise RuntimeError("fant verken pw-play (pipewire-bin) eller paplay (pulseaudio-utils)")
|
||
return cmd + [str(path)]
|
||
|
||
|
||
class Player:
|
||
"""Én FIFO-kø, én avspilling om gangen.
|
||
|
||
Tråden startes ikke her, men i start(), som main() kaller. Da kan rutene
|
||
testes med en test_client uten at noe begynner å spille, og run_once()
|
||
lar en test kjøre køen ett steg om gangen.
|
||
"""
|
||
|
||
def __init__(self, on_finished=None) -> None: # noqa: ANN001
|
||
self.q: queue.Queue[Item] = queue.Queue()
|
||
self.history: list[Item] = []
|
||
self.current: Item | None = None
|
||
self.proc: subprocess.Popen | None = None
|
||
self.lock = threading.Lock()
|
||
self.seq = 0
|
||
# Kalles med hvert ferdige Item (done/stopped/failed). Det er slik
|
||
# planleggeren får vite hvordan det gikk, uten at Player vet om den.
|
||
self.on_finished = on_finished
|
||
self._thread: threading.Thread | None = None
|
||
|
||
def start(self) -> None:
|
||
if self._thread is None:
|
||
self._thread = threading.Thread(target=self._loop, daemon=True, name="player")
|
||
self._thread.start()
|
||
|
||
def enqueue(self, text: str, voice: str, speed: float, chime: str, sender: str,
|
||
who: auth.Principal | None = None, schedule_id: str = "",
|
||
wav: Path | None = None) -> Item:
|
||
with self.lock:
|
||
self.seq += 1
|
||
item = Item(
|
||
self.seq, text, voice, speed, chime, sender,
|
||
user=who.name if who else "",
|
||
subject=who.subject if who else "",
|
||
method=who.method if who else "none",
|
||
schedule_id=schedule_id,
|
||
wav=wav,
|
||
)
|
||
self.history.append(item)
|
||
del self.history[:-50]
|
||
self.q.put(item)
|
||
return item
|
||
|
||
def stop(self) -> None:
|
||
"""Stopper det som spilles – eller det som fortsatt lages.
|
||
|
||
Under syntesen finnes det ingen prosess å drepe ennå; det er de første
|
||
sekundene av hver edge-tts-melding. Før var Stopp uten virkning da,
|
||
og meldingen gikk ut etterpå. Nå settes statusen, og _loop sjekker
|
||
den før den starter avspilling.
|
||
"""
|
||
with self.lock:
|
||
if self.current and self.current.active:
|
||
self.current.status = "stopped"
|
||
if self.proc and self.proc.poll() is None:
|
||
self.proc.terminate()
|
||
|
||
def clear(self) -> None:
|
||
while True:
|
||
try:
|
||
item = self.q.get_nowait()
|
||
except queue.Empty:
|
||
break
|
||
item.status = "stopped"
|
||
self.stop()
|
||
|
||
def snapshot(self, *, show_ip: bool = False) -> dict:
|
||
with self.lock:
|
||
return {
|
||
"current": self.current.public(show_ip=show_ip) if self.current else None,
|
||
"queued": [i.public(show_ip=show_ip) for i in self.history if i.status == "queued"],
|
||
"history": [i.public(show_ip=show_ip) for i in reversed(self.history)
|
||
if not i.active and i.status != "queued"][:15],
|
||
}
|
||
|
||
def _loop(self) -> None:
|
||
while True:
|
||
self.run_once()
|
||
|
||
def run_once(self, timeout: float | None = None) -> Item | None:
|
||
"""Tar neste melding fra køen og spiller den ferdig. Returnerer den."""
|
||
try:
|
||
item = self.q.get(timeout=timeout)
|
||
except queue.Empty:
|
||
return None
|
||
if item.status != "queued":
|
||
return item
|
||
item.status = "preparing"
|
||
with self.lock:
|
||
self.current = item
|
||
try:
|
||
# Fra sentralen kommer lyden ferdig; da er det ingenting å lage.
|
||
path = item.wav or synthesize(item.text, item.voice, item.speed, item.chime)
|
||
with self.lock:
|
||
if item.status != "preparing":
|
||
return item # stoppet mens lyden ble laget
|
||
item.status = "playing"
|
||
self.proc = subprocess.Popen(player_cmd(path), stdout=subprocess.DEVNULL,
|
||
stderr=subprocess.PIPE)
|
||
_, err = self.proc.communicate()
|
||
if item.status == "playing":
|
||
if self.proc.returncode == 0:
|
||
item.status = "done"
|
||
else:
|
||
item.status = "failed"
|
||
item.error = err.decode(errors="replace").strip()[-200:]
|
||
except Exception as e: # noqa: BLE001
|
||
log.exception("avspilling feilet")
|
||
item.status = "failed"
|
||
item.error = str(e)
|
||
finally:
|
||
with self.lock:
|
||
self.current = None
|
||
self.proc = None
|
||
audit("played", id=item.id, status=item.status, error=item.error,
|
||
subject=item.subject, name=item.user, method=item.method,
|
||
ip=item.sender, text=item.text, voice=item.voice)
|
||
if self.on_finished:
|
||
try:
|
||
self.on_finished(item)
|
||
except Exception: # noqa: BLE001
|
||
log.exception("on_finished feilet")
|
||
return item
|
||
|
||
|
||
# --------------------------------------------------------------------------
|
||
# Planlagte sendinger
|
||
# --------------------------------------------------------------------------
|
||
|
||
|
||
schedule = sched.Schedule(
|
||
SCHEDULE_PATH,
|
||
grace=float(CFG.get("schedule", "grace_seconds", sched.DEFAULT_GRACE)),
|
||
max_ahead=float(CFG.get("schedule", "max_days_ahead", 30)) * 86400,
|
||
)
|
||
TICK = max(5.0, float(CFG.get("schedule", "tick_seconds", 20)))
|
||
|
||
|
||
def _scheduled_finished(item: Item) -> None:
|
||
"""Players on_finished: lar planen følge meldingen helt i mål.
|
||
|
||
Før sto en planlagt sending som «sendt» i det den ble lagt i kø, og ble
|
||
stående slik selv om avspillingen feilet. Nå ender den som sent, failed
|
||
eller stopped – det som faktisk skjedde.
|
||
"""
|
||
if not item.schedule_id:
|
||
return
|
||
status = {"done": "sent", "failed": "failed", "stopped": "stopped"}.get(item.status)
|
||
if status:
|
||
schedule.mark(item.schedule_id, status, item.error or f"kø #{item.id}")
|
||
|
||
|
||
player = Player(on_finished=_scheduled_finished)
|
||
|
||
|
||
# --------------------------------------------------------------------------
|
||
# Mesh: sentral + soner. Finnes bare når [zones] er satt i config.
|
||
# --------------------------------------------------------------------------
|
||
|
||
try:
|
||
ZONES = mesh.zones_from_config(CFG.zones)
|
||
except ValueError as e:
|
||
raise SystemExit(f"config: {e}") from e
|
||
MESH_SECRET = str(CFG.get("mesh", "secret", "")).strip()
|
||
if ZONES and not MESH_SECRET and any(not z.local for z in ZONES.values()):
|
||
raise SystemExit("config: [zones] har eksterne soner, men ANNOUNCER_MESH_SECRET er ikke satt")
|
||
|
||
|
||
def _play_local(d: mesh.Dispatch, path: Path) -> int:
|
||
"""Sentralens eget anlegg er en sone som alle andre, bare uten HTTP."""
|
||
item = player.enqueue(
|
||
d.text, d.voice, d.speed, d.chime, d.sender,
|
||
who=auth.Principal(d.subject, d.user, (), d.method, frozenset()),
|
||
wav=path,
|
||
)
|
||
return item.id
|
||
|
||
|
||
def _dispatch_finished(d: mesh.Dispatch) -> None:
|
||
audit("dispatched", id=d.id, subject=d.subject, name=d.user, method=d.method,
|
||
ip=d.sender, text=d.text, zones={n: z.status for n, z in d.zones.items()},
|
||
error=d.error)
|
||
if d.schedule_id:
|
||
if d.accepted:
|
||
schedule.mark(d.schedule_id, "sent", d.summary())
|
||
else:
|
||
schedule.mark(d.schedule_id, "failed", d.error or d.summary())
|
||
|
||
|
||
DISPATCHER: mesh.Dispatcher | None = None
|
||
POLLER: mesh.ZonePoller | None = None
|
||
if ZONES:
|
||
_timeout = float(CFG.get("mesh", "timeout_seconds", 5))
|
||
DISPATCHER = mesh.Dispatcher(
|
||
ZONES,
|
||
synthesize=lambda *a: synthesize(*a),
|
||
play_local=_play_local,
|
||
send=lambda zone, path, d: mesh.send_to_zone(zone, path, d, secret=MESH_SECRET, timeout=_timeout),
|
||
timeout=_timeout,
|
||
on_finished=_dispatch_finished,
|
||
)
|
||
POLLER = mesh.ZonePoller(
|
||
ZONES,
|
||
fetch=lambda zone: mesh.fetch_zone_status(zone, secret=MESH_SECRET),
|
||
interval=float(CFG.get("mesh", "poll_seconds", 2)),
|
||
local_status=lambda: {
|
||
"current": player.snapshot()["current"],
|
||
"queued": len(player.snapshot()["queued"]),
|
||
},
|
||
)
|
||
|
||
|
||
def scheduler_tick() -> None:
|
||
"""Ett steg: flytter forfalte meldinger inn i den vanlige køen.
|
||
|
||
Gjør med vilje ingenting annet. Avspilling, syntese og feilhåndtering er
|
||
Players jobb, slik at en planlagt melding oppfører seg nøyaktig som en
|
||
sendt for hånd når den først er i kø. Egen funksjon, så tester kan
|
||
kalle den uten tråd.
|
||
"""
|
||
send, missed = schedule.due()
|
||
for item in missed:
|
||
schedule.mark(item.id, "missed", "maskina var ikke i gang i tide")
|
||
log.warning("planlagt #%s gikk glipp av (%s)", item.id, item.text[:40])
|
||
audit("missed", id=item.id, subject=item.subject, name=item.user,
|
||
method=item.method, text=item.text, at=item.at)
|
||
for item in send:
|
||
if DISPATCHER is not None:
|
||
try:
|
||
names = DISPATCHER.resolve(item.zones)
|
||
except ValueError as e:
|
||
schedule.mark(item.id, "failed", str(e))
|
||
audit("scheduled_fire", id=item.id, error=str(e), subject=item.subject,
|
||
name=item.user, method=item.method, text=item.text, at=item.at)
|
||
continue
|
||
d = DISPATCHER.submit(
|
||
item.text, item.voice, item.speed, item.chime, names,
|
||
user=item.user, subject=item.subject, method=item.method,
|
||
sender="planlagt", schedule_id=item.id,
|
||
)
|
||
schedule.mark(item.id, "queued", "fordeles til " + ", ".join(names))
|
||
log.info("planlagt #%s fordeles som #%s til %s", item.id, d.id, ", ".join(names))
|
||
audit("scheduled_fire", id=item.id, dispatch_id=d.id, zones=names,
|
||
subject=item.subject, name=item.user, method=item.method,
|
||
text=item.text, voice=item.voice, at=item.at)
|
||
continue
|
||
queued = player.enqueue(
|
||
item.text, item.voice, item.speed, item.chime,
|
||
sender="planlagt",
|
||
who=auth.Principal(item.subject, item.user, (), item.method, frozenset()),
|
||
schedule_id=item.id,
|
||
)
|
||
schedule.mark(item.id, "queued", f"kø #{queued.id}")
|
||
log.info("planlagt #%s lagt i kø som #%d", item.id, queued.id)
|
||
audit("scheduled_fire", id=item.id, queue_id=queued.id,
|
||
subject=item.subject, name=item.user, method=item.method,
|
||
text=item.text, voice=item.voice, at=item.at)
|
||
|
||
|
||
def _scheduler_loop() -> None:
|
||
while True:
|
||
try:
|
||
scheduler_tick()
|
||
except Exception: # noqa: BLE001
|
||
# En feil her skal ikke drepe tråden; da ville alle senere
|
||
# planlagte sendinger stilltiende blitt borte.
|
||
log.exception("planleggeren feilet")
|
||
time.sleep(TICK)
|
||
|
||
|
||
def start_scheduler() -> None:
|
||
threading.Thread(target=_scheduler_loop, daemon=True, name="scheduler").start()
|
||
|
||
|
||
# --------------------------------------------------------------------------
|
||
# HTTP
|
||
# --------------------------------------------------------------------------
|
||
|
||
|
||
MAX_LOG_BYTES = 5_000_000
|
||
_log_lock = threading.Lock()
|
||
|
||
|
||
def audit(action: str, **fields) -> None:
|
||
"""Én JSON-linje per hendelse, så man i ettertid kan se hvem som sendte hva.
|
||
|
||
Append-only og rullerer ved 5 MB. Feiler aldri forespørselen – en logg som
|
||
ikke lar seg skrive skal ikke hindre et utrop.
|
||
"""
|
||
rec = {"ts": round(time.time(), 3), "action": action,
|
||
**{k: v for k, v in fields.items() if v not in ("", None)}}
|
||
line = json.dumps(rec, ensure_ascii=False)
|
||
try:
|
||
with _log_lock:
|
||
LOG_PATH.parent.mkdir(parents=True, exist_ok=True)
|
||
if LOG_PATH.exists() and LOG_PATH.stat().st_size > MAX_LOG_BYTES:
|
||
LOG_PATH.replace(LOG_PATH.with_name(LOG_PATH.name + ".1"))
|
||
with LOG_PATH.open("a", encoding="utf-8") as fh:
|
||
fh.write(line + "\n")
|
||
except OSError as e:
|
||
log.warning("fikk ikke skrevet til %s: %s", LOG_PATH, e)
|
||
|
||
|
||
def who(p: auth.Principal) -> dict:
|
||
"""Identitetsfeltene audit() vil ha."""
|
||
return {"subject": p.subject, "name": p.name, "method": p.method,
|
||
"ip": request.remote_addr or ""}
|
||
|
||
|
||
def requested_zones(data: dict) -> list[str]:
|
||
"""Sonene en forespørsel ber om. Tom liste = ikke mesh, gå i lokal kø."""
|
||
if DISPATCHER is None:
|
||
if data.get("zones"):
|
||
abort(400, "Ingen soner er satt opp på denne maskinen")
|
||
return []
|
||
try:
|
||
return DISPATCHER.resolve(data.get("zones"))
|
||
except ValueError as e:
|
||
abort(400, str(e))
|
||
|
||
|
||
def parse_request() -> tuple[str, str, float, str]:
|
||
data = request.get_json(silent=True) or {}
|
||
text = " ".join(str(data.get("text", "")).split())
|
||
if not text:
|
||
abort(400, "Tom melding")
|
||
if len(text) > MAX_CHARS:
|
||
abort(400, f"Maks {MAX_CHARS} tegn")
|
||
voice = str(data.get("voice", DEFAULT_VOICE))
|
||
if voice not in VOICES:
|
||
abort(400, "Ukjent stemme")
|
||
try:
|
||
speed = float(data.get("speed", 1.0))
|
||
except (TypeError, ValueError):
|
||
abort(400, "Ugyldig hastighet")
|
||
speed = max(0.6, min(1.6, speed))
|
||
chime = data.get("chime", DEFAULT_CHIME)
|
||
if chime is True:
|
||
chime = DEFAULT_CHIME
|
||
elif chime is False or chime is None:
|
||
chime = "none"
|
||
chime = str(chime)
|
||
if chime != "none" and chime not in BUILTIN_CHIMES and chime not in file_chimes():
|
||
abort(400, "Ukjent jingle")
|
||
return text, voice, speed, chime
|
||
|
||
|
||
@app.get("/")
|
||
def index() -> Response:
|
||
return send_file(HERE / "index.html")
|
||
|
||
|
||
@app.get("/api/config")
|
||
def client_config() -> Response:
|
||
"""Alt sida trenger for å tegne seg selv: branding, menyer og hvem du er.
|
||
|
||
Svarer alltid 200, også med feil PIN – her spør klienten «hvem er jeg?»,
|
||
og da er svaret «ikke innlogget», ikke en feil.
|
||
"""
|
||
me = auth.current()
|
||
return jsonify(
|
||
{
|
||
"app": {
|
||
"title": CFG.app["title"],
|
||
"subtitle": CFG.app["subtitle"],
|
||
"accent": CFG.app["accent"],
|
||
"idle_text": CFG.app["idle_text"],
|
||
"message_placeholder": CFG.app["message_placeholder"],
|
||
"quick_phrases": list(CFG.app["quick_phrases"]),
|
||
},
|
||
"voices": [{"id": v["id"], "name": v["name"], "group": v["group"], "engine": v["engine"]} for v in VOICES_LIST],
|
||
"default_voice": DEFAULT_VOICE,
|
||
"chimes": list_chimes(),
|
||
"default_chime": DEFAULT_CHIME,
|
||
"max_chars": MAX_CHARS,
|
||
"schedule": {
|
||
"max_days_ahead": int(CFG.get("schedule", "max_days_ahead", 30)),
|
||
"grace_seconds": int(CFG.get("schedule", "grace_seconds", 120)),
|
||
},
|
||
# Tom liste = ingen mesh; da viser ikke sida noen avkryssing.
|
||
"zones": DISPATCHER.names() if DISPATCHER else [],
|
||
"auth": {
|
||
**AUTH.describe(),
|
||
"user": None if me.anonymous else me.public(),
|
||
"can": sorted(me.permissions),
|
||
"error": auth.error(),
|
||
},
|
||
}
|
||
)
|
||
|
||
|
||
@app.post("/api/preview")
|
||
def preview() -> Response:
|
||
me = require("preview")
|
||
text, voice, speed, chime = parse_request()
|
||
try:
|
||
path = synthesize(text, voice, speed, chime)
|
||
except Exception as e: # noqa: BLE001
|
||
log.exception("syntese feilet")
|
||
audit("preview", **who(me), text=text, voice=voice, error=str(e)[:200])
|
||
abort(502, f"Syntese feilet ({VOICES[voice]['name']}): {e}. Prøv en lokal reserve-stemme.")
|
||
audit("preview", **who(me), text=text, voice=voice)
|
||
return send_file(path, mimetype="audio/wav", max_age=3600)
|
||
|
||
|
||
@app.post("/api/announce")
|
||
def announce() -> Response:
|
||
me = require("announce")
|
||
text, voice, speed, chime = parse_request()
|
||
zones = requested_zones(request.get_json(silent=True) or {})
|
||
sender = request.remote_addr or "?"
|
||
if zones:
|
||
d = DISPATCHER.submit(text, voice, speed, chime, zones, user=me.name,
|
||
subject=me.subject, method=me.method, sender=sender)
|
||
log.info("fordeling #%s fra %s til %s: %s", d.id, me.name or sender, ", ".join(zones), text)
|
||
audit("announce", **who(me), dispatch_id=d.id, zones=zones, text=text,
|
||
voice=voice, speed=speed, chime=chime)
|
||
return jsonify({"dispatch": d.public(show_ip=me.can("clear")), "id": d.id})
|
||
item = player.enqueue(text, voice, speed, chime, sender, me)
|
||
log.info("kø #%d fra %s (%s): %s", item.id, item.user or sender, sender, text)
|
||
audit("announce", **who(me), id=item.id, text=text, voice=voice,
|
||
speed=speed, chime=chime)
|
||
return jsonify(item.public(show_ip=me.can("clear")))
|
||
|
||
|
||
@app.post("/api/play")
|
||
def play() -> Response:
|
||
"""Sonens side av mesh: ta imot en ferdig WAV fra sentralen og køe den."""
|
||
me = require("play")
|
||
f = request.files.get("wav")
|
||
if f is None:
|
||
abort(400, "Mangler wav")
|
||
try:
|
||
meta = json.loads(request.form.get("meta") or "{}")
|
||
except ValueError:
|
||
abort(400, "Ugyldig meta")
|
||
if not isinstance(meta, dict):
|
||
abort(400, "Ugyldig meta")
|
||
origin = re.sub(r"[^A-Za-z0-9_-]", "", str(meta.get("origin_id", "")))[:32] or secrets.token_hex(4)
|
||
CACHE_DIR.mkdir(parents=True, exist_ok=True)
|
||
path = CACHE_DIR / f"mesh-{origin}.wav"
|
||
f.save(path)
|
||
try:
|
||
with wave.open(str(path)) as w:
|
||
if w.getnframes() == 0:
|
||
raise ValueError("tom fil")
|
||
except (wave.Error, EOFError, ValueError) as e:
|
||
path.unlink(missing_ok=True)
|
||
abort(400, f"Ikke en gyldig WAV ({e})")
|
||
text = " ".join(str(meta.get("text", "")).split())[:MAX_CHARS] or "(uten tekst)"
|
||
origin_who = auth.Principal(
|
||
str(meta.get("subject") or "mesh"), str(meta.get("user") or "sentral"),
|
||
(), str(meta.get("method") or "mesh"), frozenset(),
|
||
)
|
||
item = player.enqueue(text, str(meta.get("voice") or "?"), 1.0, "none",
|
||
request.remote_addr or "?", who=origin_who, wav=path)
|
||
log.info("mesh: kø #%d fra sentralen (%s): %s", item.id, origin_who.name, text)
|
||
audit("play", **who(me), id=item.id, origin_id=origin, text=text,
|
||
user=origin_who.name, user_subject=origin_who.subject)
|
||
return jsonify({"id": item.id})
|
||
|
||
|
||
@app.post("/api/schedule")
|
||
def schedule_add() -> Response:
|
||
me = require("schedule")
|
||
text, voice, speed, chime = parse_request()
|
||
data = request.get_json(silent=True) or {}
|
||
try:
|
||
at = float(data["at"])
|
||
except (KeyError, TypeError, ValueError):
|
||
abort(400, "Mangler eller ugyldig tidspunkt")
|
||
zones = requested_zones(data)
|
||
try:
|
||
item = schedule.add(at=at, text=text, voice=voice, speed=speed, chime=chime,
|
||
user=me.name, subject=me.subject, method=me.method, zones=zones)
|
||
except ValueError as e:
|
||
abort(400, str(e))
|
||
log.info("planlagt #%s kl. %s av %s: %s", item.id,
|
||
time.strftime("%Y-%m-%d %H:%M", time.localtime(at)), me.name or "anonym", text)
|
||
audit("schedule", **who(me), id=item.id, at=at, text=text, voice=voice,
|
||
speed=speed, chime=chime, zones=zones)
|
||
return jsonify(item.public())
|
||
|
||
|
||
@app.post("/api/schedule/cancel")
|
||
def schedule_cancel() -> Response:
|
||
me = require("schedule")
|
||
data = request.get_json(silent=True) or {}
|
||
sched_id = str(data.get("id", "")).strip()
|
||
item = schedule.get(sched_id)
|
||
if item is None or item.status != "pending":
|
||
abort(404, "Fant ingen planlagt sending med den id-en")
|
||
# Sin egen kan man alltid avbryte. Andres krever den samme retten som
|
||
# å tømme køen – det er samme slags inngrep i noe andre har satt i gang.
|
||
if item.subject != me.subject and not me.can("clear"):
|
||
abort(403, f"{item.user or 'En annen'} planla denne. Du kan bare avbryte dine egne.")
|
||
schedule.cancel(sched_id)
|
||
audit("schedule_cancel", **who(me), id=sched_id, text=item.text)
|
||
return jsonify({"ok": True, "id": sched_id})
|
||
|
||
|
||
@app.post("/api/stop")
|
||
def stop() -> Response:
|
||
me = require("stop")
|
||
player.stop()
|
||
audit("stop", **who(me))
|
||
return jsonify({"ok": True})
|
||
|
||
|
||
@app.post("/api/clear")
|
||
def clear() -> Response:
|
||
me = require("clear")
|
||
player.clear()
|
||
audit("clear", **who(me))
|
||
return jsonify({"ok": True})
|
||
|
||
|
||
@app.get("/api/status")
|
||
def status() -> Response:
|
||
# Lukket bak «view»: køen inneholder meldingstekstene og IP-ene til dem
|
||
# som har sendt noe, og det er ikke noe alle utenfra skal få se.
|
||
me = require("view")
|
||
out = {
|
||
**player.snapshot(show_ip=me.can("clear")),
|
||
"scheduled": schedule.snapshot(),
|
||
"now": time.time(), # klienten regner «om 20 min» mot serverens klokke
|
||
}
|
||
if DISPATCHER is not None:
|
||
out["zones"] = POLLER.snapshot()
|
||
out["dispatches"] = DISPATCHER.snapshot(show_ip=me.can("clear"))
|
||
return jsonify(out)
|
||
|
||
|
||
@app.errorhandler(400)
|
||
def bad_request(e): # noqa: ANN001
|
||
return jsonify({"error": e.description}), 400
|
||
|
||
|
||
@app.errorhandler(502)
|
||
def bad_gateway(e): # noqa: ANN001
|
||
return jsonify({"error": e.description}), 502
|
||
|
||
|
||
@app.errorhandler(401)
|
||
def unauthorized(e): # noqa: ANN001
|
||
return jsonify({"error": e.description, "login": True}), 401
|
||
|
||
|
||
@app.errorhandler(403)
|
||
def forbidden(e): # noqa: ANN001
|
||
return jsonify({"error": e.description}), 403
|
||
|
||
|
||
@app.errorhandler(404)
|
||
def not_found(e): # noqa: ANN001
|
||
# Klienten snakker JSON med /api og /auth, og skal ikke få en HTML-side
|
||
# tilbake og tolke den som en feilmelding.
|
||
if request.path.startswith(("/api/", "/auth/")):
|
||
msg = e.description or ""
|
||
if not msg or "requested URL" in msg:
|
||
msg = "Finnes ikke"
|
||
return jsonify({"error": msg}), 404
|
||
return e
|
||
|
||
|
||
def check_writable() -> None:
|
||
"""Sier klart fra hvis datamappene ikke kan skrives, før noe annet.
|
||
|
||
Det vanlige tilfellet: `docker compose up` uten install.sh, så Docker
|
||
laget data/ som root mens containeren kjører som vanlig bruker. Uten
|
||
denne sjekken feiler det først ved første utrop, med en traceback.
|
||
"""
|
||
for name, path in (("cache", CACHE_DIR), ("logs", LOG_PATH.parent),
|
||
("instance", CFG.path("state")), ("schedule", SCHEDULE_PATH.parent)):
|
||
try:
|
||
path.mkdir(parents=True, exist_ok=True)
|
||
probe = path / ".skrivetest"
|
||
probe.touch()
|
||
probe.unlink()
|
||
except OSError as e:
|
||
raise SystemExit(
|
||
f"Får ikke skrevet til {path} ({e}).\n"
|
||
f"Kjører du i Docker: data/ må eies av brukeren i PUID/PGID i .env, "
|
||
f"ikke root. Fiks med sudo chown -R $(id -u):$(id -g) data/ "
|
||
f"eller kjør ./install.sh, som lager mappene riktig."
|
||
) from e
|
||
|
||
|
||
def main() -> None:
|
||
ap = argparse.ArgumentParser(description=__doc__.splitlines()[0])
|
||
ap.add_argument("--host", default=CFG.get("server", "host", "0.0.0.0"))
|
||
ap.add_argument("--port", type=int, default=int(CFG.get("server", "port", 8090)))
|
||
args = ap.parse_args()
|
||
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
|
||
logging.getLogger("werkzeug").setLevel(logging.WARNING)
|
||
check_writable()
|
||
ensure_voices()
|
||
load_voice("no_NO-talesyntese-medium") # varm opp reserven
|
||
if CFG.source:
|
||
log.info("konfigurasjon fra %s", CFG.source)
|
||
methods = ", ".join(m["method"] for m in AUTH.describe()["methods"]) or "åpen (ingen innlogging)"
|
||
venter = len(schedule.snapshot()["pending"])
|
||
player.start()
|
||
start_scheduler()
|
||
if DISPATCHER is not None:
|
||
DISPATCHER.start()
|
||
POLLER.start()
|
||
log.info("mesh: sentral for %s", ", ".join(ZONES))
|
||
elif MESH_SECRET:
|
||
log.info("mesh: sone, tar imot fra sentralen")
|
||
log.info("klar på http://%s:%d (auth: %s, sink: %s, planlagt: %d)",
|
||
args.host, args.port, methods, SINK or "standard", venter)
|
||
# waitress, ikke Flask sin utviklingsserver – og ikke gunicorn med flere
|
||
# workere: Player og planleggeren er singletons i denne prosessen, så
|
||
# flere workere ville gitt flere køer som snakker i munnen på hverandre.
|
||
# Én prosess, mange tråder.
|
||
from waitress import serve
|
||
|
||
serve(app, host=args.host, port=args.port, threads=8, ident="announcer")
|
||
|
||
|
||
if __name__ == "__main__":
|
||
main()
|