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