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