test_output.py 5.9 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193
  1. """Tests for transcript output sinks."""
  2. import json
  3. import wave
  4. import numpy as np
  5. from stt_daemon.output import TranscriptSink
  6. from stt_daemon.transcriber import TranscriptResult
  7. def _result(text="hello world"):
  8. return TranscriptResult(language="en", language_probability=0.98, text=text)
  9. def test_jsonl_includes_level_and_reason(tmp_path):
  10. sink = TranscriptSink(stdout=False, jsonl_dir=str(tmp_path), jsonl=True)
  11. sink.emit(_result(), 2.5, rms=0.041234, reason="pause")
  12. files = list(tmp_path.glob("*.jsonl"))
  13. assert len(files) == 1
  14. record = json.loads(files[0].read_text().splitlines()[0])
  15. assert record["rms"] == round(0.041234, 5)
  16. assert record["end_reason"] == "pause"
  17. assert record["duration_s"] == 2.5
  18. assert record["text"] == "hello world"
  19. assert record["lang"] == "en"
  20. def test_jsonl_includes_transcribe_seconds(tmp_path):
  21. sink = TranscriptSink(stdout=False, jsonl_dir=str(tmp_path), jsonl=True)
  22. sink.emit(_result(), 2.5, rms=0.041234, reason="pause", transcribe_s=0.81234)
  23. record = json.loads(next(tmp_path.glob("*.jsonl")).read_text().splitlines()[0])
  24. assert record["transcribe_s"] == round(0.81234, 3)
  25. def test_transcribe_seconds_omitted_when_unknown(tmp_path):
  26. sink = TranscriptSink(stdout=False, jsonl_dir=str(tmp_path), jsonl=True)
  27. sink.emit(_result(), 2.5, rms=0.041234, reason="pause")
  28. record = json.loads(next(tmp_path.glob("*.jsonl")).read_text().splitlines()[0])
  29. assert "transcribe_s" not in record
  30. def test_jsonl_disabled_writes_nothing(tmp_path, capsys):
  31. """jsonl=False keeps transcripts off disk even when jsonl_dir is set."""
  32. sink = TranscriptSink(stdout=True, jsonl_dir=str(tmp_path), jsonl=False)
  33. sink.emit(_result(), 1.0, rms=0.02, reason="pause")
  34. assert "hello world" in capsys.readouterr().out
  35. assert list(tmp_path.glob("*.jsonl")) == []
  36. def test_stdout_includes_level_and_reason(capsys):
  37. sink = TranscriptSink(stdout=True, jsonl_dir=None)
  38. sink.emit(_result(), 1.0, rms=0.02, reason="end_of_turn")
  39. out = capsys.readouterr().out
  40. assert "rms=0.020" in out
  41. assert "end_of_turn" in out
  42. assert "hello world" in out
  43. def test_empty_text_is_skipped(tmp_path, capsys):
  44. sink = TranscriptSink(stdout=True, jsonl_dir=str(tmp_path), jsonl=True)
  45. sink.emit(_result(text=""), 1.0, rms=0.02, reason="pause")
  46. assert capsys.readouterr().out == ""
  47. assert list(tmp_path.glob("*.jsonl")) == []
  48. def test_saves_wav_when_enabled(tmp_path):
  49. audio = np.zeros(16000, dtype=np.float32)
  50. audio[:8000] = 0.5
  51. logs = tmp_path / "logs"
  52. sink = TranscriptSink(
  53. stdout=False,
  54. jsonl_dir=str(logs),
  55. jsonl=True,
  56. save_audio=True,
  57. audio_dir=str(tmp_path / "audio"),
  58. sample_rate=16000,
  59. )
  60. sink.emit(_result(), 1.0, rms=0.3, reason="pause", audio=audio)
  61. wavs = list((tmp_path / "audio").rglob("*.wav"))
  62. assert len(wavs) == 1
  63. with wave.open(str(wavs[0]), "rb") as wf:
  64. assert wf.getnchannels() == 1
  65. assert wf.getsampwidth() == 2
  66. assert wf.getframerate() == 16000
  67. assert wf.getnframes() == 16000
  68. record = json.loads(next(logs.glob("*.jsonl")).read_text().splitlines()[0])
  69. assert record["audio"] == str(wavs[0])
  70. def test_single_timestamp_per_emit(tmp_path, monkeypatch):
  71. """All artifacts of one emit() share one timestamp, even across midnight."""
  72. import stt_daemon.output as output
  73. from datetime import datetime as real_datetime
  74. calls = []
  75. t1 = real_datetime(2026, 9, 20, 23, 59, 59)
  76. t2 = real_datetime(2026, 9, 21, 0, 0, 1)
  77. class FakeDatetime:
  78. @staticmethod
  79. def now():
  80. calls.append(1)
  81. return t1 if len(calls) == 1 else t2
  82. monkeypatch.setattr(output, "datetime", FakeDatetime)
  83. logs = tmp_path / "logs"
  84. sink = TranscriptSink(
  85. stdout=False,
  86. jsonl_dir=str(logs),
  87. jsonl=True,
  88. save_audio=True,
  89. audio_dir=str(tmp_path / "audio"),
  90. sample_rate=16000,
  91. )
  92. sink.emit(_result(), 1.0, rms=0.1, reason="pause", audio=np.zeros(160, dtype=np.float32))
  93. assert len(calls) == 1
  94. jsonl = next(logs.glob("*.jsonl"))
  95. assert jsonl.name == "2026-09-20.jsonl"
  96. record = json.loads(jsonl.read_text().splitlines()[0])
  97. assert record["ts"].startswith("2026-09-20")
  98. wav = next((tmp_path / "audio").rglob("*.wav"))
  99. assert wav.parent.name == "2026-09-20"
  100. def test_no_wav_when_disabled(tmp_path):
  101. sink = TranscriptSink(
  102. stdout=False,
  103. jsonl_dir=None,
  104. save_audio=False,
  105. audio_dir=str(tmp_path / "audio"),
  106. sample_rate=16000,
  107. )
  108. sink.emit(_result(), 1.0, rms=0.3, reason="pause", audio=np.zeros(1600, dtype=np.float32))
  109. assert not (tmp_path / "audio").exists()
  110. def test_save_audio_without_samples_is_safe(tmp_path):
  111. sink = TranscriptSink(
  112. stdout=False,
  113. jsonl_dir=None,
  114. save_audio=True,
  115. audio_dir=str(tmp_path / "audio"),
  116. sample_rate=16000,
  117. )
  118. sink.emit(_result(), 1.0, rms=0.3, reason="pause", audio=None)
  119. assert not (tmp_path / "audio").exists()
  120. def test_failed_audio_save_is_non_fatal(tmp_path, monkeypatch, capsys, caplog):
  121. import stt_daemon.output as output
  122. def boom(*args, **kwargs):
  123. raise OSError("disk full")
  124. monkeypatch.setattr(output, "write_wav", boom)
  125. logs = tmp_path / "logs"
  126. sink = TranscriptSink(
  127. stdout=True,
  128. jsonl_dir=str(logs),
  129. jsonl=True,
  130. save_audio=True,
  131. audio_dir=str(tmp_path / "audio"),
  132. sample_rate=16000,
  133. )
  134. with caplog.at_level("WARNING"):
  135. sink.emit(
  136. _result(), 1.0, rms=0.3, reason="pause", audio=np.zeros(1600, dtype=np.float32)
  137. )
  138. assert "hello world" in capsys.readouterr().out
  139. record = json.loads(next(logs.glob("*.jsonl")).read_text().splitlines()[0])
  140. assert record["text"] == "hello world"
  141. assert "audio" not in record
  142. assert "Failed to save audio" in caplog.text