1use axum::extract::ws::Message;
33use forge_primitives::browser::{BrowserAudio, BrowserFrame};
34use futures_util::SinkExt as _;
35
36use super::{WRITE_BOUND, write_within};
37
38pub const SAMPLE_RATE: u32 = 16_000;
43
44pub const HEADER_BYTES: usize = 1;
46
47pub const MAX_PAYLOAD_BYTES: usize = 16 * 1024;
51
52#[derive(Debug, Clone, Copy, PartialEq, Eq)]
54pub enum Kind {
55 Dictation,
58 BrowserFrame,
60 BrowserAudio,
64 TerminalOutput,
67 TerminalInput,
69}
70
71impl Kind {
72 pub const fn tag(self) -> u8 {
74 match self {
75 Self::Dictation => 0,
76 Self::BrowserFrame => 1,
77 Self::BrowserAudio => 2,
78 Self::TerminalOutput => forge_primitives::terminal::TERMINAL_OUTPUT_TAG,
79 Self::TerminalInput => forge_primitives::terminal::TERMINAL_INPUT_TAG,
80 }
81 }
82
83 const fn from_tag(tag: u8) -> Option<Self> {
84 match tag {
85 0 => Some(Self::Dictation),
86 1 => Some(Self::BrowserFrame),
87 2 => Some(Self::BrowserAudio),
88 forge_primitives::terminal::TERMINAL_OUTPUT_TAG => Some(Self::TerminalOutput),
89 forge_primitives::terminal::TERMINAL_INPUT_TAG => Some(Self::TerminalInput),
90 _ => None,
91 }
92 }
93}
94
95#[async_trait::async_trait]
101pub trait FrameTransport: Send {
102 async fn deliver(&mut self, frame: &BrowserFrame) -> anyhow::Result<()>;
105
106 async fn deliver_audio(&mut self, audio: &BrowserAudio) -> anyhow::Result<()>;
108
109 async fn deliver_terminal(&mut self, id: u16, seq: u32, bytes: &[u8]) -> anyhow::Result<()>;
112}
113
114pub struct SocketFrames<'a, S> {
116 pub socket: &'a mut S,
117}
118
119#[async_trait::async_trait]
120impl<S> FrameTransport for SocketFrames<'_, S>
121where
122 S: futures_util::Sink<Message, Error = axum::Error> + Unpin + Send,
123{
124 async fn deliver(&mut self, frame: &BrowserFrame) -> anyhow::Result<()> {
125 let mut bytes = Vec::with_capacity(HEADER_BYTES + frame.encoded.len());
126 bytes.push(Kind::BrowserFrame.tag());
127 bytes.extend_from_slice(&frame.encoded);
128 write_within(WRITE_BOUND, async {
129 self.socket.send(Message::Binary(bytes.into())).await.map_err(anyhow::Error::from)
130 })
131 .await
132 }
133
134 async fn deliver_audio(&mut self, audio: &BrowserAudio) -> anyhow::Result<()> {
135 let bytes = audio_bytes(audio);
136 write_within(WRITE_BOUND, async {
137 self.socket.send(Message::Binary(bytes.into())).await.map_err(anyhow::Error::from)
138 })
139 .await
140 }
141
142 async fn deliver_terminal(&mut self, id: u16, seq: u32, bytes: &[u8]) -> anyhow::Result<()> {
143 let frame = terminal_output_bytes(id, seq, bytes);
144 write_within(WRITE_BOUND, async {
145 self.socket.send(Message::Binary(frame.into())).await.map_err(anyhow::Error::from)
146 })
147 .await
148 }
149}
150
151fn audio_bytes(audio: &BrowserAudio) -> Vec<u8> {
154 let mut bytes = Vec::with_capacity(14 + audio.samples.len() * 2);
156 bytes.push(Kind::BrowserAudio.tag());
157 bytes.extend_from_slice(&audio.profile.to_le_bytes());
158 bytes.extend_from_slice(&audio.tab.to_le_bytes());
159 bytes.extend_from_slice(&audio.seq.to_le_bytes());
160 bytes.extend_from_slice(&audio.sample_rate.to_le_bytes());
161 bytes.push(audio.channels);
162 for sample in &audio.samples {
163 bytes.extend_from_slice(&sample.to_le_bytes());
164 }
165 bytes
166}
167
168#[derive(Debug, Clone, Copy, PartialEq, Eq)]
170pub enum Refusal {
171 ShortHeader,
173 UnknownKind(u8),
175 Oversized(usize),
177 UnevenPayload(usize),
179 ServerOnly(u8),
181 ShortTerminalInput(usize),
183}
184
185impl Refusal {
186 pub fn reason(self) -> String {
188 match self {
189 Self::ShortHeader => "a binary message with no kind byte".to_owned(),
190 Self::UnknownKind(tag) => format!("kind tag {tag} is not one this server knows"),
191 Self::Oversized(bytes) => format!("{bytes} bytes of payload is past the frame cap"),
192 Self::UnevenPayload(bytes) => {
193 format!("{bytes} bytes is not a whole number of samples")
194 }
195 Self::ServerOnly(tag) => {
196 format!("kind tag {tag} is a frame the server sends, not one a client does")
197 }
198 Self::ShortTerminalInput(bytes) => {
199 format!("{bytes} bytes is shorter than a terminal input frame's two-byte id")
200 }
201 }
202 }
203}
204
205#[derive(Debug, PartialEq)]
207pub enum Frame {
208 Audio(Vec<f32>),
210 TerminalInput { id: u16, bytes: Vec<u8> },
212}
213
214pub fn decode(bytes: &[u8]) -> Result<Frame, Refusal> {
216 let Some((&tag, payload)) = bytes.split_first() else {
217 return Err(Refusal::ShortHeader);
218 };
219 let Some(kind) = Kind::from_tag(tag) else {
220 return Err(Refusal::UnknownKind(tag));
221 };
222 match kind {
223 Kind::Dictation => {
224 if payload.len() > MAX_PAYLOAD_BYTES {
225 return Err(Refusal::Oversized(payload.len()));
226 }
227 if !payload.len().is_multiple_of(2) {
228 return Err(Refusal::UnevenPayload(payload.len()));
229 }
230 let samples = payload
231 .as_chunks::<2>()
232 .0
233 .iter()
234 .map(|pair| f32::from(i16::from_le_bytes(*pair)) / 32768.0)
235 .collect();
236 Ok(Frame::Audio(samples))
237 }
238 Kind::TerminalInput => {
239 let Some((header, bytes)) = payload.split_at_checked(2) else {
240 return Err(Refusal::ShortTerminalInput(payload.len()));
241 };
242 Ok(Frame::TerminalInput {
243 id: u16::from_le_bytes([header[0], header[1]]),
244 bytes: bytes.to_vec(),
245 })
246 }
247 Kind::BrowserFrame | Kind::BrowserAudio | Kind::TerminalOutput => {
248 Err(Refusal::ServerOnly(tag))
249 }
250 }
251}
252
253pub fn terminal_output_bytes(id: u16, seq: u32, bytes: &[u8]) -> Vec<u8> {
256 let mut out = Vec::with_capacity(7 + bytes.len());
257 out.push(Kind::TerminalOutput.tag());
258 out.extend_from_slice(&id.to_le_bytes());
259 out.extend_from_slice(&seq.to_le_bytes());
260 out.extend_from_slice(bytes);
261 out
262}
263
264#[cfg(test)]
265mod tests {
266 use super::*;
267
268 fn payload(samples: &[i16]) -> Vec<u8> {
270 let mut bytes = vec![Kind::Dictation.tag()];
271 for sample in samples {
272 bytes.extend_from_slice(&sample.to_le_bytes());
273 }
274 bytes
275 }
276
277 #[test]
282 fn a_dictation_frame_decodes_to_the_samples_the_bytes_carry() {
283 let frame = decode(&payload(&[0, 32767, -32768, 16384])).expect("a well-formed frame");
284 assert_eq!(
285 frame,
286 Frame::Audio(vec![0.0, 32767.0 / 32768.0, -1.0, 0.5]),
287 "little-endian i16 over 2^15, in order"
288 );
289 }
290
291 #[test]
295 fn an_audio_packet_carries_the_profile_tab_and_samples() {
296 let audio = BrowserAudio {
297 profile: 3,
298 tab: 7,
299 seq: 9,
300 sample_rate: 48_000,
301 channels: 2,
302 samples: vec![1, -2],
303 };
304 let mut expected = vec![Kind::BrowserAudio.tag()];
305 expected.extend_from_slice(&3_u16.to_le_bytes());
306 expected.extend_from_slice(&7_u16.to_le_bytes());
307 expected.extend_from_slice(&9_u32.to_le_bytes());
308 expected.extend_from_slice(&48_000_u32.to_le_bytes());
309 expected.push(2);
310 expected.extend_from_slice(&1_i16.to_le_bytes());
311 expected.extend_from_slice(&(-2_i16).to_le_bytes());
312 let bytes = audio_bytes(&audio);
313 assert_eq!(
314 bytes, expected,
315 "the tag, profile, tab, sequence, sample rate, channels, then samples"
316 );
317 assert_eq!(
318 bytes.len() - audio.samples.len() * 2,
319 1 + 13,
320 "one tag byte plus the viewer's 13-byte SOUND_HEADER_LEN"
321 );
322 }
323
324 #[test]
328 fn a_browser_frame_from_a_client_is_refused() {
329 assert_eq!(decode(&[Kind::BrowserFrame.tag(), 1, 2, 3]), Err(Refusal::ServerOnly(1)));
330 assert_eq!(Kind::BrowserFrame.tag(), 1, "the tag the client decodes as a frame");
331 assert_eq!(Kind::Dictation.tag(), 0, "and the audio stream keeps its own");
332 }
333
334 #[test]
335 fn a_message_with_no_header_is_refused() {
336 assert_eq!(decode(&[]), Err(Refusal::ShortHeader), "an empty message is not a frame");
337 }
338
339 #[test]
342 fn an_unknown_kind_tag_is_refused_naming_it() {
343 assert_eq!(decode(&[7, 0, 0]), Err(Refusal::UnknownKind(7)));
344 }
345
346 #[test]
349 fn an_oversized_payload_is_refused_with_its_length() {
350 let mut bytes = vec![Kind::Dictation.tag()];
351 bytes.extend(std::iter::repeat_n(0u8, MAX_PAYLOAD_BYTES + 2));
352 assert_eq!(decode(&bytes), Err(Refusal::Oversized(MAX_PAYLOAD_BYTES + 2)));
353 }
354
355 #[test]
357 fn an_uneven_payload_is_refused_rather_than_truncated() {
358 assert_eq!(decode(&[Kind::Dictation.tag(), 1, 2, 3]), Err(Refusal::UnevenPayload(3)));
359 }
360
361 #[test]
365 fn the_wire_rate_is_the_rate_the_models_read() {
366 assert_eq!(
367 SAMPLE_RATE,
368 forge_dictate::SAMPLE_RATE,
369 "a frame's samples are what the dictation models consume, at their rate"
370 );
371 }
372
373 struct Memory {
378 frames: Vec<BrowserFrame>,
379 audio: Vec<BrowserAudio>,
380 terminal: Vec<(u16, u32, Vec<u8>)>,
381 }
382
383 #[async_trait::async_trait]
384 impl FrameTransport for Memory {
385 async fn deliver(&mut self, frame: &BrowserFrame) -> anyhow::Result<()> {
386 self.frames.push(frame.clone());
387 Ok(())
388 }
389
390 async fn deliver_audio(&mut self, audio: &BrowserAudio) -> anyhow::Result<()> {
391 self.audio.push(audio.clone());
392 Ok(())
393 }
394
395 async fn deliver_terminal(
396 &mut self,
397 id: u16,
398 seq: u32,
399 bytes: &[u8],
400 ) -> anyhow::Result<()> {
401 self.terminal.push((id, seq, bytes.to_vec()));
402 Ok(())
403 }
404 }
405
406 #[tokio::test]
407 async fn a_frame_crosses_the_transport_seam_whole() {
408 let mut transport = Memory { frames: Vec::new(), audio: Vec::new(), terminal: Vec::new() };
409 let frame = BrowserFrame { profile: 5, tab: 2, seq: 3, encoded: vec![9, 8, 7] };
410 transport.deliver(&frame).await.expect("a memory transport never fails");
411 assert_eq!(
412 transport.frames,
413 vec![frame],
414 "the transport receives the whole frame: the profile id, sequence and the host's own bytes"
415 );
416 }
417
418 #[test]
421 fn a_terminal_input_frame_carries_its_id_and_bytes() {
422 let mut bytes = vec![Kind::TerminalInput.tag()];
423 bytes.extend_from_slice(&7_u16.to_le_bytes());
424 bytes.extend_from_slice(b"ls\r");
425 assert_eq!(decode(&bytes), Ok(Frame::TerminalInput { id: 7, bytes: b"ls\r".to_vec() }),);
426 assert_eq!(
427 decode(&[Kind::TerminalInput.tag(), 1]),
428 Err(Refusal::ShortTerminalInput(1)),
429 "an id cut in half is refused rather than read as a terminal",
430 );
431 }
432
433 #[test]
436 fn a_terminal_output_frame_is_the_servers() {
437 let frame = terminal_output_bytes(3, 9, b"hi");
438 assert_eq!(
439 frame,
440 [Kind::TerminalOutput.tag(), 3, 0, 9, 0, 0, 0, b'h', b'i',],
441 "the tag, the id, the sequence and the bytes",
442 );
443 assert_eq!(decode(&frame), Err(Refusal::ServerOnly(4)));
444 }
445
446 #[test]
449 fn every_refusal_names_what_it_refused() {
450 for refusal in [
451 Refusal::ShortHeader,
452 Refusal::UnknownKind(9),
453 Refusal::ServerOnly(1),
454 Refusal::Oversized(MAX_PAYLOAD_BYTES + 1),
455 Refusal::UnevenPayload(3),
456 ] {
457 assert!(!refusal.reason().is_empty(), "{refusal:?} has no line");
458 }
459 }
460}