Skip to main content

aurum_core/audio/
mod.rs

1//! Audio loading and conversion.
2//!
3//! Strategy for v0.0.0:
4//! - Prefer system `ffmpeg` for decoding any common format to 16 kHz mono f32 PCM
5//! - Fail fast with install instructions if ffmpeg is missing
6//! - WAV files that are already 16 kHz mono PCM can be read directly via `hound`
7//! - Enforce duration / decoded-size bounds *during* decode so we fail before OOM
8
9use crate::error::{EnvironmentError, Result, UserError};
10use std::path::{Path, PathBuf};
11use std::process::Stdio;
12use std::sync::Arc;
13use tokio::io::AsyncReadExt;
14use tokio::process::Command;
15use which::which;
16
17/// Supported input extensions (informational; ffmpeg is the real gate).
18pub const SUPPORTED_EXTENSIONS: &[&str] = &[
19    "mp3", "m4a", "wav", "flac", "ogg", "opus", "webm", "mp4", "aac", "wma", "mkv",
20];
21
22/// Default maximum audio duration accepted for transcription (~2.25 h matches PCM budget).
23pub const DEFAULT_MAX_DURATION_SECS: f64 = 2.25 * 3600.0;
24
25/// Approximate decoded PCM budget (f32 mono 16 kHz) — ~500 MB ≈ 2.25 h.
26pub const DEFAULT_MAX_DECODED_BYTES: usize = 500 * 1024 * 1024;
27
28/// Max compressed upload size for remote providers (~24 MB keeps base64 JSON manageable).
29pub const DEFAULT_MAX_UPLOAD_BYTES: usize = 24 * 1024 * 1024;
30
31/// Sample rate required by the local whisper.cpp path (Hz).
32pub const WHISPER_SAMPLE_RATE: u32 = 16_000;
33
34/// In-memory audio ready for a transcription provider.
35#[derive(Debug, Clone)]
36pub struct AudioInput {
37    /// Original file path when loaded from disk; synthetic label for PCM (`pcm://…`).
38    pub source_path: PathBuf,
39    /// Mono f32 samples in [-1.0, 1.0], shared to avoid extra copies.
40    /// For the local provider this must be [`WHISPER_SAMPLE_RATE`].
41    pub samples: Arc<[f32]>,
42    /// Sample rate of [`Self::samples`].
43    pub sample_rate: u32,
44    /// Duration in seconds.
45    pub duration_secs: f64,
46}
47
48impl AudioInput {
49    pub fn len(&self) -> usize {
50        self.samples.len()
51    }
52
53    pub fn is_empty(&self) -> bool {
54        self.samples.is_empty()
55    }
56
57    /// Build from pre-decoded mono PCM (e.g. mic capture). No ffmpeg, no disk I/O.
58    ///
59    /// `sample_rate` must be [`WHISPER_SAMPLE_RATE`] (16 kHz). Resample upstream if needed.
60    pub fn from_pcm(samples: impl Into<Arc<[f32]>>, sample_rate: u32) -> Result<Self> {
61        Self::from_pcm_with_limits(
62            samples,
63            sample_rate,
64            DEFAULT_MAX_DURATION_SECS,
65            DEFAULT_MAX_DECODED_BYTES,
66        )
67    }
68
69    /// Like [`from_pcm`](Self::from_pcm) with explicit safety limits.
70    pub fn from_pcm_with_limits(
71        samples: impl Into<Arc<[f32]>>,
72        sample_rate: u32,
73        max_duration_secs: f64,
74        max_decoded_bytes: usize,
75    ) -> Result<Self> {
76        if sample_rate != WHISPER_SAMPLE_RATE {
77            return Err(UserError::UnsupportedSampleRate {
78                got: sample_rate,
79                need: WHISPER_SAMPLE_RATE,
80            }
81            .into());
82        }
83        let samples: Arc<[f32]> = samples.into();
84        if samples.is_empty() {
85            return Err(UserError::InvalidAudio {
86                reason: "PCM buffer is empty".into(),
87            }
88            .into());
89        }
90        let decoded_bytes = samples.len().saturating_mul(std::mem::size_of::<f32>());
91        let duration_secs = samples.len() as f64 / f64::from(sample_rate);
92        if duration_secs > max_duration_secs {
93            return Err(UserError::AudioTooLong {
94                duration_secs,
95                max_secs: max_duration_secs,
96            }
97            .into());
98        }
99        if decoded_bytes > max_decoded_bytes {
100            return Err(UserError::AudioTooLarge {
101                decoded_bytes,
102                max_bytes: max_decoded_bytes,
103            }
104            .into());
105        }
106        Ok(Self {
107            source_path: PathBuf::from(format!("pcm://{sample_rate}hz/{}", samples.len())),
108            samples,
109            sample_rate,
110            duration_secs,
111        })
112    }
113
114    /// Copy a slice into a new [`AudioInput`] (convenience for mic chunks already at 16 kHz).
115    pub fn from_pcm_slice(samples: &[f32], sample_rate: u32) -> Result<Self> {
116        let owned: Arc<[f32]> = samples.to_vec().into();
117        Self::from_pcm(owned, sample_rate)
118    }
119}
120
121/// Ensure ffmpeg is available on PATH.
122pub fn require_ffmpeg() -> Result<PathBuf> {
123    which("ffmpeg").map_err(|_| EnvironmentError::FfmpegMissing.into())
124}
125
126/// Check whether ffmpeg is available (non-fatal).
127pub fn ffmpeg_available() -> bool {
128    which("ffmpeg").is_ok()
129}
130
131/// Load an audio file, converting to 16 kHz mono f32 PCM as needed.
132pub async fn load_audio(path: &Path) -> Result<AudioInput> {
133    load_audio_with_limits(path, DEFAULT_MAX_DURATION_SECS, DEFAULT_MAX_DECODED_BYTES).await
134}
135
136/// Load audio with explicit safety limits (used by tests and future flags).
137pub async fn load_audio_with_limits(
138    path: &Path,
139    max_duration_secs: f64,
140    max_decoded_bytes: usize,
141) -> Result<AudioInput> {
142    if !path.exists() {
143        return Err(UserError::FileNotFound {
144            path: path.display().to_string(),
145        }
146        .into());
147    }
148    if !path.is_file() {
149        return Err(UserError::InvalidAudio {
150            reason: format!("{} is not a regular file", path.display()),
151        }
152        .into());
153    }
154
155    // Fast path: already-correct WAV.
156    if path
157        .extension()
158        .and_then(|e| e.to_str())
159        .is_some_and(|e| e.eq_ignore_ascii_case("wav"))
160    {
161        if let Ok(audio) = try_load_wav_direct(path, max_duration_secs, max_decoded_bytes) {
162            return Ok(audio);
163        }
164        tracing::debug!("WAV direct-load failed; falling back to ffmpeg");
165    }
166
167    load_via_ffmpeg(path, max_duration_secs, max_decoded_bytes).await
168}
169
170/// Attempt to read a 16 kHz mono PCM WAV directly, rejecting oversized files first.
171fn try_load_wav_direct(
172    path: &Path,
173    max_duration_secs: f64,
174    max_decoded_bytes: usize,
175) -> Result<AudioInput> {
176    let meta = std::fs::metadata(path).map_err(|e| UserError::InvalidAudio {
177        reason: e.to_string(),
178    })?;
179    // Upper bound: 16-bit mono PCM payload ≈ file_size - 44 header.
180    let approx_samples = (meta.len().saturating_sub(44) / 2) as usize;
181    let approx_decoded = approx_samples.saturating_mul(std::mem::size_of::<f32>());
182    let approx_duration = approx_samples as f64 / 16_000.0;
183    if approx_duration > max_duration_secs {
184        return Err(UserError::AudioTooLong {
185            duration_secs: approx_duration,
186            max_secs: max_duration_secs,
187        }
188        .into());
189    }
190    if approx_decoded > max_decoded_bytes {
191        return Err(UserError::AudioTooLarge {
192            decoded_bytes: approx_decoded,
193            max_bytes: max_decoded_bytes,
194        }
195        .into());
196    }
197
198    let reader = hound::WavReader::open(path).map_err(|e| UserError::InvalidAudio {
199        reason: e.to_string(),
200    })?;
201    let spec = reader.spec();
202
203    if spec.sample_rate != 16_000 {
204        return Err(UserError::InvalidAudio {
205            reason: format!("sample rate is {} Hz (need 16000)", spec.sample_rate),
206        }
207        .into());
208    }
209    if spec.channels != 1 {
210        return Err(UserError::InvalidAudio {
211            reason: format!("{} channels (need mono)", spec.channels),
212        }
213        .into());
214    }
215
216    let samples_i16: std::result::Result<Vec<i16>, _> = match spec.sample_format {
217        hound::SampleFormat::Int => reader.into_samples::<i16>().collect(),
218        hound::SampleFormat::Float => {
219            return Err(UserError::InvalidAudio {
220                reason: "float WAV; use ffmpeg path".into(),
221            }
222            .into());
223        }
224    };
225
226    let samples_i16 = samples_i16.map_err(|e| UserError::InvalidAudio {
227        reason: format!("failed reading samples: {e}"),
228    })?;
229
230    let samples: Arc<[f32]> = samples_i16
231        .iter()
232        .map(|s| *s as f32 / 32768.0)
233        .collect::<Vec<_>>()
234        .into();
235
236    let duration_secs = samples.len() as f64 / 16_000.0;
237    let decoded_bytes = samples.len().saturating_mul(std::mem::size_of::<f32>());
238
239    if duration_secs > max_duration_secs {
240        return Err(UserError::AudioTooLong {
241            duration_secs,
242            max_secs: max_duration_secs,
243        }
244        .into());
245    }
246    if decoded_bytes > max_decoded_bytes {
247        return Err(UserError::AudioTooLarge {
248            decoded_bytes,
249            max_bytes: max_decoded_bytes,
250        }
251        .into());
252    }
253    if samples.is_empty() {
254        return Err(UserError::InvalidAudio {
255            reason: "audio contains no samples".into(),
256        }
257        .into());
258    }
259
260    Ok(AudioInput {
261        source_path: path.to_path_buf(),
262        samples,
263        sample_rate: 16_000,
264        duration_secs,
265    })
266}
267
268/// Decode any format to 16 kHz mono f32 via ffmpeg, streaming with hard caps.
269async fn load_via_ffmpeg(
270    path: &Path,
271    max_duration_secs: f64,
272    max_decoded_bytes: usize,
273) -> Result<AudioInput> {
274    let ffmpeg = require_ffmpeg()?;
275
276    // Cap decode duration at the source so ffmpeg stops early.
277    let max_t = format!("{max_duration_secs:.3}");
278
279    let mut child = Command::new(&ffmpeg)
280        .args(["-hide_banner", "-loglevel", "error", "-i"])
281        .arg(path)
282        .args([
283            "-t",
284            &max_t,
285            "-f",
286            "s16le",
287            "-acodec",
288            "pcm_s16le",
289            "-ac",
290            "1",
291            "-ar",
292            "16000",
293            "-",
294        ])
295        .stdout(Stdio::piped())
296        .stderr(Stdio::piped())
297        .kill_on_drop(true)
298        .spawn()
299        .map_err(|e| EnvironmentError::FfmpegFailed {
300            reason: format!("failed to spawn ffmpeg: {e}"),
301        })?;
302
303    let mut stdout = child
304        .stdout
305        .take()
306        .ok_or_else(|| EnvironmentError::FfmpegFailed {
307            reason: "ffmpeg stdout missing".into(),
308        })?;
309
310    // Raw s16le bytes; cap before converting to f32.
311    let max_raw_bytes = max_decoded_bytes / std::mem::size_of::<f32>() * 2;
312    let mut raw: Vec<u8> = Vec::new();
313    let mut buf = [0u8; 64 * 1024];
314
315    loop {
316        let n = stdout
317            .read(&mut buf)
318            .await
319            .map_err(|e| EnvironmentError::FfmpegFailed {
320                reason: format!("reading ffmpeg stdout: {e}"),
321            })?;
322        if n == 0 {
323            break;
324        }
325        if raw.len().saturating_add(n) > max_raw_bytes {
326            let _ = child.kill().await;
327            return Err(UserError::AudioTooLarge {
328                decoded_bytes: (raw.len() + n) / 2 * std::mem::size_of::<f32>(),
329                max_bytes: max_decoded_bytes,
330            }
331            .into());
332        }
333        raw.extend_from_slice(&buf[..n]);
334    }
335
336    let status = child
337        .wait_with_output()
338        .await
339        .map_err(|e| EnvironmentError::FfmpegFailed {
340            reason: format!("ffmpeg wait failed: {e}"),
341        })?;
342
343    if !status.status.success() {
344        let stderr = String::from_utf8_lossy(&status.stderr);
345        let reason = stderr.trim().to_string();
346        let short = reason.lines().last().unwrap_or(&reason).to_string();
347        return Err(UserError::InvalidAudio { reason: short }.into());
348    }
349
350    if raw.is_empty() {
351        return Err(UserError::InvalidAudio {
352            reason: "ffmpeg produced no audio data (empty or corrupt file?)".into(),
353        }
354        .into());
355    }
356    if !raw.len().is_multiple_of(2) {
357        return Err(UserError::InvalidAudio {
358            reason: "ffmpeg produced misaligned PCM data".into(),
359        }
360        .into());
361    }
362
363    let samples: Arc<[f32]> = raw
364        .chunks_exact(2)
365        .map(|c| {
366            let s = i16::from_le_bytes([c[0], c[1]]);
367            s as f32 / 32768.0
368        })
369        .collect::<Vec<_>>()
370        .into();
371
372    let duration_secs = samples.len() as f64 / 16_000.0;
373    // ffmpeg -t stops at the cap; if we filled the full window, the source may be longer.
374    // Reject to match the WAV path (no silent truncation).
375    if duration_secs + 0.05 >= max_duration_secs {
376        return Err(UserError::AudioTooLong {
377            duration_secs: max_duration_secs,
378            max_secs: max_duration_secs,
379        }
380        .into());
381    }
382
383    if samples.is_empty() {
384        return Err(UserError::InvalidAudio {
385            reason: "audio contains no samples".into(),
386        }
387        .into());
388    }
389
390    Ok(AudioInput {
391        source_path: path.to_path_buf(),
392        samples,
393        sample_rate: 16_000,
394        duration_secs,
395    })
396}
397
398/// Write samples out as a 16 kHz mono WAV using an exclusive create (O_EXCL).
399pub fn write_temp_wav(samples: &[f32], dest: &Path) -> Result<()> {
400    // Never follow symlinks: require create_new. Callers that need overwrite must unlink first.
401    let file = std::fs::OpenOptions::new()
402        .write(true)
403        .create_new(true)
404        .open(dest)
405        .map_err(|e| EnvironmentError::Other {
406            message: format!("failed to create temp wav {}: {e}", dest.display()),
407        })?;
408
409    let spec = hound::WavSpec {
410        channels: 1,
411        sample_rate: 16_000,
412        bits_per_sample: 16,
413        sample_format: hound::SampleFormat::Int,
414    };
415    let mut writer = hound::WavWriter::new(file, spec).map_err(|e| EnvironmentError::Other {
416        message: format!("failed to create wav writer {}: {e}", dest.display()),
417    })?;
418
419    for &s in samples {
420        let clamped = s.clamp(-1.0, 1.0);
421        let i = (clamped * 32767.0).round() as i16;
422        writer
423            .write_sample(i)
424            .map_err(|e| EnvironmentError::Other {
425                message: format!("failed writing wav sample: {e}"),
426            })?;
427    }
428    writer.finalize().map_err(|e| EnvironmentError::Other {
429        message: format!("failed finalizing wav: {e}"),
430    })?;
431    Ok(())
432}
433
434/// Encode samples to a compressed temp file for remote upload.
435///
436/// Returns `(path, format)` where format is `"mp3"` or `"wav"`.
437/// Caller must delete `path` when done.
438///
439/// Uses `tempfile` (O_EXCL + random name) to avoid `/tmp` symlink races.
440pub async fn encode_for_upload(
441    samples: &[f32],
442    max_bytes: usize,
443) -> Result<(PathBuf, &'static str)> {
444    let wav_tmp = tempfile::Builder::new()
445        .prefix("aurum-upload-")
446        .suffix(".wav")
447        .tempfile()
448        .map_err(|e| EnvironmentError::Other {
449            message: format!("temp wav: {e}"),
450        })?;
451    let wav_path = wav_tmp.path().to_path_buf();
452    // Close handle, rewrite exclusively via our writer.
453    drop(wav_tmp);
454    let _ = std::fs::remove_file(&wav_path);
455    write_temp_wav(samples, &wav_path)?;
456
457    if let Ok(ffmpeg) = require_ffmpeg() {
458        let mp3_tmp = tempfile::Builder::new()
459            .prefix("aurum-upload-")
460            .suffix(".mp3")
461            .tempfile()
462            .map_err(|e| EnvironmentError::Other {
463                message: format!("temp mp3: {e}"),
464            })?;
465        let mp3_path = mp3_tmp.path().to_path_buf();
466        let (_f, mp3_kept) = mp3_tmp.keep().map_err(|e| EnvironmentError::Other {
467            message: format!("persist mp3: {e}"),
468        })?;
469
470        let output = Command::new(&ffmpeg)
471            .args(["-hide_banner", "-loglevel", "error", "-y", "-i"])
472            .arg(&wav_path)
473            .args(["-codec:a", "libmp3lame", "-b:a", "64k"])
474            .arg(&mp3_path)
475            .output()
476            .await;
477
478        let _ = std::fs::remove_file(&wav_path);
479
480        if let Ok(output) = output {
481            if output.status.success() {
482                if let Ok(meta) = std::fs::metadata(&mp3_path) {
483                    if meta.len() > 0 && (meta.len() as usize) <= max_bytes {
484                        return Ok((mp3_kept, "mp3"));
485                    }
486                }
487            }
488        }
489        let _ = std::fs::remove_file(&mp3_path);
490        // Fall through to recreate wav
491        write_temp_wav(samples, &wav_path)?;
492    }
493
494    let meta = std::fs::metadata(&wav_path).map_err(|e| EnvironmentError::Other {
495        message: format!("stat upload wav: {e}"),
496    })?;
497    if meta.len() as usize > max_bytes {
498        let _ = std::fs::remove_file(&wav_path);
499        return Err(UserError::AudioTooLarge {
500            decoded_bytes: meta.len() as usize,
501            max_bytes,
502        }
503        .into());
504    }
505    Ok((wav_path, "wav"))
506}
507
508/// Infer a reasonable audio format label from a path extension.
509pub fn format_from_path(path: &Path) -> &'static str {
510    match path
511        .extension()
512        .and_then(|e| e.to_str())
513        .unwrap_or("")
514        .to_ascii_lowercase()
515        .as_str()
516    {
517        "wav" => "wav",
518        "mp3" => "mp3",
519        "m4a" | "aac" | "mp4" => "m4a",
520        "ogg" => "ogg",
521        "flac" => "flac",
522        "webm" => "webm",
523        "opus" => "opus",
524        _ => "wav",
525    }
526}
527
528#[cfg(test)]
529mod tests {
530    use super::*;
531    use tempfile::tempdir;
532
533    fn synthesize_sine_wav(path: &Path, secs: f32, freq: f32) {
534        let spec = hound::WavSpec {
535            channels: 1,
536            sample_rate: 16_000,
537            bits_per_sample: 16,
538            sample_format: hound::SampleFormat::Int,
539        };
540        let mut w = hound::WavWriter::create(path, spec).unwrap();
541        let n = (16_000.0 * secs) as usize;
542        for i in 0..n {
543            let t = i as f32 / 16_000.0;
544            let sample = (2.0 * std::f32::consts::PI * freq * t).sin();
545            w.write_sample((sample * 32767.0 * 0.2) as i16).unwrap();
546        }
547        w.finalize().unwrap();
548    }
549
550    #[test]
551    fn loads_16k_mono_wav_direct() {
552        let dir = tempdir().unwrap();
553        let path = dir.path().join("tone.wav");
554        synthesize_sine_wav(&path, 0.25, 440.0);
555
556        let audio =
557            try_load_wav_direct(&path, DEFAULT_MAX_DURATION_SECS, DEFAULT_MAX_DECODED_BYTES)
558                .unwrap();
559        assert_eq!(audio.sample_rate, 16_000);
560        assert!(audio.samples.len() > 1000);
561        assert!((audio.duration_secs - 0.25).abs() < 0.01);
562    }
563
564    #[test]
565    fn missing_file_errors() {
566        let err = load_audio_blocking(Path::new("/no/such/file.wav"));
567        assert!(err.is_err());
568    }
569
570    #[test]
571    fn rejects_directory() {
572        let err = load_audio_blocking(Path::new("/tmp"));
573        assert!(err.is_err());
574    }
575
576    fn load_audio_blocking(path: &Path) -> Result<AudioInput> {
577        let rt = tokio::runtime::Builder::new_current_thread()
578            .enable_all()
579            .build()
580            .unwrap();
581        rt.block_on(load_audio(path))
582    }
583
584    #[test]
585    fn write_and_reload_temp_wav() {
586        let dir = tempdir().unwrap();
587        let path = dir.path().join("out.wav");
588        let samples: Vec<f32> = (0..1600).map(|i| (i as f32 / 1600.0).sin()).collect();
589        write_temp_wav(&samples, &path).unwrap();
590        let audio =
591            try_load_wav_direct(&path, DEFAULT_MAX_DURATION_SECS, DEFAULT_MAX_DECODED_BYTES)
592                .unwrap();
593        assert_eq!(audio.samples.len(), samples.len());
594    }
595
596    #[test]
597    fn from_pcm_basic() {
598        let audio = AudioInput::from_pcm_slice(&[0.0; 3200], WHISPER_SAMPLE_RATE).unwrap();
599        assert_eq!(audio.len(), 3200);
600        assert!((audio.duration_secs - 0.2).abs() < 1e-9);
601    }
602
603    #[test]
604    fn enforces_duration_limit_precheck() {
605        let dir = tempdir().unwrap();
606        let path = dir.path().join("long.wav");
607        synthesize_sine_wav(&path, 1.0, 440.0);
608        let err = try_load_wav_direct(&path, 0.1, DEFAULT_MAX_DECODED_BYTES);
609        assert!(matches!(
610            err,
611            Err(crate::error::TranscriptionError::User(
612                UserError::AudioTooLong { .. }
613            ))
614        ));
615    }
616}