diff --git a/Cargo.lock b/Cargo.lock index 9a15428..cd9980a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3331,7 +3331,6 @@ dependencies = [ "toml 0.9.11+spec-1.1.0", "unic-langid", "vosk", - "webrtc-vad", "winrt-notification", ] @@ -8599,15 +8598,6 @@ 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 d3ea1d2..4d540c3 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -53,5 +53,4 @@ 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 1fcb1c9..f6d6e08 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` и подключён к голосовому циклу.** Если фраза начинается с триггера («скажи», «ответь», «произнеси»), она уходит в Groq и ответ возвращается через IPC-событие `LlmReply`. Без триггера всё работает как раньше — wake-word + intent + Lua. Это следующий шаг после CLI-only LLM из v0.2.0. +**LLM-клиент (Groq / OpenAI-совместимый) добавлен в `jarvis-core::llm`.** Пока не подключён к wake-word/intent-циклу — только как библиотечный модуль и отдельная CLI-команда для проверки. Подключение в голосовой поток запланировано на v0.3. ## Это форк @@ -125,44 +125,7 @@ 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). +На текущем этапе (v0.2.x) модуль **не подключён** к wake-word-циклу, intent-классификации и Lua-скриптам — это только фундамент. Интеграция в голосовой поток — задача v0.3. ## Лицензия diff --git a/crates/jarvis-app/src/app.rs b/crates/jarvis-app/src/app.rs index 492d4cc..cbc900f 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, 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 jarvis_core::{audio_buffer::AudioRingBuffer, audio_processing, commands, config, listener, recorder, stt, COMMANDS_LIST, intent, voices, ipc::{self, IpcEvent}, i18n, slots}; use rand::seq::SliceRandom; use crate::should_stop; @@ -159,10 +159,9 @@ fn recognize_command( let mut silence_frames: u32 = 0; let mut start = SystemTime::now(); let mut first_recognition = prefed_audio; - - let mut webrtc_vad = WebRtcVad::with_aggressiveness(config::VAD_AGGRESSIVENESS); - let mut window = ListenWindow::new(jarvis_core::audio_processing::vad::webrtc::FRAME_MS); - + + // longer silence threshold for commands (user might pause to think) + // 5 seconds let silence_threshold: u32 = ((5.0 * sample_rate as f32) / frame_length as f32) as u32; loop { @@ -172,40 +171,11 @@ 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() { @@ -215,7 +185,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; @@ -224,9 +194,8 @@ fn recognize_command( } VadState::VoiceActive => { - // 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 { + // feed to STT + if let Some(mut recognized_voice) = stt::recognize(frame_buffer, false) { info!("Recognized voice: {}", recognized_voice); ipc::send(IpcEvent::SpeechRecognized { @@ -258,8 +227,6 @@ 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; } @@ -268,13 +235,11 @@ 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 @@ -285,22 +250,7 @@ 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, ""); @@ -331,8 +281,6 @@ 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 { @@ -369,17 +317,9 @@ 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 deleted file mode 100644 index 4d8a9f6..0000000 --- a/crates/jarvis-app/src/llm_fallback.rs +++ /dev/null @@ -1,113 +0,0 @@ -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 f704a70..7a40397 100644 --- a/crates/jarvis-app/src/main.rs +++ b/crates/jarvis-app/src/main.rs @@ -18,7 +18,6 @@ mod log; // include app mod app; -mod llm_fallback; // include tray // @TODO. macOS currently not supported for tray functionality. @@ -56,9 +55,6 @@ 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/Cargo.toml b/crates/jarvis-core/Cargo.toml index 3c908e4..f315975 100644 --- a/crates/jarvis-core/Cargo.toml +++ b/crates/jarvis-core/Cargo.toml @@ -24,7 +24,6 @@ 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 @@ -59,9 +58,7 @@ default = ["jarvis_app"] jarvis_app = [ "vosk", "intent-classifier", "fastembed", "tokio", "nnnoiseless", "tokio-tungstenite", "futures-util", "lua", "llm", - "ort", "ndarray", "tokenizers", "regex", - "webrtc-vad", -] + "ort", "ndarray", "tokenizers", "regex",] intent = ["intent-classifier", "tokio"] lua = ["mlua", "reqwest", "winrt-notification"] diff --git a/crates/jarvis-core/src/audio_processing/vad.rs b/crates/jarvis-core/src/audio_processing/vad.rs index ba86c1a..a2b57c5 100644 --- a/crates/jarvis-core/src/audio_processing/vad.rs +++ b/crates/jarvis-core/src/audio_processing/vad.rs @@ -1,8 +1,5 @@ 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 deleted file mode 100644 index ae7bc7a..0000000 --- a/crates/jarvis-core/src/audio_processing/vad/listen_window.rs +++ /dev/null @@ -1,135 +0,0 @@ -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 deleted file mode 100644 index 28e09f8..0000000 --- a/crates/jarvis-core/src/audio_processing/vad/webrtc.rs +++ /dev/null @@ -1,49 +0,0 @@ -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 f7b55a4..d363366 100644 --- a/crates/jarvis-core/src/config.rs +++ b/crates/jarvis-core/src/config.rs @@ -171,13 +171,6 @@ 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 @@ -194,33 +187,6 @@ 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 7f76f72..e372d40 100644 --- a/crates/jarvis-core/src/ipc/events.rs +++ b/crates/jarvis-core/src/ipc/events.rs @@ -12,9 +12,6 @@ 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/llm/client.rs b/crates/jarvis-core/src/llm.rs similarity index 100% rename from crates/jarvis-core/src/llm/client.rs rename to crates/jarvis-core/src/llm.rs diff --git a/crates/jarvis-core/src/llm/history.rs b/crates/jarvis-core/src/llm/history.rs deleted file mode 100644 index 7d2af8d..0000000 --- a/crates/jarvis-core/src/llm/history.rs +++ /dev/null @@ -1,138 +0,0 @@ -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 deleted file mode 100644 index e4ba4ee..0000000 --- a/crates/jarvis-core/src/llm/mod.rs +++ /dev/null @@ -1,8 +0,0 @@ -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 db59dba..08429ba 100644 --- a/crates/jarvis-core/src/stt.rs +++ b/crates/jarvis-core/src/stt.rs @@ -10,7 +10,6 @@ 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 2e59206..a6aa771 100644 --- a/crates/jarvis-core/src/stt/vosk.rs +++ b/crates/jarvis-core/src/stt/vosk.rs @@ -97,16 +97,6 @@ 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();