aboutsummaryrefslogtreecommitdiffstats
# Voice assistant configuration.
#
# Relative paths are resolved against the directory of this file, not against
# the directory the binary is launched from.
#
# Values with a comment carrying a measurement come from docs/RENDIMIENTO.md:
# they were measured on this machine, not guessed.
#
# The prompts are in Spanish because the assistant speaks Spanish.

[general]
language = "es"
history_turns = 8
report_latency = true
system_prompt = """
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.
"""

# REPLACES system_prompt in the pass where the model decides whether to call
# a tool. It goes alone on purpose: measured with Qwen3.5-2B, adding any style
# instruction (two words are enough) makes it stop calling tools and make the
# data up. The guide alone gets 8 of 8; with «Responde breve.» after it, 1 of 8;
# with the voice-assistant persona, 0 of 8. See docs/RENDIMIENTO.md. Turn it
# off with tools.dedicated_prompt = false.
tools_prompt = """
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.
"""

# Appended to system_prompt to write the answer once a tool has returned its
# result. Style can be added here (the call already happened) and it is needed:
# without it the model announces what it just did («he tomado una foto, ahora
# puedo responderte sobre el color») instead of saying what it found out.
tool_result_prompt = """
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.
"""

[audio]
# Empty = the default device. `asistente devices` lists them.
input_device = ""
output_device = ""
playback_prebuffer = 0.20
output_gain = 1.0

[vad]
frame_seconds = 0.1
preroll_seconds = 0.3
silence_hold = 0.8       # silence that ends an utterance
min_utterance = 0.3      # measured on speech, not counting the preroll
max_utterance = 20.0
threshold_factor = 3.0
min_threshold = 0.0008
max_threshold = 0.02
# Interrupt the assistant by talking over it. Off by default: with open
# speakers the microphone hears itself and the assistant interrupts itself.
# Turn it on with headphones, or with --barge-in.
barge_in = false
barge_in_factor = 4.0

[asr]
model_dir = "../vendor/canary-rs/models/canary-180m-flash-onnx"
source_lang = "es"
target_lang = "es"
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 here. See docs/RENDIMIENTO.md.
execution_provider = "cpu"
inter_threads = 2
intra_threads = 4

[llm]
host = "127.0.0.1"
port = 8012
model = ""
temperature = 0.7
top_p = 0.9
top_k = 40
min_p = 0.1
repeat_penalty = 1.1
max_tokens = 400         # a long spoken answer is tiring, and synthesis is the expensive part
max_tool_rounds = 4
request_timeout_secs = 120

[tts]
host = "127.0.0.1"
port = 8013
voice = "asistente"
language = "spanish"
temperature = 0.9
top_k = 50
top_p = 1.0
repetition_penalty = 1.05
max_new_tokens = 2048
warmup = true            # the first synthesis costs ~3.5 s more than the rest
request_timeout_secs = 180

# Cloned voice, registered with the server at startup. The three files come
# from `qwen-codec --talker` on a reference recording; there is a script in
# scripts/clone-voice.sh.
[tts.reference]
name = "asistente"
speaker   = "../assets/voices/asistente.spk"
codes     = "../assets/voices/asistente.rvq"
transcript = "../assets/voices/asistente.txt"

[tools]
enabled = true
# Alternates between tools_prompt (to decide) and system_prompt (to write).
# It is the only thing that makes tools work with this model; in exchange,
# answers that use no tool lose the style guide.
# Set it to false to favour style over tools.
dedicated_prompt = true
# System command execution. 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. Turn it on here or with --shell.
shell = false
shell_allowlist = ["date", "uptime", "free", "df", "ls", "mkdir", "cat"]
shell_timeout_secs = 10
shell_dry_run = false
shell_working_dir = ""

# Web search. The key does NOT go here: it is read from the environment
# variable named by api_key_env, because this file is versioned and a secret
# in it ends up in the git history.
#
# Three backends, measured:
#   tavily   ~2.4 s. Returns an ALREADY WRITTEN answer besides the links,
#            which can be read aloud without spending another model round
#            summarizing. Needs a key.
#   ddgs     1.5-5.8 s. No key. Queries DuckDuckGo, Brave, Mojeek, Startpage
#            and friends through scripts/search-ddgs.sh. Only returns
#            snippets: the model has to summarize, and with 2B the result is
#            somewhat worse than tavily.
#   searxng  No key, but needs your own instance. Like ddgs, it only returns
#            results.
[search]
enabled = true
backend = "tavily"              # "tavily", "ddgs" o "searxng"
api_key_env = "TAVILY_API_KEY"
base_url = ""                   # searxng only, e.g. "http://127.0.0.1:8888"
# Program for the "ddgs" backend. {query} and {max} are substituted before
# running, and it must write JSON to standard output. Any other program that
# honours that works too, including a bridge to an MCP server.
command = ["../scripts/search-ddgs.sh", "{query}", "{max}"]
max_results = 5
timeout_secs = 20

# Looking through the camera. The server already loads the multimodal
# projector, so the same model that converses describes what it captures.
[camera]
enabled = true
device = "/dev/video2"
# Resolution rules latency. Measured on this machine: 1.3 s at 320x240,
# 2.9 s at 640x480 and 7.8 s at 1280x720. 640x480 is the balance.
width = 640
height = 480
# Frames discarded so auto-exposure settles; the first one is usually
# blown out and discarding a few is almost free.
warmup_frames = 5
timeout_secs = 15
# Empty = no frame is saved to disk, which is right.
# Set a directory only to debug what the model is seeing.
save_dir = ""

# Looking at the screen. Same vision as the camera, but tuned differently
# because the problem is different: a screen is TEXT.
#
# Measured with 13 px UI type, asking for specific details:
#   1280 px  7.6 s  3 of 3 right
#    960 px  4.5 s  2 of 3 right
#    640 px  2.4 s  1 of 3 right
#
# And when it fails it does not say it cannot read it: it makes it up. At
# 640 px it answered that the error was «no se pudo abrir el archivo
# involution» and the meeting was «at 10:00»; neither was in the image. Hence
# 1280 even though it costs three times the camera.
[screen]
enabled = true
# Detects the graphical environment (grim on Wayland, maim/imagemagick on
# X11), captures and scales down to {width}. Editing the script is easier than
# recompiling.
command = ["../scripts/capture-screen.sh", "{width}", "{output}"]
width = 1280
output = ""                     # a specific monitor; empty = all
timeout_secs = 20
# Empty = no capture is saved. It weighs more here than for the camera: a
# screenshot can hold passwords, private messages and open email.
save_dir = ""

[supervisor]
manage = true            # start the servers; --no-manage assumes they are up
startup_timeout_secs = 180

[supervisor.llama]
binary = "../vendor/llama.cpp/build/bin/llama-server"
model  = "../models/Qwen3.5-2B.Q8_0.gguf"
mmproj = "../models/mmproj-BF16.gguf"
# Copy of the model template with the <think> block closed from the start.
# Without it the model reasons for 7 to 9 s before the first audible word.
chat_template = "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",
  # 8192 instead of the model's full context (262144): a KV cache that size
  # does not fit in 4 GB next to the TTS. See docs/RENDIMIENTO.md.
  "--ctx-size", "8192", "--parallel", "2",
  "--cache-ram", "6144",
  "--rope-freq-base", "1000000", "--rope-freq-scale", "0.25",
]

[supervisor.tts]
binary = "../vendor/qwentts.cpp/build/tts-server"
model  = "../models/qwen-talker-1.7b-base-Q8_0.gguf"
codec  = "../models/qwen-tokenizer-12hz-Q8_0.gguf"
# The single most effective setting in the system: the stock value is 24 s,
# and with it the server returns nothing until the whole sentence is done.
# Measured, it lowers the first audio from 4948 ms to 585 ms.
codec_chunk_dur = 1.0
extra_args = []