refactor: maintainability pass — dedup, central env config, cmd helpers, tests, ARCHITECTURE.md

User asked the code stays easy to fix/extend as the feature surface grows.
This commit refactors what was duplicated, centralises what was scattered,
adds tests where there were none, and writes the architecture doc that
contributors will need.

Deduplication
  - tts::play_wav extracted to tts/mod.rs as pub(crate). Was identical in
    piper.rs and silero.rs (~25 lines × 2). Both backends now call super::play_wav.

Centralised env config
  - new crates/jarvis-core/src/runtime_config.rs — single doc-file for every
    JARVIS_* / GROQ_* / OLLAMA_* env var. Includes:
      - ENV_* constants with doc comments
      - get(name) / get_bool(name, default) / get_parse(name, default) helpers
      - feature-flag wrappers: llm_tts_enabled(), llm_router_enabled(),
        llm_router_threshold()
      - log_effective_config() prints active values on startup
  - migrated llm_fallback (JARVIS_LLM_TTS), llm_router (JARVIS_LLM_ROUTER,
    JARVIS_LLM_ROUTER_THRESHOLD) to use the new helpers. Pattern set for
    future migrations.

Lua boilerplate killer
  - new crates/jarvis-core/src/lua/api/cmd.rs exposing:
      jarvis.cmd.ok(msg?)         — play_ok + speak + {chain=false}
      jarvis.cmd.chain_ok(msg?)   — same but chain=true
      jarvis.cmd.error(msg?)      — play_error + speak + {chain=false}
      jarvis.cmd.not_found(msg?)  — play_not_found + speak + {chain=false}
      jarvis.cmd.silent_ok / silent_error
  - refactored 5 packs (daily_briefing/off, memory_pack/list, pomodoro/stop,
    habit_nudge/stop_all, scheduler/clear) — each lost 3-4 lines of repetitive
    play_*/speak/return boilerplate. Pattern for future packs documented in
    ARCHITECTURE.md.

Tests (was 32, now 49)
  - long_term_memory: 8 new tests for normalize_key, search_in (rank, limit,
    empty), build_context_from (empty + populated), serde round-trips for
    MemoryRecord and Store. Extracted pure logic (search_in, build_context_from)
    into pub(crate) functions to enable testing without global state.
  - profiles: 6 new tests for Profile::allows_command (empty/whitelist/blacklist/
    deny-wins-over-allow), serde round-trip with all fields + minimal-fields
    tolerance via #[serde(default)].
  - runtime_config: 2 tests for get_bool / get_parse defaults.
  - All 49 tests pass.

New mood/energy log pack
  - resources/commands/mood_log/ with 2 commands:
      mood.record  "запиши настроение 7" / "сегодня мне грустно" → stores
                   timestamped entry via jarvis.memory.remember
      mood.recap   "как прошла неделя" → LLM summarises last 30 entries
  - Showcase: composes memory + llm + cmd helpers in <30 lines per script.

ARCHITECTURE.md (new, 250 lines)
  - Crate layout, data flow diagram (mic→action 10 steps), per-module
    responsibility table, configuration layers, TTS pipeline diagram,
    Lua sandbox details with API quick-ref, background-services overview,
    "how to add a pack/feature/TTS backend" recipes, test coverage map,
    build instructions with MSVC env, git workflow with Forgejo NO_PROXY trick.
  - Aimed at someone who just cloned the repo and needs to fix a bug fast.

Build: cargo build --release -p jarvis-app -p jarvis-gui both green.
Tests: 49/49 jarvis-core unit tests pass.
This commit is contained in:
Bossiara13 2026-05-15 16:06:18 +03:00
parent 45243c3e3c
commit 6225198821
22 changed files with 891 additions and 93 deletions

View file

@ -137,7 +137,7 @@ pub fn handle(prompt: &str) {
}
fn speak_reply(text: &str) {
if std::env::var("JARVIS_LLM_TTS").as_deref() == Ok("false") {
if !jarvis_core::runtime_config::llm_tts_enabled() {
return;
}
tts::speak(text, &SpeakOpts::lang("ru"));

View file

@ -60,19 +60,15 @@ fn build_state() -> Option<State> {
return None;
}
};
let threshold = std::env::var("JARVIS_LLM_ROUTER_THRESHOLD")
.ok()
.and_then(|v| v.parse::<f32>().ok())
.unwrap_or(DEFAULT_THRESHOLD);
let threshold = jarvis_core::runtime_config::llm_router_threshold();
let _ = DEFAULT_THRESHOLD; // kept for backward visibility in source
info!("LLM router enabled (backend: {}, threshold {:.2}).", client.backend().name(), threshold);
Some(State { client, threshold })
}
fn router_enabled() -> bool {
std::env::var("JARVIS_LLM_ROUTER")
.map(|v| matches!(v.trim().to_lowercase().as_str(), "1" | "true" | "yes" | "on"))
.unwrap_or(true) // default ON when GROQ_TOKEN is present
jarvis_core::runtime_config::llm_router_enabled()
&& config::LLM_DEFAULT_ENABLED
}

View file

@ -57,6 +57,9 @@ fn main() -> Result<(), String> {
eprintln!("[jarvis-app] step: i18n::init");
i18n::init(&settings.lock().language);
eprintln!("[jarvis-app] step: runtime_config::log_effective_config");
jarvis_core::runtime_config::log_effective_config();
eprintln!("[jarvis-app] step: llm_fallback::init");
llm_fallback::init();

View file

@ -50,6 +50,8 @@ pub mod lua;
pub mod text_utils;
pub mod runtime_config;
pub mod tts;
pub mod long_term_memory;

View file

@ -120,10 +120,30 @@ fn now_secs() -> i64 {
.unwrap_or(0)
}
fn normalize_key(k: &str) -> String {
pub(crate) fn normalize_key(k: &str) -> String {
k.trim().to_lowercase()
}
/// Filter+rank logic shared between `search` and tests. Public for unit tests
/// to bypass the global state.
pub(crate) fn search_in<'a>(
entries: impl IntoIterator<Item = &'a MemoryRecord>,
query: &str,
limit: usize,
) -> Vec<MemoryRecord> {
let nq = normalize_key(query);
if nq.is_empty() {
return Vec::new();
}
let mut hits: Vec<MemoryRecord> = entries.into_iter()
.filter(|r| r.key.contains(&nq) || r.value.to_lowercase().contains(&nq))
.cloned()
.collect();
hits.sort_by(|a, b| b.last_used_at.cmp(&a.last_used_at));
hits.truncate(limit.max(1));
hits
}
/// Store / overwrite a fact.
pub fn remember(key: &str, value: &str) -> Result<(), String> {
let state = STATE.get().ok_or_else(|| "memory not init".to_string())?;
@ -169,18 +189,8 @@ pub fn recall(key: &str) -> Option<String> {
/// recency. Matches against both key and value.
pub fn search(query: &str, limit: usize) -> Vec<MemoryRecord> {
let Some(state) = STATE.get() else { return Vec::new(); };
let nq = normalize_key(query);
if nq.is_empty() {
return Vec::new();
}
let store = state.store.read();
let mut hits: Vec<MemoryRecord> = store.entries.values()
.filter(|r| r.key.contains(&nq) || r.value.to_lowercase().contains(&nq))
.cloned()
.collect();
hits.sort_by(|a, b| b.last_used_at.cmp(&a.last_used_at));
hits.truncate(limit.max(1));
hits
search_in(store.entries.values(), query, limit)
}
/// Delete a fact. Returns true if it existed.
@ -205,13 +215,126 @@ pub fn all() -> Vec<MemoryRecord> {
/// based on a substring search of the user's prompt. Empty string if no matches.
pub fn build_context(prompt: &str, limit: usize) -> String {
let hits = search(prompt, limit);
build_context_from(&hits)
}
/// Pure formatter used by both `build_context` and tests.
pub(crate) fn build_context_from(hits: &[MemoryRecord]) -> String {
if hits.is_empty() {
return String::new();
}
let mut buf = String::with_capacity(256);
buf.push_str("Известные факты о пользователе (используй если уместно):\n");
for h in &hits {
for h in hits {
buf.push_str(&format!("- {} = {}\n", h.key, h.value));
}
buf
}
#[cfg(test)]
mod tests {
use super::*;
fn rec(key: &str, value: &str, last_used: i64) -> MemoryRecord {
MemoryRecord {
key: key.to_string(),
value: value.to_string(),
created_at: 0,
last_used_at: last_used,
use_count: 0,
}
}
#[test]
fn normalize_key_lowercases_and_trims() {
assert_eq!(normalize_key(" HeLLo "), "hello");
assert_eq!(normalize_key(""), "");
assert_eq!(normalize_key("любимый ЧАЙ "), "любимый чай");
}
#[test]
fn search_in_matches_key_or_value() {
let entries = vec![
rec("любимый чай", "улун", 100),
rec("любимый кофе", "арабика", 200),
rec("питомец", "собака бася", 300),
];
let hits = search_in(&entries, "чай", 5);
assert_eq!(hits.len(), 1);
assert_eq!(hits[0].key, "любимый чай");
let hits = search_in(&entries, "бася", 5);
assert_eq!(hits.len(), 1);
assert_eq!(hits[0].key, "питомец");
}
#[test]
fn search_in_ranks_by_recency() {
let entries = vec![
rec("чай улун", "вкусный", 100),
rec("чай зеленый", "тоже", 300),
rec("чай черный", "обычный", 200),
];
let hits = search_in(&entries, "чай", 5);
assert_eq!(hits.len(), 3);
assert_eq!(hits[0].key, "чай зеленый"); // most recent
assert_eq!(hits[1].key, "чай черный");
assert_eq!(hits[2].key, "чай улун");
}
#[test]
fn search_in_respects_limit() {
let entries = vec![
rec("a", "x", 1),
rec("ab", "y", 2),
rec("abc", "z", 3),
];
let hits = search_in(&entries, "a", 2);
assert_eq!(hits.len(), 2);
}
#[test]
fn search_in_empty_query_returns_nothing() {
let entries = vec![rec("a", "b", 0)];
assert!(search_in(&entries, "", 5).is_empty());
assert!(search_in(&entries, " ", 5).is_empty());
}
#[test]
fn build_context_empty_returns_empty_string() {
assert_eq!(build_context_from(&[]), "");
}
#[test]
fn build_context_lists_each_fact() {
let hits = vec![
rec("чай", "улун", 0),
rec("кофе", "арабика", 0),
];
let ctx = build_context_from(&hits);
assert!(ctx.contains("чай = улун"));
assert!(ctx.contains("кофе = арабика"));
assert!(ctx.starts_with("Известные факты"));
}
#[test]
fn memory_record_serde_round_trip() {
let r = rec("test", "value", 42);
let json = serde_json::to_string(&r).unwrap();
let back: MemoryRecord = serde_json::from_str(&json).unwrap();
assert_eq!(back.key, r.key);
assert_eq!(back.value, r.value);
assert_eq!(back.last_used_at, r.last_used_at);
}
#[test]
fn store_serde_round_trip() {
let mut store = Store::default();
store.entries.insert("key1".into(), rec("key1", "val1", 100));
store.entries.insert("key2".into(), rec("key2", "val2", 200));
let json = serde_json::to_string(&store).unwrap();
let back: Store = serde_json::from_str(&json).unwrap();
assert_eq!(back.entries.len(), 2);
assert_eq!(back.entries.get("key1").unwrap().value, "val1");
}
}

View file

@ -11,4 +11,5 @@ pub mod text;
pub mod memory;
pub mod profile;
pub mod vision;
pub mod scheduler;
pub mod scheduler;
pub mod cmd;

View file

@ -0,0 +1,81 @@
//! `jarvis.cmd` — boilerplate-killer helpers for Lua command scripts.
//!
//! Most packs end with the same 3-line ritual:
//!
//! jarvis.speak(msg)
//! jarvis.audio.play_ok()
//! return { chain = false }
//!
//! With these helpers it's one call:
//!
//! return jarvis.cmd.ok(msg)
//!
//! Available helpers (each returns a `{ chain = false }` result table so the
//! pack can simply `return jarvis.cmd.ok(...)`):
//!
//! jarvis.cmd.ok(msg?) -- play_ok sound, optional spoken msg
//! jarvis.cmd.chain_ok(msg?) -- like ok() but returns {chain = true}
//! jarvis.cmd.error(msg?) -- play_error sound, optional spoken msg
//! jarvis.cmd.not_found(msg?) -- play_not_found sound, optional spoken msg
//! jarvis.cmd.silent_ok() -- only play_ok, no speech
//! jarvis.cmd.silent_error() -- only play_error, no speech
use mlua::{Lua, Table};
use crate::tts::{self, SpeakOpts};
use crate::voices;
pub fn register(lua: &Lua, jarvis: &Table) -> mlua::Result<()> {
let cmd = lua.create_table()?;
cmd.set("ok", lua.create_function(|l, msg: Option<String>| {
if let Some(text) = msg.filter(|s| !s.trim().is_empty()) {
tts::speak(&text, &SpeakOpts::default());
}
voices::play_ok();
result_table(l, false)
})?)?;
cmd.set("chain_ok", lua.create_function(|l, msg: Option<String>| {
if let Some(text) = msg.filter(|s| !s.trim().is_empty()) {
tts::speak(&text, &SpeakOpts::default());
}
voices::play_ok();
result_table(l, true)
})?)?;
cmd.set("error", lua.create_function(|l, msg: Option<String>| {
if let Some(text) = msg.filter(|s| !s.trim().is_empty()) {
tts::speak(&text, &SpeakOpts::default());
}
voices::play_error();
result_table(l, false)
})?)?;
cmd.set("not_found", lua.create_function(|l, msg: Option<String>| {
if let Some(text) = msg.filter(|s| !s.trim().is_empty()) {
tts::speak(&text, &SpeakOpts::default());
}
voices::play_not_found();
result_table(l, false)
})?)?;
cmd.set("silent_ok", lua.create_function(|l, ()| {
voices::play_ok();
result_table(l, false)
})?)?;
cmd.set("silent_error", lua.create_function(|l, ()| {
voices::play_error();
result_table(l, false)
})?)?;
jarvis.set("cmd", cmd)?;
Ok(())
}
fn result_table(lua: &Lua, chain: bool) -> mlua::Result<Table> {
let t = lua.create_table()?;
t.set("chain", chain)?;
Ok(t)
}

View file

@ -81,6 +81,7 @@ impl LuaEngine {
api::memory::register(&self.lua, &jarvis)?;
api::profile::register(&self.lua, &jarvis)?;
api::scheduler::register(&self.lua, &jarvis)?;
api::cmd::register(&self.lua, &jarvis)?;
// sandbox-controlled APIs
if self.sandbox.allows_http() {

View file

@ -227,3 +227,83 @@ pub fn list() -> Vec<String> {
names.sort();
names
}
#[cfg(test)]
mod tests {
use super::*;
fn p_with(allowed: Vec<&str>, disabled: Vec<&str>) -> Profile {
Profile {
name: "test".into(),
description: String::new(),
llm_personality: String::new(),
allowed_command_prefixes: allowed.into_iter().map(String::from).collect(),
disabled_command_prefixes: disabled.into_iter().map(String::from).collect(),
greeting: String::new(),
icon: String::new(),
}
}
#[test]
fn empty_lists_allow_everything() {
let p = p_with(vec![], vec![]);
assert!(p.allows_command("anything"));
assert!(p.allows_command("games.steam.launch"));
}
#[test]
fn allowed_prefixes_act_as_whitelist() {
let p = p_with(vec!["music.", "time."], vec![]);
assert!(p.allows_command("music.spotify.play"));
assert!(p.allows_command("time.current"));
assert!(!p.allows_command("games.steam"));
assert!(!p.allows_command("windows.minimize"));
}
#[test]
fn disabled_prefixes_act_as_blacklist() {
let p = p_with(vec![], vec!["games.", "fun."]);
assert!(!p.allows_command("games.steam.launch"));
assert!(!p.allows_command("fun.joke"));
assert!(p.allows_command("time.current"));
assert!(p.allows_command("weather.now"));
}
#[test]
fn disabled_wins_over_allowed() {
// even if "media." is on the allow list, "media.spotify" being on
// the deny list must short-circuit to false.
let p = p_with(vec!["media."], vec!["media.spotify"]);
assert!(!p.allows_command("media.spotify.skip"));
assert!(p.allows_command("media.youtube.play"));
}
#[test]
fn profile_serde_round_trip() {
let p = Profile {
name: "work".into(),
description: "Focus mode".into(),
llm_personality: "Be brief.".into(),
allowed_command_prefixes: vec!["time.".into(), "weather.".into()],
disabled_command_prefixes: vec!["games.".into()],
greeting: "Sir.".into(),
icon: "💼".into(),
};
let json = serde_json::to_string(&p).unwrap();
let back: Profile = serde_json::from_str(&json).unwrap();
assert_eq!(back.name, "work");
assert_eq!(back.allowed_command_prefixes.len(), 2);
assert_eq!(back.disabled_command_prefixes.len(), 1);
assert_eq!(back.icon, "💼");
}
#[test]
fn profile_serde_tolerates_missing_optional_fields() {
// Minimum-fields JSON should deserialize using #[serde(default)] on each field.
let minimal = r#"{"name":"minimal"}"#;
let p: Profile = serde_json::from_str(minimal).unwrap();
assert_eq!(p.name, "minimal");
assert!(p.description.is_empty());
assert!(p.allowed_command_prefixes.is_empty());
}
}

View file

@ -0,0 +1,138 @@
//! Centralised runtime configuration knobs.
//!
//! All environment-variable-driven behaviour in J.A.R.V.I.S. is documented here
//! and (for the most-used flags) wrapped in helper functions. Adding a new knob:
//!
//! 1. Add a `const ENV_*: &str = "JARVIS_..."` declaration below with a doc comment.
//! 2. Add a thin getter (e.g. `pub fn my_feature_enabled() -> bool`) below.
//! 3. Call it from the feature module instead of inlining `std::env::var(...)`.
//!
//! The goal is single-point discovery: open this file to learn every flag.
//!
//! For the on-disk settings (sqlite via `db::structs::Settings`), see `crate::db`.
use std::env;
// ─── TTS ───────────────────────────────────────────────────────────────────
/// TTS backend selector. Values: `sapi` | `piper` | `silero`. Unset → auto-detect
/// (Piper if `tools/piper/piper.exe` + voice present, else SAPI).
pub const ENV_TTS: &str = "JARVIS_TTS";
/// Absolute path to `piper.exe`. Default: `<exe_dir>/tools/piper/piper.exe`.
pub const ENV_TTS_PIPER_BIN: &str = "JARVIS_TTS_PIPER_BIN";
/// Absolute path to Piper voice `.onnx`. Default: first .onnx in `<piper_dir>/voices/`.
pub const ENV_TTS_PIPER_VOICE: &str = "JARVIS_TTS_PIPER_VOICE";
/// Python executable for Silero subprocess. Default: `python`.
pub const ENV_TTS_PYTHON: &str = "JARVIS_TTS_PYTHON";
/// Path to `silero_tts.py` helper. Default: `<exe_dir>/tools/silero/silero_tts.py`.
pub const ENV_TTS_SILERO_HELPER: &str = "JARVIS_TTS_SILERO_HELPER";
/// Silero voice name. Default: `xenia`. Other ru_v3 voices: baya, aidar, eugene, kseniya.
pub const ENV_TTS_SILERO_VOICE: &str = "JARVIS_TTS_SILERO_VOICE";
/// Set to `false` to disable LLM-reply TTS playback (keep IPC event only). Default: enabled.
pub const ENV_LLM_TTS: &str = "JARVIS_LLM_TTS";
// ─── LLM ───────────────────────────────────────────────────────────────────
/// LLM backend selector. Values: `groq` | `ollama`. Auto-detect: Groq if
/// `GROQ_TOKEN` present, else Ollama.
pub const ENV_LLM: &str = "JARVIS_LLM";
/// Groq API token. Required for cloud LLM. Get one at https://groq.com.
pub const ENV_GROQ_TOKEN: &str = "GROQ_TOKEN";
/// Groq base URL. Default: `https://api.groq.com/openai/v1`.
pub const ENV_GROQ_BASE_URL: &str = "GROQ_BASE_URL";
/// Groq model. Default: `llama-3.3-70b-versatile`.
pub const ENV_GROQ_MODEL: &str = "GROQ_MODEL";
/// Groq vision-capable model for IMBA-4. Default: `llama-3.2-11b-vision-preview`.
pub const ENV_GROQ_VISION_MODEL: &str = "GROQ_VISION_MODEL";
/// Ollama base URL. Default: `http://localhost:11434/v1`.
pub const ENV_OLLAMA_BASE_URL: &str = "OLLAMA_BASE_URL";
/// Ollama model name. Default: `qwen2.5:3b`. Pull via `ollama pull <name>`.
pub const ENV_OLLAMA_MODEL: &str = "OLLAMA_MODEL";
// ─── LLM router (IMBA-1) ───────────────────────────────────────────────────
/// Enable agentic LLM router. Values: `1` / `true` / `yes` / `on`. Default: `1` (on).
pub const ENV_LLM_ROUTER: &str = "JARVIS_LLM_ROUTER";
/// Confidence threshold (0.0..1.0) above which the router accepts the LLM's command pick.
/// Default: `0.55`. Lower → more matches but more false positives.
pub const ENV_LLM_ROUTER_THRESHOLD: &str = "JARVIS_LLM_ROUTER_THRESHOLD";
// ─── Helpers ───────────────────────────────────────────────────────────────
/// Read an env var, returning `None` for unset or whitespace-only.
pub fn get(name: &str) -> Option<String> {
env::var(name).ok().and_then(|v| {
let trimmed = v.trim();
if trimmed.is_empty() { None } else { Some(trimmed.to_string()) }
})
}
/// Parse a boolean-ish env var. Accepts `1` / `true` / `yes` / `on` (case-insensitive)
/// as true, `0` / `false` / `no` / `off` as false. Returns `default` if unset.
pub fn get_bool(name: &str, default: bool) -> bool {
match get(name).map(|v| v.to_lowercase()) {
Some(s) => matches!(s.as_str(), "1" | "true" | "yes" | "on"),
None => default,
}
}
/// Parse a typed env var. Returns `default` if unset or unparseable.
pub fn get_parse<T: std::str::FromStr>(name: &str, default: T) -> T {
get(name).and_then(|v| v.parse().ok()).unwrap_or(default)
}
// ─── Feature-flag wrappers ─────────────────────────────────────────────────
/// True if LLM-reply TTS is enabled (default). Set `JARVIS_LLM_TTS=false` to mute.
pub fn llm_tts_enabled() -> bool { get_bool(ENV_LLM_TTS, true) }
/// True if agentic LLM router is enabled (default on).
pub fn llm_router_enabled() -> bool { get_bool(ENV_LLM_ROUTER, true) }
/// Router confidence threshold; default 0.55.
pub fn llm_router_threshold() -> f32 { get_parse(ENV_LLM_ROUTER_THRESHOLD, 0.55) }
/// Log every effective env-driven knob at INFO level. Call once on startup.
pub fn log_effective_config() {
log::info!("[config] TTS backend: {}", get(ENV_TTS).unwrap_or_else(|| "auto".into()));
log::info!("[config] LLM backend: {}", get(ENV_LLM).unwrap_or_else(|| "auto".into()));
log::info!("[config] LLM router: {} (threshold {:.2})",
llm_router_enabled(), llm_router_threshold());
log::info!("[config] LLM reply TTS: {}", llm_tts_enabled());
log::info!("[config] Groq model: {}",
get(ENV_GROQ_MODEL).unwrap_or_else(|| "llama-3.3-70b-versatile (default)".into()));
log::info!("[config] Ollama model: {}",
get(ENV_OLLAMA_MODEL).unwrap_or_else(|| "qwen2.5:3b (default)".into()));
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn get_bool_defaults() {
assert!(!get_bool("JARVIS_NON_EXISTENT_XYZ", false));
assert!(get_bool("JARVIS_NON_EXISTENT_XYZ", true));
}
#[test]
fn get_parse_typed_default() {
let n: u32 = get_parse("JARVIS_NON_EXISTENT_XYZ", 42);
assert_eq!(n, 42);
let f: f32 = get_parse("JARVIS_NON_EXISTENT_XYZ", 0.5);
assert!((f - 0.5).abs() < 0.001);
}
}

View file

@ -13,6 +13,7 @@
//! If `JARVIS_TTS` is unset, the dispatcher auto-detects Piper, otherwise SAPI.
use once_cell::sync::OnceCell;
use std::path::Path;
use std::sync::Arc;
use crate::text_utils::sanitize_for_speech;
@ -25,6 +26,31 @@ pub use sapi::SapiBackend;
pub use piper::PiperBackend;
pub use silero::SileroBackend;
/// Play a WAV file synchronously via PowerShell SoundPlayer (Windows) or no-op
/// stub elsewhere. Shared by file-based TTS backends (Piper, Silero).
pub(crate) fn play_wav(path: &Path) {
play_wav_impl(path);
}
#[cfg(target_os = "windows")]
fn play_wav_impl(path: &Path) {
let escaped = path.display().to_string().replace('\'', "''");
let ps = format!(
"$p = New-Object System.Media.SoundPlayer '{}'; $p.PlaySync()",
escaped
);
let _ = std::process::Command::new("powershell")
.args(["-NoProfile", "-ExecutionPolicy", "Bypass", "-Command", &ps])
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null())
.status();
}
#[cfg(not(target_os = "windows"))]
fn play_wav_impl(path: &Path) {
log::info!("[TTS non-Windows stub] would play: {}", path.display());
}
/// Options for a single `speak()` call.
#[derive(Debug, Clone)]
pub struct SpeakOpts {

View file

@ -106,7 +106,7 @@ fn synth_and_play(binary: &Path, voice: &Path, text: &str) {
match child.wait() {
Ok(status) if status.success() => {
play_wav(&wav_path);
super::play_wav(&wav_path);
}
Ok(status) => log::warn!("Piper exited with status {}", status),
Err(e) => log::warn!("Piper wait failed: {}", e),
@ -115,25 +115,6 @@ fn synth_and_play(binary: &Path, voice: &Path, text: &str) {
let _ = std::fs::remove_file(&wav_path);
}
#[cfg(target_os = "windows")]
fn play_wav(path: &Path) {
let escaped = path.display().to_string().replace('\'', "''");
let ps = format!(
"$p = New-Object System.Media.SoundPlayer '{}'; $p.PlaySync()",
escaped
);
let _ = std::process::Command::new("powershell")
.args(["-NoProfile", "-ExecutionPolicy", "Bypass", "-Command", &ps])
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null())
.status();
}
#[cfg(not(target_os = "windows"))]
fn play_wav(path: &Path) {
log::info!("[Piper non-Windows stub] would play: {}", path.display());
}
fn find_binary() -> Option<PathBuf> {
if let Ok(p) = std::env::var("JARVIS_TTS_PIPER_BIN") {
let candidate = PathBuf::from(p);

View file

@ -107,29 +107,10 @@ fn synth_and_play(python: &str, helper: &Path, voice: &str, text: &str) {
}
let p = PathBuf::from(wav_path);
play_wav(&p);
super::play_wav(&p);
let _ = std::fs::remove_file(&p);
}
#[cfg(target_os = "windows")]
fn play_wav(path: &Path) {
let escaped = path.display().to_string().replace('\'', "''");
let ps = format!(
"$p = New-Object System.Media.SoundPlayer '{}'; $p.PlaySync()",
escaped
);
let _ = std::process::Command::new("powershell")
.args(["-NoProfile", "-ExecutionPolicy", "Bypass", "-Command", &ps])
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null())
.status();
}
#[cfg(not(target_os = "windows"))]
fn play_wav(path: &Path) {
log::info!("[Silero non-Windows stub] would play: {}", path.display());
}
fn find_helper() -> Option<PathBuf> {
if let Ok(p) = std::env::var("JARVIS_TTS_SILERO_HELPER") {
let candidate = PathBuf::from(p);