Commands/voices multilanguage support + Ukranian vosk model added

This commit is contained in:
Priler
2026-01-13 02:21:59 +05:00
parent e2370dc046
commit 11c2500d9c
67 changed files with 214473 additions and 293 deletions

View File

@@ -1,7 +1,8 @@
use std::sync::mpsc::Receiver;
use std::time::SystemTime;
use jarvis_core::{audio_buffer::AudioRingBuffer, audio_processing, commands, config, listener, recorder, stt, COMMANDS_LIST, intent, voices, ipc::{self, IpcEvent}};
use jarvis_core::{audio_buffer::AudioRingBuffer, audio_processing, commands, config, listener, recorder, stt, COMMANDS_LIST, intent, voices, ipc::{self, IpcEvent}, i18n};
use rand::seq::SliceRandom;
use crate::should_stop;
@@ -183,7 +184,8 @@ fn recognize_command(
recognized_voice = recognized_voice.to_lowercase();
// check if wake word repeated (reactivate)
if recognized_voice.contains(config::VOSK_FETCH_PHRASE) {
let wake_phrase = config::get_wake_phrase(&i18n::get_language());
if recognized_voice.contains(wake_phrase) {
info!("Wake word detected during chaining, reactivating...");
voices::play_reply();
stt::reset_speech_recognizer();
@@ -198,9 +200,13 @@ fn recognize_command(
}
// filter activation phrases
for tbr in config::ASSISTANT_PHRASES_TBR {
// for tbr in config::ASSISTANT_PHRASES_TBR {
// recognized_voice = recognized_voice.replace(tbr, "");
// }
for tbr in config::get_phrases_to_remove(&i18n::get_language()) {
recognized_voice = recognized_voice.replace(tbr, "");
}
recognized_voice = recognized_voice.trim().to_string();
if recognized_voice.is_empty() {
@@ -257,9 +263,13 @@ fn process_text_command(text: &str, rt: &tokio::runtime::Runtime) {
ipc::send(IpcEvent::SpeechRecognized { text: text.to_string() });
let mut filtered = text.to_lowercase();
for tbr in config::ASSISTANT_PHRASES_TBR {
// for tbr in config::ASSISTANT_PHRASES_TBR {
// filtered = filtered.replace(tbr, "");
// }
for tbr in config::get_phrases_to_remove(&i18n::get_language()) {
filtered = filtered.replace(tbr, "");
}
let filtered = filtered.trim();
if filtered.is_empty() {
@@ -299,7 +309,8 @@ fn execute_command(text: &str, rt: &tokio::runtime::Runtime) -> bool {
match commands::execute_command(&cmd_path, &cmd_config) {
Ok(chain) => {
info!("Command executed successfully");
voices::play_ok();
// voices::play_ok();
voices::play_random_from(cmd_config.get_sounds(&i18n::get_language()).as_slice());
ipc::send(IpcEvent::CommandExecuted {
id: cmd_config.id.clone(),
success: true,

View File

@@ -82,7 +82,7 @@ fn main() -> Result<(), String> {
Vec::new()
}
};
info!("Commands initialized. Count: {}, List: {:?}", cmds.len(), commands::list(&cmds));
info!("Commands initialized. Count: {}, List: {:?}", cmds.len(), commands::list_paths(&cmds));
COMMANDS_LIST.set(cmds).unwrap();
// init audio

View File

@@ -1,40 +1,24 @@
use crate::APP_DIR;
use rand::prelude::*;
use seqdiff::ratio;
// use serde_yaml;
use std::path::Path;
use std::{fs, fs::File};
use core::time::Duration;
use std::path::PathBuf;
use std::path::{Path, PathBuf};
use std::fs;
use std::time::Duration;
use std::process::{Child, Command};
// use tauri::Manager;
use seqdiff::ratio;
mod structs;
pub use structs::*;
use std::collections::HashMap;
use crate::{config, i18n, APP_DIR};
use crate::{config};
// @TODO. Allow commands both in yaml and json format.
pub fn parse_commands() -> Result<Vec<JCommandsList>, String> {
// collect commands
let mut commands: Vec<JCommandsList> = Vec::new();
let commands_path = APP_DIR.join(config::COMMANDS_PATH);
let cmd_dirs = fs::read_dir(&commands_path)
.map_err(|e| format!("Error reading commands directory {:?}: {}", commands_path, e))?;
for entry in cmd_dirs {
let entry = match entry {
Ok(e) => e,
Err(e) => {
warn!("Failed to read command directory entry: {}", e);
continue;
}
};
for entry in cmd_dirs.flatten() {
let cmd_path = entry.path();
let toml_file = cmd_path.join("command.toml");
@@ -42,7 +26,6 @@ pub fn parse_commands() -> Result<Vec<JCommandsList>, String> {
continue;
}
// read and parse TOML
let content = match fs::read_to_string(&toml_file) {
Ok(c) => c,
Err(e) => {
@@ -68,27 +51,30 @@ pub fn parse_commands() -> Result<Vec<JCommandsList>, String> {
if commands.is_empty() {
Err("No commands found".into())
} else {
info!("Loaded {} commands", commands.len());
info!("Loaded {} command pack(s)", commands.len());
Ok(commands)
}
}
// Commands hash generation for cache invalidation (deterministi c)
pub fn commands_hash(commands: &Vec<JCommandsList>) -> String {
pub fn commands_hash(commands: &[JCommandsList]) -> String {
use sha2::{Sha256, Digest};
let mut hasher = Sha256::new();
// collect all command ids and phrases, sorted
let mut all_ids: Vec<_> = commands.iter()
.flat_map(|ac| ac.commands.iter().map(|c| (&c.id, &c.phrases)))
let lang = i18n::get_language();
hasher.update(lang.as_bytes());
hasher.update(b"|");
// collect all command ids and phrases for current language, sorted
let mut all_data: Vec<(&str, _)> = commands.iter()
.flat_map(|ac| ac.commands.iter().map(|c| (c.id.as_str(), c.get_phrases(&lang))))
.collect();
all_ids.sort_by_key(|(id, _)| *id);
all_data.sort_by_key(|(id, _)| *id);
for (id, phrases) in all_ids {
for (id, phrases) in all_data {
hasher.update(id.as_bytes());
for phrase in phrases {
for phrase in phrases.iter() {
hasher.update(phrase.as_bytes());
}
}
@@ -99,12 +85,10 @@ pub fn commands_hash(commands: &Vec<JCommandsList>) -> String {
pub fn fetch_command<'a>(
phrase: &str,
commands: &'a Vec<JCommandsList>,
commands: &'a [JCommandsList],
) -> Option<(&'a PathBuf, &'a JCommand)> {
let mut result: Option<(&PathBuf, &JCommand)> = None;
let mut best_score = config::CMD_RATIO_THRESHOLD;
let lang = i18n::get_language();
// normalize input
let phrase = phrase.trim().to_lowercase();
if phrase.is_empty() {
return None;
@@ -113,25 +97,30 @@ pub fn fetch_command<'a>(
let phrase_chars: Vec<char> = phrase.chars().collect();
let phrase_words: Vec<&str> = phrase.split_whitespace().collect();
let mut result: Option<(&PathBuf, &JCommand)> = None;
let mut best_score = config::CMD_RATIO_THRESHOLD;
for cmd_list in commands {
for cmd in &cmd_list.commands {
for cmd_phrase in &cmd.phrases {
let cmd_phrase = cmd_phrase.trim().to_lowercase();
let cmd_phrase_chars: Vec<char> = cmd_phrase.chars().collect();
let cmd_phrases = cmd.get_phrases(&lang);
for cmd_phrase in cmd_phrases.iter() {
let cmd_phrase_lower = cmd_phrase.trim().to_lowercase();
let cmd_phrase_chars: Vec<char> = cmd_phrase_lower.chars().collect();
// character-level similarity
let char_ratio = ratio(&phrase_chars, &cmd_phrase_chars);
// word-level similarity (handles word order)
let cmd_words: Vec<&str> = cmd_phrase.split_whitespace().collect();
// word-level similarity
let cmd_words: Vec<&str> = cmd_phrase_lower.split_whitespace().collect();
let word_score = word_overlap_score(&phrase_words, &cmd_words);
// combined score (weighted average)
// combined score
let score = (char_ratio * 0.6) + (word_score * 0.4);
// early exit on perfect match
if score >= 99.0 {
debug!("Perfect match: '{}' -> '{}'", phrase, cmd_phrase);
debug!("Perfect match: '{}' -> '{}'", phrase, cmd_phrase_lower);
return Some((&cmd_list.path, cmd));
}
@@ -143,16 +132,13 @@ pub fn fetch_command<'a>(
}
}
if let Some((cmd_path, cmd)) = result {
info!(
"Fuzzy match: '{}' -> cmd '{}' (score: {:.1}%)",
phrase, cmd.id, best_score
);
Some((cmd_path, cmd))
if let Some((_, cmd)) = result {
info!("Fuzzy match: '{}' -> cmd '{}' (score: {:.1}%)", phrase, cmd.id, best_score);
} else {
debug!("No match for '{}' (best: {:.1}%)", phrase, best_score);
None
}
result
}
@@ -163,36 +149,38 @@ fn word_overlap_score(input_words: &[&str], cmd_words: &[&str]) -> f64 {
let mut matched = 0.0;
// pre-compute cmd word chars to avoid repeated allocations
let cmd_word_chars: Vec<Vec<char>> = cmd_words
.iter()
.map(|w| w.chars().collect())
.collect();
for input_word in input_words {
// find best matching word in command
let best_word_match = cmd_words
.iter()
.map(|cmd_word| {
let iw: Vec<char> = input_word.chars().collect();
let cw: Vec<char> = cmd_word.chars().collect();
ratio(&iw, &cw)
})
.fold(0.0_f64, |a, b| a.max(b));
let input_chars: Vec<char> = input_word.chars().collect();
let best_word_match = cmd_word_chars
.iter()
.map(|cw| ratio(&input_chars, cw))
.fold(0.0_f64, f64::max);
// count as match if word similarity > 70%
if best_word_match > 70.0 {
matched += best_word_match / 100.0;
}
}
// normalize by max word count
let max_words = input_words.len().max(cmd_words.len()) as f64;
(matched / max_words) * 100.0
}
// @TODO. Rewrite executors by executor type struct. (with match arms)
pub fn execute_exe(exe: &str, args: &Vec<String>) -> std::io::Result<Child> {
pub fn execute_exe(exe: &str, args: &[String]) -> std::io::Result<Child> {
Command::new(exe).args(args).spawn()
}
pub fn execute_cli(cmd: &str, args: &Vec<String>) -> std::io::Result<Child> {
debug!("Spawning cmd as: cmd /C {} {:?}", cmd, args);
pub fn execute_cli(cmd: &str, args: &[String]) -> std::io::Result<Child> {
debug!("Spawning: cmd /C {} {:?}", cmd, args);
if cfg!(target_os = "windows") {
Command::new("cmd").arg("/C").arg(cmd).args(args).spawn()
@@ -201,124 +189,42 @@ pub fn execute_cli(cmd: &str, args: &Vec<String>) -> std::io::Result<Child> {
}
}
pub fn execute_command(
cmd_path: &PathBuf,
cmd_config: &JCommand,
// app_handle: &tauri::AppHandle,
) -> Result<bool, String> {
// let sounds_directory = audio::get_sound_directory().unwrap();
pub fn execute_command(cmd_path: &Path, cmd_config: &JCommand) -> Result<bool, String> {
match cmd_config.action.as_str() {
"voice" => {
// VOICE command type
let random_cmd_sound = format!(
"{}.wav",
cmd_config
.sounds
.choose(&mut rand::thread_rng())
.unwrap()
);
// events::play(random_cmd_sound, app_handle);
// audio::play_sound(&sounds_directory.join(random_cmd_sound));
Ok(true)
}
"voice" => Ok(true),
"ahk" => {
// AutoHotkey command type
let exe_path_absolute = Path::new(&cmd_config.exe_path);
let exe_path_local = Path::new(&cmd_path).join(&cmd_config.exe_path);
let exe_path_local = cmd_path.join(&cmd_config.exe_path);
if let Ok(_) = execute_exe(
if exe_path_absolute.exists() {
exe_path_absolute.to_str().unwrap()
} else {
exe_path_local.to_str().unwrap()
},
&cmd_config.exe_args,
) {
let random_cmd_sound = format!(
"{}.wav",
cmd_config
.sounds
.choose(&mut rand::thread_rng())
.unwrap()
);
// events::play(random_cmd_sound, app_handle);
// audio::play_sound(&sounds_directory.join(random_cmd_sound));
Ok(true)
let exe_path = if exe_path_absolute.exists() {
exe_path_absolute
} else {
error!("AHK process spawn error (does exe path is valid?)");
Err("AHK process spawn error (does exe path is valid?)".into())
}
exe_path_local.as_path()
};
execute_exe(exe_path.to_str().unwrap(), &cmd_config.exe_args)
.map(|_| true)
.map_err(|e| format!("AHK process spawn error: {}", e))
}
"cli" => {
// CLI command type
let cli_cmd = &cmd_config.cli_cmd;
match execute_cli(cli_cmd, &cmd_config.cli_args) {
Ok(_) => {
let random_cmd_sound = format!(
"{}.wav",
cmd_config
.sounds
.choose(&mut rand::thread_rng())
.unwrap()
);
// events::play(random_cmd_sound, app_handle);
// audio::play_sound(&sounds_directory.join(random_cmd_sound));
Ok(true)
}
Err(msg) => {
error!("CLI command error ({})", msg);
Err(format!("Shell command error ({})", msg).into())
}
}
execute_cli(&cmd_config.cli_cmd, &cmd_config.cli_args)
.map(|_| true)
.map_err(|e| format!("CLI command error: {}", e))
}
"terminate" => {
// TERMINATE command type
let random_cmd_sound = format!(
"{}.wav",
cmd_config
.sounds
.choose(&mut rand::thread_rng())
.unwrap()
);
// events::play(random_cmd_sound, app_handle);
// audio::play_sound(&sounds_directory.join(random_cmd_sound));
std::thread::sleep(Duration::from_secs(2));
std::process::exit(0);
}
"stop_chaining" => {
// STOP_CHAINING command type
let random_cmd_sound = format!(
"{}.wav",
cmd_config
.sounds
.choose(&mut rand::thread_rng())
.unwrap()
);
// events::play(random_cmd_sound, app_handle);
// audio::play_sound(&sounds_directory.join(random_cmd_sound));
Ok(false)
}
_ => {
error!("Command type unknown");
Err("Command type unknown".into())
}
"stop_chaining" => Ok(false),
_ => Err(format!("Unknown command type: {}", cmd_config.action)),
}
}
pub fn list(from: &Vec<JCommandsList>) -> Vec<String> {
let mut out: Vec<String> = vec![];
for x in from.iter() {
out.push(String::from(x.path.to_str().unwrap()));
// out.append()
}
out
pub fn list_paths(commands: &[JCommandsList]) -> Vec<&Path> {
commands.iter().map(|x| x.path.as_path()).collect()
}

View File

@@ -1,5 +1,6 @@
use std::path::PathBuf;
use std::{collections::HashMap, path::PathBuf, sync::Arc};
use serde::{Serialize, Deserialize};
use parking_lot::RwLock;
#[derive(Serialize, Deserialize, Debug)]
pub struct JCommandsList {
@@ -11,7 +12,7 @@ pub struct JCommandsList {
#[derive(Serialize, Deserialize, Debug, Clone)]
#[derive(Serialize, Deserialize, Debug)]
pub struct JCommand {
pub id: String,
pub action: String,
@@ -31,8 +32,98 @@ pub struct JCommand {
#[serde(default)]
pub cli_args: Vec<String>,
// #[serde(default)]
// pub sounds: Vec<String>,
// Multi-language sounds
#[serde(default)]
pub sounds: Vec<String>,
pub sounds: HashMap<String, Vec<String>>,
// Multi-language phrases
#[serde(default)]
pub phrases: HashMap<String, Vec<String>>,
// CACHE
#[serde(skip, default)]
sounds_cache: RwLock<HashMap<String, Arc<Vec<String>>>>,
pub phrases: Vec<String>,
#[serde(skip, default)]
phrases_cache: RwLock<HashMap<String, Arc<Vec<String>>>>,
}
// custom Clone
impl Clone for JCommand {
fn clone(&self) -> Self {
Self {
id: self.id.clone(),
action: self.action.clone(),
description: self.description.clone(),
exe_path: self.exe_path.clone(),
exe_args: self.exe_args.clone(),
cli_cmd: self.cli_cmd.clone(),
cli_args: self.cli_args.clone(),
sounds: self.sounds.clone(),
phrases: self.phrases.clone(),
// empty caches for cloned instance
sounds_cache: RwLock::new(HashMap::new()),
phrases_cache: RwLock::new(HashMap::new()),
}
}
}
impl JCommand {
// get phrases for current language
pub fn get_phrases(&self, lang: &str) -> Arc<Vec<String>> {
if let Some(cached) = self.phrases_cache.read().get(lang) {
return Arc::clone(cached);
}
let result = Arc::new(self.resolve_localized(&self.phrases, lang));
self.phrases_cache.write().insert(lang.to_string(), Arc::clone(&result));
result
}
// get all phrases (for backwards compat)
pub fn get_all_phrases(&self) -> Vec<String> {
self.phrases.values().flatten().cloned().collect()
}
// get sounds for current language
pub fn get_sounds(&self, lang: &str) -> Arc<Vec<String>> {
if let Some(cached) = self.sounds_cache.read().get(lang) {
return Arc::clone(cached);
}
let result = Arc::new(self.resolve_localized(&self.sounds, lang));
self.sounds_cache.write().insert(lang.to_string(), Arc::clone(&result));
result
}
// get all sounds (for backwards compat)
pub fn get_all_sounds(&self) -> Vec<String> {
self.sounds.values().flatten().cloned().collect()
}
// shared fallback
fn resolve_localized(&self, map: &HashMap<String, Vec<String>>, lang: &str) -> Vec<String> {
// exact match
if let Some(values) = map.get(lang) {
return values.clone();
}
// fallback to "en"
if lang != "en" {
if let Some(values) = map.get("en") {
return values.clone();
}
}
// fallback to first available
map.values().next().cloned().unwrap_or_default()
}
}

View File

@@ -180,23 +180,72 @@ pub const NNNOISELESS_FRAME_SIZE: usize = 480;
pub const CMD_RATIO_THRESHOLD: f64 = 65f64;
pub const CMS_WAIT_DELAY: std::time::Duration = std::time::Duration::from_secs(15);
pub const ASSISTANT_GREET_PHRASES: [&str; 3] = ["greet1", "greet2", "greet3"];
pub const ASSISTANT_PHRASES_TBR: [&str; 17] = [
"джарвис",
"сэр",
"слушаю сэр",
"всегда к услугам",
"произнеси",
"ответь",
"покажи",
"скажи",
"давай",
"да сэр",
"к вашим услугам сэр",
"всегда к вашим услугам сэр",
"запрос выполнен сэр",
"выполнен сэр",
"есть",
"загружаю сэр",
"очень тонкое замечание сэр",
];
// pub const ASSISTANT_GREET_PHRASES: [&str; 3] = ["greet1", "greet2", "greet3"];
// pub const ASSISTANT_PHRASES_TBR: [&str; 17] = [
// "джарвис",
// "сэр",
// "слушаю сэр",
// "всегда к услугам",
// "произнеси",
// "ответь",
// "покажи",
// "скажи",
// "давай",
// "да сэр",
// "к вашим услугам сэр",
// "всегда к вашим услугам сэр",
// "запрос выполнен сэр",
// "выполнен сэр",
// "есть",
// "загружаю сэр",
// "очень тонкое замечание сэр",
// ];
pub fn get_wake_phrase(lang: &str) -> &'static str {
match lang {
"ru" => "джарвис",
"ua" => "джарвіс",
"en" => "jarvis",
_ => "jarvis",
}
}
pub fn get_phrases_to_remove(lang: &str) -> &'static [&'static str] {
match lang {
"ru" => &[
"джарвис", "сэр", "слушаю сэр", "всегда к услугам",
"произнеси", "ответь", "покажи", "скажи", "давай",
"да сэр", "к вашим услугам сэр", "загружаю сэр",
],
"ua" => &[
"джарвіс", "сер", "слухаю сер", "завжди до послуг",
"скажи", "покажи", "відповідай", "давай",
"так сер", "до ваших послуг сер",
],
"en" => &[
"jarvis", "sir", "yes sir", "at your service",
"please", "say", "show", "tell", "hey",
],
_ => &["jarvis"],
}
}
pub fn get_wake_grammar(lang: &str) -> &'static [&'static str] {
match lang {
"ru" => &[
"джарвис", "[unk]", "джон", "джони", "джей",
"джонстон", "привет", "давай",
],
"ua" => &[
"джарвіс", "[unk]", "джон", "джоні", "джей",
"привіт", "давай",
],
"en" => &[
"jarvis", "[unk]", "john", "johnny", "jay",
"hello", "hey", "hi",
],
_ => &["jarvis", "[unk]"],
}
}

View File

@@ -92,7 +92,9 @@ settings-picovoice-key = Picovoice Key
# settings - vosk
settings-auto-detect = Auto-detect
settings-vosk-model = Speech recognition model (Vosk)
settings-vosk-model-desc = Select Vosk model for speech recognition.
settings-vosk-model-desc =
Select Vosk model for speech recognition.
You can download models here: https://alphacephei.com/vosk/models
settings-models-not-found = Models not found
settings-models-hint = Place Vosk models in resources/vosk folder

View File

@@ -92,7 +92,9 @@ settings-picovoice-key = Ключ Picovoice
# settings - vosk
settings-auto-detect = Авто-определение
settings-vosk-model = Модель распознавания речи (Vosk)
settings-vosk-model-desc = Выберите модель Vosk для распознавания речи.
settings-vosk-model-desc =
Выберите модель Vosk для распознавания речи.
Вы можете скачать модели здесь: https://alphacephei.com/vosk/models
settings-models-not-found = Модели не найдены
settings-models-hint = Поместите модели Vosk в папку resources/vosk

View File

@@ -92,7 +92,9 @@ settings-picovoice-key = Ключ Picovoice
# settings - vosk
settings-auto-detect = Авто-визначення
settings-vosk-model = Модель розпізнавання мовлення (Vosk)
settings-vosk-model-desc = Виберіть модель Vosk для розпізнавання мовлення.
settings-vosk-model-desc =
Виберіть модель Vosk для розпізнавання мовлення.
Ви можете завантажити моделі тут: https://alphacephei.com/vosk/models
settings-models-not-found = Моделі не знайдено
settings-models-hint = Помістіть моделі Vosk в папку resources/vosk

View File

@@ -8,7 +8,7 @@ use std::path::PathBuf;
use std::fs;
use crate::commands::{self, JCommand, JCommandsList};
use crate::{APP_CONFIG_DIR};
use crate::{APP_CONFIG_DIR, i18n};
static CLASSIFIER: OnceCell<IntentClassifier> = OnceCell::const_new();
// static COMMANDS_MAP: OnceCell<Vec<JCommandsList>> = OnceCell::const_new();
@@ -16,7 +16,7 @@ static CLASSIFIER: OnceCell<IntentClassifier> = OnceCell::const_new();
const TRAINING_CACHE_FILE: &str = "intent_training.json";
const COMMANDS_HASH_FILE: &str = "commands_hash.txt";
pub async fn init(commands: &Vec<JCommandsList>) -> Result<(), String> {
pub async fn init(commands: &[JCommandsList]) -> Result<(), String> {
// parse commands first
// let commands = commands::parse_commands()?;
let current_hash = commands::commands_hash(&commands); // regen hash for current commands set
@@ -68,7 +68,7 @@ pub async fn classify(text: &str) -> Result<IntentPrediction, IntentError> {
}
// get command by intent ID
pub fn get_command(commands: &'static Vec<JCommandsList>, intent_id: &str) -> Option<(&'static PathBuf, &'static JCommand)> {
pub fn get_command(commands: &'static [JCommandsList], intent_id: &str) -> Option<(&'static PathBuf, &'static JCommand)> {
// let commands = COMMANDS_MAP.get()?;
for assistant_cmd in commands {
@@ -85,11 +85,19 @@ pub fn get_command(commands: &'static Vec<JCommandsList>, intent_id: &str) -> Op
// based on: https://github.com/ciresnave/intent-classifier/blob/main/examples/basic_usage.rs
async fn train_classifier(
classifier: &IntentClassifier,
commands: &Vec<JCommandsList>
commands: &[JCommandsList]
) -> Result<(), String> {
let lang = i18n::get_language();
info!("Training intent classifier for language: {}", lang);
let mut total_examples = 0;
for assistant_cmd in commands {
for cmd in &assistant_cmd.commands {
for phrase in &cmd.phrases {
// use language-specific phrases
let phrases = cmd.get_phrases(&lang);
for phrase in phrases.iter() {
let example = TrainingExample {
text: phrase.clone(),
intent: IntentId::from(cmd.id.as_str()),
@@ -99,9 +107,12 @@ async fn train_classifier(
classifier.add_training_example(example).await
.map_err(|e| format!("Failed to add training example: {}", e))?;
total_examples += 1;
}
}
}
info!("Added {} training examples for language '{}'", total_examples, lang);
Ok(())
}

View File

@@ -1,4 +1,4 @@
use crate::{config, stt};
use crate::{config, stt, i18n};
pub fn init() -> Result<(), ()> {
Ok(()) // nothing to init for Vosk
@@ -15,8 +15,12 @@ pub fn data_callback(frame_buffer: &[i16]) -> Option<i32> {
info!("Wake word candidate: '{}'", recognized);
// language-specific wake phrase
let lang = i18n::get_language();
let wake_phrase = config::get_wake_phrase(&lang);
// verify with seqdiff ratio
let wake_chars: Vec<char> = config::VOSK_FETCH_PHRASE.chars().collect();
let wake_chars: Vec<char> = wake_phrase.chars().collect();
let recognized_chars: Vec<char> = recognized.chars().collect();
let similarity = seqdiff::ratio(&wake_chars, &recognized_chars);

View File

@@ -4,8 +4,7 @@ use vosk::{DecodingState, Model, Recognizer};
use std::sync::Mutex;
// use crate::config::VOSK_MODEL_PATH;
use crate::config;
use crate::stt::vosk_models;
use crate::{stt::vosk_models, i18n, config};
use crate::DB;
static MODEL: OnceCell<Model> = OnceCell::new();
@@ -23,19 +22,14 @@ pub fn init_vosk() -> Result<(), String> {
let model = Model::new(model_path.to_str().unwrap())
.ok_or_else(|| format!("Failed to load Vosk model from: {}", model_path.display()))?;
// language-specific wake grammar
let lang = i18n::get_language();
let wake_grammar = config::get_wake_grammar(&lang);
info!("Wake grammar for '{}': {:?}", lang, wake_grammar);
//let mut recognizer = Recognizer::new(&model, 16000.0)
// .ok_or("Failed to create Vosk recognizer")?;
let wake_phrases: &[&str] = &[
config::VOSK_FETCH_PHRASE,
"[unk]",
"джон",
"джони",
"джей",
"джонстон",
"привет",
"давай",
];
let mut wake_recognizer = Recognizer::new_with_grammar(&model, 16000.0, wake_phrases)
let mut wake_recognizer = Recognizer::new_with_grammar(&model, 16000.0, wake_grammar)
.ok_or("Failed to create wake word recognizer")?;
wake_recognizer.set_max_alternatives(1); // required for confidence check later on

View File

@@ -78,15 +78,15 @@ fn load_voice_config(toml_path: &Path, voice_path: &Path) -> Result<structs::Voi
pub fn list_voices() -> Vec<structs::VoiceConfig> {
VOICES.get().cloned().unwrap_or_default()
pub fn list_voices() -> &'static [structs::VoiceConfig] {
VOICES.get().map(|v| v.as_slice()).unwrap_or(&[])
}
pub fn get_voice(voice_id: &str) -> Option<structs::VoiceConfig> {
VOICES.get()?.iter().find(|v| v.voice.id == voice_id).cloned()
pub fn get_voice(voice_id: &str) -> Option<&'static structs::VoiceConfig> {
VOICES.get()?.iter().find(|v| v.voice.id == voice_id)
}
pub fn get_current_voice() -> Option<structs::VoiceConfig> {
pub fn get_current_voice() -> Option<&'static structs::VoiceConfig> {
let current_id = CURRENT_VOICE_ID.get()?.read().clone();
get_voice(&current_id)
}
@@ -106,11 +106,11 @@ fn get_current_language() -> String {
fn find_sound_file(voice_path: &Path, lang: &str, sound_name: &str) -> Option<PathBuf> {
let extensions = ["mp3", "wav", "ogg"];
const EXTENSIONS: &[&str] = &["mp3", "wav", "ogg"];
let lang_path = voice_path.join(lang);
// try language subfolder first
for ext in &extensions {
// try language subfolder first (/en, /ua, /ru, etc)
for ext in EXTENSIONS {
let file_path = lang_path.join(format!("{}.{}", sound_name, ext));
if file_path.exists() {
return Some(file_path);
@@ -118,7 +118,7 @@ fn find_sound_file(voice_path: &Path, lang: &str, sound_name: &str) -> Option<Pa
}
// fallback to root voice folder
for ext in &extensions {
for ext in EXTENSIONS {
let file_path = voice_path.join(format!("{}.{}", sound_name, ext));
if file_path.exists() {
return Some(file_path);
@@ -128,29 +128,20 @@ fn find_sound_file(voice_path: &Path, lang: &str, sound_name: &str) -> Option<Pa
None
}
fn play_random_from(sounds: &[String]) {
fn play_random_from_list(voice_path: &Path, lang: &str, sounds: &[String]) {
if sounds.is_empty() {
return;
}
let voice = match get_current_voice() {
Some(v) => v,
None => {
warn!("No current voice set");
return;
}
};
let lang = get_current_language();
let sound_name = sounds.choose(&mut rand::thread_rng()).unwrap();
match find_sound_file(&voice.path, &lang, sound_name) {
match find_sound_file(voice_path, lang, sound_name) {
Some(path) => {
debug!("Playing: {:?}", path);
audio::play_sound(&path);
}
None => {
warn!("Sound not found: {} (lang: {}, voice: {})", sound_name, lang, voice.voice.id);
warn!("Sound not found: {} (lang: {})", sound_name, lang);
}
}
}
@@ -164,32 +155,53 @@ pub fn play(reaction: structs::Reaction) {
}
};
let lang = get_current_language();
let reactions = match voice.reactions.get(&lang) {
Some(r) => r,
None => {
warn!("No reactions for language: {}", lang);
return;
}
};
let sounds = match reaction {
structs::Reaction::Greet => {
// try time specific first
// try time-specific first
let time_specific = match time::TimeOfDay::now() {
time::TimeOfDay::Morning => &voice.reactions.greet_morning,
time::TimeOfDay::Day => &voice.reactions.greet_day,
time::TimeOfDay::Evening => &voice.reactions.greet_evening,
time::TimeOfDay::Night => &voice.reactions.greet_night,
time::TimeOfDay::Morning => &reactions.greet_morning,
time::TimeOfDay::Day => &reactions.greet_day,
time::TimeOfDay::Evening => &reactions.greet_evening,
time::TimeOfDay::Night => &reactions.greet_night,
};
if time_specific.is_empty() {
// fallback to simple run voice (not time specific)
&voice.reactions.greet
&reactions.greet
} else {
time_specific
}
}
structs::Reaction::Reply => &voice.reactions.reply,
structs::Reaction::Ok => &voice.reactions.ok,
structs::Reaction::NotFound => &voice.reactions.not_found,
structs::Reaction::Thanks => &voice.reactions.thanks,
structs::Reaction::Error => &voice.reactions.error,
structs::Reaction::Goodbye => &voice.reactions.goodbye,
structs::Reaction::Reply => &reactions.reply,
structs::Reaction::Ok => &reactions.ok,
structs::Reaction::NotFound => &reactions.not_found,
structs::Reaction::Thanks => &reactions.thanks,
structs::Reaction::Error => &reactions.error,
structs::Reaction::Goodbye => &reactions.goodbye,
};
play_random_from(sounds);
play_random_from_list(&voice.path, &lang, sounds);
}
pub fn play_random_from(sounds: &[String]) {
let voice = match get_current_voice() {
Some(v) => v,
None => {
warn!("No current voice set");
return;
}
};
play_random_from_list(&voice.path, &get_current_language(), sounds);
}
// Play a preview sound for a specific voice
@@ -204,10 +216,18 @@ pub fn play_preview(voice_id: &str) {
let lang = get_current_language();
// pick from reply or ok sounds for preview
let sounds: Vec<&String> = voice.reactions.reply.iter()
.chain(voice.reactions.ok.iter())
.chain(voice.reactions.greet.iter())
let reactions = match voice.reactions.get(&lang) {
Some(r) => r,
None => {
warn!("No reactions for language {} in voice {}", lang, voice_id);
return;
}
};
// pick from reply, ok, or greet sounds for preview
let sounds: Vec<&String> = reactions.reply.iter()
.chain(reactions.ok.iter())
.chain(reactions.greet.iter())
.collect();
if sounds.is_empty() {

View File

@@ -1,20 +1,26 @@
use std::path::PathBuf;
use std::{collections::HashMap, path::PathBuf};
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct VoiceConfig {
#[serde(skip)]
pub path: PathBuf,
pub voice: VoiceMeta,
pub reactions: VoiceReactions,
// Multi-language reactions
pub reactions: HashMap<String, VoiceReactions>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct VoiceMeta {
pub id: String,
pub name: String,
#[serde(default)]
pub author: String,
pub languages: Vec<String>,
}

View File

@@ -2,12 +2,12 @@ use jarvis_core::voices::{self, structs::VoiceConfig};
#[tauri::command]
pub fn list_voices() -> Vec<VoiceConfig> {
voices::list_voices()
voices::list_voices().to_vec()
}
#[tauri::command]
pub fn get_voice(voice_id: String) -> Option<VoiceConfig> {
voices::get_voice(&voice_id)
voices::get_voice(&voice_id).cloned()
}
#[tauri::command]