|
@@ -1,28 +1,53 @@
|
|
|
"""Main daemon that orchestrates capture, segmentation, and transcription."""
|
|
"""Main daemon that orchestrates capture, segmentation, and transcription."""
|
|
|
|
|
|
|
|
|
|
+import collections
|
|
|
|
|
+import itertools
|
|
|
import logging
|
|
import logging
|
|
|
|
|
+import math
|
|
|
import queue
|
|
import queue
|
|
|
import threading
|
|
import threading
|
|
|
-from typing import Optional
|
|
|
|
|
|
|
+import time
|
|
|
|
|
+from typing import Any, Callable, Optional
|
|
|
|
|
|
|
|
|
|
+import numpy as np
|
|
|
|
|
+
|
|
|
|
|
+from . import events
|
|
|
from .audio import AudioCapture, open_capture
|
|
from .audio import AudioCapture, open_capture
|
|
|
|
|
+from .bus import EventPublisher
|
|
|
from .config import Config
|
|
from .config import Config
|
|
|
from .output import TranscriptSink
|
|
from .output import TranscriptSink
|
|
|
-from .segmenter import FrameSegmenter, make_vad
|
|
|
|
|
|
|
+from .segmenter import FrameSegmenter, Utterance, make_vad
|
|
|
from .transcriber import Transcriber
|
|
from .transcriber import Transcriber
|
|
|
|
|
|
|
|
|
|
|
|
|
class Daemon:
|
|
class Daemon:
|
|
|
"""Always-on STT daemon with worker threads."""
|
|
"""Always-on STT daemon with worker threads."""
|
|
|
|
|
|
|
|
- def __init__(self, cfg: Config, log_level: int = logging.INFO):
|
|
|
|
|
|
|
+ def __init__(
|
|
|
|
|
+ self,
|
|
|
|
|
+ cfg: Config,
|
|
|
|
|
+ log_level: int = logging.INFO,
|
|
|
|
|
+ *,
|
|
|
|
|
+ capture_factory: Callable[[], Any] | None = None,
|
|
|
|
|
+ vad_factory: Callable[[int], Any] | None = None,
|
|
|
|
|
+ transcriber_factory: Callable[[], Any] | None = None,
|
|
|
|
|
+ sink_factory: Callable[[], Any] | None = None,
|
|
|
|
|
+ ):
|
|
|
"""Initialize the daemon.
|
|
"""Initialize the daemon.
|
|
|
|
|
|
|
|
Args:
|
|
Args:
|
|
|
cfg: Configuration object.
|
|
cfg: Configuration object.
|
|
|
log_level: Logging level (e.g., logging.INFO, logging.DEBUG).
|
|
log_level: Logging level (e.g., logging.INFO, logging.DEBUG).
|
|
|
|
|
+ capture_factory: Override audio capture creation (used by tests).
|
|
|
|
|
+ vad_factory: Override VAD creation (used by tests).
|
|
|
|
|
+ transcriber_factory: Override transcriber creation (used by tests).
|
|
|
|
|
+ sink_factory: Override transcript sink creation (used by tests).
|
|
|
"""
|
|
"""
|
|
|
self.cfg = cfg
|
|
self.cfg = cfg
|
|
|
|
|
+ self._capture_factory = capture_factory or (lambda: open_capture(self.cfg.audio))
|
|
|
|
|
+ self._vad_factory = vad_factory or make_vad
|
|
|
|
|
+ self._transcriber_factory = transcriber_factory or self._build_transcriber
|
|
|
|
|
+ self._sink_factory = sink_factory or self._build_sink
|
|
|
|
|
|
|
|
# Set up logging
|
|
# Set up logging
|
|
|
self.logger = logging.getLogger("stt_daemon")
|
|
self.logger = logging.getLogger("stt_daemon")
|
|
@@ -37,6 +62,7 @@ class Daemon:
|
|
|
self.segmenter: Optional[FrameSegmenter] = None
|
|
self.segmenter: Optional[FrameSegmenter] = None
|
|
|
self.transcriber: Optional[Transcriber] = None
|
|
self.transcriber: Optional[Transcriber] = None
|
|
|
self.sink: Optional[TranscriptSink] = None
|
|
self.sink: Optional[TranscriptSink] = None
|
|
|
|
|
+ self.publisher: Optional[EventPublisher] = None
|
|
|
|
|
|
|
|
# Threading
|
|
# Threading
|
|
|
self.segment_queue: queue.Queue = queue.Queue(maxsize=cfg.daemon.queue_size)
|
|
self.segment_queue: queue.Queue = queue.Queue(maxsize=cfg.daemon.queue_size)
|
|
@@ -46,6 +72,18 @@ class Daemon:
|
|
|
self._reader_thread: Optional[threading.Thread] = None
|
|
self._reader_thread: Optional[threading.Thread] = None
|
|
|
self._worker_thread: Optional[threading.Thread] = None
|
|
self._worker_thread: Optional[threading.Thread] = None
|
|
|
|
|
|
|
|
|
|
+ # Event bus state (shared across reader/worker threads)
|
|
|
|
|
+ self._seg_counter = itertools.count(1)
|
|
|
|
|
+ self._segments_done = 0
|
|
|
|
|
+ self._last_segment_done_ts = 0.0
|
|
|
|
|
+ self._last_worker_activity_ts = 0.0
|
|
|
|
|
+ self._last_transcript_ts = 0.0
|
|
|
|
|
+ self._in_flight = False
|
|
|
|
|
+ self._frames_since_heartbeat = 0
|
|
|
|
|
+ self._started_at = 0.0
|
|
|
|
|
+ rms_window = max(1, round(1000 / max(1, cfg.audio.frame_ms)))
|
|
|
|
|
+ self._rms_window: collections.deque[float] = collections.deque(maxlen=rms_window)
|
|
|
|
|
+
|
|
|
def run(self) -> None:
|
|
def run(self) -> None:
|
|
|
"""Run the daemon (blocks until stopped)."""
|
|
"""Run the daemon (blocks until stopped)."""
|
|
|
try:
|
|
try:
|
|
@@ -55,13 +93,34 @@ class Daemon:
|
|
|
finally:
|
|
finally:
|
|
|
self.stop()
|
|
self.stop()
|
|
|
|
|
|
|
|
|
|
+ def _build_transcriber(self) -> Transcriber:
|
|
|
|
|
+ return 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,
|
|
|
|
|
+ no_speech_prob_max=self.cfg.whisper.no_speech_prob_max,
|
|
|
|
|
+ avg_logprob_min=self.cfg.whisper.avg_logprob_min,
|
|
|
|
|
+ )
|
|
|
|
|
+
|
|
|
|
|
+ def _build_sink(self) -> TranscriptSink:
|
|
|
|
|
+ return TranscriptSink(
|
|
|
|
|
+ stdout=self.cfg.output.stdout,
|
|
|
|
|
+ jsonl_dir=self.cfg.output.jsonl_dir,
|
|
|
|
|
+ save_audio=self.cfg.output.save_audio,
|
|
|
|
|
+ audio_dir=self.cfg.output.audio_dir,
|
|
|
|
|
+ sample_rate=self.cfg.audio.sample_rate,
|
|
|
|
|
+ )
|
|
|
|
|
+
|
|
|
def _initialize(self) -> None:
|
|
def _initialize(self) -> None:
|
|
|
- """Initialize all components."""
|
|
|
|
|
|
|
+ """Initialize all components and start publishing events."""
|
|
|
self.logger.info("Initializing audio capture...")
|
|
self.logger.info("Initializing audio capture...")
|
|
|
- self.capture = open_capture(self.cfg.audio)
|
|
|
|
|
|
|
+ self.capture = self._capture_factory()
|
|
|
|
|
|
|
|
self.logger.info("Initializing VAD...")
|
|
self.logger.info("Initializing VAD...")
|
|
|
- vad = make_vad(self.cfg.vad.aggressiveness)
|
|
|
|
|
|
|
+ vad = self._vad_factory(self.cfg.vad.aggressiveness)
|
|
|
|
|
|
|
|
self.logger.info("Initializing segmenter...")
|
|
self.logger.info("Initializing segmenter...")
|
|
|
self.segmenter = FrameSegmenter(
|
|
self.segmenter = FrameSegmenter(
|
|
@@ -80,28 +139,43 @@ class Daemon:
|
|
|
)
|
|
)
|
|
|
|
|
|
|
|
self.logger.info(f"Loading Whisper model '{self.cfg.whisper.model}'...")
|
|
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,
|
|
|
|
|
- no_speech_prob_max=self.cfg.whisper.no_speech_prob_max,
|
|
|
|
|
- avg_logprob_min=self.cfg.whisper.avg_logprob_min,
|
|
|
|
|
- )
|
|
|
|
|
|
|
+ self.transcriber = self._transcriber_factory()
|
|
|
|
|
|
|
|
self.logger.info("Initializing output sink...")
|
|
self.logger.info("Initializing output sink...")
|
|
|
- self.sink = TranscriptSink(
|
|
|
|
|
- stdout=self.cfg.output.stdout,
|
|
|
|
|
- jsonl_dir=self.cfg.output.jsonl_dir,
|
|
|
|
|
- save_audio=self.cfg.output.save_audio,
|
|
|
|
|
- audio_dir=self.cfg.output.audio_dir,
|
|
|
|
|
- sample_rate=self.cfg.audio.sample_rate,
|
|
|
|
|
- )
|
|
|
|
|
|
|
+ self.sink = self._sink_factory()
|
|
|
|
|
+
|
|
|
|
|
+ self._started_at = time.time()
|
|
|
|
|
+ self._start_publisher()
|
|
|
|
|
|
|
|
self.logger.info("Daemon ready. Listening...")
|
|
self.logger.info("Daemon ready. Listening...")
|
|
|
|
|
|
|
|
|
|
+ def _start_publisher(self) -> None:
|
|
|
|
|
+ """Start the publish-only event bus (best effort; never fatal)."""
|
|
|
|
|
+ publisher = EventPublisher(
|
|
|
|
|
+ path=self.cfg.events.socket or None,
|
|
|
|
|
+ ring_size=self.cfg.events.ring_size,
|
|
|
|
|
+ status_provider=self._status_fields,
|
|
|
|
|
+ )
|
|
|
|
|
+ try:
|
|
|
|
|
+ publisher.start()
|
|
|
|
|
+ except OSError as e:
|
|
|
|
|
+ self.logger.warning("Event bus disabled (%s)", e)
|
|
|
|
|
+ self.publisher = None
|
|
|
|
|
+ return
|
|
|
|
|
+ self.publisher = publisher
|
|
|
|
|
+
|
|
|
|
|
+ def _status_fields(self) -> dict:
|
|
|
|
|
+ return {
|
|
|
|
|
+ "capture": self.cfg.audio.device,
|
|
|
|
|
+ "model_loaded": self.transcriber is not None,
|
|
|
|
|
+ "segments_done": self._segments_done,
|
|
|
|
|
+ "heartbeat_ms": self.cfg.events.heartbeat_ms,
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ def _publish(self, type_: str, **fields) -> None:
|
|
|
|
|
+ if self.publisher is not None:
|
|
|
|
|
+ self.publisher.emit(type_, **fields)
|
|
|
|
|
+
|
|
|
def _start_workers(self) -> None:
|
|
def _start_workers(self) -> None:
|
|
|
"""Start reader and worker threads."""
|
|
"""Start reader and worker threads."""
|
|
|
self._reader_thread = threading.Thread(target=self._reader_loop, daemon=False)
|
|
self._reader_thread = threading.Thread(target=self._reader_loop, daemon=False)
|
|
@@ -110,36 +184,41 @@ class Daemon:
|
|
|
self._worker_thread.start()
|
|
self._worker_thread.start()
|
|
|
|
|
|
|
|
def _reader_loop(self) -> None:
|
|
def _reader_loop(self) -> None:
|
|
|
- """Reader thread: capture frames and segment them."""
|
|
|
|
|
|
|
+ """Reader thread: capture frames, segment them, and publish events."""
|
|
|
try:
|
|
try:
|
|
|
if not self.capture or not self.segmenter:
|
|
if not self.capture or not self.segmenter:
|
|
|
return
|
|
return
|
|
|
|
|
|
|
|
|
|
+ heartbeat_s = max(0.05, self.cfg.events.heartbeat_ms / 1000.0)
|
|
|
|
|
+ next_heartbeat = time.time() + heartbeat_s
|
|
|
|
|
+
|
|
|
for frame in self.capture.frames():
|
|
for frame in self.capture.frames():
|
|
|
if self._stop_event.is_set():
|
|
if self._stop_event.is_set():
|
|
|
break
|
|
break
|
|
|
|
|
|
|
|
|
|
+ self._track_level(frame)
|
|
|
|
|
+ self._frames_since_heartbeat += 1
|
|
|
|
|
+
|
|
|
|
|
+ was_speaking = self.segmenter.speaking
|
|
|
utt = self.segmenter.push(frame)
|
|
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
|
|
|
|
|
- self._check_backlog()
|
|
|
|
|
|
|
+
|
|
|
|
|
+ if not was_speaking and self.segmenter.speaking:
|
|
|
|
|
+ self._publish(events.SPEECH_START)
|
|
|
|
|
+
|
|
|
|
|
+ if utt is not None:
|
|
|
|
|
+ self._on_segment_finalized(utt)
|
|
|
|
|
+
|
|
|
|
|
+ now = time.time()
|
|
|
|
|
+ if now >= next_heartbeat:
|
|
|
|
|
+ self._publish_heartbeat()
|
|
|
|
|
+ next_heartbeat = now + heartbeat_s
|
|
|
|
|
|
|
|
# Shutdown: flush any in-progress utterance
|
|
# Shutdown: flush any in-progress utterance
|
|
|
if self.segmenter:
|
|
if self.segmenter:
|
|
|
utt = self.segmenter.flush()
|
|
utt = self.segmenter.flush()
|
|
|
- if utt:
|
|
|
|
|
- try:
|
|
|
|
|
- self.segment_queue.put(utt, block=False)
|
|
|
|
|
- except queue.Full:
|
|
|
|
|
- pass
|
|
|
|
|
|
|
+ if utt is not None:
|
|
|
|
|
+ self._on_segment_finalized(utt)
|
|
|
|
|
+ self._publish_heartbeat()
|
|
|
|
|
|
|
|
except Exception as e:
|
|
except Exception as e:
|
|
|
if self._stop_event.is_set():
|
|
if self._stop_event.is_set():
|
|
@@ -150,6 +229,74 @@ class Daemon:
|
|
|
if self.capture:
|
|
if self.capture:
|
|
|
self.capture.close()
|
|
self.capture.close()
|
|
|
|
|
|
|
|
|
|
+ def _on_segment_finalized(self, utt: Utterance) -> None:
|
|
|
|
|
+ """Assign a seg_id at finalization, announce the stop, and enqueue."""
|
|
|
|
|
+ seg_id = next(self._seg_counter)
|
|
|
|
|
+ utt.seg_id = seg_id
|
|
|
|
|
+ self._publish(events.SPEECH_STOP, seg_id=seg_id, end_reason=utt.reason)
|
|
|
|
|
+ if not self._enqueue(utt):
|
|
|
|
|
+ self._publish(
|
|
|
|
|
+ events.SEGMENT_DONE, seg_id=seg_id, emitted=False, end_reason=utt.reason
|
|
|
|
|
+ )
|
|
|
|
|
+ self._check_backlog()
|
|
|
|
|
+
|
|
|
|
|
+ def _enqueue(self, utt: Utterance) -> bool:
|
|
|
|
|
+ """Queue a segment, dropping the oldest if full. False if it could not be queued."""
|
|
|
|
|
+ try:
|
|
|
|
|
+ self.segment_queue.put(utt, block=False)
|
|
|
|
|
+ return True
|
|
|
|
|
+ except queue.Full:
|
|
|
|
|
+ self.logger.warning("Segment queue full, dropping oldest")
|
|
|
|
|
+ try:
|
|
|
|
|
+ dropped = self.segment_queue.get_nowait()
|
|
|
|
|
+ except queue.Empty:
|
|
|
|
|
+ dropped = None
|
|
|
|
|
+ if dropped is not None:
|
|
|
|
|
+ self._publish(
|
|
|
|
|
+ events.SEGMENT_DONE,
|
|
|
|
|
+ seg_id=dropped.seg_id,
|
|
|
|
|
+ emitted=False,
|
|
|
|
|
+ end_reason=dropped.reason,
|
|
|
|
|
+ )
|
|
|
|
|
+ try:
|
|
|
|
|
+ self.segment_queue.put(utt, block=False)
|
|
|
|
|
+ return True
|
|
|
|
|
+ except queue.Full:
|
|
|
|
|
+ return False
|
|
|
|
|
+
|
|
|
|
|
+ def _track_level(self, frame: bytes) -> None:
|
|
|
|
|
+ samples = np.frombuffer(frame, dtype=np.int16).astype(np.float32) / 32768.0
|
|
|
|
|
+ if samples.size:
|
|
|
|
|
+ self._rms_window.append(float(np.mean(samples * samples)))
|
|
|
|
|
+
|
|
|
|
|
+ def _publish_heartbeat(self) -> None:
|
|
|
|
|
+ now = time.time()
|
|
|
|
|
+ rms = math.sqrt(sum(self._rms_window) / len(self._rms_window)) if self._rms_window else 0.0
|
|
|
|
|
+ busy = self._in_flight or self.segment_queue.qsize() > 0
|
|
|
|
|
+ self._publish(
|
|
|
|
|
+ events.HEARTBEAT,
|
|
|
|
|
+ in_flight=self._in_flight,
|
|
|
|
|
+ segments_done=self._segments_done,
|
|
|
|
|
+ worker_stall_s=(
|
|
|
|
|
+ round(now - self._last_worker_activity_ts, 3)
|
|
|
|
|
+ if busy and self._last_worker_activity_ts
|
|
|
|
|
+ else 0.0
|
|
|
|
|
+ ),
|
|
|
|
|
+ queue_depth=self.segment_queue.qsize(),
|
|
|
|
|
+ frames_since_last=self._frames_since_heartbeat,
|
|
|
|
|
+ rms=round(rms, 5),
|
|
|
|
|
+ capture=self.cfg.audio.device,
|
|
|
|
|
+ device_ok=self.capture.healthy() if self.capture else False,
|
|
|
|
|
+ model_loaded=self.transcriber is not None,
|
|
|
|
|
+ uptime_s=round(now - self._started_at, 3),
|
|
|
|
|
+ last_transcript_age_s=(
|
|
|
|
|
+ round(now - self._last_transcript_ts, 3)
|
|
|
|
|
+ if self._last_transcript_ts
|
|
|
|
|
+ else 0.0
|
|
|
|
|
+ ),
|
|
|
|
|
+ )
|
|
|
|
|
+ self._frames_since_heartbeat = 0
|
|
|
|
|
+
|
|
|
def _check_backlog(self) -> None:
|
|
def _check_backlog(self) -> None:
|
|
|
"""Warn once per overload episode; re-arm only after the queue fully drains."""
|
|
"""Warn once per overload episode; re-arm only after the queue fully drains."""
|
|
|
size = self.segment_queue.qsize()
|
|
size = self.segment_queue.qsize()
|
|
@@ -164,25 +311,70 @@ class Daemon:
|
|
|
self._backlog_warned = False
|
|
self._backlog_warned = False
|
|
|
|
|
|
|
|
def _worker_loop(self) -> None:
|
|
def _worker_loop(self) -> None:
|
|
|
- """Worker thread: transcribe segments and emit results."""
|
|
|
|
|
|
|
+ """Worker thread: transcribe segments and publish results."""
|
|
|
try:
|
|
try:
|
|
|
while not self._stop_event.is_set():
|
|
while not self._stop_event.is_set():
|
|
|
try:
|
|
try:
|
|
|
utt = self.segment_queue.get(timeout=1)
|
|
utt = self.segment_queue.get(timeout=1)
|
|
|
except queue.Empty:
|
|
except queue.Empty:
|
|
|
continue
|
|
continue
|
|
|
-
|
|
|
|
|
- if self.transcriber and self.sink:
|
|
|
|
|
- try:
|
|
|
|
|
- result = self.transcriber.transcribe(utt.audio)
|
|
|
|
|
- self.sink.emit(
|
|
|
|
|
- result, utt.duration_s, utt.rms, utt.reason, utt.audio
|
|
|
|
|
- )
|
|
|
|
|
- except Exception as e:
|
|
|
|
|
- self.logger.error(f"Transcription error: {e}")
|
|
|
|
|
-
|
|
|
|
|
|
|
+ self._process(utt)
|
|
|
except Exception as e:
|
|
except Exception as e:
|
|
|
self.logger.error(f"Worker thread error: {e}")
|
|
self.logger.error(f"Worker thread error: {e}")
|
|
|
|
|
+ finally:
|
|
|
|
|
+ self._drain_remaining()
|
|
|
|
|
+
|
|
|
|
|
+ def _process(self, utt: Utterance) -> None:
|
|
|
|
|
+ """Transcribe one segment, then always publish its segment_done."""
|
|
|
|
|
+ emitted = False
|
|
|
|
|
+ self._in_flight = True
|
|
|
|
|
+ self._last_worker_activity_ts = time.time()
|
|
|
|
|
+ try:
|
|
|
|
|
+ if self.transcriber and self.sink:
|
|
|
|
|
+ result = self.transcriber.transcribe(utt.audio)
|
|
|
|
|
+ audio_path = self.sink.emit(
|
|
|
|
|
+ result, utt.duration_s, utt.rms, utt.reason, utt.audio
|
|
|
|
|
+ )
|
|
|
|
|
+ if result.text:
|
|
|
|
|
+ emitted = True
|
|
|
|
|
+ self._last_transcript_ts = time.time()
|
|
|
|
|
+ self._publish(
|
|
|
|
|
+ events.TRANSCRIPT,
|
|
|
|
|
+ seg_id=utt.seg_id,
|
|
|
|
|
+ text=result.text,
|
|
|
|
|
+ lang=result.language,
|
|
|
|
|
+ lang_prob=result.language_probability,
|
|
|
|
|
+ duration_s=utt.duration_s,
|
|
|
|
|
+ rms=utt.rms,
|
|
|
|
|
+ end_reason=utt.reason,
|
|
|
|
|
+ audio=audio_path,
|
|
|
|
|
+ )
|
|
|
|
|
+ except Exception as e:
|
|
|
|
|
+ self.logger.error(f"Transcription error: {e}")
|
|
|
|
|
+ finally:
|
|
|
|
|
+ self._in_flight = False
|
|
|
|
|
+ self._segments_done += 1
|
|
|
|
|
+ self._last_segment_done_ts = time.time()
|
|
|
|
|
+ self._publish(
|
|
|
|
|
+ events.SEGMENT_DONE,
|
|
|
|
|
+ seg_id=utt.seg_id,
|
|
|
|
|
+ emitted=emitted,
|
|
|
|
|
+ end_reason=utt.reason,
|
|
|
|
|
+ )
|
|
|
|
|
+
|
|
|
|
|
+ def _drain_remaining(self) -> None:
|
|
|
|
|
+ """Announce any queued-but-unprocessed segments on shutdown."""
|
|
|
|
|
+ while True:
|
|
|
|
|
+ try:
|
|
|
|
|
+ utt = self.segment_queue.get_nowait()
|
|
|
|
|
+ except queue.Empty:
|
|
|
|
|
+ break
|
|
|
|
|
+ self._publish(
|
|
|
|
|
+ events.SEGMENT_DONE,
|
|
|
|
|
+ seg_id=utt.seg_id,
|
|
|
|
|
+ emitted=False,
|
|
|
|
|
+ end_reason=utt.reason,
|
|
|
|
|
+ )
|
|
|
|
|
|
|
|
def stop(self) -> None:
|
|
def stop(self) -> None:
|
|
|
"""Stop the daemon. Safe to call more than once."""
|
|
"""Stop the daemon. Safe to call more than once."""
|
|
@@ -207,5 +399,7 @@ class Daemon:
|
|
|
self.transcriber.close()
|
|
self.transcriber.close()
|
|
|
if self.sink:
|
|
if self.sink:
|
|
|
self.sink.close()
|
|
self.sink.close()
|
|
|
|
|
+ if self.publisher:
|
|
|
|
|
+ self.publisher.close()
|
|
|
|
|
|
|
|
self.logger.info("Daemon stopped.")
|
|
self.logger.info("Daemon stopped.")
|