瀏覽代碼

Extended transcript log

Efren Yevale Varela 1 月之前
父節點
當前提交
609a8cda14
共有 5 個文件被更改,包括 117 次插入 和 9 次删除
  1. 1 1
      src/stt_daemon/daemon.py
  2. 13 3
      src/stt_daemon/output.py
  3. 15 5
      src/stt_daemon/segmenter.py
  4. 43 0
      tests/test_output.py
  5. 45 0
      tests/test_segmenter.py

+ 1 - 1
src/stt_daemon/daemon.py

@@ -157,7 +157,7 @@ class Daemon:
                 if self.transcriber and self.sink:
                     try:
                         result = self.transcriber.transcribe(utt.audio)
-                        self.sink.emit(result, utt.duration_s)
+                        self.sink.emit(result, utt.duration_s, utt.rms, utt.reason)
                     except Exception as e:
                         self.logger.error(f"Transcription error: {e}")
 

+ 13 - 3
src/stt_daemon/output.py

@@ -20,21 +20,29 @@ class TranscriptSink:
         self.stdout = stdout
         self.jsonl_dir = Path(jsonl_dir) if jsonl_dir else None
 
-    def emit(self, result: TranscriptResult, duration_s: float) -> None:
+    def emit(
+        self,
+        result: TranscriptResult,
+        duration_s: float,
+        rms: float = 0.0,
+        reason: str = "unknown",
+    ) -> None:
         """Emit a transcript result.
 
         Args:
             result: TranscriptResult from transcriber.
             duration_s: Duration of the utterance in seconds.
+            rms: Root-mean-square level of the utterance audio.
+            reason: Why the utterance ended (end_of_turn/pause/hard_cap/flush).
         """
         if not result.text:
             return
 
-        # Stdout line: [HH:MM:SS] (lang) text
+        # Stdout line: [HH:MM:SS] (lang) text  [rms=0.041 end_of_turn]
         if self.stdout:
             now = datetime.now()
             timestamp = now.strftime("%H:%M:%S")
-            line = f"[{timestamp}] ({result.language}) {result.text}"
+            line = f"[{timestamp}] ({result.language}) {result.text}  [rms={rms:.3f} {reason}]"
             print(line, flush=True)
 
         # JSONL file per day
@@ -48,6 +56,8 @@ class TranscriptSink:
                 "lang": result.language,
                 "lang_prob": result.language_probability,
                 "duration_s": duration_s,
+                "rms": round(rms, 5),
+                "end_reason": reason,
                 "text": result.text,
             }
 

+ 15 - 5
src/stt_daemon/segmenter.py

@@ -8,12 +8,20 @@ import numpy as np
 import webrtcvad
 
 
+END_OF_TURN = "end_of_turn"
+PAUSE = "pause"
+HARD_CAP = "hard_cap"
+FLUSH = "flush"
+
+
 @dataclass
 class Utterance:
     """A complete utterance ready for transcription."""
     audio: np.ndarray      # float32, mono, normalized to [-1, 1], 16 kHz
     duration_s: float
     started_at: float      # time.time() when confirmed
+    rms: float = 0.0       # root-mean-square level of the emitted audio
+    reason: str = "unknown"  # why the utterance ended (END_OF_TURN/PAUSE/HARD_CAP/FLUSH)
 
 
 def make_vad(aggressiveness: int) -> webrtcvad.Vad:
@@ -137,18 +145,18 @@ class FrameSegmenter:
 
         if self.silence_run >= self.silence_frames:
             # End of turn: the speaker stopped.
-            return self._finish(keep_tail=True)
+            return self._finish(keep_tail=True, reason=END_OF_TURN)
 
         if len(self.segment) >= self.max_frames:
             # Hard cap (speaker never paused) - must cut somewhere.
-            return self._finish(keep_tail=False)
+            return self._finish(keep_tail=False, reason=HARD_CAP)
 
         if (
             len(self.segment) >= self.soft_max_frames
             and self.silence_run >= self.chunk_pause_frames
         ):
             # Long speech: end a chunk at a natural pause instead of shipping a huge blob.
-            return self._finish(keep_tail=True)
+            return self._finish(keep_tail=True, reason=PAUSE)
 
         return None
 
@@ -165,10 +173,10 @@ class FrameSegmenter:
     def flush(self) -> Utterance | None:
         """Finalize any in-progress utterance (called on shutdown)."""
         if self.speaking:
-            return self._finish(keep_tail=False)
+            return self._finish(keep_tail=False, reason=FLUSH)
         return None
 
-    def _finish(self, keep_tail: bool) -> Utterance | None:
+    def _finish(self, keep_tail: bool, reason: str) -> Utterance | None:
         """Finalize and emit an utterance, or discard it if it is a blip or too quiet."""
         frames = self.segment
         trailing_silence = self.silence_run
@@ -215,4 +223,6 @@ class FrameSegmenter:
             audio=audio,
             duration_s=len(audio) / self.sample_rate,
             started_at=self.started_at,
+            rms=rms,
+            reason=reason,
         )

+ 43 - 0
tests/test_output.py

@@ -0,0 +1,43 @@
+"""Tests for transcript output sinks."""
+
+import json
+
+from stt_daemon.output import TranscriptSink
+from stt_daemon.transcriber import TranscriptResult
+
+
+def _result(text="hello world"):
+    return TranscriptResult(language="en", language_probability=0.98, text=text)
+
+
+def test_jsonl_includes_level_and_reason(tmp_path):
+    sink = TranscriptSink(stdout=False, jsonl_dir=str(tmp_path))
+    sink.emit(_result(), 2.5, rms=0.041234, reason="pause")
+
+    files = list(tmp_path.glob("*.jsonl"))
+    assert len(files) == 1
+
+    record = json.loads(files[0].read_text().splitlines()[0])
+    assert record["rms"] == round(0.041234, 5)
+    assert record["end_reason"] == "pause"
+    assert record["duration_s"] == 2.5
+    assert record["text"] == "hello world"
+    assert record["lang"] == "en"
+
+
+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")
+
+    out = capsys.readouterr().out
+    assert "rms=0.020" in out
+    assert "end_of_turn" in out
+    assert "hello world" in out
+
+
+def test_empty_text_is_skipped(tmp_path, capsys):
+    sink = TranscriptSink(stdout=True, jsonl_dir=str(tmp_path))
+    sink.emit(_result(text=""), 1.0, rms=0.02, reason="pause")
+
+    assert capsys.readouterr().out == ""
+    assert list(tmp_path.glob("*.jsonl")) == []

+ 45 - 0
tests/test_segmenter.py

@@ -497,3 +497,48 @@ def test_trailing_silence_trimmed():
     assert utt is not None
     # 14 frames; trailing 10 silence trimmed to 7 kept -> 3 dropped
     assert len(utt.audio) == (14 - 3) * frame_samples
+
+
+def test_utterance_reports_rms_and_reason_for_each_end():
+    """Each finalize path must label why it ended and report the audio level."""
+    fs = 480
+
+    # End of turn
+    end = _segmenter(FakeVad([True] * 5 + [False] * 2), silence_ms=60)
+    for _ in range(5):
+        end.push(make_speech_frame(fs, value=1000))
+    end.push(make_silent_frame(fs))
+    u_end = end.push(make_silent_frame(fs))
+    assert u_end is not None
+    assert u_end.reason == "end_of_turn"
+    assert u_end.rms > 0.0
+
+    # Pause-aligned chunk
+    chunk = _segmenter(FakeVad([True] * 35 + [False] * 10))
+    for _ in range(35):
+        chunk.push(make_speech_frame(fs, value=1000))
+    u_chunk = None
+    for _ in range(10):
+        u_chunk = chunk.push(make_silent_frame(fs))
+        if u_chunk is not None:
+            break
+    assert u_chunk is not None
+    assert u_chunk.reason == "pause"
+
+    # Hard cap
+    capped = _segmenter(FakeVad([True] * 40), max_utterance_s=0.3)
+    u_cap = None
+    for _ in range(40):
+        u_cap = capped.push(make_speech_frame(fs, value=1000))
+        if u_cap is not None:
+            break
+    assert u_cap is not None
+    assert u_cap.reason == "hard_cap"
+
+    # Flush on shutdown
+    flushed = _segmenter(FakeVad([True] * 3))
+    for _ in range(3):
+        flushed.push(make_speech_frame(fs, value=1000))
+    u_flush = flushed.flush()
+    assert u_flush is not None
+    assert u_flush.reason == "flush"