test_output.py 8.0 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250
  1. """Tests for transcript output sinks."""
  2. import json
  3. import wave
  4. import numpy as np
  5. import pytest
  6. from stt_daemon.output import TranscriptSink
  7. from stt_daemon.transcriber import TranscriptResult
  8. def _result(text="hello world"):
  9. return TranscriptResult(language="en", language_probability=0.98, text=text)
  10. def test_jsonl_includes_level_and_reason(tmp_path):
  11. sink = TranscriptSink(stdout=False, jsonl_dir=str(tmp_path), jsonl=True)
  12. sink.emit(_result(), 2.5, rms=0.041234, reason="pause")
  13. files = list(tmp_path.glob("*.jsonl"))
  14. assert len(files) == 1
  15. record = json.loads(files[0].read_text().splitlines()[0])
  16. assert record["rms"] == round(0.041234, 5)
  17. assert record["end_reason"] == "pause"
  18. assert record["duration_s"] == 2.5
  19. assert record["text"] == "hello world"
  20. assert record["lang"] == "en"
  21. def test_jsonl_includes_transcribe_seconds(tmp_path):
  22. sink = TranscriptSink(stdout=False, jsonl_dir=str(tmp_path), jsonl=True)
  23. sink.emit(_result(), 2.5, rms=0.041234, reason="pause", transcribe_s=0.81234)
  24. record = json.loads(next(tmp_path.glob("*.jsonl")).read_text().splitlines()[0])
  25. assert record["transcribe_s"] == round(0.81234, 3)
  26. def test_transcribe_seconds_omitted_when_unknown(tmp_path):
  27. sink = TranscriptSink(stdout=False, jsonl_dir=str(tmp_path), jsonl=True)
  28. sink.emit(_result(), 2.5, rms=0.041234, reason="pause")
  29. record = json.loads(next(tmp_path.glob("*.jsonl")).read_text().splitlines()[0])
  30. assert "transcribe_s" not in record
  31. def test_jsonl_disabled_writes_nothing(tmp_path, capsys):
  32. """jsonl=False keeps transcripts off disk even when jsonl_dir is set."""
  33. sink = TranscriptSink(stdout=True, jsonl_dir=str(tmp_path), jsonl=False)
  34. sink.emit(_result(), 1.0, rms=0.02, reason="pause")
  35. assert "hello world" in capsys.readouterr().out
  36. assert list(tmp_path.glob("*.jsonl")) == []
  37. def test_stdout_includes_level_and_reason(capsys):
  38. sink = TranscriptSink(stdout=True, jsonl_dir=None)
  39. sink.emit(_result(), 1.0, rms=0.02, reason="end_of_turn")
  40. out = capsys.readouterr().out
  41. assert "rms=0.020" in out
  42. assert "end_of_turn" in out
  43. assert "hello world" in out
  44. def test_empty_text_is_skipped(tmp_path, capsys):
  45. sink = TranscriptSink(stdout=True, jsonl_dir=str(tmp_path), jsonl=True)
  46. sink.emit(_result(text=""), 1.0, rms=0.02, reason="pause")
  47. assert capsys.readouterr().out == ""
  48. assert list(tmp_path.glob("*.jsonl")) == []
  49. def test_saves_wav_when_enabled(tmp_path):
  50. audio = np.zeros(16000, dtype=np.float32)
  51. audio[:8000] = 0.5
  52. logs = tmp_path / "logs"
  53. sink = TranscriptSink(
  54. stdout=False,
  55. jsonl_dir=str(logs),
  56. jsonl=True,
  57. save_audio=True,
  58. audio_dir=str(tmp_path / "audio"),
  59. sample_rate=16000,
  60. )
  61. sink.emit(_result(), 1.0, rms=0.3, reason="pause", audio=audio)
  62. wavs = list((tmp_path / "audio").rglob("*.wav"))
  63. assert len(wavs) == 1
  64. with wave.open(str(wavs[0]), "rb") as wf:
  65. assert wf.getnchannels() == 1
  66. assert wf.getsampwidth() == 2
  67. assert wf.getframerate() == 16000
  68. assert wf.getnframes() == 16000
  69. record = json.loads(next(logs.glob("*.jsonl")).read_text().splitlines()[0])
  70. assert record["audio"] == str(wavs[0])
  71. def test_single_timestamp_per_emit(tmp_path, monkeypatch):
  72. """All artifacts of one emit() share one timestamp, even across midnight."""
  73. import stt_daemon.output as output
  74. from datetime import datetime as real_datetime
  75. calls = []
  76. t1 = real_datetime(2026, 9, 20, 23, 59, 59)
  77. t2 = real_datetime(2026, 9, 21, 0, 0, 1)
  78. class FakeDatetime:
  79. @staticmethod
  80. def now():
  81. calls.append(1)
  82. return t1 if len(calls) == 1 else t2
  83. monkeypatch.setattr(output, "datetime", FakeDatetime)
  84. logs = tmp_path / "logs"
  85. sink = TranscriptSink(
  86. stdout=False,
  87. jsonl_dir=str(logs),
  88. jsonl=True,
  89. save_audio=True,
  90. audio_dir=str(tmp_path / "audio"),
  91. sample_rate=16000,
  92. )
  93. sink.emit(_result(), 1.0, rms=0.1, reason="pause", audio=np.zeros(160, dtype=np.float32))
  94. assert len(calls) == 1
  95. jsonl = next(logs.glob("*.jsonl"))
  96. assert jsonl.name == "2026-09-20.jsonl"
  97. record = json.loads(jsonl.read_text().splitlines()[0])
  98. assert record["ts"].startswith("2026-09-20")
  99. wav = next((tmp_path / "audio").rglob("*.wav"))
  100. assert wav.parent.name == "2026-09-20"
  101. def test_no_wav_when_disabled(tmp_path):
  102. sink = TranscriptSink(
  103. stdout=False,
  104. jsonl_dir=None,
  105. save_audio=False,
  106. audio_dir=str(tmp_path / "audio"),
  107. sample_rate=16000,
  108. )
  109. sink.emit(_result(), 1.0, rms=0.3, reason="pause", audio=np.zeros(1600, dtype=np.float32))
  110. assert not (tmp_path / "audio").exists()
  111. def test_save_audio_without_samples_is_safe(tmp_path):
  112. sink = TranscriptSink(
  113. stdout=False,
  114. jsonl_dir=None,
  115. save_audio=True,
  116. audio_dir=str(tmp_path / "audio"),
  117. sample_rate=16000,
  118. )
  119. sink.emit(_result(), 1.0, rms=0.3, reason="pause", audio=None)
  120. assert not (tmp_path / "audio").exists()
  121. def test_failed_audio_save_is_non_fatal(tmp_path, monkeypatch, capsys, caplog):
  122. import stt_daemon.output as output
  123. def boom(*args, **kwargs):
  124. raise OSError("disk full")
  125. monkeypatch.setattr(output, "write_wav", boom)
  126. logs = tmp_path / "logs"
  127. sink = TranscriptSink(
  128. stdout=True,
  129. jsonl_dir=str(logs),
  130. jsonl=True,
  131. save_audio=True,
  132. audio_dir=str(tmp_path / "audio"),
  133. sample_rate=16000,
  134. )
  135. with caplog.at_level("WARNING"):
  136. sink.emit(
  137. _result(), 1.0, rms=0.3, reason="pause", audio=np.zeros(1600, dtype=np.float32)
  138. )
  139. assert "hello world" in capsys.readouterr().out
  140. record = json.loads(next(logs.glob("*.jsonl")).read_text().splitlines()[0])
  141. assert record["text"] == "hello world"
  142. assert "audio" not in record
  143. assert "Failed to save audio" in caplog.text
  144. def test_failed_serialization_leaves_jsonl_untouched(tmp_path):
  145. """A record that cannot be serialized must not leave a partial line behind."""
  146. sink = TranscriptSink(stdout=False, jsonl_dir=str(tmp_path), jsonl=True)
  147. sink.emit(_result(), 2.5, rms=0.04, reason="pause")
  148. path = next(tmp_path.glob("*.jsonl"))
  149. before = path.read_text()
  150. bad = TranscriptResult(language="en", language_probability=np.float32(0.98), text="boom")
  151. with pytest.raises(TypeError):
  152. sink.emit(bad, 2.5, rms=0.04, reason="pause")
  153. assert path.read_text() == before
  154. def test_jsonl_includes_speaker_fields(tmp_path):
  155. sink = TranscriptSink(stdout=False, jsonl_dir=str(tmp_path), jsonl=True)
  156. sink.emit(
  157. _result(),
  158. 3.2,
  159. rms=0.031,
  160. reason="pause",
  161. seg_id=142,
  162. speaker_id="spk_0001",
  163. speaker_status="matched",
  164. speaker="Efren",
  165. speaker_score=0.712345,
  166. )
  167. record = json.loads(next(tmp_path.glob("*.jsonl")).read_text().splitlines()[0])
  168. assert record["seg_id"] == 142
  169. assert record["speaker_id"] == "spk_0001"
  170. assert record["speaker_status"] == "matched"
  171. assert record["speaker"] == "Efren"
  172. assert record["speaker_score"] == round(0.712345, 4)
  173. def test_jsonl_speaker_fields_default_to_nulls(tmp_path):
  174. sink = TranscriptSink(stdout=False, jsonl_dir=str(tmp_path), jsonl=True)
  175. sink.emit(_result(), 2.5, rms=0.04, reason="pause")
  176. record = json.loads(next(tmp_path.glob("*.jsonl")).read_text().splitlines()[0])
  177. assert record["seg_id"] is None
  178. assert record["speaker_id"] is None
  179. assert record["speaker_status"] == "none"
  180. assert record["speaker"] is None
  181. assert record["speaker_score"] is None
  182. def test_jsonl_speaker_score_rounded_to_4dp(tmp_path):
  183. sink = TranscriptSink(stdout=False, jsonl_dir=str(tmp_path), jsonl=True)
  184. sink.emit(_result(), 1.0, rms=0.02, reason="pause", speaker_score=0.123456)
  185. record = json.loads(next(tmp_path.glob("*.jsonl")).read_text().splitlines()[0])
  186. assert record["speaker_score"] == round(0.123456, 4)