admin.py 54 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123112411251126112711281129113011311132113311341135113611371138113911401141114211431144114511461147114811491150115111521153115411551156115711581159116011611162116311641165116611671168116911701171117211731174117511761177117811791180118111821183118411851186118711881189119011911192119311941195119611971198119912001201120212031204120512061207120812091210121112121213121412151216121712181219122012211222122312241225122612271228122912301231123212331234123512361237123812391240124112421243124412451246124712481249125012511252125312541255125612571258125912601261126212631264126512661267126812691270127112721273127412751276127712781279128012811282128312841285128612871288128912901291129212931294129512961297129812991300130113021303130413051306130713081309131013111312131313141315131613171318131913201321132213231324132513261327132813291330133113321333133413351336133713381339134013411342134313441345134613471348134913501351135213531354135513561357135813591360136113621363136413651366136713681369137013711372137313741375137613771378137913801381138213831384138513861387138813891390139113921393139413951396139713981399140014011402140314041405140614071408140914101411141214131414141514161417141814191420142114221423142414251426142714281429143014311432143314341435143614371438143914401441144214431444144514461447144814491450145114521453145414551456145714581459146014611462146314641465146614671468146914701471147214731474147514761477147814791480148114821483148414851486148714881489149014911492149314941495149614971498149915001501150215031504150515061507150815091510151115121513151415151516151715181519152015211522152315241525152615271528152915301531153215331534153515361537153815391540154115421543154415451546154715481549155015511552155315541555155615571558155915601561156215631564156515661567156815691570157115721573157415751576157715781579158015811582158315841585158615871588158915901591159215931594159515961597159815991600
  1. #!/usr/bin/env python3
  2. """
  3. admin.py — Speaker Admin Web Server
  4. Local web interface for managing speaker names, voice recordings,
  5. and test recording playback. Also serves the fullscreen display
  6. page for tablets / TVs and the SSE stream that feeds it.
  7. Runs on port 8001 alongside bridge.py.
  8. Access at:
  9. http://localhost:8001 ← speaker admin
  10. http://[PC-IP]:8001/display ← fullscreen display for tablets
  11. """
  12. import asyncio
  13. import json
  14. import shutil
  15. import threading
  16. from contextlib import asynccontextmanager
  17. from pathlib import Path
  18. import paho.mqtt.client as mqtt
  19. from fastapi import FastAPI, HTTPException, UploadFile, File
  20. from fastapi.responses import HTMLResponse, FileResponse, StreamingResponse
  21. from pydantic import BaseModel
  22. import uvicorn
  23. SPEAKERS_FILE = Path(__file__).parent / "speakers.json"
  24. RECORDINGS_DIR = Path(__file__).parent / "recordings"
  25. TEST_RECORDINGS_DIR = Path(__file__).parent / "test_recordings"
  26. RECORDINGS_DIR.mkdir(exist_ok=True)
  27. TEST_RECORDINGS_DIR.mkdir(exist_ok=True)
  28. ALLOWED_AUDIO_EXTS = {".wav", ".mp3", ".m4a", ".ogg", ".flac", ".webm", ".aiff"}
  29. MQTT_HOST = "localhost"
  30. MQTT_PORT = 1883
  31. MQTT_TOPIC_TEXT = "display/text"
  32. MQTT_TOPIC_CLEAR = "display/clear"
  33. # ── SSE broadcast state ───────────────────────────────────────────────────────
  34. _sse_clients: set[asyncio.Queue] = set()
  35. _sse_lock = threading.Lock()
  36. _event_loop: asyncio.AbstractEventLoop | None = None
  37. def _broadcast(data: str) -> None:
  38. if _event_loop is None:
  39. return
  40. with _sse_lock:
  41. for q in list(_sse_clients):
  42. try:
  43. _event_loop.call_soon_threadsafe(q.put_nowait, data)
  44. except Exception:
  45. pass
  46. # ── MQTT subscriber ───────────────────────────────────────────────────────────
  47. def _build_mqtt_subscriber() -> mqtt.Client:
  48. client = mqtt.Client(mqtt.CallbackAPIVersion.VERSION2)
  49. def on_connect(client, userdata, flags, rc, props):
  50. if rc == 0:
  51. client.subscribe([(MQTT_TOPIC_TEXT, 0), (MQTT_TOPIC_CLEAR, 0)])
  52. print("[Admin] MQTT subscribed to display/text and display/clear")
  53. else:
  54. print(f"[Admin] MQTT connect failed: {rc}")
  55. def on_message(client, userdata, msg):
  56. payload = msg.payload.decode("utf-8", errors="replace")
  57. if msg.topic == MQTT_TOPIC_CLEAR:
  58. _broadcast("event: clear\ndata: {}\n\n")
  59. else:
  60. _broadcast(f"event: text\ndata: {payload}\n\n")
  61. client.on_connect = on_connect
  62. client.on_message = on_message
  63. client.reconnect_delay_set(min_delay=1, max_delay=30)
  64. client.connect_async(MQTT_HOST, MQTT_PORT)
  65. client.loop_start()
  66. return client
  67. # ── App lifecycle ─────────────────────────────────────────────────────────────
  68. _mqtt_sub: mqtt.Client | None = None
  69. @asynccontextmanager
  70. async def lifespan(app: FastAPI):
  71. global _mqtt_sub, _event_loop
  72. _event_loop = asyncio.get_running_loop()
  73. _mqtt_sub = _build_mqtt_subscriber()
  74. yield
  75. if _mqtt_sub:
  76. _mqtt_sub.loop_stop()
  77. _mqtt_sub.disconnect()
  78. app = FastAPI(title="Speaker Admin", lifespan=lifespan)
  79. # ── Speaker data helpers ──────────────────────────────────────────────────────
  80. def _load() -> dict[str, str]:
  81. if SPEAKERS_FILE.exists():
  82. try:
  83. return json.loads(SPEAKERS_FILE.read_text(encoding="utf-8"))
  84. except Exception:
  85. pass
  86. return {}
  87. def _save(data: dict[str, str]) -> None:
  88. SPEAKERS_FILE.write_text(
  89. json.dumps(data, indent=2, ensure_ascii=False), encoding="utf-8"
  90. )
  91. def _recording_path(sid: str) -> Path | None:
  92. for ext in (".wav", ".mp3", ".m4a", ".ogg", ".webm", ".flac"):
  93. p = RECORDINGS_DIR / f"{sid}{ext}"
  94. if p.exists():
  95. return p
  96. return None
  97. # ── Speaker API ───────────────────────────────────────────────────────────────
  98. class NameBody(BaseModel):
  99. name: str | None = None
  100. role: str | None = None
  101. location: str | None = None
  102. class AddBody(BaseModel):
  103. id: str
  104. name: str = ""
  105. role: str = ""
  106. location: str = ""
  107. @app.get("/api/speakers")
  108. def api_list():
  109. from embeddings import EmbeddingRegistry
  110. registry = EmbeddingRegistry(
  111. embeddings_dir=Path(__file__).parent / "embeddings",
  112. speakers_file=SPEAKERS_FILE,
  113. )
  114. speakers = _load()
  115. result = []
  116. for k, v in sorted(speakers.items()):
  117. if isinstance(v, dict):
  118. entry = {
  119. "id": k, "name": v.get("name", ""), "role": v.get("role", ""),
  120. "location": v.get("location", ""),
  121. "has_recording": _recording_path(k) is not None,
  122. "has_embedding": registry.has(k),
  123. "embedding_updated": v.get("embedding_updated"),
  124. }
  125. else:
  126. entry = {
  127. "id": k, "name": str(v), "role": "", "location": "",
  128. "has_recording": _recording_path(k) is not None,
  129. "has_embedding": registry.has(k),
  130. "embedding_updated": None,
  131. }
  132. result.append(entry)
  133. return {"speakers": result}
  134. @app.post("/api/speakers")
  135. def api_add(body: AddBody):
  136. speakers = _load()
  137. sid = body.id.strip()
  138. if not sid:
  139. raise HTTPException(400, "Speaker ID cannot be empty")
  140. if sid in speakers:
  141. raise HTTPException(400, f"'{sid}' already exists")
  142. speakers[sid] = {"name": body.name.strip(), "role": body.role.strip(), "location": body.location.strip()}
  143. _save(speakers)
  144. return {"ok": True, "id": sid, "name": body.name}
  145. @app.put("/api/speakers/{sid}")
  146. def api_update(sid: str, body: NameBody):
  147. speakers = _load()
  148. entry = speakers.get(sid, {})
  149. if not isinstance(entry, dict):
  150. entry = {"name": str(entry), "role": "", "location": ""}
  151. if body.name is not None:
  152. entry["name"] = body.name.strip()
  153. if body.role is not None:
  154. entry["role"] = body.role.strip()
  155. if body.location is not None:
  156. entry["location"] = body.location.strip()
  157. speakers[sid] = entry
  158. _save(speakers)
  159. return {"ok": True}
  160. @app.delete("/api/speakers/{sid}")
  161. def api_delete(sid: str):
  162. speakers = _load()
  163. speakers.pop(sid, None)
  164. _save(speakers)
  165. rec = _recording_path(sid)
  166. if rec:
  167. rec.unlink()
  168. return {"ok": True}
  169. @app.post("/api/speakers/{sid}/recording")
  170. async def api_upload(sid: str, file: UploadFile = File(...)):
  171. suffix = Path(file.filename or "audio.wav").suffix.lower() or ".wav"
  172. rec = _recording_path(sid)
  173. if rec:
  174. rec.unlink()
  175. out = RECORDINGS_DIR / f"{sid}{suffix}"
  176. with out.open("wb") as f:
  177. shutil.copyfileobj(file.file, f)
  178. speakers = _load()
  179. if sid not in speakers:
  180. speakers[sid] = sid
  181. _save(speakers)
  182. size_kb = round(out.stat().st_size / 1024)
  183. return {"ok": True, "file": out.name, "kb": size_kb}
  184. @app.get("/api/speakers/{sid}/recording")
  185. def api_playback(sid: str):
  186. rec = _recording_path(sid)
  187. if not rec:
  188. raise HTTPException(404, "No recording found")
  189. return FileResponse(rec)
  190. # ── Voiceprint / embedding API ────────────────────────────────────────────────
  191. class EnrolBody(BaseModel):
  192. audio_file: str # filename in test_recordings/
  193. start: float = 0.0
  194. end: float | None = None
  195. class AutoEnrolBody(BaseModel):
  196. audio_file: str
  197. session: str | None = None
  198. min_duration: float = 8.0
  199. def _get_registry():
  200. from embeddings import EmbeddingRegistry
  201. return EmbeddingRegistry(
  202. embeddings_dir=Path(__file__).parent / "embeddings",
  203. speakers_file=SPEAKERS_FILE,
  204. )
  205. @app.get("/api/speakers/{sid}/voiceprint")
  206. def api_voiceprint_status(sid: str):
  207. registry = _get_registry()
  208. speakers = _load()
  209. entry = speakers.get(sid, {})
  210. if not isinstance(entry, dict):
  211. entry = {}
  212. return {
  213. "has_embedding": registry.has(sid),
  214. "embedding_updated": entry.get("embedding_updated"),
  215. }
  216. @app.post("/api/speakers/{sid}/voiceprint/enrol")
  217. async def api_voiceprint_enrol(sid: str, body: EnrolBody):
  218. p = TEST_RECORDINGS_DIR / Path(body.audio_file).name
  219. if not p.exists():
  220. raise HTTPException(404, f"Recording not found: {body.audio_file}")
  221. try:
  222. registry = _get_registry()
  223. emb = await asyncio.get_running_loop().run_in_executor(
  224. None,
  225. lambda: registry.extract_and_save(sid, p, body.start, body.end),
  226. )
  227. return {"ok": True, "dim": int(emb.shape[0])}
  228. except Exception as exc:
  229. raise HTTPException(500, str(exc))
  230. @app.post("/api/speakers/{sid}/voiceprint/auto-enrol")
  231. async def api_voiceprint_auto_enrol(sid: str, body: AutoEnrolBody):
  232. p = TEST_RECORDINGS_DIR / Path(body.audio_file).name
  233. if not p.exists():
  234. raise HTTPException(404, f"Recording not found: {body.audio_file}")
  235. try:
  236. registry = _get_registry()
  237. emb = await asyncio.get_running_loop().run_in_executor(
  238. None,
  239. lambda: registry.enrol_from_transcript(
  240. sid, p,
  241. session_id=body.session,
  242. min_duration=body.min_duration,
  243. ),
  244. )
  245. return {"ok": True, "dim": int(emb.shape[0])}
  246. except ValueError as exc:
  247. raise HTTPException(422, str(exc))
  248. except Exception as exc:
  249. raise HTTPException(500, str(exc))
  250. @app.delete("/api/speakers/{sid}/voiceprint")
  251. def api_voiceprint_delete(sid: str):
  252. registry = _get_registry()
  253. deleted = registry.delete(sid)
  254. return {"ok": True, "deleted": deleted}
  255. @app.get("/api/speakers/{sid}/voiceprint/candidates")
  256. def api_voiceprint_candidates(sid: str, session: str | None = None, min_dur: float = 8.0):
  257. from embeddings import get_best_enrolment_segments
  258. candidates = get_best_enrolment_segments(sid, session_id=session, min_duration=min_dur)
  259. return {"candidates": candidates}
  260. @app.get("/api/voiceprints")
  261. def api_voiceprints_list():
  262. registry = _get_registry()
  263. return {"enrolled": registry.list_enrolled()}
  264. # ── Test playback state ───────────────────────────────────────────────────────
  265. _playback_task: asyncio.Task | None = None
  266. _playback_status: dict = {
  267. "state": "idle", # idle | loading | playing | done | error
  268. "file": None,
  269. "progress": 0, # 0–100
  270. "elapsed": 0.0, # seconds streamed so far
  271. "duration": 0.0, # total file duration in seconds
  272. "error": None,
  273. }
  274. BRIDGE_INJECT_URL = "http://127.0.0.1:8002/inject"
  275. async def _stream_file(filepath: Path, speed: float) -> None:
  276. global _playback_status
  277. try:
  278. import miniaudio
  279. import httpx
  280. except ImportError as e:
  281. _playback_status.update({"state": "error", "error": f"Missing package: {e}"})
  282. return
  283. try:
  284. _playback_status["state"] = "loading"
  285. info = miniaudio.get_file_info(str(filepath))
  286. duration = info.duration
  287. _playback_status.update({
  288. "state": "playing", "duration": round(duration, 1),
  289. "elapsed": 0.0, "progress": 0,
  290. })
  291. chunk_frames = 4096
  292. chunk_secs = chunk_frames / 16000
  293. elapsed = 0.0
  294. stream = miniaudio.stream_file(
  295. str(filepath),
  296. output_format=miniaudio.SampleFormat.SIGNED16,
  297. nchannels=1,
  298. sample_rate=16000,
  299. frames_to_read=chunk_frames,
  300. )
  301. async with httpx.AsyncClient() as client:
  302. for chunk in stream:
  303. await client.post(BRIDGE_INJECT_URL, content=bytes(chunk))
  304. elapsed += chunk_secs
  305. _playback_status["elapsed"] = round(elapsed, 1)
  306. _playback_status["progress"] = (
  307. min(99, round(elapsed / duration * 100)) if duration else 0
  308. )
  309. await asyncio.sleep(chunk_secs / speed)
  310. _playback_status.update({
  311. "state": "done", "progress": 100, "elapsed": round(duration, 1),
  312. })
  313. except asyncio.CancelledError:
  314. _playback_status.update({
  315. "state": "idle", "file": None, "progress": 0, "elapsed": 0.0,
  316. })
  317. except Exception as exc:
  318. _playback_status.update({"state": "error", "error": str(exc), "progress": 0})
  319. print(f"[Playback] {exc}")
  320. # ── Test recording API ────────────────────────────────────────────────────────
  321. @app.post("/api/test/upload")
  322. async def api_test_upload(file: UploadFile = File(...)):
  323. suffix = Path(file.filename or "recording.wav").suffix.lower()
  324. if suffix not in ALLOWED_AUDIO_EXTS:
  325. raise HTTPException(400, f"Unsupported format '{suffix}'")
  326. stem = Path(file.filename).stem[:80].replace(" ", "_")
  327. out = TEST_RECORDINGS_DIR / f"{stem}{suffix}"
  328. try:
  329. with out.open("wb") as f:
  330. shutil.copyfileobj(file.file, f)
  331. except OSError as e:
  332. raise HTTPException(500, f"Could not save file: {e}")
  333. return {"ok": True, "filename": out.name, "mb": round(out.stat().st_size / 1024 / 1024, 1)}
  334. @app.get("/api/test/files")
  335. def api_test_list():
  336. files = []
  337. for p in sorted(TEST_RECORDINGS_DIR.iterdir()):
  338. if p.suffix.lower() in ALLOWED_AUDIO_EXTS:
  339. files.append({
  340. "filename": p.name,
  341. "mb": round(p.stat().st_size / 1024 / 1024, 1),
  342. })
  343. return {"files": files}
  344. @app.delete("/api/test/files/{filename:path}")
  345. def api_test_delete(filename: str):
  346. p = TEST_RECORDINGS_DIR / Path(filename).name
  347. try:
  348. if p.exists():
  349. p.unlink()
  350. except OSError as e:
  351. raise HTTPException(500, f"Could not delete: {e}")
  352. return {"ok": True}
  353. class PlaybackBody(BaseModel):
  354. filename: str
  355. speed: float = 1.0
  356. @app.post("/api/test/start")
  357. async def api_test_start(body: PlaybackBody):
  358. global _playback_task
  359. if _playback_task and not _playback_task.done():
  360. raise HTTPException(409, "Playback already running — stop it first")
  361. p = TEST_RECORDINGS_DIR / Path(body.filename).name
  362. if not p.exists():
  363. raise HTTPException(404, "File not found")
  364. speed = max(0.25, min(8.0, body.speed))
  365. _playback_status.update({"state": "starting", "file": p.name, "progress": 0, "error": None})
  366. _playback_task = asyncio.create_task(_stream_file(p, speed))
  367. return {"ok": True}
  368. @app.post("/api/test/stop")
  369. async def api_test_stop():
  370. global _playback_task
  371. if _playback_task and not _playback_task.done():
  372. _playback_task.cancel()
  373. try:
  374. await _playback_task
  375. except asyncio.CancelledError:
  376. pass
  377. _playback_status.update({"state": "idle", "file": None, "progress": 0, "elapsed": 0.0})
  378. return {"ok": True}
  379. @app.get("/api/test/status")
  380. def api_test_status():
  381. return _playback_status
  382. # ── SSE display stream ────────────────────────────────────────────────────────
  383. @app.get("/api/display/stream")
  384. async def display_stream():
  385. q: asyncio.Queue[str] = asyncio.Queue(maxsize=50)
  386. with _sse_lock:
  387. _sse_clients.add(q)
  388. async def generator():
  389. try:
  390. yield ": heartbeat\n\n"
  391. while True:
  392. try:
  393. data = await asyncio.wait_for(q.get(), timeout=25)
  394. yield data
  395. except asyncio.TimeoutError:
  396. yield ": heartbeat\n\n"
  397. except (asyncio.CancelledError, GeneratorExit):
  398. pass
  399. finally:
  400. with _sse_lock:
  401. _sse_clients.discard(q)
  402. return StreamingResponse(
  403. generator(),
  404. media_type="text/event-stream",
  405. headers={
  406. "Cache-Control": "no-cache",
  407. "Connection": "keep-alive",
  408. "X-Accel-Buffering": "no",
  409. },
  410. )
  411. # ── Web UI (admin) ────────────────────────────────────────────────────────────
  412. HTML = """<!DOCTYPE html>
  413. <html lang="en">
  414. <head>
  415. <meta charset="UTF-8">
  416. <meta name="viewport" content="width=device-width, initial-scale=1">
  417. <title>Meeting Transcription for the Deaf</title>
  418. <style>
  419. *, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
  420. body { font-family: system-ui, sans-serif; background: #f0f4f8; color: #1e293b; }
  421. header {
  422. background: #1e3a5f; color: white; padding: 16px 24px;
  423. display: flex; align-items: center; gap: 16px;
  424. }
  425. header h1 { font-size: 1.2rem; font-weight: 600; flex: 1; }
  426. header small { opacity: .7; font-size: .8rem; }
  427. .toolbar {
  428. background: white; padding: 12px 24px;
  429. display: flex; gap: 12px; align-items: center;
  430. border-bottom: 1px solid #e2e8f0;
  431. }
  432. .toolbar input[type=search] {
  433. flex: 1; max-width: 340px; padding: 8px 12px;
  434. border: 1px solid #cbd5e1; border-radius: 6px; font-size: .95rem;
  435. }
  436. .count { color: #64748b; font-size: .9rem; margin-left: auto; }
  437. .btn {
  438. display: inline-flex; align-items: center; gap: 6px;
  439. padding: 8px 16px; border-radius: 6px; border: none;
  440. cursor: pointer; font-size: .9rem; font-weight: 500; transition: filter .15s;
  441. text-decoration: none;
  442. }
  443. .btn:hover:not(:disabled) { filter: brightness(.92); }
  444. .btn:disabled { opacity: .45; cursor: not-allowed; }
  445. .btn-primary { background: #2563eb; color: white; }
  446. .btn-danger { background: #dc2626; color: white; }
  447. .btn-ghost { background: #e2e8f0; color: #334155; }
  448. .btn-display { background: #059669; color: white; }
  449. .btn-sm { padding: 4px 10px; font-size: .82rem; }
  450. table { width: 100%; border-collapse: collapse; background: white; }
  451. th {
  452. text-align: left; padding: 10px 16px; font-size: .8rem;
  453. font-weight: 600; text-transform: uppercase; letter-spacing: .05em;
  454. color: #64748b; background: #f8fafc; border-bottom: 1px solid #e2e8f0;
  455. }
  456. td { padding: 10px 16px; border-bottom: 1px solid #f1f5f9; vertical-align: middle; }
  457. tr:hover td { background: #f8fafc; }
  458. tr.hidden { display: none; }
  459. .sid { font-family: monospace; font-size: .85rem; color: #475569; }
  460. .name-cell { display: flex; align-items: center; gap: 8px; }
  461. .name-display { cursor: pointer; flex: 1; }
  462. .name-display:hover { text-decoration: underline; }
  463. .name-input {
  464. flex: 1; padding: 4px 8px; border: 1px solid #2563eb;
  465. border-radius: 4px; font-size: .95rem; outline: none;
  466. }
  467. .rec-badge {
  468. display: inline-flex; align-items: center; gap: 4px;
  469. font-size: .75rem; padding: 2px 8px; border-radius: 999px;
  470. font-weight: 500;
  471. }
  472. .rec-yes { background: #dcfce7; color: #166534; }
  473. .rec-no { background: #f1f5f9; color: #94a3b8; }
  474. .actions { display: flex; gap: 6px; }
  475. /* Modal */
  476. .modal-bg {
  477. display: none; position: fixed; inset: 0;
  478. background: rgba(0,0,0,.45); z-index: 100;
  479. align-items: center; justify-content: center;
  480. }
  481. .modal-bg.open { display: flex; }
  482. .modal {
  483. background: white; border-radius: 10px; padding: 28px;
  484. width: 420px; max-width: 95vw; box-shadow: 0 20px 60px rgba(0,0,0,.25);
  485. }
  486. .modal h2 { font-size: 1.1rem; margin-bottom: 16px; }
  487. .field { margin-bottom: 14px; }
  488. .field label { display: block; font-size: .85rem; font-weight: 500; margin-bottom: 4px; }
  489. .field input {
  490. width: 100%; padding: 8px 10px; border: 1px solid #cbd5e1;
  491. border-radius: 6px; font-size: .95rem;
  492. }
  493. .field input:focus { outline: 2px solid #2563eb; border-color: transparent; }
  494. .modal-actions { display: flex; gap: 8px; justify-content: flex-end; margin-top: 20px; }
  495. /* Upload drop zone */
  496. .upload-area {
  497. border: 2px dashed #cbd5e1; border-radius: 8px; padding: 16px;
  498. text-align: center; cursor: pointer; transition: border-color .2s;
  499. color: #64748b; font-size: .9rem;
  500. }
  501. .upload-area.drag { border-color: #2563eb; background: #eff6ff; }
  502. .upload-area input[type=file] { display: none; }
  503. /* Audio player */
  504. .audio-player { width: 180px; height: 32px; }
  505. /* Toast */
  506. #toast {
  507. position: fixed; bottom: 24px; right: 24px;
  508. background: #1e293b; color: white; padding: 10px 18px;
  509. border-radius: 8px; font-size: .9rem; transform: translateY(80px);
  510. transition: transform .25s; z-index: 200; pointer-events: none;
  511. }
  512. #toast.show { transform: translateY(0); }
  513. #toast.error { background: #dc2626; }
  514. .container { max-width: 1100px; margin: 0 auto; padding: 24px; }
  515. audio { vertical-align: middle; }
  516. /* ── Test Playback card ─────────────────────────────── */
  517. .pb-card {
  518. background: white; border-radius: 8px; margin-top: 20px;
  519. border: 1px solid #e2e8f0; overflow: hidden;
  520. }
  521. .pb-card-header {
  522. display: flex; justify-content: space-between; align-items: center;
  523. padding: 14px 20px; cursor: pointer; font-weight: 600; font-size: 1rem;
  524. background: #f8fafc; border-bottom: 1px solid #e2e8f0; user-select: none;
  525. }
  526. .pb-card-header:hover { background: #f1f5f9; }
  527. #pb-body { padding: 20px; }
  528. .pb-hint { color: #64748b; font-size: .88rem; margin-bottom: 16px; line-height: 1.5; }
  529. .pb-layout { display: flex; gap: 20px; flex-wrap: wrap; margin-bottom: 16px; }
  530. .pb-upload { flex: 1; min-width: 260px; }
  531. .pb-controls { min-width: 210px; display: flex; flex-direction: column; gap: 12px; }
  532. .pb-select {
  533. width: 100%; padding: 7px 10px; border: 1px solid #cbd5e1;
  534. border-radius: 6px; font-size: .9rem; background: white;
  535. }
  536. .pb-label { font-size: .85rem; font-weight: 500; display: block; margin-bottom: 4px; }
  537. .pb-status-bar { margin-top: 12px; }
  538. .pb-status-row {
  539. display: flex; justify-content: space-between;
  540. font-size: .85rem; margin-bottom: 5px; min-height: 18px;
  541. }
  542. .pb-progress-track {
  543. height: 8px; background: #e2e8f0; border-radius: 999px; overflow: hidden;
  544. }
  545. .pb-progress-fill {
  546. height: 100%; background: #2563eb; border-radius: 999px;
  547. width: 0%; transition: width .8s linear;
  548. }
  549. .pb-note { font-size: .78rem; color: #94a3b8; margin-top: 10px; }
  550. .pb-error { color: #dc2626; }
  551. /* Voiceprint modal */
  552. .vp-tabs { display: flex; gap: 0; border-bottom: 2px solid #e2e8f0; margin-bottom: 16px; }
  553. .vp-tab {
  554. padding: 8px 16px; cursor: pointer; font-size: .9rem; font-weight: 500;
  555. color: #64748b; border-bottom: 2px solid transparent; margin-bottom: -2px;
  556. }
  557. .vp-tab.active { color: #2563eb; border-bottom-color: #2563eb; }
  558. .vp-panel { display: none; }
  559. .vp-panel.active { display: block; }
  560. .vp-row { display: flex; gap: 8px; align-items: flex-end; margin-bottom: 12px; }
  561. .vp-row .field { flex: 1; margin: 0; }
  562. .vp-status { font-size: .85rem; min-height: 20px; margin-top: 8px; }
  563. </style>
  564. </head>
  565. <body>
  566. <header>
  567. <div style="flex:1">
  568. <h1>&#127908; Speaker Admin</h1>
  569. <small>Meeting Transcription for the Deaf — Speaker Name &amp; Voice Library</small>
  570. </div>
  571. <a href="/display" target="_blank" class="btn btn-display">&#128065; View Display</a>
  572. </header>
  573. <div class="toolbar">
  574. <input type="search" id="search" placeholder="Search by ID or name…" oninput="filterTable()">
  575. <button class="btn btn-primary" onclick="openAddModal()">&#43; Add Speaker</button>
  576. <span class="count" id="count"></span>
  577. </div>
  578. <div class="container">
  579. <!-- Speaker table -->
  580. <table id="table">
  581. <thead>
  582. <tr>
  583. <th>Speaker ID</th>
  584. <th>Initials</th>
  585. <th>Name</th>
  586. <th>Locality</th>
  587. <th>Voiceprint</th>
  588. <th>Voice Sample</th>
  589. <th>Actions</th>
  590. </tr>
  591. </thead>
  592. <tbody id="tbody"></tbody>
  593. </table>
  594. <!-- Test Playback card -->
  595. <div class="pb-card">
  596. <div class="pb-card-header" onclick="togglePbCard()">
  597. <span>&#127911; Test Recording Playback</span>
  598. <span id="pb-chevron">&#9660;</span>
  599. </div>
  600. <div id="pb-body">
  601. <p class="pb-hint">
  602. Upload a full church service recording (WAV, MP3, FLAC, OGG, M4A) to test the
  603. transcription pipeline offline. The file streams to WhisperLiveKit exactly as a
  604. live microphone would — results appear on the
  605. <a href="/display" target="_blank">display page</a> in real time.
  606. </p>
  607. <div class="pb-layout">
  608. <!-- Upload drop zone -->
  609. <div class="pb-upload">
  610. <div class="upload-area" id="test-drop"
  611. ondragover="pbDragOver(event)" ondragleave="pbDragLeave()"
  612. ondrop="pbDrop(event)"
  613. onclick="document.getElementById('test-file-input').click()">
  614. <input type="file" id="test-file-input" accept="audio/*"
  615. onchange="pbUpload(this.files[0])">
  616. <div>&#8679; Drop a recording here, or click to browse</div>
  617. <div style="font-size:.78rem;margin-top:4px;color:#94a3b8">
  618. WAV &middot; MP3 &middot; FLAC &middot; OGG &middot; M4A
  619. </div>
  620. </div>
  621. <div id="test-upload-status" style="min-height:20px;font-size:.85rem;margin-top:8px"></div>
  622. </div>
  623. <!-- Playback controls -->
  624. <div class="pb-controls">
  625. <div>
  626. <span class="pb-label">Playback Speed</span>
  627. <select id="pb-speed" class="pb-select">
  628. <option value="0.5">0.5&times; (half speed)</option>
  629. <option value="1">1&times; real-time</option>
  630. <option value="2">2&times; faster</option>
  631. <option value="4" selected>4&times; quick review</option>
  632. <option value="8">8&times; very fast</option>
  633. </select>
  634. </div>
  635. <button id="pb-stop-btn" class="btn btn-danger" onclick="pbStop()" disabled>
  636. &#9632; Stop Playback
  637. </button>
  638. </div>
  639. </div>
  640. <!-- Uploaded test files -->
  641. <table id="test-table">
  642. <thead>
  643. <tr>
  644. <th>Recording File</th>
  645. <th style="width:80px">Size</th>
  646. <th style="width:140px">Actions</th>
  647. </tr>
  648. </thead>
  649. <tbody id="test-tbody">
  650. <tr id="test-empty-row">
  651. <td colspan="3" style="color:#94a3b8;padding:16px 16px">
  652. No recordings uploaded yet
  653. </td>
  654. </tr>
  655. </tbody>
  656. </table>
  657. <!-- Status bar -->
  658. <div class="pb-status-bar">
  659. <div class="pb-status-row">
  660. <span id="pb-status-text" style="color:#64748b">Idle</span>
  661. <span id="pb-time-text" style="color:#64748b"></span>
  662. </div>
  663. <div class="pb-progress-track">
  664. <div class="pb-progress-fill" id="pb-fill"></div>
  665. </div>
  666. </div>
  667. <p class="pb-note">
  668. While a test recording plays, the live microphone in bridge.py continues to run.
  669. Keep the room quiet during testing to avoid mixing live audio with the recording.
  670. </p>
  671. </div>
  672. </div>
  673. </div><!-- /container -->
  674. <!-- Add speaker modal -->
  675. <div class="modal-bg" id="add-modal" onclick="closeAddModal(event)">
  676. <div class="modal">
  677. <h2>Add Speaker</h2>
  678. <div class="field">
  679. <label>Speaker ID
  680. <span style="color:#64748b;font-weight:normal;font-size:.8rem">
  681. (e.g. SPK_00, or any unique key)
  682. </span>
  683. </label>
  684. <input id="new-id" placeholder="SPK_00">
  685. </div>
  686. <div class="field">
  687. <label>Initials</label>
  688. <input id="new-initials" placeholder="J.B.B">
  689. </div>
  690. <div class="field">
  691. <label>Name / Role</label>
  692. <input id="new-name" placeholder="John Brown">
  693. </div>
  694. <div class="field">
  695. <label>Locality</label>
  696. <input id="new-location" placeholder="Sydney">
  697. </div>
  698. <div class="modal-actions">
  699. <button class="btn btn-ghost" onclick="closeAddModal()">Cancel</button>
  700. <button class="btn btn-primary" onclick="addSpeaker()">Add</button>
  701. </div>
  702. </div>
  703. </div>
  704. <!-- Voiceprint modal -->
  705. <div class="modal-bg" id="vp-modal" onclick="closeVoiceprintModal(event)">
  706. <div class="modal" style="width:480px">
  707. <h2 id="vp-title">Voiceprint</h2>
  708. <div class="vp-tabs">
  709. <div class="vp-tab active" onclick="vpTab('auto')">Auto-Enrol</div>
  710. <div class="vp-tab" onclick="vpTab('manual')">Manual Segment</div>
  711. </div>
  712. <!-- Auto-enrol tab -->
  713. <div class="vp-panel active" id="vp-panel-auto">
  714. <p style="color:#64748b;font-size:.85rem;margin-bottom:12px">
  715. Picks the cleanest isolated segment from the transcript log automatically.
  716. Run a test recording first to populate the log.
  717. </p>
  718. <div class="field">
  719. <label>Source Recording</label>
  720. <select id="vp-auto-file" class="pb-select"></select>
  721. </div>
  722. <div class="field">
  723. <label>Min segment length (seconds)</label>
  724. <input id="vp-auto-mindur" type="number" value="8" min="3" max="60" step="1">
  725. </div>
  726. <div class="vp-status" id="vp-auto-status"></div>
  727. <div class="modal-actions">
  728. <button class="btn btn-ghost" onclick="closeVoiceprintModal()">Cancel</button>
  729. <button class="btn btn-primary" onclick="vpAutoEnrol()">Auto-Enrol</button>
  730. </div>
  731. </div>
  732. <!-- Manual segment tab -->
  733. <div class="vp-panel" id="vp-panel-manual">
  734. <p style="color:#64748b;font-size:.85rem;margin-bottom:12px">
  735. Extract a voiceprint from a specific time range in a recording.
  736. Choose a section with clear isolated speech (10–30 seconds).
  737. </p>
  738. <div class="field">
  739. <label>Source Recording</label>
  740. <select id="vp-manual-file" class="pb-select"></select>
  741. </div>
  742. <div class="vp-row">
  743. <div class="field">
  744. <label>Start (seconds)</label>
  745. <input id="vp-manual-start" type="number" value="0" min="0" step="1">
  746. </div>
  747. <div class="field">
  748. <label>End (seconds)</label>
  749. <input id="vp-manual-end" type="number" value="30" min="1" step="1">
  750. </div>
  751. </div>
  752. <div class="vp-status" id="vp-manual-status"></div>
  753. <div class="modal-actions">
  754. <button class="btn btn-ghost" onclick="closeVoiceprintModal()">Cancel</button>
  755. <button class="btn btn-danger btn-sm" onclick="vpDelete()" style="margin-right:auto">Delete Voiceprint</button>
  756. <button class="btn btn-primary" onclick="vpManualEnrol()">Extract &amp; Save</button>
  757. </div>
  758. </div>
  759. </div>
  760. </div>
  761. <!-- Voice sample upload modal -->
  762. <div class="modal-bg" id="upload-modal" onclick="closeUploadModal(event)">
  763. <div class="modal">
  764. <h2 id="upload-title">Upload Voice Sample</h2>
  765. <p style="color:#64748b;font-size:.85rem;margin-bottom:12px">
  766. Upload a 10–60 second clear speech recording.<br>
  767. Supported: WAV, MP3, M4A, OGG, FLAC, WebM
  768. </p>
  769. <div class="upload-area" id="drop-zone"
  770. ondragover="onDragOver(event)" ondragleave="onDragLeave(event)"
  771. ondrop="onDrop(event)" onclick="document.getElementById('file-input').click()">
  772. <input type="file" id="file-input" accept="audio/*" onchange="uploadFile(this.files[0])">
  773. <div>&#127926; Drag &amp; drop audio here, or click to browse</div>
  774. </div>
  775. <div id="upload-status" style="margin-top:10px;font-size:.85rem;color:#166534"></div>
  776. <div class="modal-actions">
  777. <button class="btn btn-ghost" onclick="closeUploadModal()">Close</button>
  778. </div>
  779. </div>
  780. </div>
  781. <div id="toast"></div>
  782. <script>
  783. let speakers = [];
  784. let uploadTarget = null;
  785. // ── Speaker table ─────────────────────────────────────────────────────────────
  786. async function load() {
  787. const res = await fetch('/api/speakers');
  788. const data = await res.json();
  789. speakers = data.speakers;
  790. render();
  791. }
  792. function render() {
  793. const tbody = document.getElementById('tbody');
  794. tbody.innerHTML = '';
  795. speakers.forEach(s => tbody.appendChild(makeRow(s)));
  796. document.getElementById('count').textContent =
  797. `${speakers.length} speaker${speakers.length !== 1 ? 's' : ''}`;
  798. filterTable();
  799. }
  800. function mkEdit(id, field, value) {
  801. return `<div class="name-cell">
  802. <span class="name-display" onclick="startEdit(this,'${id}','${field}')"
  803. title="Click to edit">${value}</span>
  804. <input class="name-input" style="display:none"
  805. onblur="saveEdit(this,'${id}','${field}')"
  806. onkeydown="editKeydown(event,this,'${id}','${field}')">
  807. </div>`;
  808. }
  809. function makeRow(s) {
  810. const tr = document.createElement('tr');
  811. tr.dataset.id = s.id;
  812. tr.dataset.name = (s.name + ' ' + s.role + ' ' + s.location).toLowerCase();
  813. const recHtml = s.has_recording
  814. ? `<span class="rec-badge rec-yes">&#9654; Recorded</span>
  815. <audio class="audio-player" controls preload="none"
  816. src="/api/speakers/${encodeURIComponent(s.id)}/recording"></audio>`
  817. : `<span class="rec-badge rec-no">No sample</span>`;
  818. const vpHtml = s.has_embedding
  819. ? `<span class="rec-badge rec-yes" title="${esc(s.embedding_updated||'')}">&#128304; Enrolled</span>
  820. <button class="btn btn-ghost btn-sm" style="margin-left:4px"
  821. onclick="openVoiceprintModal('${esc(s.id)}','${esc(s.name)}')">&#9998;</button>`
  822. : `<button class="btn btn-ghost btn-sm"
  823. onclick="openVoiceprintModal('${esc(s.id)}','${esc(s.name)}')">Enrol</button>`;
  824. tr.innerHTML = `
  825. <td class="sid">${esc(s.id)}</td>
  826. <td>${mkEdit(esc(s.id), 'name', esc(s.name))}</td>
  827. <td>${mkEdit(esc(s.id), 'role', esc(s.role))}</td>
  828. <td>${mkEdit(esc(s.id), 'location', esc(s.location))}</td>
  829. <td>${vpHtml}</td>
  830. <td>${recHtml}</td>
  831. <td>
  832. <div class="actions">
  833. <button class="btn btn-ghost btn-sm"
  834. onclick="openUploadModal('${esc(s.id)}', '${esc(s.name)}')">
  835. &#127926; ${s.has_recording ? 'Replace' : 'Upload'}
  836. </button>
  837. <button class="btn btn-danger btn-sm"
  838. onclick="deleteSpeaker('${esc(s.id)}')">&#128465;</button>
  839. </div>
  840. </td>`;
  841. return tr;
  842. }
  843. function esc(str) {
  844. return String(str)
  845. .replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;')
  846. .replace(/"/g,'&quot;').replace(/'/g,'&#39;');
  847. }
  848. function filterTable() {
  849. const q = document.getElementById('search').value.toLowerCase().trim();
  850. let visible = 0;
  851. document.querySelectorAll('#tbody tr').forEach(tr => {
  852. const match = !q || tr.dataset.id.includes(q) || tr.dataset.name.includes(q);
  853. tr.classList.toggle('hidden', !match);
  854. if (match) visible++;
  855. });
  856. document.getElementById('count').textContent =
  857. q ? `${visible} of ${speakers.length} speakers` : `${speakers.length} speakers`;
  858. }
  859. function startEdit(span, id, field) {
  860. const input = span.nextElementSibling;
  861. input.value = span.textContent;
  862. span.style.display = 'none';
  863. input.style.display = '';
  864. input.focus();
  865. input.select();
  866. }
  867. function editKeydown(e, input, id, field) {
  868. if (e.key === 'Enter') { input.blur(); }
  869. if (e.key === 'Escape') { cancelEdit(input); }
  870. }
  871. function cancelEdit(input) {
  872. const span = input.previousElementSibling;
  873. input.style.display = 'none';
  874. span.style.display = '';
  875. }
  876. async function saveEdit(input, id, field) {
  877. const value = input.value.trim();
  878. const span = input.previousElementSibling;
  879. if (value === span.textContent) { cancelEdit(input); return; }
  880. const body = {};
  881. body[field] = value;
  882. const res = await fetch(`/api/speakers/${encodeURIComponent(id)}`, {
  883. method: 'PUT',
  884. headers: {'Content-Type': 'application/json'},
  885. body: JSON.stringify(body)
  886. });
  887. if (res.ok) {
  888. span.textContent = value;
  889. toast('Saved');
  890. } else {
  891. toast('Save failed', true);
  892. }
  893. cancelEdit(input);
  894. }
  895. function openAddModal() {
  896. const nums = speakers
  897. .filter(s => /^SPEAKER_\\d+$/.test(s.id))
  898. .map(s => parseInt(s.id.split('_')[1]));
  899. const next = nums.length ? Math.max(...nums) + 1 : 0;
  900. document.getElementById('new-id').value = `SPEAKER_${String(next).padStart(2,'0')}`;
  901. document.getElementById('new-initials').value = '';
  902. document.getElementById('new-name').value = '';
  903. document.getElementById('new-location').value = '';
  904. document.getElementById('add-modal').classList.add('open');
  905. setTimeout(() => document.getElementById('new-initials').focus(), 50);
  906. }
  907. function closeAddModal(e) {
  908. if (!e || e.target === document.getElementById('add-modal'))
  909. document.getElementById('add-modal').classList.remove('open');
  910. }
  911. async function addSpeaker() {
  912. const id = document.getElementById('new-id').value.trim();
  913. const name = document.getElementById('new-initials').value.trim();
  914. const role = document.getElementById('new-name').value.trim();
  915. const location = document.getElementById('new-location').value.trim();
  916. if (!id) { toast('Speaker ID is required', true); return; }
  917. const res = await fetch('/api/speakers', {
  918. method: 'POST',
  919. headers: {'Content-Type': 'application/json'},
  920. body: JSON.stringify({id, name, role, location})
  921. });
  922. if (res.ok) {
  923. closeAddModal();
  924. toast(`Added ${name || id}`);
  925. await load();
  926. } else {
  927. const err = await res.json().catch(() => ({detail:'Error'}));
  928. toast(err.detail || 'Failed', true);
  929. }
  930. }
  931. async function deleteSpeaker(id) {
  932. const s = speakers.find(x => x.id === id);
  933. if (!confirm(`Remove "${s?.name || id}" from the speaker list?`)) return;
  934. const res = await fetch(`/api/speakers/${encodeURIComponent(id)}`, {method:'DELETE'});
  935. if (res.ok) { toast('Removed'); await load(); }
  936. else { toast('Delete failed', true); }
  937. }
  938. // ── Voiceprint modal ──────────────────────────────────────────────────────────
  939. let vpTarget = null;
  940. function vpTab(name) {
  941. document.querySelectorAll('.vp-tab').forEach((t, i) => {
  942. const panels = ['auto', 'manual'];
  943. t.classList.toggle('active', panels[i] === name);
  944. });
  945. document.getElementById('vp-panel-auto').classList.toggle('active', name === 'auto');
  946. document.getElementById('vp-panel-manual').classList.toggle('active', name === 'manual');
  947. }
  948. function vpPopulateFiles() {
  949. const files = pbFiles.map(f => f.filename);
  950. ['vp-auto-file', 'vp-manual-file'].forEach(id => {
  951. const sel = document.getElementById(id);
  952. sel.innerHTML = files.length
  953. ? files.map(f => `<option value="${esc(f)}">${esc(f)}</option>`).join('')
  954. : '<option value="">— no recordings uploaded —</option>';
  955. });
  956. }
  957. function openVoiceprintModal(id, name) {
  958. vpTarget = id;
  959. document.getElementById('vp-title').textContent = `Voiceprint — ${name}`;
  960. document.getElementById('vp-auto-status').textContent = '';
  961. document.getElementById('vp-manual-status').textContent = '';
  962. vpPopulateFiles();
  963. vpTab('auto');
  964. document.getElementById('vp-modal').classList.add('open');
  965. }
  966. function closeVoiceprintModal(e) {
  967. if (!e || e.target === document.getElementById('vp-modal')) {
  968. document.getElementById('vp-modal').classList.remove('open');
  969. vpTarget = null;
  970. load();
  971. }
  972. }
  973. async function vpAutoEnrol() {
  974. if (!vpTarget) return;
  975. const file = document.getElementById('vp-auto-file').value;
  976. const minDur = parseFloat(document.getElementById('vp-auto-mindur').value) || 8;
  977. const status = document.getElementById('vp-auto-status');
  978. if (!file) { toast('No recording selected', true); return; }
  979. status.style.color = '#2563eb';
  980. status.textContent = 'Extracting voiceprint… (this may take 10–30 seconds)';
  981. const res = await fetch(`/api/speakers/${encodeURIComponent(vpTarget)}/voiceprint/auto-enrol`, {
  982. method: 'POST',
  983. headers: {'Content-Type': 'application/json'},
  984. body: JSON.stringify({audio_file: file, min_duration: minDur}),
  985. });
  986. if (res.ok) {
  987. status.style.color = '#166534';
  988. status.textContent = '✓ Voiceprint saved';
  989. toast('Voiceprint enrolled');
  990. } else {
  991. const err = await res.json().catch(() => ({detail: 'Failed'}));
  992. status.style.color = '#dc2626';
  993. status.textContent = `✗ ${err.detail || 'Failed'}`;
  994. toast(err.detail || 'Auto-enrol failed', true);
  995. }
  996. }
  997. async function vpManualEnrol() {
  998. if (!vpTarget) return;
  999. const file = document.getElementById('vp-manual-file').value;
  1000. const start = parseFloat(document.getElementById('vp-manual-start').value) || 0;
  1001. const end = parseFloat(document.getElementById('vp-manual-end').value) || null;
  1002. const status = document.getElementById('vp-manual-status');
  1003. if (!file) { toast('No recording selected', true); return; }
  1004. status.style.color = '#2563eb';
  1005. status.textContent = `Extracting ${start}s–${end ?? 'end'}s… (may take 10–30 seconds)`;
  1006. const res = await fetch(`/api/speakers/${encodeURIComponent(vpTarget)}/voiceprint/enrol`, {
  1007. method: 'POST',
  1008. headers: {'Content-Type': 'application/json'},
  1009. body: JSON.stringify({audio_file: file, start, end}),
  1010. });
  1011. if (res.ok) {
  1012. status.style.color = '#166534';
  1013. status.textContent = '✓ Voiceprint saved';
  1014. toast('Voiceprint enrolled');
  1015. } else {
  1016. const err = await res.json().catch(() => ({detail: 'Failed'}));
  1017. status.style.color = '#dc2626';
  1018. status.textContent = `✗ ${err.detail || 'Failed'}`;
  1019. toast(err.detail || 'Enrol failed', true);
  1020. }
  1021. }
  1022. async function vpDelete() {
  1023. if (!vpTarget) return;
  1024. if (!confirm('Delete the stored voiceprint for this speaker?')) return;
  1025. const res = await fetch(`/api/speakers/${encodeURIComponent(vpTarget)}/voiceprint`, {method: 'DELETE'});
  1026. if (res.ok) { toast('Voiceprint deleted'); closeVoiceprintModal(); }
  1027. else { toast('Delete failed', true); }
  1028. }
  1029. function openUploadModal(id, name) {
  1030. uploadTarget = id;
  1031. document.getElementById('upload-title').textContent = `Voice Sample — ${name}`;
  1032. document.getElementById('upload-status').textContent = '';
  1033. document.getElementById('file-input').value = '';
  1034. document.getElementById('upload-modal').classList.add('open');
  1035. }
  1036. function closeUploadModal(e) {
  1037. if (!e || e.target === document.getElementById('upload-modal')) {
  1038. document.getElementById('upload-modal').classList.remove('open');
  1039. uploadTarget = null;
  1040. load();
  1041. }
  1042. }
  1043. function onDragOver(e) { e.preventDefault(); document.getElementById('drop-zone').classList.add('drag'); }
  1044. function onDragLeave() { document.getElementById('drop-zone').classList.remove('drag'); }
  1045. function onDrop(e) { e.preventDefault(); onDragLeave(); uploadFile(e.dataTransfer.files[0]); }
  1046. async function uploadFile(file) {
  1047. if (!file || !uploadTarget) return;
  1048. const status = document.getElementById('upload-status');
  1049. status.style.color = '#2563eb';
  1050. status.textContent = `Uploading ${file.name} (${Math.round(file.size/1024)} KB)…`;
  1051. const form = new FormData();
  1052. form.append('file', file);
  1053. const res = await fetch(`/api/speakers/${encodeURIComponent(uploadTarget)}/recording`, {
  1054. method: 'POST', body: form
  1055. });
  1056. if (res.ok) {
  1057. const data = await res.json();
  1058. status.style.color = '#166534';
  1059. status.textContent = `✓ Saved — ${data.file} (${data.kb} KB)`;
  1060. toast('Recording saved');
  1061. } else {
  1062. status.style.color = '#dc2626';
  1063. status.textContent = 'Upload failed';
  1064. toast('Upload failed', true);
  1065. }
  1066. }
  1067. // ── Toast ─────────────────────────────────────────────────────────────────────
  1068. let toastTimer;
  1069. function toast(msg, error = false) {
  1070. const el = document.getElementById('toast');
  1071. el.textContent = msg;
  1072. el.className = 'show' + (error ? ' error' : '');
  1073. clearTimeout(toastTimer);
  1074. toastTimer = setTimeout(() => el.className = '', 2500);
  1075. }
  1076. // ── Test Playback ─────────────────────────────────────────────────────────────
  1077. let pbExpanded = true;
  1078. let pbPollTimer = null;
  1079. let pbFiles = [];
  1080. function togglePbCard() {
  1081. pbExpanded = !pbExpanded;
  1082. document.getElementById('pb-body').style.display = pbExpanded ? '' : 'none';
  1083. document.getElementById('pb-chevron').textContent = pbExpanded ? '▼' : '▶';
  1084. }
  1085. async function loadTestFiles() {
  1086. try {
  1087. const res = await fetch('/api/test/files');
  1088. const data = await res.json();
  1089. pbFiles = data.files;
  1090. } catch { pbFiles = []; }
  1091. renderTestFiles();
  1092. }
  1093. function renderTestFiles() {
  1094. const tbody = document.getElementById('test-tbody');
  1095. const empty = document.getElementById('test-empty-row');
  1096. tbody.innerHTML = '';
  1097. if (pbFiles.length === 0) {
  1098. tbody.appendChild(empty);
  1099. return;
  1100. }
  1101. pbFiles.forEach(f => tbody.appendChild(makeTestRow(f)));
  1102. }
  1103. function makeTestRow(f) {
  1104. const tr = document.createElement('tr');
  1105. tr.innerHTML = `
  1106. <td style="font-family:monospace;font-size:.88rem">${esc(f.filename)}</td>
  1107. <td style="color:#64748b;white-space:nowrap">${f.mb} MB</td>
  1108. <td>
  1109. <div class="actions">
  1110. <button class="btn btn-primary btn-sm"
  1111. onclick="pbStart('${esc(f.filename)}')">&#9654; Play</button>
  1112. <button class="btn btn-danger btn-sm"
  1113. onclick="pbDeleteFile('${esc(f.filename)}')">&#128465;</button>
  1114. </div>
  1115. </td>`;
  1116. return tr;
  1117. }
  1118. function pbDragOver(e) {
  1119. e.preventDefault();
  1120. document.getElementById('test-drop').classList.add('drag');
  1121. }
  1122. function pbDragLeave() {
  1123. document.getElementById('test-drop').classList.remove('drag');
  1124. }
  1125. function pbDrop(e) {
  1126. e.preventDefault();
  1127. pbDragLeave();
  1128. pbUpload(e.dataTransfer.files[0]);
  1129. }
  1130. async function pbUpload(file) {
  1131. if (!file) return;
  1132. const status = document.getElementById('test-upload-status');
  1133. status.style.color = '#2563eb';
  1134. status.textContent = `Uploading ${file.name} (${(file.size/1024/1024).toFixed(1)} MB)…`;
  1135. const form = new FormData();
  1136. form.append('file', file);
  1137. const res = await fetch('/api/test/upload', { method: 'POST', body: form });
  1138. if (res.ok) {
  1139. const d = await res.json();
  1140. status.style.color = '#166534';
  1141. status.textContent = `✓ ${d.filename} (${d.mb} MB)`;
  1142. toast('Recording uploaded');
  1143. document.getElementById('test-file-input').value = '';
  1144. await loadTestFiles();
  1145. } else {
  1146. const err = await res.json().catch(() => ({detail: 'Upload failed'}));
  1147. status.style.color = '#dc2626';
  1148. status.textContent = err.detail || 'Upload failed';
  1149. toast(err.detail || 'Upload failed', true);
  1150. }
  1151. }
  1152. async function pbStart(filename) {
  1153. const speed = parseFloat(document.getElementById('pb-speed').value);
  1154. const res = await fetch('/api/test/start', {
  1155. method: 'POST',
  1156. headers: {'Content-Type': 'application/json'},
  1157. body: JSON.stringify({ filename, speed }),
  1158. });
  1159. if (res.ok) {
  1160. document.getElementById('pb-stop-btn').disabled = false;
  1161. toast(`Playing at ${speed}×`);
  1162. pbStartPoll();
  1163. } else {
  1164. const err = await res.json().catch(() => ({detail: 'Failed to start'}));
  1165. toast(err.detail || 'Failed to start', true);
  1166. }
  1167. }
  1168. async function pbStop() {
  1169. const res = await fetch('/api/test/stop', { method: 'POST' });
  1170. pbStopPoll();
  1171. if (res.ok) toast('Playback stopped');
  1172. setPbIdle();
  1173. }
  1174. async function pbDeleteFile(filename) {
  1175. if (!confirm(`Delete "${filename}"?`)) return;
  1176. await fetch(`/api/test/files/${encodeURIComponent(filename)}`, { method: 'DELETE' });
  1177. toast('Deleted');
  1178. await loadTestFiles();
  1179. }
  1180. function pbStartPoll() {
  1181. pbStopPoll();
  1182. pbPollTimer = setInterval(pbPoll, 1000);
  1183. pbPoll();
  1184. }
  1185. function pbStopPoll() {
  1186. if (pbPollTimer) { clearInterval(pbPollTimer); pbPollTimer = null; }
  1187. }
  1188. async function pbPoll() {
  1189. let data;
  1190. try {
  1191. const res = await fetch('/api/test/status');
  1192. data = await res.json();
  1193. } catch { return; }
  1194. const fill = document.getElementById('pb-fill');
  1195. const statusEl = document.getElementById('pb-status-text');
  1196. const timeEl = document.getElementById('pb-time-text');
  1197. fill.style.width = (data.progress || 0) + '%';
  1198. const elapsed = data.elapsed ? fmtTime(data.elapsed) : '';
  1199. const duration = data.duration ? fmtTime(data.duration) : '';
  1200. if (data.state === 'loading' || data.state === 'starting') {
  1201. statusEl.className = '';
  1202. statusEl.textContent = 'Loading file…';
  1203. timeEl.textContent = '';
  1204. } else if (data.state === 'playing') {
  1205. statusEl.className = '';
  1206. statusEl.textContent = `Playing: ${data.file}`;
  1207. timeEl.textContent = elapsed && duration ? `${elapsed} / ${duration}` : '';
  1208. } else if (data.state === 'done') {
  1209. statusEl.className = '';
  1210. statusEl.textContent = `Done: ${data.file}`;
  1211. timeEl.textContent = duration;
  1212. fill.style.width = '100%';
  1213. pbStopPoll();
  1214. document.getElementById('pb-stop-btn').disabled = true;
  1215. } else if (data.state === 'error') {
  1216. statusEl.className = 'pb-error';
  1217. statusEl.textContent = `Error: ${data.error}`;
  1218. timeEl.textContent = '';
  1219. pbStopPoll();
  1220. document.getElementById('pb-stop-btn').disabled = true;
  1221. } else {
  1222. pbStopPoll();
  1223. setPbIdle();
  1224. }
  1225. }
  1226. function setPbIdle() {
  1227. document.getElementById('pb-stop-btn').disabled = true;
  1228. document.getElementById('pb-fill').style.width = '0%';
  1229. const statusEl = document.getElementById('pb-status-text');
  1230. statusEl.className = '';
  1231. statusEl.textContent = 'Idle';
  1232. document.getElementById('pb-time-text').textContent = '';
  1233. }
  1234. function fmtTime(secs) {
  1235. const s = Math.floor(secs);
  1236. const m = Math.floor(s / 60);
  1237. return `${m}:${String(s % 60).padStart(2, '0')}`;
  1238. }
  1239. // ── Keyboard shortcuts ────────────────────────────────────────────────────────
  1240. document.addEventListener('keydown', e => {
  1241. if (e.key === 'Escape') { closeAddModal(); closeUploadModal(); }
  1242. if ((e.ctrlKey || e.metaKey) && e.key === 'f') {
  1243. e.preventDefault();
  1244. document.getElementById('search').focus();
  1245. }
  1246. });
  1247. // ── Boot ──────────────────────────────────────────────────────────────────────
  1248. load();
  1249. loadTestFiles();
  1250. </script>
  1251. </body>
  1252. </html>
  1253. """
  1254. # ── Display page (fullscreen tablet / TV) ─────────────────────────────────────
  1255. DISPLAY_HTML = """<!DOCTYPE html>
  1256. <html lang="en">
  1257. <head>
  1258. <meta charset="UTF-8">
  1259. <meta name="viewport" content="width=device-width, initial-scale=1">
  1260. <title>Live Transcription</title>
  1261. <style>
  1262. *, *::before, *::after { box-sizing: border-box; }
  1263. html, body {
  1264. margin: 0; padding: 0;
  1265. width: 100%; height: 100%;
  1266. background: #0d0d0d;
  1267. color: #eeeeee;
  1268. overflow: hidden;
  1269. }
  1270. #screen {
  1271. display: flex;
  1272. flex-direction: column;
  1273. height: 100vh;
  1274. padding: 5vh 6vw;
  1275. gap: 4vh;
  1276. justify-content: flex-start;
  1277. }
  1278. .display-line {
  1279. font-family: Georgia, 'Times New Roman', serif;
  1280. font-size: clamp(22px, 3vw, 44px);
  1281. line-height: 1.25;
  1282. letter-spacing: 0.02em;
  1283. min-height: 1.25em;
  1284. word-break: break-word;
  1285. transition: opacity 0.15s;
  1286. }
  1287. .display-line.is-speaker {
  1288. font-size: clamp(16px, 2.2vw, 32px);
  1289. color: #f5c518;
  1290. font-weight: 700;
  1291. letter-spacing: 0.1em;
  1292. font-family: system-ui, sans-serif;
  1293. padding-bottom: 1.5vh;
  1294. border-bottom: 2px solid #2a2a2a;
  1295. }
  1296. /* Small status indicator in the corner */
  1297. #conn {
  1298. position: fixed;
  1299. bottom: 12px;
  1300. right: 16px;
  1301. display: flex;
  1302. align-items: center;
  1303. gap: 6px;
  1304. font-family: system-ui, sans-serif;
  1305. font-size: 12px;
  1306. color: #333;
  1307. pointer-events: none;
  1308. }
  1309. #conn-dot {
  1310. width: 8px; height: 8px;
  1311. border-radius: 50%;
  1312. background: #555;
  1313. transition: background 0.4s;
  1314. }
  1315. #conn-dot.live { background: #22c55e; }
  1316. #conn-dot.lost { background: #dc2626; }
  1317. </style>
  1318. </head>
  1319. <body>
  1320. <div id="screen">
  1321. <div class="display-line" id="line0"></div>
  1322. <div class="display-line" id="line1"></div>
  1323. <div class="display-line" id="line2"></div>
  1324. </div>
  1325. <div id="conn">
  1326. <div id="conn-dot"></div>
  1327. <span id="conn-label">connecting</span>
  1328. </div>
  1329. <script>
  1330. const SPEAKER_RE = /^\\[(.+)\\]$/;
  1331. function applyLines(lines) {
  1332. for (let i = 0; i < 3; i++) {
  1333. const el = document.getElementById('line' + i);
  1334. const txt = String(lines[i] || '').trim();
  1335. const m = txt.match(SPEAKER_RE);
  1336. if (m) {
  1337. el.textContent = m[1];
  1338. el.className = 'display-line is-speaker';
  1339. } else {
  1340. el.textContent = txt;
  1341. el.className = 'display-line';
  1342. }
  1343. }
  1344. }
  1345. function setStatus(ok) {
  1346. const dot = document.getElementById('conn-dot');
  1347. const label = document.getElementById('conn-label');
  1348. dot.className = ok ? 'live' : 'lost';
  1349. label.textContent = ok ? 'live' : 'reconnecting…';
  1350. }
  1351. function connect() {
  1352. const es = new EventSource('/api/display/stream');
  1353. es.addEventListener('text', e => {
  1354. try { applyLines(JSON.parse(e.data).lines || []); } catch {}
  1355. });
  1356. es.addEventListener('clear', () => applyLines(['', '', '']));
  1357. es.onopen = () => setStatus(true);
  1358. es.onerror = () => {
  1359. setStatus(false);
  1360. es.close();
  1361. setTimeout(connect, 4000);
  1362. };
  1363. }
  1364. connect();
  1365. </script>
  1366. </body>
  1367. </html>
  1368. """
  1369. @app.get("/", response_class=HTMLResponse)
  1370. def index():
  1371. return HTML
  1372. @app.get("/display", response_class=HTMLResponse)
  1373. def display():
  1374. return DISPLAY_HTML
  1375. # ── Entry point ───────────────────────────────────────────────────────────────
  1376. if __name__ == "__main__":
  1377. print("[Admin] Speaker admin at http://localhost:8001")
  1378. print("[Admin] Display page at http://localhost:8001/display")
  1379. uvicorn.run(app, host="0.0.0.0", port=8001, log_level="warning")