aboutsummaryrefslogtreecommitdiffstats
path: root/crates/asist-core/src/config.rs
diff options
context:
space:
mode:
Diffstat (limited to 'crates/asist-core/src/config.rs')
-rw-r--r--crates/asist-core/src/config.rs508
1 files changed, 508 insertions, 0 deletions
diff --git a/crates/asist-core/src/config.rs b/crates/asist-core/src/config.rs
new file mode 100644
index 0000000..4ca8d8c
--- /dev/null
+++ b/crates/asist-core/src/config.rs
@@ -0,0 +1,508 @@
+//! 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 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,
+ /// 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(),
+ 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<ReferenceVoice>,
+ 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,
+ /// 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<String>,
+ /// 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,
+ 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(),
+ }
+ }
+}
+
+#[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 `<think>` 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<String>,
+}
+
+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<String>,
+}
+
+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<Path>) -> Result<Self> {
+ 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(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.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)
+ }
+}