ferrum_cli/commands/
transcribe.rs1use crate::config::CliConfig;
4use candle_core::{DType, Device as CandleDevice};
5use clap::Args;
6use colored::Colorize;
7use ferrum_models::source::{ModelFormat, ResolvedModelSource};
8use ferrum_models::{ConfigManager, HfDownloader, WhisperModelExecutor};
9use ferrum_types::Result;
10use std::path::PathBuf;
11
12#[derive(Args, Debug)]
14pub struct TranscribeCommand {
15 #[arg(required = true)]
17 pub model: String,
18
19 #[arg(required = true)]
21 pub audio: String,
22
23 #[arg(short, long)]
25 pub language: Option<String>,
26
27 #[arg(short, long, default_value = "auto")]
29 pub backend: String,
30}
31
32pub async fn execute(cmd: TranscribeCommand, config: CliConfig) -> Result<()> {
33 let model_id = resolve_whisper_alias(&cmd.model);
34 let cache_dir = get_hf_cache_dir(&config);
35
36 eprintln!("{} {}", "Model:".dimmed(), model_id.cyan());
37
38 let source = match find_cached_model(&cache_dir, &model_id) {
40 Some(source) => source,
41 None => {
42 eprintln!(
43 "{} Model '{}' not found locally, downloading...",
44 "📥".cyan(),
45 model_id
46 );
47 let token = std::env::var("HF_TOKEN")
48 .or_else(|_| std::env::var("HUGGING_FACE_HUB_TOKEN"))
49 .ok();
50 let downloader = HfDownloader::new(cache_dir, token)?;
51 let snapshot_path = downloader.download(&model_id, None).await?;
52 let format = detect_format(&snapshot_path);
53 if format == ModelFormat::Unknown {
54 return Err(ferrum_types::FerrumError::model(
55 "Downloaded model has unknown format",
56 ));
57 }
58 ResolvedModelSource {
59 original: model_id.clone(),
60 local_path: snapshot_path,
61 format,
62 from_cache: false,
63 }
64 }
65 };
66
67 let mut config_manager = ConfigManager::new();
69 let model_def = config_manager.load_from_path(&source.local_path).await?;
70 if model_def.architecture != ferrum_models::Architecture::Whisper {
71 return Err(ferrum_types::FerrumError::model(format!(
72 "'{}' is not a Whisper model (detected: {:?})",
73 model_id, model_def.architecture
74 )));
75 }
76
77 let candle_device = select_candle_device(&cmd.backend)?;
78 eprintln!("{} {:?}", "Device:".dimmed(), &candle_device);
79 eprintln!("{}", "Loading Whisper model...".dimmed());
80 let executor = WhisperModelExecutor::from_path(
81 &source.local_path.to_string_lossy(),
82 candle_device,
83 DType::F32,
84 )?;
85 eprintln!("{}", "Model loaded.".green());
86
87 eprintln!("{} {}", "Audio:".dimmed(), cmd.audio.cyan());
89 let start = std::time::Instant::now();
90 let text = executor.transcribe_file(&cmd.audio, cmd.language.as_deref())?;
91 let elapsed = start.elapsed();
92
93 println!("{}", text);
94 eprintln!("\n{} {:.2}s", "Time:".dimmed(), elapsed.as_secs_f64());
95
96 Ok(())
97}
98
99fn resolve_whisper_alias(name: &str) -> String {
100 match name.to_lowercase().as_str() {
101 "whisper-tiny" | "whisper:tiny" => "openai/whisper-tiny".to_string(),
102 "whisper-base" | "whisper:base" => "openai/whisper-base".to_string(),
103 "whisper-small" | "whisper:small" => "openai/whisper-small".to_string(),
104 "whisper-medium" | "whisper:medium" => "openai/whisper-medium".to_string(),
105 "whisper-large-v3" | "whisper:large-v3" => "openai/whisper-large-v3".to_string(),
106 "whisper-turbo" | "whisper:turbo" | "whisper-large-v3-turbo" => {
107 "openai/whisper-large-v3-turbo".to_string()
108 }
109 _ => name.to_string(),
110 }
111}
112
113fn get_hf_cache_dir(config: &CliConfig) -> PathBuf {
114 if let Ok(hf_home) = std::env::var("HF_HOME") {
115 return PathBuf::from(hf_home);
116 }
117 let configured = shellexpand::tilde(&config.models.download.hf_cache_dir).to_string();
118 PathBuf::from(configured)
119}
120
121fn find_cached_model(cache_dir: &PathBuf, model_id: &str) -> Option<ResolvedModelSource> {
122 let hub_dir = cache_dir.join("hub");
123 let model_dir_name = format!("models--{}", model_id.replace("/", "--"));
124 let model_dir = hub_dir.join(&model_dir_name);
125
126 if model_dir.exists() {
127 let snapshots_dir = model_dir.join("snapshots");
128 if snapshots_dir.exists() {
129 let ref_main = model_dir.join("refs").join("main");
131 if let Ok(rev) = std::fs::read_to_string(&ref_main) {
132 let rev = rev.trim();
133 if !rev.is_empty() {
134 let snapshot = snapshots_dir.join(rev);
135 if snapshot.exists() {
136 let format = detect_format(&snapshot);
137 if format != ModelFormat::Unknown {
138 return Some(ResolvedModelSource {
139 original: model_id.to_string(),
140 local_path: snapshot,
141 format,
142 from_cache: true,
143 });
144 }
145 }
146 }
147 }
148 if let Ok(entries) = std::fs::read_dir(&snapshots_dir) {
150 for entry in entries.flatten() {
151 let path = entry.path();
152 if path.is_dir() && path.join("config.json").exists() {
153 let format = detect_format(&path);
154 if format != ModelFormat::Unknown {
155 return Some(ResolvedModelSource {
156 original: model_id.to_string(),
157 local_path: path,
158 format,
159 from_cache: true,
160 });
161 }
162 }
163 }
164 }
165 }
166 }
167
168 None
169}
170
171pub fn select_candle_device(backend: &str) -> ferrum_types::Result<CandleDevice> {
172 match backend.to_lowercase().as_str() {
173 "cpu" => Ok(CandleDevice::Cpu),
174 "metal" => {
175 #[cfg(all(target_os = "macos", feature = "metal"))]
176 {
177 return CandleDevice::new_metal(0)
178 .map_err(|error| ferrum_types::FerrumError::device(error.to_string()));
179 }
180 #[allow(unreachable_code)]
181 {
182 Err(ferrum_types::FerrumError::unsupported(
183 "Metal transcription requires a Metal-enabled Ferrum build",
184 ))
185 }
186 }
187 "cuda" => {
188 #[cfg(feature = "candle-cuda-compat")]
189 {
190 return CandleDevice::new_cuda(0)
191 .map_err(|error| ferrum_types::FerrumError::device(error.to_string()));
192 }
193 #[allow(unreachable_code)]
194 {
195 Err(ferrum_types::FerrumError::unsupported(
196 "CUDA transcription requires the candle-cuda-compat feature",
197 ))
198 }
199 }
200 "auto" | _ => {
201 #[cfg(feature = "candle-cuda-compat")]
202 {
203 if let Ok(d) = CandleDevice::new_cuda(0) {
204 return Ok(d);
205 }
206 }
207 #[cfg(all(target_os = "macos", feature = "metal"))]
208 {
209 if let Ok(device) = CandleDevice::new_metal(0) {
210 return Ok(device);
211 }
212 }
213 #[allow(unreachable_code)]
214 Ok(CandleDevice::Cpu)
215 }
216 }
217}
218
219fn detect_format(path: &PathBuf) -> ModelFormat {
220 if path.join("model.safetensors").exists() {
221 ModelFormat::SafeTensors
222 } else if std::fs::read_dir(path)
223 .map(|d| {
224 d.filter_map(|e| e.ok())
225 .any(|e| e.path().extension().is_some_and(|ext| ext == "safetensors"))
226 })
227 .unwrap_or(false)
228 {
229 ModelFormat::SafeTensors
230 } else if path.join("pytorch_model.bin").exists() {
231 ModelFormat::PyTorchBin
232 } else {
233 ModelFormat::Unknown
234 }
235}