test_output.py 9.1 KB

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