Efren Yevale Varela 1 сар өмнө
parent
commit
6f86ce9c37

+ 7 - 2
README.md

@@ -396,10 +396,15 @@ uv run stt-daemon clear --yes
 
 **JSONL** (one line per utterance):
 ```json
-{"ts": "2025-09-20T14:23:45.123456", "lang": "es", "lang_prob": 0.95, "duration_s": 2.5, "rms": 0.04123, "end_reason": "end_of_turn", "text": "Hola, cómo estás?"}
-{"ts": "2025-09-20T14:23:52.654321", "lang": "en", "lang_prob": 0.98, "duration_s": 1.8, "rms": 0.03811, "end_reason": "pause", "text": "What time is it?"}
+{"ts": "2025-09-20T14:23:45.123456", "lang": "es", "lang_prob": 0.95, "duration_s": 2.5, "rms": 0.04123, "end_reason": "end_of_turn", "text": "Hola, cómo estás?", "transcribe_s": 0.42}
+{"ts": "2025-09-20T14:23:52.654321", "lang": "en", "lang_prob": 0.98, "duration_s": 1.8, "rms": 0.03811, "end_reason": "pause", "text": "What time is it?", "transcribe_s": 0.31}
 ```
 
+`duration_s` is the length of the utterance audio; `transcribe_s` is the wall-clock
+time the worker spent transcribing it (inference latency), rounded to milliseconds.
+Their ratio is the real-time factor. The field is omitted if the timing was not
+measured (e.g. `emit()` called directly without `transcribe_s`).
+
 `end_reason` is one of `end_of_turn` (full `silence_ms` elapsed), `pause` (chunked at
 `chunk_pause_ms` because it passed `soft_max_s`), `hard_cap` (`max_utterance_s`
 reached), or `flush` (shutdown mid-utterance).

+ 8 - 1
src/stt_daemon/daemon.py

@@ -351,9 +351,16 @@ class Daemon:
         self._last_worker_activity_ts = time.time()
         try:
             if self.transcriber and self.sink:
+                started = time.perf_counter()
                 result = self.transcriber.transcribe(utt.audio)
+                transcribe_s = time.perf_counter() - started
                 audio_path = self.sink.emit(
-                    result, utt.duration_s, utt.rms, utt.reason, utt.audio
+                    result,
+                    utt.duration_s,
+                    utt.rms,
+                    utt.reason,
+                    utt.audio,
+                    transcribe_s=transcribe_s,
                 )
                 if result.text:
                     emitted = True

+ 5 - 0
src/stt_daemon/output.py

@@ -56,6 +56,7 @@ class TranscriptSink:
         rms: float = 0.0,
         reason: str = "unknown",
         audio: np.ndarray | None = None,
+        transcribe_s: float | None = None,
     ) -> str | None:
         """Emit a transcript result.
 
@@ -65,6 +66,8 @@ class TranscriptSink:
             rms: Root-mean-square level of the utterance audio.
             reason: Why the utterance ended (end_of_turn/pause/hard_cap/flush).
             audio: Utterance samples (float32, mono); saved when save_audio is enabled.
+            transcribe_s: Wall-clock seconds spent transcribing this utterance.
+                None omits the field from the record.
 
         Returns:
             Path of the saved audio file, or None if nothing was saved/emitted.
@@ -107,6 +110,8 @@ class TranscriptSink:
             }
             if audio_path is not None:
                 record["audio"] = str(audio_path)
+            if transcribe_s is not None:
+                record["transcribe_s"] = round(transcribe_s, 3)
 
             with open(jsonl_path, "a", encoding="utf-8") as f:
                 json.dump(record, f, ensure_ascii=False)

+ 32 - 2
tests/test_daemon_events.py

@@ -94,11 +94,21 @@ class FakeTranscriber:
 class FakeSink:
     def __init__(self):
         self.emitted = []
-
-    def emit(self, result, duration_s, rms=0.0, reason="unknown", audio=None):
+        self.transcribe_times = []
+
+    def emit(
+        self,
+        result,
+        duration_s,
+        rms=0.0,
+        reason="unknown",
+        audio=None,
+        transcribe_s=None,
+    ):
         if not result.text:
             return None
         self.emitted.append(result.text)
+        self.transcribe_times.append(transcribe_s)
         return None
 
     def close(self):
@@ -199,6 +209,26 @@ def test_transcript_and_segment_done_share_seg_id(sock_path):
     daemon.stop()
 
 
+def test_transcribe_seconds_is_forwarded_to_sink(sock_path):
+    """The worker measures wall time around transcribe() and passes it to emit()."""
+    cfg = _base_config(sock_path)
+    frames = [_frame() for _ in range(5)]
+    vad = FakeVad([False, True, True, False, False])
+    transcriber = FakeTranscriber(text="hello")
+    daemon, collector, subscriber, sink = _start_daemon(
+        cfg, FakeCapture(frames), vad, transcriber
+    )
+
+    collector.wait_for(lambda e: any(x["type"] == events.SEGMENT_DONE for x in e))
+
+    assert len(sink.transcribe_times) == 1
+    assert sink.transcribe_times[0] is not None
+    assert sink.transcribe_times[0] >= 0.0
+
+    subscriber.close()
+    daemon.stop()
+
+
 def test_gated_segment_reports_emitted_false(sock_path):
     cfg = _base_config(sock_path)
     frames = [_frame() for _ in range(5)]

+ 16 - 0
tests/test_output.py

@@ -28,6 +28,22 @@ def test_jsonl_includes_level_and_reason(tmp_path):
     assert record["lang"] == "en"
 
 
+def test_jsonl_includes_transcribe_seconds(tmp_path):
+    sink = TranscriptSink(stdout=False, jsonl_dir=str(tmp_path))
+    sink.emit(_result(), 2.5, rms=0.041234, reason="pause", transcribe_s=0.81234)
+
+    record = json.loads(next(tmp_path.glob("*.jsonl")).read_text().splitlines()[0])
+    assert record["transcribe_s"] == round(0.81234, 3)
+
+
+def test_transcribe_seconds_omitted_when_unknown(tmp_path):
+    sink = TranscriptSink(stdout=False, jsonl_dir=str(tmp_path))
+    sink.emit(_result(), 2.5, rms=0.041234, reason="pause")
+
+    record = json.loads(next(tmp_path.glob("*.jsonl")).read_text().splitlines()[0])
+    assert "transcribe_s" not in record
+
+
 def test_stdout_includes_level_and_reason(capsys):
     sink = TranscriptSink(stdout=True, jsonl_dir=None)
     sink.emit(_result(), 1.0, rms=0.02, reason="end_of_turn")