config.py 11 KB

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