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.samples.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.samples.len())
|
.sum::<usize>()
|
);
|
}
|
}
|