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