From d6792fc0386ade5c8aa53642980011f9ac055fbf Mon Sep 17 00:00:00 2001 From: Bossiara13 <236771060+DmitryBykov-ISPO@users.noreply.github.com> Date: Wed, 22 Apr 2026 19:36:06 +0300 Subject: [PATCH 01/14] chore: add thiserror to workspace deps Required by the new llm module's error enum. --- Cargo.lock | 1 + Cargo.toml | 1 + 2 files changed, 2 insertions(+) diff --git a/Cargo.lock b/Cargo.lock index 9361469..cd9980a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3324,6 +3324,7 @@ dependencies = [ "sha2", "sys-locale", "tempfile", + "thiserror 2.0.17", "tokenizers", "tokio", "tokio-tungstenite", diff --git a/Cargo.toml b/Cargo.toml index 19f2ce9..4d540c3 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" From 02f5829aaa5199d9909ae1f1c1021b3a7a4b4bf4 Mon Sep 17 00:00:00 2001 From: Bossiara13 <236771060+DmitryBykov-ISPO@users.noreply.github.com> Date: Wed, 22 Apr 2026 19:36:14 +0300 Subject: [PATCH 02/14] feat(core): add Groq/OpenAI-compatible LlmClient New jarvis-core::llm module providing a blocking client for OpenAI-compatible chat completions endpoints (Groq by default). - LlmClient::new / from_env (GROQ_TOKEN, GROQ_BASE_URL, GROQ_MODEL) - complete(messages, max_tokens) -> String - thiserror-based LlmError / ConfigError - gated behind a new llm feature, included in default jarvis_app Not yet wired into the wake-word/intent loop; that lands in v0.3. --- crates/jarvis-core/Cargo.toml | 6 +- crates/jarvis-core/src/lib.rs | 3 + crates/jarvis-core/src/llm.rs | 188 ++++++++++++++++++++++++++++++++++ 3 files changed, 195 insertions(+), 2 deletions(-) create mode 100644 crates/jarvis-core/src/llm.rs diff --git a/crates/jarvis-core/Cargo.toml b/crates/jarvis-core/Cargo.toml index 0033a9f..f315975 100644 --- a/crates/jarvis-core/Cargo.toml +++ b/crates/jarvis-core/Cargo.toml @@ -41,6 +41,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 +57,10 @@ winrt-notification = { workspace = true, optional = true } default = ["jarvis_app"] jarvis_app = [ "vosk", "intent-classifier", "fastembed", "tokio", "nnnoiseless", "tokio-tungstenite", "futures-util", - "lua", + "lua", "llm", "ort", "ndarray", "tokenizers", "regex",] 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/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.rs b/crates/jarvis-core/src/llm.rs new file mode 100644 index 0000000..7c0ce91 --- /dev/null +++ b/crates/jarvis-core/src/llm.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"); + } +} From 3d9a95a2dbbcd208f58e40e6c166e66f2ff0e6bd Mon Sep 17 00:00:00 2001 From: Bossiara13 <236771060+DmitryBykov-ISPO@users.noreply.github.com> Date: Wed, 22 Apr 2026 19:42:12 +0300 Subject: [PATCH 03/14] feat(cli): jarvis-cli ask via Groq One-shot invocation: jarvis-cli ask "..." reads GROQ_TOKEN, calls LlmClient::complete and prints the reply to stdout. Exits 2 when the env var is missing, 1 on API/HTTP failure. Also available as an 'ask' command inside the interactive REPL. --- crates/jarvis-cli/Cargo.toml | 2 +- crates/jarvis-cli/src/main.rs | 45 ++++++++++++++++++++++++++++++++++- 2 files changed, 45 insertions(+), 2 deletions(-) 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)"); } From 5b7a2d15d4129a726c718cf25c4bd3b86f778a56 Mon Sep 17 00:00:00 2001 From: Bossiara13 <236771060+DmitryBykov-ISPO@users.noreply.github.com> Date: Wed, 22 Apr 2026 19:42:34 +0300 Subject: [PATCH 04/14] =?UTF-8?q?docs:=20README=20=E2=80=94=20document=20L?= =?UTF-8?q?LM=20module=20and=20env=20vars?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a Russian-language section describing the new jarvis-core::llm module, the Groq-related environment variables (GROQ_TOKEN, GROQ_BASE_URL, GROQ_MODEL), the one-shot 'jarvis-cli ask' usage and a short Rust example. Notes that the module is intentionally not yet wired into the voice/intent loop — that lands in v0.3. --- README.md | 35 ++++++++++++++++++++++++++++++++++- 1 file changed, 34 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index a151dce..f6d6e08 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`.** Пока не подключён к wake-word/intent-циклу — только как библиотечный модуль и отдельная CLI-команда для проверки. Подключение в голосовой поток запланировано на v0.3. ## Это форк @@ -94,6 +94,39 @@ 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); +``` + +На текущем этапе (v0.2.x) модуль **не подключён** к wake-word-циклу, intent-классификации и Lua-скриптам — это только фундамент. Интеграция в голосовой поток — задача v0.3. + ## Лицензия Creative Commons **Attribution-NonCommercial-ShareAlike 4.0 International** (CC BY-NC-SA 4.0). From 40f2f8b5f9ea5425a8aeba3fbb9e73b50c9b5c6c Mon Sep 17 00:00:00 2001 From: Bossiara13 <236771060+DmitryBykov-ISPO@users.noreply.github.com> Date: Wed, 22 Apr 2026 23:16:25 +0300 Subject: [PATCH 05/14] feat(core): conversation history with bounded length split llm.rs into a module with separate client and history submodules. ConversationHistory holds an optional system prompt plus a FIFO of user/ assistant turns capped at max_turns; oldest turns evict on overflow, system prompt is preserved across truncation and clear(). --- .../jarvis-core/src/{llm.rs => llm/client.rs} | 0 crates/jarvis-core/src/llm/history.rs | 115 ++++++++++++++++++ crates/jarvis-core/src/llm/mod.rs | 8 ++ 3 files changed, 123 insertions(+) rename crates/jarvis-core/src/{llm.rs => llm/client.rs} (100%) create mode 100644 crates/jarvis-core/src/llm/history.rs create mode 100644 crates/jarvis-core/src/llm/mod.rs diff --git a/crates/jarvis-core/src/llm.rs b/crates/jarvis-core/src/llm/client.rs similarity index 100% rename from crates/jarvis-core/src/llm.rs rename to crates/jarvis-core/src/llm/client.rs diff --git a/crates/jarvis-core/src/llm/history.rs b/crates/jarvis-core/src/llm/history.rs new file mode 100644 index 0000000..522fc3b --- /dev/null +++ b/crates/jarvis-core/src/llm/history.rs @@ -0,0 +1,115 @@ +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(); + } + + 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 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; From 1e2c883e68355a5c15862d6a6ec19f9e4db63324 Mon Sep 17 00:00:00 2001 From: Bossiara13 <236771060+DmitryBykov-ISPO@users.noreply.github.com> Date: Wed, 22 Apr 2026 23:16:30 +0300 Subject: [PATCH 06/14] chore(core): llm config knobs, system prompt, trigger phrases MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit add LLM_DEFAULT_ENABLED / LLM_DEFAULT_MAX_HISTORY / LLM_DEFAULT_MAX_TOKENS defaults, the russian J.A.R.V.I.S. system prompt, a fallback error line, and per-language helpers get_llm_trigger_phrases() and get_llm_system_prompt() so the voice loop can opt into Groq with a 'скажи …' style prefix. --- crates/jarvis-core/src/config.rs | 27 +++++++++++++++++++++++++++ 1 file changed, 27 insertions(+) diff --git a/crates/jarvis-core/src/config.rs b/crates/jarvis-core/src/config.rs index d363366..4e525b1 100644 --- a/crates/jarvis-core/src/config.rs +++ b/crates/jarvis-core/src/config.rs @@ -187,6 +187,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] = [ // "джарвис", From 176f405310446bb4bc971bb558ace6d75e6b4534 Mon Sep 17 00:00:00 2001 From: Bossiara13 <236771060+DmitryBykov-ISPO@users.noreply.github.com> Date: Thu, 23 Apr 2026 02:15:41 +0300 Subject: [PATCH 07/14] feat(core): pop_last_user helper on conversation history used by llm fallback to roll back the user turn when the api call fails, so the next attempt does not leave a dangling user message in the history. --- crates/jarvis-core/src/llm/history.rs | 23 +++++++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/crates/jarvis-core/src/llm/history.rs b/crates/jarvis-core/src/llm/history.rs index 522fc3b..7d2af8d 100644 --- a/crates/jarvis-core/src/llm/history.rs +++ b/crates/jarvis-core/src/llm/history.rs @@ -50,6 +50,14 @@ impl ConversationHistory { 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; @@ -102,6 +110,21 @@ mod tests { 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); From e5aaa7e064d20b2e46533e7594befe8ec055c38a Mon Sep 17 00:00:00 2001 From: Bossiara13 <236771060+DmitryBykov-ISPO@users.noreply.github.com> Date: Thu, 23 Apr 2026 02:16:02 +0300 Subject: [PATCH 08/14] feat(app): wire LlmClient into voice loop with trigger-prefix fallback MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit new module crates/jarvis-app/src/llm_fallback.rs holds an optional LlmClient + ConversationHistory built once at startup. if GROQ_TOKEN is unset the module logs a warning and stays disabled — voice commands keep working as before. both the wake-word voice path (recognize_command in app.rs) and the gui-side text command path (process_text_command) now check whether the recognized phrase starts with one of the configured trigger phrases (ru: 'скажи' / 'ответь' / 'произнеси'). when it does, the remainder of the phrase is sent to Groq and the reply is published as a new IpcEvent::LlmReply { text } so the gui can speak it. on api error the trailing user turn is popped from history, the russian fallback line is sent over ipc and a 'error' voice cue plays. the loop itself never panics. --- crates/jarvis-app/src/app.rs | 27 +++++- crates/jarvis-app/src/llm_fallback.rs | 113 ++++++++++++++++++++++++++ crates/jarvis-app/src/main.rs | 4 + crates/jarvis-core/src/ipc/events.rs | 3 + 4 files changed, 144 insertions(+), 3 deletions(-) create mode 100644 crates/jarvis-app/src/llm_fallback.rs diff --git a/crates/jarvis-app/src/app.rs b/crates/jarvis-app/src/app.rs index cbc900f..3ead27e 100644 --- a/crates/jarvis-app/src/app.rs +++ b/crates/jarvis-app/src/app.rs @@ -250,7 +250,20 @@ 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(); + ipc::send(IpcEvent::Listening); + continue; + } + } + // filter activation phrases // for tbr in config::ASSISTANT_PHRASES_TBR { // recognized_voice = recognized_voice.replace(tbr, ""); @@ -317,9 +330,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-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 }, From 8a2b3f6206bcbdfe5429ee5d4adc3b3ae4d21705 Mon Sep 17 00:00:00 2001 From: Bossiara13 <236771060+DmitryBykov-ISPO@users.noreply.github.com> Date: Thu, 23 Apr 2026 02:16:07 +0300 Subject: [PATCH 09/14] =?UTF-8?q?docs:=20README=20=E2=80=94=20voice=20loop?= =?UTF-8?q?=20calls=20Groq=20for=20free-form=20questions?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit mark the v0.3 promise from the v0.2.x section as done and add a new subsection that documents the GROQ_TOKEN env var, trigger phrases, the LlmReply ipc event and the rollback-on-error behaviour. --- README.md | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index f6d6e08..9ee7e49 100644 --- a/README.md +++ b/README.md @@ -16,7 +16,7 @@ - nnnoiseless — подавление шума. - fluent / unic-langid — i18n (`ru`, `ua`, `en`). -**LLM-клиент (Groq / OpenAI-совместимый) добавлен в `jarvis-core::llm`.** Пока не подключён к wake-word/intent-циклу — только как библиотечный модуль и отдельная CLI-команда для проверки. Подключение в голосовой поток запланировано на v0.3. +**LLM-клиент (Groq / OpenAI-совместимый) добавлен в `jarvis-core::llm` и подключён к голосовому циклу.** Если фраза начинается с триггера («скажи», «ответь», «произнеси»), она уходит в Groq и ответ возвращается через IPC-событие `LlmReply`. Без триггера всё работает как раньше — wake-word + intent + Lua. Это следующий шаг после CLI-only LLM из v0.2.0. ## Это форк @@ -125,7 +125,17 @@ let reply = client.complete(&[ChatMessage::user("привет")], 256)?; println!("{}", reply); ``` -На текущем этапе (v0.2.x) модуль **не подключён** к wake-word-циклу, intent-классификации и Lua-скриптам — это только фундамент. Интеграция в голосовой поток — задача v0.3. +### Подключение к голосовому циклу + +Помимо 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`. ## Лицензия From 3f19366ad060b3cb118cfa55b42699a96accf4e7 Mon Sep 17 00:00:00 2001 From: Bossiara13 <236771060+DmitryBykov-ISPO@users.noreply.github.com> Date: Thu, 23 Apr 2026 11:07:04 +0300 Subject: [PATCH 10/14] chore: add webrtc-vad workspace dep --- Cargo.lock | 10 ++++++++++ Cargo.toml | 1 + crates/jarvis-core/Cargo.toml | 5 ++++- 3 files changed, 15 insertions(+), 1 deletion(-) diff --git a/Cargo.lock b/Cargo.lock index cd9980a..9a15428 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3331,6 +3331,7 @@ dependencies = [ "toml 0.9.11+spec-1.1.0", "unic-langid", "vosk", + "webrtc-vad", "winrt-notification", ] @@ -8598,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 4d540c3..d3ea1d2 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -53,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/crates/jarvis-core/Cargo.toml b/crates/jarvis-core/Cargo.toml index f315975..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 @@ -58,7 +59,9 @@ default = ["jarvis_app"] jarvis_app = [ "vosk", "intent-classifier", "fastembed", "tokio", "nnnoiseless", "tokio-tungstenite", "futures-util", "lua", "llm", - "ort", "ndarray", "tokenizers", "regex",] + "ort", "ndarray", "tokenizers", "regex", + "webrtc-vad", +] intent = ["intent-classifier", "tokio"] lua = ["mlua", "reqwest", "winrt-notification"] From 37c8371f3f856ad44f0b90001c6aaf41ba7582ba Mon Sep 17 00:00:00 2001 From: Bossiara13 <236771060+DmitryBykov-ISPO@users.noreply.github.com> Date: Thu, 23 Apr 2026 11:07:35 +0300 Subject: [PATCH 11/14] feat(core): vad-driven listening window state machine --- .../jarvis-core/src/audio_processing/vad.rs | 3 + .../src/audio_processing/vad/listen_window.rs | 79 +++++++++++++++++++ .../src/audio_processing/vad/webrtc.rs | 49 ++++++++++++ crates/jarvis-core/src/config.rs | 7 ++ crates/jarvis-core/src/stt.rs | 1 + crates/jarvis-core/src/stt/vosk.rs | 10 +++ 6 files changed, 149 insertions(+) create mode 100644 crates/jarvis-core/src/audio_processing/vad/listen_window.rs create mode 100644 crates/jarvis-core/src/audio_processing/vad/webrtc.rs 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..eb8c82c --- /dev/null +++ b/crates/jarvis-core/src/audio_processing/vad/listen_window.rs @@ -0,0 +1,79 @@ +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 } +} 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 4e525b1..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 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(); From a3389f8bf7cb19b879f68c6e535818a4953e2f3a Mon Sep 17 00:00:00 2001 From: Bossiara13 <236771060+DmitryBykov-ISPO@users.noreply.github.com> Date: Thu, 23 Apr 2026 11:07:42 +0300 Subject: [PATCH 12/14] feat(app): close post-wake listening on silence (webrtc-vad) --- crates/jarvis-app/src/app.rs | 59 ++++++++++++++++++++++++++++++------ 1 file changed, 49 insertions(+), 10 deletions(-) diff --git a/crates/jarvis-app/src/app.rs b/crates/jarvis-app/src/app.rs index 3ead27e..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 @@ -259,6 +294,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; } @@ -294,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 { From acebb02a8e1b09ed6d98154d0099180fd7ad5f03 Mon Sep 17 00:00:00 2001 From: Bossiara13 <236771060+DmitryBykov-ISPO@users.noreply.github.com> Date: Thu, 23 Apr 2026 11:08:02 +0300 Subject: [PATCH 13/14] test: vad listening window timing --- .../src/audio_processing/vad/listen_window.rs | 56 +++++++++++++++++++ 1 file changed, 56 insertions(+) diff --git a/crates/jarvis-core/src/audio_processing/vad/listen_window.rs b/crates/jarvis-core/src/audio_processing/vad/listen_window.rs index eb8c82c..ae7bc7a 100644 --- a/crates/jarvis-core/src/audio_processing/vad/listen_window.rs +++ b/crates/jarvis-core/src/audio_processing/vad/listen_window.rs @@ -77,3 +77,59 @@ impl ListenWindow { 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); + } +} From d23c7fbd123e705323bbae062f58a16bb75bfda6 Mon Sep 17 00:00:00 2001 From: Bossiara13 <236771060+DmitryBykov-ISPO@users.noreply.github.com> Date: Thu, 23 Apr 2026 11:08:09 +0300 Subject: [PATCH 14/14] =?UTF-8?q?docs:=20README=20=E2=80=94=20VAD=20smart?= =?UTF-8?q?=20command=20end?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 27 +++++++++++++++++++++++++++ 1 file changed, 27 insertions(+) diff --git a/README.md b/README.md index 9ee7e49..1fcb1c9 100644 --- a/README.md +++ b/README.md @@ -137,6 +137,33 @@ println!("{}", reply); Системный промпт (русский) описывает 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).