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