"""Per-turn WAV recorder for voice brains. Direct port of Project/Sanad/voice/sanad_voice.py::TurnRecorder. Saves each conversation turn as two WAV files: _user.wav mono int16 @ 16 kHz (what the mic captured) _robot.wav mono int16 @ 24 kHz (what the brain spoke) Plus an index.json that appends one entry per turn with the transcripts. A turn starts when audio first flows through `capture_user` or `capture_robot`, and ends on `finish_turn`. Call pattern matches Sanad exactly: `capture_user`, `capture_robot`, `add_user_text`, `add_robot_text`, `finish_turn`. Disable via config: stt.gemini_record_enabled = false (the caller passes `enabled=False`). """ from __future__ import annotations import json import logging import os import threading import time import wave from datetime import datetime log = logging.getLogger("turn_recorder") class TurnRecorder: """Saves each turn as two WAV files: user mic + model output.""" def __init__( self, enabled: bool = True, out_dir: str = "", user_rate: int = 16000, robot_rate: int = 24000, keep_count: int = 50, ): self.enabled = bool(enabled) and bool(out_dir) self.out_dir = out_dir self.user_rate = int(user_rate) self.robot_rate = int(robot_rate) # Cap the number of turn-pairs on disk. 0/negative = unlimited. # finish_turn() prunes the oldest WAVs after every save so the # folder can never balloon past keep_count*2 files in the wild. self.keep_count = int(keep_count) if self.enabled: os.makedirs(self.out_dir, exist_ok=True) self._lock = threading.Lock() self._user_buf = [] self._robot_buf = [] self._user_text = "" self._robot_text = "" self._started_at = 0.0 def capture_user(self, pcm: bytes) -> None: if not self.enabled or not pcm: return with self._lock: if not self._user_buf and not self._robot_buf: self._started_at = time.time() self._user_buf.append(pcm) def capture_robot(self, pcm: bytes) -> None: if not self.enabled or not pcm: return with self._lock: if not self._user_buf and not self._robot_buf: self._started_at = time.time() self._robot_buf.append(pcm) def add_user_text(self, text: str) -> None: if text and self.enabled: with self._lock: self._user_text = (self._user_text + " " + text).strip() def add_robot_text(self, text: str) -> None: if text and self.enabled: with self._lock: self._robot_text = (self._robot_text + " " + text).strip() def finish_turn(self) -> dict: if not self.enabled: return {} with self._lock: user_data = b"".join(self._user_buf) robot_data = b"".join(self._robot_buf) user_text = self._user_text robot_text = self._robot_text started_at = self._started_at self._user_buf.clear() self._robot_buf.clear() self._user_text = "" self._robot_text = "" if not user_data and not robot_data: return {} stamp = datetime.fromtimestamp(started_at).strftime("%Y%m%d_%H%M%S") entry = { "timestamp": stamp, "started_at": started_at, "user_text": user_text, "robot_text": robot_text, } try: if user_data: p = os.path.join(self.out_dir, "{}_user.wav".format(stamp)) self._save_wav(p, user_data, self.user_rate) entry["user_wav"] = p entry["user_duration_sec"] = round( len(user_data) / (self.user_rate * 2), 3, ) if robot_data: p = os.path.join(self.out_dir, "{}_robot.wav".format(stamp)) self._save_wav(p, robot_data, self.robot_rate) entry["robot_wav"] = p entry["robot_duration_sec"] = round( len(robot_data) / (self.robot_rate * 2), 3, ) self._append_index(entry) self._rotate() log.info( "recorded turn → %s (user %.1fs, robot %.1fs)", stamp, entry.get("user_duration_sec", 0), entry.get("robot_duration_sec", 0), ) except Exception as exc: log.warning("recording save failed: %s", exc) return entry def _rotate(self) -> None: """Prune oldest WAV files when count exceeds keep_count*2 (one user + one robot per turn). index.json is never touched.""" if self.keep_count <= 0: return try: wavs = [ f for f in os.listdir(self.out_dir) if f.endswith(".wav") ] if len(wavs) <= self.keep_count * 2: return # Sort oldest-first by mtime; remove the head of the list. wavs_paths = [os.path.join(self.out_dir, f) for f in wavs] wavs_paths.sort(key=lambda p: os.path.getmtime(p)) to_remove = wavs_paths[: len(wavs_paths) - self.keep_count * 2] for p in to_remove: try: os.remove(p) except Exception: pass except Exception as exc: log.warning("recording rotate failed: %s", exc) @staticmethod def _save_wav(path: str, pcm: bytes, rate: int) -> None: with wave.open(path, "wb") as wf: wf.setnchannels(1) wf.setsampwidth(2) wf.setframerate(rate) wf.writeframes(pcm) def _append_index(self, entry: dict) -> None: idx_path = os.path.join(self.out_dir, "index.json") try: if os.path.exists(idx_path): with open(idx_path, "r", encoding="utf-8") as f: payload = json.load(f) if not isinstance(payload, dict): payload = {"records": []} else: payload = {"records": []} except Exception: payload = {"records": []} payload.setdefault("records", []).append(entry) payload["total_records"] = len(payload["records"]) try: with open(idx_path, "w", encoding="utf-8") as f: json.dump(payload, f, indent=2, ensure_ascii=False) except Exception as exc: log.warning("index.json write failed: %s", exc)