use crate::audio::models_subdir;
use crate::util::UnwrapPoison;
use crate::util::model_state::{AtomicModelState, ModelLoadGuard, ModelState};
use anyhow::{Context, Result};
use futures_util::FutureExt;
use std::panic::AssertUnwindSafe;
use std::path::{Path, PathBuf};
use std::sync::atomic::Ordering;
use std::sync::{Arc, Mutex};
use std::time::Duration;
use tracing::{debug, info, warn};
const MODEL_REPO: &str = "Qwen/Qwen3-ASR-0.6B";
pub(crate) const MODEL_DIR_NAME: &str = "qwen3-asr-0.6b";
pub(crate) const MODEL_FILENAME: &str = "model.safetensors";
pub(crate) const VOCAB_FILENAME: &str = "vocab.json";
pub(crate) const MERGES_FILENAME: &str = "merges.txt";
const MODEL_SHA256: &str = "79d6cbd4c98c7bbffe9db2edac07f56cd6637d0d5944b27f6c2b8353840323ea";
const VOCAB_SHA256: &str = "ca10d7e9fb3ed18575dd1e277a2579c16d108e32f27439684afa0e10b1440910";
const MERGES_SHA256: &str = "8831e4f1a044471340f7c0a83d7bd71306a5b867e95fd870f74d0c5308a904d5";
pub(crate) const INFERENCE_TIMEOUT: Duration = Duration::from_mins(10);
const MODEL_DOWNLOAD_TIMEOUT: Duration = Duration::from_mins(30);
const SMALL_FILE_TIMEOUT: Duration = Duration::from_mins(1);
const DOWNLOAD_RETRY_BASE_SECS: u64 = 5;
const MAX_DOWNLOAD_RETRIES: u32 = 12;
static GLOBAL_TRANSCRIBER: Mutex<Option<QwenLocalTranscriber>> = Mutex::new(None);
static SHARED_MODEL: Mutex<Option<Arc<qwen_asr::context::QwenModel>>> = Mutex::new(None);
static STATE: AtomicModelState = AtomicModelState::new(ModelState::Uninit);
fn set_transcriber_ready(tc: QwenLocalTranscriber) {
*SHARED_MODEL.lock().unwrap_poison() = Some(tc.model_arc());
*GLOBAL_TRANSCRIBER.lock().unwrap_poison() = Some(tc);
STATE.store(ModelState::Ready, Ordering::Release);
}
pub struct QwenLocalTranscriber {
ctx: Arc<Mutex<qwen_asr::context::QwenCtx>>,
}
impl QwenLocalTranscriber {
fn try_load_from(dir: &Path) -> Option<Self> {
let dir_str = dir.to_string_lossy().to_string();
let mut ctx = qwen_asr::context::QwenCtx::load(&dir_str)?;
ctx.want_language_detection = true;
ctx.segment_sec = 30.0;
Some(Self {
ctx: Arc::new(Mutex::new(ctx)),
})
}
fn clone_arc(&self) -> Arc<Mutex<qwen_asr::context::QwenCtx>> {
Arc::clone(&self.ctx)
}
fn model_arc(&self) -> Arc<qwen_asr::context::QwenModel> {
Arc::clone(&self.ctx.lock().unwrap_poison().model)
}
}
pub async fn transcribe_file_async(path: &Path, inference_timeout: Duration) -> Result<String> {
let owned = path.to_owned();
let samples = tokio::task::spawn_blocking(move || decode_audio_to_mono_f32(&owned))
.await
.context("Audio decode task panicked")?
.context("Failed to decode audio file to 16 kHz mono PCM")?;
if samples.is_empty() {
anyhow::bail!("Audio file is empty after decoding");
}
let ctx_arc = {
let guard = GLOBAL_TRANSCRIBER.lock().unwrap_poison();
let tc = guard.as_ref().ok_or_else(|| {
anyhow::anyhow!("Local transcriber not available during async transcription")
})?;
tc.clone_arc()
};
let ctx_arc2 = ctx_arc;
let shutdown_token = crate::shutdown::shutdown_token();
let text = tokio::select! {
result = tokio::time::timeout(inference_timeout, async move {
tokio::task::spawn_blocking(move || {
let mut ctx = ctx_arc2.lock().unwrap_poison();
qwen_asr::transcribe::transcribe_audio(&mut ctx, &samples)
.ok_or_else(|| anyhow::anyhow!("Qwen3-ASR inference returned no output"))
})
.await
.context("Inference task panicked")?
}) => {
result.context("Qwen3-ASR inference timed out")?
}
() = shutdown_token.cancelled() => {
anyhow::bail!("Shutdown during audio transcription");
}
};
text
}
pub(crate) fn decode_audio_to_mono_f32(path: &Path) -> Result<Vec<f32>> {
let ext = path
.extension()
.and_then(|e| e.to_str())
.unwrap_or("")
.to_lowercase();
let data = std::fs::read(path)
.with_context(|| format!("Failed to read audio file: {}", path.display()))?;
if ext == "wav" {
if let Some(samples) = qwen_asr::audio::parse_wav_buffer(&data) {
return Ok(samples);
}
}
if data.len() < 8 {
anyhow::bail!("Audio file too small: {}", path.display());
}
let (samples, sample_rate) = match ext.as_str() {
"ogg" | "oga" => decode_opus_from_ogg(&data, path)?,
"mp3" => decode_mp3(&data, path)?,
"wav" => {
anyhow::bail!(
"Failed to decode WAV file (format error): {}",
path.display()
);
}
_ => {
if data.len() >= 4 && &data[0..4] == b"OggS" {
decode_opus_from_ogg(&data, path)?
} else {
anyhow::bail!(
"Unsupported audio format '.{ext}' — only WAV, OGG/Opus, and MP3 are supported"
);
}
}
};
if sample_rate == qwen_asr::config::SAMPLE_RATE {
Ok(samples)
} else {
let resampled =
qwen_asr::audio::resample(&samples, sample_rate, qwen_asr::config::SAMPLE_RATE);
Ok(resampled)
}
}
fn decode_opus_from_ogg(data: &[u8], path: &Path) -> Result<(Vec<f32>, i32)> {
use ogg::reading::PacketReader;
use std::io::Cursor;
let cursor = Cursor::new(data);
let mut reader = PacketReader::new(cursor);
let sample_rate: i32 = 16000; let mut decoder: Option<opus_decoder::OpusDecoder> = None;
let mut channels: usize = 1; let mut samples: Vec<f32> = Vec::new();
loop {
let packet = match reader.read_packet() {
Ok(Some(pkt)) => pkt,
Ok(None) => break, Err(e) => {
warn!(path = %path.display(), error = %e, "OGG demux error, stopping");
break;
}
};
let packet_data = packet.data;
if decoder.is_none() {
if packet_data.starts_with(b"OpusHead") {
if packet_data.len() < 18 {
anyhow::bail!("Invalid Opus identification header (too short)");
}
channels = packet_data[9] as usize;
match opus_decoder::OpusDecoder::new(16000u32, channels) {
Ok(d) => {
decoder = Some(d);
}
Err(e) => {
anyhow::bail!("Failed to create Opus decoder: {e:?}");
}
}
} else {
anyhow::bail!("OGG file does not contain Opus data");
}
continue;
}
if packet_data.starts_with(b"OpusTags") {
continue;
}
if packet_data.is_empty() {
continue; }
let max_pcm_len = 5760 * channels.max(2);
let mut pcm = vec![0.0f32; max_pcm_len];
let dec = decoder.as_mut().ok_or_else(|| {
anyhow::anyhow!("Opus decoder not initialized — missing OpusHead header")
})?;
match dec.decode_float(&packet_data, &mut pcm, false) {
Ok(n_per_channel) => {
if channels == 1 {
samples.extend_from_slice(&pcm[..n_per_channel]);
} else {
for i in 0..n_per_channel {
let l = pcm[i * 2];
let r = pcm[i * 2 + 1];
samples.push((l + r) * 0.5);
}
}
}
Err(e) => {
warn!(path = %path.display(), error = ?e, "Opus decode error, skipping packet");
}
}
}
if samples.is_empty() {
anyhow::bail!("No audio decoded from {}", path.display());
}
Ok((samples, sample_rate))
}
#[expect(clippy::cast_precision_loss)]
fn decode_mp3(data: &[u8], path: &Path) -> Result<(Vec<f32>, i32)> {
use minimp3::Decoder as Mp3Decoder;
let mut decoder = Mp3Decoder::new(data);
let mut samples: Vec<f32> = Vec::new();
let mut sample_rate: i32 = 0;
loop {
match decoder.next_frame() {
Ok(frame) => {
if sample_rate == 0 {
sample_rate = frame.sample_rate;
}
let n_ch = frame.channels;
if n_ch == 1 {
for &val in &frame.data {
samples.push(f32::from(val) / 32768.0);
}
} else {
for chunk in frame.data.chunks(n_ch) {
let mono: f32 = chunk.iter().map(|&v| f32::from(v)).sum::<f32>()
/ n_ch as f32
/ 32768.0;
samples.push(mono);
}
}
}
Err(minimp3::Error::Eof) => break,
Err(e) => {
warn!(path = %path.display(), error = %e, "MP3 decode error, stopping");
break;
}
}
}
if samples.is_empty() {
anyhow::bail!("No audio decoded from {}", path.display());
}
Ok((samples, sample_rate))
}
struct ModelFile {
filename: &'static str,
url: String,
expected_sha256: &'static str,
timeout: Duration,
}
fn model_url(filename: &str) -> String {
format!("https://huggingface.co/{MODEL_REPO}/resolve/main/{filename}")
}
fn model_files() -> [ModelFile; 3] {
[
ModelFile {
filename: MODEL_FILENAME,
url: model_url(MODEL_FILENAME),
expected_sha256: MODEL_SHA256,
timeout: MODEL_DOWNLOAD_TIMEOUT,
},
ModelFile {
filename: VOCAB_FILENAME,
url: model_url(VOCAB_FILENAME),
expected_sha256: VOCAB_SHA256,
timeout: SMALL_FILE_TIMEOUT,
},
ModelFile {
filename: MERGES_FILENAME,
url: model_url(MERGES_FILENAME),
expected_sha256: MERGES_SHA256,
timeout: SMALL_FILE_TIMEOUT,
},
]
}
async fn download_file(client: &reqwest::Client, file: &ModelFile, dest: &Path) -> Result<()> {
#[expect(clippy::cast_precision_loss)]
fn calc_pct(downloaded: u64, total_size: u64) -> f64 {
(downloaded as f64 / total_size as f64 * 100.0).min(100.0)
}
crate::util::http::download_verified(
client,
&file.url,
dest,
file.expected_sha256,
Some(file.timeout),
crate::util::http::DownloadSizeCheck::None,
|downloaded, total_size| {
if total_size > 0 {
let pct = calc_pct(downloaded, total_size);
debug!(
"Downloading {}: {:.0}% ({}/{} MB)",
file.filename,
pct,
downloaded / 1_048_576,
total_size / 1_048_576,
);
}
},
)
.await
.with_context(|| format!("Failed to download {}", file.filename))?;
info!("Downloaded {} ({})", file.filename, file.expected_sha256);
Ok(())
}
fn download_client() -> Result<reqwest::Client> {
crate::util::http::install_ring_provider();
reqwest::Client::builder()
.user_agent("mahbot/0.3.0 (qwen-asr model downloader)")
.build()
.context("Failed to create HTTP client for model download")
}
async fn download_retry_loop() {
let _guard = ModelLoadGuard::new(&STATE);
let Some(dir) = models_subdir(MODEL_DIR_NAME) else {
warn!("Local transcriber: cannot resolve model directory (storage root not set)");
STATE.store(ModelState::Failed, Ordering::Release);
return;
};
let client = match download_client() {
Ok(c) => c,
Err(e) => {
warn!("Local transcriber: failed to create HTTP client: {e}");
STATE.store(ModelState::Failed, Ordering::Release);
return;
}
};
tokio::fs::create_dir_all(&dir).await.ok();
let files = model_files();
let mut attempt: u32 = 0;
loop {
attempt += 1;
let mut all_ok = true;
for file in &files {
let dest = dir.join(file.filename);
if dest.exists() {
let dest_clone = dest.clone();
let expected = file.expected_sha256.to_string();
let checksum_ok = tokio::task::spawn_blocking(move || {
crate::util::verify_sha256(&dest_clone, &expected).is_ok()
})
.await
.unwrap_or_else(|join_err| {
warn!("Local transcriber: SHA256 verification task panicked: {join_err}");
false
});
if checksum_ok {
continue;
}
warn!(
"Local transcriber: {} SHA256 mismatch, re-downloading",
file.filename
);
tokio::fs::remove_file(&dest).await.ok();
}
info!(
"Local transcriber: downloading {} — attempt {attempt}/{MAX_DOWNLOAD_RETRIES}",
file.filename,
);
match download_file(&client, file, &dest).await {
Ok(()) => {
}
Err(e) => {
warn!(
"Local transcriber: failed to download {}: {e}",
file.filename
);
tokio::fs::remove_file(&dest).await.ok();
all_ok = false;
break;
}
}
}
if all_ok {
info!("Local transcriber: all model files downloaded, loading...");
let dir_for_load = dir.clone();
let loaded = tokio::task::spawn_blocking(move || {
QwenLocalTranscriber::try_load_from(&dir_for_load)
})
.await
.ok()
.flatten();
if let Some(tc) = loaded {
info!("Local transcriber: Qwen3-ASR model loaded successfully");
set_transcriber_ready(tc);
return;
}
warn!(
"Local transcriber: model files present but failed to load — deleting and re-downloading"
);
for f in &files {
let dest = dir.join(f.filename);
tokio::fs::remove_file(&dest).await.ok();
}
}
if attempt >= MAX_DOWNLOAD_RETRIES {
warn!("Local transcriber: max retries ({MAX_DOWNLOAD_RETRIES}) reached, giving up");
STATE.store(ModelState::Failed, Ordering::Release);
return;
}
let sleep_secs = DOWNLOAD_RETRY_BASE_SECS * (1u64 << (attempt - 1).min(8));
let sleep_dur = Duration::from_secs(sleep_secs.min(300));
warn!(
"Local transcriber: retrying in {}s (attempt {attempt}/{MAX_DOWNLOAD_RETRIES})",
sleep_dur.as_secs()
);
tokio::time::sleep(sleep_dur).await;
}
}
async fn load_from_cache(dir: PathBuf) -> Option<QwenLocalTranscriber> {
tokio::task::spawn_blocking(move || QwenLocalTranscriber::try_load_from(&dir))
.await
.ok()
.flatten()
}
async fn try_init_inner(dir: PathBuf) -> bool {
let model_path = dir.join(MODEL_FILENAME);
let vocab_path = dir.join(VOCAB_FILENAME);
let merges_path = dir.join(MERGES_FILENAME);
if model_path.exists() && vocab_path.exists() && merges_path.exists() {
if let Some(tc) = load_from_cache(dir).await {
info!("Local transcriber: loaded from cache");
set_transcriber_ready(tc);
return true;
}
warn!("Local transcriber: cached files present but failed to load");
}
if tokio::runtime::Handle::try_current().is_err() {
warn!("Local transcriber: no tokio runtime available");
STATE.store(ModelState::Failed, Ordering::Release);
return false;
}
info!("Local transcriber: model not cached, spawning background download");
tokio::spawn(download_retry_loop());
false
}
async fn init_background(dir: PathBuf) {
let model_path = dir.join(MODEL_FILENAME);
let vocab_path = dir.join(VOCAB_FILENAME);
let merges_path = dir.join(MERGES_FILENAME);
if model_path.exists() && vocab_path.exists() && merges_path.exists() {
if let Some(tc) = load_from_cache(dir).await {
info!("Local Qwen3-ASR transcriber loaded from cache");
set_transcriber_ready(tc);
return;
}
warn!("Local transcriber: cached files present but failed to load — re-downloading");
}
info!("Local transcriber: model not cached, downloading in background");
download_retry_loop().await;
}
fn try_lock_init() -> Option<bool> {
match STATE.load(Ordering::Acquire) {
ModelState::Ready => return Some(true),
ModelState::Uninit => {}
_ => return Some(false),
}
if !STATE.transition(ModelState::Uninit, ModelState::Loading) {
return Some(false);
}
None
}
pub async fn try_init_from_cache() -> bool {
if let Some(result) = try_lock_init() {
return result;
}
let Some(dir) = models_subdir(MODEL_DIR_NAME) else {
STATE.store(ModelState::Failed, Ordering::Release);
return false;
};
try_init_inner(dir).await
}
pub fn spawn_background_init() {
if let Some(result) = try_lock_init() {
debug!(result, "Local transcriber: background init skipped");
return;
}
let Some(dir) = models_subdir(MODEL_DIR_NAME) else {
warn!("Local transcriber: cannot resolve model directory (storage root not set)");
STATE.store(ModelState::Failed, Ordering::Release);
return;
};
tokio::spawn(async move {
let result = AssertUnwindSafe(init_background(dir)).catch_unwind().await;
if result.is_err() {
warn!("Local transcriber: background init panicked — marking failed");
STATE.store(ModelState::Failed, Ordering::Release);
}
});
}
pub fn spawn_background_init_if_enabled() {
let disabled = crate::config::CONFIG
.snapshot()
.audio_transcription_use_local
.as_deref()
== Some("false");
if disabled {
tracing::debug!(
"Local audio transcription is disabled by config — skipping background init"
);
return;
}
spawn_background_init();
}
pub fn is_loaded() -> bool {
STATE.is_ready()
}
pub fn is_failed() -> bool {
STATE.load(Ordering::Acquire) == ModelState::Failed
}
pub fn shared_model_arc() -> Option<Arc<qwen_asr::context::QwenModel>> {
SHARED_MODEL.lock().unwrap_poison().clone()
}
pub fn retry_init() -> bool {
if !STATE.transition(ModelState::Failed, ModelState::Uninit) {
return false;
}
spawn_background_init();
true
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_hex_string_empty() {
assert_eq!(crate::util::hex_string(b""), "");
}
#[test]
fn test_hex_string_all_bytes() {
let bytes: Vec<u8> = (0..=255u8).collect();
let hex = crate::util::hex_string(&bytes);
assert_eq!(hex.len(), 512);
assert!(hex.starts_with("00010203"));
assert!(hex.ends_with("fcfdfeff"));
}
fn write_synthetic_wav(
dir: &std::path::Path,
filename: &str,
sample_rate: u32,
) -> std::path::PathBuf {
let path = dir.join(filename);
let num_samples = sample_rate as usize;
let samples: Vec<f32> = (0..num_samples)
.map(|i| {
#[expect(clippy::cast_precision_loss)] let t = i as f32 / sample_rate as f32;
(t * 440.0 * 2.0 * std::f32::consts::PI).sin()
})
.collect();
let wav = crate::audio::tts::render_wav(&samples, sample_rate).unwrap();
std::fs::write(&path, wav).unwrap();
path
}
#[test]
fn test_decode_wav_mono_16k() {
let dir = tempfile::tempdir().unwrap();
let path = write_synthetic_wav(dir.path(), "test.wav", 16000);
let result = decode_audio_to_mono_f32(&path);
assert!(
result.is_ok(),
"Failed to decode 16 kHz WAV: {:?}",
result.err()
);
let samples = result.unwrap();
assert!(!samples.is_empty(), "Decoded samples should not be empty");
assert_eq!(
samples.len(),
16000,
"Expected 16000 samples for 1 second at 16 kHz"
);
for &s in &samples {
assert!((-1.0..=1.0).contains(&s), "Sample {s} out of range");
}
assert!(
(samples[0]).abs() < 0.01,
"First sample should be near 0 for sine starting at t=0"
);
}
#[test]
fn test_decode_wav_mono_48k_resampled() {
let dir = tempfile::tempdir().unwrap();
let path = write_synthetic_wav(dir.path(), "test48k.wav", 48000);
let result = decode_audio_to_mono_f32(&path);
assert!(
result.is_ok(),
"Failed to decode 48 kHz WAV: {:?}",
result.err()
);
let samples = result.unwrap();
assert!(!samples.is_empty(), "Decoded samples should not be empty");
assert!(
samples.len() >= 15500 && samples.len() <= 16500,
"Expected ~16000 samples after resampling from 48 kHz, got {}",
samples.len()
);
}
#[test]
fn test_decode_wav_empty_file() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("empty.wav");
std::fs::write(&path, b"").unwrap();
let result = decode_audio_to_mono_f32(&path);
assert!(result.is_err(), "Empty file should fail to decode");
}
#[test]
fn test_decode_wav_too_small() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("tiny.wav");
std::fs::write(&path, b"RIFF").unwrap();
let result = decode_audio_to_mono_f32(&path);
assert!(result.is_err(), "Truncated WAV should fail to decode");
}
#[test]
fn test_decode_unknown_extension() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("audio.xyz");
std::fs::write(&path, b"not an audio file").unwrap();
let result = decode_audio_to_mono_f32(&path);
assert!(result.is_err(), "Unknown extension should fail to decode");
}
#[test]
fn test_decode_no_extension() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("noext");
std::fs::write(&path, b"not an audio file").unwrap();
let result = decode_audio_to_mono_f32(&path);
assert!(
result.is_err(),
"File without extension should fail to decode"
);
}
fn create_test_opus_ogg() -> Vec<u8> {
use ogg::writing::{PacketWriteEndInfo, PacketWriter};
let mut buf = Vec::new();
let serial = 1u32;
{
let mut writer = PacketWriter::new(&mut buf);
let mut head = Vec::new();
head.extend_from_slice(b"OpusHead"); head.push(1); head.push(1); head.extend_from_slice(&0u16.to_le_bytes()); head.extend_from_slice(&48000u32.to_le_bytes()); head.extend_from_slice(&0u16.to_le_bytes()); head.push(0); writer
.write_packet(head, serial, PacketWriteEndInfo::EndPage, 0)
.unwrap();
let vendor = b"test";
let mut tags = Vec::new();
tags.extend_from_slice(b"OpusTags");
tags.extend_from_slice(&u32::try_from(vendor.len()).unwrap().to_le_bytes());
tags.extend_from_slice(vendor);
tags.extend_from_slice(&0u32.to_le_bytes()); writer
.write_packet(tags, serial, PacketWriteEndInfo::EndPage, 0)
.unwrap();
writer
.write_packet(b"", serial, PacketWriteEndInfo::EndStream, 0)
.unwrap();
}
buf
}
#[test]
fn test_decode_opus_ogg_valid_headers() {
let data = create_test_opus_ogg();
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("voice.ogg");
std::fs::write(&path, &data).unwrap();
let result = decode_audio_to_mono_f32(&path);
assert!(
result.is_err(),
"OGG/Opus with headers + silence should produce 'No audio decoded'"
);
let err = result.unwrap_err().to_string();
assert!(
err.contains("No audio decoded") || err.contains("decode"),
"Unexpected error: {err}"
);
}
#[test]
fn test_decode_opus_ogg_truncated() {
let data = create_test_opus_ogg();
let truncated = &data[..data.len().min(64)];
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("truncated.ogg");
std::fs::write(&path, truncated).unwrap();
let result = decode_audio_to_mono_f32(&path);
assert!(result.is_err(), "Truncated OGG/Opus should fail to decode");
}
#[test]
fn test_decode_opus_ogg_invalid_data() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("fake.ogg");
std::fs::write(&path, b"not an OGG file at all").unwrap();
let result = decode_audio_to_mono_f32(&path);
assert!(result.is_err(), "Invalid OGG data should fail to decode");
}
#[test]
fn test_decode_opus_ogg_no_opus_head() {
let data = b"OggS\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x01\x00\x00\x00\x00\x00\x00\x00\x00invalid";
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("nohead.ogg");
std::fs::write(&path, data).unwrap();
let result = decode_audio_to_mono_f32(&path);
assert!(
result.is_err(),
"OGG without OpusHead should fail to decode"
);
}
fn create_test_mp3_frame() -> Vec<u8> {
let mut frame = vec![0xFF, 0xE3, 0x18, 0xC0];
frame.resize(144, 0u8);
frame
}
#[test]
fn test_decode_mp3_valid_frame() {
let data = create_test_mp3_frame();
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("test.mp3");
std::fs::write(&path, &data).unwrap();
let result = decode_audio_to_mono_f32(&path);
if let Err(e) = &result {
assert!(
e.to_string().contains("No audio decoded") || e.to_string().contains("Unsupported"),
"Unexpected error: {e}"
);
}
}
#[test]
fn test_decode_mp3_empty() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("empty.mp3");
std::fs::write(&path, b"").unwrap();
let result = decode_audio_to_mono_f32(&path);
assert!(result.is_err(), "Empty MP3 should fail to decode");
}
#[test]
fn test_decode_mp3_truncated_header() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("truncated.mp3");
std::fs::write(&path, b"\xFF\xFB").unwrap();
let result = decode_audio_to_mono_f32(&path);
assert!(
result.is_err(),
"Truncated MP3 header should fail to decode"
);
}
#[test]
fn test_decode_mp3_non_mp3_data() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("fake.mp3");
std::fs::write(&path, b"this is not an mp3 file at all").unwrap();
let result = decode_audio_to_mono_f32(&path);
assert!(result.is_err(), "Non-MP3 data should fail to decode");
}
}