config.py 25 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639
  1. """Configuration management for STT daemon."""
  2. from dataclasses import dataclass, field
  3. import logging
  4. import tomllib
  5. from pathlib import Path
  6. from typing import Any
  7. logger = logging.getLogger(__name__)
  8. KNOWN_SECTIONS = {
  9. "audio",
  10. "vad",
  11. "whisper",
  12. "output",
  13. "sink",
  14. "daemon",
  15. "events",
  16. "assistant",
  17. "audit",
  18. "llm",
  19. "triage",
  20. "speaker",
  21. "brief",
  22. "debug",
  23. }
  24. @dataclass
  25. class AudioConfig:
  26. """Audio capture configuration."""
  27. backend: str = "parec"
  28. device: str = "VirtualMicSink.monitor"
  29. sample_rate: int = 16000
  30. channels: int = 1
  31. frame_ms: int = 30
  32. gain: float = 1.0
  33. @dataclass
  34. class VadConfig:
  35. """Voice Activity Detection configuration."""
  36. aggressiveness: int = 2
  37. pre_roll_ms: int = 400
  38. speech_start_ms: int = 90
  39. silence_ms: int = 700
  40. min_utterance_ms: int = 300
  41. max_utterance_s: float = 30.0
  42. chunk_pause_ms: int = 350
  43. soft_max_s: float = 10.0
  44. rms_floor: float = 0.008
  45. trailing_trim_ms: int = 200
  46. @dataclass
  47. class WhisperConfig:
  48. """Whisper transcription configuration."""
  49. backend: str = "faster-whisper"
  50. model: str = "small"
  51. device: str = "cpu"
  52. compute_type: str = "int8"
  53. language: str | None = None
  54. beam_size: int = 1
  55. vad_filter: bool = False
  56. cpu_threads: int = 0
  57. no_speech_prob_max: float = 0.6
  58. avg_logprob_min: float = -1.0
  59. language_strategy: str = "static"
  60. sticky_redetect_logprob: float = -0.8
  61. @dataclass
  62. class OutputConfig:
  63. """Output configuration."""
  64. stdout: bool = True
  65. jsonl: bool = False
  66. session_dir: str = "transcripts"
  67. save_audio: bool = False
  68. session_gap_min: float = 5.0
  69. @dataclass
  70. class DaemonConfig:
  71. """Daemon runtime configuration."""
  72. queue_size: int = 50
  73. backlog_warn_at: int = 5
  74. @dataclass
  75. class EventsConfig:
  76. """Publish-only event bus configuration."""
  77. socket: str = ""
  78. heartbeat_ms: int = 1000
  79. ring_size: int = 200
  80. @dataclass
  81. class AssistantConfig:
  82. """Assistant (controller) configuration."""
  83. control_socket: str = ""
  84. stall_timeout_ms: int = 3000
  85. worker_stall_ms: int = 15000
  86. command_arm_timeout_s: float = 10.0
  87. chat_idle_timeout_s: float = 300.0
  88. chat_exit_phrases: list[str] = field(
  89. default_factory=lambda: ["end conversation", "that's all", "stop listening"]
  90. )
  91. require_viewer: bool = True
  92. turn_gap_s: float = 1.5
  93. @dataclass
  94. class AuditConfig:
  95. """Action audit configuration (daily JSONL, like transcripts)."""
  96. enabled: bool = True
  97. dir: str = "troubleshoot/audit"
  98. @dataclass
  99. class LlmConfig:
  100. """llama.cpp (OpenAI-compatible) configuration."""
  101. enabled: bool = True
  102. endpoint: str = "http://localhost:9931/v1/chat/completions"
  103. temperature: float = 0.7
  104. max_tokens: int = 1024
  105. timeout_s: float = 120.0
  106. cache_prompt: bool = True
  107. chat_slot_id: int | None = None
  108. command_slot_id: int | None = None
  109. max_context_tokens: int = 131072
  110. compaction_safety_margin: int = 256
  111. compaction_enabled: bool = True
  112. compaction_trigger_ratio: float = 0.8
  113. compaction_keep_turns: int = 6
  114. compaction_max_tokens: int = 512
  115. compaction_timeout_s: float = 30.0
  116. compaction_input_tokens: int = 8192
  117. compaction_system_prompt: str = (
  118. "You compress a conversation. Preserve names, numbers, decisions, user "
  119. "preferences, and unresolved questions. Omit pleasantries. Output only the summary."
  120. )
  121. brief_compaction_system_prompt: str = (
  122. "You compress a triage briefing conversation. Preserve every opportunity, blocker, "
  123. "owner, responsibility, decision, deadline, and open question; drop pleasantries. "
  124. "Output only the summary."
  125. )
  126. triage_system_prompt: str = (
  127. "You maintain a living brief of a business conversation. Given the current brief "
  128. "(your previous reply, if any) and a new transcript segment, return the UPDATED "
  129. "brief as plain text only, with no markdown fences and no JSON. Open with a Summary "
  130. "section: a rolling overview of the conversation so far, one to a few sentences for "
  131. "a short session and growing to a short paragraph or two as the session extends. "
  132. "Preserve prior content unless the new segment contradicts it; merge duplicates; "
  133. "prune stale items; keep owners, responsibilities, and deadlines explicit. Use short "
  134. "sections: Summary, Opportunities, Blockers/Risks, Responsibilities, Decisions, "
  135. "Action Items, Deadlines, Open Questions. Output the brief text directly; never "
  136. "emit tool calls, function-call syntax, or special wrapper tokens."
  137. )
  138. inference_queue_size: int = 8
  139. trim_block_turns: int = 4
  140. max_tool_iterations: int = 3
  141. system_prompt_chat: str = (
  142. "You are a concise voice assistant. Reply in 1-3 sentences."
  143. )
  144. system_prompt_command: str = (
  145. "You translate a spoken request into a single tool call when one applies."
  146. )
  147. @property
  148. def prompt_budget(self) -> int:
  149. """Prompt tokens available: the context window minus generation and slack."""
  150. return self.max_context_tokens - self.max_tokens - self.compaction_safety_margin
  151. @dataclass
  152. class TriageConfig:
  153. """Laya triage-panel configuration (in-process lane, multilingual pinned)."""
  154. enabled: bool = False
  155. questions_file: str = "questions.json"
  156. window: int = 500
  157. state_char_budget: int = 3000
  158. periodic_s: float = 30.0
  159. predict_max_tokens: int = 1024
  160. confidence_field: str = "answer_confidence"
  161. escalate_threshold: float = 0.6
  162. choice_min_confidence: float = 0.85
  163. extract_enabled: bool = True
  164. extract_max_tokens: int = 3072
  165. extract_timeout_s: float = 30.0
  166. brief_slot_id: int | None = None
  167. brief_gate_enabled: bool = True
  168. brief_gate_threshold: float = 0.5
  169. brief_max_chars: int = 4000
  170. brief_pending_char_budget: int = 3000
  171. ledger_max_facts: int = 200
  172. fact_max_chars: int = 240
  173. device: str = ""
  174. preload: list[str] = field(default_factory=lambda: ["multilingual"])
  175. default_checkpoint: str = "multilingual"
  176. dump_io: bool = False
  177. @dataclass
  178. class SpeakerConfig:
  179. """Speaker-identification configuration (sherpa-onnx embedding lane)."""
  180. enabled: bool = False
  181. model_path: str | None = None
  182. provider: str = "cpu"
  183. threshold_id: float = 0.60
  184. threshold_new: float = 0.45
  185. min_enroll_s: float = 2.0
  186. rms_floor: float = 0.0
  187. margin: float = 0.04
  188. mint_cooldown_s: float = 60.0
  189. confirm_s: float = 12.0
  190. max_prototypes: int = 8
  191. provisional_ttl_h: float = 24.0
  192. store_dir: str = "speakers"
  193. join_timeout_s: float = 2.0
  194. @dataclass
  195. class BriefConfig:
  196. """Durable brief document configuration (markdown snapshot written to disk)."""
  197. write: bool = False
  198. @dataclass
  199. class DebugConfig:
  200. """Debug signal-log configuration (JSONL diagnostics, gitignored)."""
  201. enabled: bool = False
  202. dir: str = "troubleshoot/debug"
  203. heartbeat: bool = True
  204. max_bytes_per_day: int = 50000000
  205. @dataclass
  206. class SinkConfig:
  207. """Virtual audio sink configuration."""
  208. sink_name: str = "VirtualMicSink"
  209. remap_source_name: str = "VirtualMic"
  210. loopback_latency_msec: int = 1
  211. monitor_output: bool = False
  212. @dataclass
  213. class Config:
  214. """Complete daemon configuration."""
  215. audio: AudioConfig = field(default_factory=AudioConfig)
  216. vad: VadConfig = field(default_factory=VadConfig)
  217. whisper: WhisperConfig = field(default_factory=WhisperConfig)
  218. output: OutputConfig = field(default_factory=OutputConfig)
  219. sink: SinkConfig = field(default_factory=SinkConfig)
  220. daemon: DaemonConfig = field(default_factory=DaemonConfig)
  221. events: EventsConfig = field(default_factory=EventsConfig)
  222. assistant: AssistantConfig = field(default_factory=AssistantConfig)
  223. audit: AuditConfig = field(default_factory=AuditConfig)
  224. llm: LlmConfig = field(default_factory=LlmConfig)
  225. triage: TriageConfig = field(default_factory=TriageConfig)
  226. speaker: SpeakerConfig = field(default_factory=SpeakerConfig)
  227. brief: BriefConfig = field(default_factory=BriefConfig)
  228. debug: DebugConfig = field(default_factory=DebugConfig)
  229. def _optional_int(value: Any) -> int | None:
  230. """Coerce an optional TOML value to int, degrading to None on bad input.
  231. TOML has no null, so an absent key is "unset" and reaches this helper as
  232. ``None``. ``0`` is a valid slot id and must never be mistaken for unset.
  233. """
  234. if value is None:
  235. return None
  236. try:
  237. return int(value)
  238. except (TypeError, ValueError):
  239. return None
  240. def load_config(path: str | None = None) -> Config:
  241. """Load configuration from TOML file, with defaults.
  242. Args:
  243. path: Path to config.toml. If None, looks for config.toml in cwd,
  244. then uses all defaults.
  245. Returns:
  246. Config with loaded values overlayed on defaults.
  247. """
  248. cfg = Config()
  249. # Try to find and load config file
  250. config_path = None
  251. if path:
  252. config_path = Path(path)
  253. else:
  254. default_path = Path("config.toml")
  255. if default_path.exists():
  256. config_path = default_path
  257. if config_path and config_path.exists():
  258. with open(config_path, "rb") as f:
  259. data = tomllib.load(f)
  260. unknown = set(data) - KNOWN_SECTIONS
  261. if unknown:
  262. logger.warning(
  263. "Ignoring unknown config sections: %s", ", ".join(sorted(unknown))
  264. )
  265. # Audio section
  266. if "audio" in data:
  267. audio_data = data["audio"]
  268. cfg.audio = AudioConfig(
  269. backend=audio_data.get("backend", cfg.audio.backend),
  270. device=audio_data.get("device", cfg.audio.device),
  271. sample_rate=audio_data.get("sample_rate", cfg.audio.sample_rate),
  272. channels=audio_data.get("channels", cfg.audio.channels),
  273. frame_ms=audio_data.get("frame_ms", cfg.audio.frame_ms),
  274. gain=audio_data.get("gain", cfg.audio.gain),
  275. )
  276. # VAD section
  277. if "vad" in data:
  278. vad_data = data["vad"]
  279. cfg.vad = VadConfig(
  280. aggressiveness=vad_data.get("aggressiveness", cfg.vad.aggressiveness),
  281. pre_roll_ms=vad_data.get("pre_roll_ms", cfg.vad.pre_roll_ms),
  282. speech_start_ms=vad_data.get("speech_start_ms", cfg.vad.speech_start_ms),
  283. silence_ms=vad_data.get("silence_ms", cfg.vad.silence_ms),
  284. min_utterance_ms=vad_data.get("min_utterance_ms", cfg.vad.min_utterance_ms),
  285. max_utterance_s=vad_data.get("max_utterance_s", cfg.vad.max_utterance_s),
  286. chunk_pause_ms=vad_data.get("chunk_pause_ms", cfg.vad.chunk_pause_ms),
  287. soft_max_s=vad_data.get("soft_max_s", cfg.vad.soft_max_s),
  288. rms_floor=vad_data.get("rms_floor", cfg.vad.rms_floor),
  289. trailing_trim_ms=vad_data.get("trailing_trim_ms", cfg.vad.trailing_trim_ms),
  290. )
  291. # Whisper section
  292. if "whisper" in data:
  293. whisper_data = data["whisper"]
  294. backend = whisper_data.get("backend", cfg.whisper.backend)
  295. backend = backend.strip().lower().replace("_", "-")
  296. if backend not in ("faster-whisper", "pywhispercpp"):
  297. logger.warning(
  298. "Unknown whisper backend %r; falling back to 'faster-whisper'", backend
  299. )
  300. backend = "faster-whisper"
  301. language = whisper_data.get("language", cfg.whisper.language)
  302. # Normalize language: "", "auto", "none" -> None
  303. if language in ("", "auto", "none"):
  304. language = None
  305. strategy = whisper_data.get("language_strategy", cfg.whisper.language_strategy)
  306. if strategy not in ("static", "sticky"):
  307. logger.warning(
  308. "Unknown language_strategy %r; falling back to 'static'", strategy
  309. )
  310. strategy = "static"
  311. cfg.whisper = WhisperConfig(
  312. backend=backend,
  313. model=whisper_data.get("model", cfg.whisper.model),
  314. device=whisper_data.get("device", cfg.whisper.device),
  315. compute_type=whisper_data.get("compute_type", cfg.whisper.compute_type),
  316. language=language,
  317. beam_size=whisper_data.get("beam_size", cfg.whisper.beam_size),
  318. vad_filter=whisper_data.get("vad_filter", cfg.whisper.vad_filter),
  319. cpu_threads=whisper_data.get("cpu_threads", cfg.whisper.cpu_threads),
  320. no_speech_prob_max=whisper_data.get(
  321. "no_speech_prob_max", cfg.whisper.no_speech_prob_max
  322. ),
  323. avg_logprob_min=whisper_data.get(
  324. "avg_logprob_min", cfg.whisper.avg_logprob_min
  325. ),
  326. language_strategy=strategy,
  327. sticky_redetect_logprob=whisper_data.get(
  328. "sticky_redetect_logprob", cfg.whisper.sticky_redetect_logprob
  329. ),
  330. )
  331. # Output section
  332. if "output" in data:
  333. output_data = data["output"]
  334. cfg.output = OutputConfig(
  335. stdout=output_data.get("stdout", cfg.output.stdout),
  336. jsonl=output_data.get("jsonl", cfg.output.jsonl),
  337. session_dir=output_data.get("session_dir", cfg.output.session_dir),
  338. save_audio=output_data.get("save_audio", cfg.output.save_audio),
  339. session_gap_min=output_data.get(
  340. "session_gap_min", cfg.output.session_gap_min
  341. ),
  342. )
  343. # Sink section
  344. if "sink" in data:
  345. sink_data = data["sink"]
  346. cfg.sink = SinkConfig(
  347. sink_name=sink_data.get("sink_name", cfg.sink.sink_name),
  348. remap_source_name=sink_data.get(
  349. "remap_source_name", cfg.sink.remap_source_name
  350. ),
  351. loopback_latency_msec=sink_data.get(
  352. "loopback_latency_msec", cfg.sink.loopback_latency_msec
  353. ),
  354. monitor_output=sink_data.get("monitor_output", cfg.sink.monitor_output),
  355. )
  356. # Daemon section
  357. if "daemon" in data:
  358. daemon_data = data["daemon"]
  359. cfg.daemon = DaemonConfig(
  360. queue_size=daemon_data.get("queue_size", cfg.daemon.queue_size),
  361. backlog_warn_at=daemon_data.get(
  362. "backlog_warn_at", cfg.daemon.backlog_warn_at
  363. ),
  364. )
  365. # Events section
  366. if "events" in data:
  367. events_data = data["events"]
  368. cfg.events = EventsConfig(
  369. socket=events_data.get("socket", cfg.events.socket),
  370. heartbeat_ms=events_data.get("heartbeat_ms", cfg.events.heartbeat_ms),
  371. ring_size=events_data.get("ring_size", cfg.events.ring_size),
  372. )
  373. # Assistant section
  374. if "assistant" in data:
  375. assistant_data = data["assistant"]
  376. cfg.assistant = AssistantConfig(
  377. control_socket=assistant_data.get(
  378. "control_socket", cfg.assistant.control_socket
  379. ),
  380. stall_timeout_ms=assistant_data.get(
  381. "stall_timeout_ms", cfg.assistant.stall_timeout_ms
  382. ),
  383. worker_stall_ms=assistant_data.get(
  384. "worker_stall_ms", cfg.assistant.worker_stall_ms
  385. ),
  386. command_arm_timeout_s=assistant_data.get(
  387. "command_arm_timeout_s", cfg.assistant.command_arm_timeout_s
  388. ),
  389. chat_idle_timeout_s=assistant_data.get(
  390. "chat_idle_timeout_s", cfg.assistant.chat_idle_timeout_s
  391. ),
  392. chat_exit_phrases=list(
  393. assistant_data.get(
  394. "chat_exit_phrases", cfg.assistant.chat_exit_phrases
  395. )
  396. ),
  397. require_viewer=assistant_data.get(
  398. "require_viewer", cfg.assistant.require_viewer
  399. ),
  400. turn_gap_s=assistant_data.get("turn_gap_s", cfg.assistant.turn_gap_s),
  401. )
  402. # Audit section
  403. if "audit" in data:
  404. audit_data = data["audit"]
  405. cfg.audit = AuditConfig(
  406. enabled=audit_data.get("enabled", cfg.audit.enabled),
  407. dir=audit_data.get("dir", cfg.audit.dir),
  408. )
  409. # LLM section
  410. if "llm" in data:
  411. llm_data = data["llm"]
  412. if "max_prompt_tokens" in llm_data:
  413. logger.warning(
  414. "llm.max_prompt_tokens is deprecated and ignored; the prompt budget is "
  415. "derived as max_context_tokens - max_tokens - compaction_safety_margin"
  416. )
  417. cfg.llm = LlmConfig(
  418. enabled=llm_data.get("enabled", cfg.llm.enabled),
  419. endpoint=llm_data.get("endpoint", cfg.llm.endpoint),
  420. temperature=llm_data.get("temperature", cfg.llm.temperature),
  421. max_tokens=llm_data.get("max_tokens", cfg.llm.max_tokens),
  422. timeout_s=llm_data.get("timeout_s", cfg.llm.timeout_s),
  423. cache_prompt=llm_data.get("cache_prompt", cfg.llm.cache_prompt),
  424. chat_slot_id=_optional_int(llm_data.get("chat_slot_id")),
  425. command_slot_id=_optional_int(llm_data.get("command_slot_id")),
  426. max_context_tokens=llm_data.get(
  427. "max_context_tokens", cfg.llm.max_context_tokens
  428. ),
  429. compaction_safety_margin=llm_data.get(
  430. "compaction_safety_margin", cfg.llm.compaction_safety_margin
  431. ),
  432. compaction_enabled=llm_data.get(
  433. "compaction_enabled", cfg.llm.compaction_enabled
  434. ),
  435. compaction_trigger_ratio=llm_data.get(
  436. "compaction_trigger_ratio", cfg.llm.compaction_trigger_ratio
  437. ),
  438. compaction_keep_turns=llm_data.get(
  439. "compaction_keep_turns", cfg.llm.compaction_keep_turns
  440. ),
  441. compaction_max_tokens=llm_data.get(
  442. "compaction_max_tokens", cfg.llm.compaction_max_tokens
  443. ),
  444. compaction_timeout_s=llm_data.get(
  445. "compaction_timeout_s", cfg.llm.compaction_timeout_s
  446. ),
  447. compaction_input_tokens=llm_data.get(
  448. "compaction_input_tokens", cfg.llm.compaction_input_tokens
  449. ),
  450. compaction_system_prompt=llm_data.get(
  451. "compaction_system_prompt", cfg.llm.compaction_system_prompt
  452. ),
  453. brief_compaction_system_prompt=llm_data.get(
  454. "brief_compaction_system_prompt", cfg.llm.brief_compaction_system_prompt
  455. ),
  456. triage_system_prompt=llm_data.get(
  457. "triage_system_prompt", cfg.llm.triage_system_prompt
  458. ),
  459. inference_queue_size=llm_data.get(
  460. "inference_queue_size", cfg.llm.inference_queue_size
  461. ),
  462. trim_block_turns=llm_data.get(
  463. "trim_block_turns", cfg.llm.trim_block_turns
  464. ),
  465. max_tool_iterations=llm_data.get(
  466. "max_tool_iterations", cfg.llm.max_tool_iterations
  467. ),
  468. system_prompt_chat=llm_data.get(
  469. "system_prompt_chat", cfg.llm.system_prompt_chat
  470. ),
  471. system_prompt_command=llm_data.get(
  472. "system_prompt_command", cfg.llm.system_prompt_command
  473. ),
  474. )
  475. # Triage section
  476. if "triage" in data:
  477. triage_data = data["triage"]
  478. cfg.triage = TriageConfig(
  479. enabled=triage_data.get("enabled", cfg.triage.enabled),
  480. questions_file=triage_data.get("questions_file", cfg.triage.questions_file),
  481. window=triage_data.get("window", cfg.triage.window),
  482. state_char_budget=triage_data.get(
  483. "state_char_budget", cfg.triage.state_char_budget
  484. ),
  485. periodic_s=triage_data.get("periodic_s", cfg.triage.periodic_s),
  486. predict_max_tokens=triage_data.get(
  487. "predict_max_tokens", cfg.triage.predict_max_tokens
  488. ),
  489. confidence_field=triage_data.get(
  490. "confidence_field", cfg.triage.confidence_field
  491. ),
  492. escalate_threshold=triage_data.get(
  493. "escalate_threshold", cfg.triage.escalate_threshold
  494. ),
  495. choice_min_confidence=triage_data.get(
  496. "choice_min_confidence", cfg.triage.choice_min_confidence
  497. ),
  498. extract_enabled=triage_data.get(
  499. "extract_enabled", cfg.triage.extract_enabled
  500. ),
  501. extract_max_tokens=triage_data.get(
  502. "extract_max_tokens", cfg.triage.extract_max_tokens
  503. ),
  504. extract_timeout_s=triage_data.get(
  505. "extract_timeout_s", cfg.triage.extract_timeout_s
  506. ),
  507. brief_slot_id=_optional_int(triage_data.get("brief_slot_id")),
  508. brief_gate_enabled=triage_data.get(
  509. "brief_gate_enabled", cfg.triage.brief_gate_enabled
  510. ),
  511. brief_gate_threshold=triage_data.get(
  512. "brief_gate_threshold", cfg.triage.brief_gate_threshold
  513. ),
  514. brief_max_chars=triage_data.get(
  515. "brief_max_chars", cfg.triage.brief_max_chars
  516. ),
  517. brief_pending_char_budget=triage_data.get(
  518. "brief_pending_char_budget", cfg.triage.brief_pending_char_budget
  519. ),
  520. ledger_max_facts=triage_data.get(
  521. "ledger_max_facts", cfg.triage.ledger_max_facts
  522. ),
  523. fact_max_chars=triage_data.get(
  524. "fact_max_chars", cfg.triage.fact_max_chars
  525. ),
  526. device=triage_data.get("device", cfg.triage.device),
  527. preload=list(triage_data.get("preload", cfg.triage.preload)),
  528. default_checkpoint=triage_data.get(
  529. "default_checkpoint", cfg.triage.default_checkpoint
  530. ),
  531. dump_io=triage_data.get("dump_io", cfg.triage.dump_io),
  532. )
  533. # Speaker section
  534. if "speaker" in data:
  535. speaker_data = data["speaker"]
  536. provider = speaker_data.get("provider", cfg.speaker.provider)
  537. provider = provider.strip().lower()
  538. if provider not in ("cpu", "cuda"):
  539. logger.warning(
  540. "Unknown speaker provider %r; falling back to 'cpu'", provider
  541. )
  542. provider = "cpu"
  543. cfg.speaker = SpeakerConfig(
  544. enabled=speaker_data.get("enabled", cfg.speaker.enabled),
  545. model_path=speaker_data.get("model_path", cfg.speaker.model_path),
  546. provider=provider,
  547. threshold_id=speaker_data.get("threshold_id", cfg.speaker.threshold_id),
  548. threshold_new=speaker_data.get("threshold_new", cfg.speaker.threshold_new),
  549. min_enroll_s=speaker_data.get("min_enroll_s", cfg.speaker.min_enroll_s),
  550. rms_floor=speaker_data.get("rms_floor", cfg.speaker.rms_floor),
  551. margin=speaker_data.get("margin", cfg.speaker.margin),
  552. mint_cooldown_s=speaker_data.get(
  553. "mint_cooldown_s", cfg.speaker.mint_cooldown_s
  554. ),
  555. confirm_s=speaker_data.get("confirm_s", cfg.speaker.confirm_s),
  556. max_prototypes=speaker_data.get("max_prototypes", cfg.speaker.max_prototypes),
  557. provisional_ttl_h=speaker_data.get(
  558. "provisional_ttl_h", cfg.speaker.provisional_ttl_h
  559. ),
  560. store_dir=speaker_data.get("store_dir", cfg.speaker.store_dir),
  561. join_timeout_s=speaker_data.get("join_timeout_s", cfg.speaker.join_timeout_s),
  562. )
  563. # Brief section
  564. if "brief" in data:
  565. brief_data = data["brief"]
  566. cfg.brief = BriefConfig(
  567. write=brief_data.get("write", cfg.brief.write),
  568. )
  569. # Debug section
  570. if "debug" in data:
  571. debug_data = data["debug"]
  572. cfg.debug = DebugConfig(
  573. enabled=debug_data.get("enabled", cfg.debug.enabled),
  574. dir=debug_data.get("dir", cfg.debug.dir),
  575. heartbeat=debug_data.get("heartbeat", cfg.debug.heartbeat),
  576. max_bytes_per_day=debug_data.get(
  577. "max_bytes_per_day", cfg.debug.max_bytes_per_day
  578. ),
  579. )
  580. return cfg