project restructure with Rust workspaces
This commit is contained in:
parent
1f03a44f33
commit
6e585dd37d
428 changed files with 19372 additions and 6061 deletions
16
crates/jarvis-app/.cargo/config.toml
Normal file
16
crates/jarvis-app/.cargo/config.toml
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
# Cargo configuration file
|
||||
|
||||
[build]
|
||||
jobs = 4
|
||||
rustflags = [
|
||||
# "-Ctarget-feature=+fp16,+fhm"
|
||||
]
|
||||
|
||||
[profile.dev]
|
||||
opt-level = 0
|
||||
debug = true
|
||||
|
||||
[profile.release]
|
||||
opt-level = 3
|
||||
debug = false
|
||||
lto = true
|
||||
29
crates/jarvis-app/Cargo.toml
Normal file
29
crates/jarvis-app/Cargo.toml
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
[package]
|
||||
name = "jarvis-app"
|
||||
version.workspace = true
|
||||
authors.workspace = true
|
||||
license.workspace = true
|
||||
repository.workspace = true
|
||||
edition.workspace = true
|
||||
|
||||
[dependencies]
|
||||
jarvis-core = { path = "../jarvis-core" }
|
||||
once_cell.workspace = true
|
||||
log.workspace = true
|
||||
simple-log = "2.4"
|
||||
tray-icon = "0.21"
|
||||
winit = "0.30"
|
||||
image.workspace = true
|
||||
platform-dirs.workspace = true
|
||||
rand.workspace = true
|
||||
|
||||
[target.'cfg(windows)'.dependencies.winit]
|
||||
version = "0.30"
|
||||
features = []
|
||||
|
||||
[target.'cfg(target_os = "windows")'.dependencies]
|
||||
winapi = { version = "0.3", features = ["winuser"] }
|
||||
|
||||
[target.'cfg(target_os = "linux")'.dependencies]
|
||||
gtk = "0.18"
|
||||
glib = "0.18"
|
||||
45
crates/jarvis-app/Makefile.toml
Normal file
45
crates/jarvis-app/Makefile.toml
Normal file
|
|
@ -0,0 +1,45 @@
|
|||
[tasks.format]
|
||||
install_crate = "rustfmt"
|
||||
command = "cargo"
|
||||
args = ["fmt", "--", "--emit=files"]
|
||||
|
||||
[tasks.clean]
|
||||
command = "cargo"
|
||||
args = ["clean"]
|
||||
|
||||
[tasks.build_debug]
|
||||
command = "cargo"
|
||||
args = ["build"]
|
||||
|
||||
[tasks.run]
|
||||
command = "cargo"
|
||||
args = ["run"]
|
||||
|
||||
[tasks.build_release]
|
||||
command = "cargo"
|
||||
args = ["build", "--release"]
|
||||
dependencies = ["clean"]
|
||||
|
||||
[tasks.test]
|
||||
command = "cargo"
|
||||
args = ["test"]
|
||||
# dependencies = ["clean"]
|
||||
|
||||
[tasks.post_build]
|
||||
script_runner = "python"
|
||||
script_extension = "py"
|
||||
script = { file = "post_build.py" }
|
||||
|
||||
[tasks.debug]
|
||||
dependencies = [
|
||||
"format",
|
||||
"build_debug",
|
||||
"post_build"
|
||||
]
|
||||
|
||||
[tasks.release]
|
||||
dependencies = [
|
||||
"format",
|
||||
"build_release",
|
||||
"post_build"
|
||||
]
|
||||
10
crates/jarvis-app/build.rs
Normal file
10
crates/jarvis-app/build.rs
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
fn main() {
|
||||
// link to Vosk lib
|
||||
// println!("cargo:rustc-link-lib=libvosk.dll");
|
||||
|
||||
let manifest_dir = std::env::var("CARGO_MANIFEST_DIR").unwrap();
|
||||
let lib_path = std::path::Path::new(&manifest_dir)
|
||||
.join("..\\..\\lib\\windows\\amd64");
|
||||
|
||||
println!("cargo:rustc-link-search=native={}", lib_path.display());
|
||||
}
|
||||
92
crates/jarvis-app/src/_main.rs
Normal file
92
crates/jarvis-app/src/_main.rs
Normal file
|
|
@ -0,0 +1,92 @@
|
|||
|
||||
|
||||
#[macro_use]
|
||||
extern crate lazy_static; // better switch to once_cell ?
|
||||
use pickledb::{PickleDb, PickleDbDumpPolicy, SerializationMethod};
|
||||
use log::{info};
|
||||
use log::LevelFilter;
|
||||
use std::sync::Mutex;
|
||||
|
||||
// include assistant commands
|
||||
mod assistant_commands;
|
||||
use assistant_commands::AssistantCommand;
|
||||
|
||||
// include vosk
|
||||
mod vosk;
|
||||
|
||||
// include events
|
||||
mod events;
|
||||
|
||||
// include recorder
|
||||
mod recorder;
|
||||
|
||||
// init PickleDb connection
|
||||
lazy_static! {
|
||||
static ref DB: Mutex<PickleDb> = Mutex::new(
|
||||
PickleDb::load(
|
||||
format!("{}/{}", APP_CONFIG_DIR.lock().unwrap(), DB_FILE_NAME),
|
||||
PickleDbDumpPolicy::AutoDump,
|
||||
SerializationMethod::Json
|
||||
)
|
||||
.unwrap_or_else(|_x: _| {
|
||||
info!("Creating new db file at {} ...", format!("{}/{}", APP_CONFIG_DIR.lock().unwrap(), DB_FILE_NAME));
|
||||
PickleDb::new(
|
||||
format!("{}/{}", APP_CONFIG_DIR.lock().unwrap(), DB_FILE_NAME),
|
||||
PickleDbDumpPolicy::AutoDump,
|
||||
SerializationMethod::Json,
|
||||
)
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
fn main() {
|
||||
// init vosk
|
||||
vosk::init_vosk();
|
||||
|
||||
// run the app
|
||||
tauri::Builder::default()
|
||||
.setup(|app| {
|
||||
std::fs::create_dir_all(app.path_resolver().app_config_dir().unwrap())?;
|
||||
APP_CONFIG_DIR.lock().unwrap().push_str(app.path_resolver().app_config_dir().unwrap().to_str().unwrap());
|
||||
|
||||
std::fs::create_dir_all(app.path_resolver().app_log_dir().unwrap())?;
|
||||
APP_LOG_DIR.lock().unwrap().push_str(app.path_resolver().app_log_dir().unwrap().to_str().unwrap());
|
||||
|
||||
// log to file
|
||||
let log_file_path = format!("{}/{}", APP_LOG_DIR.lock().unwrap(), config::LOG_FILE_NAME);
|
||||
println!("!!!===============!!!\nLOGGING TO {}\n!!!===============!!!\n", &log_file_path);
|
||||
simple_logging::log_to_file(&log_file_path, LevelFilter::max()).expect("Failed to start logger ... is directory writable?");
|
||||
|
||||
Ok(())
|
||||
})
|
||||
.invoke_handler(tauri::generate_handler![
|
||||
// db commands
|
||||
tauri_commands::db_read,
|
||||
tauri_commands::db_write,
|
||||
// recorder commands
|
||||
tauri_commands::pv_get_audio_devices,
|
||||
tauri_commands::pv_get_audio_device_name,
|
||||
// listener commands
|
||||
tauri_commands::start_listening,
|
||||
tauri_commands::stop_listening,
|
||||
tauri_commands::is_listening,
|
||||
// sys commands
|
||||
tauri_commands::get_current_ram_usage,
|
||||
tauri_commands::get_peak_ram_usage,
|
||||
tauri_commands::get_cpu_temp,
|
||||
tauri_commands::get_cpu_usage,
|
||||
// sound commands
|
||||
tauri_commands::play_sound,
|
||||
// fs commands
|
||||
tauri_commands::show_in_folder,
|
||||
// etc commands
|
||||
tauri_commands::get_app_version,
|
||||
tauri_commands::get_author_name,
|
||||
tauri_commands::get_repository_link,
|
||||
tauri_commands::get_tg_official_link,
|
||||
tauri_commands::get_feedback_link,
|
||||
tauri_commands::get_log_file_path
|
||||
])
|
||||
.run(tauri::generate_context!())
|
||||
.expect("error while running tauri application");
|
||||
}
|
||||
130
crates/jarvis-app/src/app.rs
Normal file
130
crates/jarvis-app/src/app.rs
Normal file
|
|
@ -0,0 +1,130 @@
|
|||
use std::time::SystemTime;
|
||||
|
||||
use jarvis_core::{audio, commands, config, listener, recorder, stt, COMMANDS_LIST};
|
||||
use rand::prelude::*;
|
||||
|
||||
pub fn start() -> Result<(), ()> {
|
||||
// start the loop
|
||||
main_loop()
|
||||
}
|
||||
|
||||
fn main_loop() -> Result<(), ()> {
|
||||
let mut start: SystemTime;
|
||||
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];
|
||||
|
||||
// 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"));
|
||||
|
||||
// start recording
|
||||
match recorder::start_recording() {
|
||||
Ok(_) => info!("Recording started."),
|
||||
Err(_) => {
|
||||
error!("Cannot start recording.");
|
||||
return Err(()); // quit
|
||||
}
|
||||
}
|
||||
|
||||
// the loop
|
||||
'wake_word: loop {
|
||||
// read from microphone
|
||||
recorder::read_microphone(&mut frame_buffer);
|
||||
|
||||
// recognize wake-word
|
||||
match listener::data_callback(&frame_buffer) {
|
||||
Some(keyword_index) => {
|
||||
// wake-word activated, process further commands
|
||||
// capture current time
|
||||
start = SystemTime::now();
|
||||
|
||||
// play some greet 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()
|
||||
)));
|
||||
|
||||
// wait for voice commands
|
||||
'voice_recognition: loop {
|
||||
// read from microphone
|
||||
recorder::read_microphone(&mut frame_buffer);
|
||||
|
||||
// stt part (without partials)
|
||||
if let Some(mut recognized_voice) = stt::recognize(&frame_buffer, false) {
|
||||
// something was recognized
|
||||
info!("Recognized voice: {}", recognized_voice);
|
||||
|
||||
// filter recognized voice
|
||||
// @TODO. Better recognized voice filtration.
|
||||
recognized_voice = recognized_voice.to_lowercase();
|
||||
for tbr in config::ASSISTANT_PHRASES_TBR {
|
||||
recognized_voice = recognized_voice.replace(tbr, "");
|
||||
}
|
||||
recognized_voice = recognized_voice.trim().into();
|
||||
|
||||
// infer command
|
||||
if let Some((cmd_path, cmd_config)) = commands::fetch_command(
|
||||
&recognized_voice,
|
||||
&COMMANDS_LIST.get().unwrap(),
|
||||
) {
|
||||
// some debug info
|
||||
info!("Recognized voice (filtered): {}", recognized_voice);
|
||||
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.");
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// return to wake-word listening after command execution (no matter successful or not)
|
||||
break 'voice_recognition;
|
||||
}
|
||||
|
||||
// only recognize voice for a certain period of time
|
||||
match start.elapsed() {
|
||||
Ok(elapsed) if elapsed > config::CMS_WAIT_DELAY => {
|
||||
// return to wake-word listening after N seconds
|
||||
break 'voice_recognition;
|
||||
}
|
||||
_ => (),
|
||||
}
|
||||
}
|
||||
}
|
||||
None => (),
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn keyword_callback(keyword_index: i32) {}
|
||||
|
||||
pub fn close(code: i32) {
|
||||
info!("Closing application.");
|
||||
std::process::exit(code);
|
||||
}
|
||||
25
crates/jarvis-app/src/log.rs
Normal file
25
crates/jarvis-app/src/log.rs
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
use simple_log::LogConfigBuilder;
|
||||
|
||||
use crate::config;
|
||||
use crate::APP_LOG_DIR;
|
||||
|
||||
pub fn init_logging() -> Result<(), String> {
|
||||
// configure logging
|
||||
let config = LogConfigBuilder::builder()
|
||||
.path(format!(
|
||||
"{}/{}",
|
||||
APP_LOG_DIR.get().unwrap().display(),
|
||||
config::LOG_FILE_NAME
|
||||
))
|
||||
.size(1 * 100)
|
||||
.roll_count(10)
|
||||
.time_format("%Y-%m-%d %H:%M:%S.%f")
|
||||
.level("debug")?
|
||||
.output_file()
|
||||
.output_console()
|
||||
.build();
|
||||
|
||||
simple_log::new(config)?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
84
crates/jarvis-app/src/main.rs
Normal file
84
crates/jarvis-app/src/main.rs
Normal file
|
|
@ -0,0 +1,84 @@
|
|||
use std::path::PathBuf;
|
||||
|
||||
// include core
|
||||
use jarvis_core::{
|
||||
audio, commands, config, db, listener, recorder, stt,
|
||||
APP_CONFIG_DIR, APP_LOG_DIR, COMMANDS_LIST, DB,
|
||||
};
|
||||
|
||||
// include log
|
||||
#[macro_use]
|
||||
extern crate simple_log;
|
||||
mod log;
|
||||
|
||||
// include app
|
||||
mod app;
|
||||
|
||||
// include tray
|
||||
// @TODO. macOS currently not supported for tray functionality.
|
||||
#[cfg(not(target_os = "macos"))]
|
||||
mod tray;
|
||||
|
||||
fn main() -> Result<(), String> {
|
||||
// initialize directories
|
||||
config::init_dirs()?;
|
||||
|
||||
// initialize logging
|
||||
log::init_logging()?;
|
||||
|
||||
// log some base info
|
||||
info!("Starting Jarvis v{} ...", config::APP_VERSION.unwrap());
|
||||
info!("Config directory is: {}", APP_CONFIG_DIR.get().unwrap().display());
|
||||
info!("Log directory is: {}", APP_LOG_DIR.get().unwrap().display());
|
||||
|
||||
// initialize database (settings)
|
||||
let _ = DB.set(db::init_settings());
|
||||
|
||||
// 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();
|
||||
|
||||
// init recorder
|
||||
if recorder::init().is_err() {
|
||||
app::close(1);
|
||||
}
|
||||
|
||||
// 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 = commands::parse_commands().unwrap();
|
||||
info!("Commands initialized. Count: {}, List: {:?}", cmds.len(), commands::list(&cmds));
|
||||
COMMANDS_LIST.set(cmds).unwrap();
|
||||
|
||||
// init audio
|
||||
if audio::init().is_err() {
|
||||
// @TODO. Allow continuing even without audio?
|
||||
app::close(1); // cannot continue without audio
|
||||
}
|
||||
|
||||
// init wake-word engine
|
||||
if listener::init().is_err() {
|
||||
app::close(1); // cannot continue without wake-word engine
|
||||
}
|
||||
|
||||
// start the app (in the background thread)
|
||||
std::thread::spawn(|| {
|
||||
app::start();
|
||||
});
|
||||
|
||||
tray::init_blocking();
|
||||
|
||||
Ok(())
|
||||
}
|
||||
131
crates/jarvis-app/src/tray.rs
Normal file
131
crates/jarvis-app/src/tray.rs
Normal file
|
|
@ -0,0 +1,131 @@
|
|||
mod menu;
|
||||
|
||||
use tray_icon::{
|
||||
menu::{AboutMetadata, Menu, MenuEvent, MenuItem, PredefinedMenuItem},
|
||||
TrayIconBuilder, TrayIconEvent,
|
||||
};
|
||||
use winit::event_loop::{ControlFlow, EventLoopBuilder};
|
||||
use image;
|
||||
|
||||
#[cfg(target_os="windows")]
|
||||
use winit::platform::windows::EventLoopBuilderExtWindows;
|
||||
|
||||
use jarvis_core::config;
|
||||
|
||||
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));
|
||||
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", "Перезапуск", true, None),
|
||||
&MenuItem::with_id("settings", "Настройки", true, None),
|
||||
&MenuItem::with_id("exit", "Выход", true, None),
|
||||
]).unwrap();
|
||||
|
||||
let _tray_icon = TrayIconBuilder::new()
|
||||
.with_menu(Box::new(tray_menu))
|
||||
.with_tooltip(config::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);
|
||||
}
|
||||
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);
|
||||
}
|
||||
}).unwrap();
|
||||
}
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
{
|
||||
// simple polling works on Windows
|
||||
loop {
|
||||
if let Ok(event) = menu_channel.try_recv() {
|
||||
handle_menu_event(&event);
|
||||
}
|
||||
|
||||
// pump Windows messages
|
||||
unsafe {
|
||||
let mut msg: winapi::um::winuser::MSG = std::mem::zeroed();
|
||||
while winapi::um::winuser::PeekMessageW(
|
||||
&mut msg,
|
||||
std::ptr::null_mut(),
|
||||
0, 0,
|
||||
winapi::um::winuser::PM_REMOVE
|
||||
) != 0 {
|
||||
winapi::um::winuser::TranslateMessage(&msg);
|
||||
winapi::um::winuser::DispatchMessageW(&msg);
|
||||
}
|
||||
}
|
||||
std::thread::sleep(std::time::Duration::from_millis(50));
|
||||
}
|
||||
}
|
||||
|
||||
info!("Tray initialized.");
|
||||
}
|
||||
|
||||
fn handle_menu_event(event: &MenuEvent) {
|
||||
match event.id.0.as_str() {
|
||||
"exit" => std::process::exit(0),
|
||||
"restart" => { /* restart logic */ }
|
||||
"settings" => { /* open settings */ }
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
fn load_icon_from_bytes(bytes: &[u8]) -> tray_icon::Icon {
|
||||
let image = image::load_from_memory(bytes)
|
||||
.expect("Failed to load icon")
|
||||
.into_rgba8();
|
||||
let (width, height) = image.dimensions();
|
||||
let rgba = image.into_raw();
|
||||
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")
|
||||
}
|
||||
15
crates/jarvis-app/src/tray/menu.rs
Normal file
15
crates/jarvis-app/src/tray/menu.rs
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
pub enum TrayMenuItem {
|
||||
Restart,
|
||||
Settings,
|
||||
Exit,
|
||||
}
|
||||
|
||||
impl TrayMenuItem {
|
||||
pub fn label(&self) -> &str {
|
||||
match *self {
|
||||
TrayMenuItem::Restart => "Перезапустить",
|
||||
TrayMenuItem::Settings => "Настройки",
|
||||
TrayMenuItem::Exit => "Выход",
|
||||
}
|
||||
}
|
||||
}
|
||||
27
crates/jarvis-core/Cargo.toml
Normal file
27
crates/jarvis-core/Cargo.toml
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
[package]
|
||||
name = "jarvis-core"
|
||||
version.workspace = true
|
||||
authors.workspace = true
|
||||
license.workspace = true
|
||||
repository.workspace = true
|
||||
edition.workspace = true
|
||||
|
||||
[dependencies]
|
||||
serde.workspace = true
|
||||
serde_json.workspace = true
|
||||
serde_yaml.workspace = true
|
||||
once_cell.workspace = true
|
||||
log.workspace = true
|
||||
rand.workspace = true
|
||||
seqdiff.workspace = true
|
||||
hound.workspace = true
|
||||
platform-dirs.workspace = true
|
||||
rodio.workspace = true
|
||||
kira.workspace = true
|
||||
pv_recorder.workspace = true
|
||||
vosk.workspace = true
|
||||
rustpotter.workspace = true
|
||||
|
||||
[features]
|
||||
default = ["jarvis_app"]
|
||||
jarvis_app = []
|
||||
88
crates/jarvis-core/src/audio.rs
Normal file
88
crates/jarvis-core/src/audio.rs
Normal file
|
|
@ -0,0 +1,88 @@
|
|||
mod kira;
|
||||
mod rodio;
|
||||
|
||||
use once_cell::sync::OnceCell;
|
||||
use std::cmp::Ordering;
|
||||
use std::path::PathBuf;
|
||||
|
||||
use crate::config::structs::AudioType;
|
||||
use crate::{config, DB, SOUND_DIR};
|
||||
|
||||
static AUDIO_TYPE: OnceCell<AudioType> = OnceCell::new();
|
||||
|
||||
pub fn init() -> Result<(), ()> {
|
||||
if !AUDIO_TYPE.get().is_none() {
|
||||
return Ok(());
|
||||
} // already initialized
|
||||
|
||||
// set default audio type
|
||||
// @TODO. Make it configurable?
|
||||
AUDIO_TYPE.set(config::DEFAULT_AUDIO_TYPE).unwrap();
|
||||
|
||||
// load given audio backend
|
||||
match AUDIO_TYPE.get().unwrap() {
|
||||
AudioType::Rodio => {
|
||||
// Init Rodio
|
||||
info!("Initializing Rodio audio backend.");
|
||||
|
||||
match rodio::init() {
|
||||
Ok(_) => {
|
||||
info!("Successfully initialized Rodio audio backend.");
|
||||
}
|
||||
Err(msg) => {
|
||||
error!("Failed to initialize Rodio audio backend.");
|
||||
|
||||
return Err(());
|
||||
}
|
||||
}
|
||||
}
|
||||
AudioType::Kira => {
|
||||
// Init Kira
|
||||
info!("Initializing Kira audio backend.");
|
||||
|
||||
match kira::init() {
|
||||
Ok(_) => {
|
||||
info!("Successfully initialized Kira audio backend.");
|
||||
}
|
||||
Err(msg) => {
|
||||
error!("Failed to initialize Kira audio backend.");
|
||||
|
||||
return Err(());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn play_sound(filename: &PathBuf) {
|
||||
info!("Playing {}", filename.display());
|
||||
|
||||
match AUDIO_TYPE.get().unwrap() {
|
||||
AudioType::Rodio => {
|
||||
rodio::play_sound(filename, true);
|
||||
}
|
||||
AudioType::Kira => kira::play_sound(filename),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn get_sound_directory() -> Option<PathBuf> {
|
||||
let voice = DB.get().unwrap().voice.as_str();
|
||||
let voice_path = SOUND_DIR.join(voice);
|
||||
|
||||
match voice_path.exists() && voice_path.cmp(&SOUND_DIR) != Ordering::Equal {
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
62
crates/jarvis-core/src/audio/kira.rs
Normal file
62
crates/jarvis-core/src/audio/kira.rs
Normal file
|
|
@ -0,0 +1,62 @@
|
|||
use once_cell::sync::OnceCell;
|
||||
use std::path::PathBuf;
|
||||
use std::sync::Mutex;
|
||||
|
||||
// use kira::{
|
||||
// manager::{backend::DefaultBackend, AudioManager, AudioManagerSettings},
|
||||
// sound::static_sound::{StaticSoundData, StaticSoundSettings},
|
||||
// };
|
||||
|
||||
use kira::{
|
||||
AudioManager, AudioManagerSettings, DefaultBackend,
|
||||
sound::static_sound::StaticSoundData,
|
||||
};
|
||||
|
||||
static MANAGER: OnceCell<Mutex<AudioManager>> = OnceCell::new();
|
||||
|
||||
pub fn init() -> Result<(), ()> {
|
||||
if MANAGER.get().is_some() {
|
||||
return Ok(());
|
||||
} // already initialized
|
||||
|
||||
// Create an audio manager. This plays sounds and manages resources.
|
||||
match AudioManager::<DefaultBackend>::new(AudioManagerSettings::default()) {
|
||||
Ok(manager) => {
|
||||
// store
|
||||
MANAGER.set(Mutex::new(manager)).ok();
|
||||
|
||||
// success
|
||||
Ok(())
|
||||
}
|
||||
Err(msg) => {
|
||||
error!("Failed to initialize audio stream.\nError details: {}", msg);
|
||||
|
||||
// failed
|
||||
Err(())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// @TODO. Cache sounds in memory? With a pool of a certain size, for instance.
|
||||
pub fn play_sound(filename: &PathBuf) {
|
||||
// load the file
|
||||
match StaticSoundData::from_file(filename) {
|
||||
Ok(sound_data) => {
|
||||
// sound_data.duration() can be used in order to sleep, if (for some reason) blocking behaviour is required
|
||||
|
||||
// play it (non-blocking)
|
||||
if let Some(manager) = MANAGER.get() {
|
||||
if let Ok(mut audio_manager) = manager.lock() {
|
||||
if let Err(e) = audio_manager.play(sound_data) {
|
||||
warn!("Failed to play sound: {}", e);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
warn!("Audio manager not initialized");
|
||||
}
|
||||
}
|
||||
Err(err) => {
|
||||
warn!("Cannot find sound file: {} (err: {})", filename.display(), err);
|
||||
}
|
||||
}
|
||||
}
|
||||
65
crates/jarvis-core/src/audio/rodio.rs
Normal file
65
crates/jarvis-core/src/audio/rodio.rs
Normal file
|
|
@ -0,0 +1,65 @@
|
|||
/*
|
||||
Abandoned temporary.
|
||||
Problems with blocking behaviour.
|
||||
Possible fixes are running rodio in a separate thread or smthng.
|
||||
*/
|
||||
|
||||
use once_cell::sync::OnceCell;
|
||||
use std::fs::File;
|
||||
use std::io::BufReader;
|
||||
use std::path::PathBuf;
|
||||
|
||||
// use rodio::{Decoder, OutputStream, OutputStreamHandle, Sink};
|
||||
use rodio::{Decoder, OutputStream, Sink};
|
||||
|
||||
// static STREAM: OnceCell<OutputStream> = OnceCell::new();
|
||||
static STREAM_HANDLE: OnceCell<OutputStream> = OnceCell::new();
|
||||
static SINK: OnceCell<Sink> = OnceCell::new();
|
||||
|
||||
pub fn init() -> Result<(), ()> {
|
||||
if !STREAM_HANDLE.get().is_none() {
|
||||
return Ok(());
|
||||
} // already initialized
|
||||
|
||||
// get output stream handle to the default physical sound device
|
||||
match rodio::OutputStreamBuilder::open_default_stream() {
|
||||
Ok(stream_handle) => {
|
||||
// create sink
|
||||
let sink = Sink::connect_new(&stream_handle.mixer());
|
||||
info!("Sink initialized.");
|
||||
|
||||
// store
|
||||
// STREAM.set(_stream).unwrap();
|
||||
STREAM_HANDLE.set(stream_handle);
|
||||
SINK.set(sink);
|
||||
|
||||
// success
|
||||
Ok(())
|
||||
}
|
||||
Err(msg) => {
|
||||
error!("Failed to initialize audio stream.\nError details: {}", msg);
|
||||
|
||||
// failed
|
||||
Err(())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn play_sound(filename: &PathBuf, sleep: bool) {
|
||||
// Load a sound from a file, using a path relative to Cargo.toml
|
||||
// let filepath = format!("{PUBLIC_PATH}/sound/{filename}.wav");
|
||||
let file = BufReader::new(File::open(&filename).unwrap());
|
||||
|
||||
// Decode that sound file into a source
|
||||
let source = Decoder::new(file).unwrap();
|
||||
|
||||
// Play the sound directly on the device
|
||||
// STREAM_HANDLE.get().unwrap().play_raw(source.convert_samples());
|
||||
SINK.get().unwrap().append(source);
|
||||
|
||||
if sleep {
|
||||
// The sound plays in a separate thread. This call will block the current thread until the sink
|
||||
// has finished playing all its queued sounds.
|
||||
SINK.get().unwrap().sleep_until_end();
|
||||
}
|
||||
}
|
||||
255
crates/jarvis-core/src/commands.rs
Normal file
255
crates/jarvis-core/src/commands.rs
Normal file
|
|
@ -0,0 +1,255 @@
|
|||
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::process::{Child, Command};
|
||||
// use tauri::Manager;
|
||||
|
||||
mod structs;
|
||||
pub use structs::*;
|
||||
|
||||
use crate::{audio, config};
|
||||
|
||||
// @TODO. Allow commands both in yaml and json format.
|
||||
pub fn parse_commands() -> Result<Vec<AssistantCommand>, String> {
|
||||
// collect commands
|
||||
let mut commands: Vec<AssistantCommand> = vec![];
|
||||
|
||||
// read commands directories first
|
||||
if let Ok(cpaths) = fs::read_dir(config::COMMANDS_PATH) {
|
||||
for cpath in cpaths {
|
||||
// validate this command, check if required files exists
|
||||
let _cpath = cpath.unwrap().path();
|
||||
let cc_file = Path::new(&_cpath).join("command.yaml");
|
||||
|
||||
if cc_file.exists() {
|
||||
// try parse config files
|
||||
let cc_reader = std::fs::File::open(&cc_file).unwrap();
|
||||
let cc_yaml: CommandsList;
|
||||
|
||||
// try parse command.yaml
|
||||
match serde_yaml::from_reader::<File, CommandsList>(cc_reader) {
|
||||
Ok(parse_result) => {
|
||||
cc_yaml = parse_result;
|
||||
}
|
||||
Err(msg) => {
|
||||
warn!(
|
||||
"Can't parse {}, skipping ...\nCommand parse error is: {:?}",
|
||||
&cc_file.display(),
|
||||
msg
|
||||
);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
// everything seems to be Ok
|
||||
commands.push(AssistantCommand {
|
||||
path: _cpath,
|
||||
commands: cc_yaml,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if commands.len() > 0 {
|
||||
Ok(commands)
|
||||
} else {
|
||||
error!("No commands were found");
|
||||
Err("No commands were found".into())
|
||||
}
|
||||
} else {
|
||||
error!("Error reading commands directory");
|
||||
return Err("Error reading commands directory".into());
|
||||
}
|
||||
}
|
||||
|
||||
// @TODO. NLU or smthng else is required, in order to infer commands with highest accuracy possible.
|
||||
pub fn fetch_command<'a>(
|
||||
phrase: &str,
|
||||
commands: &'a Vec<AssistantCommand>,
|
||||
) -> Option<(&'a PathBuf, &'a Config)> {
|
||||
// result scmd
|
||||
let mut result_scmd: Option<(&PathBuf, &Config)> = None;
|
||||
let mut current_max_ratio = config::CMD_RATIO_THRESHOLD;
|
||||
|
||||
// convert fetch phrase to sequence
|
||||
let fetch_phrase_chars = phrase.chars().collect::<Vec<_>>();
|
||||
|
||||
// list all the commands
|
||||
for cmd in commands {
|
||||
// list all subcommands
|
||||
for scmd in &cmd.commands.list {
|
||||
// list all phrases in command
|
||||
for cmd_phrase in &scmd.phrases {
|
||||
// convert cmd phrase to sequence
|
||||
let cmd_phrase_chars = cmd_phrase.chars().collect::<Vec<_>>();
|
||||
|
||||
// compare fetch phrase with cmd phrase
|
||||
let ratio = ratio(&fetch_phrase_chars, &cmd_phrase_chars);
|
||||
|
||||
// return, if it fits the given threshold
|
||||
if ratio >= current_max_ratio {
|
||||
result_scmd = Some((&cmd.path, &scmd));
|
||||
current_max_ratio = ratio;
|
||||
// println!("Ratio is: {}", ratio);
|
||||
// return Some((&cmd.path, &scmd))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if let Some((cmd_path, scmd)) = result_scmd {
|
||||
println!("Ratio is: {}", current_max_ratio);
|
||||
info!(
|
||||
"CMD is: {cmd_path:?}, SCMD is: {scmd:?}, Ratio is: {}",
|
||||
current_max_ratio
|
||||
);
|
||||
Some((&cmd_path, &scmd))
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
// @TODO. Rewrite executors by executor type struct. (with match arms)
|
||||
pub fn execute_exe(exe: &str, args: &Vec<String>) -> std::io::Result<Child> {
|
||||
Command::new(exe).args(args).spawn()
|
||||
}
|
||||
|
||||
pub fn execute_cli(cmd: &str, args: &Vec<String>) -> std::io::Result<Child> {
|
||||
println!("Spawning cmd as: cmd /C {} {:?}", cmd, args);
|
||||
|
||||
if cfg!(target_os = "windows") {
|
||||
Command::new("cmd").arg("/C").arg(cmd).args(args).spawn()
|
||||
} else {
|
||||
Command::new("sh").arg("-c").arg(cmd).args(args).spawn()
|
||||
}
|
||||
}
|
||||
|
||||
pub fn execute_command(
|
||||
cmd_path: &PathBuf,
|
||||
cmd_config: &Config,
|
||||
// app_handle: &tauri::AppHandle,
|
||||
) -> Result<bool, String> {
|
||||
let sounds_directory = audio::get_sound_directory().unwrap();
|
||||
|
||||
match cmd_config.command.action.as_str() {
|
||||
"voice" => {
|
||||
// VOICE command type
|
||||
let random_cmd_sound = format!(
|
||||
"{}.wav",
|
||||
cmd_config
|
||||
.voice
|
||||
.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)
|
||||
}
|
||||
"ahk" => {
|
||||
// AutoHotkey command type
|
||||
let exe_path_absolute = Path::new(&cmd_config.command.exe_path);
|
||||
let exe_path_local = Path::new(&cmd_path).join(&cmd_config.command.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.command.exe_args,
|
||||
) {
|
||||
let random_cmd_sound = format!(
|
||||
"{}.wav",
|
||||
cmd_config
|
||||
.voice
|
||||
.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)
|
||||
} else {
|
||||
error!("AHK process spawn error (does exe path is valid?)");
|
||||
Err("AHK process spawn error (does exe path is valid?)".into())
|
||||
}
|
||||
}
|
||||
"cli" => {
|
||||
// CLI command type
|
||||
let cli_cmd = &cmd_config.command.cli_cmd;
|
||||
|
||||
match execute_cli(cli_cmd, &cmd_config.command.cli_args) {
|
||||
Ok(_) => {
|
||||
let random_cmd_sound = format!(
|
||||
"{}.wav",
|
||||
cmd_config
|
||||
.voice
|
||||
.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())
|
||||
}
|
||||
}
|
||||
}
|
||||
"terminate" => {
|
||||
// TERMINATE command type
|
||||
let random_cmd_sound = format!(
|
||||
"{}.wav",
|
||||
cmd_config
|
||||
.voice
|
||||
.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
|
||||
.voice
|
||||
.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())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn list(from: &[AssistantCommand]) -> 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
|
||||
}
|
||||
45
crates/jarvis-core/src/commands/structs.rs
Normal file
45
crates/jarvis-core/src/commands/structs.rs
Normal file
|
|
@ -0,0 +1,45 @@
|
|||
use serde::Deserialize;
|
||||
use std::path::PathBuf;
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct AssistantCommand {
|
||||
pub path: PathBuf,
|
||||
pub commands: CommandsList,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Debug)]
|
||||
pub struct CommandsList {
|
||||
pub list: Vec<Config>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Debug)]
|
||||
pub struct Config {
|
||||
pub command: ConfigCommandSection,
|
||||
|
||||
pub voice: ConfigVoiceSection,
|
||||
|
||||
pub phrases: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Debug)]
|
||||
pub struct ConfigCommandSection {
|
||||
pub action: String,
|
||||
|
||||
#[serde(default)]
|
||||
pub exe_path: String,
|
||||
|
||||
#[serde(default)]
|
||||
pub exe_args: Vec<String>,
|
||||
|
||||
#[serde(default)]
|
||||
pub cli_cmd: String,
|
||||
|
||||
#[serde(default)]
|
||||
pub cli_args: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Debug)]
|
||||
pub struct ConfigVoiceSection {
|
||||
#[serde(default)]
|
||||
pub sounds: Vec<String>,
|
||||
}
|
||||
156
crates/jarvis-core/src/config.rs
Normal file
156
crates/jarvis-core/src/config.rs
Normal file
|
|
@ -0,0 +1,156 @@
|
|||
pub mod structs;
|
||||
use structs::AudioType;
|
||||
use structs::RecorderType;
|
||||
use structs::SpeechToTextEngine;
|
||||
use structs::WakeWordEngine;
|
||||
|
||||
use once_cell::sync::Lazy;
|
||||
use std::env;
|
||||
use std::fs;
|
||||
use std::path::PathBuf;
|
||||
|
||||
use platform_dirs::AppDirs;
|
||||
|
||||
#[cfg(feature="jarvis_app")]
|
||||
use rustpotter::{
|
||||
AudioFmt, BandPassConfig, DetectorConfig, FiltersConfig, GainNormalizationConfig,
|
||||
RustpotterConfig, ScoreMode,
|
||||
};
|
||||
|
||||
use crate::{APP_CONFIG_DIR, APP_DIRS, APP_LOG_DIR};
|
||||
|
||||
#[allow(dead_code)]
|
||||
pub fn init_dirs() -> Result<(), String> {
|
||||
// infer app dirs
|
||||
if APP_DIRS.get().is_some() {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
// cache_dir, config_dir, data_dir, state_dir
|
||||
APP_DIRS
|
||||
.set(AppDirs::new(Some(BUNDLE_IDENTIFIER), false).unwrap())
|
||||
.unwrap();
|
||||
|
||||
// setup directories
|
||||
let mut config_dir = PathBuf::from(&APP_DIRS.get().unwrap().config_dir);
|
||||
let mut log_dir = PathBuf::from(&APP_DIRS.get().unwrap().config_dir);
|
||||
|
||||
// create dirs, if required
|
||||
if !config_dir.exists() {
|
||||
if fs::create_dir_all(&config_dir).is_err() {
|
||||
config_dir = env::current_dir().expect("Cannot infer the config directory");
|
||||
fs::create_dir_all(&config_dir)
|
||||
.expect("Cannot create config directory, access denied?");
|
||||
}
|
||||
}
|
||||
|
||||
if !log_dir.exists() {
|
||||
if fs::create_dir_all(&log_dir).is_err() {
|
||||
log_dir = env::current_dir().expect("Cannot infer the log directory");
|
||||
fs::create_dir_all(&log_dir).expect("Cannot create log directory, access denied?");
|
||||
}
|
||||
}
|
||||
|
||||
// store inferred paths
|
||||
APP_CONFIG_DIR.set(config_dir).unwrap();
|
||||
APP_LOG_DIR.set(log_dir).unwrap();
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/*
|
||||
Defaults.
|
||||
*/
|
||||
pub const DEFAULT_AUDIO_TYPE: AudioType = AudioType::Kira;
|
||||
pub const DEFAULT_RECORDER_TYPE: RecorderType = RecorderType::PvRecorder;
|
||||
pub const DEFAULT_WAKE_WORD_ENGINE: WakeWordEngine = WakeWordEngine::Rustpotter;
|
||||
pub const DEFAULT_SPEECH_TO_TEXT_ENGINE: SpeechToTextEngine = SpeechToTextEngine::Vosk;
|
||||
|
||||
pub const DEFAULT_VOICE: &str = "jarvis-og";
|
||||
|
||||
pub const BUNDLE_IDENTIFIER: &str = "com.priler.jarvis";
|
||||
pub const DB_FILE_NAME: &str = "app.db";
|
||||
pub const LOG_FILE_NAME: &str = "log.txt";
|
||||
pub const APP_VERSION: Option<&str> = option_env!("CARGO_PKG_VERSION");
|
||||
pub const AUTHOR_NAME: Option<&str> = option_env!("CARGO_PKG_AUTHORS");
|
||||
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");
|
||||
|
||||
/*
|
||||
Tray.
|
||||
*/
|
||||
pub const TRAY_ICON: &str = "32x32.png";
|
||||
pub const TRAY_TOOLTIP: &str = "Jarvis Voice Assistant";
|
||||
|
||||
// RUSPOTTER
|
||||
pub const RUSPOTTER_MIN_SCORE: f32 = 0.62;
|
||||
|
||||
#[cfg(feature="jarvis_app")]
|
||||
pub const RUSTPOTTER_DEFAULT_CONFIG: Lazy<RustpotterConfig> = Lazy::new(|| {
|
||||
RustpotterConfig {
|
||||
fmt: AudioFmt::default(),
|
||||
detector: DetectorConfig {
|
||||
avg_threshold: 0.,
|
||||
threshold: 0.5,
|
||||
min_scores: 15,
|
||||
score_ref: 0.22,
|
||||
band_size: 5,
|
||||
vad_mode: None,
|
||||
score_mode: ScoreMode::Max,
|
||||
eager: false,
|
||||
// comparator_band_size: 5,
|
||||
// comparator_ref: 0.22
|
||||
},
|
||||
filters: FiltersConfig {
|
||||
gain_normalizer: GainNormalizationConfig {
|
||||
enabled: true,
|
||||
gain_ref: None,
|
||||
min_gain: 0.7,
|
||||
max_gain: 1.0,
|
||||
},
|
||||
band_pass: BandPassConfig {
|
||||
enabled: true,
|
||||
low_cutoff: 80.,
|
||||
high_cutoff: 400.,
|
||||
},
|
||||
},
|
||||
}
|
||||
});
|
||||
|
||||
// PICOVOICE
|
||||
pub const COMMANDS_PATH: &str = "commands/";
|
||||
pub const KEYWORDS_PATH: &str = "picovoice/keywords/";
|
||||
pub const DEFAULT_KEYWORD: &str = "jarvis_windows.ppn";
|
||||
pub const DEFAULT_SENSITIVITY: f32 = 1.0;
|
||||
|
||||
// VOSK
|
||||
// pub const VOSK_MODEL_PATH: &str = const_concat!(PUBLIC_PATH, "/vosk/model_small");
|
||||
pub const VOSK_FETCH_PHRASE: &str = "джарвис";
|
||||
pub const VOSK_MODEL_PATH: &str = "vosk/model_small";
|
||||
pub const VOSK_MIN_RATIO: f64 = 70.0;
|
||||
|
||||
// ETC
|
||||
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] = [
|
||||
"джарвис",
|
||||
"сэр",
|
||||
"слушаю сэр",
|
||||
"всегда к услугам",
|
||||
"произнеси",
|
||||
"ответь",
|
||||
"покажи",
|
||||
"скажи",
|
||||
"давай",
|
||||
"да сэр",
|
||||
"к вашим услугам сэр",
|
||||
"всегда к вашим услугам сэр",
|
||||
"запрос выполнен сэр",
|
||||
"выполнен сэр",
|
||||
"есть",
|
||||
"загружаю сэр",
|
||||
"очень тонкое замечание сэр",
|
||||
];
|
||||
43
crates/jarvis-core/src/config/structs.rs
Normal file
43
crates/jarvis-core/src/config/structs.rs
Normal file
|
|
@ -0,0 +1,43 @@
|
|||
use std::fmt;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Clone, Copy, Serialize, Deserialize, Debug)]
|
||||
pub enum WakeWordEngine {
|
||||
Rustpotter,
|
||||
Vosk,
|
||||
Porcupine,
|
||||
}
|
||||
|
||||
impl fmt::Display for WakeWordEngine {
|
||||
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
|
||||
write!(f, "{:?}", self)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Debug)]
|
||||
pub enum SpeechToTextEngine {
|
||||
Vosk,
|
||||
}
|
||||
|
||||
impl fmt::Display for SpeechToTextEngine {
|
||||
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
|
||||
write!(f, "{:?}", self)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(PartialEq, Debug)]
|
||||
pub enum RecorderType {
|
||||
Cpal,
|
||||
PvRecorder,
|
||||
PortAudio,
|
||||
}
|
||||
|
||||
#[derive(PartialEq, Debug)]
|
||||
pub enum AudioType {
|
||||
Rodio,
|
||||
Kira,
|
||||
}
|
||||
|
||||
// pub enum TextToSpeechEngine {}
|
||||
|
||||
// pub enum IntentRecognitionEngine {}
|
||||
58
crates/jarvis-core/src/db.rs
Normal file
58
crates/jarvis-core/src/db.rs
Normal file
|
|
@ -0,0 +1,58 @@
|
|||
pub mod structs;
|
||||
use crate::{config, APP_CONFIG_DIR};
|
||||
|
||||
use log::info;
|
||||
use std::fs::File;
|
||||
use std::io::{BufReader, Read};
|
||||
use std::path::PathBuf;
|
||||
|
||||
use serde_json;
|
||||
|
||||
fn get_db_file_path() -> PathBuf {
|
||||
PathBuf::from(format!(
|
||||
"{}/{}",
|
||||
APP_CONFIG_DIR.get().unwrap().display(),
|
||||
config::DB_FILE_NAME
|
||||
))
|
||||
}
|
||||
|
||||
pub fn init_settings() -> structs::Settings {
|
||||
let mut db = None;
|
||||
let db_file_path = get_db_file_path();
|
||||
|
||||
info!(
|
||||
"Loading settings db file located at: {}",
|
||||
db_file_path.display()
|
||||
);
|
||||
|
||||
if db_file_path.exists() {
|
||||
// try load existing settings
|
||||
if let Ok(mut db_file) = File::open(db_file_path) {
|
||||
let reader = BufReader::new(db_file);
|
||||
if let Ok(parsed_json) = serde_json::from_reader(reader) {
|
||||
info!("Settings loaded.");
|
||||
db = Some(parsed_json);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if db.is_none() {
|
||||
// create default settings db file
|
||||
warn!("No settings file found or there was an error parsing it. Creating default struct.");
|
||||
db = Some(structs::Settings::default());
|
||||
}
|
||||
|
||||
db.unwrap()
|
||||
}
|
||||
|
||||
pub fn save_settings(settings: &structs::Settings) -> Result<(), std::io::Error> {
|
||||
let db_file_path = get_db_file_path();
|
||||
|
||||
std::fs::write(
|
||||
db_file_path,
|
||||
serde_json::to_string_pretty(&settings).unwrap(),
|
||||
)?;
|
||||
|
||||
info!("Settings saved.");
|
||||
Ok(())
|
||||
}
|
||||
39
crates/jarvis-core/src/db/structs.rs
Normal file
39
crates/jarvis-core/src/db/structs.rs
Normal file
|
|
@ -0,0 +1,39 @@
|
|||
use crate::config;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::config::structs::SpeechToTextEngine;
|
||||
use crate::config::structs::WakeWordEngine;
|
||||
|
||||
#[derive(Serialize, Deserialize, Debug)]
|
||||
pub struct Settings {
|
||||
pub microphone: i32,
|
||||
pub voice: String,
|
||||
|
||||
pub wake_word_engine: WakeWordEngine,
|
||||
pub speech_to_text_engine: SpeechToTextEngine,
|
||||
|
||||
pub api_keys: ApiKeys,
|
||||
}
|
||||
|
||||
impl Default for Settings {
|
||||
fn default() -> Settings {
|
||||
Settings {
|
||||
microphone: -1,
|
||||
voice: String::from(""),
|
||||
|
||||
wake_word_engine: config::DEFAULT_WAKE_WORD_ENGINE,
|
||||
speech_to_text_engine: config::DEFAULT_SPEECH_TO_TEXT_ENGINE,
|
||||
|
||||
api_keys: ApiKeys {
|
||||
picovoice: String::from(""),
|
||||
openai: String::from(""),
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Debug)]
|
||||
pub struct ApiKeys {
|
||||
pub picovoice: String,
|
||||
pub openai: String,
|
||||
}
|
||||
28
crates/jarvis-core/src/lib.rs
Normal file
28
crates/jarvis-core/src/lib.rs
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
use once_cell::sync::{Lazy, OnceCell};
|
||||
use platform_dirs::AppDirs;
|
||||
use std::path::PathBuf;
|
||||
|
||||
#[macro_use]
|
||||
extern crate log;
|
||||
|
||||
pub mod audio;
|
||||
pub mod commands;
|
||||
pub mod config;
|
||||
pub mod db;
|
||||
pub mod listener;
|
||||
pub mod recorder;
|
||||
pub mod stt;
|
||||
|
||||
// shared statics
|
||||
pub static APP_DIR: Lazy<PathBuf> = Lazy::new(|| std::env::current_dir().unwrap());
|
||||
pub static SOUND_DIR: Lazy<PathBuf> = Lazy::new(|| APP_DIR.clone().join("sound"));
|
||||
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();
|
||||
pub static DB: OnceCell<db::structs::Settings> = OnceCell::new();
|
||||
pub static COMMANDS_LIST: OnceCell<Vec<commands::AssistantCommand>> = OnceCell::new();
|
||||
|
||||
// re-exports
|
||||
pub use commands::AssistantCommand;
|
||||
pub use config::structs::*;
|
||||
pub use db::structs::Settings;
|
||||
65
crates/jarvis-core/src/listener.rs
Normal file
65
crates/jarvis-core/src/listener.rs
Normal file
|
|
@ -0,0 +1,65 @@
|
|||
// mod porcupine;
|
||||
|
||||
mod rustpotter;
|
||||
|
||||
mod vosk;
|
||||
|
||||
use once_cell::sync::OnceCell;
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
|
||||
use crate::config::structs::WakeWordEngine;
|
||||
use crate::{config, stt};
|
||||
|
||||
use crate::DB;
|
||||
|
||||
// store wake-word engine being used
|
||||
static WAKE_WORD_ENGINE: OnceCell<WakeWordEngine> = OnceCell::new();
|
||||
|
||||
// track listening state
|
||||
static LISTENING: AtomicBool = AtomicBool::new(false);
|
||||
|
||||
pub fn init() -> Result<(), ()> {
|
||||
if !WAKE_WORD_ENGINE.get().is_none() {
|
||||
return Ok(());
|
||||
} // already initialized
|
||||
|
||||
// store current engine
|
||||
WAKE_WORD_ENGINE
|
||||
.set(DB.get().unwrap().wake_word_engine)
|
||||
.unwrap();
|
||||
|
||||
// load given wake-word engine
|
||||
match WAKE_WORD_ENGINE.get().unwrap() {
|
||||
WakeWordEngine::Porcupine => {
|
||||
// Init Porcupine wake-word engine
|
||||
info!("Initializing Porcupine wake-word engine.");
|
||||
|
||||
// return porcupine::init();
|
||||
unimplemented!("f*ck picovoice");
|
||||
}
|
||||
WakeWordEngine::Rustpotter => {
|
||||
// Init Rustpotter wake-word engine
|
||||
info!("Initializing Rustpotter wake-word engine.");
|
||||
|
||||
return rustpotter::init();
|
||||
}
|
||||
WakeWordEngine::Vosk => {
|
||||
// Init Vosk as wake-word engine (very slow, though)
|
||||
info!("Initializing Vosk as wake-word engine.");
|
||||
warn!("Using Vosk as wake-word engine is highly not recommended, because it's very slow for this task.");
|
||||
|
||||
return vosk::init();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn data_callback(frame_buffer: &[i16]) -> Option<i32> {
|
||||
match WAKE_WORD_ENGINE.get().unwrap() {
|
||||
WakeWordEngine::Porcupine => {
|
||||
// porcupine::data_callback(frame_buffer)
|
||||
unimplemented!("f*ck picovoice");
|
||||
},
|
||||
WakeWordEngine::Rustpotter => rustpotter::data_callback(frame_buffer),
|
||||
WakeWordEngine::Vosk => vosk::data_callback(frame_buffer),
|
||||
}
|
||||
}
|
||||
56
crates/jarvis-core/src/listener/porcupine.rs
Normal file
56
crates/jarvis-core/src/listener/porcupine.rs
Normal file
|
|
@ -0,0 +1,56 @@
|
|||
use std::path::Path;
|
||||
|
||||
use once_cell::sync::OnceCell;
|
||||
use porcupine::{Porcupine, PorcupineBuilder};
|
||||
|
||||
use crate::config;
|
||||
use crate::DB;
|
||||
|
||||
// store porcupine instance
|
||||
static PORCUPINE: OnceCell<Porcupine> = OnceCell::new();
|
||||
|
||||
pub fn init() -> Result<(), ()> {
|
||||
let picovoice_api_key: String;
|
||||
|
||||
// retrieve picovoice api key
|
||||
picovoice_api_key = DB.get().unwrap().api_keys.picovoice.clone();
|
||||
if picovoice_api_key.trim().is_empty() {
|
||||
warn!("Picovoice API key is not set.");
|
||||
return Err(());
|
||||
}
|
||||
|
||||
// create porcupine instance with the given API key
|
||||
match PorcupineBuilder::new_with_keyword_paths(
|
||||
picovoice_api_key,
|
||||
&[Path::new(config::KEYWORDS_PATH).join(config::DEFAULT_KEYWORD)],
|
||||
)
|
||||
.sensitivities(&[config::DEFAULT_SENSITIVITY]) // set sensitivity
|
||||
.init()
|
||||
{
|
||||
Ok(pinstance) => {
|
||||
// success
|
||||
info!("Porcupine successfully initialized with the given API key.");
|
||||
|
||||
// store
|
||||
PORCUPINE.set(pinstance);
|
||||
}
|
||||
Err(msg) => {
|
||||
error!("Porcupine failed to initialize, either API key is not valid or there is no internet connection.");
|
||||
error!("Error details: {}", msg);
|
||||
|
||||
return Err(());
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn data_callback(frame_buffer: &[i16]) -> Option<i32> {
|
||||
if let Ok(keyword_index) = PORCUPINE.get().unwrap().process(&frame_buffer) {
|
||||
if keyword_index >= 0 {
|
||||
return Some(keyword_index);
|
||||
}
|
||||
}
|
||||
|
||||
None
|
||||
}
|
||||
68
crates/jarvis-core/src/listener/rustpotter.rs
Normal file
68
crates/jarvis-core/src/listener/rustpotter.rs
Normal file
|
|
@ -0,0 +1,68 @@
|
|||
use std::path::Path;
|
||||
use std::sync::Mutex;
|
||||
|
||||
use once_cell::sync::OnceCell;
|
||||
use rustpotter::{
|
||||
AudioFmt, BandPassConfig, DetectorConfig, FiltersConfig, GainNormalizationConfig, Rustpotter,
|
||||
RustpotterConfig, ScoreMode,
|
||||
};
|
||||
|
||||
use crate::config;
|
||||
use crate::DB;
|
||||
|
||||
// store rustpotter instance
|
||||
static RUSTPOTTER: OnceCell<Mutex<Rustpotter>> = OnceCell::new();
|
||||
|
||||
pub fn init() -> Result<(), ()> {
|
||||
let rustpotter_config = config::RUSTPOTTER_DEFAULT_CONFIG;
|
||||
|
||||
// create rustpotter instance
|
||||
match Rustpotter::new(&rustpotter_config) {
|
||||
Ok(mut rinstance) => {
|
||||
// success
|
||||
// wake word files list
|
||||
// @TODO. Make it configurable via GUI for custom user voice.
|
||||
let rustpotter_wake_word_files: [&str; 1] = [
|
||||
"rustpotter/jarvis-default.rpw",
|
||||
// "rustpotter/jarvis-community-1.rpw",
|
||||
// "rustpotter/jarvis-community-2.rpw",
|
||||
// "rustpotter/jarvis-community-3.rpw",
|
||||
// "rustpotter/jarvis-community-4.rpw",
|
||||
// "rustpotter/jarvis-community-5.rpw",
|
||||
];
|
||||
|
||||
// load wake word files
|
||||
for rpw in rustpotter_wake_word_files {
|
||||
rinstance.add_wakeword_from_file(rpw, rpw).unwrap(); // @TODO: Change wakeword key to something else?
|
||||
}
|
||||
|
||||
// store
|
||||
RUSTPOTTER.set(Mutex::new(rinstance));
|
||||
}
|
||||
Err(msg) => {
|
||||
error!("Rustpotter failed to initialize.\nError details: {}", msg);
|
||||
|
||||
return Err(());
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn data_callback(frame_buffer: &[i16]) -> Option<i32> {
|
||||
let mut lock = RUSTPOTTER.get().unwrap().lock();
|
||||
let rustpotter = lock.as_mut().unwrap();
|
||||
let detection = rustpotter.process_samples(frame_buffer.to_vec()); // @TODO. Temp crutch. Fix optimization issue, frame_buffer should not be copied to a new vector!
|
||||
|
||||
if let Some(detection) = detection {
|
||||
if detection.score > config::RUSPOTTER_MIN_SCORE {
|
||||
info!("Rustpotter detection info:\n{:?}", detection);
|
||||
|
||||
return Some(0);
|
||||
} else {
|
||||
info!("Rustpotter detection info:\n{:?}", detection)
|
||||
}
|
||||
}
|
||||
|
||||
None
|
||||
}
|
||||
36
crates/jarvis-core/src/listener/vosk.rs
Normal file
36
crates/jarvis-core/src/listener/vosk.rs
Normal file
|
|
@ -0,0 +1,36 @@
|
|||
use crate::{config, stt};
|
||||
|
||||
pub fn init() -> Result<(), ()> {
|
||||
Ok(()) // nothing to init for Vosk
|
||||
}
|
||||
|
||||
// @TODO. Make it better somehow (more accurate or with higher sensitivity).
|
||||
pub fn data_callback(frame_buffer: &[i16]) -> Option<i32> {
|
||||
// recognize & convert to sequence
|
||||
let recognized_phrase = stt::recognize(&frame_buffer, true).unwrap_or("".into());
|
||||
|
||||
if !recognized_phrase.trim().is_empty() {
|
||||
info!("Vosk wake-word debug info:");
|
||||
info!("rec: {}", recognized_phrase);
|
||||
let recognized_phrases = recognized_phrase.split_whitespace();
|
||||
for phrase in recognized_phrases {
|
||||
let recognized_phrase_chars = phrase.trim().to_lowercase().chars().collect::<Vec<_>>();
|
||||
|
||||
// compare
|
||||
let compare_ratio = seqdiff::ratio(
|
||||
&config::VOSK_FETCH_PHRASE.chars().collect::<Vec<_>>(),
|
||||
&recognized_phrase_chars,
|
||||
);
|
||||
info!("og phrase: {:?}", &config::VOSK_FETCH_PHRASE);
|
||||
info!("recognized phrase: {:?}", &recognized_phrase_chars);
|
||||
info!("compare ratio: {}", compare_ratio);
|
||||
|
||||
if compare_ratio >= config::VOSK_MIN_RATIO {
|
||||
info!("Phrase activated.");
|
||||
return Some(0);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
None
|
||||
}
|
||||
150
crates/jarvis-core/src/recorder.rs
Normal file
150
crates/jarvis-core/src/recorder.rs
Normal file
|
|
@ -0,0 +1,150 @@
|
|||
mod pvrecorder;
|
||||
// mod cpal;
|
||||
// mod portaudio;
|
||||
|
||||
use once_cell::sync::OnceCell;
|
||||
|
||||
use crate::{config, config::structs::RecorderType, DB};
|
||||
|
||||
static RECORDER_TYPE: OnceCell<RecorderType> = OnceCell::new();
|
||||
static FRAME_LENGTH: OnceCell<u32> = OnceCell::new();
|
||||
|
||||
pub fn init() -> Result<(), ()> {
|
||||
// set default recorder type
|
||||
// @TODO. Make it configurable?
|
||||
RECORDER_TYPE.set(config::DEFAULT_RECORDER_TYPE).unwrap();
|
||||
|
||||
// some info
|
||||
info!("Loading recorder ...");
|
||||
info!("Available audio_devices are:\n{:?}", get_audio_devices());
|
||||
|
||||
// load given recorder
|
||||
match RECORDER_TYPE.get().unwrap() {
|
||||
RecorderType::PvRecorder => {
|
||||
// Init Pv Recorder
|
||||
info!("Initializing PvRecorder recording backend.");
|
||||
FRAME_LENGTH.set(512u32).unwrap(); // pvrecorder requires frame buffer of 512
|
||||
let selected_microphone = get_selected_microphone_index();
|
||||
match pvrecorder::init_microphone(
|
||||
selected_microphone,
|
||||
FRAME_LENGTH.get().unwrap().to_owned(),
|
||||
) {
|
||||
false => {
|
||||
error!("Recorder initialization failed.");
|
||||
|
||||
return Err(());
|
||||
}
|
||||
_ => {
|
||||
info!(
|
||||
"Recorder initialization success. Listening to microphone ({}): {}",
|
||||
selected_microphone,
|
||||
get_audio_device_name(selected_microphone)
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
RecorderType::PortAudio => {
|
||||
// Init PortAudio
|
||||
info!("Initializing PortAudio recording backend");
|
||||
todo!();
|
||||
// match portaudio::init_microphone(get_selected_microphone_index(), FRAME_LENGTH.load(Ordering::SeqCst)) {
|
||||
// false => {
|
||||
// // Switch to PortAudio recorder
|
||||
// error!("PortAudio audio backend failed.");
|
||||
// },
|
||||
// _ => ()
|
||||
// }
|
||||
}
|
||||
RecorderType::Cpal => {
|
||||
// Init CPAL
|
||||
info!("Initializing CPAL recording backend");
|
||||
todo!();
|
||||
// match cpal::init_microphone(get_selected_microphone_index(), FRAME_LENGTH.load(Ordering::SeqCst)) {
|
||||
// false => {
|
||||
// // Switch to CPAL recorder
|
||||
// error!("CPAL audio backend failed.");
|
||||
// },
|
||||
// _ => ()
|
||||
// }
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn read_microphone(frame_buffer: &mut [i16]) {
|
||||
match RECORDER_TYPE.get().unwrap() {
|
||||
RecorderType::PvRecorder => {
|
||||
pvrecorder::read_microphone(frame_buffer);
|
||||
}
|
||||
RecorderType::PortAudio => {
|
||||
todo!();
|
||||
// portaudio::read_microphone(frame_buffer);
|
||||
}
|
||||
RecorderType::Cpal => {
|
||||
// cpal::read_microphone(frame_buffer);
|
||||
panic!("Cpal should be used via callback assignment");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn start_recording() -> Result<(), ()> {
|
||||
match RECORDER_TYPE.get().unwrap() {
|
||||
RecorderType::PvRecorder => {
|
||||
return pvrecorder::start_recording(
|
||||
get_selected_microphone_index(),
|
||||
FRAME_LENGTH.get().unwrap().to_owned(),
|
||||
);
|
||||
}
|
||||
RecorderType::PortAudio => {
|
||||
todo!();
|
||||
// portaudio::start_recording(get_selected_microphone_index(), FRAME_LENGTH.load(Ordering::SeqCst));
|
||||
}
|
||||
RecorderType::Cpal => {
|
||||
todo!();
|
||||
// cpal::start_recording(get_selected_microphone_index(), FRAME_LENGTH.load(Ordering::SeqCst));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn stop_recording() -> Result<(), ()> {
|
||||
match RECORDER_TYPE.get().unwrap() {
|
||||
RecorderType::PvRecorder => pvrecorder::stop_recording(),
|
||||
RecorderType::PortAudio => {
|
||||
todo!();
|
||||
// portaudio::stop_recording();
|
||||
}
|
||||
RecorderType::Cpal => {
|
||||
todo!();
|
||||
// cpal::stop_recording();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn get_selected_microphone_index() -> i32 {
|
||||
DB.get().unwrap().microphone
|
||||
}
|
||||
|
||||
pub fn get_audio_devices() -> Vec<String> {
|
||||
match RECORDER_TYPE.get().unwrap() {
|
||||
RecorderType::PvRecorder => pvrecorder::list_audio_devices(),
|
||||
RecorderType::PortAudio => {
|
||||
todo!();
|
||||
}
|
||||
RecorderType::Cpal => {
|
||||
todo!();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn get_audio_device_name(idx: i32) -> String {
|
||||
match RECORDER_TYPE.get().unwrap() {
|
||||
RecorderType::PvRecorder => pvrecorder::get_audio_device_name(idx),
|
||||
RecorderType::PortAudio => {
|
||||
todo!();
|
||||
}
|
||||
RecorderType::Cpal => {
|
||||
todo!();
|
||||
}
|
||||
}
|
||||
}
|
||||
191
crates/jarvis-core/src/recorder/cpal.rs
Normal file
191
crates/jarvis-core/src/recorder/cpal.rs
Normal file
|
|
@ -0,0 +1,191 @@
|
|||
/*
|
||||
Abandoned temporary.
|
||||
Problems with frame size.
|
||||
*/
|
||||
|
||||
use cpal::traits::{DeviceTrait, HostTrait, StreamTrait};
|
||||
use cpal::{BufferSize, StreamConfig, SampleRate, Host, Device, Stream, SampleFormat};
|
||||
use log::{info, warn, error};
|
||||
|
||||
use once_cell::sync::OnceCell;
|
||||
use std::sync::Arc;
|
||||
use arc_swap::ArcSwap;
|
||||
use std::sync::atomic::{AtomicBool, AtomicI32, AtomicU32, Ordering};
|
||||
|
||||
use crate::tauri_commands::cpal_data_callback;
|
||||
|
||||
static HOST: OnceCell<Host> = OnceCell::new();
|
||||
thread_local!(static RECORDER: OnceCell<ArcSwap<Stream>> = OnceCell::new());
|
||||
static SELECTED_MICROPHONE_IDX: AtomicI32 = AtomicI32::new(0);
|
||||
static FRAME_LENGTH: AtomicU32 = AtomicU32::new(0);
|
||||
static IS_RECORDING: AtomicBool = AtomicBool::new(false);
|
||||
|
||||
pub fn init_microphone(device_index: i32, frame_length: u32) -> bool {
|
||||
// init host & frame buffer for the callback
|
||||
if HOST.get().is_none() {
|
||||
HOST.set(cpal::default_host());
|
||||
|
||||
// FRAME_BUFFER.set(Mutex::new(vec![0; FRAME_LENGTH.load(Ordering::SeqCst) as usize]));
|
||||
}
|
||||
|
||||
// init microphone
|
||||
RECORDER.with(|recorder| {
|
||||
match recorder.get().is_none() {
|
||||
true => {
|
||||
if let Some(device) = get_device(device_index as usize) {
|
||||
// store
|
||||
recorder.set(ArcSwap::from_pointee(create_stream(device, frame_length)));
|
||||
|
||||
// remember current configuration
|
||||
SELECTED_MICROPHONE_IDX.store(device_index, Ordering::SeqCst);
|
||||
FRAME_LENGTH.store(frame_length, Ordering::SeqCst);
|
||||
|
||||
// success
|
||||
true
|
||||
} else {
|
||||
false
|
||||
}
|
||||
},
|
||||
false => {
|
||||
// check if re-initialization required (i.e. selecetd microphoneor frame-length was changed )
|
||||
if SELECTED_MICROPHONE_IDX.load(Ordering::SeqCst) != device_index
|
||||
||
|
||||
FRAME_LENGTH.load(Ordering::SeqCst) != frame_length {
|
||||
warn!("Selected microphone or frame length was changed, re-initializing ...");
|
||||
// initialize again with new device index
|
||||
if IS_RECORDING.load(Ordering::SeqCst) {
|
||||
stop_recording();
|
||||
}
|
||||
|
||||
// remember new configuration
|
||||
SELECTED_MICROPHONE_IDX.store(device_index, Ordering::SeqCst);
|
||||
FRAME_LENGTH.store(frame_length, Ordering::SeqCst);
|
||||
|
||||
if let Some(device) = get_device(device_index as usize) {
|
||||
// store
|
||||
recorder.get().unwrap().store(Arc::new(create_stream(device, frame_length)));
|
||||
|
||||
// success
|
||||
return true
|
||||
} else {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// success
|
||||
true
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
fn create_stream(device: Device, frame_length: u32) -> Stream {
|
||||
// get default input stream config
|
||||
// let default_config = device.default_input_config().unwrap();
|
||||
|
||||
// create config for the stream
|
||||
// let config: StreamConfig = StreamConfig {
|
||||
// channels: default_config.channels(),
|
||||
// sample_rate: SampleRate(16000),
|
||||
// buffer_size: BufferSize::Fixed(frame_length)
|
||||
// };
|
||||
|
||||
let config = device
|
||||
.default_input_config()
|
||||
.expect("Failed to load default input config");
|
||||
|
||||
let channels = config.channels();
|
||||
|
||||
let err_fn = move |err| {
|
||||
eprintln!("an error occurred on stream: {}", err);
|
||||
};
|
||||
|
||||
match config.sample_format() {
|
||||
SampleFormat::F32 => device.build_input_stream(
|
||||
&config.into(),
|
||||
move |data: &[f32], info| {
|
||||
cpal_data_callback(data, channels);
|
||||
},
|
||||
err_fn,
|
||||
None
|
||||
),
|
||||
SampleFormat::U16 => device.build_input_stream(
|
||||
&config.into(),
|
||||
move |data: &[u16], info| {
|
||||
cpal_data_callback(data, channels);
|
||||
},
|
||||
err_fn,
|
||||
None
|
||||
),
|
||||
SampleFormat::I16 => device.build_input_stream(
|
||||
&config.into(),
|
||||
move |data: &[i16], info| {
|
||||
cpal_data_callback(data, channels);
|
||||
},
|
||||
err_fn,
|
||||
None
|
||||
),
|
||||
_ => todo!()
|
||||
}.unwrap()
|
||||
}
|
||||
|
||||
pub fn stereo_to_mono(input_data: &[i16]) -> Vec<i16> {
|
||||
let mut result = Vec::with_capacity(input_data.len() / 2);
|
||||
result.extend(
|
||||
input_data
|
||||
.chunks_exact(2)
|
||||
.map(|chunk| chunk[0] / 2 + chunk[1] / 2),
|
||||
);
|
||||
|
||||
result
|
||||
}
|
||||
|
||||
fn get_device(device_index: usize) -> Option<Device> {
|
||||
if let Some(device) = HOST.get().unwrap().input_devices().expect("Get devices error ...").nth(device_index) {
|
||||
Some(device)
|
||||
} else {
|
||||
if let Some(default) = HOST.get().unwrap().default_input_device() {
|
||||
Some(default)
|
||||
} else {
|
||||
error!("No default input device ...");
|
||||
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn start_recording(device_index: i32, frame_length: u32) {
|
||||
// ensure microphone is initialized
|
||||
init_microphone(device_index, frame_length);
|
||||
|
||||
// start recording
|
||||
RECORDER.with(|recorder| {
|
||||
match recorder.get().unwrap().load().play() {
|
||||
Err(msg) => {
|
||||
error!("[CPAL] Audio stream PLAY error ... {:?}", msg);
|
||||
},
|
||||
_ => ()
|
||||
};
|
||||
|
||||
IS_RECORDING.store(true, Ordering::SeqCst);
|
||||
info!("START recording from microphone ...");
|
||||
});
|
||||
}
|
||||
|
||||
pub fn stop_recording() {
|
||||
// ensure microphone is initialized
|
||||
RECORDER.with(|recorder| {
|
||||
if !recorder.get().is_none() && IS_RECORDING.load(Ordering::SeqCst) {
|
||||
// pause instead of stop
|
||||
match recorder.get().unwrap().load().pause() {
|
||||
Err(msg) => {
|
||||
error!("[CPAL] Audio stream PAUSE error ... {:?}", msg);
|
||||
},
|
||||
_ => ()
|
||||
};
|
||||
|
||||
IS_RECORDING.store(false, Ordering::SeqCst);
|
||||
info!("STOP recording from microphone ...");
|
||||
}
|
||||
});
|
||||
}
|
||||
205
crates/jarvis-core/src/recorder/portaudio.rs
Normal file
205
crates/jarvis-core/src/recorder/portaudio.rs
Normal file
|
|
@ -0,0 +1,205 @@
|
|||
/*
|
||||
Abandoned temporary.
|
||||
*/
|
||||
|
||||
use portaudio as pa;
|
||||
use pa::{DeviceIndex, Stream};
|
||||
use log::{info, warn, error};
|
||||
|
||||
use once_cell::sync::OnceCell;
|
||||
use std::sync::{Arc, Mutex};
|
||||
use arc_swap::ArcSwap;
|
||||
use std::sync::atomic::{AtomicBool, AtomicI32, AtomicU32, Ordering};
|
||||
|
||||
thread_local!(static RECORDER: OnceCell<ArcSwap<Mutex<Stream<pa::Blocking<pa::stream::Buffer>, pa::Input<i16>>>>> = OnceCell::new());
|
||||
static SELECTED_MICROPHONE_IDX: AtomicI32 = AtomicI32::new(0);
|
||||
static FRAME_LENGTH: AtomicU32 = AtomicU32::new(0);
|
||||
static IS_RECORDING: AtomicBool = AtomicBool::new(false);
|
||||
|
||||
const CHANNELS: i32 = 1;
|
||||
const SAMPLE_RATE: f64 = 16_000.0;
|
||||
|
||||
pub fn init_microphone(device_index: i32, frame_length: u32) -> bool {
|
||||
RECORDER.with(|r| {
|
||||
match r.get().is_none() {
|
||||
true => {
|
||||
match create_stream(device_index, frame_length) {
|
||||
Ok(stream) => {
|
||||
// store
|
||||
r.set(ArcSwap::from_pointee(Mutex::new(stream)));
|
||||
|
||||
// remember current configuration
|
||||
SELECTED_MICROPHONE_IDX.store(device_index, Ordering::SeqCst);
|
||||
FRAME_LENGTH.store(frame_length, Ordering::SeqCst);
|
||||
|
||||
// success
|
||||
true
|
||||
},
|
||||
Err(msg) => {
|
||||
error!("Failed to initialize portaudio.\nError details: {:?}", msg);
|
||||
|
||||
// fail
|
||||
false
|
||||
}
|
||||
}
|
||||
},
|
||||
_ => {
|
||||
// check if re-initialization required (i.e. selecetd microphoneor frame-length was changed )
|
||||
if SELECTED_MICROPHONE_IDX.load(Ordering::SeqCst) != device_index
|
||||
||
|
||||
FRAME_LENGTH.load(Ordering::SeqCst) != frame_length {
|
||||
warn!("Selected microphone or frame length was changed, re-initializing ...");
|
||||
// initialize again with new device index
|
||||
if IS_RECORDING.load(Ordering::SeqCst) {
|
||||
// RECORDER.get().unwrap().load().stop().expect("Failed to start audio recording!");
|
||||
stop_recording();
|
||||
}
|
||||
|
||||
// store
|
||||
match create_stream(device_index, frame_length) {
|
||||
Ok(stream) => {
|
||||
// store new stream
|
||||
r.get().unwrap().store(Arc::new(Mutex::new(stream)));
|
||||
|
||||
// remember new configuration
|
||||
SELECTED_MICROPHONE_IDX.store(device_index, Ordering::SeqCst);
|
||||
FRAME_LENGTH.store(frame_length, Ordering::SeqCst);
|
||||
|
||||
// success
|
||||
return true
|
||||
},
|
||||
Err(msg) => {
|
||||
error!("Failed to initialize portaudio.\nError details: {:?}", msg);
|
||||
|
||||
// fail
|
||||
return false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// success
|
||||
true
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
fn create_stream(device_index: i32, frame_length: u32) -> Result<Stream<pa::Blocking<pa::stream::Buffer>, pa::Input<i16>>, pa::Error> {
|
||||
let pa_recorder: Result<pa::PortAudio, pa::Error> = pa::PortAudio::new();
|
||||
|
||||
match pa_recorder {
|
||||
Ok(pa) => {
|
||||
let input_settings = match get_input_settings(DeviceIndex(device_index as u32), &pa, SAMPLE_RATE, frame_length, CHANNELS) {
|
||||
Ok(settings) => settings,
|
||||
Err(error) => panic!("{}", String::from(error))
|
||||
};
|
||||
|
||||
// Construct a stream with input and output sample types of i16
|
||||
match pa.open_blocking_stream(input_settings) {
|
||||
Ok(strm) => Ok(strm),
|
||||
Err(error) => panic!("{}", error.to_string()),
|
||||
}
|
||||
},
|
||||
Err(msg) => Err(msg)
|
||||
}
|
||||
}
|
||||
|
||||
fn get_input_latency(audio_port: &pa::PortAudio, input_index: pa::DeviceIndex) -> Result<f64, String>
|
||||
{
|
||||
let input_device_information = audio_port.device_info(input_index).or_else(|error| Err(String::from(format!("{}", error))));
|
||||
Ok(input_device_information.unwrap().default_low_input_latency)
|
||||
}
|
||||
|
||||
fn get_input_stream_parameters(input_index: pa::DeviceIndex, latency: f64, channels: i32) -> Result<pa::StreamParameters<i16>, String>
|
||||
{
|
||||
const INTERLEAVED: bool = true;
|
||||
Ok(pa::StreamParameters::<i16>::new(input_index, channels, INTERLEAVED, latency))
|
||||
}
|
||||
|
||||
fn get_input_settings(input_index: pa::DeviceIndex, audio_port: &pa::PortAudio, sample_rate: f64, frames: u32, channels: i32) -> Result<pa::InputStreamSettings<i16>, String>
|
||||
{
|
||||
Ok(
|
||||
pa::InputStreamSettings::new(
|
||||
(get_input_stream_parameters(
|
||||
input_index,
|
||||
(get_input_latency(
|
||||
&audio_port,
|
||||
input_index,
|
||||
))?,
|
||||
channels
|
||||
))?,
|
||||
sample_rate,
|
||||
frames,
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
// We'll use this function to wait for read/write availability.
|
||||
fn wait_for_stream<F>(f: F, name: &str) -> u32
|
||||
where
|
||||
F: Fn() -> Result<pa::StreamAvailable, pa::error::Error>,
|
||||
{
|
||||
loop {
|
||||
match f() {
|
||||
Ok(available) => match available {
|
||||
pa::StreamAvailable::Frames(frames) => return frames as u32,
|
||||
pa::StreamAvailable::InputOverflowed => println!("Input stream has overflowed"),
|
||||
pa::StreamAvailable::OutputUnderflowed => {
|
||||
println!("Output stream has underflowed")
|
||||
}
|
||||
},
|
||||
Err(err) => panic!(
|
||||
"An error occurred while waiting for the {} stream: {}",
|
||||
name, err
|
||||
),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn read_microphone(frame_buffer: &mut [i16]) {
|
||||
// ensure microphone is initialized
|
||||
RECORDER.with(|r| {
|
||||
if !r.get().is_none() {
|
||||
let cell = r.get().unwrap().load();
|
||||
let mut lock = cell.lock();
|
||||
let stream = lock.as_mut().unwrap();
|
||||
|
||||
// read to frame buffer
|
||||
let in_frames = wait_for_stream(|| stream.read_available(), "Read");
|
||||
|
||||
if in_frames > 0 {
|
||||
// let input_samples = stream.read(in_frames).expect("Cannot read frames ...");
|
||||
// println!("Read {:?} frames from the input stream.", in_frames);
|
||||
|
||||
let input_samples = stream.read(in_frames).expect("Cannot read frames ...");
|
||||
println!("Read: {} (required {})", input_samples.len(), frame_buffer.len());
|
||||
frame_buffer.copy_from_slice(input_samples.chunks(frame_buffer.len()).last().unwrap());
|
||||
}
|
||||
// r.get().unwrap().load().read(frame_buffer).expect("Failed to read audio frame");
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
pub fn start_recording(device_index: i32, frame_length: u32) {
|
||||
// ensure microphone is initialized
|
||||
init_microphone(device_index, frame_length);
|
||||
|
||||
// start recording
|
||||
RECORDER.with(|r| {
|
||||
r.get().unwrap().load().lock().unwrap().start().expect("Failed to start audio recording!");
|
||||
IS_RECORDING.store(true, Ordering::SeqCst);
|
||||
info!("START recording from microphone ...");
|
||||
});
|
||||
}
|
||||
|
||||
pub fn stop_recording() {
|
||||
RECORDER.with(|r| {
|
||||
if !r.get().is_none() && IS_RECORDING.load(Ordering::SeqCst) {
|
||||
// stop recording
|
||||
let pa = r.get().unwrap().load();
|
||||
r.get().unwrap().load().lock().unwrap().stop().expect("Failed to stop audio recording!");
|
||||
IS_RECORDING.store(false, Ordering::SeqCst);
|
||||
info!("STOP recording from microphone ...");
|
||||
}
|
||||
});
|
||||
}
|
||||
129
crates/jarvis-core/src/recorder/pvrecorder.rs
Normal file
129
crates/jarvis-core/src/recorder/pvrecorder.rs
Normal file
|
|
@ -0,0 +1,129 @@
|
|||
use once_cell::sync::OnceCell;
|
||||
use pv_recorder::{PvRecorder, PvRecorderBuilder};
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
|
||||
static RECORDER: OnceCell<PvRecorder> = OnceCell::new();
|
||||
static IS_RECORDING: AtomicBool = AtomicBool::new(false);
|
||||
|
||||
pub fn init_microphone(device_index: i32, frame_length: u32) -> bool {
|
||||
match RECORDER.get().is_none() {
|
||||
true => {
|
||||
let pv_recorder = PvRecorderBuilder::new(frame_length as i32)
|
||||
.device_index(device_index)
|
||||
// .frame_length(frame_length as i32)
|
||||
.init();
|
||||
|
||||
match pv_recorder {
|
||||
Ok(pv) => {
|
||||
// store
|
||||
RECORDER.set(pv);
|
||||
|
||||
// success
|
||||
true
|
||||
}
|
||||
Err(msg) => {
|
||||
error!("Failed to initialize pvrecorder.\nError details: {:?}", msg);
|
||||
|
||||
// fail
|
||||
false
|
||||
}
|
||||
}
|
||||
}
|
||||
_ => true, // already initialized
|
||||
}
|
||||
}
|
||||
|
||||
pub fn read_microphone(frame_buffer: &mut [i16]) {
|
||||
// ensure microphone is initialized
|
||||
if !RECORDER.get().is_none() {
|
||||
// read to frame buffer
|
||||
|
||||
let frame = RECORDER.get().unwrap().read();
|
||||
|
||||
match frame {
|
||||
Ok(f) => {
|
||||
frame_buffer.copy_from_slice(f.as_slice());
|
||||
}
|
||||
Err(msg) => {
|
||||
// @TODO: Fix? PvRecorder always wait for PCM buffer size of 512.
|
||||
error!("Failed to read audio frame. {:?}", msg);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn start_recording(device_index: i32, frame_length: u32) -> Result<(), ()> {
|
||||
// ensure microphone is initialized
|
||||
init_microphone(device_index, frame_length);
|
||||
|
||||
// start recording
|
||||
match RECORDER.get().unwrap().start() {
|
||||
Ok(_) => {
|
||||
info!("START recording from microphone ...");
|
||||
|
||||
// change recording state
|
||||
IS_RECORDING.store(true, Ordering::SeqCst);
|
||||
|
||||
// success
|
||||
Ok(())
|
||||
}
|
||||
Err(msg) => {
|
||||
error!("Failed to start audio recording!");
|
||||
|
||||
// fail
|
||||
Err(())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn stop_recording() -> Result<(), ()> {
|
||||
// ensure microphone is initialized & recording is in process
|
||||
if !RECORDER.get().is_none() && IS_RECORDING.load(Ordering::SeqCst) {
|
||||
// stop recording
|
||||
match RECORDER.get().unwrap().stop() {
|
||||
Ok(_) => {
|
||||
info!("STOP recording from microphone ...");
|
||||
|
||||
// change recording state
|
||||
IS_RECORDING.store(false, Ordering::SeqCst);
|
||||
|
||||
// success
|
||||
return Ok(());
|
||||
}
|
||||
Err(msg) => {
|
||||
error!("Failed to stop audio recording!");
|
||||
|
||||
// fail
|
||||
return Err(());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(()) // if already stopped or not yet initialized
|
||||
}
|
||||
|
||||
pub fn list_audio_devices() -> Vec<String> {
|
||||
let audio_devices = PvRecorderBuilder::default().get_available_devices();
|
||||
match audio_devices {
|
||||
Ok(audio_devices) => audio_devices,
|
||||
Err(err) => panic!("Failed to get audio devices: {}", err),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn get_audio_device_name(idx: i32) -> String {
|
||||
let audio_devices = list_audio_devices();
|
||||
let mut first_device: String = String::new();
|
||||
|
||||
for (_idx, device) in audio_devices.iter().enumerate() {
|
||||
if idx as usize == _idx {
|
||||
return device.to_string();
|
||||
}
|
||||
|
||||
if _idx == 0 {
|
||||
first_device = device.to_string()
|
||||
}
|
||||
}
|
||||
|
||||
// return first device as default, if none were matched
|
||||
first_device
|
||||
}
|
||||
36
crates/jarvis-core/src/stt.rs
Normal file
36
crates/jarvis-core/src/stt.rs
Normal file
|
|
@ -0,0 +1,36 @@
|
|||
mod vosk;
|
||||
|
||||
use crate::config;
|
||||
use once_cell::sync::OnceCell;
|
||||
|
||||
use crate::config::structs::SpeechToTextEngine;
|
||||
|
||||
static STT_TYPE: OnceCell<SpeechToTextEngine> = OnceCell::new();
|
||||
|
||||
pub fn init() -> Result<(), ()> {
|
||||
if !STT_TYPE.get().is_none() {
|
||||
return Ok(());
|
||||
} // already initialized
|
||||
|
||||
// set default stt type
|
||||
// @TODO. Make it configurable?
|
||||
STT_TYPE.set(config::DEFAULT_SPEECH_TO_TEXT_ENGINE).unwrap();
|
||||
|
||||
// load given recorder
|
||||
match STT_TYPE.get().unwrap() {
|
||||
SpeechToTextEngine::Vosk => {
|
||||
// Init Vosk
|
||||
info!("Initializing Vosk STT backend.");
|
||||
vosk::init_vosk();
|
||||
info!("STT backend initialized.");
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn recognize(data: &[i16], partial: bool) -> Option<String> {
|
||||
match STT_TYPE.get().unwrap() {
|
||||
SpeechToTextEngine::Vosk => vosk::recognize(data, partial),
|
||||
}
|
||||
}
|
||||
92
crates/jarvis-core/src/stt/vosk.rs
Normal file
92
crates/jarvis-core/src/stt/vosk.rs
Normal file
|
|
@ -0,0 +1,92 @@
|
|||
use once_cell::sync::OnceCell;
|
||||
use vosk::{DecodingState, Model, Recognizer};
|
||||
|
||||
use std::sync::Mutex;
|
||||
|
||||
use crate::config::VOSK_MODEL_PATH;
|
||||
|
||||
static MODEL: OnceCell<Model> = OnceCell::new();
|
||||
static RECOGNIZER: OnceCell<Mutex<Recognizer>> = OnceCell::new();
|
||||
|
||||
pub fn init_vosk() {
|
||||
if !RECOGNIZER.get().is_none() {
|
||||
return;
|
||||
} // already initialized
|
||||
|
||||
let model = Model::new(VOSK_MODEL_PATH).unwrap();
|
||||
let mut recognizer = Recognizer::new(&model, 16000.0).unwrap();
|
||||
|
||||
recognizer.set_max_alternatives(10);
|
||||
recognizer.set_words(true);
|
||||
recognizer.set_partial_words(true);
|
||||
|
||||
MODEL.set(model);
|
||||
RECOGNIZER.set(Mutex::new(recognizer));
|
||||
}
|
||||
|
||||
pub fn recognize(data: &[i16], include_partial: bool) -> Option<String> {
|
||||
let state = RECOGNIZER
|
||||
.get()
|
||||
.unwrap()
|
||||
.lock()
|
||||
.unwrap()
|
||||
.accept_waveform(data);
|
||||
|
||||
match state {
|
||||
Ok(ds) => {
|
||||
match ds {
|
||||
DecodingState::Running => {
|
||||
if include_partial {
|
||||
Some(
|
||||
RECOGNIZER
|
||||
.get()
|
||||
.unwrap()
|
||||
.lock()
|
||||
.unwrap()
|
||||
.partial_result()
|
||||
.partial
|
||||
.into(),
|
||||
)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
DecodingState::Finalized => {
|
||||
// Result will always be multiple because we called set_max_alternatives
|
||||
Some(
|
||||
RECOGNIZER
|
||||
.get()
|
||||
.unwrap()
|
||||
.lock()
|
||||
.unwrap()
|
||||
.result()
|
||||
.multiple()
|
||||
.unwrap()
|
||||
.alternatives
|
||||
.first()
|
||||
.unwrap()
|
||||
.text
|
||||
.into(),
|
||||
)
|
||||
}
|
||||
DecodingState::Failed => None,
|
||||
}
|
||||
},
|
||||
Err(err) => {
|
||||
error!("Vosk accept waveform error.\nError details: {}", err);
|
||||
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// pub fn stereo_to_mono(input_data: &[i16]) -> Vec<i16> {
|
||||
// let mut result = Vec::with_capacity(input_data.len() / 2);
|
||||
// result.extend(
|
||||
// input_data
|
||||
// .chunks_exact(2)
|
||||
// .map(|chunk| chunk[0] / 2 + chunk[1] / 2),
|
||||
// );
|
||||
|
||||
// result
|
||||
// }
|
||||
28
crates/jarvis-gui/Cargo.toml
Normal file
28
crates/jarvis-gui/Cargo.toml
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
[package]
|
||||
name = "jarvis-gui"
|
||||
version.workspace = true
|
||||
authors.workspace = true
|
||||
license.workspace = true
|
||||
repository.workspace = true
|
||||
edition.workspace = true
|
||||
|
||||
[dependencies]
|
||||
jarvis-core = { path = "../jarvis-core", default-features = true }
|
||||
tauri = { version = "2", features = [] } # v1: "shell-open", "dialog-message", "path-all"
|
||||
tauri-plugin-shell = "2"
|
||||
tauri-plugin-dialog = "2"
|
||||
tauri-plugin-fs = "2"
|
||||
|
||||
once_cell.workspace = true
|
||||
log.workspace = true
|
||||
simple-log = "2.4"
|
||||
serde.workspace = true
|
||||
serde_json.workspace = true
|
||||
platform-dirs.workspace = true
|
||||
|
||||
[build-dependencies]
|
||||
tauri-build = { version = "2", features = [] }
|
||||
|
||||
[features]
|
||||
default = ["custom-protocol"]
|
||||
custom-protocol = ["tauri/custom-protocol"]
|
||||
45
crates/jarvis-gui/Makefile.toml
Normal file
45
crates/jarvis-gui/Makefile.toml
Normal file
|
|
@ -0,0 +1,45 @@
|
|||
[tasks.format]
|
||||
install_crate = "rustfmt"
|
||||
command = "cargo"
|
||||
args = ["fmt", "--", "--emit=files"]
|
||||
|
||||
[tasks.clean]
|
||||
command = "cargo"
|
||||
args = ["clean"]
|
||||
|
||||
[tasks.build_debug]
|
||||
command = "cargo"
|
||||
args = ["build"]
|
||||
|
||||
[tasks.run]
|
||||
command = "cargo"
|
||||
args = ["run"]
|
||||
|
||||
[tasks.build_release]
|
||||
command = "cargo"
|
||||
args = ["build", "--release"]
|
||||
dependencies = ["clean"]
|
||||
|
||||
[tasks.test]
|
||||
command = "cargo"
|
||||
args = ["test"]
|
||||
# dependencies = ["clean"]
|
||||
|
||||
[tasks.post_build]
|
||||
script_runner = "python"
|
||||
script_extension = "py"
|
||||
script = { file = "post_build.py" }
|
||||
|
||||
[tasks.debug]
|
||||
dependencies = [
|
||||
"format",
|
||||
"build_debug",
|
||||
"post_build"
|
||||
]
|
||||
|
||||
[tasks.release]
|
||||
dependencies = [
|
||||
"format",
|
||||
"build_release",
|
||||
"post_build"
|
||||
]
|
||||
4
crates/jarvis-gui/build.rs
Normal file
4
crates/jarvis-gui/build.rs
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
fn main() {
|
||||
// Tauri build
|
||||
tauri_build::build()
|
||||
}
|
||||
20
crates/jarvis-gui/capabilities/default.json
Normal file
20
crates/jarvis-gui/capabilities/default.json
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
{
|
||||
"$schema": "https://schema.tauri.app/config/2",
|
||||
"identifier": "default",
|
||||
"description": "Default capabilities for Jarvis",
|
||||
"windows": ["main"],
|
||||
"permissions": [
|
||||
"core:default",
|
||||
"shell:allow-open",
|
||||
"dialog:allow-message",
|
||||
"fs:default",
|
||||
"fs:allow-read",
|
||||
"fs:allow-write",
|
||||
{
|
||||
"identifier": "fs:scope",
|
||||
"allow": [
|
||||
"$RESOURCE/**"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
1
crates/jarvis-gui/gen/schemas/acl-manifests.json
Normal file
1
crates/jarvis-gui/gen/schemas/acl-manifests.json
Normal file
File diff suppressed because one or more lines are too long
1
crates/jarvis-gui/gen/schemas/capabilities.json
Normal file
1
crates/jarvis-gui/gen/schemas/capabilities.json
Normal file
|
|
@ -0,0 +1 @@
|
|||
{"default":{"identifier":"default","description":"Default capabilities for Jarvis","local":true,"windows":["main"],"permissions":["core:default","shell:allow-open","dialog:allow-message","fs:default","fs:allow-read","fs:allow-write",{"identifier":"fs:scope","allow":["$RESOURCE/**"]}]}}
|
||||
6158
crates/jarvis-gui/gen/schemas/desktop-schema.json
Normal file
6158
crates/jarvis-gui/gen/schemas/desktop-schema.json
Normal file
File diff suppressed because it is too large
Load diff
6158
crates/jarvis-gui/gen/schemas/windows-schema.json
Normal file
6158
crates/jarvis-gui/gen/schemas/windows-schema.json
Normal file
File diff suppressed because it is too large
Load diff
41
crates/jarvis-gui/src/events.rs
Normal file
41
crates/jarvis-gui/src/events.rs
Normal file
|
|
@ -0,0 +1,41 @@
|
|||
use tauri::Emitter;
|
||||
|
||||
// the payload type must implement `Serialize` and `Clone`.
|
||||
#[derive(Clone, serde::Serialize)]
|
||||
pub struct Payload {
|
||||
pub data: String,
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
pub enum EventTypes {
|
||||
AudioPlay,
|
||||
AssistantWaiting,
|
||||
AssistantGreet,
|
||||
CommandStart,
|
||||
CommandInProcess,
|
||||
CommandEnd,
|
||||
}
|
||||
|
||||
impl EventTypes {
|
||||
pub fn get(&self) -> &str {
|
||||
match self {
|
||||
Self::AudioPlay => "audio-play",
|
||||
Self::AssistantWaiting => "assistant-waiting",
|
||||
Self::AssistantGreet => "assistant-greet",
|
||||
Self::CommandStart => "command-start",
|
||||
Self::CommandInProcess => "command-in-process",
|
||||
Self::CommandEnd => "command-end",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn play(phrase: &str, app_handle: &tauri::AppHandle) {
|
||||
app_handle
|
||||
.emit(
|
||||
EventTypes::AudioPlay.get(),
|
||||
Payload {
|
||||
data: phrase.into(),
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
30
crates/jarvis-gui/src/main.rs
Normal file
30
crates/jarvis-gui/src/main.rs
Normal file
|
|
@ -0,0 +1,30 @@
|
|||
// Prevents additional console window on Windows in release, DO NOT REMOVE!!
|
||||
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
|
||||
|
||||
use jarvis_core::{config, db, APP_CONFIG_DIR, APP_LOG_DIR, DB};
|
||||
|
||||
#[macro_use]
|
||||
extern crate simple_log;
|
||||
|
||||
mod events;
|
||||
|
||||
// mod tauri_commands;
|
||||
|
||||
fn main() {
|
||||
config::init_dirs().expect("Failed to init dirs");
|
||||
|
||||
// basic logging setup (simpler for GUI)
|
||||
simple_log::quick!("info");
|
||||
|
||||
let _ = DB.set(db::init_settings());
|
||||
|
||||
tauri::Builder::default()
|
||||
.plugin(tauri_plugin_shell::init())
|
||||
.plugin(tauri_plugin_dialog::init())
|
||||
.plugin(tauri_plugin_fs::init())
|
||||
.invoke_handler(tauri::generate_handler![
|
||||
// commands will be added here after tauri_commands module is fixed
|
||||
])
|
||||
.run(tauri::generate_context!())
|
||||
.expect("error while running tauri application");
|
||||
}
|
||||
27
crates/jarvis-gui/src/tauri_commands.rs
Normal file
27
crates/jarvis-gui/src/tauri_commands.rs
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
// import DB related commands
|
||||
mod db;
|
||||
pub use db::*;
|
||||
|
||||
// import RECORDER commands
|
||||
mod audio;
|
||||
pub use audio::*;
|
||||
|
||||
// import PORCUPINE commands
|
||||
mod listener;
|
||||
pub use listener::*;
|
||||
|
||||
// import SYS commands
|
||||
mod sys;
|
||||
pub use sys::*;
|
||||
|
||||
// import VOICE commands
|
||||
mod voice;
|
||||
pub use voice::*;
|
||||
|
||||
// import FS commands
|
||||
mod fs;
|
||||
pub use fs::*;
|
||||
|
||||
// import ETC commands
|
||||
mod etc;
|
||||
pub use etc::*;
|
||||
33
crates/jarvis-gui/src/tauri_commands/audio.rs
Normal file
33
crates/jarvis-gui/src/tauri_commands/audio.rs
Normal file
|
|
@ -0,0 +1,33 @@
|
|||
use pv_recorder::RecorderBuilder;
|
||||
|
||||
#[tauri::command]
|
||||
pub fn pv_get_audio_devices() -> Vec<String> {
|
||||
let audio_devices = RecorderBuilder::default().get_audio_devices();
|
||||
match audio_devices {
|
||||
Ok(audio_devices) => audio_devices,
|
||||
Err(err) => panic!("Failed to get audio devices: {}", err),
|
||||
}
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn pv_get_audio_device_name(idx: i32) -> String {
|
||||
let audio_devices = RecorderBuilder::default().get_audio_devices();
|
||||
let mut first_device: String = String::new();
|
||||
match audio_devices {
|
||||
Ok(audio_devices) => {
|
||||
for (_idx, device) in audio_devices.iter().enumerate() {
|
||||
if idx as usize == _idx {
|
||||
return device.to_string();
|
||||
}
|
||||
|
||||
if _idx == 0 {
|
||||
first_device = device.to_string()
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(err) => panic!("Failed to get audio devices: {}", err),
|
||||
};
|
||||
|
||||
// return first device as default, if none were matched
|
||||
first_device
|
||||
}
|
||||
19
crates/jarvis-gui/src/tauri_commands/db.rs
Normal file
19
crates/jarvis-gui/src/tauri_commands/db.rs
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
use crate::DB;
|
||||
|
||||
#[tauri::command]
|
||||
pub fn db_read(key: &str) -> String {
|
||||
if let Some(value) = DB.lock().unwrap().get::<String>(key) {
|
||||
return value
|
||||
}
|
||||
|
||||
String::from("")
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn db_write(key: &str, val: &str) -> bool {
|
||||
if let Ok(_) = DB.lock().unwrap().set(key, &val) {
|
||||
true
|
||||
} else {
|
||||
false
|
||||
}
|
||||
}
|
||||
54
crates/jarvis-gui/src/tauri_commands/etc.rs
Normal file
54
crates/jarvis-gui/src/tauri_commands/etc.rs
Normal file
|
|
@ -0,0 +1,54 @@
|
|||
use crate::config;
|
||||
use crate::APP_LOG_DIR;
|
||||
|
||||
// Learn more about Tauri commands at https://tauri.app/v1/guides/features/command
|
||||
|
||||
#[tauri::command]
|
||||
pub fn get_app_version() -> String {
|
||||
if let Some(res) = config::APP_VERSION {
|
||||
res.to_string()
|
||||
} else {
|
||||
String::from("error")
|
||||
}
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn get_author_name() -> String {
|
||||
if let Some(res) = config::AUTHOR_NAME {
|
||||
res.to_string()
|
||||
} else {
|
||||
String::from("error")
|
||||
}
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn get_repository_link() -> String {
|
||||
if let Some(res) = config::REPOSITORY_LINK {
|
||||
res.to_string()
|
||||
} else {
|
||||
String::from("error")
|
||||
}
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn get_tg_official_link() -> String {
|
||||
if let Some(ver) = config::TG_OFFICIAL_LINK {
|
||||
ver.to_string()
|
||||
} else {
|
||||
String::from("error")
|
||||
}
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn get_feedback_link() -> String {
|
||||
if let Some(res) = config::FEEDBACK_LINK {
|
||||
res.to_string()
|
||||
} else {
|
||||
String::from("error")
|
||||
}
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn get_log_file_path() -> String {
|
||||
format!("{}", APP_LOG_DIR.lock().unwrap())
|
||||
}
|
||||
47
crates/jarvis-gui/src/tauri_commands/fs.rs
Normal file
47
crates/jarvis-gui/src/tauri_commands/fs.rs
Normal file
|
|
@ -0,0 +1,47 @@
|
|||
use std::process::Command;
|
||||
|
||||
// taken from https://github.com/tauri-apps/tauri/issues/4062#issuecomment-1338048169
|
||||
#[tauri::command]
|
||||
pub fn show_in_folder(path: String) {
|
||||
#[cfg(target_os = "windows")]
|
||||
{
|
||||
Command::new("explorer")
|
||||
.args(["/select,", &path]) // The comma after select is not a typo
|
||||
.spawn()
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
{
|
||||
if path.contains(",") {
|
||||
// see https://gitlab.freedesktop.org/dbus/dbus/-/issues/76
|
||||
let new_path = match metadata(&path).unwrap().is_dir() {
|
||||
true => path,
|
||||
false => {
|
||||
let mut path2 = PathBuf::from(path);
|
||||
path2.pop();
|
||||
path2.into_os_string().into_string().unwrap()
|
||||
}
|
||||
};
|
||||
Command::new("xdg-open")
|
||||
.arg(&new_path)
|
||||
.spawn()
|
||||
.unwrap();
|
||||
} else {
|
||||
Command::new("dbus-send")
|
||||
.args(["--session", "--dest=org.freedesktop.FileManager1", "--type=method_call",
|
||||
"/org/freedesktop/FileManager1", "org.freedesktop.FileManager1.ShowItems",
|
||||
format!("array:string:\"file://{path}\"").as_str(), "string:\"\""])
|
||||
.spawn()
|
||||
.unwrap();
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
{
|
||||
Command::new("open")
|
||||
.args(["-R", &path])
|
||||
.spawn()
|
||||
.unwrap();
|
||||
}
|
||||
}
|
||||
416
crates/jarvis-gui/src/tauri_commands/listener.rs
Normal file
416
crates/jarvis-gui/src/tauri_commands/listener.rs
Normal file
|
|
@ -0,0 +1,416 @@
|
|||
use porcupine::{Porcupine, PorcupineBuilder};
|
||||
use std::ops::Sub;
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use std::path::Path;
|
||||
use log::{info, warn, error};
|
||||
use rustpotter::{Rustpotter, RustpotterConfig, WavFmt, DetectorConfig, FiltersConfig, ScoreMode, GainNormalizationConfig, BandPassConfig};
|
||||
// use dasp::{sample::ToSample, Sample};
|
||||
|
||||
// use crate::events::Payload;
|
||||
use tauri::Manager;
|
||||
|
||||
use rand::seq::SliceRandom;
|
||||
use std::time::SystemTime;
|
||||
use once_cell::sync::OnceCell;
|
||||
use std::sync::Mutex;
|
||||
|
||||
use crate::assistant_commands;
|
||||
use crate::events;
|
||||
|
||||
use crate::config;
|
||||
use crate::vosk;
|
||||
use crate::recorder::{self, FRAME_LENGTH};
|
||||
|
||||
use crate::COMMANDS;
|
||||
use crate::DB;
|
||||
|
||||
// track listening state
|
||||
static LISTENING: AtomicBool = AtomicBool::new(false);
|
||||
|
||||
// stop listening with Atomic flag (to make it work between different threads)
|
||||
static STOP_LISTENING: AtomicBool = AtomicBool::new(false);
|
||||
|
||||
// store tauri app_handle
|
||||
static TAURI_APP_HANDLE: OnceCell<tauri::AppHandle> = OnceCell::new();
|
||||
|
||||
// store porcupine instance
|
||||
static PORCUPINE: OnceCell<Porcupine> = OnceCell::new();
|
||||
|
||||
// store rustpotter instance
|
||||
static RUSTPOTTER: OnceCell<Mutex<Rustpotter>> = OnceCell::new();
|
||||
|
||||
#[tauri::command]
|
||||
pub fn is_listening() -> bool {
|
||||
LISTENING.load(Ordering::SeqCst)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn stop_listening() {
|
||||
if is_listening() {
|
||||
STOP_LISTENING.store(true, Ordering::SeqCst);
|
||||
stop_recording();
|
||||
}
|
||||
|
||||
// wait until listening stops
|
||||
while is_listening() {}
|
||||
}
|
||||
|
||||
fn get_wake_word_engine() -> config::WakeWordEngine {
|
||||
let selected_wake_word_engine;
|
||||
if let Some(wwengine) = DB.lock().unwrap().get::<String>("selected_wake_word_engine") {
|
||||
// from db
|
||||
match wwengine.trim().to_lowercase().as_str() {
|
||||
"rustpotter" => selected_wake_word_engine = config::WakeWordEngine::Rustpotter,
|
||||
"vosk" => selected_wake_word_engine = config::WakeWordEngine::Vosk,
|
||||
"picovoice" => selected_wake_word_engine = config::WakeWordEngine::Porcupine,
|
||||
_ => selected_wake_word_engine = config::DEFAULT_WAKE_WORD_ENGINE
|
||||
}
|
||||
} else {
|
||||
// default
|
||||
selected_wake_word_engine = config::DEFAULT_WAKE_WORD_ENGINE; // set default wake_word engine
|
||||
}
|
||||
|
||||
selected_wake_word_engine
|
||||
}
|
||||
|
||||
#[tauri::command(async)]
|
||||
pub fn start_listening(app_handle: tauri::AppHandle) -> Result<bool, String> {
|
||||
// only one listener thread is allowed
|
||||
if is_listening() {
|
||||
return Err("Already listening.".into());
|
||||
}
|
||||
|
||||
// keep app handle
|
||||
if TAURI_APP_HANDLE.get().is_none() {
|
||||
TAURI_APP_HANDLE.set(app_handle);
|
||||
}
|
||||
|
||||
// call selected wake-word engine listener command
|
||||
match get_wake_word_engine() {
|
||||
config::WakeWordEngine::Rustpotter => {
|
||||
info!("Starting RUSTPOTTER wake-word engine ...");
|
||||
return rustpotter_init();
|
||||
},
|
||||
config::WakeWordEngine::Vosk => {
|
||||
info!("Starting VOSK wake-word engine ...");
|
||||
return vosk_init();
|
||||
},
|
||||
config::WakeWordEngine::Porcupine => {
|
||||
info!("Starting PICOVOICE PORCUPINE wake-word engine ...");
|
||||
return picovoice_init();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn keyword_callback(_keyword_index: i32) {
|
||||
// vars
|
||||
let mut start: SystemTime = SystemTime::now();
|
||||
let mut frame_buffer = vec![0; recorder::FRAME_LENGTH.load(Ordering::SeqCst) as usize];
|
||||
|
||||
// play greet phrase
|
||||
events::play(
|
||||
config::ASSISTANT_GREET_PHRASES
|
||||
.choose(&mut rand::thread_rng())
|
||||
.unwrap(),
|
||||
TAURI_APP_HANDLE.get().unwrap(),
|
||||
);
|
||||
|
||||
// emit assistant greet event
|
||||
TAURI_APP_HANDLE.get().unwrap()
|
||||
.emit_all(events::EventTypes::AssistantGreet.get(), ())
|
||||
.unwrap();
|
||||
|
||||
// the loop
|
||||
while !STOP_LISTENING.load(Ordering::SeqCst) {
|
||||
recorder::read_microphone(&mut frame_buffer);
|
||||
|
||||
// vosk part (partials included)
|
||||
if let Some(mut test) = vosk::recognize(&frame_buffer, false) {
|
||||
if !test.is_empty() {
|
||||
println!("Recognized: {}", test);
|
||||
|
||||
// some filtration
|
||||
test = test.to_lowercase();
|
||||
for tbr in config::ASSISTANT_PHRASES_TBR {
|
||||
test = test.replace(tbr, "");
|
||||
}
|
||||
test = test.trim().into();
|
||||
|
||||
// infer command
|
||||
if let Some((cmd_path, cmd_config)) =
|
||||
assistant_commands::fetch_command(&test, &COMMANDS)
|
||||
{
|
||||
println!("Recognized (filtered): {}", test);
|
||||
println!("Command found: {:?}", cmd_path);
|
||||
println!("Executing ...");
|
||||
|
||||
let cmd_result = assistant_commands::execute_command(
|
||||
&cmd_path,
|
||||
&cmd_config,
|
||||
TAURI_APP_HANDLE.get().unwrap(),
|
||||
);
|
||||
|
||||
match cmd_result {
|
||||
Ok(chain) => {
|
||||
println!("Command executed successfully!");
|
||||
|
||||
if chain {
|
||||
// continue chaining commands
|
||||
start = SystemTime::now(); // listen for more commands
|
||||
} else {
|
||||
// skip forward if chaining is not required
|
||||
start = start.checked_sub(core::time::Duration::from_secs(1000)).unwrap();
|
||||
}
|
||||
|
||||
continue;
|
||||
}
|
||||
Err(error_message) => {
|
||||
println!("Error executing command: {}", error_message);
|
||||
}
|
||||
}
|
||||
|
||||
TAURI_APP_HANDLE.get().unwrap()
|
||||
.emit_all(events::EventTypes::AssistantWaiting.get(), ())
|
||||
.unwrap();
|
||||
break; // return to picovoice after command execution (no matter successfull or not)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
match start.elapsed() {
|
||||
Ok(elapsed) if elapsed > config::CMS_WAIT_DELAY => {
|
||||
// return to picovoice after N seconds
|
||||
TAURI_APP_HANDLE.get().unwrap()
|
||||
.emit_all(events::EventTypes::AssistantWaiting.get(), ())
|
||||
.unwrap();
|
||||
break;
|
||||
}
|
||||
_ => (),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn data_callback(frame_buffer: &[i16]) {
|
||||
// println!("DATA CALLBACK {}", frame_buffer.len());
|
||||
match get_wake_word_engine() {
|
||||
config::WakeWordEngine::Rustpotter => {
|
||||
let mut lock = RUSTPOTTER.get().unwrap().lock();
|
||||
let rustpotter = lock.as_mut().unwrap();
|
||||
let detection = rustpotter.process_i16(&frame_buffer);
|
||||
|
||||
if let Some(detection) = detection {
|
||||
if detection.score > config::RUSPOTTER_MIN_SCORE {
|
||||
info!("Rustpotter detection info:\n{:?}", detection);
|
||||
keyword_callback(0);
|
||||
} else {
|
||||
info!("Rustpotter detection info:\n{:?}", detection);
|
||||
}
|
||||
}
|
||||
},
|
||||
config::WakeWordEngine::Vosk => {
|
||||
// recognize & convert to sequence
|
||||
let recognized_phrase = vosk::recognize(&frame_buffer, true).unwrap_or("".into());
|
||||
|
||||
if !recognized_phrase.trim().is_empty() {
|
||||
info!("Rec: {}", recognized_phrase);
|
||||
let recognized_phrases = recognized_phrase.split_whitespace();
|
||||
for phrase in recognized_phrases {
|
||||
let recognized_phrase_chars = phrase.trim().to_lowercase().chars().collect::<Vec<_>>();
|
||||
|
||||
// compare
|
||||
let compare_ratio = seqdiff::ratio(&config::VOSK_FETCH_PHRASE.chars().collect::<Vec<_>>(), &recognized_phrase_chars);
|
||||
info!("OG phrase: {:?}", &config::VOSK_FETCH_PHRASE);
|
||||
info!("Recognized phrase: {:?}", &recognized_phrase_chars);
|
||||
info!("Compare ratio: {}", compare_ratio);
|
||||
|
||||
if compare_ratio >= config::VOSK_MIN_RATIO {
|
||||
info!("Phrase activated.");
|
||||
keyword_callback(0);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
config::WakeWordEngine::Porcupine => {
|
||||
if let Ok(keyword_index) = PORCUPINE.get().unwrap().process(&frame_buffer) {
|
||||
if keyword_index >= 0 {
|
||||
// println!("Yes, sir! {}", keyword_index);
|
||||
keyword_callback(keyword_index);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn start_recording() -> Result<bool, String> {
|
||||
// vars
|
||||
let frame_length: usize;
|
||||
|
||||
// idenfity frame length
|
||||
match get_wake_word_engine() {
|
||||
config::WakeWordEngine::Rustpotter => {
|
||||
// start recording for Rustpotter
|
||||
// You need a buffer of size `rustpotter.get_samples_per_frame()` when using samples.
|
||||
// You need a buffer of size `rustpotter.get_bytes_per_frame()` when using bytes.
|
||||
frame_length = RUSTPOTTER.get().unwrap().lock().unwrap().get_samples_per_frame();
|
||||
recorder::FRAME_LENGTH.store(frame_length as u32, Ordering::SeqCst);
|
||||
},
|
||||
config::WakeWordEngine::Vosk => {
|
||||
// start recording for Vosk
|
||||
frame_length = 128;
|
||||
recorder::FRAME_LENGTH.store(frame_length as u32, Ordering::SeqCst);
|
||||
},
|
||||
config::WakeWordEngine::Porcupine => {
|
||||
// start recording for Porcupine
|
||||
frame_length = PORCUPINE.get().unwrap().frame_length() as usize;
|
||||
recorder::FRAME_LENGTH.store(PORCUPINE.get().unwrap().frame_length(), Ordering::SeqCst);
|
||||
}
|
||||
}
|
||||
|
||||
// define frame buffer
|
||||
let mut frame_buffer: Vec<i16> = vec![0; frame_length];
|
||||
|
||||
// init stuff
|
||||
recorder::init(); // init
|
||||
recorder::start_recording(); // start
|
||||
LISTENING.store(true, Ordering::SeqCst);
|
||||
info!("START listening ...");
|
||||
|
||||
// greet user
|
||||
events::play("run", TAURI_APP_HANDLE.get().unwrap());
|
||||
|
||||
// record
|
||||
match recorder::RECORDER_TYPE.load(Ordering::SeqCst) {
|
||||
recorder::RecorderType::PvRecorder => {
|
||||
while !STOP_LISTENING.load(Ordering::SeqCst) {
|
||||
recorder::read_microphone(&mut frame_buffer);
|
||||
data_callback(&frame_buffer);
|
||||
}
|
||||
|
||||
// stop
|
||||
stop_recording();
|
||||
|
||||
Ok(true)
|
||||
},
|
||||
recorder::RecorderType::PortAudio => {
|
||||
while !STOP_LISTENING.load(Ordering::SeqCst) {
|
||||
recorder::read_microphone(&mut frame_buffer);
|
||||
data_callback(&frame_buffer);
|
||||
}
|
||||
|
||||
// stop
|
||||
stop_recording();
|
||||
|
||||
Ok(true)
|
||||
}
|
||||
recorder::RecorderType::Cpal => {
|
||||
todo!()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn stop_recording() {
|
||||
// Stop listening
|
||||
recorder::stop_recording();
|
||||
|
||||
LISTENING.store(false, Ordering::SeqCst);
|
||||
STOP_LISTENING.store(false, Ordering::SeqCst);
|
||||
info!("STOP listening ...");
|
||||
}
|
||||
|
||||
fn rustpotter_init() -> Result<bool, String> {
|
||||
|
||||
// init rustpotter
|
||||
let rustpotter_config = RustpotterConfig {
|
||||
fmt: WavFmt::default(),
|
||||
detector: DetectorConfig {
|
||||
avg_threshold: 0.,
|
||||
threshold: 0.5,
|
||||
min_scores: 15,
|
||||
score_mode: ScoreMode::Average,
|
||||
comparator_band_size: 5,
|
||||
comparator_ref: 0.22
|
||||
},
|
||||
filters: FiltersConfig {
|
||||
gain_normalizer: GainNormalizationConfig {
|
||||
enabled: true,
|
||||
gain_ref: None,
|
||||
min_gain: 0.7,
|
||||
max_gain: 1.0,
|
||||
},
|
||||
band_pass: BandPassConfig {
|
||||
enabled: true,
|
||||
low_cutoff: 80.,
|
||||
high_cutoff: 400.,
|
||||
}
|
||||
}
|
||||
};
|
||||
let mut rustpotter = Rustpotter::new(&rustpotter_config).unwrap();
|
||||
|
||||
// load a wakeword
|
||||
let rustpotter_wake_word_files: [&str; 5] = [
|
||||
"rustpotter/jarvis-default.rpw",
|
||||
"rustpotter/jarvis-community-1.rpw",
|
||||
"rustpotter/jarvis-community-2.rpw",
|
||||
"rustpotter/jarvis-community-3.rpw",
|
||||
"rustpotter/jarvis-community-4.rpw",
|
||||
// "rustpotter/jarvis-community-5.rpw",
|
||||
];
|
||||
|
||||
for rpw in rustpotter_wake_word_files {
|
||||
rustpotter.add_wakeword_from_file(rpw).unwrap();
|
||||
}
|
||||
|
||||
// store rustpotter
|
||||
if RUSTPOTTER.get().is_none() {
|
||||
RUSTPOTTER.set(Mutex::new(rustpotter));
|
||||
}
|
||||
|
||||
// start recording
|
||||
start_recording()
|
||||
}
|
||||
|
||||
fn vosk_init() -> Result<bool, String> {
|
||||
start_recording()
|
||||
}
|
||||
|
||||
fn picovoice_init() -> Result<bool, String> {
|
||||
// VARS
|
||||
let porcupine: Porcupine;
|
||||
let picovoice_api_key: String;
|
||||
|
||||
// Retrieve API key from DB
|
||||
if let Some(pkey) = DB.lock().unwrap().get::<String>("api_key__picovoice") {
|
||||
picovoice_api_key = pkey;
|
||||
} else {
|
||||
warn!("Picovoice API key is not set!");
|
||||
return Err("Picovoice API key is not set!".into());
|
||||
}
|
||||
|
||||
// Create instance of Porcupine with the given API key
|
||||
match PorcupineBuilder::new_with_keyword_paths(picovoice_api_key, &[Path::new(config::KEYWORDS_PATH).join("jarvis_windows.ppn")])
|
||||
.sensitivities(&[1.0f32]) // max sensitivity possible
|
||||
.init() {
|
||||
Ok(pinstance) => {
|
||||
// porcupine successfully initialized with the valid API key
|
||||
info!("Porcupine successfully initialized with the valid API key ...");
|
||||
porcupine = pinstance;
|
||||
}
|
||||
Err(e) => {
|
||||
error!("Porcupine error: either API key is not valid or there is no internet connection");
|
||||
error!("Error details: {}", e);
|
||||
return Err(
|
||||
"Porcupine error: either API key is not valid or there is no internet connection"
|
||||
.into(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// store
|
||||
if PORCUPINE.get().is_none() {
|
||||
PORCUPINE.set(porcupine);
|
||||
}
|
||||
|
||||
// start recording
|
||||
start_recording()
|
||||
}
|
||||
48
crates/jarvis-gui/src/tauri_commands/sys.rs
Normal file
48
crates/jarvis-gui/src/tauri_commands/sys.rs
Normal file
|
|
@ -0,0 +1,48 @@
|
|||
use peak_alloc::PeakAlloc;
|
||||
|
||||
#[global_allocator]
|
||||
static PEAK_ALLOC: PeakAlloc = PeakAlloc;
|
||||
|
||||
extern crate systemstat;
|
||||
use std::thread;
|
||||
use std::time::Duration;
|
||||
use systemstat::{Platform, System};
|
||||
|
||||
lazy_static! {
|
||||
static ref SYS: System = System::new();
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn get_current_ram_usage() -> String {
|
||||
let result = String::from(format!("{}", PEAK_ALLOC.current_usage_as_mb()));
|
||||
|
||||
result
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn get_peak_ram_usage() -> String {
|
||||
let result = String::from(format!("{}", PEAK_ALLOC.peak_usage_as_gb()));
|
||||
|
||||
result
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn get_cpu_temp() -> String {
|
||||
if let Ok(cpu_temp) = SYS.cpu_temp() {
|
||||
String::from(format!("{}", cpu_temp))
|
||||
} else {
|
||||
String::from("error")
|
||||
}
|
||||
}
|
||||
|
||||
// https://github.com/valpackett/systemstat/blob/trunk/examples/info.rs
|
||||
#[tauri::command(async)]
|
||||
pub async fn get_cpu_usage() -> String {
|
||||
if let Ok(cpu) = SYS.cpu_load_aggregate() {
|
||||
thread::sleep(Duration::from_secs(1));
|
||||
let cpu = cpu.done().unwrap();
|
||||
String::from(format!("{}", cpu.user * 100.0))
|
||||
} else {
|
||||
String::from("error")
|
||||
}
|
||||
}
|
||||
29
crates/jarvis-gui/src/tauri_commands/voice.rs
Normal file
29
crates/jarvis-gui/src/tauri_commands/voice.rs
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
use std::fs::File;
|
||||
use std::io::BufReader;
|
||||
use rodio::{Decoder, OutputStream, Sink};
|
||||
|
||||
#[tauri::command(async)]
|
||||
pub fn play_sound(filename: &str, sleep: bool) {
|
||||
// Get a output stream handle to the default physical sound device
|
||||
let (_stream, stream_handle) = OutputStream::try_default().unwrap();
|
||||
let sink = Sink::try_new(&stream_handle).unwrap();
|
||||
|
||||
// Load a sound from a file, using a path relative to Cargo.toml
|
||||
// let filepath = format!("{PUBLIC_PATH}/sound/{filename}.wav");
|
||||
let filepath = filename;
|
||||
let file = BufReader::new(File::open(&filepath).unwrap());
|
||||
|
||||
// Decode that sound file into a source
|
||||
let source = Decoder::new(file).unwrap();
|
||||
|
||||
// Play the sound directly on the device
|
||||
println!("Playing {} ...", filepath);
|
||||
// stream_handle.play_raw(source.convert_samples());
|
||||
sink.append(source);
|
||||
|
||||
if sleep {
|
||||
// The sound plays in a separate thread. This call will block the current thread until the sink
|
||||
// has finished playing all its queued sounds.
|
||||
sink.sleep_until_end();
|
||||
}
|
||||
}
|
||||
45
crates/jarvis-gui/tauri.conf.json
Normal file
45
crates/jarvis-gui/tauri.conf.json
Normal file
|
|
@ -0,0 +1,45 @@
|
|||
{
|
||||
"$schema": "https://schema.tauri.app/config/2",
|
||||
"productName": "jarvis-app",
|
||||
"version": "0.0.3",
|
||||
"identifier": "com.priler.jarvis",
|
||||
"build": {
|
||||
"beforeDevCommand": "npm run dev",
|
||||
"beforeBuildCommand": "npm run build",
|
||||
"devUrl": "http://localhost:1420",
|
||||
"frontendDist": "../../frontend/dist"
|
||||
},
|
||||
"app": {
|
||||
"withGlobalTauri": false,
|
||||
"security": {
|
||||
"csp": null
|
||||
},
|
||||
"windows": [
|
||||
{
|
||||
"fullscreen": false,
|
||||
"resizable": false,
|
||||
"title": "Jarvis Voice Assistant",
|
||||
"width": 550,
|
||||
"height": 700
|
||||
}
|
||||
]
|
||||
},
|
||||
"bundle": {
|
||||
"active": true,
|
||||
"icon": [
|
||||
"../../resources/icons/32x32.png",
|
||||
"../../resources/icons/128x128.png",
|
||||
"../../resources/icons/128x128@2x.png",
|
||||
"../../resources/icons/icon.icns",
|
||||
"../../resources/icons/icon.ico"
|
||||
],
|
||||
"targets": "all",
|
||||
"resources": {
|
||||
"../../resources/commands": "commands",
|
||||
"../../resources/sound": "sound",
|
||||
"../../resources/rustpotter": "rustpotter",
|
||||
"../../resources/vosk": "vosk",
|
||||
"../../resources/keywords": "keywords"
|
||||
}
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue