Skip to main content

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}