1use crate::error::{EnvironmentError, Result, UserError};
10use std::path::{Path, PathBuf};
11use std::process::Stdio;
12use std::sync::Arc;
13use tokio::io::AsyncReadExt;
14use tokio::process::Command;
15use which::which;
16
17pub const SUPPORTED_EXTENSIONS: &[&str] = &[
19 "mp3", "m4a", "wav", "flac", "ogg", "opus", "webm", "mp4", "aac", "wma", "mkv",
20];
21
22pub const DEFAULT_MAX_DURATION_SECS: f64 = 2.25 * 3600.0;
24
25pub const DEFAULT_MAX_DECODED_BYTES: usize = 500 * 1024 * 1024;
27
28pub const DEFAULT_MAX_UPLOAD_BYTES: usize = 24 * 1024 * 1024;
30
31pub const WHISPER_SAMPLE_RATE: u32 = 16_000;
33
34#[derive(Debug, Clone)]
36pub struct AudioInput {
37 pub source_path: PathBuf,
39 pub samples: Arc<[f32]>,
42 pub sample_rate: u32,
44 pub duration_secs: f64,
46}
47
48impl AudioInput {
49 pub fn len(&self) -> usize {
50 self.samples.len()
51 }
52
53 pub fn is_empty(&self) -> bool {
54 self.samples.is_empty()
55 }
56
57 pub fn from_pcm(samples: impl Into<Arc<[f32]>>, sample_rate: u32) -> Result<Self> {
61 Self::from_pcm_with_limits(
62 samples,
63 sample_rate,
64 DEFAULT_MAX_DURATION_SECS,
65 DEFAULT_MAX_DECODED_BYTES,
66 )
67 }
68
69 pub fn from_pcm_with_limits(
71 samples: impl Into<Arc<[f32]>>,
72 sample_rate: u32,
73 max_duration_secs: f64,
74 max_decoded_bytes: usize,
75 ) -> Result<Self> {
76 if sample_rate == 0 || sample_rate != WHISPER_SAMPLE_RATE {
77 return Err(UserError::UnsupportedSampleRate {
78 got: sample_rate,
79 need: WHISPER_SAMPLE_RATE,
80 }
81 .into());
82 }
83 let samples: Arc<[f32]> = samples.into();
84 if samples.is_empty() {
85 return Err(UserError::InvalidAudio {
86 reason: "PCM buffer is empty".into(),
87 }
88 .into());
89 }
90 for (i, s) in samples.iter().enumerate() {
92 if !s.is_finite() {
93 return Err(UserError::InvalidAudio {
94 reason: format!("PCM sample[{i}] is not finite"),
95 }
96 .into());
97 }
98 }
99 let decoded_bytes = samples.len().saturating_mul(std::mem::size_of::<f32>());
100 let duration_secs = samples.len() as f64 / f64::from(sample_rate);
101 if !duration_secs.is_finite() || duration_secs < 0.0 {
102 return Err(UserError::InvalidAudio {
103 reason: format!(
104 "computed duration is not a valid non-negative finite value ({duration_secs})"
105 ),
106 }
107 .into());
108 }
109 if duration_secs > max_duration_secs {
110 return Err(UserError::AudioTooLong {
111 duration_secs,
112 max_secs: max_duration_secs,
113 }
114 .into());
115 }
116 if decoded_bytes > max_decoded_bytes {
117 return Err(UserError::AudioTooLarge {
118 decoded_bytes,
119 max_bytes: max_decoded_bytes,
120 }
121 .into());
122 }
123 Ok(Self {
124 source_path: PathBuf::from(format!("pcm://{sample_rate}hz/{}", samples.len())),
125 samples,
126 sample_rate,
127 duration_secs,
128 })
129 }
130
131 pub fn from_pcm_slice(samples: &[f32], sample_rate: u32) -> Result<Self> {
133 let owned: Arc<[f32]> = samples.to_vec().into();
134 Self::from_pcm(owned, sample_rate)
135 }
136}
137
138pub fn require_ffmpeg() -> Result<PathBuf> {
140 which("ffmpeg").map_err(|_| EnvironmentError::FfmpegMissing.into())
141}
142
143pub fn ffmpeg_available() -> bool {
145 which("ffmpeg").is_ok()
146}
147
148pub async fn load_audio(path: &Path) -> Result<AudioInput> {
150 load_audio_with_limits(path, DEFAULT_MAX_DURATION_SECS, DEFAULT_MAX_DECODED_BYTES).await
151}
152
153pub async fn load_audio_with_limits(
155 path: &Path,
156 max_duration_secs: f64,
157 max_decoded_bytes: usize,
158) -> Result<AudioInput> {
159 if !path.exists() {
160 return Err(UserError::FileNotFound {
161 path: path.display().to_string(),
162 }
163 .into());
164 }
165 if !path.is_file() {
166 return Err(UserError::InvalidAudio {
167 reason: format!("{} is not a regular file", path.display()),
168 }
169 .into());
170 }
171
172 if path
174 .extension()
175 .and_then(|e| e.to_str())
176 .is_some_and(|e| e.eq_ignore_ascii_case("wav"))
177 {
178 if let Ok(audio) = try_load_wav_direct(path, max_duration_secs, max_decoded_bytes) {
179 return Ok(audio);
180 }
181 tracing::debug!("WAV direct-load failed; falling back to ffmpeg");
182 }
183
184 load_via_ffmpeg(path, max_duration_secs, max_decoded_bytes).await
185}
186
187fn try_load_wav_direct(
189 path: &Path,
190 max_duration_secs: f64,
191 max_decoded_bytes: usize,
192) -> Result<AudioInput> {
193 let meta = std::fs::metadata(path).map_err(|e| UserError::InvalidAudio {
194 reason: e.to_string(),
195 })?;
196 let approx_samples = (meta.len().saturating_sub(44) / 2) as usize;
198 let approx_decoded = approx_samples.saturating_mul(std::mem::size_of::<f32>());
199 let approx_duration = approx_samples as f64 / 16_000.0;
200 if approx_duration > max_duration_secs {
201 return Err(UserError::AudioTooLong {
202 duration_secs: approx_duration,
203 max_secs: max_duration_secs,
204 }
205 .into());
206 }
207 if approx_decoded > max_decoded_bytes {
208 return Err(UserError::AudioTooLarge {
209 decoded_bytes: approx_decoded,
210 max_bytes: max_decoded_bytes,
211 }
212 .into());
213 }
214
215 let reader = hound::WavReader::open(path).map_err(|e| UserError::InvalidAudio {
216 reason: e.to_string(),
217 })?;
218 let spec = reader.spec();
219
220 if spec.sample_rate != 16_000 {
221 return Err(UserError::InvalidAudio {
222 reason: format!("sample rate is {} Hz (need 16000)", spec.sample_rate),
223 }
224 .into());
225 }
226 if spec.channels != 1 {
227 return Err(UserError::InvalidAudio {
228 reason: format!("{} channels (need mono)", spec.channels),
229 }
230 .into());
231 }
232
233 let samples: Arc<[f32]> = match spec.sample_format {
236 hound::SampleFormat::Int => {
237 let mut out: Vec<f32> = Vec::with_capacity(approx_samples.min(max_decoded_bytes / 4));
238 for sample in reader.into_samples::<i16>() {
239 let s = sample.map_err(|e| UserError::InvalidAudio {
240 reason: format!("failed reading samples: {e}"),
241 })?;
242 let decoded = out
243 .len()
244 .saturating_add(1)
245 .saturating_mul(std::mem::size_of::<f32>());
246 if decoded > max_decoded_bytes {
247 return Err(UserError::AudioTooLarge {
248 decoded_bytes: decoded,
249 max_bytes: max_decoded_bytes,
250 }
251 .into());
252 }
253 out.push(s as f32 / 32768.0);
254 }
255 out.into()
256 }
257 hound::SampleFormat::Float => {
258 return Err(UserError::InvalidAudio {
259 reason: "float WAV; use ffmpeg path".into(),
260 }
261 .into());
262 }
263 };
264
265 let duration_secs = samples.len() as f64 / 16_000.0;
266 let decoded_bytes = samples.len().saturating_mul(std::mem::size_of::<f32>());
267
268 if duration_secs > max_duration_secs {
269 return Err(UserError::AudioTooLong {
270 duration_secs,
271 max_secs: max_duration_secs,
272 }
273 .into());
274 }
275 if decoded_bytes > max_decoded_bytes {
276 return Err(UserError::AudioTooLarge {
277 decoded_bytes,
278 max_bytes: max_decoded_bytes,
279 }
280 .into());
281 }
282 if samples.is_empty() {
283 return Err(UserError::InvalidAudio {
284 reason: "audio contains no samples".into(),
285 }
286 .into());
287 }
288
289 Ok(AudioInput {
290 source_path: path.to_path_buf(),
291 samples,
292 sample_rate: 16_000,
293 duration_secs,
294 })
295}
296
297pub const DEFAULT_FFMPEG_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(600);
299const STDERR_TAIL_CAP: usize = 8 * 1024;
301
302#[derive(Debug, Clone, PartialEq, Eq)]
304pub enum FfmpegTermination {
305 Success,
306 InvalidMedia,
307 LimitExceeded,
308 Timeout,
309 Cancelled,
310 SpawnFailure,
311 NonZeroExit,
312}
313
314async fn load_via_ffmpeg(
320 path: &Path,
321 max_duration_secs: f64,
322 max_decoded_bytes: usize,
323) -> Result<AudioInput> {
324 load_via_ffmpeg_with_timeout(
325 path,
326 max_duration_secs,
327 max_decoded_bytes,
328 DEFAULT_FFMPEG_TIMEOUT,
329 None,
330 )
331 .await
332}
333
334pub async fn load_via_ffmpeg_with_timeout(
336 path: &Path,
337 max_duration_secs: f64,
338 max_decoded_bytes: usize,
339 timeout: std::time::Duration,
340 cancel: Option<crate::cancel::CancelFlag>,
341) -> Result<AudioInput> {
342 let ffmpeg = require_ffmpeg()?;
343 let max_t = format!("{max_duration_secs:.3}");
344 let protocol_whitelist = "file,crypto,data";
346
347 let mut child = Command::new(&ffmpeg)
348 .args([
349 "-hide_banner",
350 "-loglevel",
351 "error",
352 "-nostdin",
353 "-protocol_whitelist",
354 protocol_whitelist,
355 "-i",
356 ])
357 .arg(path)
358 .args([
359 "-t",
360 &max_t,
361 "-f",
362 "s16le",
363 "-acodec",
364 "pcm_s16le",
365 "-ac",
366 "1",
367 "-ar",
368 "16000",
369 "-",
370 ])
371 .stdin(Stdio::null())
372 .stdout(Stdio::piped())
373 .stderr(Stdio::piped())
374 .kill_on_drop(true)
375 .spawn()
376 .map_err(|e| EnvironmentError::FfmpegFailed {
377 reason: format!("failed to spawn ffmpeg: {e}"),
378 })?;
379
380 let mut stdout = child
381 .stdout
382 .take()
383 .ok_or_else(|| EnvironmentError::FfmpegFailed {
384 reason: "ffmpeg stdout missing".into(),
385 })?;
386 let mut stderr = child
387 .stderr
388 .take()
389 .ok_or_else(|| EnvironmentError::FfmpegFailed {
390 reason: "ffmpeg stderr missing".into(),
391 })?;
392
393 let max_raw_bytes = max_decoded_bytes / std::mem::size_of::<f32>() * 2;
394
395 if let Some(flag) = &cancel {
396 if flag.is_cancelled() {
397 let _ = child.kill().await;
398 let _ = child.wait().await;
399 return Err(crate::error::ProviderError::Cancelled.into());
400 }
401 }
402
403 let stdout_task = async {
407 let max_samples = max_decoded_bytes / std::mem::size_of::<f32>();
409 let mut samples: Vec<f32> = Vec::with_capacity(max_samples.min(64 * 1024));
410 let mut buf = [0u8; 64 * 1024];
411 let mut carry: Option<u8> = None;
412 let mut raw_bytes_seen: usize = 0;
413 loop {
414 let n = stdout
415 .read(&mut buf)
416 .await
417 .map_err(|e| EnvironmentError::FfmpegFailed {
418 reason: format!("reading ffmpeg stdout: {e}"),
419 })?;
420 if n == 0 {
421 break;
422 }
423 raw_bytes_seen = raw_bytes_seen.saturating_add(n);
424 if raw_bytes_seen > max_raw_bytes {
425 return Err(UserError::AudioTooLarge {
426 decoded_bytes: (raw_bytes_seen / 2) * std::mem::size_of::<f32>(),
427 max_bytes: max_decoded_bytes,
428 }
429 .into());
430 }
431
432 let mut offset = 0usize;
433 if let Some(lo) = carry.take() {
434 let hi = buf[0];
435 offset = 1;
436 let s = i16::from_le_bytes([lo, hi]);
437 samples.push(s as f32 / 32768.0);
438 }
439
440 let rem = &buf[offset..n];
441 let pairs = rem.len() / 2;
442 if samples.len().saturating_add(pairs) > max_samples {
443 return Err(UserError::AudioTooLarge {
444 decoded_bytes: (samples.len() + pairs) * std::mem::size_of::<f32>(),
445 max_bytes: max_decoded_bytes,
446 }
447 .into());
448 }
449 for chunk in rem.chunks_exact(2) {
450 let s = i16::from_le_bytes([chunk[0], chunk[1]]);
451 samples.push(s as f32 / 32768.0);
452 }
453 if rem.len() % 2 == 1 {
454 carry = Some(rem[rem.len() - 1]);
455 }
456 }
457 if carry.is_some() {
458 return Err(UserError::InvalidAudio {
459 reason: "ffmpeg produced misaligned PCM data".into(),
460 }
461 .into());
462 }
463 Ok::<Vec<f32>, crate::error::TranscriptionError>(samples)
464 };
465
466 let stderr_task = async {
467 let mut tail: Vec<u8> = Vec::new();
468 let mut buf = [0u8; 4 * 1024];
469 loop {
470 let n = stderr
471 .read(&mut buf)
472 .await
473 .map_err(|e| EnvironmentError::FfmpegFailed {
474 reason: format!("reading ffmpeg stderr: {e}"),
475 })?;
476 if n == 0 {
477 break;
478 }
479 if tail.len() + n > STDERR_TAIL_CAP {
480 let drop_n = (tail.len() + n).saturating_sub(STDERR_TAIL_CAP);
481 if drop_n < tail.len() {
482 tail.drain(..drop_n);
483 } else {
484 tail.clear();
485 }
486 }
487 tail.extend_from_slice(&buf[..n]);
488 }
489 Ok::<Vec<u8>, crate::error::TranscriptionError>(tail)
490 };
491
492 let drains = async { tokio::try_join!(stdout_task, stderr_task) };
493 let cancel_flag = cancel.clone();
494 let cancel_watch = async move {
495 match cancel_flag {
496 Some(flag) => loop {
497 if flag.is_cancelled() {
498 return;
499 }
500 tokio::time::sleep(std::time::Duration::from_millis(25)).await;
501 },
502 None => std::future::pending::<()>().await,
503 }
504 };
505
506 let drain_outcome: Result<(Vec<f32>, Vec<u8>)> = tokio::select! {
507 biased;
508 _ = cancel_watch => {
509 let _ = child.kill().await;
510 let _ = child.wait().await;
511 Err(crate::error::ProviderError::Cancelled.into())
512 }
513 timed = tokio::time::timeout(timeout, drains) => {
514 match timed {
515 Ok(Ok(pair)) => Ok(pair),
516 Ok(Err(e)) => {
517 let _ = child.kill().await;
518 let _ = child.wait().await;
519 Err(e)
520 }
521 Err(_elapsed) => {
522 let _ = child.kill().await;
523 let _ = child.wait().await;
524 Err(crate::error::ProviderError::DeadlineExceeded.into())
525 }
526 }
527 }
528 };
529
530 let (samples_vec, stderr_bytes) = drain_outcome?;
531
532 let status = match tokio::time::timeout(std::time::Duration::from_secs(30), child.wait()).await
534 {
535 Ok(Ok(s)) => s,
536 Ok(Err(e)) => {
537 return Err(EnvironmentError::FfmpegFailed {
538 reason: format!("ffmpeg wait failed: {e}"),
539 }
540 .into());
541 }
542 Err(_) => {
543 let _ = child.kill().await;
544 let _ = child.wait().await;
545 return Err(EnvironmentError::FfmpegFailed {
546 reason: "ffmpeg hung after decode pipes closed".into(),
547 }
548 .into());
549 }
550 };
551 if !status.success() {
552 let stderr = String::from_utf8_lossy(&stderr_bytes);
553 let reason = stderr.trim();
554 let short = reason
555 .lines()
556 .last()
557 .unwrap_or("ffmpeg failed")
558 .chars()
559 .take(400)
560 .collect::<String>();
561 return Err(UserError::InvalidAudio { reason: short }.into());
562 }
563 if samples_vec.is_empty() {
564 return Err(UserError::InvalidAudio {
565 reason: "ffmpeg produced no audio data (empty or corrupt file?)".into(),
566 }
567 .into());
568 }
569 let samples: Arc<[f32]> = samples_vec.into();
570 let duration_secs = samples.len() as f64 / 16_000.0;
571 if duration_secs + 0.05 >= max_duration_secs {
572 return Err(UserError::AudioTooLong {
573 duration_secs: max_duration_secs,
574 max_secs: max_duration_secs,
575 }
576 .into());
577 }
578 if samples.is_empty() {
579 return Err(UserError::InvalidAudio {
580 reason: "audio contains no samples".into(),
581 }
582 .into());
583 }
584 Ok(AudioInput {
585 source_path: path.to_path_buf(),
586 samples,
587 sample_rate: 16_000,
588 duration_secs,
589 })
590}
591
592#[cfg(test)]
595async fn race_cancel_against_stalled_pipes(
596 child: &mut tokio::process::Child,
597 cancel: crate::cancel::CancelFlag,
598 poll_ms: u64,
599) -> Result<()> {
600 let mut stdout = child.stdout.take().ok_or_else(|| EnvironmentError::Other {
601 message: "stdout missing".into(),
602 })?;
603 let mut stderr = child.stderr.take().ok_or_else(|| EnvironmentError::Other {
604 message: "stderr missing".into(),
605 })?;
606
607 let stdout_task = async {
608 let mut buf = [0u8; 1024];
609 loop {
610 let n = stdout
611 .read(&mut buf)
612 .await
613 .map_err(|e| EnvironmentError::Other {
614 message: format!("stdout read: {e}"),
615 })?;
616 if n == 0 {
617 break;
618 }
619 }
620 Ok::<(), crate::error::TranscriptionError>(())
621 };
622 let stderr_task = async {
623 let mut buf = [0u8; 1024];
624 loop {
625 let n = stderr
626 .read(&mut buf)
627 .await
628 .map_err(|e| EnvironmentError::Other {
629 message: format!("stderr read: {e}"),
630 })?;
631 if n == 0 {
632 break;
633 }
634 }
635 Ok::<(), crate::error::TranscriptionError>(())
636 };
637
638 let drains = async { tokio::try_join!(stdout_task, stderr_task) };
639 let flag = cancel.clone();
640 let cancel_watch = async move {
641 loop {
642 if flag.is_cancelled() {
643 return;
644 }
645 tokio::time::sleep(std::time::Duration::from_millis(poll_ms)).await;
646 }
647 };
648
649 tokio::select! {
650 biased;
651 _ = cancel_watch => {
652 let _ = child.kill().await;
653 let _ = child.wait().await;
654 Err(crate::error::ProviderError::Cancelled.into())
655 }
656 r = drains => {
657 let _ = child.wait().await;
658 r.map(|_| ())
659 }
660 }
661}
662
663pub fn write_temp_wav(samples: &[f32], dest: &Path) -> Result<()> {
665 let file = std::fs::OpenOptions::new()
667 .write(true)
668 .create_new(true)
669 .open(dest)
670 .map_err(|e| EnvironmentError::Other {
671 message: format!("failed to create temp wav {}: {e}", dest.display()),
672 })?;
673
674 let spec = hound::WavSpec {
675 channels: 1,
676 sample_rate: 16_000,
677 bits_per_sample: 16,
678 sample_format: hound::SampleFormat::Int,
679 };
680 let mut writer = hound::WavWriter::new(file, spec).map_err(|e| EnvironmentError::Other {
681 message: format!("failed to create wav writer {}: {e}", dest.display()),
682 })?;
683
684 for &s in samples {
685 let clamped = s.clamp(-1.0, 1.0);
686 let i = (clamped * 32767.0).round() as i16;
687 writer
688 .write_sample(i)
689 .map_err(|e| EnvironmentError::Other {
690 message: format!("failed writing wav sample: {e}"),
691 })?;
692 }
693 writer.finalize().map_err(|e| EnvironmentError::Other {
694 message: format!("failed finalizing wav: {e}"),
695 })?;
696 Ok(())
697}
698
699pub async fn encode_for_upload(
713 samples: &[f32],
714 max_bytes: usize,
715) -> Result<(PathBuf, &'static str)> {
716 encode_for_upload_with_timeout(samples, max_bytes, DEFAULT_FFMPEG_TIMEOUT, None).await
717}
718
719pub async fn encode_for_upload_with_timeout(
721 samples: &[f32],
722 max_bytes: usize,
723 timeout: std::time::Duration,
724 cancel: Option<crate::cancel::CancelFlag>,
725) -> Result<(PathBuf, &'static str)> {
726 let wav_tmp = tempfile::Builder::new()
727 .prefix("aurum-upload-")
728 .suffix(".wav")
729 .tempfile()
730 .map_err(|e| EnvironmentError::Other {
731 message: format!("temp wav: {e}"),
732 })?;
733 let wav_path = wav_tmp.path().to_path_buf();
734 drop(wav_tmp);
736 let _ = std::fs::remove_file(&wav_path);
737 write_temp_wav(samples, &wav_path)?;
738
739 if let Ok(ffmpeg) = require_ffmpeg() {
740 if let Some(flag) = &cancel {
741 if flag.is_cancelled() {
742 let _ = std::fs::remove_file(&wav_path);
743 return Err(crate::error::ProviderError::Cancelled.into());
744 }
745 }
746
747 let mp3_path = {
751 let t = tempfile::Builder::new()
752 .prefix("aurum-upload-")
753 .suffix(".mp3")
754 .tempfile()
755 .map_err(|e| EnvironmentError::Other {
756 message: format!("temp mp3: {e}"),
757 })?;
758 let p = t.path().to_path_buf();
759 drop(t);
760 let _ = std::fs::remove_file(&p);
761 p
762 };
763
764 let encode = supervise_ffmpeg_encode(
765 &ffmpeg,
766 &wav_path,
767 &mp3_path,
768 max_bytes,
769 timeout,
770 cancel.clone(),
771 )
772 .await;
773
774 match encode {
775 Ok(()) => {
776 let _ = std::fs::remove_file(&wav_path);
777 if let Ok(meta) = std::fs::metadata(&mp3_path) {
778 if meta.len() > 0 && (meta.len() as usize) <= max_bytes {
779 return Ok((mp3_path, "mp3"));
780 }
781 }
782 let _ = std::fs::remove_file(&mp3_path);
783 }
785 Err(e) if is_terminal_upload_control_error(&e) => {
786 let _ = std::fs::remove_file(&mp3_path);
787 let _ = std::fs::remove_file(&wav_path);
788 return Err(e);
789 }
790 Err(e) => {
791 let _ = std::fs::remove_file(&mp3_path);
792 tracing::debug!(
793 error = %e,
794 "supervised mp3 encode failed with codec/conversion error; falling back to wav"
795 );
796 let _ = std::fs::remove_file(&wav_path);
797 }
798 }
799
800 if let Some(flag) = &cancel {
802 if flag.is_cancelled() {
803 return Err(crate::error::ProviderError::Cancelled.into());
804 }
805 }
806 write_temp_wav(samples, &wav_path)?;
807 }
808
809 if let Some(flag) = &cancel {
810 if flag.is_cancelled() {
811 let _ = std::fs::remove_file(&wav_path);
812 return Err(crate::error::ProviderError::Cancelled.into());
813 }
814 }
815
816 let meta = std::fs::metadata(&wav_path).map_err(|e| EnvironmentError::Other {
817 message: format!("stat upload wav: {e}"),
818 })?;
819 if meta.len() as usize > max_bytes {
820 let _ = std::fs::remove_file(&wav_path);
821 return Err(UserError::AudioTooLarge {
822 decoded_bytes: meta.len() as usize,
823 max_bytes,
824 }
825 .into());
826 }
827 Ok((wav_path, "wav"))
828}
829
830fn is_terminal_upload_control_error(e: &crate::error::TranscriptionError) -> bool {
832 use crate::error::{EnvironmentError, ProviderError, TranscriptionError, UserError};
833 match e {
834 TranscriptionError::Provider(ProviderError::Cancelled)
835 | TranscriptionError::Provider(ProviderError::DeadlineExceeded)
836 | TranscriptionError::User(UserError::AudioTooLarge { .. }) => true,
837 TranscriptionError::Environment(EnvironmentError::FfmpegFailed { reason }) => {
838 reason.contains("wall-clock deadline") || reason.contains("deadline exceeded")
840 }
841 _ => false,
842 }
843}
844
845async fn supervise_ffmpeg_encode(
847 ffmpeg: &Path,
848 wav_path: &Path,
849 mp3_path: &Path,
850 max_bytes: usize,
851 timeout: std::time::Duration,
852 cancel: Option<crate::cancel::CancelFlag>,
853) -> Result<()> {
854 let mut child = Command::new(ffmpeg)
855 .args([
856 "-hide_banner",
857 "-loglevel",
858 "error",
859 "-nostdin",
860 "-n", "-protocol_whitelist",
862 "file,crypto,data",
863 "-i",
864 ])
865 .arg(wav_path)
866 .args(["-codec:a", "libmp3lame", "-b:a", "64k"])
867 .arg(mp3_path)
868 .stdin(Stdio::null())
869 .stdout(Stdio::null())
870 .stderr(Stdio::piped())
871 .kill_on_drop(true)
872 .spawn()
873 .map_err(|e| EnvironmentError::FfmpegFailed {
874 reason: format!("failed to spawn ffmpeg encode: {e}"),
875 })?;
876
877 let mut stderr = child
878 .stderr
879 .take()
880 .ok_or_else(|| EnvironmentError::FfmpegFailed {
881 reason: "ffmpeg stderr missing".into(),
882 })?;
883
884 let cancel_stderr = cancel.clone();
887 let stderr_join = tokio::spawn(async move {
888 let mut tail: Vec<u8> = Vec::new();
889 let mut buf = [0u8; 4 * 1024];
890 loop {
891 if let Some(flag) = &cancel_stderr {
892 if flag.is_cancelled() {
893 return Err(crate::error::ProviderError::Cancelled.into());
894 }
895 }
896 let n = stderr
897 .read(&mut buf)
898 .await
899 .map_err(|e| EnvironmentError::FfmpegFailed {
900 reason: format!("reading ffmpeg stderr: {e}"),
901 })?;
902 if n == 0 {
903 break;
904 }
905 if tail.len() + n > STDERR_TAIL_CAP {
906 let drop_n = (tail.len() + n).saturating_sub(STDERR_TAIL_CAP);
907 if drop_n < tail.len() {
908 tail.drain(..drop_n);
909 } else {
910 tail.clear();
911 }
912 }
913 tail.extend_from_slice(&buf[..n]);
914 }
915 Ok::<Vec<u8>, crate::error::TranscriptionError>(tail)
916 });
917
918 let deadline = tokio::time::Instant::now() + timeout;
920 let result: crate::error::Result<(std::process::ExitStatus, Vec<u8>)> = loop {
921 if tokio::time::Instant::now() >= deadline {
922 let _ = child.kill().await;
923 let _ = child.wait().await;
924 stderr_join.abort();
925 let _ = stderr_join.await;
926 break Err(crate::error::ProviderError::DeadlineExceeded.into());
928 }
929 if let Some(flag) = &cancel {
930 if flag.is_cancelled() {
931 let _ = child.kill().await;
932 let _ = child.wait().await;
933 stderr_join.abort();
934 let _ = stderr_join.await;
935 break Err(crate::error::ProviderError::Cancelled.into());
936 }
937 }
938 if let Ok(meta) = std::fs::metadata(mp3_path) {
939 if meta.len() as usize > max_bytes {
940 let _ = child.kill().await;
941 let _ = child.wait().await;
942 stderr_join.abort();
943 let _ = stderr_join.await;
944 break Err(UserError::AudioTooLarge {
945 decoded_bytes: meta.len() as usize,
946 max_bytes,
947 }
948 .into());
949 }
950 }
951 match child.try_wait() {
953 Ok(Some(status)) => {
954 let tail = match stderr_join.await {
955 Ok(Ok(t)) => t,
956 Ok(Err(e)) => break Err(e),
957 Err(e) => {
958 break Err(EnvironmentError::FfmpegFailed {
959 reason: format!("stderr join: {e}"),
960 }
961 .into());
962 }
963 };
964 break Ok((status, tail));
965 }
966 Ok(None) => {
967 tokio::time::sleep(std::time::Duration::from_millis(50)).await;
968 }
969 Err(e) => {
970 let _ = child.kill().await;
971 let _ = child.wait().await;
972 stderr_join.abort();
973 let _ = stderr_join.await;
974 break Err(EnvironmentError::FfmpegFailed {
975 reason: format!("ffmpeg encode wait failed: {e}"),
976 }
977 .into());
978 }
979 }
980 };
981
982 match result {
983 Ok((status, tail)) => {
984 if !status.success() {
985 let diag = String::from_utf8_lossy(&tail);
986 let short = diag
987 .lines()
988 .last()
989 .unwrap_or("ffmpeg encode failed")
990 .chars()
991 .take(400)
992 .collect::<String>();
993 return Err(EnvironmentError::FfmpegFailed {
994 reason: format!("ffmpeg encode exited with {status}: {short}"),
995 }
996 .into());
997 }
998 Ok(())
999 }
1000 Err(e) => Err(e),
1001 }
1002}
1003
1004pub fn format_from_path(path: &Path) -> &'static str {
1006 match path
1007 .extension()
1008 .and_then(|e| e.to_str())
1009 .unwrap_or("")
1010 .to_ascii_lowercase()
1011 .as_str()
1012 {
1013 "wav" => "wav",
1014 "mp3" => "mp3",
1015 "m4a" | "aac" | "mp4" => "m4a",
1016 "ogg" => "ogg",
1017 "flac" => "flac",
1018 "webm" => "webm",
1019 "opus" => "opus",
1020 _ => "wav",
1021 }
1022}
1023
1024#[cfg(test)]
1025mod tests {
1026 use super::*;
1027 use tempfile::tempdir;
1028
1029 fn synthesize_sine_wav(path: &Path, secs: f32, freq: f32) {
1030 let spec = hound::WavSpec {
1031 channels: 1,
1032 sample_rate: 16_000,
1033 bits_per_sample: 16,
1034 sample_format: hound::SampleFormat::Int,
1035 };
1036 let mut w = hound::WavWriter::create(path, spec).unwrap();
1037 let n = (16_000.0 * secs) as usize;
1038 for i in 0..n {
1039 let t = i as f32 / 16_000.0;
1040 let sample = (2.0 * std::f32::consts::PI * freq * t).sin();
1041 w.write_sample((sample * 32767.0 * 0.2) as i16).unwrap();
1042 }
1043 w.finalize().unwrap();
1044 }
1045
1046 #[test]
1047 fn loads_16k_mono_wav_direct() {
1048 let dir = tempdir().unwrap();
1049 let path = dir.path().join("tone.wav");
1050 synthesize_sine_wav(&path, 0.25, 440.0);
1051
1052 let audio =
1053 try_load_wav_direct(&path, DEFAULT_MAX_DURATION_SECS, DEFAULT_MAX_DECODED_BYTES)
1054 .unwrap();
1055 assert_eq!(audio.sample_rate, 16_000);
1056 assert!(audio.samples.len() > 1000);
1057 assert!((audio.duration_secs - 0.25).abs() < 0.01);
1058 }
1059
1060 #[test]
1061 fn missing_file_errors() {
1062 let err = load_audio_blocking(Path::new("/no/such/file.wav"));
1063 assert!(err.is_err());
1064 }
1065
1066 #[test]
1067 fn rejects_directory() {
1068 let err = load_audio_blocking(Path::new("/tmp"));
1069 assert!(err.is_err());
1070 }
1071
1072 fn load_audio_blocking(path: &Path) -> Result<AudioInput> {
1073 let rt = tokio::runtime::Builder::new_current_thread()
1074 .enable_all()
1075 .build()
1076 .unwrap();
1077 rt.block_on(load_audio(path))
1078 }
1079
1080 #[test]
1081 fn write_and_reload_temp_wav() {
1082 let dir = tempdir().unwrap();
1083 let path = dir.path().join("out.wav");
1084 let samples: Vec<f32> = (0..1600).map(|i| (i as f32 / 1600.0).sin()).collect();
1085 write_temp_wav(&samples, &path).unwrap();
1086 let audio =
1087 try_load_wav_direct(&path, DEFAULT_MAX_DURATION_SECS, DEFAULT_MAX_DECODED_BYTES)
1088 .unwrap();
1089 assert_eq!(audio.samples.len(), samples.len());
1090 }
1091
1092 #[test]
1093 fn from_pcm_basic() {
1094 let audio = AudioInput::from_pcm_slice(&[0.0; 3200], WHISPER_SAMPLE_RATE).unwrap();
1095 assert_eq!(audio.len(), 3200);
1096 assert!((audio.duration_secs - 0.2).abs() < 1e-9);
1097 }
1098
1099 #[test]
1100 fn enforces_duration_limit_precheck() {
1101 let dir = tempdir().unwrap();
1102 let path = dir.path().join("long.wav");
1103 synthesize_sine_wav(&path, 1.0, 440.0);
1104 let err = try_load_wav_direct(&path, 0.1, DEFAULT_MAX_DECODED_BYTES);
1105 assert!(matches!(
1106 err,
1107 Err(crate::error::TranscriptionError::User(
1108 UserError::AudioTooLong { .. }
1109 ))
1110 ));
1111 }
1112
1113 #[test]
1114 fn upload_control_errors_are_not_fallback_eligible() {
1115 use crate::error::{EnvironmentError, ProviderError, TranscriptionError, UserError};
1116 assert!(is_terminal_upload_control_error(
1117 &TranscriptionError::Provider(ProviderError::Cancelled)
1118 ));
1119 assert!(is_terminal_upload_control_error(
1120 &TranscriptionError::Provider(ProviderError::DeadlineExceeded)
1121 ));
1122 assert!(is_terminal_upload_control_error(&TranscriptionError::User(
1123 UserError::AudioTooLarge {
1124 decoded_bytes: 9,
1125 max_bytes: 1,
1126 }
1127 )));
1128 assert!(is_terminal_upload_control_error(
1129 &TranscriptionError::Environment(EnvironmentError::FfmpegFailed {
1130 reason: "ffmpeg encode exceeded wall-clock deadline (1s)".into(),
1131 })
1132 ));
1133 assert!(!is_terminal_upload_control_error(
1135 &TranscriptionError::Environment(EnvironmentError::FfmpegFailed {
1136 reason: "ffmpeg encode exited with exit status: 1: Unknown encoder".into(),
1137 })
1138 ));
1139 assert!(!is_terminal_upload_control_error(
1140 &TranscriptionError::Environment(EnvironmentError::FfmpegMissing)
1141 ));
1142 }
1143
1144 #[tokio::test]
1145 async fn encode_for_upload_pre_cancel_does_not_succeed() {
1146 let cancel = crate::cancel::CancelFlag::new();
1147 cancel.cancel();
1148 let samples = vec![0.0f32; 1600];
1149 let err = encode_for_upload_with_timeout(
1150 &samples,
1151 DEFAULT_MAX_UPLOAD_BYTES,
1152 std::time::Duration::from_secs(5),
1153 Some(cancel),
1154 )
1155 .await
1156 .unwrap_err();
1157 assert!(matches!(
1158 err,
1159 crate::error::TranscriptionError::Provider(crate::error::ProviderError::Cancelled)
1160 ));
1161 }
1162
1163 #[tokio::test]
1166 async fn stalled_pipe_cancel_kills_child_promptly() {
1167 use std::process::Stdio;
1168 use std::time::Instant;
1169 use tokio::process::Command;
1170
1171 let mut child = Command::new("sleep")
1172 .arg("60")
1173 .stdout(Stdio::piped())
1174 .stderr(Stdio::piped())
1175 .kill_on_drop(true)
1176 .spawn()
1177 .expect("spawn sleep");
1178 let cancel = crate::cancel::CancelFlag::new();
1179 let cancel_for_task = cancel.clone();
1180 let start = Instant::now();
1181 let join = tokio::spawn(async move {
1183 race_cancel_against_stalled_pipes(&mut child, cancel_for_task, 15).await
1184 });
1185 tokio::time::sleep(std::time::Duration::from_millis(100)).await;
1186 cancel.cancel();
1187 let err = join.await.expect("join").unwrap_err();
1188 assert!(
1189 start.elapsed() < std::time::Duration::from_secs(5),
1190 "cancel must not wait for long timeout; elapsed {:?}",
1191 start.elapsed()
1192 );
1193 assert!(matches!(
1194 err,
1195 crate::error::TranscriptionError::Provider(crate::error::ProviderError::Cancelled)
1196 ));
1197 }
1198}