1use std::path::PathBuf;
9use std::time::Duration;
10
11use anvil_client::codex_client::CodexClient;
12use anvil_client::transcribe::TranscribeRequest;
13use axum::body::Bytes;
14use tokio::sync::oneshot;
15use tokio_util::sync::CancellationToken;
16
17pub use mj_client::auth::{auth_paths, available_auth};
18
19pub const MAX_AUDIO_BYTES: usize = 20 * 1024 * 1024;
21pub const MAX_AUDIO_DURATION: Duration = Duration::from_secs(600);
23const MAX_PCM_DATA_BYTES: u64 = 16_000 * 2 * MAX_AUDIO_DURATION.as_secs();
27pub const DICTATION_TIMEOUT: Duration = Duration::from_secs(120);
29
30#[derive(Debug)]
32pub enum DictationOperation {
33 Availability,
35 Transcribe(Bytes),
37}
38
39#[derive(Debug)]
41pub struct DictationRequest {
42 pub session_id: String,
43 pub operation: DictationOperation,
44 pub cancel: CancellationToken,
45 pub reply: oneshot::Sender<Result<DictationResponse, DictationError>>,
46}
47
48#[derive(Debug, Clone, PartialEq, Eq)]
50pub enum DictationResponse {
51 Availability {
52 available: bool,
53 reason: Option<String>,
54 },
55 Transcript {
56 text: String,
57 },
58}
59
60#[derive(Debug)]
63pub enum DictationError {
64 SessionNotFound,
65 CredentialsUnavailable,
66 InvalidAudio(&'static str),
67 Cancelled,
68 TimedOut,
69 CredentialProbe,
70 Provider(String),
71}
72
73impl std::fmt::Display for DictationError {
74 fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
75 match self {
76 Self::SessionNotFound => formatter.write_str("unknown session"),
77 Self::CredentialsUnavailable => {
78 formatter.write_str("no Codex subscription credentials are available")
79 }
80 Self::InvalidAudio(message) => write!(formatter, "invalid WAV audio: {message}"),
81 Self::Cancelled => formatter.write_str("dictation was cancelled"),
82 Self::TimedOut => formatter.write_str("dictation timed out"),
83 Self::CredentialProbe => formatter.write_str("could not inspect dictation credentials"),
84 Self::Provider(message) => {
85 write!(formatter, "transcription provider failed: {message}")
86 }
87 }
88 }
89}
90
91impl std::error::Error for DictationError {}
92
93pub fn validate_wav(audio: &Bytes) -> Result<(), DictationError> {
97 validate_wav_bytes(audio)
98}
99
100fn validate_wav_bytes(audio: &[u8]) -> Result<(), DictationError> {
101 if audio.is_empty() {
102 return Err(DictationError::InvalidAudio(
103 "audio upload must not be empty",
104 ));
105 }
106 if audio.len() > MAX_AUDIO_BYTES {
107 return Err(DictationError::InvalidAudio("audio upload is too large"));
108 }
109 if audio.len() < 12 || &audio[..4] != b"RIFF" || &audio[8..12] != b"WAVE" {
110 return Err(DictationError::InvalidAudio("expected a RIFF/WAVE file"));
111 }
112 let declared_size = u32::from_le_bytes(audio[4..8].try_into().unwrap()) as usize;
113 if declared_size != audio.len().saturating_sub(8) {
114 return Err(DictationError::InvalidAudio(
115 "RIFF length does not match the upload",
116 ));
117 }
118
119 let mut cursor = 12_usize;
120 let mut fmt_seen = false;
121 let mut data_bytes = 0_u64;
122 let mut data_seen = false;
123 while cursor < audio.len() {
124 if audio.len() - cursor < 8 {
125 return Err(DictationError::InvalidAudio("truncated WAV chunk header"));
126 }
127 let id = &audio[cursor..cursor + 4];
128 let chunk_size = u32::from_le_bytes(
129 audio[cursor + 4..cursor + 8]
130 .try_into()
131 .expect("WAV chunk size is four bytes"),
132 ) as usize;
133 let data_start = cursor + 8;
134 let data_end = data_start
135 .checked_add(chunk_size)
136 .ok_or(DictationError::InvalidAudio("WAV chunk length overflows"))?;
137 let padded_end = data_end
138 .checked_add(chunk_size & 1)
139 .ok_or(DictationError::InvalidAudio("WAV chunk padding overflows"))?;
140 if data_end > audio.len() || padded_end > audio.len() {
141 return Err(DictationError::InvalidAudio("truncated WAV chunk data"));
142 }
143 match id {
144 b"fmt " => {
145 if fmt_seen || chunk_size < 16 {
146 return Err(DictationError::InvalidAudio("invalid WAV format chunk"));
147 }
148 fmt_seen = true;
149 let format =
150 u16::from_le_bytes(audio[data_start..data_start + 2].try_into().unwrap());
151 let channels =
152 u16::from_le_bytes(audio[data_start + 2..data_start + 4].try_into().unwrap());
153 let rate =
154 u32::from_le_bytes(audio[data_start + 4..data_start + 8].try_into().unwrap());
155 let bytes_per_second =
156 u32::from_le_bytes(audio[data_start + 8..data_start + 12].try_into().unwrap());
157 let alignment =
158 u16::from_le_bytes(audio[data_start + 12..data_start + 14].try_into().unwrap());
159 let bits =
160 u16::from_le_bytes(audio[data_start + 14..data_start + 16].try_into().unwrap());
161 if format != 1
162 || channels != 1
163 || rate != 16_000
164 || alignment != 2
165 || bits != 16
166 || bytes_per_second != 32_000
167 {
168 return Err(DictationError::InvalidAudio(
169 "WAV must be mono 16-bit PCM at 16 kHz",
170 ));
171 }
172 }
173 b"data" => {
174 if data_seen || !chunk_size.is_multiple_of(2) {
175 return Err(DictationError::InvalidAudio(
176 "WAV PCM data is not one even-sized sample chunk",
177 ));
178 }
179 data_seen = true;
180 data_bytes = data_bytes
181 .checked_add(chunk_size as u64)
182 .ok_or(DictationError::InvalidAudio("WAV sample length overflows"))?;
183 }
184 _ => {}
185 }
186 cursor = padded_end;
187 }
188 if !fmt_seen {
189 return Err(DictationError::InvalidAudio("WAV format chunk is missing"));
190 }
191 if data_bytes == 0 {
192 return Err(DictationError::InvalidAudio("WAV sample data is missing"));
193 }
194 if data_bytes > MAX_PCM_DATA_BYTES {
195 return Err(DictationError::InvalidAudio(
196 "audio duration exceeds 600 seconds",
197 ));
198 }
199 Ok(())
200}
201
202pub async fn execute(
206 request: DictationRequest,
207 auth_paths: Option<Vec<PathBuf>>,
208 shutdown: CancellationToken,
209) {
210 let DictationRequest {
211 session_id: _,
212 operation,
213 cancel,
214 mut reply,
215 } = request;
216 let result = tokio::select! {
217 biased;
218 _ = reply.closed() => return,
219 _ = shutdown.cancelled() => Err(DictationError::Cancelled),
220 _ = cancel.cancelled() => Err(DictationError::Cancelled),
221 result = tokio::time::timeout(DICTATION_TIMEOUT, execute_operation(operation, auth_paths, cancel.clone())) => {
222 result.unwrap_or(Err(DictationError::TimedOut))
223 },
224 };
225 let _ = reply.send(result);
226}
227
228async fn execute_operation(
229 operation: DictationOperation,
230 auth_paths: Option<Vec<PathBuf>>,
231 cancel: CancellationToken,
232) -> Result<DictationResponse, DictationError> {
233 let paths = auth_paths.ok_or(DictationError::SessionNotFound)?;
234 let audio = match &operation {
235 DictationOperation::Transcribe(audio) => Some(audio.clone()),
236 DictationOperation::Availability => None,
237 };
238 let probe = tokio::task::spawn_blocking(move || {
239 if let Some(audio) = audio {
240 validate_wav(&audio)?;
241 }
242 Ok::<_, DictationError>(available_auth(paths))
243 });
244 let auth_path = tokio::select! {
245 biased;
246 _ = cancel.cancelled() => return Err(DictationError::Cancelled),
247 result = probe => result.map_err(|error| {
248 tracing::warn!(%error, "Codex dictation credential probe task failed");
249 DictationError::CredentialProbe
250 })??,
251 };
252
253 match operation {
254 DictationOperation::Availability => Ok(DictationResponse::Availability {
255 available: auth_path.is_some(),
256 reason: auth_path
257 .is_none()
258 .then(|| "no Codex subscription credentials are available".to_owned()),
259 }),
260 DictationOperation::Transcribe(audio) => {
261 let auth_path = auth_path.ok_or(DictationError::CredentialsUnavailable)?;
262 let mut request = TranscribeRequest::new(audio, "audio.wav", "audio/wav");
263 request.cancel = cancel.clone();
264 request.timeout = DICTATION_TIMEOUT;
265 let transcription = CodexClient::with_auth_path(auth_path)
266 .transcribe(request)
267 .await
268 .map_err(|error| {
269 let message = error.to_string();
270 if message.to_ascii_lowercase().contains("timed out") {
271 DictationError::TimedOut
272 } else if cancel.is_cancelled() {
273 DictationError::Cancelled
274 } else {
275 DictationError::Provider(message)
276 }
277 })?;
278 Ok(DictationResponse::Transcript {
279 text: transcription.text,
280 })
281 }
282 }
283}
284
285#[cfg(test)]
286mod tests {
287 use super::*;
288
289 fn wav(sample_bytes: usize) -> Bytes {
290 let padded = sample_bytes + (sample_bytes & 1);
291 let riff_size = 36 + padded;
292 let mut bytes = Vec::with_capacity(8 + riff_size);
293 bytes.extend_from_slice(b"RIFF");
294 bytes.extend_from_slice(&(riff_size as u32).to_le_bytes());
295 bytes.extend_from_slice(b"WAVEfmt ");
296 bytes.extend_from_slice(&16_u32.to_le_bytes());
297 bytes.extend_from_slice(&1_u16.to_le_bytes());
298 bytes.extend_from_slice(&1_u16.to_le_bytes());
299 bytes.extend_from_slice(&16_000_u32.to_le_bytes());
300 bytes.extend_from_slice(&32_000_u32.to_le_bytes());
301 bytes.extend_from_slice(&2_u16.to_le_bytes());
302 bytes.extend_from_slice(&16_u16.to_le_bytes());
303 bytes.extend_from_slice(b"data");
304 bytes.extend_from_slice(&(sample_bytes as u32).to_le_bytes());
305 bytes.resize(bytes.len() + sample_bytes, 0);
306 if sample_bytes & 1 != 0 {
307 bytes.push(0);
308 }
309 Bytes::from(bytes)
310 }
311
312 #[test]
313 fn accepts_wav_with_unknown_padded_chunk() {
314 let mut audio = wav(64).to_vec();
315 audio.splice(12..12, b"JUNK\x01\x00\x00\x00x\x00".iter().copied());
316 let size = audio.len() - 8;
317 audio[4..8].copy_from_slice(&(size as u32).to_le_bytes());
318 assert!(validate_wav(&Bytes::from(audio)).is_ok());
319 }
320
321 #[test]
322 fn rejects_truncated_and_wrong_format_audio() {
323 assert!(matches!(
324 validate_wav(&Bytes::from_static(b"RIFF")),
325 Err(DictationError::InvalidAudio(_))
326 ));
327 let mut audio = wav(64).to_vec();
328 audio[22..24].copy_from_slice(&2_u16.to_le_bytes());
329 assert!(matches!(
330 validate_wav(&Bytes::from(audio)),
331 Err(DictationError::InvalidAudio(_))
332 ));
333 }
334
335 #[test]
336 fn rejects_data_longer_than_six_hundred_seconds() {
337 let audio = wav((MAX_PCM_DATA_BYTES + 2) as usize);
338 assert!(matches!(
339 validate_wav(&audio),
340 Err(DictationError::InvalidAudio(_))
341 ));
342 }
343
344 #[tokio::test]
345 async fn availability_and_transcription_without_credentials_do_not_call_provider() {
346 for operation in [
347 DictationOperation::Availability,
348 DictationOperation::Transcribe(wav(96_000)),
349 ] {
350 let availability = matches!(operation, DictationOperation::Availability);
351 let (reply, answer) = oneshot::channel();
352 execute(
353 DictationRequest {
354 session_id: "session".into(),
355 operation,
356 cancel: CancellationToken::new(),
357 reply,
358 },
359 Some(vec![]),
360 CancellationToken::new(),
361 )
362 .await;
363 let result = answer.await.unwrap();
364 if availability {
365 assert!(matches!(
366 result,
367 Ok(DictationResponse::Availability {
368 available: false,
369 reason: Some(_)
370 })
371 ));
372 } else {
373 assert!(matches!(
374 result,
375 Err(DictationError::CredentialsUnavailable)
376 ));
377 }
378 }
379 }
380
381 #[tokio::test]
382 async fn shutdown_and_request_cancellation_preempt_credential_work() {
383 for shutdown_cancelled in [false, true] {
384 let cancel = CancellationToken::new();
385 let shutdown = CancellationToken::new();
386 if shutdown_cancelled {
387 shutdown.cancel();
388 } else {
389 cancel.cancel();
390 }
391 let (reply, answer) = oneshot::channel();
392 execute(
393 DictationRequest {
394 session_id: "session".into(),
395 operation: DictationOperation::Availability,
396 cancel,
397 reply,
398 },
399 None,
400 shutdown,
401 )
402 .await;
403 assert!(matches!(
404 answer.await.unwrap(),
405 Err(DictationError::Cancelled)
406 ));
407 }
408 }
409
410 #[test]
411 fn duplicate_or_odd_data_chunks_and_wrong_riff_lengths_are_rejected() {
412 let mut duplicate = wav(2).to_vec();
413 duplicate.extend_from_slice(b"data\x02\x00\x00\x00\x00\x00");
414 let length = duplicate.len() as u32 - 8;
415 duplicate[4..8].copy_from_slice(&length.to_le_bytes());
416 assert!(validate_wav(&Bytes::from(duplicate)).is_err());
417 assert!(validate_wav(&wav(3)).is_err());
418 let mut wrong_length = wav(2).to_vec();
419 wrong_length[4..8].copy_from_slice(&0_u32.to_le_bytes());
420 assert!(validate_wav(&Bytes::from(wrong_length)).is_err());
421 assert!(validate_wav(&wav(MAX_PCM_DATA_BYTES as usize)).is_ok());
422 }
423}