Annotated reference for every stt-daemon configuration key; config.example.toml ships the values only.
Copy the example config and edit as needed:
cp config.example.toml config.toml
# Edit config.toml to adjust settings (see section below)
The config file is TOML. Here are all available options with defaults.
config.example.toml ships values only; this section is the annotated reference.
[audio]
backend = "parec" # "parec" (PulseAudio by name) | "sounddevice"
device = "VirtualMicSink.monitor" # PulseAudio source name (parec) or device index/name (sounddevice)
sample_rate = 16000 # Hz
channels = 1 # Mono only
frame_ms = 30 # webrtcvad requires 10, 20, or 30
gain = 1.0 # Amplitude scaling (1.0 = no change, >1 = louder)
[vad]
aggressiveness = 2 # 0–3: higher = fewer false positives (speech misses more)
pre_roll_ms = 400 # Ring buffer duration: keep this many ms of pre-speech
speech_start_ms = 90 # Consecutive speech frames to confirm speech start
silence_ms = 700 # Consecutive silence to end a turn
min_utterance_ms = 300 # Discard utterances with less actual speech than this
max_utterance_s = 30.0 # Hard cap: force-finalize a very long utterance
chunk_pause_ms = 350 # Once soft_max_s is reached, end a chunk at a pause this long
soft_max_s = 10.0 # After this much speech, chunk-ends become eligible at pauses
rms_floor = 0.008 # Drop segments quieter than this RMS (noise-triggered blips)
trailing_trim_ms = 200 # Keep only this much trailing silence (less hallucination-prone padding)
[whisper]
backend = "faster-whisper" # "faster-whisper" (CTranslate2) | "pywhispercpp" (whisper.cpp; see docs/PYWHISPERCPP.md)
model = "small" # "tiny"/"base"/"small"/"medium"/"large" (multilingual; do NOT use `.en` variants)
device = "cpu" # "cpu" or "cuda"
compute_type = "int8" # "int8" (CPU) or "float16" (CUDA, slower but more accurate)
language = "auto" # "auto" (detect), "en", "es", or None/""/"none"
beam_size = 1 # 1 = fast greedy, >1 = slower beam search
vad_filter = false # Use Whisper's built-in VAD (we already have webrtcvad)
cpu_threads = 0 # CPU inference threads (0 = CTranslate2's automatic choice).
# On many-core CPUs, set this to the physical core count
# (e.g. 16 on a Ryzen 9 8945HX) - measurably faster than auto.
no_speech_prob_max = 0.6 # Drop segments Whisper scores as non-speech above this
avg_logprob_min = -1.0 # Drop segments with confidence below this
language_strategy = "static" # "static" (detect per utterance) | "sticky" (detect once, pin)
sticky_redetect_logprob = -0.8 # Unpin and re-detect if segment avg_logprob falls below this.
# Segments below avg_logprob_min (-1.0) are dropped; between
# -1.0 and -0.8 they are accepted but trigger re-detection.
# No accepted segments also clears the pin for re-detection.
[sink] # Virtual audio sink configuration
sink_name = "VirtualMicSink" # null-sink name
remap_source_name = "VirtualMic" # Microphone source name
loopback_latency_msec = 1 # Module latency (ms)
monitor_output = false # Loop sink.monitor → default sink (OFF = no feedback risk)
[output]
stdout = true # Print transcripts to stdout
jsonl = false # Persist transcripts to JSONL files (off by default for agent-only use)
session_dir = "transcripts" # Conversation root: one folder per conversation, named by the first transcription's YYYY-MM-DD-HH-MM-SS
save_audio = false # Also write each transcribed utterance as a 16 kHz mono WAV
session_gap_min = 5.0 # Quiet gap in minutes before a new conversation starts; <= 0 disables rollover
[daemon]
queue_size = 50 # Max utterances queued for transcription (oldest dropped when full)
backlog_warn_at = 5 # Warn when the queue backs up this far (worker falling behind)
[events] # Publish-only event bus
socket = "" # Default: $XDG_RUNTIME_DIR/stt-daemon.sock
heartbeat_ms = 1000 # Heartbeat interval (progress-driven, from the capture loop)
ring_size = 200 # Recent events replayed to a newly attached subscriber
[assistant] # Controller process
control_socket = "" # Default: $XDG_RUNTIME_DIR/stt-assistant.sock
stall_timeout_ms = 3000 # No heartbeat for this long -> STT considered offline
worker_stall_ms = 15000 # Heartbeats arriving but a transcription runs this long -> degraded
command_arm_timeout_s = 10.0 # Disarm a command if no turn is captured in this time
chat_idle_timeout_s = 300.0 # Leave chat mode after this much silence
turn_gap_s = 1.5 # Quiet gap that ends a turn
require_viewer = true # Refuse to execute commands with no TUI attached
chat_exit_phrases = ["end conversation", "that's all", "stop listening"]
[audit] # Executed actions only (commands/chats are volatile)
enabled = true # audit/YYYY-MM-DD.jsonl
dir = "audit"
[llm] # local llama.cpp (OpenAI-compatible)
enabled = true
endpoint = "http://localhost:9931/v1/chat/completions"
temperature = 0.7
max_tokens = 1024 # max output per request; reasoning models spend tokens before the answer, so keep generous
timeout_s = 120.0
cache_prompt = true # reuse the KV prefix (append-only history keeps it warm)
chat_slot_id = "" # optional llama.cpp id_slot for chat; empty/unset omits it (server needs -np 2)
command_slot_id = "" # optional llama.cpp id_slot for command tool-calls; empty/unset omits it (server needs -np 2)
max_context_tokens = 131072 # the server's --ctx-size (llama.cpp `GET /props` -> n_ctx); the prompt budget derives from it
compaction_safety_margin = 256 # tokens reserved for the token estimate error + template/tool overhead
compaction_enabled = true # summarize old turns instead of deleting them
compaction_trigger_ratio = 0.8 # compact when the prompt reaches this fraction of the budget
compaction_keep_turns = 6 # recent turns kept verbatim
compaction_max_tokens = 512 # summary length cap
compaction_timeout_s = 30.0 # summarizer timeout, so a compaction cannot hold the inference lane
compaction_input_tokens = 8192 # summarizer input cap before chunk-and-fold
compaction_system_prompt = "You compress a conversation. Preserve names, numbers, decisions, user preferences, and unresolved questions. Omit pleasantries. Output only the summary."
brief_compaction_system_prompt = "You compress a triage briefing conversation. Preserve every opportunity, blocker, owner, responsibility, decision, deadline, and open question; drop pleasantries. Output only the summary."
inference_queue_size = 8 # bounded single-lane inference queue
trim_block_turns = 4 # fallback block trimming, not per-turn, to limit re-prefill
max_tool_iterations = 3
system_prompt_chat = "You are a concise voice assistant. Reply in 1-3 sentences."
system_prompt_command = "You translate a spoken request into a single tool call when one applies."
triage_system_prompt = "You maintain a living brief of a business conversation. Given the current brief (your previous reply, if any) and a new transcript segment, return the UPDATED brief as plain text only, with no markdown fences and no JSON. Open with a Summary section: a rolling overview of the conversation so far, one to a few sentences for a short session and growing to a short paragraph or two as the session extends. Preserve prior content unless the new segment contradicts it; merge duplicates; prune stale items; keep owners, responsibilities, and deadlines explicit. Use short sections: Summary, Opportunities, Blockers/Risks, Responsibilities, Decisions, Action Items, Deadlines, Open Questions. Output the brief text directly; never emit tool calls, function-call syntax, or special wrapper tokens."
[triage] # Display-only signal panel (Laya lane; no socket, no extra process)
enabled = false # Master gate: false = no lane, no panel, behavior identical to today
questions_file = "questions.json" # Laya questions JSON in the cwd; missing/invalid -> no_questions
window = 500 # Utterances available per classification; the char budget is the real limiter
state_char_budget = 3000 # Char cap (~750 tokens) for the condensed state sent to Laya
periodic_s = 30.0 # Accumulating-speech cadence (s): while utterances keep arriving without a settled turn, re-run triage every N seconds; <= 0 disables
predict_max_tokens = 1024 # Max tokens sent to Laya; the multilingual checkpoint supports 1024; state shares this budget with the ~192-token question head (~830-token state room)
confidence_field = "answer_confidence" # Gating confidence: "answer_confidence" (calibrated max(p)) | "confidence"
escalate_threshold = 0.6 # noul answers at or above this p are flagged (⚠ marker only)
choice_min_confidence = 0.85 # choice answers below this confidence are flagged (⚠ marker only)
extract_enabled = true # Master switch for the brief engine (plain-text brief update after each Laya pass)
extract_max_tokens = 1536 # Brief reply token cap; the brief is then capped to brief_max_chars characters
extract_timeout_s = 30.0 # Brief request timeout
brief_slot_id = "" # optional llama.cpp id_slot for the brief engine; empty/unset omits it (server needs -np 2)
brief_gate_enabled = true # fail-open gate: update the brief only when the top signal's p >= brief_gate_threshold
brief_gate_threshold = 0.5 # gate threshold; the top signal at/above this p triggers a brief update
brief_max_chars = 4000 # display cap for the brief (truncated, never dropped) when over this
brief_pending_char_budget = 3000 # pending transcript chars that force an immediate flush of a coalesced update
device = "" # "" = auto -> None (Laya picks the multilingual checkpoint's default device)
preload = ["multilingual"] # Fixed default: only the multilingual checkpoint is preloaded
default_checkpoint = "multilingual" # Router default; multilingual is pinned for every call
dump_io = false # Opt-in triage I/O dump: one JSON per triage under triage/YYYY-MM-DD/HHMMSS-mmm.json (Laya request/response + LLM request/response; best-effort, gitignored)
[speaker] # Speaker identification (sherpa-onnx embedding lane; off by default)
enabled = false # Master switch: false = no embedding lane, every record status "none"
model_path = "" # Path to the sherpa-onnx embedding .onnx (e.g. "models/nemo_en_titanet_small.onnx"); empty/unset disables
provider = "cpu" # "cpu" | "cuda"; "cuda" needs a CUDA-enabled sherpa wheel, else warns and falls back to "cpu"
threshold_id = 0.60 # T_id: cosine score at/above which a turn matches an existing prototype
threshold_new = 0.45 # T_new: cosine score below which a turn may mint a new provisional
min_enroll_s = 2.0 # Mint/adapt duration gate: utterances shorter than this never create/adapt
rms_floor = 0.0 # Mint/adapt level gate: utterances quieter than this RMS never create/adapt
margin = 0.04 # Best-2nd margin: require top1 - top2 >= this before adapt/mint decisions
mint_cooldown_s = 60.0 # Cooldown between mints (no second mint within this many seconds)
confirm_s = 12.0 # Cumulative speech seconds a provisional needs to become confirmed
max_prototypes = 8 # Cap on stored prototypes per speaker
provisional_ttl_h = 24.0 # Prune provisionals that never reach confirm_s within this many hours (startup)
store_dir = "speakers" # Store root (cwd-relative); gitignored (biometric artifacts)
join_timeout_s = 2.0 # Embedding-lane join timeout before emitting the transcript
[brief] # Durable brief artifact (plain-text markdown snapshot; off by default)
write = false # false = brief shown in the TUI only; true = also persist under the conversation folder
[debug] # Structured health-signal log (no transcript text; off by default)
enabled = false # false = no debug lines, zero overhead (DebugSink is None)
dir = "debug" # Destination directory (cwd-relative); gitignored, one JSONL per day
heartbeat = true # true = ~1 line/s heartbeat signals; false = anomalies only
max_bytes_per_day = 50000000 # Best-effort per-day byte cap; writes past the cap are skipped
[speaker] adds opt-in speaker identification: a dedicated in-process sherpa-onnx embedding lane
maps each utterance to a vector, which is matched (max over stored prototypes) against the local
store_dir store to label the turn. With enabled = false (the default) the lane is never built
and every record keeps speaker_status = "none". With enabled = true but no model_path (or a
model that fails to load) the feature disables gracefully: it warns once and never crashes the
daemon.
Place the embedding model, nemo_en_titanet_small.onnx (CC-BY-4.0, 192-dim), under models/,
which is gitignored; set model_path = "models/nemo_en_titanet_small.onnx". Download it
with (note the upstream release tag's spelling, speaker-recongition-models):
curl -L -o models/nemo_en_titanet_small.onnx \
"https://github.com/k2-fsa/sherpa-onnx/releases/download/speaker-recongition-models/nemo_en_titanet_small.onnx"
The installed onnxruntime exposes only CPUExecutionProvider, so provider = "cpu" is the
default and the recommended value; provider = "cuda" requires a CUDA-enabled sherpa-onnx
wheel (not onnxruntime-gpu beside the CPU wheel) and otherwise warns and falls back to
"cpu". See SPEAKERS.md for enrollment, thresholds, the store layout, and the
stt-ctl commands.
threshold_id, threshold_new, margin, and the mint gates are model- and channel-dependent.
The defaults are a starting point only — calibrate them on real far-field audio from the actual
microphone/room before trusting the labels, and use the per-record scores to tune.
backend picks the transcription engine. faster-whisper is the default, so nothing changes
unless you opt in. pywhispercpp selects the whisper.cpp backend (GPU via the vendored CUDA
wheel; see PYWHISPERCPP.md). model, language, beam_size,
cpu_threads, no_speech_prob_max, avg_logprob_min, language_strategy, and
sticky_redetect_logprob apply to both; device and compute_type apply only to
faster-whisper; vad_filter is ignored (with a warning) on pywhispercpp. For pywhispercpp
the ggml quantization is chosen by the model name (e.g. small, small-q5_1).
max_prompt_tokens was removed: the prompt budget is derived as
max_context_tokens - max_tokens - compaction_safety_margin, so the window and the trim budget
can never disagree. A legacy max_prompt_tokens key is ignored with a warning.
[triage] adds a display-only signal panel. On each settled turn in any mode, independent of
chat, the assistant classifies the last window transcribed utterances (from the STT stream,
not the chat history) with a local Laya multilingual checkpoint on a dedicated
in-process lane, then, when extract_enabled is true, asks the [llm] lane on a dedicated
brief conversation to fold the turn into a living plain-text brief (on a Laya error the prompt
carries transcript state only). A settled turn means speech stopped plus quiet for
[assistant].turn_gap_s; because a long unbroken monologue never settles, periodic_s
(default 30 seconds) additionally re-runs classification every N seconds while new utterances
keep arriving (a repeated window is still skipped), so the panel stays current mid-stream. Set
periodic_s = 0 to require a settled turn again. extract_enabled stays the brief engine's
master switch: with it false the panel still classifies but no brief update is requested.
device = "" means auto: it maps to None, so Laya picks the
default device for the multilingual checkpoint. preload = ["multilingual"] is the fixed default
and default_checkpoint = "multilingual"; the multilingual checkpoint is pinned for every call
(there is no .en English-only variant). predict_max_tokens is the Laya token budget (the
multilingual checkpoint supports 1024); the condensed state shares it with the ~192-token
question head, leaving ~830 tokens of room for state at the default. confidence_field selects which of Laya's two
confidence values drives the per-item flagged/⚠ marker, with answer_confidence (the
calibrated max(p)) as the default. With enabled = false (the default) the lane is never built and the system behaves
exactly as before. dump_io = true additionally writes one JSON file per triage under
triage/YYYY-MM-DD/HHMMSS-mmm.json (local time, millisecond precision, same naming scheme as
the audio files) containing the Laya request/response and the LLM request/response; it is
off by default, best-effort (a dump failure never disturbs triage), and gitignored.
[llm].triage_system_prompt drives the brief engine: given the current brief (its previous
reply) and a new transcript segment, it must return the updated brief as plain text only, with
no JSON and no markdown fences. The model reply is bounded by [triage].extract_max_tokens
(default 1536) and extract_timeout_s, and the stored/displayed brief is then capped to
brief_max_chars characters (truncated, never dropped). [llm].brief_compaction_system_prompt
is the summarizer used when the brief conversation's own history is compacted.
The gate brief_gate_enabled (default true) is fail-open: a turn updates the brief only when
the top signal's calibrated p is at least brief_gate_threshold (default 0.5), and any
missing or non-numeric signal passes. brief_pending_char_budget bounds how many pending
transcript characters accumulate before a coalesced update is flushed, so a burst of turns does
not stack one in-flight brief job after another.
chat_slot_id, command_slot_id, and brief_slot_id pin each engine's request to a specific
llama.cpp id_slot. When unset (the default) the id_slot field is omitted from the request
body; setting any of them requires the server to expose slot support (llama-server -np 2 or
more), and the server rejects an out-of-range slot.
[brief] controls persistence of that living brief. With write = false (the default) it stays
display-only: the TUI renders it and nothing is written to disk. With write = true the
assistant snapshots the current brief as plain text to
<session_dir>/<YYYY-MM-DD-HH-MM-SS>/brief.md (cwd-relative, gitignored): one folder per
conversation, named by the first transcription's YYYY-MM-DD-HH-MM-SS, holding that
conversation's transcript JSONL, audio, brief, and screenshots, while audit/debug/triage
remain global. Starting a new conversation resets the brief; clearing the brief truncates
the file.
[debug] persists per-component health signals to debug/YYYY-MM-DD.jsonl (one JSON
object per line, ensure_ascii=False) so the next silent freeze is diagnosable from disk.
Signals only — no transcript text, no audio, no prompt content: RMS/queue/timing fields such
as device_ok, worker_stall_s, queue_depth, inference_depth, plus anomaly lines for
capture reopens, inference-queue queue-full / eviction-drop, llm-timeout, worker
worker-stall, triage lifecycle (triage-submit / triage-skip / triage-result /
triage-brief with a trigger/reason, plus triage-brief-failure), and supervisor/child
exit lines. With
enabled = false (the default) no sink is built and the suite stays disk-clean; with
heartbeat = false only anomalies are written. max_bytes_per_day is a best-effort guard:
writes past the cap are skipped with one warning (never crash, never rotate). debug/ lives
on local storage (a slow/NFS/FUSE mount can block the only capturer) and is gitignored like
transcripts/, audit/, and triage/; clean it manually with rm debug/*. The assistant
constructs its sink via debug_sink_factory threaded through runner.main (None when
disabled), and every assistant emit runs after its with self._lock block — never under the
RLock — so a slow write cannot stall the ticker + event reader.
Upgrading from a v5-draft config: socket, model, predict_timeout_s, checkpoint,
subfolder, and max_len are not [triage] keys and are silently ignored by load_config
(per-field .get()). checkpoint is superseded by default_checkpoint; the others have no
successor. The 8192-token long-document max_len capability is out of scope for this feature.
For audio source selection, virtual sink creation, and audio routing, see AUDIO-SETUP.md.