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