1use 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
17pub const SUPPORTED_EXTENSIONS: &[&str] = &[
19 "mp3", "m4a", "wav", "flac", "ogg", "opus", "webm", "mp4", "aac", "wma", "mkv",
20];
21
22pub const DEFAULT_MAX_DURATION_SECS: f64 = 2.25 * 3600.0;
24
25pub const DEFAULT_MAX_DECODED_BYTES: usize = 500 * 1024 * 1024;
27
28pub const DEFAULT_MAX_UPLOAD_BYTES: usize = 24 * 1024 * 1024;
30
31pub const WHISPER_SAMPLE_RATE: u32 = 16_000;
33
34#[derive(Debug, Clone)]
36pub struct AudioInput {
37 pub source_path: PathBuf,
39 pub samples: Arc<[f32]>,
42 pub sample_rate: u32,
44 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 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 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 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
121pub fn require_ffmpeg() -> Result<PathBuf> {
123 which("ffmpeg").map_err(|_| EnvironmentError::FfmpegMissing.into())
124}
125
126pub fn ffmpeg_available() -> bool {
128 which("ffmpeg").is_ok()
129}
130
131pub 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
136pub 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 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
170fn 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 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
268async 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 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 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 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
398pub fn write_temp_wav(samples: &[f32], dest: &Path) -> Result<()> {
400 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
434pub 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 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 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
508pub 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}