Tray options implementation + Voice system rewrite + build fixes

This commit is contained in:
Priler 2026-01-07 23:29:46 +05:00
parent 412acb7e2d
commit 47b7e7a65d
117 changed files with 1300 additions and 2334 deletions

View file

@ -57,9 +57,17 @@ pub fn init() -> Result<(), ()> {
}
pub fn play_sound(filename: &PathBuf) {
let audio_type = match AUDIO_TYPE.get() {
Some(t) => t,
None => {
warn!("Audio not initialized, cannot play: {}", filename.display());
return;
}
};
info!("Playing {}", filename.display());
match AUDIO_TYPE.get().unwrap() {
match audio_type {
AudioType::Rodio => {
rodio::play_sound(filename, true);
}
@ -75,18 +83,11 @@ pub fn get_sound_directory() -> Option<PathBuf> {
SOUND_DIR.join(&s.voice)
};
match voice_path.exists() && voice_path.cmp(&SOUND_DIR) != Ordering::Equal {
match voice_path.exists() {
true => Some(voice_path),
_ => {
let default_voice_path = SOUND_DIR.join(config::DEFAULT_VOICE);
match default_voice_path.exists() {
true => Some(default_voice_path),
_ => {
error!("No sounds found. Search path - {:?}", voice_path);
None
}
}
error!("No sounds folder found. Search path - {:?}", voice_path);
None
}
}
}

View file

@ -1,6 +1,7 @@
use crate::APP_DIR;
use rand::prelude::*;
use seqdiff::ratio;
use serde_yaml;
// use serde_yaml;
use std::path::Path;
use std::{fs, fs::File};
@ -14,15 +15,16 @@ pub use structs::*;
use std::collections::HashMap;
use crate::{audio, config};
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 cmd_dirs = fs::read_dir(config::COMMANDS_PATH)
.map_err(|e| format!("Error reading commands directory: {}", e))?;
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 {
@ -204,7 +206,7 @@ pub fn execute_command(
cmd_config: &JCommand,
// app_handle: &tauri::AppHandle,
) -> Result<bool, String> {
let sounds_directory = audio::get_sound_directory().unwrap();
// let sounds_directory = audio::get_sound_directory().unwrap();
match cmd_config.action.as_str() {
"voice" => {
@ -217,7 +219,7 @@ pub fn execute_command(
.unwrap()
);
// events::play(random_cmd_sound, app_handle);
audio::play_sound(&sounds_directory.join(random_cmd_sound));
// audio::play_sound(&sounds_directory.join(random_cmd_sound));
Ok(true)
}
@ -242,7 +244,7 @@ pub fn execute_command(
.unwrap()
);
// events::play(random_cmd_sound, app_handle);
audio::play_sound(&sounds_directory.join(random_cmd_sound));
// audio::play_sound(&sounds_directory.join(random_cmd_sound));
Ok(true)
} else {
@ -264,7 +266,7 @@ pub fn execute_command(
.unwrap()
);
// events::play(random_cmd_sound, app_handle);
audio::play_sound(&sounds_directory.join(random_cmd_sound));
// audio::play_sound(&sounds_directory.join(random_cmd_sound));
Ok(true)
}
@ -284,7 +286,7 @@ pub fn execute_command(
.unwrap()
);
// events::play(random_cmd_sound, app_handle);
audio::play_sound(&sounds_directory.join(random_cmd_sound));
// audio::play_sound(&sounds_directory.join(random_cmd_sound));
std::thread::sleep(Duration::from_secs(2));
std::process::exit(0);
@ -299,7 +301,7 @@ pub fn execute_command(
.unwrap()
);
// events::play(random_cmd_sound, app_handle);
audio::play_sound(&sounds_directory.join(random_cmd_sound));
// audio::play_sound(&sounds_directory.join(random_cmd_sound));
Ok(false)
}

View file

@ -1,7 +1,7 @@
use std::path::PathBuf;
use serde::Deserialize;
use serde::{Serialize, Deserialize};
#[derive(Deserialize, Debug)]
#[derive(Serialize, Deserialize, Debug)]
pub struct JCommandsList {
#[serde(skip)]
pub path: PathBuf,
@ -11,7 +11,7 @@ pub struct JCommandsList {
#[derive(Deserialize, Debug, Clone)]
#[derive(Serialize, Deserialize, Debug, Clone)]
pub struct JCommand {
pub id: String,
pub action: String,

View file

@ -70,7 +70,9 @@ pub const DEFAULT_WAKE_WORD_ENGINE: WakeWordEngine = WakeWordEngine::Vosk;
pub const DEFAULT_INTENT_RECOGNITION_ENGINE: IntentRecognitionEngine = IntentRecognitionEngine::IntentClassifier;
pub const DEFAULT_SPEECH_TO_TEXT_ENGINE: SpeechToTextEngine = SpeechToTextEngine::Vosk;
pub const DEFAULT_VOICE: &str = "jarvis-og";
pub const DEFAULT_VOICE: &str = "jarvis-remaster";
pub const SOUND_PATH: &str = "resources/sound"; // extended from SOUND_DIR (resources/sound)
pub const VOICES_PATH: &str = "voices"; // extended from SOUND_PATH (resources/sound)
pub const BUNDLE_IDENTIFIER: &str = "com.priler.jarvis";
pub const DB_FILE_NAME: &str = "app.db";
@ -81,7 +83,7 @@ pub const REPOSITORY_LINK: Option<&str> = option_env!("CARGO_PKG_REPOSITORY");
pub const TG_OFFICIAL_LINK: Option<&str> = Some("https://t.me/howdyho_official");
pub const FEEDBACK_LINK: Option<&str> = Some("https://t.me/jarvis_feedback_bot");
pub const SUPPORT_BOOSTY_LINK: Option<&str> = Some("https://boosty.to/howdyho");
pub const SUPPORT_PATREON_LINK: Option<&str> = Some("https://www.patreon.com/user?u=22843414");
pub const SUPPORT_PATREON_LINK: Option<&str> = Some("https://www.patreon.com/c/priler");
/*
Tray.

View file

@ -54,7 +54,9 @@ settings-microphone = Microphone
settings-microphone-desc = The assistant will listen to this microphone.
settings-mic-default = Default (System)
settings-voice = Assistant voice
settings-voice-desc = Not all commands work with all sound packs.
settings-voice-desc =
Not all commands work with all sound packs.
Click to listen the preview of sound.
settings-wake-word-engine = Wake word engine
settings-wake-word-desc = Choose the engine for wake word recognition.
settings-stt-engine = Speech recognition
@ -117,3 +119,8 @@ notification-saved = Settings saved!
notification-error = Error
notification-assistant-started = Assistant started
notification-assistant-stopped = Assistant stopped
# ETC
search-error-not-running = Assistant is not running
search-error-failed = Failed to execute command
settings-no-voices = No voices found

View file

@ -54,7 +54,9 @@ settings-microphone = Микрофон
settings-microphone-desc = Его будет слушать ассистент.
settings-mic-default = По умолчанию (Система)
settings-voice = Голос ассистента
settings-voice-desc = Не все команды работают со всеми звуковыми пакетами.
settings-voice-desc =
Не все команды работают со всеми звуковыми пакетами.
Кликните, чтобы прослушать как звучит голос.
settings-wake-word-engine = Движок активации
settings-wake-word-desc = Выберите нейросеть для распознавания активационной фразы.
settings-stt-engine = Распознавание речи
@ -117,3 +119,8 @@ notification-saved = Настройки сохранены!
notification-error = Ошибка
notification-assistant-started = Ассистент запущен
notification-assistant-stopped = Ассистент остановлен
# ETC
search-error-not-running = Ассистент не запущен
search-error-failed = Не удалось выполнить команду
settings-no-voices = Голоса не найдены

View file

@ -54,7 +54,9 @@ settings-microphone = Мікрофон
settings-microphone-desc = Його буде слухати асистент.
settings-mic-default = За замовчуванням (Система)
settings-voice = Голос асистента
settings-voice-desc = Не всі команди працюють з усіма звуковими пакетами.
settings-voice-desc =
Не всі команди працюють з усіма звуковими пакетами.
Натисніть, щоб прослухати як звучить голос.
settings-wake-word-engine = Рушій активації
settings-wake-word-desc = Виберіть нейромережу для розпізнавання активаційної фрази.
settings-stt-engine = Розпізнавання мовлення
@ -117,3 +119,9 @@ notification-saved = Налаштування збережено!
notification-error = Помилка
notification-assistant-started = Асистент запущено
notification-assistant-stopped = Асистент зупинено
# ETC
search-error-not-running = Асистент не запущено
search-error-failed = Не вдалося виконати команду
settings-no-voices = Голоси не знайдено

View file

@ -2,4 +2,4 @@ mod events;
mod server;
pub use events::{IpcAction, IpcEvent};
pub use server::{init, send, set_action_handler, start_server, IPC_ADDR, IPC_PORT};
pub use server::{init, send, set_action_handler, start_server, has_clients, IPC_ADDR, IPC_PORT};

View file

@ -30,6 +30,9 @@ pub enum IpcEvent {
// Pong response
Pong,
// request GUI to reveal/focus window
RevealWindow,
}
// Actions sent from GUI to jarvis-app
@ -47,4 +50,7 @@ pub enum IpcAction {
// Mute/unmute listening
SetMuted { muted: bool },
// Execute text command
TextCommand { text: String },
}

View file

@ -187,4 +187,12 @@ async fn handle_client(
}
info!("IPC: Client disconnected: {}", peer_addr);
}
pub fn has_clients() -> bool {
if let Some(tx) = BROADCAST_TX.get() {
tx.receiver_count() > 0
} else {
false
}
}

View file

@ -7,6 +7,8 @@ use std::path::PathBuf;
#[macro_use]
extern crate log;
pub mod time;
pub mod audio;
pub mod commands;
pub mod config;
@ -32,6 +34,8 @@ pub mod audio_processing;
#[cfg(feature = "jarvis_app")]
pub mod ipc;
pub mod voices;
// shared statics
// pub static APP_DIR: Lazy<PathBuf> = Lazy::new(|| std::env::current_dir().unwrap());
pub static APP_DIR: Lazy<PathBuf> = Lazy::new(|| {
@ -40,7 +44,7 @@ pub static APP_DIR: Lazy<PathBuf> = Lazy::new(|| {
.and_then(|p| p.parent().map(|p| p.to_path_buf()))
.unwrap_or_else(|| std::env::current_dir().unwrap())
});
pub static SOUND_DIR: Lazy<PathBuf> = Lazy::new(|| APP_DIR.clone().join("resources/sound"));
pub static SOUND_DIR: Lazy<PathBuf> = Lazy::new(|| APP_DIR.clone().join(config::SOUND_PATH));
pub static APP_DIRS: OnceCell<AppDirs> = OnceCell::new();
pub static APP_CONFIG_DIR: OnceCell<PathBuf> = OnceCell::new();
pub static APP_LOG_DIR: OnceCell<PathBuf> = OnceCell::new();

View file

@ -0,0 +1,2 @@
pub mod structs;
pub use structs::*;

View file

@ -0,0 +1,21 @@
use chrono::Timelike;
#[derive(Debug, Clone, Copy)]
pub enum TimeOfDay {
Morning, // 5:00 - 11:59
Day, // 12:00 - 16:59
Evening, // 17:00 - 21:59
Night, // 22:00 - 4:59
}
impl TimeOfDay {
pub fn now() -> Self {
let hour = chrono::Local::now().hour();
match hour {
5..=11 => TimeOfDay::Morning,
12..=16 => TimeOfDay::Day,
17..=21 => TimeOfDay::Evening,
_ => TimeOfDay::Night,
}
}
}

View file

@ -0,0 +1,234 @@
use std::fs;
use std::path::{Path, PathBuf};
use rand::prelude::*;
use once_cell::sync::OnceCell;
use parking_lot::RwLock;
// use chrono::Timelike;
use crate::{DB, SOUND_DIR, audio, config, time};
pub mod structs;
static VOICES: OnceCell<Vec<structs::VoiceConfig>> = OnceCell::new();
static CURRENT_VOICE_ID: OnceCell<RwLock<String>> = OnceCell::new();
pub fn init(default_voice: &str) -> Result<(), String> {
CURRENT_VOICE_ID.get_or_init(|| RwLock::new(default_voice.to_string()));
let voices = scan_voices()?;
if voices.is_empty() {
return Err("No voices found".into());
}
info!("Loaded {} voice(s): {:?}",
voices.len(),
voices.iter().map(|v| &v.voice.id).collect::<Vec<_>>()
);
VOICES.set(voices).map_err(|_| "Voices already initialized")?;
Ok(())
}
pub fn scan_voices() -> Result<Vec<structs::VoiceConfig>, String> {
let voices_dir = SOUND_DIR.join(&config::VOICES_PATH);
if !voices_dir.exists() {
return Err(format!("Voices directory not found: {:?}", voices_dir));
}
let mut voices = Vec::new();
let entries = fs::read_dir(&voices_dir)
.map_err(|e| format!("Failed to read voices directory: {}", e))?;
for entry in entries.flatten() {
let voice_path = entry.path();
if !voice_path.is_dir() {
continue;
}
let toml_path = voice_path.join("voice.toml");
if !toml_path.exists() {
warn!("Voice folder {:?} missing voice.toml, skipping", voice_path);
continue;
}
match load_voice_config(&toml_path, &voice_path) {
Ok(config) => voices.push(config),
Err(e) => warn!("Failed to load voice {:?}: {}", voice_path, e),
}
}
Ok(voices)
}
fn load_voice_config(toml_path: &Path, voice_path: &Path) -> Result<structs::VoiceConfig, String> {
let content = fs::read_to_string(toml_path)
.map_err(|e| format!("Failed to read voice.toml: {}", e))?;
let mut config: structs::VoiceConfig = toml::from_str(&content)
.map_err(|e| format!("Failed to parse voice.toml: {}", e))?;
config.path = voice_path.to_path_buf();
Ok(config)
}
pub fn list_voices() -> Vec<structs::VoiceConfig> {
VOICES.get().cloned().unwrap_or_default()
}
pub fn get_voice(voice_id: &str) -> Option<structs::VoiceConfig> {
VOICES.get()?.iter().find(|v| v.voice.id == voice_id).cloned()
}
pub fn get_current_voice() -> Option<structs::VoiceConfig> {
let current_id = CURRENT_VOICE_ID.get()?.read().clone();
get_voice(&current_id)
}
pub fn set_current_voice(voice_id: &str) {
if let Some(lock) = CURRENT_VOICE_ID.get() {
*lock.write() = voice_id.to_string();
}
}
fn get_current_language() -> String {
DB.get()
.map(|db| db.read().language.clone())
.unwrap_or_else(|| "ru".to_string())
}
fn find_sound_file(voice_path: &Path, lang: &str, sound_name: &str) -> Option<PathBuf> {
let extensions = ["mp3", "wav", "ogg"];
let lang_path = voice_path.join(lang);
// try language subfolder first
for ext in &extensions {
let file_path = lang_path.join(format!("{}.{}", sound_name, ext));
if file_path.exists() {
return Some(file_path);
}
}
// fallback to root voice folder
for ext in &extensions {
let file_path = voice_path.join(format!("{}.{}", sound_name, ext));
if file_path.exists() {
return Some(file_path);
}
}
None
}
fn play_random_from(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) {
Some(path) => {
debug!("Playing: {:?}", path);
audio::play_sound(&path);
}
None => {
warn!("Sound not found: {} (lang: {}, voice: {})", sound_name, lang, voice.voice.id);
}
}
}
pub fn play(reaction: structs::Reaction) {
let voice = match get_current_voice() {
Some(v) => v,
None => {
warn!("No current voice set");
return;
}
};
let sounds = match reaction {
structs::Reaction::Greet => {
// 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,
};
if time_specific.is_empty() {
// fallback to simple run voice (not time specific)
&voice.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,
};
play_random_from(sounds);
}
// Play a preview sound for a specific voice
pub fn play_preview(voice_id: &str) {
let voice = match get_voice(voice_id) {
Some(v) => v,
None => {
warn!("Voice not found for preview: {}", voice_id);
return;
}
};
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())
.collect();
if sounds.is_empty() {
warn!("No preview sounds for voice: {}", voice_id);
return;
}
let sound_name = sounds.choose(&mut rand::thread_rng()).unwrap();
if let Some(path) = find_sound_file(&voice.path, &lang, sound_name) {
debug!("Playing preview: {:?}", path);
audio::play_sound(&path);
}
}
// shortcuts
pub fn play_greet() { play(structs::Reaction::Greet); } // app startup
pub fn play_reply() { play(structs::Reaction::Reply); } // wake word detected
pub fn play_ok() { play(structs::Reaction::Ok); } // command executed
pub fn play_not_found() { play(structs::Reaction::NotFound); }
pub fn play_thanks() { play(structs::Reaction::Thanks); }
pub fn play_error() { play(structs::Reaction::Error); }
pub fn play_goodbye() { play(structs::Reaction::Goodbye); }

View file

@ -0,0 +1,70 @@
use std::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,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct VoiceMeta {
pub id: String,
pub name: String,
#[serde(default)]
pub author: String,
pub languages: Vec<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct VoiceReactions {
// app startup (time-based or generic)
#[serde(default)]
pub greet: Vec<String>,
#[serde(default)]
pub greet_morning: Vec<String>,
#[serde(default)]
pub greet_day: Vec<String>,
#[serde(default)]
pub greet_evening: Vec<String>,
#[serde(default)]
pub greet_night: Vec<String>,
// wake word detected
#[serde(default)]
pub reply: Vec<String>,
// command executed
#[serde(default)]
pub ok: Vec<String>,
// command not found
#[serde(default)]
pub not_found: Vec<String>,
// thank you
#[serde(default)]
pub thanks: Vec<String>,
// error
#[serde(default)]
pub error: Vec<String>,
// shutdown
#[serde(default)]
pub goodbye: Vec<String>,
}
#[derive(Debug, Clone, Copy)]
pub enum Reaction {
Greet, // app startup
Reply, // wake word detected
Ok, // command executed
NotFound,
Thanks,
Error,
Goodbye,
}