output.py 4.3 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127
  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. save_audio: bool = False,
  25. audio_dir: str = "audio",
  26. sample_rate: int = 16000,
  27. ):
  28. """Initialize output sinks.
  29. Args:
  30. stdout: If True, emit to stdout.
  31. jsonl_dir: If set, append JSONL lines to daily files in this directory.
  32. save_audio: If True, write each transcript's audio as a WAV file.
  33. audio_dir: Directory for WAV files (dated subfolders).
  34. sample_rate: Audio sample rate, written into the WAV header.
  35. """
  36. self.stdout = stdout
  37. self.jsonl_dir = Path(jsonl_dir) if jsonl_dir else None
  38. self.save_audio = save_audio
  39. self.audio_dir = Path(audio_dir)
  40. self.sample_rate = sample_rate
  41. def emit(
  42. self,
  43. result: TranscriptResult,
  44. duration_s: float,
  45. rms: float = 0.0,
  46. reason: str = "unknown",
  47. audio: np.ndarray | None = None,
  48. ) -> str | None:
  49. """Emit a transcript result.
  50. Args:
  51. result: TranscriptResult from transcriber.
  52. duration_s: Duration of the utterance in seconds.
  53. rms: Root-mean-square level of the utterance audio.
  54. reason: Why the utterance ended (end_of_turn/pause/hard_cap/flush).
  55. audio: Utterance samples (float32, mono); saved when save_audio is enabled.
  56. Returns:
  57. Path of the saved audio file, or None if nothing was saved/emitted.
  58. """
  59. if not result.text:
  60. return None
  61. # One timestamp for the whole record: stdout, JSONL name/ts, and the
  62. # WAV path must agree even when emit() straddles midnight.
  63. now = datetime.now()
  64. audio_path = None
  65. if self.save_audio and audio is not None:
  66. try:
  67. audio_path = self._save_audio(audio, reason, now)
  68. except Exception as e:
  69. # Best-effort side output: a failed save must not drop the transcript.
  70. logger.warning("Failed to save audio for this utterance: %s", e)
  71. # Stdout line: [HH:MM:SS] (lang) text [rms=0.041 end_of_turn]
  72. if self.stdout:
  73. timestamp = now.strftime("%H:%M:%S")
  74. line = f"[{timestamp}] ({result.language}) {result.text} [rms={rms:.3f} {reason}]"
  75. print(line, flush=True)
  76. # JSONL file per day
  77. if self.jsonl_dir:
  78. self.jsonl_dir.mkdir(parents=True, exist_ok=True)
  79. today = now.strftime("%Y-%m-%d")
  80. jsonl_path = self.jsonl_dir / f"{today}.jsonl"
  81. record = {
  82. "ts": now.isoformat(),
  83. "lang": result.language,
  84. "lang_prob": result.language_probability,
  85. "duration_s": duration_s,
  86. "rms": round(rms, 5),
  87. "end_reason": reason,
  88. "text": result.text,
  89. }
  90. if audio_path is not None:
  91. record["audio"] = str(audio_path)
  92. with open(jsonl_path, "a", encoding="utf-8") as f:
  93. json.dump(record, f, ensure_ascii=False)
  94. f.write("\n")
  95. return str(audio_path) if audio_path is not None else None
  96. def _save_audio(self, audio: np.ndarray, reason: str, now: datetime) -> Path:
  97. day_dir = self.audio_dir / now.strftime("%Y-%m-%d")
  98. day_dir.mkdir(parents=True, exist_ok=True)
  99. name = f"{now.strftime('%H%M%S')}-{now.strftime('%f')[:3]}-{reason}.wav"
  100. path = day_dir / name
  101. write_wav(path, audio, self.sample_rate)
  102. return path
  103. def close(self) -> None:
  104. """Clean up (no-op for file-based output)."""
  105. pass