mirror of
https://github.com/Priler/jarvis.git
synced 2026-05-26 07:08:11 +00:00
Commands/voices multilanguage support + Ukranian vosk model added
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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]"],
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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(())
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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(¤t_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() {
|
||||
|
||||
@@ -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>,
|
||||
}
|
||||
|
||||
|
||||
@@ -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]
|
||||
|
||||
Reference in New Issue
Block a user