output.py 4.9 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137
  1. """Output sinks for transcripts."""
  2. import json
  3. import logging
  4. import wave
  5. from datetime import datetime
  6. from pathlib import Path
  7. import numpy as np
  8. from .transcriber import TranscriptResult
  9. logger = logging.getLogger(__name__)
  10. def write_wav(path: Path, audio: np.ndarray, sample_rate: int) -> None:
  11. """Write float32 mono audio in [-1, 1] to a 16-bit PCM WAV file."""
  12. pcm = (np.clip(audio, -1.0, 1.0) * 32767.0).astype(np.int16)
  13. with wave.open(str(path), "wb") as wav:
  14. wav.setnchannels(1)
  15. wav.setsampwidth(2)
  16. wav.setframerate(sample_rate)
  17. wav.writeframes(pcm.tobytes())
  18. class TranscriptSink:
  19. """Emits transcripts to stdout and/or JSONL files."""
  20. def __init__(
  21. self,
  22. stdout: bool = True,
  23. jsonl_dir: str | None = None,
  24. jsonl: bool = False,
  25. save_audio: bool = False,
  26. audio_dir: str = "audio",
  27. sample_rate: int = 16000,
  28. ):
  29. """Initialize output sinks.
  30. Args:
  31. stdout: If True, emit to stdout.
  32. jsonl_dir: Directory for daily JSONL files.
  33. jsonl: If True (and jsonl_dir is set), append JSONL lines to daily
  34. files in that directory. Defaults off so agent-only runs do not
  35. persist transcripts to disk.
  36. save_audio: If True, write each transcript's audio as a WAV file.
  37. audio_dir: Directory for WAV files (dated subfolders).
  38. sample_rate: Audio sample rate, written into the WAV header.
  39. """
  40. self.stdout = stdout
  41. self.jsonl = jsonl
  42. self.jsonl_dir = Path(jsonl_dir) if (jsonl and jsonl_dir) else None
  43. self.save_audio = save_audio
  44. self.audio_dir = Path(audio_dir)
  45. self.sample_rate = sample_rate
  46. def emit(
  47. self,
  48. result: TranscriptResult,
  49. duration_s: float,
  50. rms: float = 0.0,
  51. reason: str = "unknown",
  52. audio: np.ndarray | None = None,
  53. transcribe_s: float | None = None,
  54. ) -> str | None:
  55. """Emit a transcript result.
  56. Args:
  57. result: TranscriptResult from transcriber.
  58. duration_s: Duration of the utterance in seconds.
  59. rms: Root-mean-square level of the utterance audio.
  60. reason: Why the utterance ended (end_of_turn/pause/hard_cap/flush).
  61. audio: Utterance samples (float32, mono); saved when save_audio is enabled.
  62. transcribe_s: Wall-clock seconds spent transcribing this utterance.
  63. None omits the field from the record.
  64. Returns:
  65. Path of the saved audio file, or None if nothing was saved/emitted.
  66. """
  67. if not result.text:
  68. return None
  69. # One timestamp for the whole record: stdout, JSONL name/ts, and the
  70. # WAV path must agree even when emit() straddles midnight.
  71. now = datetime.now()
  72. audio_path = None
  73. if self.save_audio and audio is not None:
  74. try:
  75. audio_path = self._save_audio(audio, reason, now)
  76. except Exception as e:
  77. # Best-effort side output: a failed save must not drop the transcript.
  78. logger.warning("Failed to save audio for this utterance: %s", e)
  79. # Stdout line: [HH:MM:SS] (lang) text [rms=0.041 end_of_turn]
  80. if self.stdout:
  81. timestamp = now.strftime("%H:%M:%S")
  82. line = f"[{timestamp}] ({result.language}) {result.text} [rms={rms:.3f} {reason}]"
  83. print(line, flush=True)
  84. # JSONL file per day
  85. if self.jsonl_dir:
  86. self.jsonl_dir.mkdir(parents=True, exist_ok=True)
  87. today = now.strftime("%Y-%m-%d")
  88. jsonl_path = self.jsonl_dir / f"{today}.jsonl"
  89. record = {
  90. "ts": now.isoformat(),
  91. "lang": result.language,
  92. "lang_prob": result.language_probability,
  93. "duration_s": duration_s,
  94. "rms": round(rms, 5),
  95. "end_reason": reason,
  96. "text": result.text,
  97. }
  98. if audio_path is not None:
  99. record["audio"] = str(audio_path)
  100. if transcribe_s is not None:
  101. record["transcribe_s"] = round(transcribe_s, 3)
  102. with open(jsonl_path, "a", encoding="utf-8") as f:
  103. json.dump(record, f, ensure_ascii=False)
  104. f.write("\n")
  105. return str(audio_path) if audio_path is not None else None
  106. def _save_audio(self, audio: np.ndarray, reason: str, now: datetime) -> Path:
  107. day_dir = self.audio_dir / now.strftime("%Y-%m-%d")
  108. day_dir.mkdir(parents=True, exist_ok=True)
  109. name = f"{now.strftime('%H%M%S')}-{now.strftime('%f')[:3]}-{reason}.wav"
  110. path = day_dir / name
  111. write_wav(path, audio, self.sample_rate)
  112. return path
  113. def close(self) -> None:
  114. """Clean up (no-op for file-based output)."""
  115. pass