//! Assistant configuration: a TOML file whose defaults already include what //! was learned by measuring the pipeline (see `docs/RENDIMIENTO.md`). use std::path::{Path, PathBuf}; use serde::{Deserialize, Serialize}; use crate::error::{Error, Result}; #[derive(Debug, Clone, Default, Serialize, Deserialize)] #[serde(default, deny_unknown_fields)] pub struct Config { pub general: General, pub audio: AudioConfig, pub vad: VadConfig, pub asr: AsrConfig, pub llm: LlmConfig, pub tts: TtsConfig, pub tools: ToolsConfig, pub search: SearchConfig, pub camera: CameraConfig, pub screen: ScreenConfig, pub supervisor: SupervisorConfig, } #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(default, deny_unknown_fields)] pub struct General { /// Conversation language; propagated to ASR and TTS. pub language: String, /// System prompt. It asks for short sentences without markdown on purpose: /// the TTS reads asterisks and bullets literally. pub system_prompt: String, /// System prompt for the pass where the model decides whether to use a /// tool. /// /// It is separate, and **replaces** `system_prompt` in that pass, for a /// measured reason rather than taste: with Qwen3.5-2B, adding any style /// instruction to this guide (in any position, even two words) makes the /// model stop calling tools and make the data up. Measured over 8 tries: /// the guide alone gets 8/8; with «Responde breve.» after it, 1/8; with the /// voice-assistant persona, 0/8. See docs/RENDIMIENTO.md. pub tools_prompt: String, /// Appended to `system_prompt` to write the answer once a tool has /// returned its result. /// /// Here style can be added safely (the call already happened), and it is /// needed: without this instruction the model announces what it just did /// instead of telling what it found out. pub tool_result_prompt: String, /// History turns sent to the model (0 = no memory). pub history_turns: usize, /// Prints a per-turn latency summary at the end of each answer. pub report_latency: bool, } impl Default for General { fn default() -> Self { Self { language: "es".into(), system_prompt: concat!( "Eres un asistente de voz en español. Tus respuestas se leen en voz alta, ", "así que responde en una o dos frases cortas, en texto plano corrido. ", "No uses markdown, ni listas, ni asteriscos, ni emojis, ni encabezados. ", "No escribas URLs ni código salvo que te lo pidan explícitamente. ", "Si no sabes algo, dilo en una frase." ) .into(), tools_prompt: concat!( "Antes de responder, comprueba si alguna de tus herramientas te da el dato. ", "Si es así, llámala primero y espera su resultado; no contestes de memoria. ", "Sólo cuando tengas el resultado, resúmelo en una frase." ) .into(), tool_result_prompt: concat!( "Acabas de recibir el resultado de una herramienta. Contesta a la pregunta ", "usando ese resultado y nada más. No anuncies lo que has hecho ni lo que ", "podrías hacer: di directamente lo que has averiguado. Si el resultado ", "viene en otro idioma, tradúcelo al español." ) .into(), history_turns: 8, report_latency: true, } } } #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(default, deny_unknown_fields)] pub struct AudioConfig { /// Name (or substring) of the input device; empty = the default one. pub input_device: String, /// Same for the output. pub output_device: String, /// Seconds of audio playback keeps queued before starting. It absorbs /// TTS hiccups without adding noticeable latency. pub playback_prebuffer: f32, /// Gain applied to playback. pub output_gain: f32, } impl Default for AudioConfig { fn default() -> Self { Self { input_device: String::new(), output_device: String::new(), playback_prebuffer: 0.20, output_gain: 1.0, } } } #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(default, deny_unknown_fields)] pub struct VadConfig { /// Audio window per detector decision. pub frame_seconds: f32, /// Audio kept before speech onset so the first syllable is not cut. pub preroll_seconds: f32, /// Silence that ends an utterance. pub silence_hold: f32, /// Shortest utterance worth a final transcription. pub min_utterance: f32, /// Forced cut, which bounds the cost of the final decode. pub max_utterance: f32, /// Multiple of the noise floor above which audio counts as speech. pub threshold_factor: f32, pub min_threshold: f32, pub max_threshold: f32, /// Lets the user talk over the assistant to interrupt it. /// /// Off by default: with open speakers the microphone hears itself and the /// assistant interrupts itself. Turn it on with headphones or with system /// echo cancellation. pub barge_in: bool, /// With barge-in on, how much louder than the normal threshold the voice /// must be to interrupt. Raises the bar against speaker echo. pub barge_in_factor: f32, } impl Default for VadConfig { fn default() -> Self { Self { frame_seconds: 0.1, preroll_seconds: 0.3, silence_hold: 0.8, min_utterance: 0.3, max_utterance: 20.0, threshold_factor: 3.0, min_threshold: 0.0008, max_threshold: 0.02, barge_in: false, barge_in_factor: 4.0, } } } #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(default, deny_unknown_fields)] pub struct AsrConfig { /// Directory of the Canary ONNX model. pub model_dir: PathBuf, pub source_lang: String, pub target_lang: String, /// Sliding window for the partial transcriptions. pub window: f32, /// Window step between decodes. pub step: f32, /// Windows that must agree for a word to be considered stable. pub stability: usize, /// Emit partial transcriptions. They cost CPU and are only there to be /// shown on screen: the turn is triggered by the final one. pub partials: bool, /// Loads a second model instance so final decodes do not block the /// partial ones. Doubles the memory. pub dedicated_final_model: bool, /// ONNX Runtime execution provider: `cpu`, `cuda`, ... pub execution_provider: String, pub inter_threads: usize, pub intra_threads: usize, } impl Default for AsrConfig { fn default() -> Self { Self { model_dir: PathBuf::from("vendor/canary-rs/models/canary-180m-flash-onnx"), source_lang: "es".into(), target_lang: "es".into(), window: 6.0, step: 0.4, stability: 2, partials: true, dedicated_final_model: false, // CPU on purpose: the 4 GB GPU is taken by the TTS talker, and fighting // over it costs more than decoding on the CPU. execution_provider: "cpu".into(), inter_threads: 2, intra_threads: 4, } } } #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(default, deny_unknown_fields)] pub struct LlmConfig { pub host: String, pub port: u16, pub model: String, pub temperature: f32, pub top_p: f32, pub top_k: i32, pub min_p: f32, pub repeat_penalty: f32, pub max_tokens: u32, /// Maximum rounds of the tool loop before giving up. pub max_tool_rounds: usize, pub request_timeout_secs: u64, } impl Default for LlmConfig { fn default() -> Self { Self { host: "127.0.0.1".into(), port: 8012, model: String::new(), temperature: 0.7, top_p: 0.9, top_k: 40, min_p: 0.1, repeat_penalty: 1.1, // A long spoken answer is tiring; the cap also bounds the cost of // synthesis, which is the slow stage. max_tokens: 300, max_tool_rounds: 4, request_timeout_secs: 120, } } } #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(default, deny_unknown_fields)] pub struct TtsConfig { pub host: String, pub port: u16, /// Voice registered on the server, or one of the model's. pub voice: String, /// Cloned voice registered at startup, if defined. pub reference: Option, pub language: String, pub temperature: f32, pub top_k: i32, pub top_p: f32, pub repetition_penalty: f32, pub max_new_tokens: u32, /// Synthesizes a short sentence at startup. The first request pays for /// building the graphs: ~3.5 s better not spent on the first real turn. pub warmup: bool, pub request_timeout_secs: u64, } impl Default for TtsConfig { fn default() -> Self { Self { host: "127.0.0.1".into(), port: 8013, voice: "asistente".into(), reference: None, language: "spanish".into(), temperature: 0.9, top_k: 50, top_p: 1.0, repetition_penalty: 1.05, max_new_tokens: 2048, warmup: true, request_timeout_secs: 180, } } } /// Latents of a cloned voice, as produced by `qwen-codec --talker`. #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(deny_unknown_fields)] pub struct ReferenceVoice { /// Name it is registered under on the server. pub name: String, /// Speaker embedding (`.spk`). pub speaker: PathBuf, /// Reference codes (`.rvq`), which enable ICL cloning. pub codes: PathBuf, /// Transcript of the reference (`.txt`). pub transcript: PathBuf, } #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(default, deny_unknown_fields)] pub struct ToolsConfig { /// Lets the model call tools. pub enabled: bool, /// Uses `general.tools_prompt` alone for the pass where the model decides /// whether to call a tool, and `general.system_prompt` only to write the /// spoken answer. /// /// It is the only thing that makes tools really work with this model (see /// `tools_prompt`), at the cost of answers that use no tool losing the /// style guide. That is partly offset by the text cleaner removing /// markdown before speaking. Set it to `false` to favour style over tools. pub dedicated_prompt: bool, /// Says a short sentence when a slow tool starts, so the wait is not /// mistaken for a hang. pub spoken_ack: bool, /// Enables the tool that runs system commands. /// /// Off by default on purpose: giving a shell to a model that obeys what /// it hears through the microphone is a change of security posture, not a /// convenience option. pub shell: bool, /// Allowed commands, matched against the executable (argv[0]). /// An empty list denies everything even if `shell` is on. pub shell_allowlist: Vec, /// Seconds a command may run before it is killed. pub shell_timeout_secs: u64, /// Logs the command but does not run it. Useful to break in the allowlist. pub shell_dry_run: bool, /// Working directory of the commands; empty = the process one. pub shell_working_dir: String, } impl Default for ToolsConfig { fn default() -> Self { Self { enabled: true, dedicated_prompt: true, spoken_ack: true, shell: false, shell_allowlist: vec![ "date".into(), "uptime".into(), "free".into(), "df".into(), "ls".into(), ], shell_timeout_secs: 10, shell_dry_run: false, shell_working_dir: String::new(), } } } /// Web search. #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(default, deny_unknown_fields)] pub struct SearchConfig { pub enabled: bool, /// `tavily` (needs a key), `ddgs` (no key) or `searxng` (needs your own /// instance). pub backend: String, /// Environment variable the key is read from. /// /// The key comes from the environment and not from the file on purpose: /// the configuration is versioned and shared, and a secret in it ends up in /// the git history. pub api_key_env: String, /// Base URL of the SearXNG instance. pub base_url: String, /// Program that runs the `ddgs` backend, with its arguments. /// /// `{query}` and `{max}` are substituted before running. It must write /// JSON to standard output; both the bare list ddgs returns and the /// `{answer, results}` shape are accepted. Any other program that honours /// that works too, including a bridge to an MCP server. pub command: Vec, pub max_results: usize, pub timeout_secs: u64, } impl Default for SearchConfig { fn default() -> Self { Self { enabled: true, backend: "tavily".into(), api_key_env: "TAVILY_API_KEY".into(), base_url: String::new(), command: vec![ "scripts/search-ddgs.sh".into(), "{query}".into(), "{max}".into(), ], // Five results fill the context well without drowning a 2B model, which // gets lost between sources with more. max_results: 5, timeout_secs: 20, } } } /// Looking through the camera. #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(default, deny_unknown_fields)] pub struct CameraConfig { /// If off, the tool is never registered and the model does not even know /// it exists. It also turns itself off if there is no device. pub enabled: bool, pub device: PathBuf, /// Capture resolution. It weighs heavily on latency: measured, the model /// takes 1.3 s at 320x240, 2.9 s at 640x480 and 7.8 s at 1280x720. pub width: u32, pub height: u32, /// Frames discarded so auto-exposure can settle. pub warmup_frames: u32, pub timeout_secs: u64, /// Directory where each captured frame is saved. Empty = none is saved, /// which is the right default. pub save_dir: String, } impl Default for CameraConfig { fn default() -> Self { Self { enabled: true, device: PathBuf::from("/dev/video0"), width: 640, height: 480, warmup_frames: 5, timeout_secs: 15, save_dir: String::new(), } } } /// Looking at the screen. #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(default, deny_unknown_fields)] pub struct ScreenConfig { pub enabled: bool, /// Capture program. `{width}` and `{output}` are substituted before /// running, and it must write a JPEG to standard output. It lives outside /// the binary so supporting another compositor means editing a script. pub command: Vec, /// Width the capture is scaled down to before sending it to the model. /// /// **Do not lower it lightly.** Measured with 13 px UI type and asking for /// specific details: at 1280 px it gets 3 of 3 in 7.6 s; at 960, 2 of 3; /// at 640, 1 of 3. And when it fails it does not say it cannot read it: it /// confidently makes the content up. pub width: u32, /// A specific monitor. Empty = everything there is. pub output: String, pub timeout_secs: u64, /// Directory where captures are saved. Empty = none is saved, which is /// right: a capture can hold passwords and private messages. pub save_dir: String, } impl Default for ScreenConfig { fn default() -> Self { Self { enabled: true, command: vec![ "scripts/capture-screen.sh".into(), "{width}".into(), "{output}".into(), ], width: 1280, output: String::new(), timeout_secs: 20, save_dir: String::new(), } } } #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(default, deny_unknown_fields)] pub struct SupervisorConfig { /// Starts the servers instead of assuming they are already up. pub manage: bool, /// Seconds to wait for a server to answer `/health`. pub startup_timeout_secs: u64, pub llama: LlamaProcess, pub tts: TtsProcess, } impl Default for SupervisorConfig { fn default() -> Self { Self { manage: true, startup_timeout_secs: 180, llama: LlamaProcess::default(), tts: TtsProcess::default(), } } } #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(default, deny_unknown_fields)] pub struct LlamaProcess { pub binary: PathBuf, pub model: PathBuf, pub mmproj: PathBuf, /// Chat template passed with `--chat-template-file`. /// /// The model's own template opens `` and never closes it, so the /// assistant spends several seconds reasoning before saying the first /// word. This copy starts with the block already closed. pub chat_template: PathBuf, pub extra_args: Vec, } impl Default for LlamaProcess { fn default() -> Self { Self { binary: PathBuf::from("vendor/llama.cpp/build/bin/llama-server"), model: PathBuf::from("models/Qwen3.5-2B.Q5_K_M.gguf"), mmproj: PathBuf::from("models/mmproj-BF16.gguf"), chat_template: PathBuf::from("config/qwen35-no-think.jinja"), extra_args: [ "--threads", "10", "--threads-batch", "10", "--batch-size", "512", "--ubatch-size", "256", "--gpu-layers", "10", "--split-mode", "layer", "--tensor-split", "1", "--main-gpu", "0", "--no-mmap", "--ctx-size", "8192", "--parallel", "2", "--cache-ram", "6144", "--rope-freq-base", "1000000", "--rope-freq-scale", "0.25", "--jinja", ] .iter() .map(|s| s.to_string()) .collect(), } } } #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(default, deny_unknown_fields)] pub struct TtsProcess { pub binary: PathBuf, pub model: PathBuf, pub codec: PathBuf, /// Seconds of audio the codec accumulates before decoding a block. /// /// The stock value is 24 s, which in practice means «return nothing until /// the whole sentence is done»: measured, it lowers the first audio from /// 4.9 s to 0.6 s. It is the single most effective setting in the system. pub codec_chunk_dur: f32, pub extra_args: Vec, } impl Default for TtsProcess { fn default() -> Self { Self { binary: PathBuf::from("vendor/qwentts.cpp/build/tts-server"), model: PathBuf::from("models/qwen-talker-1.7b-base-Q8_0.gguf"), codec: PathBuf::from("models/qwen-tokenizer-12hz-Q8_0.gguf"), codec_chunk_dur: 1.0, extra_args: Vec::new(), } } } impl Config { pub fn load(path: impl AsRef) -> Result { let path = path.as_ref(); let raw = std::fs::read_to_string(path) .map_err(|e| Error::Config(format!("no se pudo leer {}: {e}", path.display())))?; let mut config: Config = toml::from_str(&raw).map_err(|e| Error::Config(format!("{}: {e}", path.display())))?; // Relative paths are resolved against the TOML directory, not against // the directory the binary is launched from. if let Some(base) = path.parent().filter(|p| !p.as_os_str().is_empty()) { config.rebase(base); } config.validate()?; Ok(config) } /// Reinterprets relative paths relative to `base`. pub fn rebase(&mut self, base: &Path) { let fix = |p: &mut PathBuf| { if p.is_relative() { *p = base.join(&*p); } }; fix(&mut self.asr.model_dir); fix(&mut self.supervisor.llama.binary); fix(&mut self.supervisor.llama.model); fix(&mut self.supervisor.llama.mmproj); fix(&mut self.supervisor.llama.chat_template); fix(&mut self.supervisor.tts.binary); fix(&mut self.supervisor.tts.model); fix(&mut self.supervisor.tts.codec); if let Some(program) = self.screen.command.first_mut() { let path = PathBuf::from(&*program); if path.is_relative() && program.contains('/') { *program = base.join(path).to_string_lossy().into_owned(); } } if let Some(program) = self.search.command.first_mut() { let path = PathBuf::from(&*program); if path.is_relative() && program.contains('/') { *program = base.join(path).to_string_lossy().into_owned(); } } if !self.camera.save_dir.is_empty() { let dir = PathBuf::from(&self.camera.save_dir); if dir.is_relative() { self.camera.save_dir = base.join(dir).to_string_lossy().into_owned(); } } if let Some(reference) = self.tts.reference.as_mut() { fix(&mut reference.speaker); fix(&mut reference.codes); fix(&mut reference.transcript); } } fn validate(&self) -> Result<()> { if self.vad.silence_hold <= 0.0 { return Err(Error::Config("vad.silence_hold debe ser > 0".into())); } if self.vad.min_utterance >= self.vad.max_utterance { return Err(Error::Config( "vad.min_utterance debe ser menor que vad.max_utterance".into(), )); } if self.asr.step <= 0.0 || self.asr.window <= self.asr.step { return Err(Error::Config( "asr.window debe ser mayor que asr.step, y ambos > 0".into(), )); } if self.search.enabled { match self.search.backend.trim().to_lowercase().as_str() { "tavily" => { if self.search.api_key_env.trim().is_empty() { return Err(Error::Config( "search.backend = «tavily» necesita search.api_key_env".into(), )); } } "searxng" => { if self.search.base_url.trim().is_empty() { return Err(Error::Config( "search.backend = «searxng» necesita search.base_url".into(), )); } } "ddgs" | "command" | "comando" => { if self.search.command.is_empty() { return Err(Error::Config( "search.backend = «ddgs» necesita search.command".into(), )); } } other => { return Err(Error::Config(format!( "search.backend desconocido: «{other}». Usa «tavily», «ddgs» o «searxng»" ))) } } } if self.camera.enabled && (self.camera.width == 0 || self.camera.height == 0) { return Err(Error::Config( "camera.width y camera.height deben ser > 0".into(), )); } if self.tools.shell && self.tools.shell_allowlist.is_empty() { return Err(Error::Config( "tools.shell está activo pero tools.shell_allowlist está vacía: \ declara las órdenes permitidas o desactiva tools.shell" .into(), )); } Ok(()) } pub fn llm_authority(&self) -> String { format!("{}:{}", self.llm.host, self.llm.port) } pub fn tts_authority(&self) -> String { format!("{}:{}", self.tts.host, self.tts.port) } }