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