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