1use 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
19pub const DEFAULT_MAX_ENCODED_BYTES: usize = 16 * 1024 * 1024;
21
22pub const DEFAULT_MAX_PCM_SAMPLES: usize = 48_000 * 600;
24
25pub const DEFAULT_MAX_DURATION: Duration = Duration::from_secs(600);
27
28pub 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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
35pub enum EncodedAudioFormat {
36 PcmS16Le { sample_rate_hz: u32, channels: u16 },
38 Wav,
40 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#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
56pub enum ChannelPolicy {
57 MonoOnly,
59 #[default]
61 DownmixStereo,
62}
63
64#[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 pub fn tight() -> Self {
87 Self {
88 max_encoded_bytes: 64 * 1024,
89 max_pcm_samples: 48_000, 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#[derive(Debug, Clone)]
102pub struct BoundedAudioBody {
103 bytes: Vec<u8>,
104}
105
106impl BoundedAudioBody {
107 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#[derive(Debug, Clone)]
151pub struct NormalizedAudio {
152 pub pcm_i16_mono: Vec<i16>,
153 pub sample_rate_hz: u32,
154 pub duration_ms: u64,
156 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
170pub 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 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 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 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 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 let declared = reader.duration() as usize; 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
476async 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 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 let max_secs = limits.max_duration.as_secs_f64().max(0.001);
513 let max_t = format!("{max_secs:.3}");
514 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 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 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 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 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 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]; 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 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 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}