Announcer skal kjøre standalone på klienten som er koblet til anlegget, i Docker – aldri på utviklingsmaskina. install.sh installerte nativt med apt og uv, og det passer ikke lenger. - install.sh er nå en Docker-bootstrap: installerer Docker om den mangler, lager .env med riktig bruker-ID og portbinding, oppretter data/, bygger, starter, og setter ufw. Den rydder også bort gamle native enheter, både announcer.service og sg-announcer.service. - Dockerfile kopierer koden inn istedenfor å låne den fra et bind-mount, så det som kjører i drift er uavhengig av hva som ligger i mappa. - Alt som skal overleve en ny container ligger i data/ på verten. Bind-mount og ikke navngitt volum, med vilje: da kan man lese data/logs/announcements.jsonl og slippe egne jingler i data/chimes/ uten å gå inn i containeren, og eierskapet følger brukeren istedenfor root. - Testriggen er flyttet til compose.test.yaml, med eget prosjektnavn, så den ikke kan forveksles med eller forstyrre drift. Lyden ut av containeren krever tre ting, og de er dokumentert i AGENTS.md: hele XDG_RUNTIME_DIR montert inn (ikke bare pipewire-socketen – PulseAudio trenger pulse/native og en cookie i samme mappe, og da virker begge lydservere), user: PUID:PGID lik eieren av socketen, og en innlogget grafisk økt. Containeren starter ved boot, altså før innlogging, så avspilling feiler til noen logger inn – det retter seg selv, siden player_cmd() slås opp per melding. Verifisert: lyden spiller fra driftscontaineren. Brannmuren er den viktige endringen fra nativ tid. Dockers publiserte porter settes opp i nat-tabellen, før ufws regler, så en ufw-regel på porten gjør ingenting. Tilgangen styres nå av BIND_IP i .env, og install.sh setter den til NetBird-adressen når wt0 finnes. ufw settes fortsatt for ssh, men er ikke involvert i tilgangen til announcer. Testriggen bindes nå til 127.0.0.1. Den sto med PIN 1234 publisert på 0.0.0.0, og gikk altså forbi ufw ut på LAN-et. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
275 lines
13 KiB
Markdown
275 lines
13 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 |
|
||
| `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/test_auth.py` | `uv run pytest` |
|
||
| `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/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`.
|
||
|
||
## 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.
|
||
- `/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`.
|
||
- `player = Player()` står på modulnivå, så avspillingstråden starter ved
|
||
`import announcer` – også i `install.sh`, som importerer modulen for å laste
|
||
ned stemmer. Harmløst, men greit å vite.
|
||
- `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.
|