Compare commits

...

20 commits

Author SHA1 Message Date
Bossiara13
1b055fa08c Release v0.4.0 — VAD smart command end (parity with Python v0.2.0) 2026-04-23 11:10:12 +03:00
Bossiara13
9a1c047282 Merge feature/vad-rust into dev
Adds webrtc-vad-driven listening window for the post-wake-word phase.
State machine in jarvis-core::audio_processing::vad::listen_window;
adapter in vad::webrtc; integrated in app.rs::recognize_command and
stt::vosk::finalize_speech. Same parameters as Python v0.2.0
(aggressiveness 2, end-silence 1200ms, min-speech 500ms,
min-listen 1000ms, hard-cap 15000ms).
2026-04-23 11:10:06 +03:00
Bossiara13
d23c7fbd12 docs: README — VAD smart command end 2026-04-23 11:08:09 +03:00
Bossiara13
acebb02a8e test: vad listening window timing 2026-04-23 11:08:02 +03:00
Bossiara13
a3389f8bf7 feat(app): close post-wake listening on silence (webrtc-vad) 2026-04-23 11:07:42 +03:00
Bossiara13
37c8371f3f feat(core): vad-driven listening window state machine 2026-04-23 11:07:35 +03:00
Bossiara13
3f19366ad0 chore: add webrtc-vad workspace dep 2026-04-23 11:07:04 +03:00
Bossiara13
ff70e6019f Release v0.3.0 — Groq LLM wired into voice loop 2026-04-23 02:18:21 +03:00
Bossiara13
3429d934e3 Merge feature/groq-in-voice-loop into dev
Wires LlmClient into the voice loop with a trigger-prefix pattern
(скажи / ответь / произнеси). Conversation history bounded at 8
turns, system prompt always preserved. LlmReply published via IPC;
pre-recorded play_ok cue accompanies the text reply. On error,
plays play_error and sends russian fallback line over IPC.
2026-04-23 02:18:09 +03:00
Bossiara13
8a2b3f6206 docs: README — voice loop calls Groq for free-form questions
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.
2026-04-23 02:16:07 +03:00
Bossiara13
e5aaa7e064 feat(app): wire LlmClient into voice loop with trigger-prefix fallback
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.
2026-04-23 02:16:02 +03:00
Bossiara13
176f405310 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.
2026-04-23 02:15:41 +03:00
Bossiara13
1e2c883e68 chore(core): llm config knobs, system prompt, trigger phrases
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.
2026-04-22 23:16:30 +03:00
Bossiara13
40f2f8b5f9 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().
2026-04-22 23:16:25 +03:00
Bossiara13
b2045cf950 Release v0.2.0 — Groq LLM client available (CLI only, not yet in voice loop) 2026-04-22 19:44:15 +03:00
Bossiara13
23c57b855b Merge feature/groq-llm-rust into dev
Adds Groq (OpenAI-compatible) LlmClient in jarvis-core behind the
new 'llm' cargo feature, plus a jarvis-cli 'ask <prompt>' subcommand
for one-shot testing. Not yet wired into the wake-word/intent loop —
that's the next feature. thiserror added to workspace deps.
2026-04-22 19:44:09 +03:00
Bossiara13
5b7a2d15d4 docs: README — document LLM module and env vars
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.
2026-04-22 19:42:34 +03:00
Bossiara13
3d9a95a2db feat(cli): jarvis-cli ask <prompt> 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.
2026-04-22 19:42:12 +03:00
Bossiara13
02f5829aaa 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.
2026-04-22 19:36:14 +03:00
Bossiara13
d6792fc038 chore: add thiserror to workspace deps
Required by the new llm module's error enum.
2026-04-22 19:36:06 +03:00
20 changed files with 899 additions and 19 deletions

11
Cargo.lock generated
View file

@ -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"

View file

@ -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"

View file

@ -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. как британского дворецкого Тони Старка — короткие реплики (13 предложения), обращение «сэр», без излишней цензуры. Меняется в `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).

View file

@ -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;
@ -160,8 +160,9 @@ fn recognize_command(
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 {
@ -172,6 +173,35 @@ fn recognize_command(
recorder::read_microphone(frame_buffer);
let processed = audio_processing::process(frame_buffer);
let mut vad_finalized: Option<String> = 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);
@ -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;
}
@ -240,6 +273,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;
} else {
// wake word + command in one phrase - execute the command part
@ -251,6 +286,21 @@ 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 {
@ -320,6 +372,14 @@ fn process_text_command(text: &str, rt: &tokio::runtime::Runtime) {
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, "");

View file

@ -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<ConversationHistory>,
max_tokens: u32,
}
static STATE: OnceCell<Option<State>> = OnceCell::new();
pub fn init() {
let _ = STATE.set(build_state());
}
fn build_state() -> Option<State> {
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<String> {
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<String> {
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<ChatMessage> = {
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();
}
}
}

View file

@ -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);

View file

@ -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"

View file

@ -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 <text> - Test intent classification
execute <text> - Simulate voice input and execute command
ask <prompt> - Send prompt to Groq LLM and print reply
list - List all loaded commands
phrases - List all training phrases
hash - Show commands hash
@ -92,8 +96,40 @@ 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<dyn std::error::Error>> {
let argv: Vec<String> = std::env::args().collect();
if argv.len() >= 2 && argv[1] == "ask" {
if argv.len() < 3 {
eprintln!("Usage: jarvis-cli ask <prompt>");
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")
@ -189,6 +225,13 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
execute_text(COMMANDS_LIST.get().unwrap(), arg).await;
}
}
"ask" | "a" => {
if arg.is_empty() {
println!(" Usage: ask <prompt>");
} else {
ask_llm(arg);
}
}
"reload" => {
println!(" Note: Reload requires app restart (statics can't be reset)");
}

View file

@ -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"]
llm = ["reqwest", "thiserror"]

View file

@ -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;

View file

@ -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<bool> = std::iter::repeat(true).take(40).collect();
let silence: Vec<bool> = 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<bool> = 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<bool> = 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<bool> = std::iter::repeat(false).take(600).collect();
let (decision, _) = run(&v);
assert_eq!(decision, WindowDecision::HardCap);
}
}

View file

@ -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<i16>,
}
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<bool> {
self.buf.extend_from_slice(samples);
let mut out = Vec::new();
while self.buf.len() >= FRAME_SAMPLES {
let frame: Vec<i16> = 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() }
}

View file

@ -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] = [
// "джарвис",

View file

@ -13,6 +13,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 },

View file

@ -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<PathBuf> = Lazy::new(|| std::env::current_dir().unwrap());
pub static APP_DIR: Lazy<PathBuf> = Lazy::new(|| {

View file

@ -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<String>) -> Self {
Self { role: "user".to_string(), content: content.into() }
}
pub fn system(content: impl Into<String>) -> Self {
Self { role: "system".to_string(), content: content.into() }
}
pub fn assistant(content: impl Into<String>) -> 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<Choice>,
}
#[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<String>,
api_key: impl Into<String>,
model: impl Into<String>,
) -> 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<Self, ConfigError> {
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<String, LlmError> {
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");
}
}

View file

@ -0,0 +1,138 @@
use super::client::ChatMessage;
pub struct ConversationHistory {
system: Option<ChatMessage>,
turns: Vec<ChatMessage>,
max_turns: usize,
}
impl ConversationHistory {
pub fn new(system_prompt: impl Into<String>, 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<String>) {
self.turns.push(ChatMessage::user(content));
self.truncate();
}
pub fn push_assistant(&mut self, content: impl Into<String>) {
self.turns.push(ChatMessage::assistant(content));
self.truncate();
}
pub fn snapshot(&self) -> Vec<ChatMessage> {
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<ChatMessage> {
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");
}
}

View file

@ -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;

View file

@ -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<SpeechToTextEngine> = OnceCell::new();

View file

@ -97,6 +97,16 @@ pub fn reset_speech_recognizer() {
}
}
pub fn finalize_speech() -> Option<String> {
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();