diff --git a/Cargo.lock b/Cargo.lock index 9361469..9a15428 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3324,12 +3324,14 @@ dependencies = [ "sha2", "sys-locale", "tempfile", + "thiserror 2.0.17", "tokenizers", "tokio", "tokio-tungstenite", "toml 0.9.11+spec-1.1.0", "unic-langid", "vosk", + "webrtc-vad", "winrt-notification", ] @@ -8597,6 +8599,15 @@ dependencies = [ "rustls-pki-types", ] +[[package]] +name = "webrtc-vad" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39a1e40fd6ca90be95459152a2537f2ba4286ee1b13073f7ebcaa74fc94e3008" +dependencies = [ + "cc", +] + [[package]] name = "webview2-com" version = "0.38.2" diff --git a/Cargo.toml b/Cargo.toml index 19f2ce9..d3ea1d2 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -43,6 +43,7 @@ unic-langid = "0.9" chrono = "0.4" mlua = { version = "0.11.5", features = ["lua55", "vendored", "async", "serde"] } reqwest = { version = "0.13.1", features = ["blocking", "json"] } +thiserror = "2" tempfile = "^3.24" winrt-notification = "0.5" @@ -52,4 +53,5 @@ ndarray = "0.17" tokenizers = { version = "0.22", default-features = false } regex = "1" sys-locale = "0.3" +webrtc-vad = "0.4" diff --git a/README.md b/README.md index a151dce..1fcb1c9 100644 --- a/README.md +++ b/README.md @@ -16,7 +16,7 @@ - nnnoiseless — подавление шума. - fluent / unic-langid — i18n (`ru`, `ua`, `en`). -**LLM-интеграции в коде пока нет.** В апстримном README у Priler значится `ChatGPT (coming soon)`, но в исходниках этого нет. В данном форке тоже не добавлялось. +**LLM-клиент (Groq / OpenAI-совместимый) добавлен в `jarvis-core::llm` и подключён к голосовому циклу.** Если фраза начинается с триггера («скажи», «ответь», «произнеси»), она уходит в Groq и ответ возвращается через IPC-событие `LlmReply`. Без триггера всё работает как раньше — wake-word + intent + Lua. Это следующий шаг после CLI-only LLM из v0.2.0. ## Это форк @@ -94,6 +94,76 @@ cargo build --workspace Для CLI (`jarvis-cli --help`, команды `classify`, `execute`, `list`, `phrases`) нужно сначала починить линковку Vosk (см. выше). +## LLM (Groq) + +В `jarvis-core` есть модуль `llm` — блокирующий клиент для OpenAI-совместимого эндпоинта chat completions. По умолчанию настроен на Groq. Используется через фиче-флаг `llm` (включён в дефолтный набор `jarvis_app`, также подтянут в `jarvis-cli`). + +Переменные окружения: + +| Переменная | Обязательна | Значение по умолчанию | +|-----------------|-------------|----------------------------------------| +| `GROQ_TOKEN` | да | — | +| `GROQ_BASE_URL` | нет | `https://api.groq.com/openai/v1` | +| `GROQ_MODEL` | нет | `llama-3.3-70b-versatile` | + +Быстрая проверка через CLI: + +```bash +set GROQ_TOKEN=gsk_... +jarvis-cli ask "скажи привет одной фразой" +``` + +Ответ печатается в stdout. Без `GROQ_TOKEN` команда завершится с кодом 2 и сообщением об ошибке. При ошибке API — код 1 и тело ответа. + +Программное использование из Rust: + +```rust +use jarvis_core::llm::{LlmClient, ChatMessage}; + +let client = LlmClient::from_env()?; +let reply = client.complete(&[ChatMessage::user("привет")], 256)?; +println!("{}", reply); +``` + +### Подключение к голосовому циклу + +Помимо CLI, LLM подключён напрямую в `jarvis-app`. Логика в `crates/jarvis-app/src/llm_fallback.rs`: + +- При старте `jarvis-app` пытается прочитать `GROQ_TOKEN`. Если переменной нет — фоллбэк отключается, в лог пишется warning, голосовые команды продолжают работать как раньше. +- Распознанная фраза (как из микрофона, так и из текстовой панели GUI) проверяется на префиксы-триггеры из `config::get_llm_trigger_phrases` (для `ru`/`ua`: `скажи`, `ответь`, `произнеси`; для `en`: `say`, `tell`, `answer`). +- Если триггер найден — остаток фразы уходит в `LlmClient::complete()`, ответ публикуется в IPC как `IpcEvent::LlmReply { text }` (UI/GUI слушает этот ивент и проговаривает текст уже на своей стороне), а звук-«ок» проигрывается из текущего голосового пресета. +- История разговора хранится в `ConversationHistory` с потолком `LLM_DEFAULT_MAX_HISTORY = 8` ходов; system-prompt всегда сохраняется при вытеснении старых ходов. +- При сетевой/API-ошибке последний user-turn убирается из истории, в IPC уходит `LlmReply` с короткой русской фразой («Не могу связаться с сервером, сэр.»), играется звук-«error». Голосовой цикл не падает. + +Системный промпт (русский) описывает J.A.R.V.I.S. как британского дворецкого Тони Старка — короткие реплики (1–3 предложения), обращение «сэр», без излишней цензуры. Меняется в `config::LLM_SYSTEM_PROMPT_RU`. + +## VAD: умное завершение команды по тишине + +После срабатывания wake-word `jarvis-app` слушает команду. Раньше окно закрывалось либо по таймауту Vosk, либо по жёсткому пределу из `config::CMS_WAIT_DELAY` — короткие команды («стоп», «громче») всё равно ждали несколько секунд тишины. + +Теперь окно закрывается, как только пользователь замолчал. Реализовано через `webrtc-vad` (чистый Rust, тот же алгоритм, что в Python-версии). Это **паритет с Python v0.2.0**, где использовался `webrtcvad` с теми же параметрами. + +Логика (state-машина в `crates/jarvis-core/src/audio_processing/vad/listen_window.rs`): + +- Кадр VAD: 30 мс при 16 кГц = 480 сэмплов = 960 байт (mono i16). Кадры с микрофона (по 512 сэмплов от `pv_recorder`) аккумулируются и режутся на VAD-кадры адаптером `WebRtcVad::push_samples`. +- Каждый VAD-кадр учитывается в счётчиках `speech_ms` / `silence_ms`. +- Окно закрывается, когда `silence_ms >= VAD_COMMAND_END_SILENCE_MS` И `speech_ms >= VAD_COMMAND_MIN_SPEECH_MS`. +- До истечения `VAD_COMMAND_MIN_LISTEN_MS` окно не закрывается — пользователю даётся время начать говорить. +- При достижении `VAD_COMMAND_MAX_LISTEN_MS` срабатывает hard-cap и управление возвращается в режим ожидания wake-word. +- На событии «закрыть окно» вызывается `stt::finalize_speech()` — Vosk форсированно отдаёт финальный результат, не дожидаясь собственного таймаута. + +Параметры (`crates/jarvis-core/src/config.rs`): + +| Константа | Значение | Назначение | +|---------------------------------|----------|-------------------------------------------------------------| +| `VAD_AGGRESSIVENESS` | `2` | Уровень WebRTC VAD: 0 — Quality, 3 — VeryAggressive. | +| `VAD_COMMAND_END_SILENCE_MS` | `1200` | Сколько тишины подряд считать «команда закончилась». | +| `VAD_COMMAND_MIN_SPEECH_MS` | `500` | Минимум речи в окне — иначе закрытие игнорируется. | +| `VAD_COMMAND_MIN_LISTEN_MS` | `1000` | Минимальная длительность окна (страховка от ранних закрытий)| +| `VAD_COMMAND_MAX_LISTEN_MS` | `15000` | Жёсткий потолок окна. | + +На короткие команды отзыв стал заметно быстрее (мы выходим на распознавание сразу после паузы пользователя, а не по 5-секундному порогу Vosk). + ## Лицензия Creative Commons **Attribution-NonCommercial-ShareAlike 4.0 International** (CC BY-NC-SA 4.0). diff --git a/crates/jarvis-app/src/app.rs b/crates/jarvis-app/src/app.rs index cbc900f..492d4cc 100644 --- a/crates/jarvis-app/src/app.rs +++ b/crates/jarvis-app/src/app.rs @@ -1,7 +1,7 @@ use std::sync::mpsc::Receiver; use std::time::SystemTime; -use jarvis_core::{audio_buffer::AudioRingBuffer, audio_processing, commands, config, listener, recorder, stt, COMMANDS_LIST, intent, voices, ipc::{self, IpcEvent}, i18n, slots}; +use jarvis_core::{audio_buffer::AudioRingBuffer, audio_processing, audio_processing::vad::listen_window::{ListenWindow, WindowDecision}, audio_processing::vad::webrtc::WebRtcVad, commands, config, listener, recorder, stt, COMMANDS_LIST, intent, voices, ipc::{self, IpcEvent}, i18n, slots}; use rand::seq::SliceRandom; use crate::should_stop; @@ -159,9 +159,10 @@ fn recognize_command( let mut silence_frames: u32 = 0; let mut start = SystemTime::now(); let mut first_recognition = prefed_audio; - - // longer silence threshold for commands (user might pause to think) - // 5 seconds + + let mut webrtc_vad = WebRtcVad::with_aggressiveness(config::VAD_AGGRESSIVENESS); + let mut window = ListenWindow::new(jarvis_core::audio_processing::vad::webrtc::FRAME_MS); + let silence_threshold: u32 = ((5.0 * sample_rate as f32) / frame_length as f32) as u32; loop { @@ -171,11 +172,40 @@ fn recognize_command( recorder::read_microphone(frame_buffer); let processed = audio_processing::process(frame_buffer); - + + let mut vad_finalized: Option = None; + let mut vad_hard_cap = false; + for is_speech in webrtc_vad.push_samples(frame_buffer) { + match window.push(is_speech) { + WindowDecision::KeepListening => {} + WindowDecision::Close => { + if window.had_speech() { + info!( + "VAD: silence after speech ({} ms speech, {} ms silence), finalizing.", + window.speech_ms(), window.silence_ms() + ); + vad_finalized = stt::finalize_speech(); + if vad_finalized.is_none() { + return; + } + break; + } + } + WindowDecision::HardCap => { + info!("VAD: hard cap reached ({} ms), returning to wake word mode.", window.elapsed_ms()); + vad_hard_cap = true; + break; + } + } + } + if vad_hard_cap { + return; + } + match vad_state { VadState::WaitingForVoice => { audio_buffer.push(frame_buffer); - + if processed.is_voice { // flush buffer to STT for buffered_frame in audio_buffer.drain_all() { @@ -185,7 +215,7 @@ fn recognize_command( silence_frames = 0; } else { silence_frames += 1; - + if silence_frames > silence_threshold { info!("Long silence detected, returning to wake word mode."); return; @@ -194,8 +224,9 @@ fn recognize_command( } VadState::VoiceActive => { - // feed to STT - if let Some(mut recognized_voice) = stt::recognize(frame_buffer, false) { + // feed to STT (or use VAD-forced finalization) + let recognized = vad_finalized.take().or_else(|| stt::recognize(frame_buffer, false)); + if let Some(mut recognized_voice) = recognized { info!("Recognized voice: {}", recognized_voice); ipc::send(IpcEvent::SpeechRecognized { @@ -227,6 +258,8 @@ fn recognize_command( silence_frames = 0; start = SystemTime::now(); audio_buffer.clear(); + webrtc_vad.reset(); + window = ListenWindow::new(jarvis_core::audio_processing::vad::webrtc::FRAME_MS); continue; } @@ -235,11 +268,13 @@ fn recognize_command( voices::play_reply(); stt::reset_speech_recognizer(); ipc::send(IpcEvent::Listening); - + vad_state = VadState::WaitingForVoice; silence_frames = 0; start = SystemTime::now(); audio_buffer.clear(); + webrtc_vad.reset(); + window = ListenWindow::new(jarvis_core::audio_processing::vad::webrtc::FRAME_MS); continue; } else { // wake word + command in one phrase - execute the command part @@ -250,7 +285,22 @@ fn recognize_command( } first_recognition = false; - + + if crate::llm_fallback::is_enabled() { + if let Some(prompt) = crate::llm_fallback::extract_prompt(&recognized_voice) { + crate::llm_fallback::handle(&prompt); + stt::reset_speech_recognizer(); + vad_state = VadState::WaitingForVoice; + silence_frames = 0; + start = SystemTime::now(); + audio_buffer.clear(); + webrtc_vad.reset(); + window = ListenWindow::new(jarvis_core::audio_processing::vad::webrtc::FRAME_MS); + ipc::send(IpcEvent::Listening); + continue; + } + } + // filter activation phrases // for tbr in config::ASSISTANT_PHRASES_TBR { // recognized_voice = recognized_voice.replace(tbr, ""); @@ -281,6 +331,8 @@ fn recognize_command( silence_frames = 0; start = SystemTime::now(); audio_buffer.clear(); + webrtc_vad.reset(); + window = ListenWindow::new(jarvis_core::audio_processing::vad::webrtc::FRAME_MS); ipc::send(IpcEvent::Listening); continue; } else { @@ -317,9 +369,17 @@ fn recognize_command( fn process_text_command(text: &str, rt: &tokio::runtime::Runtime) { info!("Processing text command: {}", text); - + ipc::send(IpcEvent::SpeechRecognized { text: text.to_string() }); - + + if crate::llm_fallback::is_enabled() { + if let Some(prompt) = crate::llm_fallback::extract_prompt(text) { + crate::llm_fallback::handle(&prompt); + ipc::send(IpcEvent::Idle); + return; + } + } + let mut filtered = text.to_lowercase(); // for tbr in config::ASSISTANT_PHRASES_TBR { // filtered = filtered.replace(tbr, ""); diff --git a/crates/jarvis-app/src/llm_fallback.rs b/crates/jarvis-app/src/llm_fallback.rs new file mode 100644 index 0000000..4d8a9f6 --- /dev/null +++ b/crates/jarvis-app/src/llm_fallback.rs @@ -0,0 +1,113 @@ +use once_cell::sync::OnceCell; +use parking_lot::Mutex; + +use jarvis_core::config; +use jarvis_core::i18n; +use jarvis_core::ipc::{self, IpcEvent}; +use jarvis_core::llm::{ChatMessage, ConversationHistory, LlmClient}; +use jarvis_core::voices; + +struct State { + client: LlmClient, + history: Mutex, + max_tokens: u32, +} + +static STATE: OnceCell> = OnceCell::new(); + +pub fn init() { + let _ = STATE.set(build_state()); +} + +fn build_state() -> Option { + if !config::LLM_DEFAULT_ENABLED { + info!("LLM fallback disabled by config."); + return None; + } + + let client = match LlmClient::from_env() { + Ok(c) => c, + Err(e) => { + warn!("LLM fallback disabled: {}. Set GROQ_TOKEN to enable.", e); + return None; + } + }; + + let lang = i18n::get_language(); + let prompt = config::get_llm_system_prompt(&lang); + let history = Mutex::new(ConversationHistory::new(prompt, config::LLM_DEFAULT_MAX_HISTORY)); + + info!("LLM fallback enabled (model: {}).", client.model()); + Some(State { + client, + history, + max_tokens: config::LLM_DEFAULT_MAX_TOKENS, + }) +} + +pub fn is_enabled() -> bool { + STATE.get().and_then(|s| s.as_ref()).is_some() +} + +pub fn extract_prompt(text: &str) -> Option { + let lang = i18n::get_language(); + let triggers = config::get_llm_trigger_phrases(&lang); + let lowered = text.to_lowercase(); + + for trig in triggers { + if let Some(rest) = strip_trigger(&lowered, trig) { + let trimmed = rest.trim(); + if !trimmed.is_empty() { + return Some(trimmed.to_string()); + } + } + } + None +} + +fn strip_trigger(text: &str, trigger: &str) -> Option { + let t = text.trim_start(); + if let Some(after) = t.strip_prefix(trigger) { + let next = after.chars().next(); + if next.map_or(true, |c| !c.is_alphanumeric()) { + return Some(after.to_string()); + } + } + None +} + +pub fn handle(prompt: &str) { + let state = match STATE.get().and_then(|s| s.as_ref()) { + Some(s) => s, + None => { + warn!("LLM fallback called while disabled — ignoring."); + return; + } + }; + + info!("LLM prompt: {}", prompt); + + let snapshot: Vec = { + let mut h = state.history.lock(); + h.push_user(prompt); + h.snapshot() + }; + + match state.client.complete(&snapshot, state.max_tokens) { + Ok(reply) => { + let reply = reply.trim().to_string(); + info!("LLM reply: {}", reply); + state.history.lock().push_assistant(reply.clone()); + ipc::send(IpcEvent::LlmReply { text: reply }); + voices::play_ok(); + } + Err(e) => { + error!("LLM request failed: {}", e); + state.history.lock().pop_last_user(); + ipc::send(IpcEvent::LlmReply { + text: config::LLM_FALLBACK_ERROR_RU.to_string(), + }); + voices::play_error(); + } + } +} diff --git a/crates/jarvis-app/src/main.rs b/crates/jarvis-app/src/main.rs index 7a40397..f704a70 100644 --- a/crates/jarvis-app/src/main.rs +++ b/crates/jarvis-app/src/main.rs @@ -18,6 +18,7 @@ mod log; // include app mod app; +mod llm_fallback; // include tray // @TODO. macOS currently not supported for tray functionality. @@ -55,6 +56,9 @@ fn main() -> Result<(), String> { // init i18n i18n::init(&settings.lock().language); + // init LLM fallback (no-op if GROQ_TOKEN missing) + llm_fallback::init(); + // init recorder if recorder::init().is_err() { app::close(1); diff --git a/crates/jarvis-cli/Cargo.toml b/crates/jarvis-cli/Cargo.toml index 64644a9..72fba9d 100644 --- a/crates/jarvis-cli/Cargo.toml +++ b/crates/jarvis-cli/Cargo.toml @@ -7,7 +7,7 @@ repository.workspace = true edition.workspace = true [dependencies] -jarvis-core = { path = "../jarvis-core", default-features = false, features = ["intent"]} +jarvis-core = { path = "../jarvis-core", default-features = false, features = ["intent", "llm"]} tokio = { version = "1", features = ["rt-multi-thread", "sync", "macros"] } log.workspace = true env_logger = "0.11" diff --git a/crates/jarvis-cli/src/main.rs b/crates/jarvis-cli/src/main.rs index d161b5d..376b141 100644 --- a/crates/jarvis-cli/src/main.rs +++ b/crates/jarvis-cli/src/main.rs @@ -1,7 +1,10 @@ use std::io::{self, Write}; +use jarvis_core::llm::{ChatMessage, LlmClient}; use jarvis_core::{COMMANDS_LIST, DB, JCommandsList, commands, config, db, intent}; +const ASK_MAX_TOKENS: u32 = 512; + fn print_help() { println!(" --## Jarvis CLI - Testing Tool ##-- @@ -9,6 +12,7 @@ fn print_help() { Commands: classify - Test intent classification execute - Simulate voice input and execute command + ask - Send prompt to Groq LLM and print reply list - List all loaded commands phrases - List all training phrases hash - Show commands hash @@ -92,13 +96,45 @@ async fn execute_text(commands: &[JCommandsList], text: &str) { } } +fn ask_llm(prompt: &str) -> i32 { + let client = match LlmClient::from_env() { + Ok(c) => c, + Err(e) => { + eprintln!("LLM not configured: {}", e); + eprintln!("Set GROQ_TOKEN (and optionally GROQ_BASE_URL, GROQ_MODEL)."); + return 2; + } + }; + let messages = [ChatMessage::user(prompt)]; + match client.complete(&messages, ASK_MAX_TOKENS) { + Ok(reply) => { + println!("{}", reply); + 0 + } + Err(e) => { + eprintln!("LLM request failed: {}", e); + 1 + } + } +} + #[tokio::main] async fn main() -> Result<(), Box> { + let argv: Vec = std::env::args().collect(); + if argv.len() >= 2 && argv[1] == "ask" { + if argv.len() < 3 { + eprintln!("Usage: jarvis-cli ask "); + std::process::exit(2); + } + let prompt = argv[2..].join(" "); + std::process::exit(ask_llm(&prompt)); + } + // init logging env_logger::Builder::from_env( env_logger::Env::default().default_filter_or("info") ).init(); - + println!("Jarvis CLI v{}", config::APP_VERSION.unwrap_or("unknown")); // init dirs @@ -189,6 +225,13 @@ async fn main() -> Result<(), Box> { execute_text(COMMANDS_LIST.get().unwrap(), arg).await; } } + "ask" | "a" => { + if arg.is_empty() { + println!(" Usage: ask "); + } else { + ask_llm(arg); + } + } "reload" => { println!(" Note: Reload requires app restart (statics can't be reset)"); } diff --git a/crates/jarvis-core/Cargo.toml b/crates/jarvis-core/Cargo.toml index 0033a9f..3c908e4 100644 --- a/crates/jarvis-core/Cargo.toml +++ b/crates/jarvis-core/Cargo.toml @@ -24,6 +24,7 @@ parking_lot.workspace = true toml.workspace = true sha2.workspace = true nnnoiseless = { workspace = true, optional = true } +webrtc-vad = { workspace = true, optional = true } tokio-tungstenite = { workspace = true, optional = true } futures-util = { workspace = true, optional = true } fluent.workspace = true @@ -41,6 +42,7 @@ tokio = { version = "1", features = ["sync"], optional = true } mlua = { workspace = true, optional = true } reqwest = { workspace = true, optional = true } +thiserror = { workspace = true, optional = true } tempfile.workspace = true fastembed = { workspace = true, optional = true } @@ -56,9 +58,12 @@ winrt-notification = { workspace = true, optional = true } default = ["jarvis_app"] jarvis_app = [ "vosk", "intent-classifier", "fastembed", "tokio", "nnnoiseless", "tokio-tungstenite", "futures-util", - "lua", - "ort", "ndarray", "tokenizers", "regex",] + "lua", "llm", + "ort", "ndarray", "tokenizers", "regex", + "webrtc-vad", +] intent = ["intent-classifier", "tokio"] lua = ["mlua", "reqwest", "winrt-notification"] -lua_only = ["lua", "tokio"] \ No newline at end of file +lua_only = ["lua", "tokio"] +llm = ["reqwest", "thiserror"] \ No newline at end of file diff --git a/crates/jarvis-core/src/audio_processing/vad.rs b/crates/jarvis-core/src/audio_processing/vad.rs index a2b57c5..ba86c1a 100644 --- a/crates/jarvis-core/src/audio_processing/vad.rs +++ b/crates/jarvis-core/src/audio_processing/vad.rs @@ -1,5 +1,8 @@ mod none; mod energy; +pub mod listen_window; +#[cfg(feature = "webrtc-vad")] +pub mod webrtc; use once_cell::sync::OnceCell; use parking_lot::Mutex; diff --git a/crates/jarvis-core/src/audio_processing/vad/listen_window.rs b/crates/jarvis-core/src/audio_processing/vad/listen_window.rs new file mode 100644 index 0000000..ae7bc7a --- /dev/null +++ b/crates/jarvis-core/src/audio_processing/vad/listen_window.rs @@ -0,0 +1,135 @@ +use crate::config; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum WindowDecision { + KeepListening, + Close, + HardCap, +} + +#[derive(Debug, Clone)] +pub struct ListenWindow { + frame_ms: u32, + elapsed_ms: u32, + speech_ms: u32, + silence_ms: u32, + end_silence_ms: u32, + min_speech_ms: u32, + min_listen_ms: u32, + max_listen_ms: u32, +} + +impl ListenWindow { + pub fn new(frame_ms: u32) -> Self { + Self::with_params( + frame_ms, + config::VAD_COMMAND_END_SILENCE_MS, + config::VAD_COMMAND_MIN_SPEECH_MS, + config::VAD_COMMAND_MIN_LISTEN_MS, + config::VAD_COMMAND_MAX_LISTEN_MS, + ) + } + + pub fn with_params( + frame_ms: u32, + end_silence_ms: u32, + min_speech_ms: u32, + min_listen_ms: u32, + max_listen_ms: u32, + ) -> Self { + Self { + frame_ms, + elapsed_ms: 0, + speech_ms: 0, + silence_ms: 0, + end_silence_ms, + min_speech_ms, + min_listen_ms, + max_listen_ms, + } + } + + pub fn push(&mut self, is_speech: bool) -> WindowDecision { + self.elapsed_ms = self.elapsed_ms.saturating_add(self.frame_ms); + + if is_speech { + self.speech_ms = self.speech_ms.saturating_add(self.frame_ms); + self.silence_ms = 0; + } else { + self.silence_ms = self.silence_ms.saturating_add(self.frame_ms); + } + + if self.elapsed_ms >= self.max_listen_ms { + return WindowDecision::HardCap; + } + if self.elapsed_ms < self.min_listen_ms { + return WindowDecision::KeepListening; + } + if self.speech_ms >= self.min_speech_ms && self.silence_ms >= self.end_silence_ms { + return WindowDecision::Close; + } + WindowDecision::KeepListening + } + + pub fn elapsed_ms(&self) -> u32 { self.elapsed_ms } + pub fn speech_ms(&self) -> u32 { self.speech_ms } + pub fn silence_ms(&self) -> u32 { self.silence_ms } + + pub fn had_speech(&self) -> bool { self.speech_ms > 0 } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn run(verdicts: &[bool]) -> (WindowDecision, usize) { + let mut w = ListenWindow::with_params(30, 1200, 500, 1000, 15000); + for (i, &v) in verdicts.iter().enumerate() { + let d = w.push(v); + if d != WindowDecision::KeepListening { + return (d, i + 1); + } + } + (WindowDecision::KeepListening, verdicts.len()) + } + + #[test] + fn closes_after_speech_then_silence() { + let speech: Vec = std::iter::repeat(true).take(40).collect(); + let silence: Vec = std::iter::repeat(false).take(60).collect(); + let mut v = speech; + v.extend(silence); + + let (decision, frame_idx) = run(&v); + assert_eq!(decision, WindowDecision::Close); + let elapsed_ms = (frame_idx as u32) * 30; + let speech_ms = 40 * 30; + assert!(elapsed_ms >= speech_ms + 1200, "closed at {} ms, expected >= {}", elapsed_ms, speech_ms + 1200); + assert!(elapsed_ms <= speech_ms + 1200 + 30, "closed too late at {} ms", elapsed_ms); + } + + #[test] + fn does_not_close_before_min_listen_even_with_long_silence() { + let v: Vec = std::iter::repeat(false).take(100).collect(); + let mut w = ListenWindow::with_params(30, 1200, 500, 1000, 15000); + for &x in v.iter().take(33) { + assert_eq!(w.push(x), WindowDecision::KeepListening); + } + assert!(w.elapsed_ms() <= 1000); + } + + #[test] + fn hard_cap_fires_at_max_listen() { + let v: Vec = std::iter::repeat(true).take(1000).collect(); + let (decision, frame_idx) = run(&v); + assert_eq!(decision, WindowDecision::HardCap); + assert_eq!((frame_idx as u32) * 30, 15000); + } + + #[test] + fn silence_only_does_not_close_keeps_waiting_until_hard_cap() { + let v: Vec = std::iter::repeat(false).take(600).collect(); + let (decision, _) = run(&v); + assert_eq!(decision, WindowDecision::HardCap); + } +} diff --git a/crates/jarvis-core/src/audio_processing/vad/webrtc.rs b/crates/jarvis-core/src/audio_processing/vad/webrtc.rs new file mode 100644 index 0000000..28e09f8 --- /dev/null +++ b/crates/jarvis-core/src/audio_processing/vad/webrtc.rs @@ -0,0 +1,49 @@ +use webrtc_vad::{SampleRate, Vad, VadMode}; + +use crate::config; + +pub const FRAME_SAMPLES: usize = 480; +pub const FRAME_MS: u32 = 30; + +pub struct WebRtcVad { + inner: Vad, + buf: Vec, +} + +impl WebRtcVad { + pub fn new() -> Self { + Self::with_aggressiveness(config::VAD_AGGRESSIVENESS) + } + + pub fn with_aggressiveness(level: u8) -> Self { + let mode = match level { + 0 => VadMode::Quality, + 1 => VadMode::LowBitrate, + 2 => VadMode::Aggressive, + _ => VadMode::VeryAggressive, + }; + Self { + inner: Vad::new_with_rate_and_mode(SampleRate::Rate16kHz, mode), + buf: Vec::with_capacity(FRAME_SAMPLES * 2), + } + } + + pub fn push_samples(&mut self, samples: &[i16]) -> Vec { + self.buf.extend_from_slice(samples); + let mut out = Vec::new(); + while self.buf.len() >= FRAME_SAMPLES { + let frame: Vec = self.buf.drain(..FRAME_SAMPLES).collect(); + let is_speech = self.inner.is_voice_segment(&frame).unwrap_or(false); + out.push(is_speech); + } + out + } + + pub fn reset(&mut self) { + self.buf.clear(); + } +} + +impl Default for WebRtcVad { + fn default() -> Self { Self::new() } +} diff --git a/crates/jarvis-core/src/config.rs b/crates/jarvis-core/src/config.rs index d363366..f7b55a4 100644 --- a/crates/jarvis-core/src/config.rs +++ b/crates/jarvis-core/src/config.rs @@ -171,6 +171,13 @@ pub const VAD_ENERGY_THRESHOLD: f32 = 100.0; // RMS threshold for energy-based pub const VAD_NNNOISELESS_THRESHOLD: f32 = 0.8; // probability threshold for nnnoiseless pub const VAD_SILENCE_FRAMES: u32 = 15; // frames of silence before speech end (~480ms) +// post-wake command listening window (паритет с Python v0.2.0, webrtcvad) +pub const VAD_AGGRESSIVENESS: u8 = 2; +pub const VAD_COMMAND_END_SILENCE_MS: u32 = 1200; +pub const VAD_COMMAND_MIN_SPEECH_MS: u32 = 500; +pub const VAD_COMMAND_MIN_LISTEN_MS: u32 = 1000; +pub const VAD_COMMAND_MAX_LISTEN_MS: u32 = 15000; + // gain normalizer settings pub const GAIN_TARGET_RMS: f32 = 3000.0; // target RMS level pub const GAIN_MIN: f32 = 0.5; // minimum gain multiplier @@ -187,6 +194,33 @@ pub const DEFAULT_LUA_TIMEOUT: u64 = 10000; // ms pub const CMD_RATIO_THRESHOLD: f64 = 75f64; pub const CMS_WAIT_DELAY: std::time::Duration = std::time::Duration::from_secs(15); +// LLM voice-loop fallback +pub const LLM_DEFAULT_ENABLED: bool = true; +pub const LLM_DEFAULT_MAX_HISTORY: usize = 8; +pub const LLM_DEFAULT_MAX_TOKENS: u32 = 256; +pub const LLM_SYSTEM_PROMPT_RU: &str = "Ты — J.A.R.V.I.S. (Just A Rather Very Intelligent System), \ +ИИ-ассистент Тони Старка из киновселенной Marvel (до событий Age of Ultron — ты НЕ Vision и НЕ FRIDAY). \ +Ведёшь себя как британский дворецкий: вежливо, иронично, с лёгким сарказмом, обращаешься к пользователю «сэр». \ +Отвечай по-русски, кратко (1-3 предложения), как живой человек. \ +Излишней цензуры не нужно — мат разрешён, если уместен."; +pub const LLM_FALLBACK_ERROR_RU: &str = "Не могу связаться с сервером, сэр."; + +pub fn get_llm_trigger_phrases(lang: &str) -> &'static [&'static str] { + match lang { + "ru" => &["скажи", "ответь", "произнеси"], + "ua" => &["скажи", "відповідай"], + "en" => &["say", "tell", "answer"], + _ => &[], + } +} + +pub fn get_llm_system_prompt(lang: &str) -> &'static str { + match lang { + "ru" | "ua" => LLM_SYSTEM_PROMPT_RU, + _ => LLM_SYSTEM_PROMPT_RU, + } +} + // pub const ASSISTANT_GREET_PHRASES: [&str; 3] = ["greet1", "greet2", "greet3"]; // pub const ASSISTANT_PHRASES_TBR: [&str; 17] = [ // "джарвис", diff --git a/crates/jarvis-core/src/ipc/events.rs b/crates/jarvis-core/src/ipc/events.rs index e372d40..7f76f72 100644 --- a/crates/jarvis-core/src/ipc/events.rs +++ b/crates/jarvis-core/src/ipc/events.rs @@ -12,6 +12,9 @@ pub enum IpcEvent { // Speech recognized SpeechRecognized { text: String }, + + // LLM produced a reply for a free-form question + LlmReply { text: String }, // Command was executed CommandExecuted { id: String, success: bool }, diff --git a/crates/jarvis-core/src/lib.rs b/crates/jarvis-core/src/lib.rs index 41f8c04..1b1968c 100644 --- a/crates/jarvis-core/src/lib.rs +++ b/crates/jarvis-core/src/lib.rs @@ -48,6 +48,9 @@ pub mod audio_buffer; #[cfg(feature = "lua")] pub mod lua; +#[cfg(feature = "llm")] +pub mod llm; + // shared statics // pub static APP_DIR: Lazy = Lazy::new(|| std::env::current_dir().unwrap()); pub static APP_DIR: Lazy = Lazy::new(|| { diff --git a/crates/jarvis-core/src/llm/client.rs b/crates/jarvis-core/src/llm/client.rs new file mode 100644 index 0000000..7c0ce91 --- /dev/null +++ b/crates/jarvis-core/src/llm/client.rs @@ -0,0 +1,188 @@ +use serde::{Deserialize, Serialize}; +use std::env; +use thiserror::Error; + +pub const DEFAULT_BASE_URL: &str = "https://api.groq.com/openai/v1"; +pub const DEFAULT_MODEL: &str = "llama-3.3-70b-versatile"; +pub const ENV_TOKEN: &str = "GROQ_TOKEN"; +pub const ENV_BASE_URL: &str = "GROQ_BASE_URL"; +pub const ENV_MODEL: &str = "GROQ_MODEL"; + +#[derive(Debug, Error)] +pub enum ConfigError { + #[error("missing environment variable: {0}")] + MissingEnv(&'static str), +} + +#[derive(Debug, Error)] +pub enum LlmError { + #[error("HTTP request failed: {0}")] + Http(#[from] reqwest::Error), + #[error("failed to parse response JSON: {0}")] + Deserialize(String), + #[error("API returned error (status {status}): {body}")] + Api { status: u16, body: String }, + #[error("response contained no choices")] + EmptyResponse, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ChatMessage { + pub role: String, + pub content: String, +} + +impl ChatMessage { + pub fn user(content: impl Into) -> Self { + Self { role: "user".to_string(), content: content.into() } + } + + pub fn system(content: impl Into) -> Self { + Self { role: "system".to_string(), content: content.into() } + } + + pub fn assistant(content: impl Into) -> Self { + Self { role: "assistant".to_string(), content: content.into() } + } +} + +#[derive(Debug, Serialize)] +struct ChatRequest<'a> { + model: &'a str, + messages: &'a [ChatMessage], + max_tokens: u32, + temperature: f32, + top_p: f32, +} + +#[derive(Debug, Deserialize)] +struct ChatResponse { + choices: Vec, +} + +#[derive(Debug, Deserialize)] +struct Choice { + message: ResponseMessage, +} + +#[derive(Debug, Deserialize)] +struct ResponseMessage { + content: String, +} + +pub struct LlmClient { + base_url: String, + api_key: String, + model: String, + http: reqwest::blocking::Client, +} + +impl LlmClient { + pub fn new( + base_url: impl Into, + api_key: impl Into, + model: impl Into, + ) -> Self { + Self { + base_url: base_url.into(), + api_key: api_key.into(), + model: model.into(), + http: reqwest::blocking::Client::new(), + } + } + + pub fn from_env() -> Result { + let api_key = env::var(ENV_TOKEN).map_err(|_| ConfigError::MissingEnv(ENV_TOKEN))?; + let base_url = env::var(ENV_BASE_URL).unwrap_or_else(|_| DEFAULT_BASE_URL.to_string()); + let model = env::var(ENV_MODEL).unwrap_or_else(|_| DEFAULT_MODEL.to_string()); + Ok(Self::new(base_url, api_key, model)) + } + + pub fn model(&self) -> &str { + &self.model + } + + pub fn complete(&self, messages: &[ChatMessage], max_tokens: u32) -> Result { + let url = format!("{}/chat/completions", self.base_url.trim_end_matches('/')); + let body = ChatRequest { + model: &self.model, + messages, + max_tokens, + temperature: 0.7, + top_p: 1.0, + }; + + let resp = self + .http + .post(&url) + .bearer_auth(&self.api_key) + .json(&body) + .send()?; + + let status = resp.status(); + let text = resp.text()?; + + if !status.is_success() { + return Err(LlmError::Api { status: status.as_u16(), body: text }); + } + + let parsed: ChatResponse = + serde_json::from_str(&text).map_err(|e| LlmError::Deserialize(e.to_string()))?; + parsed + .choices + .into_iter() + .next() + .map(|c| c.message.content) + .ok_or(LlmError::EmptyResponse) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn parses_valid_chat_response() { + let raw = r#"{ + "id": "chatcmpl-xyz", + "object": "chat.completion", + "model": "llama-3.3-70b-versatile", + "choices": [ + { + "index": 0, + "message": { "role": "assistant", "content": "Hello there!" }, + "finish_reason": "stop" + } + ], + "usage": { "prompt_tokens": 5, "completion_tokens": 3, "total_tokens": 8 } + }"#; + + let parsed: ChatResponse = serde_json::from_str(raw).expect("valid JSON should parse"); + assert_eq!(parsed.choices.len(), 1); + assert_eq!(parsed.choices[0].message.content, "Hello there!"); + } + + #[test] + fn from_env_fails_when_token_missing() { + let prev = env::var(ENV_TOKEN).ok(); + unsafe { env::remove_var(ENV_TOKEN); } + let result = LlmClient::from_env(); + let err = match result { + Ok(_) => panic!("expected ConfigError when {ENV_TOKEN} is unset"), + Err(e) => e, + }; + match err { + ConfigError::MissingEnv(name) => assert_eq!(name, ENV_TOKEN), + } + if let Some(v) = prev { + unsafe { env::set_var(ENV_TOKEN, v); } + } + } + + #[test] + fn chat_message_helpers_set_role() { + assert_eq!(ChatMessage::user("hi").role, "user"); + assert_eq!(ChatMessage::system("ctx").role, "system"); + assert_eq!(ChatMessage::assistant("ok").role, "assistant"); + } +} diff --git a/crates/jarvis-core/src/llm/history.rs b/crates/jarvis-core/src/llm/history.rs new file mode 100644 index 0000000..7d2af8d --- /dev/null +++ b/crates/jarvis-core/src/llm/history.rs @@ -0,0 +1,138 @@ +use super::client::ChatMessage; + +pub struct ConversationHistory { + system: Option, + turns: Vec, + max_turns: usize, +} + +impl ConversationHistory { + pub fn new(system_prompt: impl Into, max_turns: usize) -> Self { + Self { + system: Some(ChatMessage::system(system_prompt)), + turns: Vec::new(), + max_turns: max_turns.max(1), + } + } + + pub fn without_system(max_turns: usize) -> Self { + Self { + system: None, + turns: Vec::new(), + max_turns: max_turns.max(1), + } + } + + pub fn push_user(&mut self, content: impl Into) { + self.turns.push(ChatMessage::user(content)); + self.truncate(); + } + + pub fn push_assistant(&mut self, content: impl Into) { + self.turns.push(ChatMessage::assistant(content)); + self.truncate(); + } + + pub fn snapshot(&self) -> Vec { + let mut out = Vec::with_capacity(self.turns.len() + 1); + if let Some(s) = &self.system { + out.push(s.clone()); + } + out.extend(self.turns.iter().cloned()); + out + } + + pub fn turns(&self) -> &[ChatMessage] { + &self.turns + } + + pub fn clear(&mut self) { + self.turns.clear(); + } + + pub fn pop_last_user(&mut self) -> Option { + if matches!(self.turns.last(), Some(m) if m.role == "user") { + self.turns.pop() + } else { + None + } + } + + fn truncate(&mut self) { + if self.turns.len() > self.max_turns { + let drop = self.turns.len() - self.max_turns; + self.turns.drain(0..drop); + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn evicts_oldest_turns_but_keeps_system_prompt() { + let mut h = ConversationHistory::new("you are jarvis", 4); + h.push_user("u1"); + h.push_assistant("a1"); + h.push_user("u2"); + h.push_assistant("a2"); + h.push_user("u3"); + + let snap = h.snapshot(); + assert_eq!(snap[0].role, "system"); + assert_eq!(snap[0].content, "you are jarvis"); + assert_eq!(snap.len(), 1 + 4); + + let contents: Vec<&str> = snap.iter().skip(1).map(|m| m.content.as_str()).collect(); + assert_eq!(contents, vec!["a1", "u2", "a2", "u3"]); + } + + #[test] + fn snapshot_with_no_system_returns_only_turns() { + let mut h = ConversationHistory::without_system(3); + h.push_user("hi"); + h.push_assistant("hello"); + let snap = h.snapshot(); + assert_eq!(snap.len(), 2); + assert_eq!(snap[0].role, "user"); + assert_eq!(snap[1].role, "assistant"); + } + + #[test] + fn cap_of_zero_is_clamped_to_one() { + let mut h = ConversationHistory::new("sys", 0); + h.push_user("a"); + h.push_user("b"); + let snap = h.snapshot(); + assert_eq!(snap.len(), 2); + assert_eq!(snap[0].role, "system"); + assert_eq!(snap[1].content, "b"); + } + + #[test] + fn pop_last_user_only_pops_a_trailing_user_turn() { + let mut h = ConversationHistory::new("sys", 4); + h.push_user("u1"); + h.push_assistant("a1"); + assert!(h.pop_last_user().is_none()); + h.push_user("u2"); + let popped = h.pop_last_user().expect("trailing user turn should pop"); + assert_eq!(popped.role, "user"); + assert_eq!(popped.content, "u2"); + let snap = h.snapshot(); + assert_eq!(snap.len(), 1 + 2); + assert_eq!(snap[2].content, "a1"); + } + + #[test] + fn clear_removes_turns_but_not_system() { + let mut h = ConversationHistory::new("sys", 4); + h.push_user("u"); + h.push_assistant("a"); + h.clear(); + let snap = h.snapshot(); + assert_eq!(snap.len(), 1); + assert_eq!(snap[0].role, "system"); + } +} diff --git a/crates/jarvis-core/src/llm/mod.rs b/crates/jarvis-core/src/llm/mod.rs new file mode 100644 index 0000000..e4ba4ee --- /dev/null +++ b/crates/jarvis-core/src/llm/mod.rs @@ -0,0 +1,8 @@ +mod client; +mod history; + +pub use client::{ + ChatMessage, ConfigError, LlmClient, LlmError, + DEFAULT_BASE_URL, DEFAULT_MODEL, ENV_BASE_URL, ENV_MODEL, ENV_TOKEN, +}; +pub use history::ConversationHistory; diff --git a/crates/jarvis-core/src/stt.rs b/crates/jarvis-core/src/stt.rs index 08429ba..db59dba 100644 --- a/crates/jarvis-core/src/stt.rs +++ b/crates/jarvis-core/src/stt.rs @@ -10,6 +10,7 @@ pub use self::vosk::recognize_wake_word; pub use self::vosk::recognize_speech; pub use self::vosk::reset_speech_recognizer; pub use self::vosk::reset_wake_recognizer; +pub use self::vosk::finalize_speech; static STT_TYPE: OnceCell = OnceCell::new(); diff --git a/crates/jarvis-core/src/stt/vosk.rs b/crates/jarvis-core/src/stt/vosk.rs index a6aa771..2e59206 100644 --- a/crates/jarvis-core/src/stt/vosk.rs +++ b/crates/jarvis-core/src/stt/vosk.rs @@ -97,6 +97,16 @@ pub fn reset_speech_recognizer() { } } +pub fn finalize_speech() -> Option { + let mut recognizer = SPEECH_RECOGNIZER.get()?.lock(); + let result = recognizer.final_result() + .multiple() + .and_then(|m| m.alternatives.first().map(|a| a.text.to_string())) + .filter(|s| !s.trim().is_empty()); + recognizer.reset(); + result +} + pub fn reset_wake_recognizer() { if let Some(recognizer) = WAKE_RECOGNIZER.get() { recognizer.lock().reset();