cai
2026-06-28 1a97f35257691aadfdd4d07cb08cd2cee571e237
src/audio.rs
@@ -1,317 +1,720 @@
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,
    )
}
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::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 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());
    }
}