From 9eca12eae4372c79eb35cef972f1ab4756c0a5e0 Mon Sep 17 00:00:00 2001
From: cai <cai@nbcai.cc>
Date: Wed, 12 Aug 2026 11:55:49 +0800
Subject: [PATCH] Add controlled fixture ACK observability
---
src/audio.rs | 1386 ++++++++++++++++++++++++++++++++++++++++++++-------------
1 files changed, 1,069 insertions(+), 317 deletions(-)
diff --git a/src/audio.rs b/src/audio.rs
index 01d3a28..cec22f0 100644
--- a/src/audio.rs
+++ b/src/audio.rs
@@ -1,317 +1,1069 @@
-use std::{fs, io::Cursor, path::Path};
-
-use anyhow::{Context, Result, anyhow};
-use hound::{SampleFormat, WavReader};
-use minimp3::{Decoder as Mp3Decoder, Error as Mp3Error, Frame as Mp3Frame};
-use reqwest::Client;
-
-#[derive(Clone, Debug, PartialEq, Eq)]
-pub struct PcmFrame {
- pub data: Vec<i16>,
- pub sample_rate: u32,
- pub num_channels: u32,
- pub samples_per_channel: u32,
-}
-
-impl PcmFrame {
- pub fn new(
- data: Vec<i16>,
- sample_rate: u32,
- num_channels: u32,
- samples_per_channel: u32,
- ) -> Self {
- Self {
- data,
- sample_rate,
- num_channels,
- samples_per_channel,
- }
- }
-}
-
-pub async fn load_pre_recorded_frames(
- http: &Client,
- audio_file: Option<&str>,
- audio_url: Option<&str>,
- target_sample_rate_hz: u32,
- target_num_channels: u16,
-) -> Result<Option<Vec<PcmFrame>>> {
- if let Some(path) = audio_file.filter(|value| !value.trim().is_empty()) {
- let audio_bytes = fs::read(Path::new(path))
- .with_context(|| format!("failed to read greeting audio file {path}"))?;
- return decode_audio_frames(&audio_bytes, target_sample_rate_hz, target_num_channels)
- .map(Some);
- }
- if let Some(url) = audio_url.filter(|value| !value.trim().is_empty()) {
- let response = http
- .get(url)
- .send()
- .await
- .with_context(|| format!("failed to fetch greeting audio {url}"))?;
- if !response.status().is_success() {
- return Err(anyhow!(
- "greeting audio fetch failed for {url} with status {}",
- response.status()
- ));
- }
- let bytes = response
- .bytes()
- .await
- .with_context(|| format!("failed to read greeting audio body {url}"))?;
- return decode_audio_frames(&bytes, target_sample_rate_hz, target_num_channels).map(Some);
- }
- Ok(None)
-}
-
-fn decode_audio_frames(
- audio_bytes: &[u8],
- target_sample_rate_hz: u32,
- target_num_channels: u16,
-) -> Result<Vec<PcmFrame>> {
- if is_wav(audio_bytes) {
- return decode_wav_frames(audio_bytes, target_sample_rate_hz, target_num_channels);
- }
- if is_mp3(audio_bytes) {
- return decode_mp3_frames(audio_bytes, target_sample_rate_hz, target_num_channels);
- }
-
- let wav_result = decode_wav_frames(audio_bytes, target_sample_rate_hz, target_num_channels);
- if wav_result.is_ok() {
- return wav_result;
- }
- let wav_error = wav_result.err();
- decode_mp3_frames(audio_bytes, target_sample_rate_hz, target_num_channels).map_err(
- |mp3_error| {
- anyhow!(
- "failed to decode greeting audio as wav or mp3: wav={}, mp3={}",
- wav_error
- .map(|error| error.to_string())
- .unwrap_or_else(|| "unknown".to_string()),
- mp3_error
- )
- },
- )
-}
-
-fn decode_wav_frames(
- wav_bytes: &[u8],
- target_sample_rate_hz: u32,
- target_num_channels: u16,
-) -> Result<Vec<PcmFrame>> {
- let cursor = Cursor::new(wav_bytes.to_vec());
- let mut reader = WavReader::new(cursor).context("failed to open greeting wav bytes")?;
- let spec = reader.spec();
- let src_channels = spec.channels.max(1);
- let mut samples = match (spec.sample_format, spec.bits_per_sample) {
- (SampleFormat::Int, 16) => reader
- .samples::<i16>()
- .collect::<std::result::Result<Vec<_>, _>>()
- .context("failed to decode 16-bit wav samples")?,
- (SampleFormat::Float, 32) => reader
- .samples::<f32>()
- .map(|sample| sample.map(|value| (value.clamp(-1.0, 1.0) * i16::MAX as f32) as i16))
- .collect::<std::result::Result<Vec<_>, _>>()
- .context("failed to decode float wav samples")?,
- _ => {
- return Err(anyhow!(
- "unsupported greeting wav format: {:?} {}-bit",
- spec.sample_format,
- spec.bits_per_sample
- ));
- }
- };
-
- samples = remap_channels(samples, src_channels, target_num_channels);
- samples = resample_linear(
- samples,
- spec.sample_rate,
- target_sample_rate_hz,
- target_num_channels,
- );
-
- Ok(chunk_pcm_samples(
- samples,
- target_sample_rate_hz,
- target_num_channels,
- ))
-}
-
-fn decode_mp3_frames(
- mp3_bytes: &[u8],
- target_sample_rate_hz: u32,
- target_num_channels: u16,
-) -> Result<Vec<PcmFrame>> {
- let cursor = Cursor::new(mp3_bytes.to_vec());
- let mut decoder = Mp3Decoder::new(cursor);
- let mut samples = Vec::new();
-
- loop {
- match decoder.next_frame() {
- Ok(Mp3Frame {
- data,
- sample_rate,
- channels,
- ..
- }) => {
- let src_rate = u32::try_from(sample_rate)
- .map_err(|_| anyhow!("unsupported mp3 sample rate {sample_rate}"))?;
- let src_channels = u16::try_from(channels)
- .map_err(|_| anyhow!("unsupported mp3 channel count {channels}"))?;
- let mut frame_samples = remap_channels(data, src_channels, target_num_channels);
- frame_samples = resample_linear(
- frame_samples,
- src_rate,
- target_sample_rate_hz,
- target_num_channels,
- );
- samples.extend(frame_samples);
- }
- Err(Mp3Error::Eof) => break,
- Err(Mp3Error::SkippedData) | Err(Mp3Error::InsufficientData) => continue,
- Err(error) => return Err(anyhow!("failed to decode mp3 frame: {error:?}")),
- }
- }
-
- if samples.is_empty() {
- return Err(anyhow!("decoded mp3 contains no audio samples"));
- }
-
- Ok(chunk_pcm_samples(
- samples,
- target_sample_rate_hz,
- target_num_channels,
- ))
-}
-
-fn chunk_pcm_samples(
- samples: Vec<i16>,
- target_sample_rate_hz: u32,
- target_num_channels: u16,
-) -> Vec<PcmFrame> {
- const CHUNK_MS: u32 = 20;
- let samples_per_chunk = ((target_sample_rate_hz / 1000) * CHUNK_MS).max(1) as usize
- * usize::from(target_num_channels);
- let mut frames = Vec::new();
- for chunk in samples.chunks(samples_per_chunk) {
- let mut frame_data = chunk.to_vec();
- if frame_data.len() < samples_per_chunk {
- frame_data.resize(samples_per_chunk, 0);
- }
- let samples_per_channel =
- (frame_data.len() / usize::from(target_num_channels.max(1))) as u32;
- frames.push(PcmFrame::new(
- frame_data,
- target_sample_rate_hz,
- u32::from(target_num_channels),
- samples_per_channel,
- ));
- }
- frames
-}
-
-fn is_wav(bytes: &[u8]) -> bool {
- bytes.len() >= 12 && bytes.starts_with(b"RIFF") && &bytes[8..12] == b"WAVE"
-}
-
-fn is_mp3(bytes: &[u8]) -> bool {
- bytes.starts_with(b"ID3") || (bytes.len() >= 2 && bytes[0] == 0xff && bytes[1] & 0xe0 == 0xe0)
-}
-
-fn remap_channels(samples: Vec<i16>, src_channels: u16, dst_channels: u16) -> Vec<i16> {
- if src_channels == dst_channels {
- return samples;
- }
-
- let src_channels = usize::from(src_channels.max(1));
- let dst_channels = usize::from(dst_channels.max(1));
- let frames = samples.chunks(src_channels);
- let mut output = Vec::new();
- for frame in frames {
- match (src_channels, dst_channels) {
- (1, 2) => {
- let sample = frame.first().copied().unwrap_or_default();
- output.push(sample);
- output.push(sample);
- }
- (2, 1) => {
- let left = frame.first().copied().unwrap_or_default() as i32;
- let right = frame.get(1).copied().unwrap_or_default() as i32;
- output.push(((left + right) / 2) as i16);
- }
- _ => {
- for channel in 0..dst_channels {
- output.push(
- frame
- .get(channel % src_channels)
- .copied()
- .unwrap_or_default(),
- );
- }
- }
- }
- }
- output
-}
-
-fn resample_linear(samples: Vec<i16>, src_rate: u32, dst_rate: u32, channels: u16) -> Vec<i16> {
- if src_rate == dst_rate {
- return samples;
- }
-
- let channels = usize::from(channels.max(1));
- let src_frames = samples.len() / channels;
- if src_frames <= 1 {
- return samples;
- }
- let ratio = dst_rate as f64 / src_rate as f64;
- let dst_frames = ((src_frames as f64) * ratio).round().max(1.0) as usize;
- let mut output = Vec::with_capacity(dst_frames * channels);
-
- for dst_frame in 0..dst_frames {
- let src_pos = (dst_frame as f64) / ratio;
- let src_index = src_pos.floor() as usize;
- let next_index = (src_index + 1).min(src_frames - 1);
- let frac = (src_pos - src_index as f64) as f32;
- for channel in 0..channels {
- let a = samples[src_index * channels + channel] as f32;
- let b = samples[next_index * channels + channel] as f32;
- let mixed = a + (b - a) * frac;
- output.push(mixed.round().clamp(i16::MIN as f32, i16::MAX as f32) as i16);
- }
- }
-
- output
-}
-
-#[cfg(test)]
-mod tests {
- use super::load_pre_recorded_frames;
-
- #[tokio::test]
- async fn load_pre_recorded_frames_reads_local_wav() {
- let dir = std::env::temp_dir().join("cv-runtime-helper-tests");
- std::fs::create_dir_all(&dir).expect("create temp dir");
- let path = dir.join("fixture.wav");
- let spec = hound::WavSpec {
- channels: 1,
- sample_rate: 16_000,
- bits_per_sample: 16,
- sample_format: hound::SampleFormat::Int,
- };
- let mut writer = hound::WavWriter::create(&path, spec).expect("create wav");
- for _ in 0..16_000 {
- writer.write_sample(512_i16).expect("write sample");
- }
- writer.finalize().expect("finalize wav");
-
- let http = reqwest::Client::new();
- let frames = load_pre_recorded_frames(&http, path.to_str(), None, 48_000, 1)
- .await
- .expect("load frames")
- .expect("frames");
-
- assert!(!frames.is_empty());
- assert_eq!(frames[0].sample_rate, 48_000);
- assert_eq!(frames[0].num_channels, 1);
- }
-}
+use std::{
+ fs,
+ io::Cursor,
+ path::{Path, PathBuf},
+};
+
+use anyhow::{Context, Result, anyhow};
+use hound::{SampleFormat, WavReader, WavSpec, WavWriter};
+use minimp3::{Decoder as Mp3Decoder, Error as Mp3Error, Frame as Mp3Frame};
+use reqwest::Client;
+
+#[derive(Clone, Debug, PartialEq, Eq)]
+pub struct PcmFrame {
+ pub data: Vec<i16>,
+ pub sample_rate: u32,
+ pub num_channels: u32,
+ pub samples_per_channel: u32,
+}
+
+impl PcmFrame {
+ pub fn new(
+ data: Vec<i16>,
+ sample_rate: u32,
+ num_channels: u32,
+ samples_per_channel: u32,
+ ) -> Self {
+ Self {
+ data,
+ sample_rate,
+ num_channels,
+ samples_per_channel,
+ }
+ }
+}
+
+#[derive(Clone, Debug)]
+pub struct LoadedAudio {
+ pub frames: Vec<PcmFrame>,
+ pub diagnostics: AudioDiagnostics,
+}
+
+#[derive(Clone, Debug)]
+pub struct AudioDiagnostics {
+ pub source_kind: &'static str,
+ pub source_format: &'static str,
+ pub source_bytes: usize,
+ pub source_sample_rate_hz: u32,
+ pub source_num_channels: u16,
+ pub decoded_sample_count: usize,
+ pub decoded_duration_ms: u64,
+ pub target_sample_rate_hz: u32,
+ pub target_num_channels: u16,
+ pub target_sample_count: usize,
+ pub target_duration_ms: u64,
+ pub frame_count: usize,
+ pub rms: f64,
+ pub peak: f64,
+ pub clipped_sample_count: usize,
+ pub silence_ratio: f64,
+ pub mp3_skipped_data_count: usize,
+ pub mp3_insufficient_data_count: usize,
+ pub debug_source_path: Option<String>,
+ pub debug_pcm_wav_path: Option<String>,
+}
+
+struct DecodeResult {
+ samples: Vec<i16>,
+ diagnostics: AudioDiagnostics,
+}
+
+pub async fn load_pre_recorded_frames(
+ http: &Client,
+ audio_file: Option<&str>,
+ audio_url: Option<&str>,
+ target_sample_rate_hz: u32,
+ target_num_channels: u16,
+ debug_dump_dir: Option<&str>,
+ call_id: &str,
+ debug_label: &str,
+) -> Result<Option<LoadedAudio>> {
+ if let Some(path) = audio_file.filter(|value| !value.trim().is_empty()) {
+ let audio_bytes = fs::read(Path::new(path))
+ .with_context(|| format!("failed to read greeting audio file {path}"))?;
+ return decode_audio_frames(
+ &audio_bytes,
+ "local_file",
+ target_sample_rate_hz,
+ target_num_channels,
+ debug_dump_dir,
+ call_id,
+ debug_label,
+ )
+ .map(Some);
+ }
+ if let Some(url) = audio_url.filter(|value| !value.trim().is_empty()) {
+ let response = http
+ .get(url)
+ .send()
+ .await
+ .with_context(|| format!("failed to fetch greeting audio {url}"))?;
+ if !response.status().is_success() {
+ return Err(anyhow!(
+ "greeting audio fetch failed for {url} with status {}",
+ response.status()
+ ));
+ }
+ let bytes = response
+ .bytes()
+ .await
+ .with_context(|| format!("failed to read greeting audio body {url}"))?;
+ return decode_audio_frames(
+ &bytes,
+ "downloaded_url",
+ target_sample_rate_hz,
+ target_num_channels,
+ debug_dump_dir,
+ call_id,
+ debug_label,
+ )
+ .map(Some);
+ }
+ Ok(None)
+}
+
+pub fn decode_audio_bytes_to_frames(
+ audio_bytes: &[u8],
+ source_kind: &'static str,
+ target_sample_rate_hz: u32,
+ target_num_channels: u16,
+ debug_dump_dir: Option<&str>,
+ call_id: &str,
+ debug_label: &str,
+) -> Result<LoadedAudio> {
+ decode_audio_frames(
+ audio_bytes,
+ source_kind,
+ target_sample_rate_hz,
+ target_num_channels,
+ debug_dump_dir,
+ call_id,
+ debug_label,
+ )
+}
+
+pub fn pcm_s16le_bytes_to_frames(
+ pcm_bytes: &[u8],
+ source_sample_rate_hz: u32,
+ source_num_channels: u32,
+ target_sample_rate_hz: u32,
+ target_num_channels: u16,
+) -> Result<Vec<PcmFrame>> {
+ if source_sample_rate_hz == 0 {
+ return Err(anyhow!("pcm_s16le source sample rate is zero"));
+ }
+ if source_num_channels == 0 {
+ return Err(anyhow!("pcm_s16le source channel count is zero"));
+ }
+ if pcm_bytes.len() % 2 != 0 {
+ return Err(anyhow!("pcm_s16le payload has odd byte length"));
+ }
+ let samples: Vec<i16> = pcm_bytes
+ .chunks_exact(2)
+ .map(|chunk| i16::from_le_bytes([chunk[0], chunk[1]]))
+ .collect();
+ if samples.is_empty() {
+ return Ok(Vec::new());
+ }
+ let source_channels = u16::try_from(source_num_channels)
+ .map_err(|_| anyhow!("unsupported pcm_s16le channel count {source_num_channels}"))?;
+ let mut target_samples = remap_channels(samples, source_channels, target_num_channels);
+ target_samples = resample_linear(
+ target_samples,
+ source_sample_rate_hz,
+ target_sample_rate_hz,
+ target_num_channels,
+ );
+ Ok(chunk_pcm_samples(
+ target_samples,
+ target_sample_rate_hz,
+ target_num_channels,
+ ))
+}
+
+#[derive(Debug, Clone, Default)]
+pub struct PcmS16leStreamChunkResult {
+ pub frames: Vec<PcmFrame>,
+ pub dropped_tail_bytes: usize,
+ pub buffered_source_bytes: usize,
+ pub buffered_source_samples: usize,
+}
+
+#[derive(Debug, Clone, Default)]
+pub struct PcmS16leStreamDebugDump {
+ pub debug_source_path: Option<String>,
+ pub debug_pcm_wav_path: Option<String>,
+ pub debug_pcm_wav_size_bytes: Option<u64>,
+}
+
+#[derive(Debug, Clone)]
+pub struct PcmS16leStreamDecoder {
+ source_sample_rate_hz: u32,
+ source_num_channels: u16,
+ target_sample_rate_hz: u32,
+ target_num_channels: u16,
+ source_byte_buffer: Vec<u8>,
+ source_sample_buffer: Vec<i16>,
+ debug_source_samples: Vec<i16>,
+ debug_target_samples: Vec<i16>,
+}
+
+impl PcmS16leStreamDecoder {
+ pub fn new(
+ source_sample_rate_hz: u32,
+ source_num_channels: u32,
+ target_sample_rate_hz: u32,
+ target_num_channels: u16,
+ ) -> Result<Self> {
+ if source_sample_rate_hz == 0 {
+ return Err(anyhow!("pcm_s16le source sample rate is zero"));
+ }
+ if source_num_channels == 0 {
+ return Err(anyhow!("pcm_s16le source channel count is zero"));
+ }
+ let source_num_channels = u16::try_from(source_num_channels)
+ .map_err(|_| anyhow!("unsupported pcm_s16le channel count {source_num_channels}"))?;
+ Ok(Self {
+ source_sample_rate_hz,
+ source_num_channels,
+ target_sample_rate_hz,
+ target_num_channels,
+ source_byte_buffer: Vec::new(),
+ source_sample_buffer: Vec::new(),
+ debug_source_samples: Vec::new(),
+ debug_target_samples: Vec::new(),
+ })
+ }
+
+ pub fn push_bytes(
+ &mut self,
+ pcm_bytes: &[u8],
+ source_sample_rate_hz: u32,
+ source_num_channels: u32,
+ last: bool,
+ ) -> Result<PcmS16leStreamChunkResult> {
+ let source_num_channels = u16::try_from(source_num_channels)
+ .map_err(|_| anyhow!("unsupported pcm_s16le channel count {source_num_channels}"))?;
+ if source_sample_rate_hz != self.source_sample_rate_hz
+ || source_num_channels != self.source_num_channels
+ {
+ return Err(anyhow!(
+ "pcm_s16le stream format changed from {}Hz/{}ch to {}Hz/{}ch",
+ self.source_sample_rate_hz,
+ self.source_num_channels,
+ source_sample_rate_hz,
+ source_num_channels
+ ));
+ }
+
+ self.source_byte_buffer.extend_from_slice(pcm_bytes);
+ let source_frame_alignment_bytes = usize::from(self.source_num_channels) * 2;
+ let aligned_len = self.source_byte_buffer.len()
+ - self.source_byte_buffer.len() % source_frame_alignment_bytes;
+ if aligned_len > 0 {
+ let aligned_bytes: Vec<u8> = self.source_byte_buffer.drain(..aligned_len).collect();
+ let source_samples: Vec<i16> = aligned_bytes
+ .chunks_exact(2)
+ .map(|chunk| i16::from_le_bytes([chunk[0], chunk[1]]))
+ .collect();
+ self.debug_source_samples.extend_from_slice(&source_samples);
+ let remapped = remap_channels(
+ source_samples,
+ self.source_num_channels,
+ self.target_num_channels,
+ );
+ self.source_sample_buffer.extend(remapped);
+ }
+
+ let dropped_tail_bytes = if last && !self.source_byte_buffer.is_empty() {
+ let dropped = self.source_byte_buffer.len();
+ self.source_byte_buffer.clear();
+ dropped
+ } else {
+ 0
+ };
+
+ let mut frames = self.drain_full_frames();
+ if last {
+ if let Some(frame) = self.drain_final_partial_frame() {
+ frames.push(frame);
+ }
+ }
+
+ Ok(PcmS16leStreamChunkResult {
+ frames,
+ dropped_tail_bytes,
+ buffered_source_bytes: self.source_byte_buffer.len(),
+ buffered_source_samples: self.source_sample_buffer.len(),
+ })
+ }
+
+ pub fn write_debug_dump(
+ &self,
+ dir: &str,
+ call_id: &str,
+ debug_label: &str,
+ ) -> Result<PcmS16leStreamDebugDump> {
+ let base_dir = PathBuf::from(dir);
+ fs::create_dir_all(&base_dir)
+ .with_context(|| format!("failed to create audio debug dump dir {dir}"))?;
+ let safe_call_id = sanitize_file_segment(call_id);
+ let safe_label = sanitize_file_segment(debug_label);
+
+ let source_path = base_dir.join(format!("{safe_call_id}-{safe_label}-source.wav"));
+ write_debug_wav(
+ &source_path,
+ &self.debug_source_samples,
+ self.source_sample_rate_hz,
+ self.source_num_channels,
+ )?;
+
+ let pcm_path = base_dir.join(format!("{safe_call_id}-{safe_label}-target.wav"));
+ write_debug_wav(
+ &pcm_path,
+ &self.debug_target_samples,
+ self.target_sample_rate_hz,
+ self.target_num_channels,
+ )?;
+ let pcm_size = fs::metadata(&pcm_path)
+ .with_context(|| format!("failed to stat audio debug wav {}", pcm_path.display()))?
+ .len();
+
+ Ok(PcmS16leStreamDebugDump {
+ debug_source_path: Some(source_path.to_string_lossy().to_string()),
+ debug_pcm_wav_path: Some(pcm_path.to_string_lossy().to_string()),
+ debug_pcm_wav_size_bytes: Some(pcm_size),
+ })
+ }
+
+ fn drain_full_frames(&mut self) -> Vec<PcmFrame> {
+ let source_samples_per_frame = self.source_samples_per_20ms_frame();
+ let mut frames = Vec::new();
+ while self.source_sample_buffer.len() >= source_samples_per_frame {
+ let source_samples: Vec<i16> = self
+ .source_sample_buffer
+ .drain(..source_samples_per_frame)
+ .collect();
+ frames.push(self.convert_source_samples_to_frame(source_samples, true));
+ }
+ frames
+ }
+
+ fn drain_final_partial_frame(&mut self) -> Option<PcmFrame> {
+ if self.source_sample_buffer.is_empty() {
+ return None;
+ }
+ let source_samples: Vec<i16> = self.source_sample_buffer.drain(..).collect();
+ Some(self.convert_source_samples_to_frame(source_samples, true))
+ }
+
+ fn convert_source_samples_to_frame(
+ &mut self,
+ source_samples: Vec<i16>,
+ pad_to_frame: bool,
+ ) -> PcmFrame {
+ let mut target_samples = resample_linear(
+ source_samples,
+ self.source_sample_rate_hz,
+ self.target_sample_rate_hz,
+ self.target_num_channels,
+ );
+ let target_frame_samples = self.target_samples_per_20ms_frame();
+ if target_samples.len() > target_frame_samples {
+ target_samples.truncate(target_frame_samples);
+ } else if pad_to_frame && target_samples.len() < target_frame_samples {
+ target_samples.resize(target_frame_samples, 0);
+ }
+ self.debug_target_samples.extend_from_slice(&target_samples);
+ PcmFrame::new(
+ target_samples,
+ self.target_sample_rate_hz,
+ u32::from(self.target_num_channels),
+ self.target_samples_per_channel_per_20ms_frame() as u32,
+ )
+ }
+
+ fn source_samples_per_20ms_frame(&self) -> usize {
+ let source_frames =
+ ((f64::from(self.source_sample_rate_hz) / 50.0).round() as usize).max(1);
+ source_frames * usize::from(self.target_num_channels)
+ }
+
+ fn target_samples_per_20ms_frame(&self) -> usize {
+ self.target_samples_per_channel_per_20ms_frame() * usize::from(self.target_num_channels)
+ }
+
+ fn target_samples_per_channel_per_20ms_frame(&self) -> usize {
+ ((self.target_sample_rate_hz / 1000) * 20).max(1) as usize
+ }
+}
+
+fn decode_audio_frames(
+ audio_bytes: &[u8],
+ source_kind: &'static str,
+ target_sample_rate_hz: u32,
+ target_num_channels: u16,
+ debug_dump_dir: Option<&str>,
+ call_id: &str,
+ debug_label: &str,
+) -> Result<LoadedAudio> {
+ if is_wav(audio_bytes) {
+ return finalize_decode_result(
+ audio_bytes,
+ decode_wav_samples(
+ audio_bytes,
+ source_kind,
+ target_sample_rate_hz,
+ target_num_channels,
+ )?,
+ target_sample_rate_hz,
+ target_num_channels,
+ debug_dump_dir,
+ call_id,
+ debug_label,
+ );
+ }
+ if is_mp3(audio_bytes) {
+ return finalize_decode_result(
+ audio_bytes,
+ decode_mp3_samples(
+ audio_bytes,
+ source_kind,
+ target_sample_rate_hz,
+ target_num_channels,
+ )?,
+ target_sample_rate_hz,
+ target_num_channels,
+ debug_dump_dir,
+ call_id,
+ debug_label,
+ );
+ }
+
+ let wav_result = decode_wav_samples(
+ audio_bytes,
+ source_kind,
+ target_sample_rate_hz,
+ target_num_channels,
+ );
+ if wav_result.is_ok() {
+ return finalize_decode_result(
+ audio_bytes,
+ wav_result?,
+ target_sample_rate_hz,
+ target_num_channels,
+ debug_dump_dir,
+ call_id,
+ debug_label,
+ );
+ }
+ let wav_error = wav_result.err();
+ let mp3_result = decode_mp3_samples(
+ audio_bytes,
+ source_kind,
+ target_sample_rate_hz,
+ target_num_channels,
+ );
+ match mp3_result {
+ Ok(result) => finalize_decode_result(
+ audio_bytes,
+ result,
+ target_sample_rate_hz,
+ target_num_channels,
+ debug_dump_dir,
+ call_id,
+ debug_label,
+ ),
+ Err(mp3_error) => Err(anyhow!(
+ "failed to decode greeting audio as wav or mp3: wav={}, mp3={}",
+ wav_error
+ .map(|error| error.to_string())
+ .unwrap_or_else(|| "unknown".to_string()),
+ mp3_error
+ )),
+ }
+}
+
+fn decode_wav_samples(
+ wav_bytes: &[u8],
+ source_kind: &'static str,
+ target_sample_rate_hz: u32,
+ target_num_channels: u16,
+) -> Result<DecodeResult> {
+ let cursor = Cursor::new(wav_bytes.to_vec());
+ let mut reader = WavReader::new(cursor).context("failed to open greeting wav bytes")?;
+ let spec = reader.spec();
+ let src_channels = spec.channels.max(1);
+ let decoded_samples = match (spec.sample_format, spec.bits_per_sample) {
+ (SampleFormat::Int, 16) => reader
+ .samples::<i16>()
+ .collect::<std::result::Result<Vec<_>, _>>()
+ .context("failed to decode 16-bit wav samples")?,
+ (SampleFormat::Float, 32) => reader
+ .samples::<f32>()
+ .map(|sample| sample.map(|value| (value.clamp(-1.0, 1.0) * i16::MAX as f32) as i16))
+ .collect::<std::result::Result<Vec<_>, _>>()
+ .context("failed to decode float wav samples")?,
+ _ => {
+ return Err(anyhow!(
+ "unsupported greeting wav format: {:?} {}-bit",
+ spec.sample_format,
+ spec.bits_per_sample
+ ));
+ }
+ };
+
+ let decoded_sample_count = decoded_samples.len();
+ let mut samples = remap_channels(decoded_samples, src_channels, target_num_channels);
+ samples = resample_linear(
+ samples,
+ spec.sample_rate,
+ target_sample_rate_hz,
+ target_num_channels,
+ );
+
+ Ok(DecodeResult {
+ diagnostics: build_diagnostics(
+ source_kind,
+ "wav",
+ wav_bytes.len(),
+ spec.sample_rate,
+ src_channels,
+ decoded_sample_count,
+ &samples,
+ target_sample_rate_hz,
+ target_num_channels,
+ 0,
+ 0,
+ ),
+ samples,
+ })
+}
+
+fn decode_mp3_samples(
+ mp3_bytes: &[u8],
+ source_kind: &'static str,
+ target_sample_rate_hz: u32,
+ target_num_channels: u16,
+) -> Result<DecodeResult> {
+ let cursor = Cursor::new(mp3_bytes.to_vec());
+ let mut decoder = Mp3Decoder::new(cursor);
+ let mut samples = Vec::new();
+ let mut decoded_sample_count = 0usize;
+ let mut source_sample_rate_hz = 0u32;
+ let mut source_num_channels = 0u16;
+ let mut skipped_data_count = 0usize;
+ let mut insufficient_data_count = 0usize;
+
+ loop {
+ match decoder.next_frame() {
+ Ok(Mp3Frame {
+ data,
+ sample_rate,
+ channels,
+ ..
+ }) => {
+ let src_rate = u32::try_from(sample_rate)
+ .map_err(|_| anyhow!("unsupported mp3 sample rate {sample_rate}"))?;
+ let src_channels = u16::try_from(channels)
+ .map_err(|_| anyhow!("unsupported mp3 channel count {channels}"))?;
+ if source_sample_rate_hz == 0 {
+ source_sample_rate_hz = src_rate;
+ }
+ if source_num_channels == 0 {
+ source_num_channels = src_channels;
+ }
+ decoded_sample_count += data.len();
+ let mut frame_samples = remap_channels(data, src_channels, target_num_channels);
+ frame_samples = resample_linear(
+ frame_samples,
+ src_rate,
+ target_sample_rate_hz,
+ target_num_channels,
+ );
+ samples.extend(frame_samples);
+ }
+ Err(Mp3Error::Eof) => break,
+ Err(Mp3Error::SkippedData) => {
+ skipped_data_count += 1;
+ continue;
+ }
+ Err(Mp3Error::InsufficientData) => {
+ insufficient_data_count += 1;
+ continue;
+ }
+ Err(error) => return Err(anyhow!("failed to decode mp3 frame: {error:?}")),
+ }
+ }
+
+ if samples.is_empty() {
+ return Err(anyhow!("decoded mp3 contains no audio samples"));
+ }
+
+ Ok(DecodeResult {
+ diagnostics: build_diagnostics(
+ source_kind,
+ "mp3",
+ mp3_bytes.len(),
+ source_sample_rate_hz,
+ source_num_channels.max(1),
+ decoded_sample_count,
+ &samples,
+ target_sample_rate_hz,
+ target_num_channels,
+ skipped_data_count,
+ insufficient_data_count,
+ ),
+ samples,
+ })
+}
+
+fn finalize_decode_result(
+ audio_bytes: &[u8],
+ mut decode_result: DecodeResult,
+ target_sample_rate_hz: u32,
+ target_num_channels: u16,
+ debug_dump_dir: Option<&str>,
+ call_id: &str,
+ debug_label: &str,
+) -> Result<LoadedAudio> {
+ if let Some(dir) = debug_dump_dir.filter(|value| !value.trim().is_empty()) {
+ let paths = dump_debug_audio(
+ dir,
+ call_id,
+ debug_label,
+ decode_result.diagnostics.source_format,
+ audio_bytes,
+ &decode_result.samples,
+ target_sample_rate_hz,
+ target_num_channels,
+ )?;
+ decode_result.diagnostics.debug_source_path = paths.0;
+ decode_result.diagnostics.debug_pcm_wav_path = paths.1;
+ }
+
+ let frames = chunk_pcm_samples(
+ decode_result.samples,
+ target_sample_rate_hz,
+ target_num_channels,
+ );
+ decode_result.diagnostics.frame_count = frames.len();
+
+ Ok(LoadedAudio {
+ frames,
+ diagnostics: decode_result.diagnostics,
+ })
+}
+
+fn build_diagnostics(
+ source_kind: &'static str,
+ source_format: &'static str,
+ source_bytes: usize,
+ source_sample_rate_hz: u32,
+ source_num_channels: u16,
+ decoded_sample_count: usize,
+ target_samples: &[i16],
+ target_sample_rate_hz: u32,
+ target_num_channels: u16,
+ mp3_skipped_data_count: usize,
+ mp3_insufficient_data_count: usize,
+) -> AudioDiagnostics {
+ let target_sample_count = target_samples.len();
+ let decoded_duration_ms = duration_ms(
+ decoded_sample_count,
+ source_sample_rate_hz,
+ source_num_channels,
+ );
+ let target_duration_ms = duration_ms(
+ target_sample_count,
+ target_sample_rate_hz,
+ target_num_channels,
+ );
+ let (rms, peak, clipped_sample_count, silence_ratio) = quality_stats(target_samples);
+ AudioDiagnostics {
+ source_kind,
+ source_format,
+ source_bytes,
+ source_sample_rate_hz,
+ source_num_channels,
+ decoded_sample_count,
+ decoded_duration_ms,
+ target_sample_rate_hz,
+ target_num_channels,
+ target_sample_count,
+ target_duration_ms,
+ frame_count: 0,
+ rms,
+ peak,
+ clipped_sample_count,
+ silence_ratio,
+ mp3_skipped_data_count,
+ mp3_insufficient_data_count,
+ debug_source_path: None,
+ debug_pcm_wav_path: None,
+ }
+}
+
+fn duration_ms(sample_count: usize, sample_rate_hz: u32, channels: u16) -> u64 {
+ if sample_count == 0 || sample_rate_hz == 0 || channels == 0 {
+ return 0;
+ }
+ let frames = sample_count as f64 / f64::from(channels.max(1));
+ ((frames / f64::from(sample_rate_hz)) * 1000.0).round() as u64
+}
+
+fn quality_stats(samples: &[i16]) -> (f64, f64, usize, f64) {
+ if samples.is_empty() {
+ return (0.0, 0.0, 0, 0.0);
+ }
+ const SILENCE_THRESHOLD: i32 = 256;
+ let mut sum_squares = 0f64;
+ let mut peak = 0i32;
+ let mut clipped = 0usize;
+ let mut silence = 0usize;
+ for &sample in samples {
+ let abs = i32::from(sample).abs();
+ peak = peak.max(abs);
+ if abs >= i32::from(i16::MAX) {
+ clipped += 1;
+ }
+ if abs <= SILENCE_THRESHOLD {
+ silence += 1;
+ }
+ let normalized = f64::from(sample) / f64::from(i16::MAX);
+ sum_squares += normalized * normalized;
+ }
+ let rms = (sum_squares / samples.len() as f64).sqrt();
+ let peak = f64::from(peak) / f64::from(i16::MAX);
+ let silence_ratio = silence as f64 / samples.len() as f64;
+ (round4(rms), round4(peak), clipped, round4(silence_ratio))
+}
+
+fn round4(value: f64) -> f64 {
+ (value * 10_000.0).round() / 10_000.0
+}
+
+fn dump_debug_audio(
+ dir: &str,
+ call_id: &str,
+ debug_label: &str,
+ source_format: &str,
+ source_bytes: &[u8],
+ target_samples: &[i16],
+ target_sample_rate_hz: u32,
+ target_num_channels: u16,
+) -> Result<(Option<String>, Option<String>)> {
+ let base_dir = PathBuf::from(dir);
+ fs::create_dir_all(&base_dir)
+ .with_context(|| format!("failed to create audio debug dump dir {dir}"))?;
+ let safe_call_id = sanitize_file_segment(call_id);
+ let safe_label = sanitize_file_segment(debug_label);
+ let source_path = base_dir.join(format!(
+ "{safe_call_id}-{safe_label}-source.{source_format}"
+ ));
+ fs::write(&source_path, source_bytes).with_context(|| {
+ format!(
+ "failed to write audio debug source {}",
+ source_path.display()
+ )
+ })?;
+
+ let pcm_path = base_dir.join(format!("{safe_call_id}-{safe_label}-target.wav"));
+ write_debug_wav(
+ &pcm_path,
+ target_samples,
+ target_sample_rate_hz,
+ target_num_channels,
+ )?;
+
+ Ok((
+ Some(source_path.to_string_lossy().to_string()),
+ Some(pcm_path.to_string_lossy().to_string()),
+ ))
+}
+
+fn write_debug_wav(path: &Path, samples: &[i16], sample_rate_hz: u32, channels: u16) -> Result<()> {
+ let spec = WavSpec {
+ channels,
+ sample_rate: sample_rate_hz,
+ bits_per_sample: 16,
+ sample_format: SampleFormat::Int,
+ };
+ let mut writer = WavWriter::create(path, spec)
+ .with_context(|| format!("failed to create audio debug wav {}", path.display()))?;
+ for &sample in samples {
+ writer
+ .write_sample(sample)
+ .with_context(|| format!("failed to write audio debug wav {}", path.display()))?;
+ }
+ writer
+ .finalize()
+ .with_context(|| format!("failed to finalize audio debug wav {}", path.display()))
+}
+
+pub fn write_pcm_wav(
+ path: &Path,
+ samples: &[i16],
+ sample_rate_hz: u32,
+ channels: u16,
+) -> Result<()> {
+ write_debug_wav(path, samples, sample_rate_hz, channels)
+}
+
+fn sanitize_file_segment(value: &str) -> String {
+ let sanitized: String = value
+ .chars()
+ .map(|ch| {
+ if ch.is_ascii_alphanumeric() || ch == '-' || ch == '_' || ch == '.' {
+ ch
+ } else {
+ '_'
+ }
+ })
+ .collect();
+ if sanitized.is_empty() {
+ "unknown".to_string()
+ } else {
+ sanitized
+ }
+}
+
+fn chunk_pcm_samples(
+ samples: Vec<i16>,
+ target_sample_rate_hz: u32,
+ target_num_channels: u16,
+) -> Vec<PcmFrame> {
+ const CHUNK_MS: u32 = 20;
+ let samples_per_chunk = ((target_sample_rate_hz / 1000) * CHUNK_MS).max(1) as usize
+ * usize::from(target_num_channels);
+ let mut frames = Vec::new();
+ for chunk in samples.chunks(samples_per_chunk) {
+ let mut frame_data = chunk.to_vec();
+ if frame_data.len() < samples_per_chunk {
+ frame_data.resize(samples_per_chunk, 0);
+ }
+ let samples_per_channel =
+ (frame_data.len() / usize::from(target_num_channels.max(1))) as u32;
+ frames.push(PcmFrame::new(
+ frame_data,
+ target_sample_rate_hz,
+ u32::from(target_num_channels),
+ samples_per_channel,
+ ));
+ }
+ frames
+}
+
+fn is_wav(bytes: &[u8]) -> bool {
+ bytes.len() >= 12 && bytes.starts_with(b"RIFF") && &bytes[8..12] == b"WAVE"
+}
+
+fn is_mp3(bytes: &[u8]) -> bool {
+ bytes.starts_with(b"ID3") || (bytes.len() >= 2 && bytes[0] == 0xff && bytes[1] & 0xe0 == 0xe0)
+}
+
+fn remap_channels(samples: Vec<i16>, src_channels: u16, dst_channels: u16) -> Vec<i16> {
+ if src_channels == dst_channels {
+ return samples;
+ }
+
+ let src_channels = usize::from(src_channels.max(1));
+ let dst_channels = usize::from(dst_channels.max(1));
+ let frames = samples.chunks(src_channels);
+ let mut output = Vec::new();
+ for frame in frames {
+ match (src_channels, dst_channels) {
+ (1, 2) => {
+ let sample = frame.first().copied().unwrap_or_default();
+ output.push(sample);
+ output.push(sample);
+ }
+ (2, 1) => {
+ let left = frame.first().copied().unwrap_or_default() as i32;
+ let right = frame.get(1).copied().unwrap_or_default() as i32;
+ output.push(((left + right) / 2) as i16);
+ }
+ _ => {
+ for channel in 0..dst_channels {
+ output.push(
+ frame
+ .get(channel % src_channels)
+ .copied()
+ .unwrap_or_default(),
+ );
+ }
+ }
+ }
+ }
+ output
+}
+
+fn resample_linear(samples: Vec<i16>, src_rate: u32, dst_rate: u32, channels: u16) -> Vec<i16> {
+ if src_rate == dst_rate {
+ return samples;
+ }
+
+ let channels = usize::from(channels.max(1));
+ let src_frames = samples.len() / channels;
+ if src_frames <= 1 {
+ return samples;
+ }
+ let ratio = dst_rate as f64 / src_rate as f64;
+ let dst_frames = ((src_frames as f64) * ratio).round().max(1.0) as usize;
+ let mut output = Vec::with_capacity(dst_frames * channels);
+
+ for dst_frame in 0..dst_frames {
+ let src_pos = (dst_frame as f64) / ratio;
+ let src_index = src_pos.floor() as usize;
+ let next_index = (src_index + 1).min(src_frames - 1);
+ let frac = (src_pos - src_index as f64) as f32;
+ for channel in 0..channels {
+ let a = samples[src_index * channels + channel] as f32;
+ let b = samples[next_index * channels + channel] as f32;
+ let mixed = a + (b - a) * frac;
+ output.push(mixed.round().clamp(i16::MIN as f32, i16::MAX as f32) as i16);
+ }
+ }
+
+ output
+}
+
+#[cfg(test)]
+mod tests {
+ use super::{PcmS16leStreamDecoder, load_pre_recorded_frames, pcm_s16le_bytes_to_frames};
+
+ #[tokio::test]
+ async fn load_pre_recorded_frames_reads_local_wav() {
+ let dir = std::env::temp_dir().join("cv-runtime-helper-tests");
+ std::fs::create_dir_all(&dir).expect("create temp dir");
+ let path = dir.join("fixture.wav");
+ let spec = hound::WavSpec {
+ channels: 1,
+ sample_rate: 16_000,
+ bits_per_sample: 16,
+ sample_format: hound::SampleFormat::Int,
+ };
+ let mut writer = hound::WavWriter::create(&path, spec).expect("create wav");
+ for _ in 0..16_000 {
+ writer.write_sample(512_i16).expect("write sample");
+ }
+ writer.finalize().expect("finalize wav");
+
+ let http = reqwest::Client::new();
+ let loaded = load_pre_recorded_frames(
+ &http,
+ path.to_str(),
+ None,
+ 48_000,
+ 1,
+ Some(dir.to_string_lossy().as_ref()),
+ "test-call",
+ "greeting",
+ )
+ .await
+ .expect("load frames")
+ .expect("frames");
+
+ assert!(!loaded.frames.is_empty());
+ assert_eq!(loaded.frames[0].sample_rate, 48_000);
+ assert_eq!(loaded.frames[0].num_channels, 1);
+ assert_eq!(loaded.diagnostics.source_format, "wav");
+ assert_eq!(loaded.diagnostics.source_sample_rate_hz, 16_000);
+ assert_eq!(loaded.diagnostics.target_sample_rate_hz, 48_000);
+ assert!(loaded.diagnostics.debug_source_path.is_some());
+ assert!(loaded.diagnostics.debug_pcm_wav_path.is_some());
+ }
+
+ #[test]
+ fn pcm_s16le_bytes_to_frames_resamples_elevenlabs_pcm_to_livekit_frames() {
+ let mut pcm = Vec::new();
+ for index in 0..4410 {
+ let sample = if index % 2 == 0 { 1024_i16 } else { -1024_i16 };
+ pcm.extend_from_slice(&sample.to_le_bytes());
+ }
+
+ let frames = pcm_s16le_bytes_to_frames(&pcm, 44_100, 1, 48_000, 1).expect("pcm frames");
+
+ assert!(!frames.is_empty());
+ assert_eq!(frames[0].sample_rate, 48_000);
+ assert_eq!(frames[0].num_channels, 1);
+ assert_eq!(frames[0].samples_per_channel, 960);
+ }
+
+ #[test]
+ fn pcm_s16le_stream_decoder_does_not_pad_each_network_chunk() {
+ let mut decoder = PcmS16leStreamDecoder::new(44_100, 1, 48_000, 1).expect("stream decoder");
+ let mut pcm = Vec::new();
+ for index in 0..4410 {
+ let sample = if index % 2 == 0 { 1024_i16 } else { -1024_i16 };
+ pcm.extend_from_slice(&sample.to_le_bytes());
+ }
+
+ let mut frames = Vec::new();
+ let chunk_size = 698 * 2;
+ for (index, chunk) in pcm.chunks(chunk_size).enumerate() {
+ let last = (index + 1) * chunk_size >= pcm.len();
+ let result = decoder
+ .push_bytes(chunk, 44_100, 1, last)
+ .expect("push pcm chunk");
+ frames.extend(result.frames);
+ }
+
+ assert_eq!(5, frames.len());
+ assert_eq!(
+ 4_800,
+ frames.iter().map(|frame| frame.data.len()).sum::<usize>()
+ );
+ assert!(frames.iter().all(|frame| frame.sample_rate == 48_000));
+ assert!(frames.iter().all(|frame| frame.num_channels == 1));
+ assert!(frames.iter().all(|frame| frame.samples_per_channel == 960));
+ }
+
+ #[test]
+ fn pcm_s16le_stream_decoder_waits_until_full_20ms_frame() {
+ let mut decoder = PcmS16leStreamDecoder::new(44_100, 1, 48_000, 1).expect("stream decoder");
+ let ten_ms = vec![0_u8; 441 * 2];
+
+ let first = decoder
+ .push_bytes(&ten_ms, 44_100, 1, false)
+ .expect("first chunk");
+ assert!(first.frames.is_empty());
+ assert_eq!(441, first.buffered_source_samples);
+
+ let second = decoder
+ .push_bytes(&ten_ms, 44_100, 1, false)
+ .expect("second chunk");
+ assert_eq!(1, second.frames.len());
+ assert_eq!(960, second.frames[0].samples_per_channel);
+ assert_eq!(0, second.buffered_source_samples);
+ }
+
+ #[test]
+ fn pcm_s16le_stream_decoder_can_keep_16k_native_audio_profile() {
+ let mut decoder = PcmS16leStreamDecoder::new(16_000, 1, 16_000, 1).expect("stream decoder");
+ let mut pcm = Vec::new();
+ for index in 0..1_600 {
+ let sample = if index % 2 == 0 { 768_i16 } else { -768_i16 };
+ pcm.extend_from_slice(&sample.to_le_bytes());
+ }
+
+ let mut frames = Vec::new();
+ let chunk_size = 250 * 2;
+ for (index, chunk) in pcm.chunks(chunk_size).enumerate() {
+ let last = (index + 1) * chunk_size >= pcm.len();
+ let result = decoder
+ .push_bytes(chunk, 16_000, 1, last)
+ .expect("push pcm chunk");
+ frames.extend(result.frames);
+ }
+
+ assert_eq!(5, frames.len());
+ assert!(frames.iter().all(|frame| frame.sample_rate == 16_000));
+ assert!(frames.iter().all(|frame| frame.num_channels == 1));
+ assert!(frames.iter().all(|frame| frame.samples_per_channel == 320));
+ assert_eq!(
+ 1_600,
+ frames.iter().map(|frame| frame.data.len()).sum::<usize>()
+ );
+ }
+}
--
Gitblit v1.9.3