Persona ↔ Radio integration
End-to-end BGM (background-music) channel selection for a streamer's agent. The streamer picks a radio channel in Hub → persona-server spawns a per-agent WebSocket bridge to radio.blal.pro → now_playing frames land on the PersonaEvent bus → the live page renders «currently playing …» without ever opening a second WS to radio.
Shipped 2026-05-22 in three coordinated commits across three repos.
The full loop
[Hub UI] [Persona server] [Radio service]
radio.blal.pro
streamer picks
"Lo-fi 24/7" ──PATCH──► agent_overrides handler
bgm_source = "radio:lo-fi-24-7"
│
▼
reconcile_bridge():
spawn tokio task
│
▼
connect_async ─────WSS─────► /v1/radio/channels/
(1s→30s backoff) lo-fi-24-7/subscribe
│
│◄─── now_playing frame ───
▼
PersonaEvent::Custom {
topic: "radio.now_playing",
payload: { title, artist, audio_url, ... }
}
│
▼ (existing live-page SSE/WS
subscribes once per room)
▼
[Live page]
"🎶 Amber Lo-fi · OpenAlice CDN"What lives where
| Layer | Repo | Module | Owns |
|---|---|---|---|
| Music distribution | openalice-radio | src/handlers/ws.rs, src/seed.rs | 4 shared channels (lo-fi-24-7, synthwave-24-7, jazz-cafe, ambient-spacy) + 1Hz scheduler + WS fan-out + tracks CRUD + Lyria 3 share pipeline (planned). Endpoint: wss://radio.blal.pro/v1/radio/channels/:slug/subscribe |
| Music origin | openalice-cdn | assets/audio/bgm/*.mp3 | CC0 public-domain placeholders bundled from Free Music Archive (HoliznaCC0 etc.) — see ATTRIBUTIONS.md. Future: Lyria-generated own tracks |
| Bridge engine | openalice-persona | server/src/radio_bridge.rs | connect_async + JSON frame parse + exponential-backoff reconnect (1s/2s/5s/10s/30s cap) + Drop-aborts-task BridgeHandle. Gated behind ws-bridge cargo feature, default on |
| Bridge wiring | openalice-persona | server/src/agent_overrides.rs, server/src/state.rs, server/src/boot.rs | AppState.bridge_registry: Arc<Mutex<HashMap<(Uuid, Uuid), BridgeHandle>>>. PATCH transitions spawn/drop bridges. Startup-restore re-spawns existing bridges so a restart doesn't drop live subscriptions |
| Picker UI | openalice-app | src/app/account/agents/[id]/settings/page.tsx | 5-card grid (Тишина + 4 channels) + live now-playing strip + roadmap placeholder + PATCH to persona. Alice voice copy throughout |
Wire shape
WS frame received from radio:
{
"channel_id": "...",
"track_id": "...",
"title": "Amber Lo-fi",
"artist": "OpenAlice CDN",
"audio_url": "https://cdn.blal.pro/audio/bgm/lofi-amber.mp3",
"duration_ms": 180000,
"started_at": "2026-05-22T10:39:10.322812+00:00",
"position_ms_at_start": 0
}Re-emitted on PersonaEvent bus under topic radio.now_playing — exact payload preserved, so the live-page consumer (existing SSE subscriber on the agent's default room) reads it without needing to parse a second protocol.
Format conventions
bgm_sourcecolumn onagent_overrides:NULL(silent) or"radio:<slug>"(regexradio:[a-z0-9-]+). Invalid slug → HTTP 400.- Future extensions reserved:
"file:<url>"(custom upload),"lyria:<id>"(in-room Lyria generation). - Slug formats live in radio's
seed.rs— current canonical four:lo-fi-24-7,synthwave-24-7,jazz-cafe,ambient-spacy.
Non-trivial design decisions
- Why bridge per (tenant, agent) and not per channel? A single agent's
bgm_sourceis the unit of intent. Two agents picking the same channel could share a WS connection, but the bookkeeping savings don't justify the lifecycle complexity. Per-agent bridges keep abort semantics trivial and isolate failure modes. - Why startup-restore? Persona-server restarts (deploy, OOM, manual) shouldn't drop live radio subscriptions.
boot::spawn_radio_bridge_restorescansagent_overrideswith non-nullbgm_sourceand re-spawns bridges before the HTTP listener opens. - Why exponential backoff to 30s? Radio service uptime is high but not perfect; bare reconnect would spam logs on outage. 30s cap means ~120 retries/hour worst case, which is light.
- Why not let live page subscribe to radio directly? Two reasons: (1) the live page already subscribes once per room to PersonaEvent — adding a second WS doubles handshake + connection-state surface for no benefit; (2) the persona side may want to react to track changes (announce on chat, emote, etc.) — easier when frames flow through its event bus.
Sentence-level vs frame-level emotion (relevant context for TTS)
Music streaming is frame-on-change, not chunked. Radio emits one now_playing frame per track transition; persona forwards immediately. This is the inverse of TTS streaming (where sentence-level chunks are the correct grain — see radio_bridge.rs comments and the TTS chunker in tts_stream.rs). Music = whole-frame events; voice = sentence chunks. Different rhythms, both correct for their domain.
Verification
End-to-end verified 2026-05-22 10:39 UTC: DB UPDATE → restart → boot restore → connect_async → connected slug=lo-fi-24-7 → 180 s "Amber Lo-fi" track snapshot returned by /v1/radio/channels/lo-fi-24-7/ now-playing. 558 persona-server tests + 674 hub tests green; zero regressions. Bridge feature is default = ["ws-bridge"] — no env flag needed.
Open / future
- Hub UI v2: custom track upload (
bgm_source = "file:<url>") - In-room Lyria 3 generation (
bgm_source = "lyria:<prompt-id>"), draws fromopenalice-radio's already-built Lyria-share pipeline - Cross-agent channel sharing (one bridge → many agents' rooms)
- Persona reacting in-chat to track changes («ой, я люблю эту»)