Gjennomgang: Stopp under syntese, planstatus, waitress, tester på HTTP-laget
Sju ting fra en gjennomgang av det som ble bygd i går. Stopp virket ikke mens lyden ble laget. Player.stop() drepte self.proc, men under syntesen finnes ingen prosess ennå – det er de første sekundene av hver edge-tts-melding – så knappen gjorde ingenting, og meldingen gikk ut etterpå. Nå har køen statusen preparing, stop() setter stopped på den, og run_once() sjekker det før den starter avspilling. UI-et viser «Lager lyd …» i stedet for å late som det spilles i stillhet. Planlagte sendinger løy om status. De ble markert «sendt» idet de ble lagt i kø, og ble stående slik selv om avspillingen feilet. Player har fått en on_finished-callback, og planen følger nå meldingen helt i mål: sent, failed eller stopped – det som faktisk skjedde. Statusene i schedule.py er utvidet tilsvarende (queued er «lagt i kø», sent er «spilt ferdig»). Flask sin utviklingsserver er byttet ut med waitress. Én prosess med mange tråder, med vilje: Player og planleggeren er singletons, så gunicorn med flere workere ville gitt flere køer som snakker i munnen på hverandre. HTTP-laget hadde null tester. import announcer startet tråder og dro inn Piper, så rutene lot seg ikke prøve. Trådene startes nå i main(), og run_once() driver køen ett steg om gangen. tests/test_routes.py dekker rutene, valideringen, køen, Stopp i begge faser, revisjonsloggen og hele planleggingsflyten gjennom HTTP. 30 nye tester, 110 totalt. Kappløp i synthesize(): tmp-fila hadde fast navn, så to som forhåndslyttet samme tekst samtidig skrev til samme fil, og den ene rename feilet. Unik tmp-fil per kall. verify_signature = false er fjernet. Det var en felle: én feilskrevet linje i config, og alle tokens ble godtatt. Ingen trenger den. docker compose up uten install.sh krasjet kryptisk ved første utrop, fordi Docker lagde data/ som root. check_writable() sier nå klart fra ved oppstart, med hva som må gjøres. Verifisert live i container: Stopp under edge-tts-syntese gir stopped, ikke done. Server-headeren er waitress sin, ikke werkzeugs. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
parent
04b3712ef5
commit
eb27a162d5
9 changed files with 565 additions and 81 deletions
232
announcer.py
232
announcer.py
|
|
@ -298,13 +298,19 @@ def synthesize(text: str, voice_name: str, speed: float, chime: str) -> Path:
|
|||
pcm += chunk.audio_int16_bytes
|
||||
pcm += b"\x00\x00" * int(rate * 0.3)
|
||||
|
||||
tmp = path.with_suffix(".tmp")
|
||||
# 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))
|
||||
tmp.rename(path)
|
||||
os.replace(tmp, path)
|
||||
return path
|
||||
|
||||
|
||||
|
|
@ -325,8 +331,14 @@ class Item:
|
|||
subject: str = "" # stabil id, f.eks. "jwt:kent"
|
||||
method: str = "none"
|
||||
created: float = field(default_factory=time.time)
|
||||
status: str = "queued" # queued | playing | done | stopped | failed
|
||||
# queued -> preparing (syntese) -> playing -> done | stopped | failed
|
||||
status: str = "queued"
|
||||
error: str = ""
|
||||
schedule_id: str = "" # satt når meldingen kom fra planleggeren
|
||||
|
||||
@property
|
||||
def active(self) -> bool:
|
||||
return self.status in ("preparing", "playing")
|
||||
|
||||
def public(self, *, show_ip: bool = False) -> dict:
|
||||
return {
|
||||
|
|
@ -360,17 +372,32 @@ def player_cmd(path: Path) -> list[str]:
|
|||
|
||||
|
||||
class Player:
|
||||
def __init__(self) -> None:
|
||||
"""É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
|
||||
threading.Thread(target=self._loop, daemon=True, name="player").start()
|
||||
# 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) -> Item:
|
||||
who: auth.Principal | None = None, schedule_id: str = "") -> Item:
|
||||
with self.lock:
|
||||
self.seq += 1
|
||||
item = Item(
|
||||
|
|
@ -378,6 +405,7 @@ class Player:
|
|||
user=who.name if who else "",
|
||||
subject=who.subject if who else "",
|
||||
method=who.method if who else "none",
|
||||
schedule_id=schedule_id,
|
||||
)
|
||||
self.history.append(item)
|
||||
del self.history[:-50]
|
||||
|
|
@ -385,11 +413,18 @@ class Player:
|
|||
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()
|
||||
if self.current:
|
||||
self.current.status = "stopped"
|
||||
|
||||
def clear(self) -> None:
|
||||
while True:
|
||||
|
|
@ -406,43 +441,56 @@ class Player:
|
|||
"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 i.status not in ("queued", "playing")][:15],
|
||||
if not i.active and i.status != "queued"][:15],
|
||||
}
|
||||
|
||||
def _loop(self) -> None:
|
||||
while True:
|
||||
item = self.q.get()
|
||||
if item.status != "queued":
|
||||
continue
|
||||
item.status = "playing"
|
||||
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:
|
||||
path = synthesize(item.text, item.voice, item.speed, item.chime)
|
||||
with self.lock:
|
||||
self.current = item
|
||||
try:
|
||||
path = synthesize(item.text, item.voice, item.speed, item.chime)
|
||||
cmd = player_cmd(path)
|
||||
with self.lock:
|
||||
self.proc = subprocess.Popen(cmd, 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)
|
||||
|
||||
|
||||
player = Player()
|
||||
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
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
|
|
@ -458,32 +506,55 @@ schedule = sched.Schedule(
|
|||
TICK = max(5.0, float(CFG.get("schedule", "tick_seconds", 20)))
|
||||
|
||||
|
||||
def _scheduler() -> None:
|
||||
"""Flytter forfalte meldinger inn i den vanlige køen.
|
||||
def _scheduled_finished(item: Item) -> None:
|
||||
"""Players on_finished: lar planen følge meldingen helt i mål.
|
||||
|
||||
Egen tråd, og den 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ø.
|
||||
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)
|
||||
|
||||
|
||||
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:
|
||||
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:
|
||||
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:
|
||||
queued = player.enqueue(
|
||||
item.text, item.voice, item.speed, item.chime,
|
||||
sender="planlagt",
|
||||
who=auth.Principal(item.subject, item.user, (), item.method, frozenset()),
|
||||
)
|
||||
schedule.mark(item.id, "sent", 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)
|
||||
scheduler_tick()
|
||||
except Exception: # noqa: BLE001
|
||||
# En feil her skal ikke drepe tråden; da ville alle senere
|
||||
# planlagte sendinger stilltiende blitt borte.
|
||||
|
|
@ -491,7 +562,8 @@ def _scheduler() -> None:
|
|||
time.sleep(TICK)
|
||||
|
||||
|
||||
threading.Thread(target=_scheduler, daemon=True, name="scheduler").start()
|
||||
def start_scheduler() -> None:
|
||||
threading.Thread(target=_scheduler_loop, daemon=True, name="scheduler").start()
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
|
|
@ -721,6 +793,29 @@ def not_found(e): # noqa: ANN001
|
|||
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"))
|
||||
|
|
@ -728,15 +823,24 @@ def main() -> None:
|
|||
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()
|
||||
log.info("klar på http://%s:%d (auth: %s, sink: %s, planlagt: %d)",
|
||||
args.host, args.port, methods, SINK or "standard", venter)
|
||||
app.run(host=args.host, port=args.port, threaded=True)
|
||||
# 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__":
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue