"""Configuration management for STT daemon.""" from dataclasses import dataclass, field import logging import tomllib from pathlib import Path from typing import Any logger = logging.getLogger(__name__) KNOWN_SECTIONS = { "audio", "vad", "whisper", "output", "sink", "daemon", "events", "assistant", "audit", "llm", "triage", "speaker", "brief", "debug", } @dataclass class AudioConfig: """Audio capture configuration.""" backend: str = "parec" device: str = "VirtualMicSink.monitor" sample_rate: int = 16000 channels: int = 1 frame_ms: int = 30 gain: float = 1.0 @dataclass class VadConfig: """Voice Activity Detection configuration.""" aggressiveness: int = 2 pre_roll_ms: int = 400 speech_start_ms: int = 90 silence_ms: int = 700 min_utterance_ms: int = 300 max_utterance_s: float = 30.0 chunk_pause_ms: int = 350 soft_max_s: float = 10.0 rms_floor: float = 0.008 trailing_trim_ms: int = 200 @dataclass class WhisperConfig: """Whisper transcription configuration.""" backend: str = "faster-whisper" model: str = "small" device: str = "cpu" compute_type: str = "int8" language: str | None = None beam_size: int = 1 vad_filter: bool = False cpu_threads: int = 0 no_speech_prob_max: float = 0.6 avg_logprob_min: float = -1.0 language_strategy: str = "static" sticky_redetect_logprob: float = -0.8 @dataclass class OutputConfig: """Output configuration.""" stdout: bool = True jsonl: bool = False session_dir: str = "transcripts" save_audio: bool = False session_gap_min: float = 5.0 @dataclass class DaemonConfig: """Daemon runtime configuration.""" queue_size: int = 50 backlog_warn_at: int = 5 @dataclass class EventsConfig: """Publish-only event bus configuration.""" socket: str = "" heartbeat_ms: int = 1000 ring_size: int = 200 @dataclass class AssistantConfig: """Assistant (controller) configuration.""" control_socket: str = "" stall_timeout_ms: int = 3000 worker_stall_ms: int = 15000 command_arm_timeout_s: float = 10.0 chat_idle_timeout_s: float = 300.0 chat_exit_phrases: list[str] = field( default_factory=lambda: ["end conversation", "that's all", "stop listening"] ) require_viewer: bool = True turn_gap_s: float = 1.5 @dataclass class AuditConfig: """Action audit configuration (daily JSONL, like transcripts).""" enabled: bool = True dir: str = "troubleshoot/audit" @dataclass class LlmConfig: """llama.cpp (OpenAI-compatible) configuration.""" enabled: bool = True endpoint: str = "http://localhost:9931/v1/chat/completions" model: str = "" api_key_env: str = "" session_id: str = "" session_header: str = "" user_agent: str = "" reasoning_effort: str = "" extra_body: dict[str, Any] = field(default_factory=dict) temperature: float = 0.7 max_tokens: int = 1024 timeout_s: float = 120.0 cache_prompt: bool = True chat_slot_id: int | None = None command_slot_id: int | None = None max_context_tokens: int = 131072 compaction_safety_margin: int = 256 compaction_enabled: bool = True compaction_trigger_ratio: float = 0.8 compaction_keep_turns: int = 6 compaction_max_tokens: int = 512 compaction_timeout_s: float = 30.0 compaction_input_tokens: int = 8192 compaction_system_prompt: str = ( "You compress a conversation. Preserve names, numbers, decisions, user " "preferences, and unresolved questions. Omit pleasantries. Output only the summary." ) brief_compaction_system_prompt: str = ( "You compress a triage briefing conversation. Preserve every opportunity, blocker, " "owner, responsibility, decision, deadline, and open question; drop pleasantries. " "Output only the summary." ) triage_system_prompt: str = ( "You maintain a living brief of a business conversation. The caller sends " "CURRENT LEDGER plus CURRENT BRIEF plus NEW UTTERANCES. Reply with exactly " "two sections in this order: NEW FACTS: first, then BRIEF:. Facts go first " "so a truncated reply still preserves extraction (truncation safety). In " "NEW FACTS:, emit one fact per line as '- ', or the single line " "'none' when the new utterances add no new facts. Never repeat facts " "already listed in CURRENT LEDGER. In BRIEF:, 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 utterances " "contradict 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. Never silently delete Action Items, Open " "Questions, or Decisions lines. When an action completes or a question " "is answered, do NOT drop the line — move it to Decisions as " "'Done: — resolved: ' (or keep it in place " "with a 'Done (...)' suffix) AND list the resolution as a NEW FACTS " "bullet. Prune only exact duplicates, never resolved history. " "Never emit a 'Prior Details:' section. Output " "the two sections directly; never emit tool calls, function-call syntax, " "or special wrapper tokens." ) inference_queue_size: int = 8 trim_block_turns: int = 4 max_tool_iterations: int = 3 system_prompt_chat: str = ( "You are a concise voice assistant. Reply in 1-3 sentences." ) system_prompt_command: str = ( "You translate a spoken request into a single tool call when one applies." ) @property def prompt_budget(self) -> int: """Prompt tokens available: the context window minus generation and slack.""" return self.max_context_tokens - self.max_tokens - self.compaction_safety_margin @dataclass class TriageConfig: """Laya triage-panel configuration (in-process lane, multilingual pinned).""" enabled: bool = False questions_file: str = "questions.json" window: int = 500 state_char_budget: int = 3000 periodic_s: float = 30.0 predict_max_tokens: int = 1024 confidence_field: str = "answer_confidence" escalate_threshold: float = 0.6 choice_min_confidence: float = 0.85 extract_enabled: bool = True extract_max_tokens: int = 3072 extract_timeout_s: float = 30.0 extract_temperature: float = 0.2 brief_slot_id: int | None = None brief_gate_enabled: bool = True brief_gate_threshold: float = 0.5 brief_max_chars: int = 4000 brief_pending_char_budget: int = 3000 ledger_max_facts: int = 200 fact_max_chars: int = 240 device: str = "" preload: list[str] = field(default_factory=lambda: ["multilingual"]) default_checkpoint: str = "multilingual" dump_io: bool = False @dataclass class SpeakerConfig: """Speaker-identification configuration (sherpa-onnx embedding lane).""" enabled: bool = False model_path: str | None = None provider: str = "cpu" threshold_id: float = 0.60 threshold_new: float = 0.45 min_enroll_s: float = 2.0 rms_floor: float = 0.0 margin: float = 0.04 mint_cooldown_s: float = 60.0 confirm_s: float = 12.0 max_prototypes: int = 8 provisional_ttl_h: float = 24.0 store_dir: str = "speakers" join_timeout_s: float = 2.0 @dataclass class BriefConfig: """Durable brief document configuration (markdown snapshot written to disk).""" write: bool = False @dataclass class DebugConfig: """Debug signal-log configuration (JSONL diagnostics, gitignored).""" enabled: bool = False dir: str = "troubleshoot/debug" heartbeat: bool = True max_bytes_per_day: int = 50000000 @dataclass class SinkConfig: """Virtual audio sink configuration.""" sink_name: str = "VirtualMicSink" remap_source_name: str = "VirtualMic" loopback_latency_msec: int = 1 monitor_output: bool = False @dataclass class Config: """Complete daemon configuration.""" audio: AudioConfig = field(default_factory=AudioConfig) vad: VadConfig = field(default_factory=VadConfig) whisper: WhisperConfig = field(default_factory=WhisperConfig) output: OutputConfig = field(default_factory=OutputConfig) sink: SinkConfig = field(default_factory=SinkConfig) daemon: DaemonConfig = field(default_factory=DaemonConfig) events: EventsConfig = field(default_factory=EventsConfig) assistant: AssistantConfig = field(default_factory=AssistantConfig) audit: AuditConfig = field(default_factory=AuditConfig) llm: LlmConfig = field(default_factory=LlmConfig) triage: TriageConfig = field(default_factory=TriageConfig) speaker: SpeakerConfig = field(default_factory=SpeakerConfig) brief: BriefConfig = field(default_factory=BriefConfig) debug: DebugConfig = field(default_factory=DebugConfig) def _optional_int(value: Any) -> int | None: """Coerce an optional TOML value to int, degrading to None on bad input. TOML has no null, so an absent key is "unset" and reaches this helper as ``None``. ``0`` is a valid slot id and must never be mistaken for unset. """ if value is None: return None try: return int(value) except (TypeError, ValueError): return None def load_config(path: str | None = None) -> Config: """Load configuration from TOML file, with defaults. Args: path: Path to config.toml. If None, looks for config.toml in cwd, then uses all defaults. Returns: Config with loaded values overlayed on defaults. """ cfg = Config() # Try to find and load config file config_path = None if path: config_path = Path(path) else: default_path = Path("config.toml") if default_path.exists(): config_path = default_path if config_path and config_path.exists(): with open(config_path, "rb") as f: data = tomllib.load(f) unknown = set(data) - KNOWN_SECTIONS if unknown: logger.warning( "Ignoring unknown config sections: %s", ", ".join(sorted(unknown)) ) # Audio section if "audio" in data: audio_data = data["audio"] cfg.audio = AudioConfig( backend=audio_data.get("backend", cfg.audio.backend), device=audio_data.get("device", cfg.audio.device), sample_rate=audio_data.get("sample_rate", cfg.audio.sample_rate), channels=audio_data.get("channels", cfg.audio.channels), frame_ms=audio_data.get("frame_ms", cfg.audio.frame_ms), gain=audio_data.get("gain", cfg.audio.gain), ) # VAD section if "vad" in data: vad_data = data["vad"] cfg.vad = VadConfig( aggressiveness=vad_data.get("aggressiveness", cfg.vad.aggressiveness), pre_roll_ms=vad_data.get("pre_roll_ms", cfg.vad.pre_roll_ms), speech_start_ms=vad_data.get("speech_start_ms", cfg.vad.speech_start_ms), silence_ms=vad_data.get("silence_ms", cfg.vad.silence_ms), min_utterance_ms=vad_data.get("min_utterance_ms", cfg.vad.min_utterance_ms), max_utterance_s=vad_data.get("max_utterance_s", cfg.vad.max_utterance_s), chunk_pause_ms=vad_data.get("chunk_pause_ms", cfg.vad.chunk_pause_ms), soft_max_s=vad_data.get("soft_max_s", cfg.vad.soft_max_s), rms_floor=vad_data.get("rms_floor", cfg.vad.rms_floor), trailing_trim_ms=vad_data.get("trailing_trim_ms", cfg.vad.trailing_trim_ms), ) # Whisper section if "whisper" in data: whisper_data = data["whisper"] backend = whisper_data.get("backend", cfg.whisper.backend) backend = backend.strip().lower().replace("_", "-") if backend not in ("faster-whisper", "pywhispercpp"): logger.warning( "Unknown whisper backend %r; falling back to 'faster-whisper'", backend ) backend = "faster-whisper" language = whisper_data.get("language", cfg.whisper.language) # Normalize language: "", "auto", "none" -> None if language in ("", "auto", "none"): language = None strategy = whisper_data.get("language_strategy", cfg.whisper.language_strategy) if strategy not in ("static", "sticky"): logger.warning( "Unknown language_strategy %r; falling back to 'static'", strategy ) strategy = "static" cfg.whisper = WhisperConfig( backend=backend, model=whisper_data.get("model", cfg.whisper.model), device=whisper_data.get("device", cfg.whisper.device), compute_type=whisper_data.get("compute_type", cfg.whisper.compute_type), language=language, beam_size=whisper_data.get("beam_size", cfg.whisper.beam_size), vad_filter=whisper_data.get("vad_filter", cfg.whisper.vad_filter), cpu_threads=whisper_data.get("cpu_threads", cfg.whisper.cpu_threads), no_speech_prob_max=whisper_data.get( "no_speech_prob_max", cfg.whisper.no_speech_prob_max ), avg_logprob_min=whisper_data.get( "avg_logprob_min", cfg.whisper.avg_logprob_min ), language_strategy=strategy, sticky_redetect_logprob=whisper_data.get( "sticky_redetect_logprob", cfg.whisper.sticky_redetect_logprob ), ) # Output section if "output" in data: output_data = data["output"] cfg.output = OutputConfig( stdout=output_data.get("stdout", cfg.output.stdout), jsonl=output_data.get("jsonl", cfg.output.jsonl), session_dir=output_data.get("session_dir", cfg.output.session_dir), save_audio=output_data.get("save_audio", cfg.output.save_audio), session_gap_min=output_data.get( "session_gap_min", cfg.output.session_gap_min ), ) # Sink section if "sink" in data: sink_data = data["sink"] cfg.sink = SinkConfig( sink_name=sink_data.get("sink_name", cfg.sink.sink_name), remap_source_name=sink_data.get( "remap_source_name", cfg.sink.remap_source_name ), loopback_latency_msec=sink_data.get( "loopback_latency_msec", cfg.sink.loopback_latency_msec ), monitor_output=sink_data.get("monitor_output", cfg.sink.monitor_output), ) # Daemon section if "daemon" in data: daemon_data = data["daemon"] cfg.daemon = DaemonConfig( queue_size=daemon_data.get("queue_size", cfg.daemon.queue_size), backlog_warn_at=daemon_data.get( "backlog_warn_at", cfg.daemon.backlog_warn_at ), ) # Events section if "events" in data: events_data = data["events"] cfg.events = EventsConfig( socket=events_data.get("socket", cfg.events.socket), heartbeat_ms=events_data.get("heartbeat_ms", cfg.events.heartbeat_ms), ring_size=events_data.get("ring_size", cfg.events.ring_size), ) # Assistant section if "assistant" in data: assistant_data = data["assistant"] cfg.assistant = AssistantConfig( control_socket=assistant_data.get( "control_socket", cfg.assistant.control_socket ), stall_timeout_ms=assistant_data.get( "stall_timeout_ms", cfg.assistant.stall_timeout_ms ), worker_stall_ms=assistant_data.get( "worker_stall_ms", cfg.assistant.worker_stall_ms ), command_arm_timeout_s=assistant_data.get( "command_arm_timeout_s", cfg.assistant.command_arm_timeout_s ), chat_idle_timeout_s=assistant_data.get( "chat_idle_timeout_s", cfg.assistant.chat_idle_timeout_s ), chat_exit_phrases=list( assistant_data.get( "chat_exit_phrases", cfg.assistant.chat_exit_phrases ) ), require_viewer=assistant_data.get( "require_viewer", cfg.assistant.require_viewer ), turn_gap_s=assistant_data.get("turn_gap_s", cfg.assistant.turn_gap_s), ) # Audit section if "audit" in data: audit_data = data["audit"] cfg.audit = AuditConfig( enabled=audit_data.get("enabled", cfg.audit.enabled), dir=audit_data.get("dir", cfg.audit.dir), ) # LLM section if "llm" in data: llm_data = data["llm"] if "max_prompt_tokens" in llm_data: logger.warning( "llm.max_prompt_tokens is deprecated and ignored; the prompt budget is " "derived as max_context_tokens - max_tokens - compaction_safety_margin" ) cfg.llm = LlmConfig( enabled=llm_data.get("enabled", cfg.llm.enabled), endpoint=llm_data.get("endpoint", cfg.llm.endpoint), model=llm_data.get("model", cfg.llm.model), api_key_env=llm_data.get("api_key_env", cfg.llm.api_key_env), session_id=llm_data.get("session_id", cfg.llm.session_id), session_header=llm_data.get("session_header", cfg.llm.session_header), user_agent=llm_data.get("user_agent", cfg.llm.user_agent), reasoning_effort=llm_data.get( "reasoning_effort", cfg.llm.reasoning_effort ), extra_body=dict(llm_data.get("extra_body", cfg.llm.extra_body)), temperature=llm_data.get("temperature", cfg.llm.temperature), max_tokens=llm_data.get("max_tokens", cfg.llm.max_tokens), timeout_s=llm_data.get("timeout_s", cfg.llm.timeout_s), cache_prompt=llm_data.get("cache_prompt", cfg.llm.cache_prompt), chat_slot_id=_optional_int(llm_data.get("chat_slot_id")), command_slot_id=_optional_int(llm_data.get("command_slot_id")), max_context_tokens=llm_data.get( "max_context_tokens", cfg.llm.max_context_tokens ), compaction_safety_margin=llm_data.get( "compaction_safety_margin", cfg.llm.compaction_safety_margin ), compaction_enabled=llm_data.get( "compaction_enabled", cfg.llm.compaction_enabled ), compaction_trigger_ratio=llm_data.get( "compaction_trigger_ratio", cfg.llm.compaction_trigger_ratio ), compaction_keep_turns=llm_data.get( "compaction_keep_turns", cfg.llm.compaction_keep_turns ), compaction_max_tokens=llm_data.get( "compaction_max_tokens", cfg.llm.compaction_max_tokens ), compaction_timeout_s=llm_data.get( "compaction_timeout_s", cfg.llm.compaction_timeout_s ), compaction_input_tokens=llm_data.get( "compaction_input_tokens", cfg.llm.compaction_input_tokens ), compaction_system_prompt=llm_data.get( "compaction_system_prompt", cfg.llm.compaction_system_prompt ), brief_compaction_system_prompt=llm_data.get( "brief_compaction_system_prompt", cfg.llm.brief_compaction_system_prompt ), triage_system_prompt=llm_data.get( "triage_system_prompt", cfg.llm.triage_system_prompt ), inference_queue_size=llm_data.get( "inference_queue_size", cfg.llm.inference_queue_size ), trim_block_turns=llm_data.get( "trim_block_turns", cfg.llm.trim_block_turns ), max_tool_iterations=llm_data.get( "max_tool_iterations", cfg.llm.max_tool_iterations ), system_prompt_chat=llm_data.get( "system_prompt_chat", cfg.llm.system_prompt_chat ), system_prompt_command=llm_data.get( "system_prompt_command", cfg.llm.system_prompt_command ), ) # Triage section if "triage" in data: triage_data = data["triage"] cfg.triage = TriageConfig( enabled=triage_data.get("enabled", cfg.triage.enabled), questions_file=triage_data.get("questions_file", cfg.triage.questions_file), window=triage_data.get("window", cfg.triage.window), state_char_budget=triage_data.get( "state_char_budget", cfg.triage.state_char_budget ), periodic_s=triage_data.get("periodic_s", cfg.triage.periodic_s), predict_max_tokens=triage_data.get( "predict_max_tokens", cfg.triage.predict_max_tokens ), confidence_field=triage_data.get( "confidence_field", cfg.triage.confidence_field ), escalate_threshold=triage_data.get( "escalate_threshold", cfg.triage.escalate_threshold ), choice_min_confidence=triage_data.get( "choice_min_confidence", cfg.triage.choice_min_confidence ), extract_enabled=triage_data.get( "extract_enabled", cfg.triage.extract_enabled ), extract_max_tokens=triage_data.get( "extract_max_tokens", cfg.triage.extract_max_tokens ), extract_timeout_s=triage_data.get( "extract_timeout_s", cfg.triage.extract_timeout_s ), extract_temperature=triage_data.get( "extract_temperature", cfg.triage.extract_temperature ), brief_slot_id=_optional_int(triage_data.get("brief_slot_id")), brief_gate_enabled=triage_data.get( "brief_gate_enabled", cfg.triage.brief_gate_enabled ), brief_gate_threshold=triage_data.get( "brief_gate_threshold", cfg.triage.brief_gate_threshold ), brief_max_chars=triage_data.get( "brief_max_chars", cfg.triage.brief_max_chars ), brief_pending_char_budget=triage_data.get( "brief_pending_char_budget", cfg.triage.brief_pending_char_budget ), ledger_max_facts=triage_data.get( "ledger_max_facts", cfg.triage.ledger_max_facts ), fact_max_chars=triage_data.get( "fact_max_chars", cfg.triage.fact_max_chars ), device=triage_data.get("device", cfg.triage.device), preload=list(triage_data.get("preload", cfg.triage.preload)), default_checkpoint=triage_data.get( "default_checkpoint", cfg.triage.default_checkpoint ), dump_io=triage_data.get("dump_io", cfg.triage.dump_io), ) # Speaker section if "speaker" in data: speaker_data = data["speaker"] provider = speaker_data.get("provider", cfg.speaker.provider) provider = provider.strip().lower() if provider not in ("cpu", "cuda"): logger.warning( "Unknown speaker provider %r; falling back to 'cpu'", provider ) provider = "cpu" cfg.speaker = SpeakerConfig( enabled=speaker_data.get("enabled", cfg.speaker.enabled), model_path=speaker_data.get("model_path", cfg.speaker.model_path), provider=provider, threshold_id=speaker_data.get("threshold_id", cfg.speaker.threshold_id), threshold_new=speaker_data.get("threshold_new", cfg.speaker.threshold_new), min_enroll_s=speaker_data.get("min_enroll_s", cfg.speaker.min_enroll_s), rms_floor=speaker_data.get("rms_floor", cfg.speaker.rms_floor), margin=speaker_data.get("margin", cfg.speaker.margin), mint_cooldown_s=speaker_data.get( "mint_cooldown_s", cfg.speaker.mint_cooldown_s ), confirm_s=speaker_data.get("confirm_s", cfg.speaker.confirm_s), max_prototypes=speaker_data.get("max_prototypes", cfg.speaker.max_prototypes), provisional_ttl_h=speaker_data.get( "provisional_ttl_h", cfg.speaker.provisional_ttl_h ), store_dir=speaker_data.get("store_dir", cfg.speaker.store_dir), join_timeout_s=speaker_data.get("join_timeout_s", cfg.speaker.join_timeout_s), ) # Brief section if "brief" in data: brief_data = data["brief"] cfg.brief = BriefConfig( write=brief_data.get("write", cfg.brief.write), ) # Debug section if "debug" in data: debug_data = data["debug"] cfg.debug = DebugConfig( enabled=debug_data.get("enabled", cfg.debug.enabled), dir=debug_data.get("dir", cfg.debug.dir), heartbeat=debug_data.get("heartbeat", cfg.debug.heartbeat), max_bytes_per_day=debug_data.get( "max_bytes_per_day", cfg.debug.max_bytes_per_day ), ) return cfg