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