AI models shared registry + Code cleanup + Better async handling + Some fixes, etc

This commit is contained in:
Priler 2026-02-18 21:08:48 +05:00
parent a8ff3442ff
commit 520b98143f
62 changed files with 1683 additions and 1239 deletions

View file

@ -13,12 +13,11 @@ enum VadState {
VoiceActive,
}
pub fn start(text_cmd_rx: Receiver<String>) -> Result<(), ()> {
main_loop(text_cmd_rx)
pub fn start(text_cmd_rx: Receiver<String>, rt: &tokio::runtime::Runtime) -> Result<(), ()> {
main_loop(text_cmd_rx, rt)
}
fn main_loop(text_cmd_rx: Receiver<String>) -> Result<(), ()> {
let rt = tokio::runtime::Runtime::new().expect("Failed to create tokio runtime");
fn main_loop(text_cmd_rx: Receiver<String>, rt: &tokio::runtime::Runtime) -> Result<(), ()> {
let frame_length: usize = 512;
let sample_rate: usize = 16000;
let mut frame_buffer: Vec<i16> = vec![0; frame_length];

View file

@ -1,5 +1,4 @@
use jarvis_core::slots;
use parking_lot::RwLock;
use std::sync::Arc;
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::mpsc;
@ -8,7 +7,7 @@ use std::sync::mpsc;
use jarvis_core::{
audio, audio_processing, commands, config, db, listener, recorder, stt, intent,
ipc::{self, IpcAction},
i18n, voices,
i18n, voices, models,
APP_CONFIG_DIR, APP_LOG_DIR, COMMANDS_LIST, DB,
};
@ -39,41 +38,39 @@ fn main() -> Result<(), String> {
info!("Config directory is: {}", APP_CONFIG_DIR.get().unwrap().display());
info!("Log directory is: {}", APP_LOG_DIR.get().unwrap().display());
// initialize database (settings)
DB.set(Arc::new(RwLock::new(db::init_settings())))
// initialize settings
let settings = db::init();
// set global DB (for core modules that read settings at init time)
DB.set(settings.arc().clone())
.expect("DB already initialized");
// init voices
let voice_id = DB.get().unwrap().read().voice.clone();
if let Err(e) = voices::init(&voice_id) {
let voice_id = settings.lock().voice.clone();
let language = settings.lock().language.clone();
if let Err(e) = voices::init(&voice_id, &language) {
warn!("Failed to init voices: {}", e);
}
// init i18n
i18n::init(&DB.get().unwrap().read().language);
// initialize tray
// @TODO. macOS currently not supported for tray functionality,
// due to the separate thread in which tray processing works,
// but macOS requires it to be processed in the main thread only
// The solution may be to include wake-word detection etc. in the winit event loop. (only for MacOS, though?)
//#[cfg(not(target_os = "macos"))]
//tray::init();
i18n::init(&settings.lock().language);
// init recorder
if recorder::init().is_err() {
app::close(1);
}
// init models registry (scans available AI models)
if let Err(e) = models::init() {
warn!("Models registry init failed: {}", e);
}
// init stt engine
if stt::init().is_err() {
// @TODO. Allow continuing even without STT, if commands is using keywords or smthng?
app::close(1); // cannot continue without stt
}
// init tts engine
// none for now (Silero-rs coming)
// init commands
info!("Initializing commands.");
let cmds = match commands::parse_commands() {
@ -93,12 +90,17 @@ fn main() -> Result<(), String> {
}
// init wake-word engine
if listener::init().is_err() {
app::close(1); // cannot continue without wake-word engine
if let Err(e) = listener::init() {
error!("Wake-word engine init failed: {}", e);
app::close(1);
}
// shared async runtime for intent classification, IPC, etc.
let rt = Arc::new(
tokio::runtime::Runtime::new().expect("Failed to create tokio runtime")
);
// init intent-recognition engine
let rt = tokio::runtime::Runtime::new().expect("Failed to create tokio runtime");
rt.block_on(async {
if let Err(e) = intent::init(COMMANDS_LIST.get().unwrap()).await {
error!("Failed to initialize intent classifier: {}", e);
@ -149,22 +151,23 @@ fn main() -> Result<(), String> {
}
});
// start WebSocket server for ipc
std::thread::spawn(|| {
let rt = tokio::runtime::Runtime::new().expect("Failed to create tokio runtime for IPC");
rt.block_on(ipc::start_server());
// start WebSocket server on the shared runtime
let ipc_rt = Arc::clone(&rt);
std::thread::spawn(move || {
ipc_rt.block_on(ipc::start_server());
});
// start the app (in the background thread)
std::thread::spawn(|| {
let _ = app::start(text_cmd_rx);
let app_rt = Arc::clone(&rt);
std::thread::spawn(move || {
let _ = app::start(text_cmd_rx, &app_rt);
});
tray::init_blocking();
tray::init_blocking(settings);
Ok(())
}
pub fn should_stop() -> bool {
SHOULD_STOP.load(Ordering::SeqCst)
}
}

View file

@ -1,84 +1,64 @@
mod menu;
use tray_icon::{
menu::{AboutMetadata, Menu, MenuEvent, MenuItem, PredefinedMenuItem},
TrayIconBuilder, TrayIconEvent,
menu::MenuEvent,
TrayIconBuilder,
};
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, ipc::{self, IpcEvent}};
use jarvis_core::{config, i18n, voices, ipc::{self, IpcEvent}, SettingsManager};
const TRAY_ICON_BYTES: &[u8] = include_bytes!("../../../resources/icons/32x32.png");
pub fn init_blocking() {
// load tray icon
//let icon_path = format!("{}/../../resources/icons/{}", env!("CARGO_MANIFEST_DIR"), config::TRAY_ICON);
//let icon = load_icon(std::path::Path::new(&icon_path));
pub fn init_blocking(settings: SettingsManager) {
let icon = load_icon_from_bytes(TRAY_ICON_BYTES);
// form tray menu
// let tray_menu = Menu::with_items(&[
// &MenuItem::new("Перезапуск", true, None),
// &MenuItem::new("Настройки", true, None),
// &MenuItem::new("Выход", true, None),
// ])
// .unwrap();
let tray_menu = Menu::with_items(&[
&MenuItem::with_id("restart", i18n::t("tray-restart"), true, None),
&MenuItem::with_id("settings", i18n::t("tray-settings"), true, None),
&MenuItem::with_id("exit", i18n::t("tray-exit"), true, None),
]).unwrap();
// build menu with settings submenus
let tray_menu = menu::build(&settings);
let menu::TrayMenu { menu, state: tray_state } = tray_menu;
let _tray_icon = TrayIconBuilder::new()
.with_menu(Box::new(tray_menu))
.with_menu(Box::new(menu))
.with_tooltip(i18n::t("tray-tooltip"))
.with_icon(icon)
.build()
.unwrap();
let menu_channel = MenuEvent::receiver();
// let tray_channel = TrayIconEvent::receiver();
// @TODO: Test on Linux
// We need gtk for the tray icon to show up, we need to initialize gtk and create the tray_icon
#[cfg(target_os = "linux")]
{
gtk::init().unwrap();
glib::timeout_add_local(std::time::Duration::from_millis(100), move || {
if let Ok(event) = menu_channel.try_recv() {
handle_menu_event(&event);
handle_menu_event(&event, &settings, &tray_state);
}
glib::ControlFlow::Continue
});
gtk::main();
}
// @TODO: Test on MacOS
#[cfg(target_os = "macos")]
{
// macOS needs proper run loop - tao or winit on main thread
use winit::event_loop::{EventLoop, ControlFlow};
let event_loop = EventLoop::new().unwrap();
event_loop.run(move |_event, elwt| {
elwt.set_control_flow(ControlFlow::Wait);
if let Ok(event) = menu_channel.try_recv() {
handle_menu_event(&event);
handle_menu_event(&event, &settings, &tray_state);
}
}).unwrap();
}
#[cfg(target_os = "windows")]
{
// simple polling works on Windows
loop {
if let Ok(event) = menu_channel.try_recv() {
handle_menu_event(&event);
handle_menu_event(&event, &settings, &tray_state);
}
// pump Windows messages
@ -101,8 +81,65 @@ pub fn init_blocking() {
info!("Tray initialized.");
}
fn handle_menu_event(event: &MenuEvent) {
match event.id.0.as_str() {
fn handle_menu_event(event: &MenuEvent, settings: &SettingsManager, tray_state: &menu::TrayState) {
let id = event.id.0.as_str();
// -- radio group: "set:key:value"
if let Some(rest) = id.strip_prefix("set:") {
if let Some((key, value)) = rest.split_once(':') {
match settings.write(key, value) {
Ok(()) => {
info!("Tray: {} = {}", key, value);
// update check marks in the radio group
for group in &tray_state.radio_groups {
if group.setting_key == key {
group.select(value);
break;
}
}
// apply side effects
match key {
"language" => {
i18n::set_language(value);
}
"assistant_voice" => {
voices::set_current_voice(value);
}
_ => {}
}
}
Err(e) => {
warn!("Tray: failed to set {} = {}: {}", key, value, e);
}
}
return;
}
}
// -- toggle: "toggle:key"
if let Some(key) = id.strip_prefix("toggle:") {
match key {
"gain_normalizer" => {
// CheckMenuItem auto-toggles on click, just read the new state
let new_val = tray_state.gain_toggle.is_checked();
let val_str = if new_val { "true" } else { "false" };
if let Err(e) = settings.write(key, val_str) {
warn!("Tray: failed to toggle {}: {}", key, e);
// revert visual state on error
tray_state.gain_toggle.set_checked(!new_val);
} else {
info!("Tray: {} = {}", key, val_str);
}
}
_ => {}
}
return;
}
// -- action items
match id {
"exit" => std::process::exit(0),
"restart" => {
info!("Restarting from tray menu...");
@ -116,6 +153,8 @@ fn handle_menu_event(event: &MenuEvent) {
}
}
// HELPERS
fn load_icon_from_bytes(bytes: &[u8]) -> tray_icon::Icon {
let image = image::load_from_memory(bytes)
.expect("Failed to load icon")
@ -125,20 +164,7 @@ fn load_icon_from_bytes(bytes: &[u8]) -> tray_icon::Icon {
tray_icon::Icon::from_rgba(rgba, width, height).expect("Failed to create icon")
}
fn load_icon(path: &std::path::Path) -> tray_icon::Icon {
let (icon_rgba, icon_width, icon_height) = {
let image = image::open(path)
.expect("Failed to open icon path")
.into_rgba8();
let (width, height) = image.dimensions();
let rgba = image.into_raw();
(rgba, width, height)
};
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) => {
@ -147,7 +173,6 @@ fn restart_app() {
}
};
// spawn new instance
match Command::new(&exe_path).spawn() {
Ok(_) => {
info!("Spawned new instance, exiting current...");
@ -160,13 +185,10 @@ fn restart_app() {
}
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();
}
@ -181,7 +203,6 @@ fn launch_gui() {
}
};
// 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());
@ -189,12 +210,8 @@ fn launch_gui() {
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);
}
Ok(_) => info!("Launched jarvis-gui"),
Err(e) => error!("Failed to launch jarvis-gui: {}", e),
}
}
@ -206,4 +223,4 @@ fn get_gui_executable_name() -> &'static str {
#[cfg(not(target_os = "windows"))]
fn get_gui_executable_name() -> &'static str {
"jarvis-gui"
}
}

View file

@ -1,15 +1,182 @@
pub enum TrayMenuItem {
Restart,
Settings,
Exit,
use tray_icon::menu::{CheckMenuItem, Menu, MenuItem, PredefinedMenuItem, Submenu};
use jarvis_core::{i18n, voices, SettingsManager};
use jarvis_core::config::structs::{WakeWordEngine, NoiseSuppressionBackend};
// RADIO GROUP
// a group of check menu items where only one can be active at a time.
// stores (menu_item, setting_value) pairs.
pub struct RadioGroup {
pub setting_key: String,
pub items: Vec<(CheckMenuItem, String)>,
}
impl TrayMenuItem {
pub fn label(&self) -> &str {
match *self {
TrayMenuItem::Restart => "Перезапустить",
TrayMenuItem::Settings => "Настройки",
TrayMenuItem::Exit => "Выход",
impl RadioGroup {
pub fn select(&self, value: &str) {
for (item, val) in &self.items {
item.set_checked(val == value);
}
}
}
// TRAY MENU STATE
pub struct TrayMenu {
pub menu: Menu,
pub state: TrayState,
}
// holds references to menu items for updating check marks after build
pub struct TrayState {
pub radio_groups: Vec<RadioGroup>,
pub gain_toggle: CheckMenuItem,
}
// BUILD
pub fn build(settings: &SettingsManager) -> TrayMenu {
let menu = Menu::new();
let mut radio_groups = Vec::new();
// -- language submenu
let lang_sub = Submenu::new(i18n::t("tray-language"), true);
let current_lang = settings.read("language").unwrap_or_default();
let mut lang_items = Vec::new();
for &lang in i18n::SUPPORTED_LANGUAGES {
let label = match lang {
"ru" => "Русский",
"en" => "English",
"ua" => "Українська",
_ => lang,
};
let item = CheckMenuItem::with_id(
format!("set:language:{}", lang),
label,
true,
lang == current_lang,
None,
);
let _ = lang_sub.append(&item);
lang_items.push((item, lang.to_string()));
}
radio_groups.push(RadioGroup {
setting_key: "language".to_string(),
items: lang_items,
});
// -- voice submenu
let voice_sub = Submenu::new(i18n::t("tray-voice"), true);
let current_voice = voices::get_current_voice()
.map(|v| v.voice.id.clone())
.unwrap_or_default();
let mut voice_items = Vec::new();
for voice in voices::list_voices() {
let item = CheckMenuItem::with_id(
format!("set:assistant_voice:{}", voice.voice.id),
&voice.voice.name,
true,
voice.voice.id == current_voice,
None,
);
let _ = voice_sub.append(&item);
voice_items.push((item, voice.voice.id.clone()));
}
radio_groups.push(RadioGroup {
setting_key: "assistant_voice".to_string(),
items: voice_items,
});
// -- wake word engine submenu
let ww_sub = Submenu::new(i18n::t("tray-wake-word"), true);
let current_ww = settings.read("selected_wake_word_engine").unwrap_or_default();
let mut ww_items = Vec::new();
for (label, value) in &[("Rustpotter", "Rustpotter"), ("Vosk", "Vosk")] {
let item = CheckMenuItem::with_id(
format!("set:selected_wake_word_engine:{}", value.to_lowercase()),
*label,
true,
current_ww == *label,
None,
);
let _ = ww_sub.append(&item);
ww_items.push((item, value.to_lowercase()));
}
radio_groups.push(RadioGroup {
setting_key: "selected_wake_word_engine".to_string(),
items: ww_items,
});
// -- noise suppression submenu
let ns_sub = Submenu::new(i18n::t("tray-noise-suppression"), true);
let current_ns = settings.read("noise_suppression").unwrap_or_default();
let mut ns_items = Vec::new();
for (label, value) in &[("None", "none"), ("Nnnoiseless", "nnnoiseless")] {
let item = CheckMenuItem::with_id(
format!("set:noise_suppression:{}", value),
*label,
true,
current_ns.to_lowercase() == *value,
None,
);
let _ = ns_sub.append(&item);
ns_items.push((item, value.to_string()));
}
radio_groups.push(RadioGroup {
setting_key: "noise_suppression".to_string(),
items: ns_items,
});
// -- vad submenu
let vad_sub = Submenu::new(i18n::t("tray-vad"), true);
let current_vad = settings.read("vad_backend").unwrap_or_default();
let mut vad_items = Vec::new();
for (label, value) in &[("None", "none"), ("Energy", "energy"), ("Nnnoiseless", "nnnoiseless")] {
let item = CheckMenuItem::with_id(
format!("set:vad_backend:{}", value),
*label,
true,
current_vad == *value,
None,
);
let _ = vad_sub.append(&item);
vad_items.push((item, value.to_string()));
}
radio_groups.push(RadioGroup {
setting_key: "vad_backend".to_string(),
items: vad_items,
});
// -- gain normalizer toggle
let gain_on = settings.read("gain_normalizer")
.map(|v| v == "true")
.unwrap_or(true);
let gain_toggle = CheckMenuItem::with_id(
"toggle:gain_normalizer",
i18n::t("tray-gain-normalizer"),
true,
gain_on,
None,
);
// -- assemble main menu
let _ = menu.append(&lang_sub);
let _ = menu.append(&voice_sub);
let _ = menu.append(&ww_sub);
let _ = menu.append(&ns_sub);
let _ = menu.append(&vad_sub);
let _ = menu.append(&gain_toggle);
let _ = menu.append(&PredefinedMenuItem::separator());
let _ = menu.append(&MenuItem::with_id("restart", i18n::t("tray-restart"), true, None));
let _ = menu.append(&MenuItem::with_id("settings", i18n::t("tray-settings"), true, None));
let _ = menu.append(&MenuItem::with_id("exit", i18n::t("tray-exit"), true, None));
TrayMenu {
menu,
state: TrayState {
radio_groups,
gain_toggle,
},
}
}