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

@ -1,26 +1,27 @@
use std::sync::mpsc::Receiver;
use std::time::SystemTime;
use jarvis_core::{audio, audio_processing, commands, config, listener, recorder, stt, COMMANDS_LIST, intent, ipc::{self, IpcEvent}};
use jarvis_core::{audio, audio_processing, commands, config, listener, recorder, stt, COMMANDS_LIST, intent, voices, ipc::{self, IpcEvent}};
use rand::prelude::*;
use crate::should_stop;
pub fn start() -> Result<(), ()> {
pub fn start(text_cmd_rx: Receiver<String>) -> Result<(), ()> {
// start the loop
main_loop()
main_loop(text_cmd_rx)
}
fn main_loop() -> Result<(), ()> {
fn main_loop(text_cmd_rx: Receiver<String>) -> Result<(), ()> {
let rt = tokio::runtime::Runtime::new().expect("Failed to create tokio runtime");
let mut start: SystemTime;
let sounds_directory = audio::get_sound_directory().unwrap();
// let sounds_directory = audio::get_sound_directory().unwrap();
let frame_length: usize = 512; // default for every wake-word engine
let mut frame_buffer: Vec<i16> = vec![0; frame_length];
let mut silence_frames: u32 = 0;
// play some run phrase
// @TODO. Different sounds? Or better make it via commands or upcoming events system.
audio::play_sound(&sounds_directory.join("run.wav"));
// play some startup phrase
// audio::play_sound(&sounds_directory.join("run.wav"));
voices::play_greet();
// start recording
match recorder::start_recording() {
@ -39,10 +40,17 @@ fn main_loop() -> Result<(), ()> {
// check for stop signal
if should_stop() {
info!("Stop signal received, shutting down...");
voices::play_goodbye();
ipc::send(IpcEvent::Stopping);
break;
}
// check for text commands
if let Ok(text) = text_cmd_rx.try_recv() {
process_text_command(&text, &rt);
continue 'wake_word;
}
// read from microphone
recorder::read_microphone(&mut frame_buffer);
@ -70,14 +78,10 @@ fn main_loop() -> Result<(), ()> {
start = SystemTime::now();
silence_frames = 0;
// play some greet phrase
// play some reply phrase
// @TODO. Make it via commands or upcoming events system.
audio::play_sound(&sounds_directory.join(format!(
"{}.wav",
config::ASSISTANT_GREET_PHRASES
.choose(&mut rand::thread_rng())
.unwrap()
)));
voices::play_reply();
// notify GUI we're listening
ipc::send(IpcEvent::Listening);
@ -125,12 +129,13 @@ fn main_loop() -> Result<(), ()> {
info!("Wake word detected during chaining, reactivating...");
// play greet sound
audio::play_sound(&sounds_directory.join(format!(
"{}.wav",
config::ASSISTANT_GREET_PHRASES
.choose(&mut rand::thread_rng())
.unwrap()
)));
// audio::play_sound(&sounds_directory.join(format!(
// "{}.wav",
// config::ASSISTANT_GREET_PHRASES
// .choose(&mut rand::thread_rng())
// .unwrap()
// )));
voices::play_reply();
// reset timer and continue listening
start = SystemTime::now();
@ -152,59 +157,8 @@ fn main_loop() -> Result<(), ()> {
continue 'voice_recognition;
}
// infer command (try intent recognition first, fallback to levenshtein)
let cmd_result = if let Some((intent_id, confidence)) =
rt.block_on(intent::classify(&recognized_voice))
{
info!("Intent recognized: {} (confidence: {:.2})", intent_id, confidence);
intent::get_command_by_intent(COMMANDS_LIST.get().unwrap(), &intent_id)
} else {
info!("Intent not recognized, trying levenshtein fallback ...");
commands::fetch_command(&recognized_voice, COMMANDS_LIST.get().unwrap())
};
if let Some((cmd_path, cmd_config)) = cmd_result {
info!("Command found: {:?}", cmd_path);
info!("Executing!");
// execute the command
match commands::execute_command(&cmd_path, &cmd_config) {
Ok(chain) => {
// success
info!("Command executed successfully.");
// notify GUI
ipc::send(IpcEvent::CommandExecuted {
id: cmd_config.id.clone(),
success: true,
});
if chain {
// chain commands
start = SystemTime::now();
} else {
// skip, if chaining is not required
start = start
.checked_sub(core::time::Duration::from_secs(1000))
.unwrap();
}
continue 'voice_recognition; // continue voice recognition
}
Err(msg) => {
// fail
error!("Error executing command: {}", msg);
ipc::send(IpcEvent::CommandExecuted {
id: cmd_config.id.clone(),
success: false,
});
ipc::send(IpcEvent::Error {
message: msg.to_string(),
});
}
}
}
// execute command (shared executor)
execute_command(&recognized_voice, &rt);
// return to wake-word listening after command execution (no matter successful or not)
break 'voice_recognition;
@ -236,10 +190,93 @@ fn main_loop() -> Result<(), ()> {
Ok(())
}
// process text command from GUI
fn process_text_command(text: &str, rt: &tokio::runtime::Runtime) {
info!("Processing text command: {}", text);
ipc::send(IpcEvent::SpeechRecognized { text: text.to_string() });
// filter text same as voice
let mut filtered = text.to_lowercase();
for tbr in config::ASSISTANT_PHRASES_TBR {
filtered = filtered.replace(tbr, "");
}
let filtered = filtered.trim();
if filtered.is_empty() {
ipc::send(IpcEvent::Idle);
return;
}
execute_command(filtered, rt);
}
// shared command execution logic (manual & voice)
fn execute_command(text: &str, rt: &tokio::runtime::Runtime) {
let commands_list = match COMMANDS_LIST.get() {
Some(c) => c,
None => {
ipc::send(IpcEvent::Error { message: "Commands not loaded".to_string() });
ipc::send(IpcEvent::Idle);
return;
}
};
// let sounds_directory = audio::get_sound_directory().unwrap();
// try intent recognition first, fallback to levenshtein
let cmd_result = if let Some((intent_id, confidence)) =
rt.block_on(intent::classify(text))
{
info!("Intent recognized: {} (confidence: {:.2})", intent_id, confidence);
intent::get_command_by_intent(commands_list, &intent_id)
} else {
info!("Intent not recognized, trying levenshtein fallback...");
commands::fetch_command(text, commands_list)
};
if let Some((cmd_path, cmd_config)) = cmd_result {
info!("Command found: {:?}", cmd_path);
match commands::execute_command(&cmd_path, &cmd_config) {
Ok(_) => {
info!("Command executed successfully");
voices::play_ok(); // command executed sound
ipc::send(IpcEvent::CommandExecuted {
id: cmd_config.id.clone(),
success: true,
});
}
Err(msg) => {
error!("Error executing command: {}", msg);
voices::play_error();
ipc::send(IpcEvent::CommandExecuted {
id: cmd_config.id.clone(),
success: false,
});
ipc::send(IpcEvent::Error { message: msg.to_string() });
}
}
} else {
info!("No command found for: {}", text);
// play "not understood" sound
// audio::play_sound(&sounds_directory.join("not_understand.wav"));
voices::play_not_found();
ipc::send(IpcEvent::Error {
message: format!("Command not found: {}", text)
});
}
ipc::send(IpcEvent::Idle);
}
fn keyword_callback(keyword_index: i32) {}
pub fn close(code: i32) {
info!("Closing application.");
voices::play_goodbye();
ipc::send(IpcEvent::Stopping);
std::process::exit(code);
}

View file

@ -1,12 +1,13 @@
use parking_lot::RwLock;
use std::sync::Arc;
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::mpsc;
// include core
use jarvis_core::{
audio, audio_processing, commands, config, db, listener, recorder, stt, intent,
ipc::{self, IpcAction},
i18n,
i18n, voices,
APP_CONFIG_DIR, APP_LOG_DIR, COMMANDS_LIST, DB,
};
@ -41,6 +42,12 @@ fn main() -> Result<(), String> {
DB.set(Arc::new(RwLock::new(db::init_settings())))
.expect("DB already initialized");
// init voices
let voice_id = DB.get().unwrap().read().voice.clone();
if let Err(e) = voices::init(&voice_id) {
warn!("Failed to init voices: {}", e);
}
// init i18n
i18n::init(&DB.get().unwrap().read().language);
@ -108,7 +115,10 @@ fn main() -> Result<(), String> {
info!("Initializing IPC...");
ipc::init();
ipc::set_action_handler(|action| {
// channel for text commands (manually written in the GUI)
let (text_cmd_tx, text_cmd_rx) = mpsc::channel::<String>();
ipc::set_action_handler(move |action| {
match action {
IpcAction::Stop => {
info!("Received stop command from GUI");
@ -122,6 +132,15 @@ fn main() -> Result<(), String> {
info!("Received mute request: {}", muted);
// TODO: implement mute
}
IpcAction::TextCommand { text } => {
info!("Received text command: {}", text);
if let Err(e) = text_cmd_tx.send(text) {
error!("Failed to send text command to app: {}", e);
}
}
IpcAction::Ping => {
// handled internally by server
}
_ => {}
}
});
@ -134,7 +153,7 @@ fn main() -> Result<(), String> {
// start the app (in the background thread)
std::thread::spawn(|| {
let _ = app::start();
let _ = app::start(text_cmd_rx);
});
tray::init_blocking();

View file

@ -6,11 +6,12 @@ use tray_icon::{
};
use winit::event_loop::{ControlFlow, EventLoopBuilder};
use image;
use std::process::Command;
#[cfg(target_os="windows")]
use winit::platform::windows::EventLoopBuilderExtWindows;
use jarvis_core::{config, i18n};
use jarvis_core::{config, i18n, ipc::{self, IpcEvent}};
const TRAY_ICON_BYTES: &[u8] = include_bytes!("../../../resources/icons/32x32.png");
@ -103,8 +104,14 @@ pub fn init_blocking() {
fn handle_menu_event(event: &MenuEvent) {
match event.id.0.as_str() {
"exit" => std::process::exit(0),
"restart" => { /* restart logic */ }
"settings" => { /* open settings */ }
"restart" => {
info!("Restarting from tray menu...");
restart_app();
}
"settings" => {
info!("Opening settings from tray menu...");
open_settings();
}
_ => {}
}
}
@ -129,3 +136,74 @@ fn load_icon(path: &std::path::Path) -> tray_icon::Icon {
};
tray_icon::Icon::from_rgba(icon_rgba, icon_width, icon_height).expect("Failed to open icon")
}
fn restart_app() {
// get current executable path
let exe_path = match std::env::current_exe() {
Ok(path) => path,
Err(e) => {
error!("Failed to get executable path: {}", e);
return;
}
};
// spawn new instance
match Command::new(&exe_path).spawn() {
Ok(_) => {
info!("Spawned new instance, exiting current...");
std::process::exit(0);
}
Err(e) => {
error!("Failed to restart: {}", e);
}
}
}
fn open_settings() {
// check if jarvis-gui is connected via IPC
if ipc::has_clients() {
// gui is running, send reveal event
info!("GUI is connected, sending reveal event");
ipc::send(IpcEvent::RevealWindow);
} else {
// gui not running, launch it
info!("GUI not connected, launching jarvis-gui");
launch_gui();
}
}
fn launch_gui() {
let exe_path = match std::env::current_exe() {
Ok(path) => path,
Err(e) => {
error!("Failed to get executable path: {}", e);
return;
}
};
// jarvis-gui should be in same directory as jarvis-app
let gui_path = exe_path.parent()
.map(|p| p.join(get_gui_executable_name()))
.unwrap_or_else(|| get_gui_executable_name().into());
info!("Launching GUI: {:?}", gui_path);
match Command::new(&gui_path).spawn() {
Ok(_) => {
info!("Launched jarvis-gui");
}
Err(e) => {
error!("Failed to launch jarvis-gui: {}", e);
}
}
}
#[cfg(target_os = "windows")]
fn get_gui_executable_name() -> &'static str {
"jarvis-gui.exe"
}
#[cfg(not(target_os = "windows"))]
fn get_gui_executable_name() -> &'static str {
"jarvis-gui"
}