CONFIGURATION.md 20 KB

Configuration

Annotated reference for every stt-daemon configuration key; config.example.toml ships the values only.

Quick Start

Copy the example config and edit as needed:

cp config.example.toml config.toml
# Edit config.toml to adjust settings (see section below)

Configuration Reference

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)
jsonl_dir  = "transcripts"            # JSONL destination (one file per day); only used if jsonl = true
save_audio = false                    # Also write each transcribed utterance as a 16 kHz mono WAV
audio_dir  = "audio"                  # WAV destination (YYYY-MM-DD subfolders); only used if save_audio

[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. Preserve prior content unless the new segment contradicts it; merge duplicates; prune stale items; keep owners, responsibilities, and deadlines explicit. Use short sections: 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
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 to disk
dir      = "brief"                          # Destination directory (cwd-relative); gitignored
filename = "brief.md"                       # Snapshot filename inside dir

[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). 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 dir/filename (default brief/brief.md, cwd-relative, gitignored); clearing the brief truncates the file.

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.