websockets based IPC added in order to bind GUI/app together
This commit is contained in:
parent
eb0d40bae6
commit
adec595cfa
15 changed files with 1069 additions and 47 deletions
|
|
@ -1,8 +1,10 @@
|
|||
use std::time::SystemTime;
|
||||
|
||||
use jarvis_core::{audio, audio_processing, commands, config, listener, recorder, stt, COMMANDS_LIST, intent};
|
||||
use jarvis_core::{audio, audio_processing, commands, config, listener, recorder, stt, COMMANDS_LIST, intent, ipc::{self, IpcEvent}};
|
||||
use rand::prelude::*;
|
||||
|
||||
use crate::should_stop;
|
||||
|
||||
pub fn start() -> Result<(), ()> {
|
||||
// start the loop
|
||||
main_loop()
|
||||
|
|
@ -29,8 +31,18 @@ fn main_loop() -> Result<(), ()> {
|
|||
}
|
||||
}
|
||||
|
||||
// notify GUI we're ready
|
||||
ipc::send(IpcEvent::Idle);
|
||||
|
||||
// the loop
|
||||
'wake_word: loop {
|
||||
// check for stop signal
|
||||
if should_stop() {
|
||||
info!("Stop signal received, shutting down...");
|
||||
ipc::send(IpcEvent::Stopping);
|
||||
break;
|
||||
}
|
||||
|
||||
// read from microphone
|
||||
recorder::read_microphone(&mut frame_buffer);
|
||||
|
||||
|
|
@ -45,6 +57,9 @@ fn main_loop() -> Result<(), ()> {
|
|||
// recognize wake-word
|
||||
match listener::data_callback(&frame_buffer) {
|
||||
Some(_keyword_index) => {
|
||||
// notify GUI
|
||||
ipc::send(IpcEvent::WakeWordDetected);
|
||||
|
||||
// reset some things
|
||||
stt::reset_wake_recognizer();
|
||||
stt::reset_speech_recognizer();
|
||||
|
|
@ -64,8 +79,16 @@ fn main_loop() -> Result<(), ()> {
|
|||
.unwrap()
|
||||
)));
|
||||
|
||||
// notify GUI we're listening
|
||||
ipc::send(IpcEvent::Listening);
|
||||
|
||||
// wait for voice commands
|
||||
'voice_recognition: loop {
|
||||
// check for stop
|
||||
if should_stop() {
|
||||
break 'wake_word;
|
||||
}
|
||||
|
||||
// read from microphone
|
||||
recorder::read_microphone(&mut frame_buffer);
|
||||
|
||||
|
|
@ -88,6 +111,11 @@ fn main_loop() -> Result<(), ()> {
|
|||
// something was recognized
|
||||
info!("Recognized voice: {}", recognized_voice);
|
||||
|
||||
// notify GUI
|
||||
ipc::send(IpcEvent::SpeechRecognized {
|
||||
text: recognized_voice.clone(),
|
||||
});
|
||||
|
||||
// filter recognized voice
|
||||
// @TODO. Better recognized voice filtration.
|
||||
recognized_voice = recognized_voice.to_lowercase();
|
||||
|
|
@ -108,6 +136,8 @@ fn main_loop() -> Result<(), ()> {
|
|||
start = SystemTime::now();
|
||||
silence_frames = 0;
|
||||
stt::reset_speech_recognizer();
|
||||
|
||||
ipc::send(IpcEvent::Listening);
|
||||
continue 'voice_recognition;
|
||||
}
|
||||
|
||||
|
|
@ -143,6 +173,12 @@ fn main_loop() -> Result<(), ()> {
|
|||
// 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();
|
||||
|
|
@ -158,6 +194,14 @@ fn main_loop() -> Result<(), ()> {
|
|||
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(),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -178,12 +222,17 @@ fn main_loop() -> Result<(), ()> {
|
|||
// reset things
|
||||
stt::reset_wake_recognizer();
|
||||
audio_processing::reset();
|
||||
ipc::send(IpcEvent::Idle);
|
||||
}
|
||||
}
|
||||
None => (),
|
||||
}
|
||||
}
|
||||
|
||||
// cleanup
|
||||
recorder::stop_recording().ok();
|
||||
ipc::send(IpcEvent::Stopping);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
|
|
@ -191,5 +240,6 @@ fn keyword_callback(keyword_index: i32) {}
|
|||
|
||||
pub fn close(code: i32) {
|
||||
info!("Closing application.");
|
||||
ipc::send(IpcEvent::Stopping);
|
||||
std::process::exit(code);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,9 +1,11 @@
|
|||
use parking_lot::RwLock;
|
||||
use std::sync::Arc;
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
|
||||
// include core
|
||||
use jarvis_core::{
|
||||
audio, audio_processing, commands, config, db, listener, recorder, stt, intent,
|
||||
ipc::{self, IpcAction},
|
||||
APP_CONFIG_DIR, APP_LOG_DIR, COMMANDS_LIST, DB,
|
||||
};
|
||||
|
||||
|
|
@ -20,6 +22,8 @@ mod app;
|
|||
#[cfg(not(target_os = "macos"))]
|
||||
mod tray;
|
||||
|
||||
static SHOULD_STOP: AtomicBool = AtomicBool::new(false);
|
||||
|
||||
fn main() -> Result<(), String> {
|
||||
// initialize directories
|
||||
config::init_dirs()?;
|
||||
|
|
@ -96,6 +100,34 @@ fn main() -> Result<(), String> {
|
|||
warn!("Audio processing init failed: {}", e);
|
||||
}
|
||||
|
||||
// init IPC
|
||||
info!("Initializing IPC...");
|
||||
ipc::init();
|
||||
|
||||
ipc::set_action_handler(|action| {
|
||||
match action {
|
||||
IpcAction::Stop => {
|
||||
info!("Received stop command from GUI");
|
||||
SHOULD_STOP.store(true, Ordering::SeqCst);
|
||||
}
|
||||
IpcAction::ReloadCommands => {
|
||||
info!("Received reload commands request");
|
||||
// TODO: implement reload
|
||||
}
|
||||
IpcAction::SetMuted { muted } => {
|
||||
info!("Received mute request: {}", muted);
|
||||
// TODO: implement mute
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
});
|
||||
|
||||
// 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 the app (in the background thread)
|
||||
std::thread::spawn(|| {
|
||||
let _ = app::start();
|
||||
|
|
@ -104,4 +136,8 @@ fn main() -> Result<(), String> {
|
|||
tray::init_blocking();
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn should_stop() -> bool {
|
||||
SHOULD_STOP.load(Ordering::SeqCst)
|
||||
}
|
||||
|
|
@ -8,7 +8,7 @@ edition.workspace = true
|
|||
|
||||
[dependencies]
|
||||
jarvis-core = { path = "../jarvis-core", default-features = false, features = ["intent"]}
|
||||
tokio = { version = "1", features = ["rt-multi-thread", "macros"] }
|
||||
tokio = { version = "1", features = ["rt-multi-thread", "sync", "macros"] }
|
||||
log.workspace = true
|
||||
env_logger = "0.11"
|
||||
parking_lot.workspace = true
|
||||
|
|
@ -24,6 +24,8 @@ parking_lot.workspace = true
|
|||
toml.workspace = true
|
||||
sha2.workspace = true
|
||||
nnnoiseless = { workspace = true, optional = true }
|
||||
tokio-tungstenite = { workspace = true, optional = true }
|
||||
futures-util = { workspace = true, optional = true }
|
||||
|
||||
# pv_recorder = { workspace = true, optional = true }
|
||||
vosk = { version = "0.3.1", optional = true }
|
||||
|
|
@ -34,5 +36,5 @@ tokio = { version = "1", features = ["sync"], optional = true }
|
|||
|
||||
[features]
|
||||
default = ["jarvis_app"]
|
||||
jarvis_app = ["vosk", "intent-classifier", "tokio", "nnnoiseless"]
|
||||
jarvis_app = ["vosk", "intent-classifier", "tokio", "nnnoiseless", "tokio-tungstenite", "futures-util"]
|
||||
intent = ["intent-classifier", "tokio"]
|
||||
5
crates/jarvis-core/src/ipc.rs
Normal file
5
crates/jarvis-core/src/ipc.rs
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
mod events;
|
||||
mod server;
|
||||
|
||||
pub use events::{IpcAction, IpcEvent};
|
||||
pub use server::{init, send, set_action_handler, start_server, IPC_ADDR, IPC_PORT};
|
||||
50
crates/jarvis-core/src/ipc/events.rs
Normal file
50
crates/jarvis-core/src/ipc/events.rs
Normal file
|
|
@ -0,0 +1,50 @@
|
|||
use serde::{Deserialize, Serialize};
|
||||
|
||||
// Events sent from jarvis-app to GUI
|
||||
#[derive(Clone, Debug, Serialize)]
|
||||
#[serde(tag = "event", rename_all = "snake_case")]
|
||||
pub enum IpcEvent {
|
||||
// Wake word detected, starting to listen
|
||||
WakeWordDetected,
|
||||
|
||||
// Actively listening for command
|
||||
Listening,
|
||||
|
||||
// Speech recognized
|
||||
SpeechRecognized { text: String },
|
||||
|
||||
// Command was executed
|
||||
CommandExecuted { id: String, success: bool },
|
||||
|
||||
// Returned to idle state
|
||||
Idle,
|
||||
|
||||
// Error occurred
|
||||
Error { message: String },
|
||||
|
||||
// App started
|
||||
Started,
|
||||
|
||||
// App is shutting down
|
||||
Stopping,
|
||||
|
||||
// Pong response
|
||||
Pong,
|
||||
}
|
||||
|
||||
// Actions sent from GUI to jarvis-app
|
||||
#[derive(Clone, Debug, Deserialize)]
|
||||
#[serde(tag = "action", rename_all = "snake_case")]
|
||||
pub enum IpcAction {
|
||||
// Request graceful shutdown
|
||||
Stop,
|
||||
|
||||
// Reload commands from disk
|
||||
ReloadCommands,
|
||||
|
||||
// Ping to check connection
|
||||
Ping,
|
||||
|
||||
// Mute/unmute listening
|
||||
SetMuted { muted: bool },
|
||||
}
|
||||
190
crates/jarvis-core/src/ipc/server.rs
Normal file
190
crates/jarvis-core/src/ipc/server.rs
Normal file
|
|
@ -0,0 +1,190 @@
|
|||
use std::net::SocketAddr;
|
||||
use std::sync::Arc;
|
||||
|
||||
use futures_util::{SinkExt, StreamExt};
|
||||
use once_cell::sync::OnceCell;
|
||||
use parking_lot::RwLock;
|
||||
use tokio::net::{TcpListener, TcpStream};
|
||||
use tokio::sync::broadcast;
|
||||
use tokio_tungstenite::{accept_async, tungstenite::Message};
|
||||
|
||||
use super::events::{IpcAction, IpcEvent};
|
||||
|
||||
pub const IPC_PORT: u16 = 9712;
|
||||
pub const IPC_ADDR: &str = "127.0.0.1";
|
||||
|
||||
static BROADCAST_TX: OnceCell<broadcast::Sender<IpcEvent>> = OnceCell::new();
|
||||
static ACTION_HANDLER: OnceCell<Arc<RwLock<Option<Box<dyn Fn(IpcAction) + Send + Sync>>>>> = OnceCell::new();
|
||||
|
||||
// Initialize the IPC broadcast channel
|
||||
pub fn init() -> broadcast::Sender<IpcEvent> {
|
||||
if let Some(tx) = BROADCAST_TX.get() {
|
||||
return tx.clone();
|
||||
}
|
||||
|
||||
let (tx, _) = broadcast::channel::<IpcEvent>(32);
|
||||
BROADCAST_TX.set(tx.clone()).ok();
|
||||
ACTION_HANDLER.set(Arc::new(RwLock::new(None))).ok();
|
||||
|
||||
info!("IPC: Broadcast channel initialized");
|
||||
tx
|
||||
}
|
||||
|
||||
// Send event to all connected clients
|
||||
pub fn send(event: IpcEvent) {
|
||||
if let Some(tx) = BROADCAST_TX.get() {
|
||||
match tx.send(event.clone()) {
|
||||
Ok(n) => {
|
||||
if n > 0 {
|
||||
debug!("IPC: Sent {:?} to {} client(s)", event, n);
|
||||
}
|
||||
}
|
||||
Err(_) => {
|
||||
// no receivers, that's fine
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Register handler for incoming actions from GUI
|
||||
pub fn set_action_handler<F>(handler: F)
|
||||
where
|
||||
F: Fn(IpcAction) + Send + Sync + 'static,
|
||||
{
|
||||
if let Some(h) = ACTION_HANDLER.get() {
|
||||
*h.write() = Some(Box::new(handler));
|
||||
}
|
||||
}
|
||||
|
||||
fn handle_action(action: IpcAction) {
|
||||
info!("IPC: Received action {:?}", action);
|
||||
|
||||
// handle ping internally
|
||||
if matches!(action, IpcAction::Ping) {
|
||||
send(IpcEvent::Pong);
|
||||
return;
|
||||
}
|
||||
|
||||
// forward to registered handler
|
||||
if let Some(handler_lock) = ACTION_HANDLER.get() {
|
||||
let handler = handler_lock.read();
|
||||
if let Some(ref h) = *handler {
|
||||
h(action);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Start the WebSocket server (blocking)
|
||||
pub async fn start_server() {
|
||||
let addr = format!("{}:{}", IPC_ADDR, IPC_PORT);
|
||||
let socket_addr: SocketAddr = addr.parse().expect("Invalid IPC address");
|
||||
|
||||
let listener = match TcpListener::bind(&socket_addr).await {
|
||||
Ok(l) => {
|
||||
info!("IPC: WebSocket server listening on ws://{}", addr);
|
||||
l
|
||||
}
|
||||
Err(e) => {
|
||||
error!("IPC: Failed to bind to {}: {}", addr, e);
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
// notify that we're ready
|
||||
send(IpcEvent::Started);
|
||||
|
||||
while let Ok((stream, peer_addr)) = listener.accept().await {
|
||||
info!("IPC: Client connecting from {}", peer_addr);
|
||||
|
||||
let rx = BROADCAST_TX
|
||||
.get()
|
||||
.map(|tx| tx.subscribe())
|
||||
.expect("IPC not initialized");
|
||||
|
||||
tokio::spawn(handle_client(stream, peer_addr, rx));
|
||||
}
|
||||
}
|
||||
|
||||
async fn handle_client(
|
||||
stream: TcpStream,
|
||||
peer_addr: SocketAddr,
|
||||
mut event_rx: broadcast::Receiver<IpcEvent>,
|
||||
) {
|
||||
let ws_stream = match accept_async(stream).await {
|
||||
Ok(ws) => {
|
||||
info!("IPC: Client connected: {}", peer_addr);
|
||||
ws
|
||||
}
|
||||
Err(e) => {
|
||||
error!("IPC: WebSocket handshake failed for {}: {}", peer_addr, e);
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
let (mut ws_tx, mut ws_rx) = ws_stream.split();
|
||||
|
||||
loop {
|
||||
tokio::select! {
|
||||
// forward events to client
|
||||
event_result = event_rx.recv() => {
|
||||
match event_result {
|
||||
Ok(event) => {
|
||||
let json = match serde_json::to_string(&event) {
|
||||
Ok(j) => j,
|
||||
Err(e) => {
|
||||
error!("IPC: Failed to serialize event: {}", e);
|
||||
continue;
|
||||
}
|
||||
};
|
||||
|
||||
if ws_tx.send(Message::Text(json.into())).await.is_err() {
|
||||
info!("IPC: Client {} disconnected (send failed)", peer_addr);
|
||||
break;
|
||||
}
|
||||
}
|
||||
Err(broadcast::error::RecvError::Lagged(n)) => {
|
||||
warn!("IPC: Client {} lagged {} events", peer_addr, n);
|
||||
}
|
||||
Err(broadcast::error::RecvError::Closed) => {
|
||||
info!("IPC: Broadcast channel closed");
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// receive messages from client
|
||||
msg_result = ws_rx.next() => {
|
||||
match msg_result {
|
||||
Some(Ok(Message::Text(text))) => {
|
||||
match serde_json::from_str::<IpcAction>(&text) {
|
||||
Ok(action) => handle_action(action),
|
||||
Err(e) => {
|
||||
warn!("IPC: Invalid action from {}: {} ({})", peer_addr, text, e);
|
||||
}
|
||||
}
|
||||
}
|
||||
Some(Ok(Message::Ping(data))) => {
|
||||
if ws_tx.send(Message::Pong(data)).await.is_err() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
Some(Ok(Message::Close(_))) => {
|
||||
info!("IPC: Client {} sent close frame", peer_addr);
|
||||
break;
|
||||
}
|
||||
Some(Err(e)) => {
|
||||
error!("IPC: Error receiving from {}: {}", peer_addr, e);
|
||||
break;
|
||||
}
|
||||
None => {
|
||||
info!("IPC: Client {} stream ended", peer_addr);
|
||||
break;
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
info!("IPC: Client disconnected: {}", peer_addr);
|
||||
}
|
||||
|
|
@ -28,6 +28,9 @@ pub mod vosk_models;
|
|||
#[cfg(feature = "jarvis_app")]
|
||||
pub mod audio_processing;
|
||||
|
||||
#[cfg(feature = "jarvis_app")]
|
||||
pub mod ipc;
|
||||
|
||||
// shared statics
|
||||
// pub static APP_DIR: Lazy<PathBuf> = Lazy::new(|| std::env::current_dir().unwrap());
|
||||
pub static APP_DIR: Lazy<PathBuf> = Lazy::new(|| {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue