| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185 |
- """Main daemon that orchestrates capture, segmentation, and transcription."""
- import logging
- import queue
- import threading
- from typing import Optional
- from .audio import AudioCapture, open_capture
- from .config import Config
- from .output import TranscriptSink
- from .segmenter import FrameSegmenter, make_vad
- from .transcriber import Transcriber
- class Daemon:
- """Always-on STT daemon with worker threads."""
- def __init__(self, cfg: Config, log_level: int = logging.INFO):
- """Initialize the daemon.
- Args:
- cfg: Configuration object.
- log_level: Logging level (e.g., logging.INFO, logging.DEBUG).
- """
- self.cfg = cfg
- # Set up logging
- self.logger = logging.getLogger("stt_daemon")
- self.logger.setLevel(log_level)
- if not self.logger.handlers:
- handler = logging.StreamHandler()
- handler.setFormatter(logging.Formatter("[%(levelname)s] %(message)s"))
- self.logger.addHandler(handler)
- # Initialize components
- self.capture: Optional[AudioCapture] = None
- self.segmenter: Optional[FrameSegmenter] = None
- self.transcriber: Optional[Transcriber] = None
- self.sink: Optional[TranscriptSink] = None
- # Threading
- self.segment_queue: queue.Queue = queue.Queue(maxsize=50)
- self._stop_event = threading.Event()
- self._stopped = False
- self._reader_thread: Optional[threading.Thread] = None
- self._worker_thread: Optional[threading.Thread] = None
- def run(self) -> None:
- """Run the daemon (blocks until stopped)."""
- try:
- self._initialize()
- self._start_workers()
- self._stop_event.wait()
- finally:
- self.stop()
- def _initialize(self) -> None:
- """Initialize all components."""
- self.logger.info("Initializing audio capture...")
- self.capture = open_capture(self.cfg.audio)
- self.logger.info("Initializing VAD...")
- vad = make_vad(self.cfg.vad.aggressiveness)
- self.logger.info("Initializing segmenter...")
- self.segmenter = FrameSegmenter(
- sample_rate=self.cfg.audio.sample_rate,
- frame_ms=self.cfg.audio.frame_ms,
- vad=vad,
- pre_roll_ms=self.cfg.vad.pre_roll_ms,
- speech_start_ms=self.cfg.vad.speech_start_ms,
- silence_ms=self.cfg.vad.silence_ms,
- min_utterance_ms=self.cfg.vad.min_utterance_ms,
- max_utterance_s=self.cfg.vad.max_utterance_s,
- )
- self.logger.info(f"Loading Whisper model '{self.cfg.whisper.model}'...")
- self.transcriber = Transcriber(
- model=self.cfg.whisper.model,
- device=self.cfg.whisper.device,
- compute_type=self.cfg.whisper.compute_type,
- language=self.cfg.whisper.language,
- beam_size=self.cfg.whisper.beam_size,
- vad_filter=self.cfg.whisper.vad_filter,
- )
- self.logger.info("Initializing output sink...")
- self.sink = TranscriptSink(
- stdout=self.cfg.output.stdout,
- jsonl_dir=self.cfg.output.jsonl_dir,
- )
- self.logger.info("Daemon ready. Listening...")
- def _start_workers(self) -> None:
- """Start reader and worker threads."""
- self._reader_thread = threading.Thread(target=self._reader_loop, daemon=False)
- self._worker_thread = threading.Thread(target=self._worker_loop, daemon=False)
- self._reader_thread.start()
- self._worker_thread.start()
- def _reader_loop(self) -> None:
- """Reader thread: capture frames and segment them."""
- try:
- if not self.capture or not self.segmenter:
- return
- for frame in self.capture.frames():
- if self._stop_event.is_set():
- break
- utt = self.segmenter.push(frame)
- if utt:
- try:
- self.segment_queue.put(utt, block=False)
- except queue.Full:
- self.logger.warning("Segment queue full, dropping oldest")
- try:
- self.segment_queue.get_nowait()
- self.segment_queue.put(utt, block=False)
- except queue.Empty:
- pass
- # Shutdown: flush any in-progress utterance
- if self.segmenter:
- utt = self.segmenter.flush()
- if utt:
- try:
- self.segment_queue.put(utt, block=False)
- except queue.Full:
- pass
- except Exception as e:
- if self._stop_event.is_set():
- self.logger.debug(f"Reader stopped: {e}")
- else:
- self.logger.error(f"Reader thread error: {e}")
- finally:
- if self.capture:
- self.capture.close()
- def _worker_loop(self) -> None:
- """Worker thread: transcribe segments and emit results."""
- try:
- while not self._stop_event.is_set():
- try:
- utt = self.segment_queue.get(timeout=1)
- except queue.Empty:
- continue
- if self.transcriber and self.sink:
- try:
- result = self.transcriber.transcribe(utt.audio)
- self.sink.emit(result, utt.duration_s)
- except Exception as e:
- self.logger.error(f"Transcription error: {e}")
- except Exception as e:
- self.logger.error(f"Worker thread error: {e}")
- def stop(self) -> None:
- """Stop the daemon. Safe to call more than once."""
- if self._stopped:
- return
- self._stopped = True
- self.logger.info("Stopping daemon...")
- self._stop_event.set()
- # Close capture FIRST so the reader thread's blocking read unblocks
- # (otherwise a non-daemon reader thread can hang process shutdown).
- if self.capture:
- self.capture.close()
- if self._reader_thread:
- self._reader_thread.join(timeout=5)
- if self._worker_thread:
- self._worker_thread.join(timeout=5)
- if self.transcriber:
- self.transcriber.close()
- if self.sink:
- self.sink.close()
- self.logger.info("Daemon stopped.")
|