ryu_hardware/session.rs
1//! Per-connection realtime session: the bridge from RHP frames to existing Core
2//! seams (PROTOCOL.md §4).
3//!
4//! One [`HardwareSession`] exists per live WS connection. It owns the per-turn
5//! state and routes work IN-PROCESS (never self-HTTP) to:
6//! - chat: [`crate::sidecar::adapters::run_text_turn`] (the same non-stream
7//! text-turn primitive the off-chat `AgentRunner` uses) for the
8//! model turn, plus [`crate::server::voice`] for ASR/TTS.
9//! - ambient: the [`crate::ingest::MeetingIngest`] seam
10//! ([`MeetingIngest::append_segment`]) feeding the long-running
11//! meeting that is the ambient session — inverted so this crate never
12//! links `ryu_meetings`.
13//!
14//! Opus decode/encode happens at the codec edge ([`super::codec`]) so the rest of
15//! Core sees PCM/WAV. The WS upgrade + frame pump lives in
16//! `server::hardware_ws`; this type holds the logic the pump drives.
17//!
18//! ## Streaming model
19//!
20//! [`run_text_turn`] is non-streaming (it returns the full reply text). For v1 we
21//! emit the reply as sentence-chunked `chat_delta`s + a `chat_end`, then
22//! synthesize the whole reply to TTS. True per-token deltas would require
23//! consuming the SSE chat adapter — out of scope for the device link.
24
25use std::sync::Arc;
26
27use anyhow::Result;
28
29use super::codec::{self, UplinkDecoder, DOWNLINK_RATE, FRAME_MS, UPLINK_RATE};
30use super::protocol::{AudioFormat, Caps, DeviceType, Emotion, Mode, RhpServerMsg};
31use crate::ingest::MeetingIngest;
32
33/// Process-global registry of live device WS senders, so out-of-band producers
34/// (the dashboard refresh loop, the ambient rolling-summary) can push a control
35/// message to a connected device without holding its socket. Keyed by `device_id`;
36/// the WS handler registers a clone of its outbound `mpsc::Sender` on connect and
37/// removes it on disconnect (review gap #4: the desk e-ink got no content because
38/// nothing ever told it to re-poll).
39///
40/// This is the hardware analog of `ryu_dashboards::store`'s SSE broadcast: the
41/// desktop learns of fresh widget data over SSE; a device learns over its RHP WS.
42pub mod live {
43 use super::{RhpServerMsg, SessionOutput};
44 use std::collections::HashMap;
45 use std::sync::OnceLock;
46 use tokio::sync::{mpsc, Mutex};
47
48 static REGISTRY: OnceLock<Mutex<HashMap<String, mpsc::Sender<SessionOutput>>>> =
49 OnceLock::new();
50
51 fn registry() -> &'static Mutex<HashMap<String, mpsc::Sender<SessionOutput>>> {
52 REGISTRY.get_or_init(|| Mutex::new(HashMap::new()))
53 }
54
55 /// Register a connected device's outbound sender. Replaces any prior entry (a
56 /// reconnect supersedes the stale socket).
57 pub async fn register(device_id: &str, tx: mpsc::Sender<SessionOutput>) {
58 registry().lock().await.insert(device_id.to_string(), tx);
59 }
60
61 /// Remove a device's sender on disconnect. Idempotent.
62 pub async fn unregister(device_id: &str) {
63 registry().lock().await.remove(device_id);
64 }
65
66 /// Whether a device currently has a live socket (so a producer can skip work
67 /// for offline devices — the device will re-poll on its own cadence anyway).
68 pub async fn is_connected(device_id: &str) -> bool {
69 registry().lock().await.contains_key(device_id)
70 }
71
72 /// Push one control message to a connected device. Returns `true` if it was
73 /// queued (the device is connected and its channel is not full/closed). A closed
74 /// channel is pruned so it isn't retried.
75 pub async fn send(device_id: &str, msg: RhpServerMsg) -> bool {
76 let tx = {
77 let map = registry().lock().await;
78 map.get(device_id).cloned()
79 };
80 match tx {
81 Some(tx) => match tx.try_send(SessionOutput::Control(msg)) {
82 Ok(()) => true,
83 Err(mpsc::error::TrySendError::Closed(_)) => {
84 unregister(device_id).await;
85 false
86 }
87 // Full: the device is busy draining; the nudge is best-effort.
88 Err(mpsc::error::TrySendError::Full(_)) => false,
89 },
90 None => false,
91 }
92 }
93}
94
95/// What the session wants the WS pump to send back to the device. The pump
96/// serializes control variants to TEXT frames and audio to BINARY frames.
97pub enum SessionOutput {
98 /// A control message to serialize and send.
99 Control(RhpServerMsg),
100 /// One Opus packet of TTS audio (24 kHz, 60 ms) to send as a BINARY frame.
101 Audio(Vec<u8>),
102}
103
104impl SessionOutput {
105 fn control(msg: RhpServerMsg) -> Self {
106 SessionOutput::Control(msg)
107 }
108}
109
110/// Live state for one connected device.
111pub struct HardwareSession {
112 pub device_id: String,
113 pub device_type: DeviceType,
114 pub caps: Caps,
115 pub mode: Mode,
116 /// The ambient long-running meeting id, if this device is ambient-capable.
117 pub ambient_session_id: Option<String>,
118 /// Stable per-device conversation id used for each chat turn's trace + (future)
119 /// history binding. NOTE: v1 hardware chat is per-turn STATELESS — the turn
120 /// runs `run_text_turn(persist=false)` with only the current user message, so
121 /// prior turns are not replayed into the model. The stable id is here so a
122 /// later `persist=true` / history-prefill upgrade has a durable key to hang on.
123 /// Public so the Core WS pump (which owns the kernel-welded chat turn) can build
124 /// the turn's key without reaching into the session.
125 pub conversation_id: String,
126 /// The agent that handles device chat turns (None = the default LLM path).
127 /// Resolved once at connect by the Core WS handler and carried here so the
128 /// pump can build the kernel-welded `ChatTurn` from a pure [`TurnInput`].
129 pub agent_id: Option<String>,
130 /// Meeting-ingest seam: the ambient capture path feeds WAV segments here. Held
131 /// as a trait object ([`MeetingIngest`]) so this crate links neither the
132 /// in-process engine nor the sidecar — Core injects the concrete impl.
133 meetings: Arc<dyn MeetingIngest>,
134 /// Opus decoder for the mic uplink (stateful across frames).
135 uplink: UplinkDecoder,
136 /// PCM accumulated for the current chat turn (decoded uplink, 16 kHz mono).
137 chat_pcm: Vec<i16>,
138 /// PCM accumulated for the ambient pipeline since the last flush (16 kHz mono).
139 ambient_pcm: Vec<i16>,
140}
141
142/// ~1 s of 16 kHz mono audio — the ambient flush granularity (PROTOCOL.md §4.2).
143const AMBIENT_FLUSH_SAMPLES: usize = UPLINK_RATE as usize;
144
145impl HardwareSession {
146 /// Create a session from the device's `hello`. `ambient_session_id` is the
147 /// resumed/opened long-running meeting (set by the WS handler when the device
148 /// is ambient-capable); `None` for interactive-only devices.
149 pub fn new(
150 device_id: String,
151 device_type: DeviceType,
152 caps: Caps,
153 ambient_session_id: Option<String>,
154 meetings: Arc<dyn MeetingIngest>,
155 agent_id: Option<String>,
156 ) -> Result<Self> {
157 Ok(Self {
158 conversation_id: format!("hw_{device_id}"),
159 device_id,
160 device_type,
161 caps,
162 mode: Mode::Idle,
163 ambient_session_id,
164 agent_id,
165 meetings,
166 uplink: UplinkDecoder::new()?,
167 chat_pcm: Vec::new(),
168 ambient_pcm: Vec::new(),
169 })
170 }
171
172 /// The TTS downlink format advertised back to the device in `hello_ack`.
173 pub fn tts_format() -> AudioFormat {
174 AudioFormat {
175 codec: "opus".to_string(),
176 sample_rate: DOWNLINK_RATE,
177 frame_ms: FRAME_MS,
178 }
179 }
180
181 /// Switch operating mode. On entering chat we clear any stale turn buffer.
182 pub fn set_mode(&mut self, mode: Mode) {
183 if mode == Mode::Chat {
184 self.chat_pcm.clear();
185 }
186 self.mode = mode;
187 }
188
189 /// Begin a chat turn: drop any buffered audio so the turn starts clean.
190 pub fn on_listen_start(&mut self) {
191 self.chat_pcm.clear();
192 }
193
194 /// Handle a decoded uplink Opus packet for the current mode.
195 ///
196 /// In `chat` it accumulates the turn (the model runs on `listen:stop`). In
197 /// `ambient` it buffers ~1 s then feeds a WAV chunk to the meetings pipeline,
198 /// returning `ambient_ack`/`ambient_skip`. Idle mode ignores audio.
199 pub async fn on_audio(&mut self, opus_packet: &[u8]) -> Result<Vec<SessionOutput>> {
200 let pcm = self.uplink.decode(opus_packet)?;
201 match self.mode {
202 Mode::Chat => {
203 self.chat_pcm.extend_from_slice(&pcm);
204 Ok(Vec::new())
205 }
206 Mode::Ambient => {
207 self.ambient_pcm.extend_from_slice(&pcm);
208 if self.ambient_pcm.len() >= AMBIENT_FLUSH_SAMPLES {
209 self.flush_ambient().await
210 } else {
211 Ok(Vec::new())
212 }
213 }
214 Mode::Idle => Ok(Vec::new()),
215 }
216 }
217
218 /// Feed the buffered ambient PCM to the meetings chunk pipeline as one WAV
219 /// chunk, emitting `ambient_ack` (a segment was transcribed) or `ambient_skip`
220 /// (silence / no meeting bound).
221 async fn flush_ambient(&mut self) -> Result<Vec<SessionOutput>> {
222 let pcm = std::mem::take(&mut self.ambient_pcm);
223 let Some(meeting_id) = self.ambient_session_id.clone() else {
224 return Ok(vec![SessionOutput::control(RhpServerMsg::AmbientSkip {
225 reason: "no ambient session".to_string(),
226 })]);
227 };
228 let wav = codec::pcm16_to_wav(&pcm, UPLINK_RATE)?;
229 match self
230 .meetings
231 .append_segment(&meeting_id, wav, "ambient.wav".to_string())
232 .await
233 {
234 Ok(segment_id) => Ok(vec![SessionOutput::control(RhpServerMsg::AmbientAck {
235 segment_id,
236 })]),
237 // A silent chunk is the common case, not an error worth surfacing.
238 Err(e) if e.contains("silence") || e.contains("empty") => {
239 Ok(vec![SessionOutput::control(RhpServerMsg::AmbientSkip {
240 reason: "silence".to_string(),
241 })])
242 }
243 Err(e) => Ok(vec![SessionOutput::control(RhpServerMsg::AmbientSkip {
244 reason: e,
245 })]),
246 }
247 }
248
249 /// Take the buffered chat turn on `listen:stop`. Returns the captured 16 kHz
250 /// mono PCM as a pure [`TurnInput`], or `None` when nothing was captured. The
251 /// Core WS pump wraps this in its kernel-welded `ChatTurn` (with the session
252 /// deps + `conversation_id`) and spawns the ASR → model → TTS turn off the recv
253 /// loop so the loop stays live for barge-in.
254 pub fn take_voice_turn(&mut self) -> Option<TurnInput> {
255 let pcm = std::mem::take(&mut self.chat_pcm);
256 if pcm.is_empty() {
257 return None;
258 }
259 Some(TurnInput::Voice(pcm))
260 }
261
262 /// Build a `text`-fallback turn input (skips ASR). `None` for empty input.
263 pub fn take_text_turn(&mut self, content: &str) -> Option<TurnInput> {
264 let content = content.trim();
265 if content.is_empty() {
266 return None;
267 }
268 Some(TurnInput::Text(content.to_string()))
269 }
270
271 /// Map a chat/processing phase to the face emotion to push. Used by the WS
272 /// handler when it wants to nudge the face outside a full turn.
273 pub fn emotion_for_phase(&self) -> Emotion {
274 match self.mode {
275 Mode::Chat => Emotion::Listening,
276 Mode::Ambient => Emotion::Neutral,
277 Mode::Idle => Emotion::Neutral,
278 }
279 }
280}
281
282/// The user input that opens a chat turn.
283pub enum TurnInput {
284 /// Captured mic PCM (16 kHz mono) to transcribe before the model turn.
285 Voice(Vec<i16>),
286 /// Already-text input (the `text` fallback frame) — skips ASR.
287 Text(String),
288}