config.py 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356
  1. """Configuration management for STT daemon."""
  2. from dataclasses import dataclass, field
  3. import logging
  4. import tomllib
  5. from pathlib import Path
  6. logger = logging.getLogger(__name__)
  7. KNOWN_SECTIONS = {
  8. "audio",
  9. "vad",
  10. "whisper",
  11. "output",
  12. "sink",
  13. "daemon",
  14. "events",
  15. "assistant",
  16. "audit",
  17. "llm",
  18. }
  19. @dataclass
  20. class AudioConfig:
  21. """Audio capture configuration."""
  22. backend: str = "parec"
  23. device: str = "VirtualMicSink.monitor"
  24. sample_rate: int = 16000
  25. channels: int = 1
  26. frame_ms: int = 30
  27. gain: float = 1.0
  28. @dataclass
  29. class VadConfig:
  30. """Voice Activity Detection configuration."""
  31. aggressiveness: int = 2
  32. pre_roll_ms: int = 400
  33. speech_start_ms: int = 90
  34. silence_ms: int = 700
  35. min_utterance_ms: int = 300
  36. max_utterance_s: float = 30.0
  37. chunk_pause_ms: int = 350
  38. soft_max_s: float = 10.0
  39. rms_floor: float = 0.008
  40. trailing_trim_ms: int = 200
  41. @dataclass
  42. class WhisperConfig:
  43. """Whisper transcription configuration."""
  44. model: str = "small"
  45. device: str = "cpu"
  46. compute_type: str = "int8"
  47. language: str | None = None
  48. beam_size: int = 1
  49. vad_filter: bool = False
  50. cpu_threads: int = 0
  51. no_speech_prob_max: float = 0.6
  52. avg_logprob_min: float = -1.0
  53. language_strategy: str = "static"
  54. sticky_redetect_logprob: float = -0.8
  55. @dataclass
  56. class OutputConfig:
  57. """Output configuration."""
  58. stdout: bool = True
  59. jsonl_dir: str | None = "transcripts"
  60. save_audio: bool = False
  61. audio_dir: str = "audio"
  62. @dataclass
  63. class DaemonConfig:
  64. """Daemon runtime configuration."""
  65. queue_size: int = 50
  66. backlog_warn_at: int = 5
  67. @dataclass
  68. class EventsConfig:
  69. """Publish-only event bus configuration."""
  70. socket: str = ""
  71. heartbeat_ms: int = 1000
  72. ring_size: int = 200
  73. @dataclass
  74. class AssistantConfig:
  75. """Assistant (controller) configuration."""
  76. control_socket: str = ""
  77. stall_timeout_ms: int = 3000
  78. worker_stall_ms: int = 15000
  79. command_arm_timeout_s: float = 10.0
  80. chat_idle_timeout_s: float = 300.0
  81. chat_exit_phrases: list[str] = field(
  82. default_factory=lambda: ["end conversation", "that's all", "stop listening"]
  83. )
  84. require_viewer: bool = True
  85. turn_gap_s: float = 1.5
  86. @dataclass
  87. class AuditConfig:
  88. """Action audit configuration (daily JSONL, like transcripts)."""
  89. enabled: bool = True
  90. dir: str = "audit"
  91. @dataclass
  92. class LlmConfig:
  93. """llama.cpp (OpenAI-compatible) configuration."""
  94. enabled: bool = True
  95. endpoint: str = "http://localhost:9931/v1/chat/completions"
  96. temperature: float = 0.7
  97. max_tokens: int = 1024
  98. timeout_s: float = 120.0
  99. cache_prompt: bool = True
  100. max_prompt_tokens: int = 64000
  101. trim_block_turns: int = 4
  102. max_tool_iterations: int = 3
  103. system_prompt_chat: str = (
  104. "You are a concise voice assistant. Reply in 1-3 sentences."
  105. )
  106. system_prompt_command: str = (
  107. "You translate a spoken request into a single tool call when one applies."
  108. )
  109. @dataclass
  110. class SinkConfig:
  111. """Virtual audio sink configuration."""
  112. sink_name: str = "VirtualMicSink"
  113. remap_source_name: str = "VirtualMic"
  114. loopback_latency_msec: int = 1
  115. monitor_output: bool = False
  116. @dataclass
  117. class Config:
  118. """Complete daemon configuration."""
  119. audio: AudioConfig = field(default_factory=AudioConfig)
  120. vad: VadConfig = field(default_factory=VadConfig)
  121. whisper: WhisperConfig = field(default_factory=WhisperConfig)
  122. output: OutputConfig = field(default_factory=OutputConfig)
  123. sink: SinkConfig = field(default_factory=SinkConfig)
  124. daemon: DaemonConfig = field(default_factory=DaemonConfig)
  125. events: EventsConfig = field(default_factory=EventsConfig)
  126. assistant: AssistantConfig = field(default_factory=AssistantConfig)
  127. audit: AuditConfig = field(default_factory=AuditConfig)
  128. llm: LlmConfig = field(default_factory=LlmConfig)
  129. def load_config(path: str | None = None) -> Config:
  130. """Load configuration from TOML file, with defaults.
  131. Args:
  132. path: Path to config.toml. If None, looks for config.toml in cwd,
  133. then uses all defaults.
  134. Returns:
  135. Config with loaded values overlayed on defaults.
  136. """
  137. cfg = Config()
  138. # Try to find and load config file
  139. config_path = None
  140. if path:
  141. config_path = Path(path)
  142. else:
  143. default_path = Path("config.toml")
  144. if default_path.exists():
  145. config_path = default_path
  146. if config_path and config_path.exists():
  147. with open(config_path, "rb") as f:
  148. data = tomllib.load(f)
  149. unknown = set(data) - KNOWN_SECTIONS
  150. if unknown:
  151. logger.warning(
  152. "Ignoring unknown config sections: %s", ", ".join(sorted(unknown))
  153. )
  154. # Audio section
  155. if "audio" in data:
  156. audio_data = data["audio"]
  157. cfg.audio = AudioConfig(
  158. backend=audio_data.get("backend", cfg.audio.backend),
  159. device=audio_data.get("device", cfg.audio.device),
  160. sample_rate=audio_data.get("sample_rate", cfg.audio.sample_rate),
  161. channels=audio_data.get("channels", cfg.audio.channels),
  162. frame_ms=audio_data.get("frame_ms", cfg.audio.frame_ms),
  163. gain=audio_data.get("gain", cfg.audio.gain),
  164. )
  165. # VAD section
  166. if "vad" in data:
  167. vad_data = data["vad"]
  168. cfg.vad = VadConfig(
  169. aggressiveness=vad_data.get("aggressiveness", cfg.vad.aggressiveness),
  170. pre_roll_ms=vad_data.get("pre_roll_ms", cfg.vad.pre_roll_ms),
  171. speech_start_ms=vad_data.get("speech_start_ms", cfg.vad.speech_start_ms),
  172. silence_ms=vad_data.get("silence_ms", cfg.vad.silence_ms),
  173. min_utterance_ms=vad_data.get("min_utterance_ms", cfg.vad.min_utterance_ms),
  174. max_utterance_s=vad_data.get("max_utterance_s", cfg.vad.max_utterance_s),
  175. chunk_pause_ms=vad_data.get("chunk_pause_ms", cfg.vad.chunk_pause_ms),
  176. soft_max_s=vad_data.get("soft_max_s", cfg.vad.soft_max_s),
  177. rms_floor=vad_data.get("rms_floor", cfg.vad.rms_floor),
  178. trailing_trim_ms=vad_data.get("trailing_trim_ms", cfg.vad.trailing_trim_ms),
  179. )
  180. # Whisper section
  181. if "whisper" in data:
  182. whisper_data = data["whisper"]
  183. language = whisper_data.get("language", cfg.whisper.language)
  184. # Normalize language: "", "auto", "none" -> None
  185. if language in ("", "auto", "none"):
  186. language = None
  187. strategy = whisper_data.get("language_strategy", cfg.whisper.language_strategy)
  188. if strategy not in ("static", "sticky"):
  189. logger.warning(
  190. "Unknown language_strategy %r; falling back to 'static'", strategy
  191. )
  192. strategy = "static"
  193. cfg.whisper = WhisperConfig(
  194. model=whisper_data.get("model", cfg.whisper.model),
  195. device=whisper_data.get("device", cfg.whisper.device),
  196. compute_type=whisper_data.get("compute_type", cfg.whisper.compute_type),
  197. language=language,
  198. beam_size=whisper_data.get("beam_size", cfg.whisper.beam_size),
  199. vad_filter=whisper_data.get("vad_filter", cfg.whisper.vad_filter),
  200. cpu_threads=whisper_data.get("cpu_threads", cfg.whisper.cpu_threads),
  201. no_speech_prob_max=whisper_data.get(
  202. "no_speech_prob_max", cfg.whisper.no_speech_prob_max
  203. ),
  204. avg_logprob_min=whisper_data.get(
  205. "avg_logprob_min", cfg.whisper.avg_logprob_min
  206. ),
  207. language_strategy=strategy,
  208. sticky_redetect_logprob=whisper_data.get(
  209. "sticky_redetect_logprob", cfg.whisper.sticky_redetect_logprob
  210. ),
  211. )
  212. # Output section
  213. if "output" in data:
  214. output_data = data["output"]
  215. cfg.output = OutputConfig(
  216. stdout=output_data.get("stdout", cfg.output.stdout),
  217. jsonl_dir=output_data.get("jsonl_dir", cfg.output.jsonl_dir),
  218. save_audio=output_data.get("save_audio", cfg.output.save_audio),
  219. audio_dir=output_data.get("audio_dir", cfg.output.audio_dir),
  220. )
  221. # Sink section
  222. if "sink" in data:
  223. sink_data = data["sink"]
  224. cfg.sink = SinkConfig(
  225. sink_name=sink_data.get("sink_name", cfg.sink.sink_name),
  226. remap_source_name=sink_data.get(
  227. "remap_source_name", cfg.sink.remap_source_name
  228. ),
  229. loopback_latency_msec=sink_data.get(
  230. "loopback_latency_msec", cfg.sink.loopback_latency_msec
  231. ),
  232. monitor_output=sink_data.get("monitor_output", cfg.sink.monitor_output),
  233. )
  234. # Daemon section
  235. if "daemon" in data:
  236. daemon_data = data["daemon"]
  237. cfg.daemon = DaemonConfig(
  238. queue_size=daemon_data.get("queue_size", cfg.daemon.queue_size),
  239. backlog_warn_at=daemon_data.get(
  240. "backlog_warn_at", cfg.daemon.backlog_warn_at
  241. ),
  242. )
  243. # Events section
  244. if "events" in data:
  245. events_data = data["events"]
  246. cfg.events = EventsConfig(
  247. socket=events_data.get("socket", cfg.events.socket),
  248. heartbeat_ms=events_data.get("heartbeat_ms", cfg.events.heartbeat_ms),
  249. ring_size=events_data.get("ring_size", cfg.events.ring_size),
  250. )
  251. # Assistant section
  252. if "assistant" in data:
  253. assistant_data = data["assistant"]
  254. cfg.assistant = AssistantConfig(
  255. control_socket=assistant_data.get(
  256. "control_socket", cfg.assistant.control_socket
  257. ),
  258. stall_timeout_ms=assistant_data.get(
  259. "stall_timeout_ms", cfg.assistant.stall_timeout_ms
  260. ),
  261. worker_stall_ms=assistant_data.get(
  262. "worker_stall_ms", cfg.assistant.worker_stall_ms
  263. ),
  264. command_arm_timeout_s=assistant_data.get(
  265. "command_arm_timeout_s", cfg.assistant.command_arm_timeout_s
  266. ),
  267. chat_idle_timeout_s=assistant_data.get(
  268. "chat_idle_timeout_s", cfg.assistant.chat_idle_timeout_s
  269. ),
  270. chat_exit_phrases=list(
  271. assistant_data.get(
  272. "chat_exit_phrases", cfg.assistant.chat_exit_phrases
  273. )
  274. ),
  275. require_viewer=assistant_data.get(
  276. "require_viewer", cfg.assistant.require_viewer
  277. ),
  278. turn_gap_s=assistant_data.get("turn_gap_s", cfg.assistant.turn_gap_s),
  279. )
  280. # Audit section
  281. if "audit" in data:
  282. audit_data = data["audit"]
  283. cfg.audit = AuditConfig(
  284. enabled=audit_data.get("enabled", cfg.audit.enabled),
  285. dir=audit_data.get("dir", cfg.audit.dir),
  286. )
  287. # LLM section
  288. if "llm" in data:
  289. llm_data = data["llm"]
  290. cfg.llm = LlmConfig(
  291. enabled=llm_data.get("enabled", cfg.llm.enabled),
  292. endpoint=llm_data.get("endpoint", cfg.llm.endpoint),
  293. temperature=llm_data.get("temperature", cfg.llm.temperature),
  294. max_tokens=llm_data.get("max_tokens", cfg.llm.max_tokens),
  295. timeout_s=llm_data.get("timeout_s", cfg.llm.timeout_s),
  296. cache_prompt=llm_data.get("cache_prompt", cfg.llm.cache_prompt),
  297. max_prompt_tokens=llm_data.get(
  298. "max_prompt_tokens", cfg.llm.max_prompt_tokens
  299. ),
  300. trim_block_turns=llm_data.get(
  301. "trim_block_turns", cfg.llm.trim_block_turns
  302. ),
  303. max_tool_iterations=llm_data.get(
  304. "max_tool_iterations", cfg.llm.max_tool_iterations
  305. ),
  306. system_prompt_chat=llm_data.get(
  307. "system_prompt_chat", cfg.llm.system_prompt_chat
  308. ),
  309. system_prompt_command=llm_data.get(
  310. "system_prompt_command", cfg.llm.system_prompt_command
  311. ),
  312. )
  313. return cfg