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>
333 lines
17 KiB
Markdown
333 lines
17 KiB
Markdown
# announcer – utrop på høyttaleranlegget
|
||
|
||
Liten Flask-app: tekst inn i nettleseren → forhåndslytt → «Send ut på anlegget»
|
||
spiller lyden lokalt på **denne** maskinen med `pw-play`. TTS er edge-tts
|
||
(nevrale stemmer, krever nett) med Piper som lokal reserve.
|
||
|
||
Appen er generisk: tittel, undertittel, aksentfarge og hurtigfraser kommer fra
|
||
`config.toml`, ikke fra HTML-en. Den het `sg-announcer` og var skrevet for Sauda
|
||
Gathering fram til oktober 2026.
|
||
|
||
Dokumentasjon for Kent: Athena-notatet `30 Ressurser/Teknologi/Announcer.md`.
|
||
Oppdater det, ikke denne fila, når noe som er verdt å huske endres.
|
||
|
||
Filer:
|
||
|
||
| Fil | Hva |
|
||
|---|---|
|
||
| `announcer.py` | server, kø, TTS, jingler, ruter |
|
||
| `auth.py` | `Principal`, leverandørene, OIDC-rutene |
|
||
| `config.py` | `config.toml` + miljøvariabler, standardverdier |
|
||
| `schedule.py` | planlagte sendinger, lagret i `data/scheduled.json` |
|
||
| `index.html` | hele sida, inline CSS/JS, ingen byggesteg |
|
||
| `config.example.toml` | kommentert mal; kopier til `config.toml` |
|
||
| `docs/crew-integration.md` | kontrakten mot crew-panelet |
|
||
| `tests/` | `uv run pytest` – 110 tester: auth, planlegging og HTTP-laget |
|
||
| `Dockerfile` + `compose.yaml` | **drift** – appen kjøres i Docker |
|
||
| `.env.example` | bruker-ID, portbinding og hemmeligheter |
|
||
| `compose.test.yaml` | testoppsett med dummy-IdP, se under |
|
||
| `docker/dummy_idp.py` | falsk crew-panel, så OIDC kan prøves |
|
||
|
||
## Kjøring
|
||
|
||
**Announcer kjører i Docker, på en egen klient som er koblet til
|
||
høyttaleranlegget – aldri på kitsune.** Kitsune er utviklingsmaskin; der er
|
||
det bare koden som bor.
|
||
|
||
Ny klient (Debian/Ubuntu/Mint):
|
||
|
||
```bash
|
||
git clone https://git.1x1.no/kent/announcer.git
|
||
cd announcer && ./install.sh
|
||
```
|
||
|
||
`install.sh` installerer Docker om den mangler, lager `.env` med riktig
|
||
bruker-ID og portbinding, oppretter `data/`, bygger bildet, starter
|
||
containeren og setter ufw. Den rydder også bort en eventuell gammel nativ
|
||
`announcer.service`/`sg-announcer.service`. Kan kjøres på nytt; en `.env` som
|
||
finnes blir ikke overskrevet.
|
||
|
||
Drift:
|
||
|
||
```bash
|
||
docker compose logs -f # følg loggen
|
||
docker compose restart # etter endringer i data/config.toml
|
||
docker compose up -d --build # etter endringer i koden
|
||
cat data/logs/announcements.jsonl # hvem som sendte hva
|
||
```
|
||
|
||
Alt som skal overleve en ny container ligger i `data/` på verten
|
||
(bind-mount, ikke navngitt volum, så du kan lese loggen og slippe egne
|
||
jingler i `data/chimes/` uten å gå inn i containeren):
|
||
|
||
| | |
|
||
|---|---|
|
||
| `data/voices/` | Piper-modellene, ~670 MB, lastes ned ved første oppstart |
|
||
| `data/cache/` | ferdige WAV-er etter innholdshash. Trygt å slette |
|
||
| `data/chimes/` | egne jinglefiler |
|
||
| `data/logs/` | revisjonsloggen |
|
||
| `data/instance/` | sesjonsnøkkelen |
|
||
| `data/scheduled.json` | planlagte sendinger. Lesbar med `cat` |
|
||
| `data/config.toml` | valgfri. Uten den er sida åpen |
|
||
|
||
### Lyden ut av containeren
|
||
|
||
Det eneste virkelig uvanlige i oppsettet. Tre ting må stemme:
|
||
|
||
1. Hele vertens `XDG_RUNTIME_DIR` monteres inn på samme sti. Ikke bare
|
||
`pipewire-0`: PulseAudio bruker `pulse/native` pluss en cookie i samme
|
||
mappe, og ved å ta hele mappa virker begge lydservere uten at compose må
|
||
vite hvilken maskina har. `player_cmd()` velger `pw-play` eller `paplay`.
|
||
2. `user: "${PUID}:${PGID}"` må være brukeren som **eier** socketen på
|
||
verten. Stemmer ikke ID-en, kommer det ingen lyd. `install.sh` fyller inn
|
||
`id -u`/`id -g`.
|
||
3. Maskina må være logget inn grafisk, ellers finnes socketen ikke.
|
||
Containeren starter ved boot, altså før innlogging, og avspilling feiler
|
||
til noen logger inn. Det retter seg selv: `player_cmd()` slås opp per
|
||
melding, så første utrop etter innlogging virker uten omstart. Derfor
|
||
anbefaler `install.sh` automatisk innlogging.
|
||
|
||
### Brannmur: BIND_IP, ikke ufw
|
||
|
||
**Dockers publiserte porter går forbi ufw.** De settes opp i nat-tabellen,
|
||
før ufws regler, så `ufw deny` på porten gjør ingenting. Tilgangen styres
|
||
derfor av `BIND_IP` i `.env`:
|
||
|
||
| | |
|
||
|---|---|
|
||
| `0.0.0.0` | alle nett maskina er på |
|
||
| `100.75.x.x` | bare NetBird. `install.sh` velger dette når `wt0` finnes |
|
||
| `127.0.0.1` | bare maskina selv |
|
||
|
||
ufw settes fortsatt for maskinens øvrige porter (ssh på `wt0`), men den er
|
||
ikke involvert i tilgangen til announcer. Dette er en endring fra den native
|
||
tida, da porten måtte åpnes eksplisitt i ufw.
|
||
|
||
## Testing i Docker
|
||
|
||
`docker compose -f compose.test.yaml up --build` gir to containere:
|
||
|
||
| | |
|
||
|---|---|
|
||
| `http://localhost:8091` | announcer med PIN, JWT **og** OIDC slått på (`docker/config.test.toml`) |
|
||
| `http://localhost:9000` | `docker/dummy_idp.py`, som later som den er crew-panelet |
|
||
|
||
Egen fil, eget prosjektnavn og port 8091, så den ikke kan forveksles med
|
||
eller forstyrre den som kjører i drift. Begge porter er bundet til
|
||
`127.0.0.1`, så testriggen – som har PIN `1234` – aldri ligger åpen på
|
||
LAN-et.
|
||
|
||
Dummy-IdP-en finnes fordi crew-panelet ikke har OIDC ennå: uten den kan
|
||
OIDC-veien ikke prøves i det hele tatt. Den kjører en ekte
|
||
autorisasjonskode-runde med PKCE (og avviser feil `code_verifier`), signerer
|
||
`id_token` med klienthemmeligheten, og lar deg velge hvem du logger inn som –
|
||
Kari (`crew`), Kent (`admin`) eller Ola (ingen roller, skal gi 403, ikke 401).
|
||
`http://localhost:9000/token?who=kari&go=1` logger deg rett inn via
|
||
token-lenka, slik panelet skal gjøre.
|
||
|
||
Merk i `docker/config.test.toml`: `authorization_endpoint` peker på
|
||
`localhost:9000` fordi nettleseren skal dit, mens `token_endpoint` peker på
|
||
`idp:9000` fordi announcer selv snakker med den inne i compose-nettet. Delt
|
||
horisont, og samtidig en test av at manuelle endepunkter virker når et panel
|
||
mangler discovery.
|
||
|
||
Lyd: `/run/user/1000` er montert inn, så `pw-play` i containeren spiller på
|
||
vertens anlegg. Kommenter ut den linja i `compose.yaml` hvis du bare skal
|
||
teste innlogging.
|
||
|
||
`compose.test.yaml` bind-monterer koden, så endringer slår inn med en
|
||
restart. Driftsbildet kopierer koden inn i stedet, så det som kjører er
|
||
uavhengig av hva som ligger i mappa. `UV_PROJECT_ENVIRONMENT=/opt/venv` er
|
||
derfor ikke pynt: med bind-mount ville containerens `uv sync` ellers
|
||
overskrevet `.venv` på verten.
|
||
|
||
## Konfigurasjon
|
||
|
||
Tre lag, siste vinner: `DEFAULTS` i `config.py` → `config.toml` →
|
||
`ANNOUNCER_*`-miljøvariabler. **Uten `config.toml` virker alt**, med de gamle
|
||
standardverdiene og uten innlogging; det er med vilje, og
|
||
`test_uten_konfig_er_alt_apent` passer på det.
|
||
|
||
`config.toml` er gitignorert. `config.example.toml` er malen, og den er
|
||
kommentert – les den framfor å gjette.
|
||
|
||
Hemmeligheter settes som miljøvariabler i enheten, ikke i `config.toml`:
|
||
`ANNOUNCER_PIN`, `ANNOUNCER_JWT_SECRET`, `ANNOUNCER_OIDC_CLIENT_SECRET`,
|
||
`ANNOUNCER_SECRET_KEY`. Hele tabellen over variabler står i `ENV` i `config.py`;
|
||
den er eneste sted en ny variabel legges til.
|
||
|
||
## Autentisering
|
||
|
||
Modellen er én datatype og én funksjon: `Principal` (hvem + roller +
|
||
rettigheter) og `require(action)` som enten gir deg den eller stopper
|
||
forespørselen. Handlingene er `view`, `preview`, `announce`, `stop`, `clear`.
|
||
|
||
Tre leverandører, prøvd i rekkefølge, alle valgfrie:
|
||
|
||
- **pin** – `X-Pin`-header, som før. `hmac.compare_digest`, ikke `==`.
|
||
Får rollen `admin` som standard, så gammelt oppsett oppfører seg som før.
|
||
Virker uten nett, derfor beholdt.
|
||
- **jwt** – `Authorization: Bearer <jwt>` fra crew-panelet. HS256 med delt
|
||
hemmelighet, eller RS256/ES256 mot panelets JWKS (PyJWT cacher nøklene).
|
||
Krever `exp`; roller leses av et konfigurerbart claim.
|
||
- **oidc** – autorisasjonskode med PKCE. `/auth/login` → panelet →
|
||
`/auth/callback`. Discovery hentes først ved første innlogging, så tjenesten
|
||
starter selv om panelet er nede.
|
||
|
||
**En leverandør er aktiv når den står i `auth.providers` *eller* når den er
|
||
ferdig konfigurert.** Derfor slår `ANNOUNCER_PIN=1234` alene fortsatt på PIN.
|
||
Er ingen aktiv, er alt åpent.
|
||
|
||
`/auth/token?t=<jwt>` er broa mens panelet mangler OIDC: panelet lenker hit med
|
||
et kortlevd token, announcer setter sesjonscookie. Se
|
||
`docs/crew-integration.md` – det er kontrakten panelet skal oppfylle.
|
||
|
||
Roller → handlinger står i `[roles]` i `config.toml`. `"*"` betyr alt.
|
||
Rettighetene slås opp på nytt per forespørsel, så en endring der slår inn uten
|
||
at noen må logge inn igjen; nye `[auth.*]`-verdier krever omstart.
|
||
|
||
401 betyr «vi vet ikke hvem du er, logg inn», 403 betyr «vi vet hvem du er, men
|
||
rollen din får ikke dette». Et gyldig token uten roller gir 403, ikke 401 – å
|
||
logge inn på nytt ville ikke hjulpet.
|
||
|
||
Sesjonsnøkkelen genereres én gang og lagres i `instance/secret_key` (0600).
|
||
Uten den logges alle ut ved hver `systemctl restart`. `SESSION_COOKIE_SECURE`
|
||
settes bare når `server.behind_tls` er sann – appen kjører på rå HTTP over LAN
|
||
og NetBird, og med Secure på ville cookien aldri blitt sendt.
|
||
|
||
## Utvikling på kitsune
|
||
|
||
`uv run announcer.py --port 8099` kjører appen direkte, uten Docker. Det er
|
||
veien for å prøve en endring kjapt; `uv run pytest` for testene. Men
|
||
**ingen tjeneste skal stå og kjøre på kitsune** – det var slik det var før, og
|
||
det er ryddet bort. Skal noe prøves med innlogging, bruk
|
||
`compose.test.yaml`.
|
||
|
||
## Planlagte sendinger
|
||
|
||
`schedule.py` er den første delen av appen som **må** ligge på disk. Kø og
|
||
historikk har alltid bare levd i minnet, og det går bra fordi en melding
|
||
spilles med en gang. En melding planlagt til 18:00 må overleve at containeren
|
||
startes om 17:45, så den lagres i `data/scheduled.json`, skrevet til side og
|
||
flyttet på plass så fila aldri er halvskrevet.
|
||
|
||
Ingen database, med vilje: dette er en håndfull meldinger på et arrangement,
|
||
og en fil man kan lese med `cat` er verdt mer her enn indekser.
|
||
|
||
Modulen vet ingenting om Flask eller avspilling. Den lagrer, og den svarer på
|
||
«hva skal sendes nå?». `_scheduler()` i `announcer.py` eier tråden som spør
|
||
hvert 20. sekund og legger forfalte meldinger i den vanlige køen – derfra
|
||
oppfører de seg nøyaktig som en melding sendt for hånd.
|
||
|
||
**Bare engangs**, ikke gjentakende. Derfor sender klienten et absolutt
|
||
tidspunkt (epoch), og hele tidssoneproblemet forsvinner: containeren kan stå
|
||
i UTC uten at det gjør noe. `TZ` settes likevel fra `.env`, ellers står
|
||
loggen i UTC mens du står i CEST. Skal gjentakelse inn senere, må «09:00»
|
||
tolkes i en tidssone, og da begynner `TZ` å bety noe.
|
||
|
||
**Gikk den glipp av, spilles den ikke.** `due()` skiller på `grace_seconds`
|
||
(standard 120): innenfor den sendes meldingen, utenfor markeres den `missed`.
|
||
Nådefristen er slingringsmonn for at tråden våkner med mellomrom og for en
|
||
kjapp omstart – ikke en policy om å ta igjen etterslep. «Turneringen starter
|
||
om ti minutter» skal ikke ut på anlegget en time etter at maskina kom
|
||
tilbake. `due()` endrer ingenting selv; `announcer.py` markerer først etter at
|
||
meldingen faktisk er lagt i kø, så ingenting kan stå som «sendt» uten å ha
|
||
blitt det.
|
||
|
||
Forhåndslytten gir en bonus: planlegging er låst til den samme nøkkelen som
|
||
«Send ut», så man planlegger bare det man har hørt – og siden forhåndslytt
|
||
varmer `cache/`, ligger WAV-en ferdig på disk. **En planlagt edge-tts-melding
|
||
spiller altså kl. 18:00 selv om nettet er nede da.**
|
||
|
||
Sine egne planlagte sendinger kan man alltid avbryte. Andres krever `clear` –
|
||
samme rett som å tømme køen, siden det er samme slags inngrep i noe andre har
|
||
satt i gang.
|
||
|
||
## Hvordan det henger sammen
|
||
|
||
- `/api/config` er alt sida trenger for å tegne seg selv: branding, stemmer,
|
||
jingler og en `auth`-blokk med hvem du er og hva du får gjøre. Svarer alltid
|
||
200, også med feil PIN – klienten spør «hvem er jeg?», og «ikke innlogget» er
|
||
et svar, ikke en feil. Route-funksjonen heter `client_config()`; navnet
|
||
`config` er tatt av modulen.
|
||
- `/api/preview` syntetiserer og returnerer WAV til nettleseren. Ingen lyd på PC-en.
|
||
- `/api/announce` legger meldingen i én FIFO-kø; en bakgrunnstråd spiller én om
|
||
gangen med `pw-play`. `/api/stop` dreper gjeldende avspilling, `/api/clear`
|
||
tømmer køen.
|
||
- En melding går `queued → preparing → playing → done | stopped | failed`.
|
||
`preparing` er syntesen – de første sekundene av hver edge-tts-melding, der
|
||
det er stille. Stopp virker også der: `Player.stop()` setter statusen, og
|
||
`run_once()` sjekker den før den starter avspilling. En planlagt sending
|
||
følger med helt i mål via `on_finished`, så planen viser `sent`, `failed`
|
||
eller `stopped` – det som faktisk skjedde – ikke bare at den ble lagt i kø.
|
||
- `/api/status` krever `view`. Den var åpen før, og lekket både meldingstekster
|
||
og IP-ene til dem som hadde sendt noe. IP vises nå bare for den som har
|
||
`clear`; alle andre ser navnet.
|
||
- `/` er åpen uansett, ellers kunne ingen komme til innloggingsknappen.
|
||
- Knappen «Send ut» er låst til nøyaktig den teksten/stemmen/hastigheten som
|
||
sist ble forhåndslyttet. Det er med vilje: ingen skal sende noe de ikke har hørt.
|
||
- Ferdige WAV-er bufres i `cache/` etter innholdshash. Trygt å slette.
|
||
- `audit()` skriver én JSON-linje per hendelse til `logs/announcements.jsonl`
|
||
(`announce`, `played`, `stop`, `clear`) med navn, subject, metode og IP.
|
||
Rullerer til `.jsonl.1` ved 5 MB. Den feiler aldri forespørselen – en logg
|
||
som ikke lar seg skrive skal ikke hindre et utrop.
|
||
- Jingler: de innebygde (`BUILTIN_CHIMES`) genereres i Python med `_tone`/
|
||
`_mix`/`_overlay`, ingen lydfiler. Egne filer i `chimes/` (wav/mp3/ogg/flac)
|
||
dukker opp i menyen som `file:<stem>`; ffmpeg kutter til 6 s, normaliserer
|
||
til -18 LUFS og fader ut. Mappa leses ved hver forespørsel, så nye filer
|
||
vises uten restart. Toppnivå på de innebygde ligger mellom -4 og -10 dBFS,
|
||
talen går til 0 dBFS, så jinglen skal aldri være høyere enn stemmen.
|
||
|
||
## Stemmer og motorer
|
||
|
||
`VOICES_LIST` i `announcer.py` er eneste sted en stemme legges til. To motorer:
|
||
|
||
- **edge** (`_edge`): Microsofts nevrale stemmer via `edge-tts`-pakken. Gratis,
|
||
ingen nøkkel, men krever internett og er en uoffisiell bruk av Edge sin
|
||
«les høyt»-tjeneste – kan slutte å virke uten varsel. Norsk: bare
|
||
`nb-NO-FinnNeural` og `nb-NO-PernilleNeural`. `*MultilingualNeural`-stemmene
|
||
kan lese norsk med engelsk aksent. Liste: `uv run edge-tts --list-voices`.
|
||
Leverer MP3 24 kHz; dekodes med ffmpeg til PCM i `edge_pcm()`.
|
||
- **piper** (`_piper`): lokalt, robotaktig, reserve når nettet er nede.
|
||
Modellen lastes ned ved oppstart til `voices/` (gitignored). Norsk finnes
|
||
bare som `talesyntese-medium` og `nvcc-medium` (ti talere, `speaker`-indeks).
|
||
Full liste: `https://huggingface.co/rhasspy/piper-voices/resolve/main/voices.json`.
|
||
|
||
Vil Kent ha enda bedre lyd: ElevenLabs (nøkkel, best norsk) eller OpenAI
|
||
`gpt-4o-mini-tts` er neste trinn; legg til som ny motor i `synthesize()`.
|
||
|
||
## Feller
|
||
|
||
- **ufw styrer ikke Docker-porter.** Se «Brannmur» over: tilgangen avgrenses
|
||
med `BIND_IP` i `.env`. En `ufw deny` på porten gir falsk trygghet.
|
||
- **Ingen lyd?** Nesten alltid `PUID`/`PGID` i `.env` som ikke er brukeren
|
||
som eier lydsocketen, eller at maskina ikke er logget inn grafisk. Sjekk
|
||
hva containeren velger:
|
||
`docker compose exec announcer python -c "import announcer,pathlib;print(announcer.player_cmd(pathlib.Path('/tmp/x.wav')))"`
|
||
- `pkill -f announcer.py` fra et skall som selv har `announcer.py` i
|
||
kommandolinja dreper skallet. Gjelder utvikling med `uv run`; i drift er
|
||
det `docker compose`.
|
||
- Trådene (`player.start()`, `start_scheduler()`) startes i `main()`, ikke
|
||
ved import. Det er det som gjør at `tests/test_routes.py` kan kjøre rutene
|
||
med en test_client og drive køen ett steg om gangen med
|
||
`player.run_once()`. Starter du dem ved import igjen, mister du den
|
||
muligheten.
|
||
- **Én prosess.** `main()` kjører waitress med mange tråder, ikke gunicorn
|
||
med flere workere: `Player` og planleggeren er singletons i prosessen, og
|
||
flere workere ville gitt flere køer som snakker i munnen på hverandre.
|
||
- **Oppgradering:** `schedule` er en ny handling. Har et oppsett et eksplisitt
|
||
`[roles]` i `config.toml`, får `crew` den *ikke* automatisk – eksplisitt
|
||
konfigurasjon overstyrer standardverdiene, og da blir svaret 403 «Rollen din
|
||
har ikke lov til dette». Legg `"schedule"` inn selv. Dette traff
|
||
`docker/config.test.toml` under testing.
|
||
- `tests/conftest.py` nuller ut alle `ANNOUNCER_*`-variabler før hver test.
|
||
Uten den arver testene maskinens miljø – systemd-enheten setter
|
||
`ANNOUNCER_PORT`, og compose setter JWT- og OIDC-hemmeligheter, noe som
|
||
gjorde 28 tester røde i containeren med feilmeldinger som ikke pekte på noe
|
||
galt.
|
||
- `pyproject.toml` har `[tool.uv] package = false`. Appen er løse `.py`-filer
|
||
som kjøres på plass, ikke en pakke; uten den vil `uv sync` bygge et wheel og
|
||
klage på at `announcer/__init__.py` mangler.
|
||
- Piper 1.8 bruker `SynthesisConfig(length_scale=…)`; lavere = raskere.
|
||
- Forhåndslytt svarer 502 med feilmelding hvis edge-tts feiler (ingen nett).
|
||
Køen markerer meldingen som `failed`. Bytt til en lokal reserve-stemme.
|