use async_trait::async_trait;
use ndarray::{s, Array3, Array4, ArrayD};
use ort::session::Session;
use ort::value::Value;
use serde::Deserialize;
use std::path::PathBuf;
use std::sync::{Arc, Mutex};
use super::mel::{self, WhisperMel};
use super::tokenizer::WhisperTokenizer;
use crate::router::{AdapterRequest, AsrRuntimeFactory, ModelSource, RouterError};
use crate::traits::{AsrAdapter, AsrError};
use crate::types::{AudioChunk, Transcript};
const N_DECODER_LAYERS: usize = 4;
const MAX_NEW_TOKENS: usize = 440;
#[derive(Debug, Clone, Default)]
pub struct WhisperOnnxConfig {
pub language: Option<String>,
pub encoder_file: Option<String>,
pub decoder_file: Option<String>,
pub decoder_with_past_file: Option<String>,
}
impl WhisperOnnxConfig {
fn encoder_filename(&self) -> &str {
self.encoder_file
.as_deref()
.unwrap_or("encoder_model_q4.onnx")
}
fn decoder_filename(&self) -> &str {
self.decoder_file
.as_deref()
.unwrap_or("decoder_model_q4.onnx")
}
fn decoder_with_past_filename(&self) -> &str {
self.decoder_with_past_file
.as_deref()
.unwrap_or("decoder_with_past_model_q4.onnx")
}
}
pub struct WhisperOnnxAdapter {
encoder: Mutex<Session>,
decoder: Mutex<Session>,
decoder_with_past: Mutex<Session>,
decoder_output_names: Vec<String>,
decoder_past_output_names: Vec<String>,
mel: WhisperMel,
tokenizer: WhisperTokenizer,
cfg: WhisperOnnxConfig,
#[allow(dead_code)]
model_dir: PathBuf,
}
impl WhisperOnnxAdapter {
pub fn load(model_dir: impl Into<PathBuf>) -> Result<Self, AsrError> {
Self::load_with_config(model_dir, WhisperOnnxConfig::default())
}
pub fn load_with_config(
model_dir: impl Into<PathBuf>,
cfg: WhisperOnnxConfig,
) -> Result<Self, AsrError> {
let dir = model_dir.into();
let enc_path = dir.join("onnx").join(cfg.encoder_filename());
let dec_path = dir.join("onnx").join(cfg.decoder_filename());
let dec_past_path = dir.join("onnx").join(cfg.decoder_with_past_filename());
let encoder = build_session(&enc_path, "encoder")?;
let decoder = build_session(&dec_path, "decoder")?;
let decoder_with_past = build_session(&dec_past_path, "decoder_with_past")?;
let decoder_output_names: Vec<String> = decoder
.outputs()
.iter()
.map(|o| o.name().to_string())
.collect();
let decoder_past_output_names: Vec<String> = decoder_with_past
.outputs()
.iter()
.map(|o| o.name().to_string())
.collect();
let tokenizer = WhisperTokenizer::load(&dir)?;
Ok(Self {
encoder: Mutex::new(encoder),
decoder: Mutex::new(decoder),
decoder_with_past: Mutex::new(decoder_with_past),
decoder_output_names,
decoder_past_output_names,
mel: WhisperMel::new(),
tokenizer,
cfg,
model_dir: dir,
})
}
pub fn with_language(mut self, lang: impl Into<String>) -> Self {
self.cfg.language = Some(lang.into());
self
}
pub fn transcribe_samples(&self, samples: &[f32]) -> Result<String, AsrError> {
let mel_data = self.mel.compute(samples);
let input_features = Array3::from_shape_vec((1, mel::N_MELS, mel::N_FRAMES), mel_data)
.map_err(|e| AsrError::Inference(format!("whisper-onnx mel reshape: {e}")))?;
let enc_value = Value::from_array(input_features).map_err(|e| AsrError::Inference(format!("whisper-onnx input_features Value: {e}")))?;
let encoder_hidden = {
let mut session = self.encoder.lock().map_err(|e| AsrError::Inference(format!("whisper-onnx encoder lock poisoned: {e}")))?;
let outputs = session
.run(vec![("input_features", enc_value.into_dyn())])
.map_err(|e| AsrError::Inference(format!("whisper-onnx encoder run: {e}")))?;
outputs[0]
.try_extract_array::<f32>()
.map_err(|e| AsrError::Inference(format!("extract last_hidden_state: {e}")))?
.to_owned()
.into_dimensionality::<ndarray::Ix3>()
.map_err(|e| AsrError::Inference(format!("last_hidden_state rank: {e}")))?
};
let lang = self.cfg.language.clone();
let prompt = self
.tokenizer
.build_prompt(lang.as_deref().filter(|s| !s.is_empty() && *s != "auto"))?;
let prompt_len = prompt.len();
let eot = self.tokenizer.eot();
let input_ids =
ndarray::Array2::from_shape_vec((1, prompt_len), prompt.clone()).map_err(|e| {
AsrError::Inference(format!("whisper-onnx prompt reshape: {e}"))
})?;
let ids_v = Value::from_array(input_ids).map_err(|e| AsrError::Inference(format!("whisper-onnx input_ids Value: {e}")))?;
let enc_v = Value::from_array(encoder_hidden.clone()).map_err(|e| AsrError::Inference(format!("whisper-onnx encoder_hidden_states Value: {e}")))?;
let mut session = self.decoder.lock().map_err(|e| AsrError::Inference(format!("whisper-onnx decoder lock poisoned: {e}")))?;
let outputs = session
.run(vec![
("input_ids", ids_v.into_dyn()),
("encoder_hidden_states", enc_v.into_dyn()),
])
.map_err(|e| AsrError::Inference(format!("whisper-onnx decoder run: {e}")))?;
let logits = outputs[0]
.try_extract_array::<f32>()
.map_err(|e| AsrError::Inference(format!("extract logits: {e}")))?
.to_owned();
let mut next_token = greedy_last(&logits)?;
let mut decoder_kv: Vec<(String, Array4<f32>)> = Vec::with_capacity(N_DECODER_LAYERS * 2);
let mut encoder_kv: Vec<(String, Array4<f32>)> = Vec::with_capacity(N_DECODER_LAYERS * 2);
for (i, name) in self.decoder_output_names.iter().enumerate().skip(1) {
let arr: Array4<f32> = outputs[i]
.try_extract_array::<f32>()
.map_err(|e| AsrError::Inference(format!("extract {name}: {e}")))?
.to_owned()
.into_dimensionality::<ndarray::Ix4>()
.map_err(|e| AsrError::Inference(format!("{name} rank: {e}")))?;
let past_name = present_to_past(name);
if name.contains(".encoder.") {
encoder_kv.push((past_name, arr));
} else {
decoder_kv.push((past_name, arr));
}
}
drop(outputs);
drop(session);
let mut generated: Vec<i64> = prompt;
generated.push(next_token);
if next_token != eot {
let mut past_session = self.decoder_with_past.lock().map_err(|e| AsrError::Inference(format!("whisper-onnx decoder_with_past lock poisoned: {e}")))?;
for _ in 0..MAX_NEW_TOKENS {
if next_token == eot {
break;
}
let ids =
ndarray::Array2::from_shape_vec((1, 1), vec![next_token]).map_err(|e| {
AsrError::Inference(format!("whisper-onnx step input_ids reshape: {e}"))
})?;
let ids_v = Value::from_array(ids).map_err(|e| AsrError::Inference(format!("whisper-onnx step input_ids Value: {e}")))?;
let mut feeds: Vec<(&str, ort::value::DynValue)> =
Vec::with_capacity(1 + N_DECODER_LAYERS * 4);
feeds.push(("input_ids", ids_v.into_dyn()));
for (name, arr) in decoder_kv.iter().chain(encoder_kv.iter()) {
let v = Value::from_array(arr.clone()).map_err(|e| AsrError::Inference(format!("whisper-onnx step {name} Value: {e}")))?;
feeds.push((name.as_str(), v.into_dyn()));
}
let outs = past_session.run(feeds).map_err(|e| AsrError::Inference(format!("whisper-onnx decoder_with_past run: {e}")))?;
let logits = outs[0]
.try_extract_array::<f32>()
.map_err(|e| AsrError::Inference(format!("extract step logits: {e}")))?
.to_owned();
next_token = greedy_last(&logits)?;
generated.push(next_token);
let mut new_decoder_kv: Vec<(String, Array4<f32>)> =
Vec::with_capacity(N_DECODER_LAYERS * 2);
for (i, name) in self.decoder_past_output_names.iter().enumerate().skip(1) {
let arr: Array4<f32> = outs[i]
.try_extract_array::<f32>()
.map_err(|e| AsrError::Inference(format!("extract step {name}: {e}")))?
.to_owned()
.into_dimensionality::<ndarray::Ix4>()
.map_err(|e| AsrError::Inference(format!("step {name} rank: {e}")))?;
new_decoder_kv.push((present_to_past(name), arr));
}
decoder_kv = new_decoder_kv;
}
}
let text_ids: Vec<i64> = generated
.into_iter()
.skip(prompt_len)
.take_while(|&t| t != eot)
.collect();
self.tokenizer.decode(&text_ids)
}
}
#[async_trait]
impl AsrAdapter for WhisperOnnxAdapter {
async fn transcribe(&self, audio: &[AudioChunk]) -> Result<Transcript, AsrError> {
let samples = AudioChunk::concat(audio);
let text = self.transcribe_samples(&samples)?;
Ok(Transcript::new(text))
}
}
fn build_session(path: &std::path::Path, role: &str) -> Result<Session, AsrError> {
let threads = std::cmp::min(num_cpus_hint(), 4);
let mut builder = Session::builder().map_err(|e| AsrError::ModelLoad(format!("whisper-onnx {role} builder: {e}")))?;
builder = builder.with_intra_threads(threads).map_err(|e| AsrError::Inference(format!("whisper-onnx {role} with_intra_threads({threads}): {e}")))?;
builder = builder.with_inter_threads(1).map_err(|e| AsrError::Inference(format!("whisper-onnx {role} with_inter_threads(1): {e}")))?;
builder.commit_from_file(path).map_err(|e| AsrError::ModelLoad(format!("whisper-onnx {role} load failed at {}: {e}", path.display())))
}
fn num_cpus_hint() -> usize {
std::thread::available_parallelism()
.map(|n| n.get())
.unwrap_or(1)
}
fn present_to_past(name: &str) -> String {
name.replacen("present.", "past_key_values.", 1)
}
fn greedy_last(logits: &ArrayD<f32>) -> Result<i64, AsrError> {
let shape = logits.shape();
if shape.len() != 3 {
return Err(AsrError::Inference(format!("logits expected rank 3, got {shape:?}")));
}
let (_b, t, v) = (shape[0], shape[1], shape[2]);
if t == 0 || v == 0 {
return Err(AsrError::Inference(format!("logits empty: shape={shape:?}")));
}
let last = logits
.view()
.into_dimensionality::<ndarray::Ix3>()
.map_err(|e| AsrError::Inference(format!("logits rank3 view: {e}")))?
.slice(s![0, t - 1, ..])
.to_owned();
let mut best = 0_usize;
let mut best_val = f32::NEG_INFINITY;
for (i, &x) in last.iter().enumerate() {
if x > best_val {
best_val = x;
best = i;
}
}
Ok(best as i64)
}
#[derive(Debug, Default, Deserialize)]
struct WhisperOnnxOptions {
#[serde(default)]
quant: Option<String>,
}
pub struct WhisperOnnxFactory;
impl WhisperOnnxFactory {
pub const ID: &'static str = "whisper-onnx";
}
#[async_trait]
impl AsrRuntimeFactory for WhisperOnnxFactory {
fn id(&self) -> &'static str {
Self::ID
}
async fn instantiate(&self, req: &AdapterRequest) -> Result<Arc<dyn AsrAdapter>, RouterError> {
let ModelSource::LocalPath(model_dir) = &req.model_source;
let opts: WhisperOnnxOptions = if req.options.is_null() {
WhisperOnnxOptions::default()
} else {
serde_json::from_value(req.options.clone()).map_err(|e| {
RouterError::InvalidRequest(format!("whisper-onnx options parse error: {e}"))
})?
};
let mut cfg = WhisperOnnxConfig::default();
if let Some(q) = opts.quant {
cfg.encoder_file = Some(format!("encoder_model_{q}.onnx"));
cfg.decoder_file = Some(format!("decoder_model_{q}.onnx"));
cfg.decoder_with_past_file = Some(format!("decoder_with_past_model_{q}.onnx"));
}
if !req.language.is_empty() {
cfg.language = Some(req.language.clone());
}
let model_dir = model_dir.clone();
let path_for_err = model_dir.clone();
let cfg_for_load = cfg.clone();
let adapter = tokio::task::spawn_blocking(move || {
WhisperOnnxAdapter::load_with_config(model_dir, cfg_for_load)
})
.await
.map_err(|e| RouterError::InstantiationFailed {
runtime: Self::ID.to_string(),
message: format!("whisper-onnx load task panicked at {path_for_err:?}: {e}"),
})?
.map_err(|e| RouterError::InstantiationFailed {
runtime: Self::ID.to_string(),
message: e.to_string(),
})?;
Ok(Arc::new(adapter))
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::router::{AdapterRequest, AsrRouter, ModelSource, RouterError};
use serde_json::json;
use std::path::PathBuf;
fn req(language: &str, options: serde_json::Value) -> AdapterRequest {
AdapterRequest {
language: language.into(),
runtime: WhisperOnnxFactory::ID.into(),
model_source: ModelSource::LocalPath(PathBuf::from("/nonexistent/whisper-onnx")),
options,
}
}
#[tokio::test]
async fn factory_id_matches_constant() {
assert_eq!(WhisperOnnxFactory.id(), "whisper-onnx");
}
#[tokio::test]
async fn dispatch_with_missing_bundle_returns_instantiation_failed() {
let router = AsrRouter::new().register(WhisperOnnxFactory);
match router.dispatch(req("ko", serde_json::Value::Null)).await {
Err(RouterError::InstantiationFailed { runtime, .. }) => {
assert_eq!(runtime, "whisper-onnx");
}
Err(other) => panic!("expected InstantiationFailed, got {other:?}"),
Ok(_) => panic!("expected loader error"),
}
}
#[tokio::test]
async fn malformed_options_return_invalid_request() {
let router = AsrRouter::new().register(WhisperOnnxFactory);
match router.dispatch(req("ko", json!({ "quant": 42 }))).await {
Err(RouterError::InvalidRequest(msg)) => {
assert!(msg.contains("whisper-onnx"));
}
Err(other) => panic!("expected InvalidRequest, got {other:?}"),
Ok(_) => panic!("expected parse error"),
}
}
#[test]
fn config_default_filenames_are_q4() {
let cfg = WhisperOnnxConfig::default();
assert_eq!(cfg.encoder_filename(), "encoder_model_q4.onnx");
assert_eq!(cfg.decoder_filename(), "decoder_model_q4.onnx");
assert_eq!(
cfg.decoder_with_past_filename(),
"decoder_with_past_model_q4.onnx"
);
}
#[test]
fn present_to_past_renames_only_prefix() {
assert_eq!(
present_to_past("present.0.decoder.key"),
"past_key_values.0.decoder.key"
);
assert_eq!(
present_to_past("present.3.encoder.value"),
"past_key_values.3.encoder.value"
);
assert_eq!(
present_to_past("foo.present.0.key"),
"foo.past_key_values.0.key"
);
}
}