//! Configuración del asistente: un TOML con valores por defecto que ya //! incorporan lo aprendido midiendo el pipeline (ver `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 supervisor: SupervisorConfig, } #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(default, deny_unknown_fields)] pub struct General { /// Idioma de la conversación; se propaga a ASR y TTS. pub language: String, /// Instrucción de sistema. Pide frases cortas y sin markdown a propósito: /// el TTS lee literalmente los asteriscos y las viñetas. pub system_prompt: String, /// Instrucción de sistema de la pasada en que el modelo decide si usar /// una herramienta. /// /// Va aparte, y **sustituye** a `system_prompt` en esa pasada, por una /// razón medida y no por gusto: con Qwen3.5-2B, añadir cualquier /// indicación de estilo a esta guía —en cualquier posición, incluso dos /// palabras— hace que el modelo deje de llamar a las herramientas y se /// invente el dato. Medido sobre 8 intentos: la guía sola acierta 8/8; /// con «Responde breve.» detrás, 1/8; con la persona de asistente de voz, /// 0/8. Ver docs/RENDIMIENTO.md. pub tools_prompt: String, /// Se añade a `system_prompt` para redactar la respuesta cuando una /// herramienta ya ha devuelto su resultado. /// /// Aquí sí se puede añadir estilo sin miedo —la llamada ya ocurrió—, y /// hace falta: sin esta orden el modelo anuncia lo que acaba de hacer en /// vez de contar lo que averiguó. pub tool_result_prompt: String, /// Turnos de historial que se envían al modelo (0 = sin memoria). pub history_turns: usize, /// Imprime un resumen de latencias por turno al terminar cada respuesta. 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 { /// Nombre (o subcadena) del dispositivo de entrada; vacío = el de por defecto. pub input_device: String, /// Ídem para la salida. pub output_device: String, /// Segundos de audio que la reproducción mantiene en cola antes de /// arrancar. Amortigua los baches del TTS sin añadir latencia perceptible. pub playback_prebuffer: f32, /// Ganancia aplicada a la reproducción. 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 { /// Ventana de audio por decisión del detector. pub frame_seconds: f32, /// Audio conservado antes del inicio de voz para no cortar la primera sílaba. pub preroll_seconds: f32, /// Silencio que cierra una intervención. pub silence_hold: f32, /// Intervención más corta que merezca una transcripción final. pub min_utterance: f32, /// Corte forzado, que acota el coste de la decodificación final. pub max_utterance: f32, /// Múltiplo del suelo de ruido a partir del cual se considera voz. pub threshold_factor: f32, pub min_threshold: f32, pub max_threshold: f32, /// Permite hablar encima del asistente para cortarlo. /// /// Desactivado por defecto: con altavoces abiertos el micrófono se oye a sí /// mismo y el asistente se interrumpe solo. Actívalo con auriculares o con /// cancelación de eco del sistema. pub barge_in: bool, /// Con barge-in activo, cuánto más fuerte que el umbral normal debe sonar /// la voz para cortar. Sube el listón frente al eco del altavoz. 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 { /// Carpeta del modelo Canary en ONNX. pub model_dir: PathBuf, pub source_lang: String, pub target_lang: String, /// Ventana deslizante de las transcripciones provisionales. pub window: f32, /// Avance de la ventana entre decodificaciones. pub step: f32, /// Ventanas que deben coincidir para dar una palabra por estable. pub stability: usize, /// Emitir transcripciones provisionales. Cuestan CPU y sólo sirven para /// verlas en pantalla: el turno se dispara con la final. pub partials: bool, /// Carga una segunda instancia del modelo para que las decodificaciones /// finales no bloqueen a las provisionales. Duplica la memoria. pub dedicated_final_model: bool, /// Proveedor de ejecución de ONNX Runtime: `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 a propósito: la GPU de 4 GB está ocupada por el hablante del // TTS, y disputársela sale más caro que decodificar en 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, /// Vueltas máximas del bucle de herramientas antes de rendirse. 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, // Una respuesta hablada larga cansa; el recorte también acota el // coste de la síntesis, que es la etapa lenta. 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, /// Voz registrada en el servidor, o una del modelo. pub voice: String, /// Voz clonada que se registra al arrancar, si se define. 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, /// Sintetiza una frase corta al arrancar. La primera petición paga la /// construcción de los grafos: ~3,5 s que conviene no gastar en el /// primer turno real. 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, } } } /// Latentes de una voz clonada, tal y como los produce `qwen-codec --talker`. #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(deny_unknown_fields)] pub struct ReferenceVoice { /// Nombre con el que se registra en el servidor. pub name: String, /// Embedding del hablante (`.spk`). pub speaker: PathBuf, /// Códigos de referencia (`.rvq`), que activan el clonado ICL. pub codes: PathBuf, /// Transcripción de la referencia (`.txt`). pub transcript: PathBuf, } #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(default, deny_unknown_fields)] pub struct ToolsConfig { /// Deja que el modelo llame a herramientas. pub enabled: bool, /// Usa `general.tools_prompt` en solitario para la pasada en que el /// modelo decide si llamar a una herramienta, y `general.system_prompt` /// sólo para redactar la respuesta hablada. /// /// Es lo único que hace que las herramientas funcionen de verdad con este /// modelo (ver `tools_prompt`), a cambio de que las respuestas que no /// usan herramienta pierdan la guía de estilo. Se compensa en parte /// porque el limpiador de texto quita el markdown antes de hablar. /// Ponlo a `false` para priorizar el estilo sobre las herramientas. pub dedicated_prompt: bool, /// Pronuncia una frase corta al empezar una herramienta lenta, para que la /// espera no se confunda con un cuelgue. pub spoken_ack: bool, /// Habilita la herramienta de ejecución de órdenes del sistema. /// /// Apagada por defecto a conciencia: darle una shell a un modelo que /// obedece a lo que oye por el micrófono es un cambio de postura de /// seguridad, no una opción de comodidad. pub shell: bool, /// Órdenes admitidas, comparadas contra el ejecutable (argv[0]). /// Una lista vacía deniega todo aunque `shell` esté activo. pub shell_allowlist: Vec, /// Segundos que puede durar una orden antes de que se la mate. pub shell_timeout_secs: u64, /// Registra la orden pero no la ejecuta. Útil para estrenar la lista blanca. pub shell_dry_run: bool, /// Directorio de trabajo de las órdenes; vacío = el del proceso. 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(), } } } /// Búsqueda en internet. #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(default, deny_unknown_fields)] pub struct SearchConfig { pub enabled: bool, /// `tavily` (necesita clave), `ddgs` (sin clave) o `searxng` (necesita /// una instancia propia). pub backend: String, /// Variable de entorno de la que se lee la clave. /// /// La clave se lee del entorno y no del fichero a propósito: la /// configuración se versiona y se comparte, y un secreto ahí dentro acaba /// en el historial de git. pub api_key_env: String, /// URL base de la instancia de SearXNG. pub base_url: String, /// Programa que ejecuta el backend `ddgs`, con sus argumentos. /// /// `{consulta}` y `{max}` se sustituyen antes de ejecutar. Tiene que /// escribir JSON por la salida estándar; se admiten tanto la lista suelta /// que devuelve ddgs como la forma `{answer, results}`. Cualquier otro /// programa que respete eso sirve igual, incluido un puente a un servidor /// MCP. 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/buscar-ddgs.sh".into(), "{consulta}".into(), "{max}".into(), ], // Cinco resultados llenan bien el contexto sin ahogar a un modelo // de 2B, que con más se pierde entre fuentes. max_results: 5, timeout_secs: 20, } } } /// Mirar por la cámara. #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(default, deny_unknown_fields)] pub struct CameraConfig { /// Si se apaga, la herramienta no llega a registrarse y el modelo ni /// sabe que existe. También se apaga sola si no hay dispositivo. pub enabled: bool, pub device: PathBuf, /// Resolución de captura. Manda mucho en la latencia: medido, el modelo /// tarda 1,3 s a 320x240, 2,9 s a 640x480 y 7,8 s a 1280x720. pub width: u32, pub height: u32, /// Fotogramas descartados para que la exposición automática se asiente. pub warmup_frames: u32, pub timeout_secs: u64, /// Carpeta donde guardar cada fotograma capturado. Vacío = no se guarda /// ninguno, que es lo que corresponde por defecto. 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(), } } } #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(default, deny_unknown_fields)] pub struct SupervisorConfig { /// Lanza los servidores en lugar de suponer que ya están arriba. pub manage: bool, /// Segundos de espera a que un servidor conteste a `/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, /// Plantilla de chat que se pasa con `--chat-template-file`. /// /// La del modelo abre `` sin cerrarlo nunca, y el asistente se pasa /// entonces varios segundos razonando antes de decir la primera palabra. /// Esta copia deja el bloque cerrado de entrada. 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, /// Segundos de audio que el codec acumula antes de decodificar un bloque. /// /// El valor de fábrica son 24 s, que en la práctica significa «no /// devuelvas nada hasta terminar la frase entera»: medido, baja el primer /// audio de 4,9 s a 0,6 s. Es el ajuste con más efecto de todo el sistema. 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())))?; // Las rutas relativas se resuelven contra la carpeta del TOML, no // contra el directorio desde el que se lanza el binario. if let Some(base) = path.parent().filter(|p| !p.as_os_str().is_empty()) { config.rebase(base); } config.validate()?; Ok(config) } /// Reinterpreta las rutas relativas respecto de `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.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" | "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) } }