Skip to main content

aurum_core/audio/
mod.rs

1//! Audio loading and conversion.
2//!
3//! Strategy for v0.0.0:
4//! - Prefer system `ffmpeg` for decoding any common format to 16 kHz mono f32 PCM
5//! - Fail fast with install instructions if ffmpeg is missing
6//! - WAV files that are already 16 kHz mono PCM can be read directly via `hound`
7//! - Enforce duration / decoded-size bounds *during* decode so we fail before OOM
8
9use 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
17/// Supported input extensions (informational; ffmpeg is the real gate).
18pub const SUPPORTED_EXTENSIONS: &[&str] = &[
19    "mp3", "m4a", "wav", "flac", "ogg", "opus", "webm", "mp4", "aac", "wma", "mkv",
20];
21
22/// Default maximum audio duration accepted for transcription (~2.25 h matches PCM budget).
23pub const DEFAULT_MAX_DURATION_SECS: f64 = 2.25 * 3600.0;
24
25/// Approximate decoded PCM budget (f32 mono 16 kHz) — ~500 MB ≈ 2.25 h.
26pub const DEFAULT_MAX_DECODED_BYTES: usize = 500 * 1024 * 1024;
27
28/// Max compressed upload size for remote providers (~24 MB keeps base64 JSON manageable).
29pub const DEFAULT_MAX_UPLOAD_BYTES: usize = 24 * 1024 * 1024;
30
31/// Sample rate required by the local whisper.cpp path (Hz).
32pub const WHISPER_SAMPLE_RATE: u32 = 16_000;
33
34/// In-memory audio ready for a transcription provider.
35#[derive(Debug, Clone)]
36pub struct AudioInput {
37    /// Original file path when loaded from disk; synthetic label for PCM (`pcm://…`).
38    pub source_path: PathBuf,
39    /// Mono f32 samples in [-1.0, 1.0], shared to avoid extra copies.
40    /// For the local provider this must be [`WHISPER_SAMPLE_RATE`].
41    pub samples: Arc<[f32]>,
42    /// Sample rate of [`Self::samples`].
43    pub sample_rate: u32,
44    /// Duration in seconds.
45    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    /// Build from pre-decoded mono PCM (e.g. mic capture). No ffmpeg, no disk I/O.
58    ///
59    /// `sample_rate` must be [`WHISPER_SAMPLE_RATE`] (16 kHz). Resample upstream if needed.
60    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    /// Like [`from_pcm`](Self::from_pcm) with explicit safety limits.
70    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    /// Copy a slice into a new [`AudioInput`] (convenience for mic chunks already at 16 kHz).
115    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
121/// Ensure ffmpeg is available on PATH.
122pub fn require_ffmpeg() -> Result<PathBuf> {
123    which("ffmpeg").map_err(|_| EnvironmentError::FfmpegMissing.into())
124}
125
126/// Check whether ffmpeg is available (non-fatal).
127pub fn ffmpeg_available() -> bool {
128    which("ffmpeg").is_ok()
129}
130
131/// Load an audio file, converting to 16 kHz mono f32 PCM as needed.
132pub 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
136/// Load audio with explicit safety limits (used by tests and future flags).
137pub 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    // Fast path: already-correct WAV.
156    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
170/// Attempt to read a 16 kHz mono PCM WAV directly, rejecting oversized files first.
171fn 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    // Upper bound: 16-bit mono PCM payload ≈ file_size - 44 header.
180    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    // Stream i16 → f32 directly into one destination buffer (JOE-1602).
217    // Never materialize a complete i16 vector alongside the f32 output.
218    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
280/// Default wall-clock deadline for a single FFmpeg decode (JOE-1585).
281pub const DEFAULT_FFMPEG_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(600);
282/// Bounded stderr diagnostic tail retained for user-facing errors.
283const STDERR_TAIL_CAP: usize = 8 * 1024;
284
285/// Structured FFmpeg termination reason (JOE-1585).
286#[derive(Debug, Clone, PartialEq, Eq)]
287pub enum FfmpegTermination {
288    Success,
289    InvalidMedia,
290    LimitExceeded,
291    Timeout,
292    Cancelled,
293    SpawnFailure,
294    NonZeroExit,
295}
296
297/// Decode any format to 16 kHz mono f32 via supervised FFmpeg (JOE-1585).
298///
299/// - Shell-free argv, `-nostdin`, protocol restriction for local files
300/// - Concurrent stdout/stderr drain with hard caps
301/// - Wall-clock deadline; kill+reap on any failure path
302async 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
317/// Supervised FFmpeg decode with explicit deadline and optional cancel flag.
318pub 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    // Restrict demuxer protocols to local files when supported by the build.
328    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    // Concurrent pipe drains. Cancellation is raced *outside* the read futures
387    // so a stalled read does not delay cancel until the wall-clock timeout
388    // (JOE-1648 fourth-pass residual).
389    let stdout_task = async {
390        // Stream s16le → f32 on the fly (JOE-1602).
391        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    // Bound the final wait so a stuck child after pipe close cannot hang forever.
516    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/// Test helper: race cancel against stalled pipe reads on an arbitrary child
576/// (JOE-1648 fourth-pass). Production decode uses the same `select!` pattern.
577#[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
646/// Write samples out as a 16 kHz mono WAV using an exclusive create (O_EXCL).
647pub fn write_temp_wav(samples: &[f32], dest: &Path) -> Result<()> {
648    // Never follow symlinks: require create_new. Callers that need overwrite must unlink first.
649    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
682/// Encode samples to a compressed temp file for remote upload (JOE-1648).
683///
684/// Returns `(path, format)` where format is `"mp3"` or `"wav"`.
685/// Caller must delete `path` when done.
686///
687/// MP3 encoding uses the same supervised FFmpeg lifecycle as decode: `-nostdin`,
688/// concurrent stderr drain, wall-clock deadline, kill+reap on failure. Destination
689/// is exclusively created (no `-y` clobber).
690///
691/// WAV fallback is **only** for encoder/codec conversion failures (missing
692/// libmp3lame, non-zero FFmpeg exit for encode reasons). Cancellation, absolute
693/// deadline expiry, and size-cap violations **never** fall back to a successful
694/// WAV (JOE-1648 third-pass residual).
695pub 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
702/// Supervised upload encode with explicit deadline and optional cancel flag.
703pub 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    // Close handle, rewrite exclusively via our writer.
718    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        // Reserve a unique path (O_EXCL), then remove so FFmpeg creates the file
731        // without `-y` clobber semantics. `-n` refuses to overwrite if something
732        // else appears at the path (JOE-1648).
733        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                // Empty / oversize output is a conversion failure → WAV fallback.
767            }
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        // Re-check control plane before returning fallback success.
784        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
813/// Errors that must not be rewritten as a successful WAV fallback.
814fn 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            // Supervisor maps wall-clock timeout into this variant.
822            reason.contains("wall-clock deadline") || reason.contains("deadline exceeded")
823        }
824        _ => false,
825    }
826}
827
828/// Run FFmpeg encode with concurrent stderr drain, deadline, cancel, and file-size cap.
829async 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", // never overwrite an unexpected existing path
844            "-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    // Concurrent stderr drain from process start (JOE-1648). Never wait for the
868    // child first — a full stderr pipe would deadlock FFmpeg before exit.
869    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    // Manual deadline so we keep ownership of `child` for explicit kill/reap.
902    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            // Distinct from codec failure so upload fallback cannot swallow it.
910            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        // Poll child without blocking the concurrent stderr drain task.
935        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
987/// Infer a reasonable audio format label from a path extension.
988pub 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        // Codec/non-zero exit may fall back to WAV.
1117        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    /// Stalled pipe reads must not delay cancellation until a long timeout
1147    /// (JOE-1648 fourth-pass). `sleep` keeps stdout/stderr open without data.
1148    #[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        // Start drains first so reads park on empty pipes, then cancel.
1165        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}