#[cfg(feature = "transcription")]
use std::path::{Path, PathBuf};
#[cfg(feature = "transcription")]
use crate::core::config::transcription::WhisperModel;
#[cfg(feature = "transcription")]
#[cfg_attr(alef, alef(skip))]
#[derive(Debug, Clone)]
pub struct WhisperModelPaths {
pub encoder: PathBuf,
pub decoder: PathBuf,
pub decoder_with_past: PathBuf,
pub tokenizer: PathBuf,
pub config: PathBuf,
pub n_mels: u32,
}
#[cfg(feature = "transcription")]
#[derive(Debug, thiserror::Error)]
#[cfg_attr(alef, alef(skip))]
pub enum WhisperModelError {
#[error("network access disabled and model not cached: {0}")]
ModelMissing(String),
#[error("hf-hub download failed: {0}")]
Download(String),
#[error("io error: {0}")]
Io(#[from] std::io::Error),
#[error("cache directory unavailable: {0}")]
Cache(String),
#[error("hash verification is unavailable because Whisper checksums are not bundled")]
HashVerificationUnavailable,
}
#[cfg(feature = "transcription")]
pub(crate) fn hf_repo(model: WhisperModel) -> &'static str {
match model {
WhisperModel::Tiny => "onnx-community/whisper-tiny",
WhisperModel::Base => "onnx-community/whisper-base",
WhisperModel::Small => "onnx-community/whisper-small",
WhisperModel::Medium => "Xenova/whisper-medium",
WhisperModel::LargeV3 => "Xenova/whisper-large-v3",
}
}
#[cfg(feature = "transcription")]
pub(crate) fn hf_revision(model: WhisperModel) -> &'static str {
match model {
WhisperModel::Tiny => "ff4177021cc41f7db950912b73ea4fdf7d01d8e7",
WhisperModel::Base => "1846881b6b3a3024392c1eea3ad983695bc23925",
WhisperModel::Small => "36050c46d777d46dc4b5f43f6d90574fc38f8732",
WhisperModel::Medium => "8c5b90880ab9f79487ab33613413431bf661d595",
WhisperModel::LargeV3 => "67bf02d92b7754a1ff82a7f8545f8b8c378b2ef0",
}
}
#[cfg(feature = "transcription")]
pub(crate) fn n_mels(model: WhisperModel) -> u32 {
match model {
WhisperModel::LargeV3 => 128,
_ => 80,
}
}
#[cfg(feature = "transcription")]
fn is_sharded(model: WhisperModel) -> bool {
matches!(
model,
WhisperModel::Small | WhisperModel::Medium | WhisperModel::LargeV3
)
}
#[cfg(feature = "transcription")]
fn has_external_data_shard(model: WhisperModel) -> bool {
matches!(model, WhisperModel::LargeV3)
}
#[cfg(feature = "transcription")]
fn model_files(model: WhisperModel) -> Vec<(&'static str, &'static str)> {
if is_sharded(model) {
let mut files = vec![
("onnx/encoder_model.onnx", "encoder.onnx"),
("onnx/decoder_model_merged.onnx", "decoder.onnx"),
];
if has_external_data_shard(model) {
files.push(("onnx/decoder_model_merged.onnx_data", "decoder.onnx_data"));
}
files.push(("tokenizer.json", "tokenizer.json"));
files.push(("config.json", "config.json"));
files
} else {
vec![
("onnx/encoder_model.onnx", "encoder.onnx"),
("onnx/decoder_model.onnx", "decoder.onnx"),
("onnx/decoder_with_past_model.onnx", "decoder_with_past.onnx"),
("tokenizer.json", "tokenizer.json"),
("config.json", "config.json"),
]
}
}
#[cfg(feature = "transcription")]
#[cfg_attr(alef, alef(skip))]
pub fn ensure_whisper_model(
model: WhisperModel,
cache_dir: Option<&Path>,
allow_network: bool,
verify_hash: bool,
) -> Result<WhisperModelPaths, WhisperModelError> {
if verify_hash {
return Err(WhisperModelError::HashVerificationUnavailable);
}
let revision = hf_revision(model);
let mut resolved = std::collections::HashMap::new();
for (remote_path, local_name) in model_files(model) {
let path = if allow_network {
crate::model_download::hf_resolve_file(hf_repo(model), remote_path, Some(revision), cache_dir, None)
.map_err(WhisperModelError::Download)?
} else {
crate::model_download::hf_cached_file(hf_repo(model), remote_path, Some(revision), cache_dir)
.map_err(WhisperModelError::Download)?
.ok_or_else(|| WhisperModelError::ModelMissing(format!("{}@{revision}", hf_repo(model))))?
};
resolved.insert(local_name, path);
}
build_paths(model, resolved)
}
#[cfg(feature = "transcription")]
fn build_paths(
model: WhisperModel,
mut resolved: std::collections::HashMap<&'static str, PathBuf>,
) -> Result<WhisperModelPaths, WhisperModelError> {
let mut take = |name: &str| {
resolved
.remove(name)
.ok_or_else(|| WhisperModelError::Cache(format!("resolved Whisper snapshot is missing {name}")))
};
let encoder = take("encoder.onnx")?;
let tokenizer = take("tokenizer.json")?;
let config = take("config.json")?;
let (decoder, decoder_with_past) = if is_sharded(model) {
let merged = take("decoder.onnx")?;
(merged.clone(), merged)
} else {
(take("decoder.onnx")?, take("decoder_with_past.onnx")?)
};
Ok(WhisperModelPaths {
encoder,
decoder,
decoder_with_past,
tokenizer,
config,
n_mels: n_mels(model),
})
}
#[cfg(test)]
mod tests {
use super::*;
#[cfg(feature = "transcription")]
use crate::core::config::transcription::WhisperModel;
#[cfg(feature = "transcription")]
fn resolved_fixture(model: WhisperModel, directory: &Path) -> std::collections::HashMap<&'static str, PathBuf> {
model_files(model)
.into_iter()
.map(|(_, local_name)| (local_name, directory.join(local_name)))
.collect()
}
#[cfg(feature = "transcription")]
#[test]
fn hf_repo_points_at_published_onnx_exports() {
assert_eq!(hf_repo(WhisperModel::Tiny), "onnx-community/whisper-tiny");
assert_eq!(hf_repo(WhisperModel::Base), "onnx-community/whisper-base");
assert_eq!(hf_repo(WhisperModel::Small), "onnx-community/whisper-small");
assert_eq!(hf_repo(WhisperModel::Medium), "Xenova/whisper-medium");
assert_eq!(hf_repo(WhisperModel::LargeV3), "Xenova/whisper-large-v3");
}
#[cfg(feature = "transcription")]
#[test]
fn only_large_v3_uses_external_data_shard() {
assert!(!has_external_data_shard(WhisperModel::Small));
assert!(!has_external_data_shard(WhisperModel::Medium));
assert!(has_external_data_shard(WhisperModel::LargeV3));
assert!(
!model_files(WhisperModel::Small)
.iter()
.any(|(remote, _)| remote.ends_with(".onnx_data"))
);
assert!(
model_files(WhisperModel::LargeV3)
.iter()
.any(|(remote, _)| remote.ends_with(".onnx_data"))
);
}
#[cfg(feature = "transcription")]
#[test]
fn n_mels_is_128_only_for_large_v3() {
for model in [
WhisperModel::Tiny,
WhisperModel::Base,
WhisperModel::Small,
WhisperModel::Medium,
] {
assert_eq!(n_mels(model), 80, "{model:?} should have 80 mels");
}
assert_eq!(n_mels(WhisperModel::LargeV3), 128);
}
#[cfg(feature = "transcription")]
#[test]
fn ensure_model_returns_missing_when_network_disabled_and_uncached() {
let tmp = tempfile::tempdir().expect("tempdir");
let result = ensure_whisper_model(WhisperModel::Tiny, Some(tmp.path()), false, false);
assert!(
matches!(result, Err(WhisperModelError::ModelMissing(_))),
"expected ModelMissing, got: {result:?}",
);
}
#[cfg(feature = "transcription")]
#[test]
fn verify_hash_requests_fail_fast() {
let tmp = tempfile::tempdir().expect("tempdir");
let result = ensure_whisper_model(WhisperModel::Tiny, Some(tmp.path()), false, true);
assert!(
matches!(result, Err(WhisperModelError::HashVerificationUnavailable)),
"expected HashVerificationUnavailable, got: {result:?}",
);
}
#[cfg(feature = "transcription")]
#[test]
fn sharded_models_use_merged_decoder() {
let tmp = tempfile::tempdir().expect("tempdir");
let paths = build_paths(WhisperModel::Small, resolved_fixture(WhisperModel::Small, tmp.path()))
.expect("fixture contains all paths");
assert_eq!(
paths.decoder, paths.decoder_with_past,
"sharded model: decoder and decoder_with_past must point at the merged file",
);
assert_eq!(paths.n_mels, 80);
}
#[cfg(feature = "transcription")]
#[test]
fn non_sharded_models_have_distinct_decoder_files() {
let tmp = tempfile::tempdir().expect("tempdir");
let paths = build_paths(WhisperModel::Tiny, resolved_fixture(WhisperModel::Tiny, tmp.path()))
.expect("fixture contains all paths");
assert_ne!(
paths.decoder, paths.decoder_with_past,
"non-sharded model: decoder and decoder_with_past must be distinct files",
);
assert_eq!(paths.n_mels, 80);
}
#[cfg(feature = "transcription")]
#[test]
fn large_v3_uses_128_mels_from_cached_paths() {
let tmp = tempfile::tempdir().expect("tempdir");
let paths = build_paths(
WhisperModel::LargeV3,
resolved_fixture(WhisperModel::LargeV3, tmp.path()),
)
.expect("fixture contains all paths");
assert_eq!(paths.n_mels, 128);
}
}