303 lines
11 KiB
Python
303 lines
11 KiB
Python
"""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}
|