"""Saved records management — list, play, pause, resume, stop, rename, delete. Manages WAV recordings saved via the typed replay engine. """ from __future__ import annotations import json import threading from pathlib import Path from typing import Any from fastapi import APIRouter, HTTPException from pydantic import BaseModel from Project.Sanad.config import AUDIO_RECORDINGS_DIR from Project.Sanad.dashboard.routes._safe_io import ( safe_filename, safe_path_under, atomic_write_json, ) router = APIRouter() RECORDS_INDEX = AUDIO_RECORDINGS_DIR / "records.json" _INDEX_LOCK = threading.Lock() # Strong refs to fire-and-forget playback tasks. The event loop only keeps a # weak reference to tasks, so an unreferenced create_task() result can be # garbage-collected (cancelling playback) before it finishes. Mirror replay.py. import asyncio as _asyncio # noqa: E402 _BG_TASKS: set[_asyncio.Task] = set() def _load_index() -> dict[str, Any]: if not RECORDS_INDEX.exists(): return {"records": [], "total_records": 0, "last_updated": ""} try: with open(RECORDS_INDEX, "r", encoding="utf-8") as f: return json.load(f) except (json.JSONDecodeError, OSError): # Backup corrupt file rather than nuking it try: RECORDS_INDEX.rename(RECORDS_INDEX.with_suffix(".json.corrupt")) except OSError: pass return {"records": [], "total_records": 0, "last_updated": ""} def _save_index(payload: dict[str, Any]): AUDIO_RECORDINGS_DIR.mkdir(parents=True, exist_ok=True) payload["total_records"] = len(payload.get("records", [])) atomic_write_json(RECORDS_INDEX, payload) def _resolve_path(path_str: str) -> Path: """Resolve record path — basename / relative / absolute. Legacy records stored absolute paths. New records store basenames. Both flavors resolve to a real file under AUDIO_RECORDINGS_DIR. """ if not path_str: return AUDIO_RECORDINGS_DIR p = Path(path_str) if p.is_absolute(): return p return AUDIO_RECORDINGS_DIR / p def _reconcile(payload: dict[str, Any]) -> tuple[dict[str, Any], int]: kept, removed = [], 0 for entry in payload.get("records", []): try: sp = _resolve_path(entry["files"]["speaker_recording"]["path"]) rp = _resolve_path(entry["files"]["gemini_raw_output"]["path"]) if sp.exists() and rp.exists(): kept.append(entry) else: removed += 1 except (KeyError, TypeError): removed += 1 payload["records"] = kept payload["total_records"] = len(kept) return payload, removed @router.get("/") async def list_records(): with _INDEX_LOCK: payload = _load_index() payload, removed = _reconcile(payload) if removed: _save_index(payload) return payload class RecordPlay(BaseModel): record_name: str file_kind: str = "speaker" # speaker | raw @router.post("/play") async def play_record(payload: RecordPlay): with _INDEX_LOCK: index = _load_index() entry = next((r for r in index.get("records", []) if r.get("record_name") == payload.record_name), None) if entry is None: raise HTTPException(404, f"Record not found: {payload.record_name}") file_key = "speaker_recording" if payload.file_kind == "speaker" else "gemini_raw_output" raw_path = _resolve_path(entry["files"][file_key]["path"]).resolve() base = AUDIO_RECORDINGS_DIR.resolve() try: raw_path.relative_to(base) except ValueError: raise HTTPException(400, "Record path outside recordings directory.") if not raw_path.exists(): raise HTTPException(404, f"File not found: {raw_path.name}") from Project.Sanad.main import audio_mgr import threading # Fire-and-forget on a DEDICATED daemon thread — NOT asyncio.to_thread. # to_thread runs on the shared default executor, which gets starved while # the dashboard services the live-voice child's reconnect chatter; that # delayed record playback by several seconds (clip silent, counter parked). # A dedicated thread starts immediately regardless of executor/event-loop # load. play_wav blocks for the clip duration and serves pause/stop via # _play_state; the UI stays responsive because this handler returns now. # Python keeps running threads alive, so no ref is needed to prevent GC. threading.Thread( target=audio_mgr.play_wav, args=(raw_path, payload.record_name), name="record-playback", daemon=True, ).start() return {"ok": True, "record_name": payload.record_name, "file_kind": payload.file_kind, "path": str(raw_path)} @router.post("/pause") async def pause_playback(): from Project.Sanad.main import audio_mgr return audio_mgr.pause_playback() @router.post("/resume") async def resume_playback(): from Project.Sanad.main import audio_mgr return audio_mgr.resume_playback() @router.post("/seek") async def seek_playback(position_sec: float): """Jump to a position (seconds) in the currently-playing clip — used by the waveform scrubber. No-op (ok=False) if nothing is playing.""" from Project.Sanad.main import audio_mgr return audio_mgr.seek_playback(position_sec) @router.post("/stop") async def stop_playback(): from Project.Sanad.main import audio_mgr import asyncio await asyncio.to_thread(audio_mgr.stop_playback) return {"ok": True, "stopped": True} @router.get("/playback-status") async def playback_status(): from Project.Sanad.main import audio_mgr return audio_mgr.playback_status() @router.post("/live-hold") async def set_live_hold(on: bool): """Manual hold for the live-Gemini pause. on=True pauses the live voice and keeps it paused (records won't resume it) until on=False is sent. Default behaviour (on=False) is AUTO: records pause Gemini only for the clip.""" from Project.Sanad.main import audio_mgr return {"live_hold": audio_mgr.set_live_voice_hold(on)} class RecordRename(BaseModel): record_name: str new_name: str @router.post("/rename") async def rename_record(payload: RecordRename): new_name = safe_filename(payload.new_name) # Strip any extension the user provided — we add our own if new_name.lower().endswith(".wav"): new_name = new_name[:-4] if not new_name or new_name.startswith("."): raise HTTPException(400, "Invalid new name.") with _INDEX_LOCK: index = _load_index() entry = next( (r for r in index.get("records", []) if r.get("record_name") == payload.record_name), None, ) if entry is None: raise HTTPException(404, f"Record not found: {payload.record_name}") base = AUDIO_RECORDINGS_DIR.resolve() for key in ("speaker_recording", "gemini_raw_output"): try: old_path = _resolve_path(entry["files"][key]["path"]).resolve() old_path.relative_to(base) # ensure inside recordings dir except (KeyError, ValueError): continue if not old_path.exists(): continue suffix = "_raw.wav" if key == "gemini_raw_output" else ".wav" new_path = safe_path_under(AUDIO_RECORDINGS_DIR, f"{new_name}{suffix}") if new_path.exists(): raise HTTPException(409, f"File already exists: {new_path.name}") old_path.rename(new_path) entry["files"][key]["path"] = new_path.name # basename — portable entry["files"][key]["name"] = new_path.name entry["record_name"] = new_name _save_index(index) return {"ok": True, "record": entry} class RecordDelete(BaseModel): record_name: str @router.post("/delete") async def delete_record(payload: RecordDelete): with _INDEX_LOCK: index = _load_index() kept = [] deleted_entry = None for r in index.get("records", []): if r.get("record_name") == payload.record_name and deleted_entry is None: deleted_entry = r else: kept.append(r) if deleted_entry is None: raise HTTPException(404, f"Record not found: {payload.record_name}") base = AUDIO_RECORDINGS_DIR.resolve() deleted_files = [] for fi in deleted_entry.get("files", {}).values(): try: # _resolve_path handles new-style basenames (resolved under # AUDIO_RECORDINGS_DIR) as well as legacy absolute paths. # A raw Path(basename) would resolve vs CWD and fall outside # base, so the relative_to guard would skip the unlink and the # WAV would be orphaned on disk. Mirror play_record/rename_record. p = _resolve_path(fi.get("path", "")).resolve() p.relative_to(base) # never delete files outside recordings dir except (ValueError, OSError): continue if p.exists(): p.unlink() deleted_files.append(str(p)) index["records"] = kept _save_index(index) return {"ok": True, "deleted": payload.record_name, "deleted_files": deleted_files} class RecordBulkDelete(BaseModel): record_names: list[str] | None = None all: bool = False @router.post("/delete-bulk") async def delete_bulk(payload: RecordBulkDelete): """Delete many records in one call. all=True wipes every record; otherwise only those in record_names. Files are unlinked, guarded to the recordings dir (same safety as /delete).""" names = set(payload.record_names or []) with _INDEX_LOCK: index = _load_index() base = AUDIO_RECORDINGS_DIR.resolve() kept: list = [] removed: list = [] deleted_files = 0 for r in index.get("records", []): if payload.all or r.get("record_name") in names: removed.append(r.get("record_name")) for fi in r.get("files", {}).values(): try: p = _resolve_path(fi.get("path", "")).resolve() p.relative_to(base) # never delete outside recordings dir except (ValueError, OSError): continue if p.exists(): try: p.unlink() deleted_files += 1 except OSError: pass else: kept.append(r) index["records"] = kept _save_index(index) return {"ok": True, "deleted": removed, "deleted_count": len(removed), "deleted_files": deleted_files}