mod admission;
pub mod decode;
#[cfg(target_os = "macos")]
pub mod metal;
#[cfg(test)]
mod tests;
pub mod weights;
pub use decode::{StopReason, WindowDecoder};
use std::path::Path;
use std::sync::Mutex;
use audio::whisper::{whisper_log_mel_window, WHISPER_SAMPLE_RATE, WHISPER_WINDOW_SAMPLES};
use compute::vision::layer_norm;
use compute::whisper::{
whisper_conv_frontend, whisper_encoder_layer, WhisperConvWeights, WhisperEncoderLayerWeights,
};
use model_io::safetensors::SafetensorsFile;
use model_io::speech_family::SpeechFamily;
use model_io::whisper_config::{WhisperConfig, WhisperSpecialTokens};
use tokenizer::Tokenizer;
use self::weights::WhisperWeights;
#[derive(Debug, Clone, PartialEq)]
pub struct WhisperSegment {
pub index: usize,
pub start_seconds: f64,
pub end_seconds: f64,
pub text: String,
}
#[derive(Debug, Clone, PartialEq)]
pub struct WhisperTranscription {
pub segments: Vec<WhisperSegment>,
pub language: String,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum WhisperProgress {
Window {
index: usize,
total: usize,
},
Token {
window: usize,
emitted: usize,
budget: usize,
},
}
pub struct WhisperRunner {
pub config: WhisperConfig,
pub tokens: WhisperSpecialTokens,
pub weights: WhisperWeights,
pub tokenizer: Option<Tokenizer>,
pub(crate) metal: Option<Mutex<metal::WhisperMetalEngine>>,
}
pub(crate) const WINDOW_STRIDE_SAMPLES: usize =
WHISPER_WINDOW_SAMPLES - WHISPER_SAMPLE_RATE as usize;
pub(crate) fn whisper_profile() -> bool {
std::env::var("TURBOSPARK_WHISPER_PROFILE").as_deref() == Ok("1")
}
impl WhisperRunner {
pub fn transcribe_with_progress(
&self,
pcm: &[f32],
language: Option<&str>,
cancelled: &dyn Fn() -> bool,
mut progress: impl FnMut(WhisperProgress) -> bool,
) -> Result<WhisperTranscription, String> {
self.transcribe_controlled(pcm, language, cancelled, &mut progress)
}
pub fn open(model_dir: &Path) -> Result<Self, String> {
Self::open_with_budget(model_dir, admission::host_budget)
}
fn open_with_budget(
model_dir: &Path,
available: impl Fn() -> Result<u64, String>,
) -> Result<Self, String> {
static OPEN_LOCK: Mutex<()> = Mutex::new(());
let _open = OPEN_LOCK.lock().map_err(|_| "Whisper open lock poisoned")?;
let config_path = model_dir.join("config.json");
let config_bytes = admission::read_bounded(&config_path, admission::CONFIG_LIMIT)?;
let config_str = std::str::from_utf8(&config_bytes).map_err(|e| e.to_string())?;
let config = WhisperConfig::from_json_str(config_str)
.map_err(|e| format!("failed to parse {}: {e}", config_path.display()))?;
admission::admit(&config, 0, available()?)?;
let receipt = admission::receipt(model_dir, &config)?;
let checkpoint_bytes = receipt.files["model.safetensors"].size;
admission::admit(&config, checkpoint_bytes, available()?)?;
admission::verify_bytes(&receipt, "config.json", &config_bytes)?;
let tokenizer_path = model_dir.join("tokenizer.json");
let tokenizer_bytes = admission::read_bounded(&tokenizer_path, admission::TOKENIZER_LIMIT)?;
admission::verify_bytes(&receipt, "tokenizer.json", &tokenizer_bytes)?;
let tokenizer = Tokenizer::from_bytes(&tokenizer_bytes)
.map_err(|e| format!("failed to load tokenizer.json: {e}"))?;
let tokenizer_str = tokenizer
.to_string(false)
.map_err(|e| format!("failed to serialize loaded tokenizer: {e}"))?;
let tokens = WhisperSpecialTokens::from_tokenizer_json(&tokenizer_str)
.map_err(|e| format!("tokenizer.json is not a whisper tokenizer: {e}"))?;
tokens
.validate_config(&config)
.map_err(|e| format!("tokenizer/config mismatch: {e}"))?;
if tokenizer
.get_vocab(true)
.values()
.any(|&id| id as usize >= config.vocab_size)
{
return Err("loaded tokenizer ID exceeds config vocab_size".to_string());
}
let safetensors_path = model_dir.join("model.safetensors");
let file = SafetensorsFile::open_with_header_limit(&safetensors_path, 1 << 20)
.map_err(|e| format!("failed to open {}: {e}", safetensors_path.display()))?;
file.verify_sha256(&receipt.files["model.safetensors"].sha256)
.map_err(|e| format!("speech receipt model.safetensors: {e}"))?;
admission::admit(&config, checkpoint_bytes, available()?)?;
let weights = WhisperWeights::load_from_safetensors(&file, &config)
.map_err(|e| format!("failed to load whisper weights: {e}"))?;
let metal = Self::try_metal(&weights, &config);
Ok(Self {
config,
tokens,
weights,
tokenizer: Some(tokenizer),
metal,
})
}
fn try_metal(
weights: &WhisperWeights,
config: &WhisperConfig,
) -> Option<Mutex<metal::WhisperMetalEngine>> {
if std::env::var("TURBOSPARK_WHISPER_DEVICE").as_deref() == Ok("cpu") {
return None;
}
match metal::WhisperMetalEngine::new(weights, config) {
Ok(engine) => Some(Mutex::new(engine)),
Err(_) => None,
}
}
pub fn using_metal(&self) -> bool {
self.metal.is_some()
}
pub fn from_parts(
config: WhisperConfig,
tokens: WhisperSpecialTokens,
weights: WhisperWeights,
) -> Self {
Self {
config,
tokens,
weights,
tokenizer: None,
metal: None,
}
}
pub fn encode_window(&self, mel_frame_major: &[Vec<f32>]) -> Result<Vec<f32>, String> {
let n_mels = self.config.n_mels;
let frames = mel_frame_major.len();
if frames == 0 || mel_frame_major.iter().any(|frame| frame.len() != n_mels) {
return Err(format!("mel window must carry {n_mels} bands per frame"));
}
let mut mel = vec![0.0f32; n_mels * frames];
for (t, frame) in mel_frame_major.iter().enumerate() {
for (b, &v) in frame.iter().enumerate() {
mel[b * frames + t] = v;
}
}
let d = self.config.d_model;
let conv = WhisperConvWeights {
conv1_weight: &self.weights.conv1,
conv1_bias: &self.weights.conv1_bias,
conv2_weight: &self.weights.conv2,
conv2_bias: &self.weights.conv2_bias,
};
let conv_out = whisper_conv_frontend(&mel, n_mels, frames, &conv, d);
let seq = conv_out.len() / d;
if seq != self.config.max_source_positions {
return Err(format!(
"conv front end produced {seq} positions, config says {}",
self.config.max_source_positions
));
}
let mut hidden = vec![0.0f32; seq * d];
for t in 0..seq {
for i in 0..d {
hidden[t * d + i] = conv_out[i * seq + t] + self.weights.enc_positions[t * d + i];
}
}
for layer in &self.weights.enc_layers {
let w = WhisperEncoderLayerWeights {
q_weight: &layer.q,
q_bias: &layer.q_bias,
k_weight: &layer.k,
v_weight: &layer.v,
v_bias: &layer.v_bias,
out_weight: &layer.out,
out_bias: &layer.out_bias,
ln1_weight: &layer.ln1_weight,
ln1_bias: &layer.ln1_bias,
fc1_weight: &layer.fc1,
fc1_bias: &layer.fc1_bias,
fc2_weight: &layer.fc2,
fc2_bias: &layer.fc2_bias,
ln2_weight: &layer.ln2_weight,
ln2_bias: &layer.ln2_bias,
};
hidden = whisper_encoder_layer(
&hidden,
seq,
&w,
d,
self.config.num_attention_heads,
self.config.encoder_ffn_dim,
self.config.layer_norm_eps,
);
}
let mut out = vec![0.0f32; seq * d];
for t in 0..seq {
out[t * d..(t + 1) * d].copy_from_slice(&layer_norm(
&hidden[t * d..(t + 1) * d],
&self.weights.enc_ln_weight,
&self.weights.enc_ln_bias,
self.config.layer_norm_eps,
));
}
Ok(out)
}
pub(crate) fn logits(&self, hidden: &[f32]) -> Result<Vec<f32>, String> {
let d = self.config.d_model;
if hidden.len() != d {
return Err(format!(
"decoder stream row is {} wide, config says {d}",
hidden.len()
));
}
let normed = layer_norm(
hidden,
&self.weights.dec_ln_weight,
&self.weights.dec_ln_bias,
self.config.layer_norm_eps,
);
let vocab = self.config.vocab_size;
let mut out = Vec::with_capacity(vocab);
for v in 0..vocab {
let row = &self.weights.embed_tokens[v * d..(v + 1) * d];
let acc: f64 = normed
.iter()
.zip(row)
.map(|(&a, &b)| f64::from(a) * f64::from(b))
.sum();
out.push(acc as f32);
}
Ok(out)
}
pub(crate) fn language_token(
&self,
language: Option<&str>,
encoder_out: Option<&[f32]>,
seq: usize,
) -> Result<u32, String> {
match language {
Some(code) if !code.is_empty() => {
let needle = format!("<|{code}|>");
let tokenizer = self
.tokenizer
.as_ref()
.ok_or_else(|| "language selection needs a tokenizer".to_string())?;
for (id, token) in tokenizer.get_added_tokens_decoder() {
if token.content == needle && self.tokens.is_language(id) {
return Ok(id);
}
}
Err(format!(
"language {code:?} has no token in this model's vocabulary"
))
}
_ => {
let encoder_out = encoder_out
.ok_or_else(|| "language auto-detection needs an encoded window".to_string())?;
decode::detect_language(self, encoder_out, seq)
}
}
}
pub fn transcribe(
&self,
pcm: &[f32],
language: Option<&str>,
) -> Result<WhisperTranscription, String> {
self.transcribe_cancellable(pcm, language, &|| false)
}
pub fn transcribe_cancellable(
&self,
pcm: &[f32],
language: Option<&str>,
cancelled: &dyn Fn() -> bool,
) -> Result<WhisperTranscription, String> {
self.transcribe_controlled(pcm, language, cancelled, &mut |_| true)
}
fn transcribe_controlled(
&self,
pcm: &[f32],
language: Option<&str>,
cancelled: &dyn Fn() -> bool,
progress: &mut dyn FnMut(WhisperProgress) -> bool,
) -> Result<WhisperTranscription, String> {
if cancelled() {
return Err("audio job cancelled".into());
}
if pcm.is_empty() {
return Err("pcm is empty".to_string());
}
if pcm.iter().any(|sample| !sample.is_finite()) {
return Err("pcm samples must be finite".to_string());
}
if !self.tokens.multilingual
&& language.is_some_and(|code| !matches!(code, "" | "auto" | "en"))
{
return Err("English-only Whisper models require language en or auto".to_string());
}
if let Some(engine) = &self.metal {
let mut engine = engine.lock().expect("whisper Metal engine lock");
return self.transcribe_metal(&mut engine, pcm, language, cancelled, progress);
}
let n_mels = self.config.n_mels;
let mut segments: Vec<WhisperSegment> = Vec::new();
let mut chosen_language: Option<u32> = None;
let mut window_start = 0usize;
while window_start < pcm.len() {
if cancelled()
|| !progress(WhisperProgress::Window {
index: window_start / WINDOW_STRIDE_SAMPLES,
total: pcm
.len()
.saturating_sub(WHISPER_WINDOW_SAMPLES)
.div_ceil(WINDOW_STRIDE_SAMPLES)
+ 1,
})
{
return Err("audio job cancelled".into());
}
let window_len = (pcm.len() - window_start).min(WHISPER_WINDOW_SAMPLES);
let mut window = vec![0.0f32; WHISPER_WINDOW_SAMPLES];
window[..window_len].copy_from_slice(&pcm[window_start..window_start + window_len]);
let mel = whisper_log_mel_window(&window, n_mels)
.map_err(|e| format!("mel frontend failed: {e}"))?;
let encoder_out = self.encode_window(&mel)?;
let seq = encoder_out.len() / self.config.d_model;
let language_token = match chosen_language {
Some(token) => token,
None => match language {
_ if !self.tokens.multilingual => 0,
Some(code) if !code.is_empty() && code != "auto" => {
self.language_token(Some(code), None, seq)?
}
_ => self.language_token(None, Some(&encoder_out), seq)?,
},
};
chosen_language = Some(language_token);
let prompt = decode::build_prompt(&self.tokens, language_token);
let decoder = WindowDecoder::new(self, &encoder_out, seq, &prompt)?;
let decoded = decoder.decode_with_progress(cancelled, &mut |emitted, budget| {
progress(WhisperProgress::Token {
window: window_start / WINDOW_STRIDE_SAMPLES,
emitted,
budget,
})
})?;
let start_seconds = window_start as f64 / f64::from(WHISPER_SAMPLE_RATE);
let exclusive_end = if window_start + WHISPER_WINDOW_SAMPLES < pcm.len() {
window_start + WINDOW_STRIDE_SAMPLES
} else {
window_start + window_len
};
let end_seconds = exclusive_end as f64 / f64::from(WHISPER_SAMPLE_RATE);
let text = match &self.tokenizer {
Some(tokenizer) => tokenizer
.decode(&decoded.text_tokens, true)
.map_err(|error| format!("transcript token decoding failed: {error}"))?,
None => String::new(),
};
if !text.trim().is_empty() {
segments.push(WhisperSegment {
index: segments.len(),
start_seconds,
end_seconds,
text: text.trim().to_string(),
});
}
if window_start + WHISPER_WINDOW_SAMPLES >= pcm.len() {
break;
}
window_start += WINDOW_STRIDE_SAMPLES;
}
let language = if !self.tokens.multilingual {
"en".to_string()
} else {
self.language_code(chosen_language.ok_or("no window decoded")?)
};
Ok(WhisperTranscription { segments, language })
}
fn transcribe_metal(
&self,
engine: &mut metal::WhisperMetalEngine,
pcm: &[f32],
language: Option<&str>,
cancelled: &dyn Fn() -> bool,
progress: &mut dyn FnMut(WhisperProgress) -> bool,
) -> Result<WhisperTranscription, String> {
let n_mels = self.config.n_mels;
let mut segments: Vec<WhisperSegment> = Vec::new();
let mut chosen_language: Option<u32> = None;
let mut window_start = 0usize;
while window_start < pcm.len() {
if cancelled()
|| !progress(WhisperProgress::Window {
index: window_start / WINDOW_STRIDE_SAMPLES,
total: pcm
.len()
.saturating_sub(WHISPER_WINDOW_SAMPLES)
.div_ceil(WINDOW_STRIDE_SAMPLES)
+ 1,
})
{
return Err("audio job cancelled".into());
}
let window_len = (pcm.len() - window_start).min(WHISPER_WINDOW_SAMPLES);
let mut window = vec![0.0f32; WHISPER_WINDOW_SAMPLES];
window[..window_len].copy_from_slice(&pcm[window_start..window_start + window_len]);
let profile = whisper_profile();
let phase_started = std::time::Instant::now();
let mel = whisper_log_mel_window(&window, n_mels)
.map_err(|e| format!("mel frontend failed: {e}"))?;
let frames = mel.len();
let mut mel_band_major = vec![0.0f32; n_mels * frames];
for (t, frame) in mel.iter().enumerate() {
for (b, &v) in frame.iter().enumerate() {
mel_band_major[b * frames + t] = v;
}
}
let mel_ms = phase_started.elapsed().as_secs_f64() * 1e3;
let encode_started = std::time::Instant::now();
engine.encode(&mel_band_major, frames)?;
let encode_ms = encode_started.elapsed().as_secs_f64() * 1e3;
let cross_started = std::time::Instant::now();
engine.build_cross_caches()?;
let cross_ms = cross_started.elapsed().as_secs_f64() * 1e3;
let language_token = match chosen_language {
Some(token) => token,
None => match language {
_ if !self.tokens.multilingual => 0,
Some(code) if !code.is_empty() && code != "auto" => {
self.language_token(Some(code), None, self.config.max_source_positions)?
}
_ => metal::detect_language_metal(
engine,
&self.weights.embed_tokens,
&self.weights.dec_positions,
&self.tokens,
)?,
},
};
chosen_language = Some(language_token);
let prompt = decode::build_prompt(&self.tokens, language_token);
let decode_started = std::time::Instant::now();
let decoder = metal::MetalWindowDecoder::new(
engine,
&self.weights.embed_tokens,
&self.weights.dec_positions,
self.tokens,
&prompt,
)?;
let decoded = decoder.decode_with_progress(cancelled, &mut |emitted, budget| {
progress(WhisperProgress::Token {
window: window_start / WINDOW_STRIDE_SAMPLES,
emitted,
budget,
})
})?;
if profile {
let decode_ms = decode_started.elapsed().as_secs_f64() * 1e3;
eprintln!(
"[whisper] window at {:.2}s: mel {mel_ms:.1} ms, encode {encode_ms:.1} ms, cross {cross_ms:.1} ms, decode {decode_ms:.1} ms ({} tokens)",
window_start as f64 / f64::from(WHISPER_SAMPLE_RATE),
decoded.text_tokens.len()
);
}
let start_seconds = window_start as f64 / f64::from(WHISPER_SAMPLE_RATE);
let exclusive_end = if window_start + WHISPER_WINDOW_SAMPLES < pcm.len() {
window_start + WINDOW_STRIDE_SAMPLES
} else {
window_start + window_len
};
let end_seconds = exclusive_end as f64 / f64::from(WHISPER_SAMPLE_RATE);
let text = match &self.tokenizer {
Some(tokenizer) => tokenizer
.decode(&decoded.text_tokens, true)
.map_err(|error| format!("transcript token decoding failed: {error}"))?,
None => String::new(),
};
if !text.trim().is_empty() {
segments.push(WhisperSegment {
index: segments.len(),
start_seconds,
end_seconds,
text: text.trim().to_string(),
});
}
if window_start + WHISPER_WINDOW_SAMPLES >= pcm.len() {
break;
}
window_start += WINDOW_STRIDE_SAMPLES;
}
let language = if !self.tokens.multilingual {
"en".to_string()
} else {
self.language_code(chosen_language.ok_or("no window decoded")?)
};
if whisper_profile() {
match gpu::dispatch_profile_report(1) {
Some(report) => eprintln!("{report}"),
None => eprintln!("[whisper] dispatch profile: no samples"),
}
}
Ok(WhisperTranscription { segments, language })
}
pub(crate) fn language_code(&self, token: u32) -> String {
if let Some(tokenizer) = self.tokenizer.as_ref() {
if let Some(added) = tokenizer.get_added_tokens_decoder().get(&token) {
let inner = added
.content
.strip_prefix("<|")
.and_then(|s| s.strip_suffix("|>"));
if let Some(code) = inner {
return code.to_string();
}
}
}
"en".to_string()
}
pub fn family() -> SpeechFamily {
SpeechFamily::Whisper
}
}