daemon.py 6.4 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185
  1. """Main daemon that orchestrates capture, segmentation, and transcription."""
  2. import logging
  3. import queue
  4. import threading
  5. from typing import Optional
  6. from .audio import AudioCapture, open_capture
  7. from .config import Config
  8. from .output import TranscriptSink
  9. from .segmenter import FrameSegmenter, make_vad
  10. from .transcriber import Transcriber
  11. class Daemon:
  12. """Always-on STT daemon with worker threads."""
  13. def __init__(self, cfg: Config, log_level: int = logging.INFO):
  14. """Initialize the daemon.
  15. Args:
  16. cfg: Configuration object.
  17. log_level: Logging level (e.g., logging.INFO, logging.DEBUG).
  18. """
  19. self.cfg = cfg
  20. # Set up logging
  21. self.logger = logging.getLogger("stt_daemon")
  22. self.logger.setLevel(log_level)
  23. if not self.logger.handlers:
  24. handler = logging.StreamHandler()
  25. handler.setFormatter(logging.Formatter("[%(levelname)s] %(message)s"))
  26. self.logger.addHandler(handler)
  27. # Initialize components
  28. self.capture: Optional[AudioCapture] = None
  29. self.segmenter: Optional[FrameSegmenter] = None
  30. self.transcriber: Optional[Transcriber] = None
  31. self.sink: Optional[TranscriptSink] = None
  32. # Threading
  33. self.segment_queue: queue.Queue = queue.Queue(maxsize=50)
  34. self._stop_event = threading.Event()
  35. self._stopped = False
  36. self._reader_thread: Optional[threading.Thread] = None
  37. self._worker_thread: Optional[threading.Thread] = None
  38. def run(self) -> None:
  39. """Run the daemon (blocks until stopped)."""
  40. try:
  41. self._initialize()
  42. self._start_workers()
  43. self._stop_event.wait()
  44. finally:
  45. self.stop()
  46. def _initialize(self) -> None:
  47. """Initialize all components."""
  48. self.logger.info("Initializing audio capture...")
  49. self.capture = open_capture(self.cfg.audio)
  50. self.logger.info("Initializing VAD...")
  51. vad = make_vad(self.cfg.vad.aggressiveness)
  52. self.logger.info("Initializing segmenter...")
  53. self.segmenter = FrameSegmenter(
  54. sample_rate=self.cfg.audio.sample_rate,
  55. frame_ms=self.cfg.audio.frame_ms,
  56. vad=vad,
  57. pre_roll_ms=self.cfg.vad.pre_roll_ms,
  58. speech_start_ms=self.cfg.vad.speech_start_ms,
  59. silence_ms=self.cfg.vad.silence_ms,
  60. min_utterance_ms=self.cfg.vad.min_utterance_ms,
  61. max_utterance_s=self.cfg.vad.max_utterance_s,
  62. )
  63. self.logger.info(f"Loading Whisper model '{self.cfg.whisper.model}'...")
  64. self.transcriber = Transcriber(
  65. model=self.cfg.whisper.model,
  66. device=self.cfg.whisper.device,
  67. compute_type=self.cfg.whisper.compute_type,
  68. language=self.cfg.whisper.language,
  69. beam_size=self.cfg.whisper.beam_size,
  70. vad_filter=self.cfg.whisper.vad_filter,
  71. )
  72. self.logger.info("Initializing output sink...")
  73. self.sink = TranscriptSink(
  74. stdout=self.cfg.output.stdout,
  75. jsonl_dir=self.cfg.output.jsonl_dir,
  76. )
  77. self.logger.info("Daemon ready. Listening...")
  78. def _start_workers(self) -> None:
  79. """Start reader and worker threads."""
  80. self._reader_thread = threading.Thread(target=self._reader_loop, daemon=False)
  81. self._worker_thread = threading.Thread(target=self._worker_loop, daemon=False)
  82. self._reader_thread.start()
  83. self._worker_thread.start()
  84. def _reader_loop(self) -> None:
  85. """Reader thread: capture frames and segment them."""
  86. try:
  87. if not self.capture or not self.segmenter:
  88. return
  89. for frame in self.capture.frames():
  90. if self._stop_event.is_set():
  91. break
  92. utt = self.segmenter.push(frame)
  93. if utt:
  94. try:
  95. self.segment_queue.put(utt, block=False)
  96. except queue.Full:
  97. self.logger.warning("Segment queue full, dropping oldest")
  98. try:
  99. self.segment_queue.get_nowait()
  100. self.segment_queue.put(utt, block=False)
  101. except queue.Empty:
  102. pass
  103. # Shutdown: flush any in-progress utterance
  104. if self.segmenter:
  105. utt = self.segmenter.flush()
  106. if utt:
  107. try:
  108. self.segment_queue.put(utt, block=False)
  109. except queue.Full:
  110. pass
  111. except Exception as e:
  112. if self._stop_event.is_set():
  113. self.logger.debug(f"Reader stopped: {e}")
  114. else:
  115. self.logger.error(f"Reader thread error: {e}")
  116. finally:
  117. if self.capture:
  118. self.capture.close()
  119. def _worker_loop(self) -> None:
  120. """Worker thread: transcribe segments and emit results."""
  121. try:
  122. while not self._stop_event.is_set():
  123. try:
  124. utt = self.segment_queue.get(timeout=1)
  125. except queue.Empty:
  126. continue
  127. if self.transcriber and self.sink:
  128. try:
  129. result = self.transcriber.transcribe(utt.audio)
  130. self.sink.emit(result, utt.duration_s)
  131. except Exception as e:
  132. self.logger.error(f"Transcription error: {e}")
  133. except Exception as e:
  134. self.logger.error(f"Worker thread error: {e}")
  135. def stop(self) -> None:
  136. """Stop the daemon. Safe to call more than once."""
  137. if self._stopped:
  138. return
  139. self._stopped = True
  140. self.logger.info("Stopping daemon...")
  141. self._stop_event.set()
  142. # Close capture FIRST so the reader thread's blocking read unblocks
  143. # (otherwise a non-daemon reader thread can hang process shutdown).
  144. if self.capture:
  145. self.capture.close()
  146. if self._reader_thread:
  147. self._reader_thread.join(timeout=5)
  148. if self._worker_thread:
  149. self._worker_thread.join(timeout=5)
  150. if self.transcriber:
  151. self.transcriber.close()
  152. if self.sink:
  153. self.sink.close()
  154. self.logger.info("Daemon stopped.")