use async_trait::async_trait;
use std::path::{Path, PathBuf};
use crate::traits::{AsrAdapter, AsrError};
use crate::types::{AudioChunk, Transcript};
use super::decoder::{CanaryDecoder, DecodeOptions};
use super::encoder::CanaryEncoder;
use super::frontend::{MelFrontend, MelOpts};
use super::vocab::Vocab;
#[derive(Debug, Clone)]
pub struct CanaryConfig {
pub encoder_filename: String,
pub decoder_filename: String,
pub vocab_filename: String,
pub mel: MelOpts,
pub default_language: String,
pub default_pnc: bool,
pub max_sequence_length: usize,
pub repetition_penalty: f32,
pub min_token_to_frame_ratio: f32,
pub eos_confidence_margin: f32,
pub beam_size: usize,
pub length_penalty: f32,
pub prefix_format: super::decoder::PrefixFormat,
}
impl CanaryConfig {
pub fn istupakov_default() -> Self {
Self {
encoder_filename: "encoder-model.onnx".to_string(),
decoder_filename: "decoder-model.onnx".to_string(),
vocab_filename: "vocab.txt".to_string(),
mel: MelOpts::canary_default(),
default_language: "en".to_string(),
default_pnc: true,
max_sequence_length: super::decoder::DEFAULT_MAX_SEQUENCE_LENGTH,
repetition_penalty: super::decoder::DEFAULT_REPETITION_PENALTY,
min_token_to_frame_ratio: super::decoder::DEFAULT_MIN_TOKEN_TO_FRAME_RATIO,
eos_confidence_margin: super::decoder::DEFAULT_EOS_CONFIDENCE_MARGIN,
beam_size: super::decoder::DEFAULT_BEAM_SIZE,
length_penalty: super::decoder::DEFAULT_LENGTH_PENALTY,
prefix_format: super::decoder::DEFAULT_PREFIX_FORMAT,
}
}
pub fn with_int8_weights(mut self) -> Self {
self.encoder_filename = "encoder-model.int8.onnx".to_string();
self.decoder_filename = "decoder-model.int8.onnx".to_string();
self
}
}
impl Default for CanaryConfig {
fn default() -> Self {
Self::istupakov_default()
}
}
pub struct CanaryAdapter {
frontend: MelFrontend,
encoder: CanaryEncoder,
decoder: CanaryDecoder,
vocab: Vocab,
cfg: CanaryConfig,
language: String,
}
impl CanaryAdapter {
pub fn load(model_dir: impl AsRef<Path>) -> Result<Self, AsrError> {
Self::load_with_config(model_dir, CanaryConfig::default())
}
pub fn load_with_config(
model_dir: impl AsRef<Path>,
cfg: CanaryConfig,
) -> Result<Self, AsrError> {
let dir: PathBuf = model_dir.as_ref().to_path_buf();
let encoder_path = dir.join(&cfg.encoder_filename);
let decoder_path = dir.join(&cfg.decoder_filename);
let vocab_path = dir.join(&cfg.vocab_filename);
let encoder = CanaryEncoder::load(&encoder_path)?;
let decoder = CanaryDecoder::load(&decoder_path)?;
let vocab = Vocab::from_file(&vocab_path)?;
if vocab.language_token(&cfg.default_language).is_none() {
return Err(AsrError::ModelLoad(format!(
"vocab {} has no language token for default_language={:?}",
vocab_path.display(),
cfg.default_language
)));
}
let language = cfg.default_language.clone();
Ok(Self {
frontend: MelFrontend::new(cfg.mel.clone()),
encoder,
decoder,
vocab,
cfg,
language,
})
}
pub fn with_language(mut self, lang: impl Into<String>) -> Self {
self.language = lang.into();
self
}
pub fn transcribe_samples(&self, samples: &[f32]) -> Result<String, AsrError> {
if samples.is_empty() {
return Err(AsrError::NoAudio);
}
let (mel, n_frames) = self.frontend.compute(samples);
if n_frames == 0 {
return Err(AsrError::Inference(format!(
"audio too short for one mel frame ({} samples, need ≥{})",
samples.len(),
self.cfg.mel.win_length,
)));
}
let n_mels = self.frontend.n_mels();
let enc = self.encoder.encode(&mel, n_mels, n_frames)?;
let opts = DecodeOptions {
source_language: self.language.clone(),
target_language: self.language.clone(),
pnc: self.cfg.default_pnc,
max_sequence_length: self.cfg.max_sequence_length,
repetition_penalty: self.cfg.repetition_penalty,
min_token_to_frame_ratio: self.cfg.min_token_to_frame_ratio,
eos_confidence_margin: self.cfg.eos_confidence_margin,
beam_size: self.cfg.beam_size,
length_penalty: self.cfg.length_penalty,
prefix_format: self.cfg.prefix_format,
};
let decoded = self
.decoder
.decode(&enc.embeddings, &enc.mask, &self.vocab, &opts)?;
Ok(self.vocab.decode(&decoded.tokens))
}
}
#[async_trait]
impl AsrAdapter for CanaryAdapter {
async fn transcribe(&self, audio: &[AudioChunk]) -> Result<Transcript, AsrError> {
let all_samples = AudioChunk::concat(audio);
if all_samples.is_empty() {
return Err(AsrError::NoAudio);
}
tracing::info!(
audio_samples = all_samples.len(),
language = %self.language,
"transcribing with canary-180m-flash"
);
let text = self.transcribe_samples(&all_samples)?;
Ok(Transcript::new(text))
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn load_nonexistent_dir_returns_error() {
let res = CanaryAdapter::load("/nonexistent/path/to/canary");
assert!(res.is_err());
let err = match res {
Ok(_) => panic!("expected error"),
Err(e) => e,
};
assert!(
err.to_string().contains("load Canary encoder")
|| err.to_string().contains("load Canary decoder")
|| err.to_string().contains("read vocab"),
"expected load-failure message, got: {}",
err
);
}
#[test]
fn config_default_filenames_match_istupakov_layout() {
let cfg = CanaryConfig::default();
assert_eq!(cfg.encoder_filename, "encoder-model.onnx");
assert_eq!(cfg.decoder_filename, "decoder-model.onnx");
assert_eq!(cfg.vocab_filename, "vocab.txt");
assert_eq!(cfg.default_language, "en");
assert!(cfg.default_pnc);
}
#[test]
fn with_int8_weights_swaps_filenames() {
let cfg = CanaryConfig::default().with_int8_weights();
assert_eq!(cfg.encoder_filename, "encoder-model.int8.onnx");
assert_eq!(cfg.decoder_filename, "decoder-model.int8.onnx");
assert_eq!(cfg.vocab_filename, "vocab.txt");
assert_eq!(cfg.default_language, "en");
}
#[test]
fn adapter_is_send_and_sync() {
fn assert_send_sync<T: Send + Sync>() {}
assert_send_sync::<CanaryAdapter>();
}
#[tokio::test]
async fn empty_audio_yields_no_audio_received_error() {
assert!(AudioChunk::concat(&[]).is_empty());
assert!(AudioChunk::sample_rate_of(&[]).is_none());
}
}