AI models shared registry + Code cleanup + Better async handling + Some fixes, etc
This commit is contained in:
parent
a8ff3442ff
commit
520b98143f
62 changed files with 1683 additions and 1239 deletions
140
crates/jarvis-core/src/models/catalog.rs
Normal file
140
crates/jarvis-core/src/models/catalog.rs
Normal file
|
|
@ -0,0 +1,140 @@
|
|||
use std::fs;
|
||||
use std::path::Path;
|
||||
|
||||
use super::structs::{Task, ModelDef, BackendOption};
|
||||
|
||||
// scan the models directory for folders containing model.toml
|
||||
pub fn scan_models(models_dir: &Path) -> Vec<ModelDef> {
|
||||
let mut models = Vec::new();
|
||||
|
||||
if !models_dir.exists() {
|
||||
warn!("Models directory not found: {:?}", models_dir);
|
||||
return models;
|
||||
}
|
||||
|
||||
let entries = match fs::read_dir(models_dir) {
|
||||
Ok(e) => e,
|
||||
Err(e) => {
|
||||
warn!("Failed to read models dir: {}", e);
|
||||
return models;
|
||||
}
|
||||
};
|
||||
|
||||
for entry in entries.flatten() {
|
||||
let path = entry.path();
|
||||
if !path.is_dir() {
|
||||
continue;
|
||||
}
|
||||
|
||||
let toml_path = path.join("model.toml");
|
||||
if !toml_path.exists() {
|
||||
continue;
|
||||
}
|
||||
|
||||
match load_model_def(&toml_path, &path) {
|
||||
Ok(def) => {
|
||||
info!("Found model: {} ({}) - tasks: {:?}", def.name, def.id, def.tasks);
|
||||
models.push(def);
|
||||
}
|
||||
Err(e) => warn!("Failed to load model from {:?}: {}", path, e),
|
||||
}
|
||||
}
|
||||
|
||||
models
|
||||
}
|
||||
|
||||
fn load_model_def(toml_path: &Path, model_dir: &Path) -> Result<ModelDef, String> {
|
||||
let content = fs::read_to_string(toml_path)
|
||||
.map_err(|e| format!("read error: {}", e))?;
|
||||
|
||||
let parsed: ModelToml = toml::from_str(&content)
|
||||
.map_err(|e| format!("parse error: {}", e))?;
|
||||
|
||||
let mut def = parsed.model;
|
||||
def.path = model_dir.to_path_buf();
|
||||
|
||||
Ok(def)
|
||||
}
|
||||
|
||||
#[derive(serde::Deserialize)]
|
||||
struct ModelToml {
|
||||
model: ModelDef,
|
||||
}
|
||||
|
||||
// Code backends per task
|
||||
pub fn code_backends(task: Task) -> Vec<BackendOption> {
|
||||
match task {
|
||||
Task::Intent => vec![
|
||||
BackendOption {
|
||||
id: "intent-classifier".into(),
|
||||
name: "Intent Classifier".into(),
|
||||
model_id: None,
|
||||
},
|
||||
],
|
||||
Task::Slots => vec![],
|
||||
Task::Vad => vec![
|
||||
BackendOption {
|
||||
id: "energy".into(),
|
||||
name: "Energy-based".into(),
|
||||
model_id: None,
|
||||
},
|
||||
BackendOption {
|
||||
id: "nnnoiseless".into(),
|
||||
name: "Nnnoiseless".into(),
|
||||
model_id: None,
|
||||
},
|
||||
],
|
||||
Task::NoiseSuppression => vec![
|
||||
BackendOption {
|
||||
id: "nnnoiseless".into(),
|
||||
name: "Nnnoiseless".into(),
|
||||
model_id: None,
|
||||
},
|
||||
],
|
||||
Task::Stt => vec![
|
||||
BackendOption {
|
||||
id: "vosk".into(),
|
||||
name: "Vosk".into(),
|
||||
model_id: None,
|
||||
},
|
||||
],
|
||||
}
|
||||
}
|
||||
|
||||
// get all available options for a task:
|
||||
// "none" first, then code backends, then AI models from catalog
|
||||
pub fn get_options(task: Task, models: &[ModelDef]) -> Vec<BackendOption> {
|
||||
let mut options = vec![
|
||||
BackendOption {
|
||||
id: "none".into(),
|
||||
name: "Disabled".into(),
|
||||
model_id: None,
|
||||
},
|
||||
];
|
||||
|
||||
options.extend(code_backends(task));
|
||||
|
||||
for model in models {
|
||||
if model.tasks.contains(&task) {
|
||||
options.push(BackendOption {
|
||||
id: model.id.clone(),
|
||||
name: model.name.clone(),
|
||||
model_id: Some(model.id.clone()),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
options
|
||||
}
|
||||
|
||||
pub fn is_valid_backend(task: Task, backend_id: &str, models: &[ModelDef]) -> bool {
|
||||
if backend_id == "none" {
|
||||
return true;
|
||||
}
|
||||
|
||||
if code_backends(task).iter().any(|b| b.id == backend_id) {
|
||||
return true;
|
||||
}
|
||||
|
||||
models.iter().any(|m| m.id == backend_id && m.tasks.contains(&task))
|
||||
}
|
||||
130
crates/jarvis-core/src/models/gliner_models.rs
Normal file
130
crates/jarvis-core/src/models/gliner_models.rs
Normal file
|
|
@ -0,0 +1,130 @@
|
|||
use std::collections::HashMap;
|
||||
use std::fs;
|
||||
use crate::APP_DIR;
|
||||
|
||||
const GLINER_DIRS: &[&str] = &["gliner_small-v2.1", "gliner_multi-v2.1"];
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct GlinerModelVariant {
|
||||
// type id stored in settings, e.g. "int8", "fp16", "full"
|
||||
pub value: String,
|
||||
// shown in dropdown, e.g. "int8 (174MB / 332MB)"
|
||||
pub display_name: String,
|
||||
}
|
||||
|
||||
// scan both model dirs and return a deduplicated list of model types
|
||||
pub fn scan_gliner_variants() -> Vec<GlinerModelVariant> {
|
||||
let base = APP_DIR.join("resources").join("models");
|
||||
|
||||
// collect: type -> { dir_name -> size_mb }
|
||||
let mut types: HashMap<String, HashMap<String, u64>> = HashMap::new();
|
||||
|
||||
for dir_name in GLINER_DIRS {
|
||||
let onnx_dir = base.join(dir_name).join("onnx");
|
||||
if !onnx_dir.exists() { continue; }
|
||||
|
||||
let entries = match fs::read_dir(&onnx_dir) {
|
||||
Ok(e) => e,
|
||||
Err(_) => continue,
|
||||
};
|
||||
|
||||
for entry in entries.flatten() {
|
||||
let path = entry.path();
|
||||
let file_name = match path.file_name().and_then(|n| n.to_str()) {
|
||||
Some(n) if n.ends_with(".onnx") => n.to_string(),
|
||||
_ => continue,
|
||||
};
|
||||
|
||||
let variant_type = file_name_to_type(&file_name);
|
||||
let size_mb = fs::metadata(&path).map(|m| m.len()).unwrap_or(0) / (1024 * 1024);
|
||||
|
||||
types.entry(variant_type)
|
||||
.or_default()
|
||||
.insert(dir_name.to_string(), size_mb);
|
||||
}
|
||||
}
|
||||
|
||||
let mut result: Vec<GlinerModelVariant> = types.into_iter().map(|(variant, sizes)| {
|
||||
let size_str = build_size_string(&sizes);
|
||||
let label = if variant == "full" { "Full".to_string() } else { variant.clone() };
|
||||
GlinerModelVariant {
|
||||
display_name: format!("{} ({})", label, size_str),
|
||||
value: variant,
|
||||
}
|
||||
}).collect();
|
||||
|
||||
// sort: full first, then alphabetically
|
||||
result.sort_by(|a, b| {
|
||||
let a_full = a.value == "full";
|
||||
let b_full = b.value == "full";
|
||||
b_full.cmp(&a_full).then_with(|| a.value.cmp(&b.value))
|
||||
});
|
||||
|
||||
result
|
||||
}
|
||||
|
||||
// "model.onnx" -> "full", "model_int8.onnx" -> "int8"
|
||||
fn file_name_to_type(name: &str) -> String {
|
||||
let stem = name.strip_suffix(".onnx").unwrap_or(name);
|
||||
if stem == "model" {
|
||||
"full".to_string()
|
||||
} else if let Some(variant) = stem.strip_prefix("model_") {
|
||||
variant.to_string()
|
||||
} else {
|
||||
stem.to_string()
|
||||
}
|
||||
}
|
||||
|
||||
// build size display: "174MB" if only one dir, "small: 174MB / multi: 332MB" if both
|
||||
fn build_size_string(sizes: &HashMap<String, u64>) -> String {
|
||||
if sizes.len() == 1 {
|
||||
let (dir, mb) = sizes.iter().next().unwrap();
|
||||
let short = short_dir_name(dir);
|
||||
return format!("{}: {}MB", short, mb);
|
||||
}
|
||||
|
||||
let mut parts: Vec<String> = Vec::new();
|
||||
for dir_name in GLINER_DIRS {
|
||||
if let Some(mb) = sizes.get(*dir_name) {
|
||||
parts.push(format!("{}: {}MB", short_dir_name(dir_name), mb));
|
||||
}
|
||||
}
|
||||
parts.join(" / ")
|
||||
}
|
||||
|
||||
fn short_dir_name(dir: &str) -> &str {
|
||||
if dir.contains("small") { "small" }
|
||||
else if dir.contains("multi") { "multi" }
|
||||
else { dir }
|
||||
}
|
||||
|
||||
// resolve variant type + language into actual file path
|
||||
// returns (model_dir_path, onnx_file_name) or None
|
||||
pub fn resolve_model(variant: &str, language: &str) -> Option<(std::path::PathBuf, String)> {
|
||||
let base = APP_DIR.join("resources").join("models");
|
||||
let file_name = type_to_file_name(variant);
|
||||
|
||||
// pick dir based on language
|
||||
let preferred: &[&str] = match language {
|
||||
"en" => &["gliner_small-v2.1", "gliner_multi-v2.1"],
|
||||
_ => &["gliner_multi-v2.1", "gliner_small-v2.1"],
|
||||
};
|
||||
|
||||
for dir_name in preferred {
|
||||
let path = base.join(dir_name).join("onnx").join(&file_name);
|
||||
if path.exists() {
|
||||
return Some((base.join(dir_name), file_name));
|
||||
}
|
||||
}
|
||||
|
||||
None
|
||||
}
|
||||
|
||||
// "full" -> "model.onnx", "int8" -> "model_int8.onnx"
|
||||
fn type_to_file_name(variant: &str) -> String {
|
||||
if variant == "full" || variant.is_empty() {
|
||||
"model.onnx".to_string()
|
||||
} else {
|
||||
format!("model_{}.onnx", variant)
|
||||
}
|
||||
}
|
||||
47
crates/jarvis-core/src/models/loaders/embedding.rs
Normal file
47
crates/jarvis-core/src/models/loaders/embedding.rs
Normal file
|
|
@ -0,0 +1,47 @@
|
|||
// fastembed embedding model (all-MiniLM-L6-v2, paraphrase-multilingual, etc.)
|
||||
|
||||
use std::sync::Arc;
|
||||
use parking_lot::Mutex;
|
||||
use fastembed::{TextEmbedding, UserDefinedEmbeddingModel, TokenizerFiles, Pooling, QuantizationMode, OutputKey};
|
||||
|
||||
use crate::models::registry::ModelRegistry;
|
||||
|
||||
pub struct EmbeddingModel {
|
||||
pub embedding: Mutex<TextEmbedding>,
|
||||
}
|
||||
|
||||
// fastembed uses ORT internally which is thread-safe
|
||||
unsafe impl Send for EmbeddingModel {}
|
||||
unsafe impl Sync for EmbeddingModel {}
|
||||
|
||||
pub fn load(registry: &ModelRegistry, model_id: &str) -> Result<Arc<EmbeddingModel>, String> {
|
||||
registry.get_or_load::<EmbeddingModel>(model_id, |def| {
|
||||
let model_dir = &def.path;
|
||||
|
||||
info!("Loading embedding model from: {}", model_dir.display());
|
||||
|
||||
let user_model = UserDefinedEmbeddingModel {
|
||||
onnx_file: std::fs::read(model_dir.join("model.onnx"))
|
||||
.map_err(|e| format!("Failed to read model.onnx: {}", e))?,
|
||||
tokenizer_files: TokenizerFiles {
|
||||
tokenizer_file: std::fs::read(model_dir.join("tokenizer.json"))
|
||||
.map_err(|e| format!("Failed to read tokenizer.json: {}", e))?,
|
||||
config_file: std::fs::read(model_dir.join("config.json"))
|
||||
.map_err(|e| format!("Failed to read config.json: {}", e))?,
|
||||
special_tokens_map_file: std::fs::read(model_dir.join("special_tokens_map.json"))
|
||||
.map_err(|e| format!("Failed to read special_tokens_map.json: {}", e))?,
|
||||
tokenizer_config_file: std::fs::read(model_dir.join("tokenizer_config.json"))
|
||||
.map_err(|e| format!("Failed to read tokenizer_config.json: {}", e))?,
|
||||
},
|
||||
pooling: Some(Pooling::Mean),
|
||||
quantization: QuantizationMode::None,
|
||||
output_key: Some(OutputKey::ByName("last_hidden_state")),
|
||||
};
|
||||
|
||||
let model = TextEmbedding::try_new_from_user_defined(user_model, Default::default())
|
||||
.map_err(|e| format!("Failed to load embedding model: {}", e))?;
|
||||
|
||||
info!("Embedding model loaded: {}", def.name);
|
||||
Ok(EmbeddingModel { embedding: Mutex::new(model) })
|
||||
})
|
||||
}
|
||||
51
crates/jarvis-core/src/models/loaders/gliner.rs
Normal file
51
crates/jarvis-core/src/models/loaders/gliner.rs
Normal file
|
|
@ -0,0 +1,51 @@
|
|||
// GLiNER model for named entity recognition / slot extraction
|
||||
|
||||
use std::sync::Arc;
|
||||
use parking_lot::Mutex;
|
||||
use regex::Regex;
|
||||
use tokenizers::Tokenizer;
|
||||
|
||||
use crate::models::registry::ModelRegistry;
|
||||
|
||||
const WORD_REGEX: &str = r"\w+(?:[-_]\w+)*|\S";
|
||||
|
||||
pub struct GlinerModel {
|
||||
pub session: Mutex<ort::session::Session>,
|
||||
pub tokenizer: Tokenizer,
|
||||
pub splitter: Regex,
|
||||
}
|
||||
|
||||
unsafe impl Send for GlinerModel {}
|
||||
unsafe impl Sync for GlinerModel {}
|
||||
|
||||
pub fn load(registry: &ModelRegistry, model_id: &str) -> Result<Arc<GlinerModel>, String> {
|
||||
registry.get_or_load::<GlinerModel>(model_id, |def| {
|
||||
let model_dir = &def.path;
|
||||
|
||||
// GLiNER models keep onnx files in an "onnx" subfolder
|
||||
let onnx_dir = model_dir.join("onnx");
|
||||
let model_path = if onnx_dir.exists() {
|
||||
onnx_dir.join("model.onnx")
|
||||
} else {
|
||||
model_dir.join("model.onnx")
|
||||
};
|
||||
|
||||
let tokenizer_path = model_dir.join("tokenizer.json");
|
||||
|
||||
info!("Loading GLiNER model from: {}", model_dir.display());
|
||||
|
||||
let session = ort::session::Session::builder()
|
||||
.map_err(|e| format!("Failed to create ORT session builder: {}", e))?
|
||||
.commit_from_file(&model_path)
|
||||
.map_err(|e| format!("Failed to load ONNX model: {}", e))?;
|
||||
|
||||
let tokenizer = Tokenizer::from_file(&tokenizer_path)
|
||||
.map_err(|e| format!("Failed to load tokenizer: {}", e))?;
|
||||
|
||||
let splitter = Regex::new(WORD_REGEX)
|
||||
.map_err(|e| format!("Failed to compile word regex: {}", e))?;
|
||||
|
||||
info!("GLiNER model loaded: {}", def.name);
|
||||
Ok(GlinerModel { session: Mutex::new(session), tokenizer, splitter })
|
||||
})
|
||||
}
|
||||
30
crates/jarvis-core/src/models/loaders/intent_classifier.rs
Normal file
30
crates/jarvis-core/src/models/loaders/intent_classifier.rs
Normal file
|
|
@ -0,0 +1,30 @@
|
|||
// intent-classifier crate wrapper
|
||||
|
||||
use std::sync::Arc;
|
||||
use intent_classifier::IntentClassifier;
|
||||
|
||||
use crate::models::registry::ModelRegistry;
|
||||
|
||||
pub struct IntentClassifierModel {
|
||||
pub classifier: IntentClassifier,
|
||||
}
|
||||
|
||||
unsafe impl Send for IntentClassifierModel {}
|
||||
unsafe impl Sync for IntentClassifierModel {}
|
||||
|
||||
// init is async (IntentClassifier::new().await), so we create it
|
||||
// outside the registry and insert it after
|
||||
pub async fn load(registry: &ModelRegistry, model_id: &str) -> Result<Arc<IntentClassifierModel>, String> {
|
||||
if let Some(existing) = registry.get::<IntentClassifierModel>(model_id) {
|
||||
info!("IntentClassifier '{}' already loaded, reusing", model_id);
|
||||
return Ok(existing);
|
||||
}
|
||||
|
||||
info!("Initializing IntentClassifier...");
|
||||
|
||||
let classifier = IntentClassifier::new().await
|
||||
.map_err(|e| format!("Failed to init IntentClassifier: {}", e))?;
|
||||
|
||||
info!("IntentClassifier initialized");
|
||||
Ok(registry.insert(model_id, IntentClassifierModel { classifier }))
|
||||
}
|
||||
12
crates/jarvis-core/src/models/loaders/mod.rs
Normal file
12
crates/jarvis-core/src/models/loaders/mod.rs
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
#[cfg(feature = "jarvis_app")]
|
||||
pub mod embedding;
|
||||
#[cfg(feature = "jarvis_app")]
|
||||
pub mod gliner;
|
||||
#[cfg(feature = "jarvis_app")]
|
||||
pub mod ort_model;
|
||||
#[cfg(feature = "jarvis_app")]
|
||||
pub mod intent_classifier;
|
||||
#[cfg(feature = "vosk")]
|
||||
pub mod vosk;
|
||||
#[cfg(feature = "nnnoiseless")]
|
||||
pub mod nnnoiseless;
|
||||
110
crates/jarvis-core/src/models/loaders/nnnoiseless.rs
Normal file
110
crates/jarvis-core/src/models/loaders/nnnoiseless.rs
Normal file
|
|
@ -0,0 +1,110 @@
|
|||
// nnnoiseless - used for both noise suppression and VAD.
|
||||
// each consumer needs its own DenoiseState (stateful per-stream),
|
||||
// so this doesn't go through the registry. just centralizes creation.
|
||||
|
||||
use nnnoiseless::DenoiseState;
|
||||
use crate::config;
|
||||
|
||||
// noise suppression instance
|
||||
pub struct NnnoiselessNS {
|
||||
state: Box<DenoiseState<'static>>,
|
||||
buffer: Vec<f32>,
|
||||
}
|
||||
|
||||
impl NnnoiselessNS {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
state: DenoiseState::new(),
|
||||
buffer: Vec::with_capacity(config::NNNOISELESS_FRAME_SIZE * 2),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn process(&mut self, input: &[i16]) -> Vec<i16> {
|
||||
self.buffer.extend(input.iter().map(|&s| s as f32));
|
||||
|
||||
let frame_size = config::NNNOISELESS_FRAME_SIZE;
|
||||
let full_frames = self.buffer.len() / frame_size;
|
||||
|
||||
if full_frames == 0 {
|
||||
return input.to_vec();
|
||||
}
|
||||
|
||||
let mut output: Vec<i16> = Vec::with_capacity(full_frames * frame_size);
|
||||
let mut input_frame = [0.0f32; 480];
|
||||
let mut output_frame = [0.0f32; 480];
|
||||
|
||||
let consumed = full_frames * frame_size;
|
||||
for i in 0..full_frames {
|
||||
let offset = i * frame_size;
|
||||
input_frame.copy_from_slice(&self.buffer[offset..offset + frame_size]);
|
||||
|
||||
let _ = self.state.process_frame(&mut output_frame, &input_frame);
|
||||
|
||||
for &sample in &output_frame {
|
||||
let clamped = sample.clamp(i16::MIN as f32, i16::MAX as f32);
|
||||
output.push(clamped as i16);
|
||||
}
|
||||
}
|
||||
|
||||
// keep leftover samples (single drain at the end)
|
||||
self.buffer.drain(..consumed);
|
||||
|
||||
output
|
||||
}
|
||||
|
||||
pub fn reset(&mut self) {
|
||||
self.buffer.clear();
|
||||
}
|
||||
}
|
||||
|
||||
// VAD instance
|
||||
pub struct NnnoiselessVAD {
|
||||
state: Box<DenoiseState<'static>>,
|
||||
buffer: Vec<f32>,
|
||||
}
|
||||
|
||||
impl NnnoiselessVAD {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
state: DenoiseState::new(),
|
||||
buffer: Vec::with_capacity(config::NNNOISELESS_FRAME_SIZE * 2),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn detect(&mut self, input: &[i16]) -> (bool, f32) {
|
||||
self.buffer.extend(input.iter().map(|&s| s as f32));
|
||||
|
||||
let frame_size = config::NNNOISELESS_FRAME_SIZE;
|
||||
let full_frames = self.buffer.len() / frame_size;
|
||||
|
||||
if full_frames == 0 {
|
||||
return (true, 0.5);
|
||||
}
|
||||
|
||||
let mut total_vad = 0.0f32;
|
||||
let mut input_frame = [0.0f32; 480];
|
||||
let mut output_frame = [0.0f32; 480];
|
||||
|
||||
let consumed = full_frames * frame_size;
|
||||
for i in 0..full_frames {
|
||||
let offset = i * frame_size;
|
||||
input_frame.copy_from_slice(&self.buffer[offset..offset + frame_size]);
|
||||
|
||||
let vad_prob = self.state.process_frame(&mut output_frame, &input_frame);
|
||||
total_vad += vad_prob;
|
||||
}
|
||||
|
||||
// single drain
|
||||
self.buffer.drain(..consumed);
|
||||
|
||||
let avg_vad = total_vad / full_frames as f32;
|
||||
let is_voice = avg_vad >= config::VAD_NNNOISELESS_THRESHOLD;
|
||||
|
||||
(is_voice, avg_vad)
|
||||
}
|
||||
|
||||
pub fn reset(&mut self) {
|
||||
self.state = DenoiseState::new();
|
||||
self.buffer.clear();
|
||||
}
|
||||
}
|
||||
44
crates/jarvis-core/src/models/loaders/ort_model.rs
Normal file
44
crates/jarvis-core/src/models/loaders/ort_model.rs
Normal file
|
|
@ -0,0 +1,44 @@
|
|||
// generic ORT model - session + optional tokenizer.
|
||||
// for models like BERT (tiny, distil, mini) that can serve
|
||||
// multiple tasks (intent, NER, text classification, etc.)
|
||||
|
||||
use std::sync::Arc;
|
||||
use parking_lot::Mutex;
|
||||
use tokenizers::Tokenizer;
|
||||
|
||||
use crate::models::registry::ModelRegistry;
|
||||
|
||||
pub struct OrtModel {
|
||||
pub session: Mutex<ort::session::Session>,
|
||||
pub tokenizer: Option<Tokenizer>,
|
||||
}
|
||||
|
||||
unsafe impl Send for OrtModel {}
|
||||
unsafe impl Sync for OrtModel {}
|
||||
|
||||
pub fn load(registry: &ModelRegistry, model_id: &str) -> Result<Arc<OrtModel>, String> {
|
||||
registry.get_or_load::<OrtModel>(model_id, |def| {
|
||||
let model_dir = &def.path;
|
||||
let onnx_path = model_dir.join("model.onnx");
|
||||
|
||||
info!("Loading ORT model from: {}", model_dir.display());
|
||||
|
||||
let session = ort::session::Session::builder()
|
||||
.map_err(|e| format!("ORT session builder error: {}", e))?
|
||||
.commit_from_file(&onnx_path)
|
||||
.map_err(|e| format!("Failed to load ONNX model '{}': {}", onnx_path.display(), e))?;
|
||||
|
||||
let tokenizer_path = model_dir.join("tokenizer.json");
|
||||
let tokenizer = if tokenizer_path.exists() {
|
||||
Some(
|
||||
Tokenizer::from_file(&tokenizer_path)
|
||||
.map_err(|e| format!("Failed to load tokenizer: {}", e))?
|
||||
)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
info!("ORT model loaded: {}", def.name);
|
||||
Ok(OrtModel { session: Mutex::new(session), tokenizer })
|
||||
})
|
||||
}
|
||||
33
crates/jarvis-core/src/models/loaders/vosk.rs
Normal file
33
crates/jarvis-core/src/models/loaders/vosk.rs
Normal file
|
|
@ -0,0 +1,33 @@
|
|||
// vosk speech recognition model
|
||||
|
||||
use std::sync::Arc;
|
||||
use vosk::Model;
|
||||
|
||||
use crate::models::registry::ModelRegistry;
|
||||
|
||||
pub struct VoskModel {
|
||||
pub model: Model,
|
||||
}
|
||||
|
||||
unsafe impl Send for VoskModel {}
|
||||
unsafe impl Sync for VoskModel {}
|
||||
|
||||
// load a vosk model by path through the registry.
|
||||
// vosk models aren't in the catalog (they use their own directory structure),
|
||||
// so we pass the path directly and use model_id for dedup.
|
||||
// @ToDo: Consider moving to catalog
|
||||
pub fn load(registry: &ModelRegistry, model_id: &str, model_path: &str) -> Result<Arc<VoskModel>, String> {
|
||||
// check if already loaded
|
||||
if let Some(existing) = registry.get::<VoskModel>(model_id) {
|
||||
info!("Vosk model '{}' already loaded, reusing", model_id);
|
||||
return Ok(existing);
|
||||
}
|
||||
|
||||
info!("Loading Vosk model from: {}", model_path);
|
||||
|
||||
let model = Model::new(model_path)
|
||||
.ok_or_else(|| format!("Failed to load Vosk model from: {}", model_path))?;
|
||||
|
||||
info!("Vosk model loaded: {}", model_id);
|
||||
Ok(registry.insert(model_id, VoskModel { model }))
|
||||
}
|
||||
108
crates/jarvis-core/src/models/registry.rs
Normal file
108
crates/jarvis-core/src/models/registry.rs
Normal file
|
|
@ -0,0 +1,108 @@
|
|||
use std::any::Any;
|
||||
use std::collections::HashMap;
|
||||
use std::sync::Arc;
|
||||
use parking_lot::{Mutex, RwLock};
|
||||
|
||||
use super::structs::ModelDef;
|
||||
|
||||
// central model registry. loads models once and shares them between components.
|
||||
// completely type-agnostic
|
||||
pub struct ModelRegistry {
|
||||
loaded: Mutex<HashMap<String, Arc<dyn Any + Send + Sync>>>,
|
||||
catalog: RwLock<Vec<ModelDef>>,
|
||||
}
|
||||
|
||||
impl ModelRegistry {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
loaded: Mutex::new(HashMap::new()),
|
||||
catalog: RwLock::new(Vec::new()),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn set_catalog(&self, defs: Vec<ModelDef>) {
|
||||
*self.catalog.write() = defs;
|
||||
}
|
||||
|
||||
// read access to catalog without cloning the whole vec
|
||||
pub fn with_catalog<R>(&self, f: impl FnOnce(&[ModelDef]) -> R) -> R {
|
||||
f(&self.catalog.read())
|
||||
}
|
||||
|
||||
pub fn get_model_def(&self, id: &str) -> Option<ModelDef> {
|
||||
self.catalog.read().iter().find(|m| m.id == id).cloned()
|
||||
}
|
||||
|
||||
// get a loaded model, downcasted to the expected type
|
||||
pub fn get<T: 'static + Send + Sync>(&self, id: &str) -> Option<Arc<T>> {
|
||||
self.loaded.lock()
|
||||
.get(id)?
|
||||
.clone()
|
||||
.downcast::<T>()
|
||||
.ok()
|
||||
}
|
||||
|
||||
// get or load a model. if two components request the same id,
|
||||
// the model only loads once.
|
||||
//
|
||||
// the lock is released before calling the loader to avoid deadlocks
|
||||
// if the loader tries to load a dependency through the registry.
|
||||
pub fn get_or_load<T: 'static + Send + Sync>(
|
||||
&self,
|
||||
id: &str,
|
||||
loader: impl FnOnce(&ModelDef) -> Result<T, String>,
|
||||
) -> Result<Arc<T>, String> {
|
||||
// fast path: already loaded
|
||||
if let Some(existing) = self.get::<T>(id) {
|
||||
info!("Model '{}' already loaded, reusing", id);
|
||||
return Ok(existing);
|
||||
}
|
||||
|
||||
// grab model def (releases catalog lock immediately)
|
||||
let def = self.get_model_def(id)
|
||||
.ok_or_else(|| format!("Model '{}' not found in catalog", id))?;
|
||||
|
||||
// run loader without holding any lock
|
||||
info!("Loading model '{}' from {:?}...", id, def.path);
|
||||
let model = loader(&def)?;
|
||||
let arc = Arc::new(model);
|
||||
|
||||
// insert (check again in case another thread loaded it meanwhile)
|
||||
let mut map = self.loaded.lock();
|
||||
if let Some(existing) = map.get(id) {
|
||||
if let Ok(typed) = existing.clone().downcast::<T>() {
|
||||
info!("Model '{}' was loaded by another thread, reusing", id);
|
||||
return Ok(typed);
|
||||
}
|
||||
}
|
||||
|
||||
map.insert(id.to_string(), arc.clone());
|
||||
info!("Model '{}' loaded and registered", id);
|
||||
|
||||
Ok(arc)
|
||||
}
|
||||
|
||||
// insert a model directly (for models not in the catalog,
|
||||
// or loaded through non-standard means like async init)
|
||||
pub fn insert<T: 'static + Send + Sync>(&self, id: &str, model: T) -> Arc<T> {
|
||||
let arc = Arc::new(model);
|
||||
self.loaded.lock().insert(id.to_string(), arc.clone());
|
||||
arc
|
||||
}
|
||||
|
||||
pub fn unload(&self, id: &str) -> bool {
|
||||
let removed = self.loaded.lock().remove(id).is_some();
|
||||
if removed {
|
||||
info!("Model '{}' unloaded from registry", id);
|
||||
}
|
||||
removed
|
||||
}
|
||||
|
||||
pub fn is_loaded(&self, id: &str) -> bool {
|
||||
self.loaded.lock().contains_key(id)
|
||||
}
|
||||
|
||||
pub fn loaded_ids(&self) -> Vec<String> {
|
||||
self.loaded.lock().keys().cloned().collect()
|
||||
}
|
||||
}
|
||||
38
crates/jarvis-core/src/models/structs.rs
Normal file
38
crates/jarvis-core/src/models/structs.rs
Normal file
|
|
@ -0,0 +1,38 @@
|
|||
use std::path::PathBuf;
|
||||
use serde::{Serialize, Deserialize};
|
||||
|
||||
// tasks that components can request a backend for
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum Task {
|
||||
Intent,
|
||||
Slots,
|
||||
Vad,
|
||||
NoiseSuppression,
|
||||
Stt,
|
||||
}
|
||||
|
||||
// metadata about a model, parsed from model.toml on disk
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ModelDef {
|
||||
pub id: String,
|
||||
pub name: String,
|
||||
pub tasks: Vec<Task>,
|
||||
|
||||
#[serde(default)]
|
||||
pub description: String,
|
||||
|
||||
// set at runtime after scanning the folder
|
||||
#[serde(skip)]
|
||||
pub path: PathBuf,
|
||||
}
|
||||
|
||||
// a selectable option for a task (shown in UI / stored in settings)
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
pub struct BackendOption {
|
||||
pub id: String,
|
||||
pub name: String,
|
||||
// if Some, this option uses a model from the registry.
|
||||
// if None, it's a code-only backend (like energy VAD) or disabled.
|
||||
pub model_id: Option<String>,
|
||||
}
|
||||
113
crates/jarvis-core/src/models/vosk_models.rs
Normal file
113
crates/jarvis-core/src/models/vosk_models.rs
Normal file
|
|
@ -0,0 +1,113 @@
|
|||
use std::fs;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use crate::{APP_DIR, config};
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct VoskModelInfo {
|
||||
pub name: String, // folder name: "vosk-model-small-ru-0.22"
|
||||
pub path: PathBuf, // full path
|
||||
pub language: String, // extracted from name: "ru"
|
||||
pub size: String, // "small", "large", etc.
|
||||
}
|
||||
|
||||
// Scan for available Vosk models
|
||||
pub fn scan_vosk_models() -> Vec<VoskModelInfo> {
|
||||
let models_dir = {
|
||||
APP_DIR.join(config::VOSK_MODELS_PATH)
|
||||
};
|
||||
let mut models = Vec::new();
|
||||
|
||||
info!("VOSK MODELS DIR: {}", models_dir.display());
|
||||
|
||||
if !models_dir.exists() {
|
||||
warn!("Vosk models directory not found: {}", models_dir.display());
|
||||
return models;
|
||||
}
|
||||
|
||||
let entries = match fs::read_dir(models_dir) {
|
||||
Ok(e) => e,
|
||||
Err(e) => {
|
||||
warn!("Failed to read vosk models directory: {}", e);
|
||||
return models;
|
||||
}
|
||||
};
|
||||
|
||||
for entry in entries {
|
||||
let entry = match entry {
|
||||
Ok(e) => e,
|
||||
Err(_) => continue,
|
||||
};
|
||||
|
||||
let path = entry.path();
|
||||
|
||||
// must be a directory
|
||||
if !path.is_dir() {
|
||||
continue;
|
||||
}
|
||||
|
||||
// check if it looks like a vosk model (has am/conf/graph folders or similar)
|
||||
if !is_vosk_model(&path) {
|
||||
continue;
|
||||
}
|
||||
|
||||
let name = path.file_name()
|
||||
.and_then(|n| n.to_str())
|
||||
.unwrap_or("")
|
||||
.to_string();
|
||||
|
||||
let (language, size) = parse_model_name(&name);
|
||||
|
||||
models.push(VoskModelInfo {
|
||||
name,
|
||||
path,
|
||||
language,
|
||||
size,
|
||||
});
|
||||
}
|
||||
|
||||
models.sort_by(|a, b| a.name.cmp(&b.name));
|
||||
models
|
||||
}
|
||||
|
||||
// Check if directory looks like a Vosk model
|
||||
fn is_vosk_model(path: &Path) -> bool {
|
||||
// vosk models typically have these subdirectories
|
||||
path.join("am").exists() ||
|
||||
path.join("conf").exists() ||
|
||||
path.join("graph").exists() ||
|
||||
path.join("ivector").exists()
|
||||
}
|
||||
|
||||
// Extract language and size from model name
|
||||
// e.g., "vosk-model-small-ru-0.22" -> ("ru", "small")
|
||||
fn parse_model_name(name: &str) -> (String, String) {
|
||||
let parts: Vec<&str> = name.split('-').collect();
|
||||
|
||||
let mut language = String::from("unknown");
|
||||
let mut size = String::from("unknown");
|
||||
|
||||
// look for common size indicators
|
||||
for part in &parts {
|
||||
match *part {
|
||||
"small" | "big" | "large" | "lgraph" => size = part.to_string(),
|
||||
// language codes are usually 2 letters
|
||||
s if s.len() == 2 && s.chars().all(|c| c.is_alphabetic()) => {
|
||||
language = s.to_string();
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
(language, size)
|
||||
}
|
||||
|
||||
// Get model path by name
|
||||
pub fn get_model_path(model_name: &str) -> Option<PathBuf> {
|
||||
let path = Path::new(config::VOSK_MODELS_PATH).join(model_name);
|
||||
if path.exists() && path.is_dir() {
|
||||
Some(path)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue