Skip to main content

aurum_core/audio/
remote_normalize.rs

1//! Bounded remote-audio normalization for remote TTS (JOE-1937).
2//!
3//! Converts provider wire bytes into validated mono `i16` PCM plus an accurate
4//! sample rate. Prefer in-process PCM/WAV paths; use supervised FFmpeg only for
5//! compressed containers (MP3). Provider bytes never bypass encoded/decoded caps.
6//!
7//! Security: remote audio is untrusted parser input. Error messages never include
8//! audio body bytes, PCM previews, or synthesis text.
9
10use crate::error::{EnvironmentError, ProviderError, Result};
11use crate::runtime::OpContext;
12use std::io::Cursor;
13use std::path::PathBuf;
14use std::process::Stdio;
15use std::time::Duration;
16use tokio::io::AsyncReadExt;
17use tokio::process::Command;
18
19/// Default hard cap on encoded provider response bodies (16 MiB).
20pub const DEFAULT_MAX_ENCODED_BYTES: usize = 16 * 1024 * 1024;
21
22/// Default hard cap on decoded mono PCM sample count (~10 min @ 48 kHz).
23pub const DEFAULT_MAX_PCM_SAMPLES: usize = 48_000 * 600;
24
25/// Default maximum duration of normalized audio.
26pub const DEFAULT_MAX_DURATION: Duration = Duration::from_secs(600);
27
28/// Discrete sample rates accepted for remote TTS wire formats.
29pub const ALLOWED_SAMPLE_RATES_HZ: &[u32] = &[
30    8_000, 11_025, 12_000, 16_000, 22_050, 24_000, 32_000, 44_100, 48_000,
31];
32
33/// Wire format declared by the provider/capability path (not raw MIME guessing).
34#[derive(Debug, Clone, Copy, PartialEq, Eq)]
35pub enum EncodedAudioFormat {
36    /// Little-endian signed 16-bit PCM with an explicit layout.
37    PcmS16Le { sample_rate_hz: u32, channels: u16 },
38    /// RIFF WAVE container (in-process bounded parser when PCM-compatible).
39    Wav,
40    /// MPEG-1/2 Layer III (supervised FFmpeg → mono WAV → in-process parse).
41    Mp3,
42}
43
44impl EncodedAudioFormat {
45    pub fn as_label(self) -> &'static str {
46        match self {
47            Self::PcmS16Le { .. } => "pcm_s16le",
48            Self::Wav => "wav",
49            Self::Mp3 => "mp3",
50        }
51    }
52}
53
54/// How multi-channel PCM is handled.
55#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
56pub enum ChannelPolicy {
57    /// Reject any channel count other than 1.
58    MonoOnly,
59    /// Average L+R for stereo (deterministic); reject >2 channels.
60    #[default]
61    DownmixStereo,
62}
63
64/// Resource bounds for remote audio normalization.
65#[derive(Debug, Clone, Copy)]
66pub struct RemoteAudioLimits {
67    pub max_encoded_bytes: usize,
68    pub max_pcm_samples: usize,
69    pub max_duration: Duration,
70    pub channel_policy: ChannelPolicy,
71}
72
73impl Default for RemoteAudioLimits {
74    fn default() -> Self {
75        Self {
76            max_encoded_bytes: DEFAULT_MAX_ENCODED_BYTES,
77            max_pcm_samples: DEFAULT_MAX_PCM_SAMPLES,
78            max_duration: DEFAULT_MAX_DURATION,
79            channel_policy: ChannelPolicy::DownmixStereo,
80        }
81    }
82}
83
84impl RemoteAudioLimits {
85    /// Tight limits for unit tests and fuzz budgets.
86    pub fn tight() -> Self {
87        Self {
88            max_encoded_bytes: 64 * 1024,
89            max_pcm_samples: 48_000, // 1 s @ 48 kHz
90            max_duration: Duration::from_secs(2),
91            channel_policy: ChannelPolicy::DownmixStereo,
92        }
93    }
94
95    pub fn sample_rate_allowed(self, rate: u32) -> bool {
96        ALLOWED_SAMPLE_RATES_HZ.contains(&rate)
97    }
98}
99
100/// Encoded provider body already under a hard byte cap.
101#[derive(Debug, Clone)]
102pub struct BoundedAudioBody {
103    bytes: Vec<u8>,
104}
105
106impl BoundedAudioBody {
107    /// Construct from fully buffered bytes, enforcing `max_bytes`.
108    ///
109    /// Prefer streaming caps ([`crate::remote::read_body_limited`]) at the HTTP
110    /// layer; this is a second gate before decode.
111    pub fn try_from_bytes(bytes: Vec<u8>, max_bytes: usize, provider: &str) -> Result<Self> {
112        if bytes.len() > max_bytes {
113            return Err(ProviderError::ResponseTooLarge {
114                provider: provider.into(),
115                reason: format!(
116                    "encoded audio body {} bytes exceeds cap {max_bytes}",
117                    bytes.len()
118                ),
119            }
120            .into());
121        }
122        if bytes.is_empty() {
123            return Err(ProviderError::InvalidProviderPayload {
124                provider: provider.into(),
125                reason: "encoded audio body is empty".into(),
126            }
127            .into());
128        }
129        Ok(Self { bytes })
130    }
131
132    pub fn len(&self) -> usize {
133        self.bytes.len()
134    }
135
136    pub fn is_empty(&self) -> bool {
137        self.bytes.is_empty()
138    }
139
140    pub fn as_slice(&self) -> &[u8] {
141        &self.bytes
142    }
143
144    pub fn into_bytes(self) -> Vec<u8> {
145        self.bytes
146    }
147}
148
149/// Validated mono PCM result of remote-audio normalization.
150#[derive(Debug, Clone)]
151pub struct NormalizedAudio {
152    pub pcm_i16_mono: Vec<i16>,
153    pub sample_rate_hz: u32,
154    /// Duration derived from final PCM length and sample rate.
155    pub duration_ms: u64,
156    /// Wire format that produced this PCM (for honesty / diagnostics).
157    pub source_format: &'static str,
158}
159
160impl NormalizedAudio {
161    pub fn channels(&self) -> u16 {
162        1
163    }
164
165    pub fn sample_count(&self) -> usize {
166        self.pcm_i16_mono.len()
167    }
168}
169
170/// Normalize provider audio bytes into mono `i16` PCM under shared limits.
171///
172/// `format` must come from capability/request context — MIME headers alone are
173/// not trusted. Format mismatches fail with [`ProviderError::InvalidProviderPayload`].
174pub async fn normalize_remote_audio(
175    body: BoundedAudioBody,
176    format: EncodedAudioFormat,
177    limits: RemoteAudioLimits,
178    op: &OpContext,
179    provider: &str,
180) -> Result<NormalizedAudio> {
181    op.check()?;
182    if body.len() > limits.max_encoded_bytes {
183        return Err(ProviderError::ResponseTooLarge {
184            provider: provider.into(),
185            reason: format!(
186                "encoded audio body {} bytes exceeds cap {}",
187                body.len(),
188                limits.max_encoded_bytes
189            ),
190        }
191        .into());
192    }
193
194    match format {
195        EncodedAudioFormat::PcmS16Le {
196            sample_rate_hz,
197            channels,
198        } => {
199            let pcm =
200                decode_pcm_s16le(body.as_slice(), sample_rate_hz, channels, limits, provider)?;
201            op.check()?;
202            finalize_mono(pcm, sample_rate_hz, limits, provider, format.as_label())
203        }
204        EncodedAudioFormat::Wav => {
205            let (pcm, rate) = decode_wav_in_process(body.as_slice(), limits, provider)?;
206            op.check()?;
207            finalize_mono(pcm, rate, limits, provider, format.as_label())
208        }
209        EncodedAudioFormat::Mp3 => {
210            let (pcm, rate) = decode_mp3_supervised(body.as_slice(), limits, op, provider).await?;
211            op.check()?;
212            finalize_mono(pcm, rate, limits, provider, format.as_label())
213        }
214    }
215}
216
217fn finalize_mono(
218    pcm: Vec<i16>,
219    sample_rate_hz: u32,
220    limits: RemoteAudioLimits,
221    provider: &str,
222    source_format: &'static str,
223) -> Result<NormalizedAudio> {
224    validate_sample_rate(sample_rate_hz, limits, provider)?;
225    if pcm.is_empty() {
226        return Err(ProviderError::InvalidProviderPayload {
227            provider: provider.into(),
228            reason: "decoded audio contains no samples".into(),
229        }
230        .into());
231    }
232    if pcm.len() > limits.max_pcm_samples {
233        return Err(ProviderError::LimitExceeded {
234            reason: format!(
235                "decoded PCM has {} samples (limit {})",
236                pcm.len(),
237                limits.max_pcm_samples
238            ),
239        }
240        .into());
241    }
242    let duration_ms = duration_ms_from_pcm(pcm.len(), sample_rate_hz);
243    let max_ms = limits.max_duration.as_millis() as u64;
244    if duration_ms > max_ms {
245        return Err(ProviderError::LimitExceeded {
246            reason: format!("decoded audio duration {duration_ms} ms exceeds limit {max_ms} ms"),
247        }
248        .into());
249    }
250    // Minimum ~5 ms (match local TTS soft floor intent).
251    let min_samples = (sample_rate_hz as usize / 200).max(1);
252    if pcm.len() < min_samples {
253        return Err(ProviderError::InvalidProviderPayload {
254            provider: provider.into(),
255            reason: format!(
256                "decoded audio too short ({} samples at {sample_rate_hz} Hz)",
257                pcm.len()
258            ),
259        }
260        .into());
261    }
262    Ok(NormalizedAudio {
263        pcm_i16_mono: pcm,
264        sample_rate_hz,
265        duration_ms,
266        source_format,
267    })
268}
269
270fn duration_ms_from_pcm(sample_count: usize, sample_rate_hz: u32) -> u64 {
271    if sample_rate_hz == 0 {
272        return 0;
273    }
274    (sample_count as u64)
275        .saturating_mul(1000)
276        .checked_div(sample_rate_hz as u64)
277        .unwrap_or(0)
278}
279
280fn validate_sample_rate(rate: u32, limits: RemoteAudioLimits, provider: &str) -> Result<()> {
281    if rate == 0 {
282        return Err(ProviderError::InvalidProviderPayload {
283            provider: provider.into(),
284            reason: "sample rate is zero".into(),
285        }
286        .into());
287    }
288    if !limits.sample_rate_allowed(rate) {
289        return Err(ProviderError::InvalidProviderPayload {
290            provider: provider.into(),
291            reason: format!("sample rate {rate} Hz is not in the allowed remote set"),
292        }
293        .into());
294    }
295    Ok(())
296}
297
298fn decode_pcm_s16le(
299    bytes: &[u8],
300    sample_rate_hz: u32,
301    channels: u16,
302    limits: RemoteAudioLimits,
303    provider: &str,
304) -> Result<Vec<i16>> {
305    validate_sample_rate(sample_rate_hz, limits, provider)?;
306    if channels == 0 {
307        return Err(ProviderError::InvalidProviderPayload {
308            provider: provider.into(),
309            reason: "PCM channel count is zero".into(),
310        }
311        .into());
312    }
313    if !bytes.len().is_multiple_of(2) {
314        return Err(ProviderError::InvalidProviderPayload {
315            provider: provider.into(),
316            reason: "PCM body has odd byte count (not s16le-aligned)".into(),
317        }
318        .into());
319    }
320    let frame_bytes = 2usize.saturating_mul(channels as usize);
321    if frame_bytes == 0 || !bytes.len().is_multiple_of(frame_bytes) {
322        return Err(ProviderError::InvalidProviderPayload {
323            provider: provider.into(),
324            reason: "PCM body length is not an integer number of frames".into(),
325        }
326        .into());
327    }
328
329    let frame_count = bytes.len() / frame_bytes;
330    // Pre-check mono sample budget after downmix.
331    if frame_count > limits.max_pcm_samples {
332        return Err(ProviderError::LimitExceeded {
333            reason: format!(
334                "PCM frame count {frame_count} exceeds sample cap {}",
335                limits.max_pcm_samples
336            ),
337        }
338        .into());
339    }
340
341    let interleaved = read_i16le_samples(bytes);
342    apply_channel_policy(&interleaved, channels, limits.channel_policy, provider)
343}
344
345fn read_i16le_samples(bytes: &[u8]) -> Vec<i16> {
346    let mut out = Vec::with_capacity(bytes.len() / 2);
347    for chunk in bytes.chunks_exact(2) {
348        out.push(i16::from_le_bytes([chunk[0], chunk[1]]));
349    }
350    out
351}
352
353fn apply_channel_policy(
354    interleaved: &[i16],
355    channels: u16,
356    policy: ChannelPolicy,
357    provider: &str,
358) -> Result<Vec<i16>> {
359    match (channels, policy) {
360        (1, _) => Ok(interleaved.to_vec()),
361        (2, ChannelPolicy::DownmixStereo) => {
362            if !interleaved.len().is_multiple_of(2) {
363                return Err(ProviderError::InvalidProviderPayload {
364                    provider: provider.into(),
365                    reason: "stereo PCM sample count is not even".into(),
366                }
367                .into());
368            }
369            let mut mono = Vec::with_capacity(interleaved.len() / 2);
370            for pair in interleaved.chunks_exact(2) {
371                // Deterministic average with rounding toward zero via i32 mid.
372                let l = pair[0] as i32;
373                let r = pair[1] as i32;
374                mono.push(((l + r) / 2) as i16);
375            }
376            Ok(mono)
377        }
378        (2, ChannelPolicy::MonoOnly) => Err(ProviderError::InvalidProviderPayload {
379            provider: provider.into(),
380            reason: "stereo PCM rejected by mono-only channel policy".into(),
381        }
382        .into()),
383        (n, _) => Err(ProviderError::InvalidProviderPayload {
384            provider: provider.into(),
385            reason: format!("{n}-channel PCM is not supported (max 2 with downmix)"),
386        }
387        .into()),
388    }
389}
390
391fn decode_wav_in_process(
392    bytes: &[u8],
393    limits: RemoteAudioLimits,
394    provider: &str,
395) -> Result<(Vec<i16>, u32)> {
396    // Quick RIFF magic check — fail closed without guessing MP3-as-WAV.
397    if bytes.len() < 12 || &bytes[0..4] != b"RIFF" || &bytes[8..12] != b"WAVE" {
398        return Err(ProviderError::InvalidProviderPayload {
399            provider: provider.into(),
400            reason: "body is not a RIFF/WAVE container (format/capability mismatch)".into(),
401        }
402        .into());
403    }
404
405    let cursor = Cursor::new(bytes);
406    let reader =
407        hound::WavReader::new(cursor).map_err(|_| ProviderError::InvalidProviderPayload {
408            provider: provider.into(),
409            reason: "malformed WAV header or chunks".into(),
410        })?;
411    let spec = reader.spec();
412
413    if spec.sample_rate == 0 || spec.channels == 0 {
414        return Err(ProviderError::InvalidProviderPayload {
415            provider: provider.into(),
416            reason: "WAV declares zero sample rate or channels".into(),
417        }
418        .into());
419    }
420    validate_sample_rate(spec.sample_rate, limits, provider)?;
421
422    if spec.sample_format != hound::SampleFormat::Int {
423        return Err(ProviderError::InvalidProviderPayload {
424            provider: provider.into(),
425            reason: "float WAV is not accepted on the remote in-process path".into(),
426        }
427        .into());
428    }
429    if spec.bits_per_sample != 16 {
430        return Err(ProviderError::InvalidProviderPayload {
431            provider: provider.into(),
432            reason: format!(
433                "WAV bits_per_sample {} not supported (need 16)",
434                spec.bits_per_sample
435            ),
436        }
437        .into());
438    }
439
440    // Bound by declared duration before materializing full PCM.
441    let declared = reader.duration() as usize; // frames
442    if declared > limits.max_pcm_samples {
443        return Err(ProviderError::LimitExceeded {
444            reason: format!(
445                "WAV declares {declared} frames exceeding sample cap {}",
446                limits.max_pcm_samples
447            ),
448        }
449        .into());
450    }
451
452    let mut interleaved: Vec<i16> = Vec::with_capacity(
453        declared
454            .saturating_mul(spec.channels as usize)
455            .min(limits.max_pcm_samples.saturating_mul(2)),
456    );
457    for sample in reader.into_samples::<i16>() {
458        let s = sample.map_err(|_| ProviderError::InvalidProviderPayload {
459            provider: provider.into(),
460            reason: "failed reading WAV samples".into(),
461        })?;
462        let frames_so_far = interleaved.len() / (spec.channels as usize).max(1);
463        if frames_so_far >= limits.max_pcm_samples {
464            return Err(ProviderError::LimitExceeded {
465                reason: format!("WAV sample stream exceeded cap {}", limits.max_pcm_samples),
466            }
467            .into());
468        }
469        interleaved.push(s);
470    }
471
472    let mono = apply_channel_policy(&interleaved, spec.channels, limits.channel_policy, provider)?;
473    Ok((mono, spec.sample_rate))
474}
475
476/// Supervised FFmpeg decode of MP3 → mono WAV → in-process parser.
477async fn decode_mp3_supervised(
478    bytes: &[u8],
479    limits: RemoteAudioLimits,
480    op: &OpContext,
481    provider: &str,
482) -> Result<(Vec<i16>, u32)> {
483    op.check()?;
484    let ffmpeg = which::which("ffmpeg").map_err(|_| EnvironmentError::FfmpegMissing)?;
485
486    let mp3_tmp = tempfile::Builder::new()
487        .prefix("aurum-remote-")
488        .suffix(".mp3")
489        .tempfile()
490        .map_err(|e| EnvironmentError::Other {
491            message: format!("temp mp3: {e}"),
492        })?;
493    let mp3_path = mp3_tmp.path().to_path_buf();
494    std::fs::write(&mp3_path, bytes).map_err(|e| EnvironmentError::Other {
495        message: format!("write temp mp3: {e}"),
496    })?;
497    // Keep file until decode finishes; drop tempfile later.
498    let _mp3_keep = mp3_tmp;
499
500    let wav_tmp = tempfile::Builder::new()
501        .prefix("aurum-remote-")
502        .suffix(".wav")
503        .tempfile()
504        .map_err(|e| EnvironmentError::Other {
505            message: format!("temp wav: {e}"),
506        })?;
507    let wav_path = wav_tmp.path().to_path_buf();
508    drop(wav_tmp);
509    let _ = std::fs::remove_file(&wav_path);
510
511    // Cap duration via -t; keep native sample rate (no arbitrary -ar).
512    let max_secs = limits.max_duration.as_secs_f64().max(0.001);
513    let max_t = format!("{max_secs:.3}");
514    // Encoded byte cap already applied; bound decoded WAV file size ~ 2 * max samples + header.
515    let max_wav_bytes = limits
516        .max_pcm_samples
517        .saturating_mul(2)
518        .saturating_add(4096);
519
520    let timeout = op
521        .remaining()
522        .unwrap_or(Duration::from_secs(120))
523        .min(Duration::from_secs(120));
524
525    let result = run_ffmpeg_mp3_to_wav(
526        &ffmpeg,
527        &mp3_path,
528        &wav_path,
529        &max_t,
530        max_wav_bytes,
531        timeout,
532        op,
533        provider,
534    )
535    .await;
536
537    let _ = std::fs::remove_file(&mp3_path);
538    let decode = result;
539    let out = match decode {
540        Ok(()) => {
541            let wav_bytes = std::fs::read(&wav_path).map_err(|e| EnvironmentError::Other {
542                message: format!("read decoded wav: {e}"),
543            })?;
544            let _ = std::fs::remove_file(&wav_path);
545            if wav_bytes.len() > max_wav_bytes {
546                return Err(ProviderError::LimitExceeded {
547                    reason: format!(
548                        "decoded WAV size {} exceeds bound {max_wav_bytes}",
549                        wav_bytes.len()
550                    ),
551                }
552                .into());
553            }
554            if wav_bytes.len() > limits.max_encoded_bytes.saturating_mul(32) {
555                // Separate decompression-amplification guard (encoded → wav).
556                return Err(ProviderError::LimitExceeded {
557                    reason: "decoded WAV exceeds decompression amplification bound".into(),
558                }
559                .into());
560            }
561            decode_wav_in_process(&wav_bytes, limits, provider)
562        }
563        Err(e) => {
564            let _ = std::fs::remove_file(&wav_path);
565            Err(e)
566        }
567    };
568    out
569}
570
571#[allow(clippy::too_many_arguments)]
572async fn run_ffmpeg_mp3_to_wav(
573    ffmpeg: &std::path::Path,
574    mp3_path: &PathBuf,
575    wav_path: &PathBuf,
576    max_t: &str,
577    max_wav_bytes: usize,
578    timeout: Duration,
579    op: &OpContext,
580    provider: &str,
581) -> Result<()> {
582    op.check()?;
583    let protocol_whitelist = "file,crypto,data";
584
585    let mut child = Command::new(ffmpeg)
586        .args([
587            "-hide_banner",
588            "-loglevel",
589            "error",
590            "-nostdin",
591            "-protocol_whitelist",
592            protocol_whitelist,
593            "-i",
594        ])
595        .arg(mp3_path)
596        .args(["-t", max_t, "-ac", "1", "-f", "wav", "-acodec", "pcm_s16le"])
597        .arg(wav_path)
598        .stdin(Stdio::null())
599        .stdout(Stdio::null())
600        .stderr(Stdio::piped())
601        .kill_on_drop(true)
602        .spawn()
603        .map_err(|e| EnvironmentError::FfmpegFailed {
604            reason: format!("failed to spawn ffmpeg: {e}"),
605        })?;
606
607    let mut stderr = child
608        .stderr
609        .take()
610        .ok_or_else(|| EnvironmentError::FfmpegFailed {
611            reason: "ffmpeg stderr missing".into(),
612        })?;
613
614    const STDERR_TAIL_CAP: usize = 8 * 1024;
615    let stderr_task = async {
616        let mut tail: Vec<u8> = Vec::new();
617        let mut buf = [0u8; 4 * 1024];
618        loop {
619            let n = stderr
620                .read(&mut buf)
621                .await
622                .map_err(|e| EnvironmentError::FfmpegFailed {
623                    reason: format!("reading ffmpeg stderr: {e}"),
624                })?;
625            if n == 0 {
626                break;
627            }
628            if tail.len() + n > STDERR_TAIL_CAP {
629                let drop_n = (tail.len() + n).saturating_sub(STDERR_TAIL_CAP);
630                if drop_n < tail.len() {
631                    tail.drain(..drop_n);
632                } else {
633                    tail.clear();
634                }
635            }
636            tail.extend_from_slice(&buf[..n]);
637        }
638        Ok::<Vec<u8>, crate::error::TranscriptionError>(tail)
639    };
640
641    let cancel = op.cancel.clone();
642    let cancel_watch = async move {
643        loop {
644            if cancel.is_cancelled() {
645                return;
646            }
647            tokio::time::sleep(Duration::from_millis(25)).await;
648        }
649    };
650
651    let drain_outcome: Result<Vec<u8>> = tokio::select! {
652        biased;
653        _ = cancel_watch => {
654            let _ = child.kill().await;
655            let _ = child.wait().await;
656            Err(ProviderError::Cancelled.into())
657        }
658        timed = tokio::time::timeout(timeout, stderr_task) => {
659            match timed {
660                Ok(Ok(tail)) => Ok(tail),
661                Ok(Err(e)) => {
662                    let _ = child.kill().await;
663                    let _ = child.wait().await;
664                    Err(e)
665                }
666                Err(_elapsed) => {
667                    let _ = child.kill().await;
668                    let _ = child.wait().await;
669                    Err(ProviderError::DeadlineExceeded.into())
670                }
671            }
672        }
673    };
674
675    let stderr_bytes = drain_outcome?;
676
677    let status = match tokio::time::timeout(Duration::from_secs(30), child.wait()).await {
678        Ok(Ok(s)) => s,
679        Ok(Err(e)) => {
680            return Err(EnvironmentError::FfmpegFailed {
681                reason: format!("ffmpeg wait failed: {e}"),
682            }
683            .into());
684        }
685        Err(_) => {
686            let _ = child.kill().await;
687            let _ = child.wait().await;
688            return Err(EnvironmentError::FfmpegFailed {
689                reason: "ffmpeg hung after decode".into(),
690            }
691            .into());
692        }
693    };
694
695    if !status.success() {
696        let stderr = String::from_utf8_lossy(&stderr_bytes);
697        let short = stderr
698            .trim()
699            .lines()
700            .last()
701            .unwrap_or("ffmpeg failed")
702            .chars()
703            .take(200)
704            .collect::<String>();
705        // Do not include path or body; map as format/payload failure when possible.
706        return Err(ProviderError::InvalidProviderPayload {
707            provider: provider.into(),
708            reason: format!("MP3 decode failed: {short}"),
709        }
710        .into());
711    }
712
713    if let Ok(meta) = std::fs::metadata(wav_path) {
714        if meta.len() as usize > max_wav_bytes {
715            let _ = std::fs::remove_file(wav_path);
716            return Err(ProviderError::LimitExceeded {
717                reason: format!(
718                    "decoded WAV file {} bytes exceeds bound {max_wav_bytes}",
719                    meta.len()
720                ),
721            }
722            .into());
723        }
724        if meta.len() == 0 {
725            return Err(ProviderError::InvalidProviderPayload {
726                provider: provider.into(),
727                reason: "MP3 decode produced empty audio".into(),
728            }
729            .into());
730        }
731    } else {
732        return Err(ProviderError::InvalidProviderPayload {
733            provider: provider.into(),
734            reason: "MP3 decode produced no output file".into(),
735        }
736        .into());
737    }
738
739    Ok(())
740}
741
742#[cfg(test)]
743mod tests {
744    use super::*;
745    use crate::runtime::OpContext;
746    use std::io::Cursor;
747
748    fn provider() -> &'static str {
749        "test-remote"
750    }
751
752    fn write_wav_i16(samples: &[i16], rate: u32, channels: u16) -> Vec<u8> {
753        let spec = hound::WavSpec {
754            channels,
755            sample_rate: rate,
756            bits_per_sample: 16,
757            sample_format: hound::SampleFormat::Int,
758        };
759        let mut cursor = Cursor::new(Vec::new());
760        {
761            let mut w = hound::WavWriter::new(&mut cursor, spec).unwrap();
762            for &s in samples {
763                w.write_sample(s).unwrap();
764            }
765            w.finalize().unwrap();
766        }
767        cursor.into_inner()
768    }
769
770    #[test]
771    fn bounded_body_rejects_oversize_and_empty() {
772        let err = BoundedAudioBody::try_from_bytes(vec![0; 10], 5, provider()).unwrap_err();
773        assert!(err.to_string().contains("exceeds") || err.to_string().contains("large"));
774        let err = BoundedAudioBody::try_from_bytes(vec![], 100, provider()).unwrap_err();
775        assert!(err.to_string().contains("empty"));
776    }
777
778    #[test]
779    fn pcm_rejects_odd_byte_count() {
780        let limits = RemoteAudioLimits::tight();
781        let err = decode_pcm_s16le(&[0, 1, 2], 24_000, 1, limits, provider()).unwrap_err();
782        assert!(err.to_string().contains("odd") || err.to_string().contains("align"));
783    }
784
785    #[test]
786    fn pcm_rejects_zero_rate_and_channels() {
787        let limits = RemoteAudioLimits::tight();
788        assert!(decode_pcm_s16le(&[0, 0], 0, 1, limits, provider()).is_err());
789        assert!(decode_pcm_s16le(&[0, 0], 24_000, 0, limits, provider()).is_err());
790    }
791
792    #[test]
793    fn pcm_rejects_disallowed_rate() {
794        let limits = RemoteAudioLimits::tight();
795        let err = decode_pcm_s16le(&[0, 0, 0, 0], 13_000, 1, limits, provider()).unwrap_err();
796        assert!(err.to_string().contains("sample rate"));
797    }
798
799    #[test]
800    fn pcm_mono_roundtrip() {
801        let limits = RemoteAudioLimits::tight();
802        // 100 ms @ 24 kHz = 2400 samples
803        let samples: Vec<i16> = (0..2400).map(|i| (i % 100) as i16).collect();
804        let mut bytes = Vec::with_capacity(samples.len() * 2);
805        for s in &samples {
806            bytes.extend_from_slice(&s.to_le_bytes());
807        }
808        let out = decode_pcm_s16le(&bytes, 24_000, 1, limits, provider()).unwrap();
809        assert_eq!(out, samples);
810    }
811
812    #[test]
813    fn pcm_stereo_downmix_averages() {
814        let limits = RemoteAudioLimits::tight();
815        // frames: (100,200), (10,30) → 150, 20
816        let bytes = {
817            let mut b = Vec::new();
818            for s in [100i16, 200, 10, 30] {
819                b.extend_from_slice(&s.to_le_bytes());
820            }
821            b
822        };
823        // pad to min duration: need ~120 samples at 24k for 5ms
824        let mut frames = bytes;
825        for _ in 0..200 {
826            frames.extend_from_slice(&0i16.to_le_bytes());
827            frames.extend_from_slice(&0i16.to_le_bytes());
828        }
829        let out = decode_pcm_s16le(&frames, 24_000, 2, limits, provider()).unwrap();
830        assert_eq!(out[0], 150);
831        assert_eq!(out[1], 20);
832    }
833
834    #[test]
835    fn pcm_stereo_mono_only_rejects() {
836        let mut limits = RemoteAudioLimits::tight();
837        limits.channel_policy = ChannelPolicy::MonoOnly;
838        let bytes = [0u8; 8];
839        let err = decode_pcm_s16le(&bytes, 24_000, 2, limits, provider()).unwrap_err();
840        assert!(err.to_string().contains("mono-only") || err.to_string().contains("stereo"));
841    }
842
843    #[test]
844    fn pcm_rejects_over_sample_cap() {
845        let limits = RemoteAudioLimits {
846            max_pcm_samples: 10,
847            ..RemoteAudioLimits::tight()
848        };
849        let bytes = vec![0u8; 40]; // 20 mono samples
850        let err = decode_pcm_s16le(&bytes, 24_000, 1, limits, provider()).unwrap_err();
851        assert!(err.to_string().contains("cap") || err.to_string().contains("limit"));
852    }
853
854    #[tokio::test]
855    async fn normalize_pcm_end_to_end() {
856        let limits = RemoteAudioLimits::tight();
857        let samples: Vec<i16> = (0..2400).map(|i| ((i % 50) as i16) * 10).collect();
858        let mut bytes = Vec::new();
859        for s in &samples {
860            bytes.extend_from_slice(&s.to_le_bytes());
861        }
862        let body =
863            BoundedAudioBody::try_from_bytes(bytes, limits.max_encoded_bytes, provider()).unwrap();
864        let op = OpContext::new();
865        let norm = normalize_remote_audio(
866            body,
867            EncodedAudioFormat::PcmS16Le {
868                sample_rate_hz: 24_000,
869                channels: 1,
870            },
871            limits,
872            &op,
873            provider(),
874        )
875        .await
876        .unwrap();
877        assert_eq!(norm.sample_rate_hz, 24_000);
878        assert_eq!(norm.pcm_i16_mono, samples);
879        assert_eq!(norm.duration_ms, 100);
880        assert_eq!(norm.source_format, "pcm_s16le");
881        assert_eq!(norm.channels(), 1);
882    }
883
884    #[tokio::test]
885    async fn normalize_wav_mono() {
886        let limits = RemoteAudioLimits::tight();
887        let samples: Vec<i16> = (0..2400).map(|i| (i % 100) as i16).collect();
888        let wav = write_wav_i16(&samples, 24_000, 1);
889        let body =
890            BoundedAudioBody::try_from_bytes(wav, limits.max_encoded_bytes, provider()).unwrap();
891        let norm = normalize_remote_audio(
892            body,
893            EncodedAudioFormat::Wav,
894            limits,
895            &OpContext::new(),
896            provider(),
897        )
898        .await
899        .unwrap();
900        assert_eq!(norm.pcm_i16_mono, samples);
901        assert_eq!(norm.sample_rate_hz, 24_000);
902    }
903
904    #[tokio::test]
905    async fn normalize_wav_rejects_non_riff() {
906        let limits = RemoteAudioLimits::tight();
907        let body = BoundedAudioBody::try_from_bytes(
908            b"not-a-wav-file-content-here!!!!".to_vec(),
909            limits.max_encoded_bytes,
910            provider(),
911        )
912        .unwrap();
913        let err = normalize_remote_audio(
914            body,
915            EncodedAudioFormat::Wav,
916            limits,
917            &OpContext::new(),
918            provider(),
919        )
920        .await
921        .unwrap_err();
922        assert!(
923            err.to_string().contains("RIFF")
924                || err.to_string().contains("WAVE")
925                || err.to_string().contains("mismatch")
926        );
927    }
928
929    #[tokio::test]
930    async fn normalize_wav_rejects_oversized_declaration() {
931        let limits = RemoteAudioLimits {
932            max_pcm_samples: 100,
933            ..RemoteAudioLimits::tight()
934        };
935        // 500 frames @ 24k — exceeds 100 sample cap
936        let samples: Vec<i16> = vec![1; 500];
937        let wav = write_wav_i16(&samples, 24_000, 1);
938        let body =
939            BoundedAudioBody::try_from_bytes(wav, limits.max_encoded_bytes, provider()).unwrap();
940        let err = normalize_remote_audio(
941            body,
942            EncodedAudioFormat::Wav,
943            limits,
944            &OpContext::new(),
945            provider(),
946        )
947        .await
948        .unwrap_err();
949        assert!(
950            err.to_string().contains("cap")
951                || err.to_string().contains("limit")
952                || err.to_string().contains("exceed")
953        );
954    }
955
956    #[tokio::test]
957    async fn cancel_before_normalize() {
958        let limits = RemoteAudioLimits::tight();
959        let body =
960            BoundedAudioBody::try_from_bytes(vec![0; 100], limits.max_encoded_bytes, provider())
961                .unwrap();
962        let op = OpContext::new();
963        op.cancel.cancel();
964        let err = normalize_remote_audio(
965            body,
966            EncodedAudioFormat::PcmS16Le {
967                sample_rate_hz: 24_000,
968                channels: 1,
969            },
970            limits,
971            &op,
972            provider(),
973        )
974        .await
975        .unwrap_err();
976        assert!(matches!(
977            err,
978            crate::error::TranscriptionError::Provider(ProviderError::Cancelled)
979        ));
980    }
981
982    #[tokio::test]
983    async fn duration_matches_pcm_length() {
984        let limits = RemoteAudioLimits::tight();
985        // 500 ms @ 16 kHz
986        let n = 8_000;
987        let samples = vec![100i16; n];
988        let mut bytes = Vec::new();
989        for s in &samples {
990            bytes.extend_from_slice(&s.to_le_bytes());
991        }
992        let body =
993            BoundedAudioBody::try_from_bytes(bytes, limits.max_encoded_bytes, provider()).unwrap();
994        let norm = normalize_remote_audio(
995            body,
996            EncodedAudioFormat::PcmS16Le {
997                sample_rate_hz: 16_000,
998                channels: 1,
999            },
1000            limits,
1001            &OpContext::new(),
1002            provider(),
1003        )
1004        .await
1005        .unwrap();
1006        assert_eq!(norm.duration_ms, 500);
1007    }
1008}