admin.py 34 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036
  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.
  6. Runs on port 8001 alongside bridge.py.
  7. Access at: http://localhost:8001
  8. """
  9. import asyncio
  10. import json
  11. import shutil
  12. from pathlib import Path
  13. from fastapi import FastAPI, HTTPException, UploadFile, File
  14. from fastapi.responses import HTMLResponse, FileResponse
  15. from pydantic import BaseModel
  16. import uvicorn
  17. import websockets
  18. SPEAKERS_FILE = Path(__file__).parent / "speakers.json"
  19. RECORDINGS_DIR = Path(__file__).parent / "recordings"
  20. TEST_RECORDINGS_DIR = Path(__file__).parent / "test_recordings"
  21. RECORDINGS_DIR.mkdir(exist_ok=True)
  22. TEST_RECORDINGS_DIR.mkdir(exist_ok=True)
  23. ALLOWED_AUDIO_EXTS = {".wav", ".mp3", ".m4a", ".ogg", ".flac", ".webm", ".aiff"}
  24. WS_URL = "ws://localhost:8000/asr"
  25. app = FastAPI(title="Speaker Admin")
  26. # ── Speaker data helpers ──────────────────────────────────────────────────────
  27. def _load() -> dict[str, str]:
  28. if SPEAKERS_FILE.exists():
  29. try:
  30. return json.loads(SPEAKERS_FILE.read_text(encoding="utf-8"))
  31. except Exception:
  32. pass
  33. return {}
  34. def _save(data: dict[str, str]) -> None:
  35. SPEAKERS_FILE.write_text(
  36. json.dumps(data, indent=2, ensure_ascii=False), encoding="utf-8"
  37. )
  38. def _recording_path(sid: str) -> Path | None:
  39. for ext in (".wav", ".mp3", ".m4a", ".ogg", ".webm", ".flac"):
  40. p = RECORDINGS_DIR / f"{sid}{ext}"
  41. if p.exists():
  42. return p
  43. return None
  44. # ── Speaker API ───────────────────────────────────────────────────────────────
  45. class NameBody(BaseModel):
  46. name: str
  47. class AddBody(BaseModel):
  48. id: str
  49. name: str
  50. @app.get("/api/speakers")
  51. def api_list():
  52. speakers = _load()
  53. return {"speakers": [
  54. {"id": k, "name": v, "has_recording": _recording_path(k) is not None}
  55. for k, v in sorted(speakers.items())
  56. ]}
  57. @app.post("/api/speakers")
  58. def api_add(body: AddBody):
  59. speakers = _load()
  60. sid = body.id.strip()
  61. if not sid:
  62. raise HTTPException(400, "Speaker ID cannot be empty")
  63. if sid in speakers:
  64. raise HTTPException(400, f"'{sid}' already exists")
  65. speakers[sid] = body.name.strip()
  66. _save(speakers)
  67. return {"ok": True, "id": sid, "name": speakers[sid]}
  68. @app.put("/api/speakers/{sid}")
  69. def api_update(sid: str, body: NameBody):
  70. name = body.name.strip()
  71. if not name:
  72. raise HTTPException(400, "Name cannot be empty")
  73. speakers = _load()
  74. speakers[sid] = name
  75. _save(speakers)
  76. return {"ok": True}
  77. @app.delete("/api/speakers/{sid}")
  78. def api_delete(sid: str):
  79. speakers = _load()
  80. speakers.pop(sid, None)
  81. _save(speakers)
  82. rec = _recording_path(sid)
  83. if rec:
  84. rec.unlink()
  85. return {"ok": True}
  86. @app.post("/api/speakers/{sid}/recording")
  87. async def api_upload(sid: str, file: UploadFile = File(...)):
  88. suffix = Path(file.filename or "audio.wav").suffix.lower() or ".wav"
  89. rec = _recording_path(sid)
  90. if rec:
  91. rec.unlink()
  92. out = RECORDINGS_DIR / f"{sid}{suffix}"
  93. with out.open("wb") as f:
  94. shutil.copyfileobj(file.file, f)
  95. speakers = _load()
  96. if sid not in speakers:
  97. speakers[sid] = sid
  98. _save(speakers)
  99. size_kb = round(out.stat().st_size / 1024)
  100. return {"ok": True, "file": out.name, "kb": size_kb}
  101. @app.get("/api/speakers/{sid}/recording")
  102. def api_playback(sid: str):
  103. rec = _recording_path(sid)
  104. if not rec:
  105. raise HTTPException(404, "No recording found")
  106. return FileResponse(rec)
  107. # ── Test playback state ───────────────────────────────────────────────────────
  108. _playback_task: asyncio.Task | None = None
  109. _playback_status: dict = {
  110. "state": "idle", # idle | loading | playing | done | error
  111. "file": None,
  112. "progress": 0, # 0–100
  113. "elapsed": 0.0, # seconds streamed so far
  114. "duration": 0.0, # total file duration in seconds
  115. "error": None,
  116. }
  117. async def _stream_file(filepath: Path, speed: float) -> None:
  118. global _playback_status
  119. try:
  120. import miniaudio # type: ignore
  121. except ImportError:
  122. _playback_status.update({
  123. "state": "error",
  124. "error": "miniaudio not installed — run: pip install miniaudio",
  125. })
  126. return
  127. try:
  128. _playback_status["state"] = "loading"
  129. duration = 0.0
  130. try:
  131. info = miniaudio.get_file_info(str(filepath))
  132. duration = info.duration
  133. except Exception:
  134. pass
  135. _playback_status.update({
  136. "state": "playing",
  137. "duration": round(duration, 1),
  138. "elapsed": 0.0,
  139. "progress": 0,
  140. })
  141. chunk_frames = 4096
  142. chunk_secs = chunk_frames / 16000
  143. elapsed = 0.0
  144. async with websockets.connect(WS_URL, max_size=2**23) as ws:
  145. stream = miniaudio.stream_file(
  146. str(filepath),
  147. output_format=miniaudio.SampleFormat.SIGNED16,
  148. nchannels=1,
  149. sample_rate=16000,
  150. frames_to_read=chunk_frames,
  151. )
  152. for chunk in stream:
  153. await ws.send(bytes(chunk))
  154. elapsed += chunk_secs
  155. _playback_status["elapsed"] = round(elapsed, 1)
  156. _playback_status["progress"] = (
  157. min(99, round(elapsed / duration * 100)) if duration else 0
  158. )
  159. await asyncio.sleep(chunk_secs / speed)
  160. _playback_status.update({
  161. "state": "done",
  162. "progress": 100,
  163. "elapsed": round(duration, 1),
  164. })
  165. except asyncio.CancelledError:
  166. _playback_status.update({
  167. "state": "idle", "file": None, "progress": 0, "elapsed": 0.0,
  168. })
  169. except Exception as exc:
  170. _playback_status.update({"state": "error", "error": str(exc), "progress": 0})
  171. print(f"[Playback] {exc}")
  172. # ── Test recording API ────────────────────────────────────────────────────────
  173. @app.post("/api/test/upload")
  174. async def api_test_upload(file: UploadFile = File(...)):
  175. suffix = Path(file.filename or "recording.wav").suffix.lower()
  176. if suffix not in ALLOWED_AUDIO_EXTS:
  177. raise HTTPException(400, f"Unsupported format '{suffix}'. Use WAV, MP3, FLAC, OGG, or M4A.")
  178. stem = Path(file.filename).stem[:80] # limit filename length
  179. out = TEST_RECORDINGS_DIR / f"{stem}{suffix}"
  180. with out.open("wb") as f:
  181. shutil.copyfileobj(file.file, f)
  182. size_mb = round(out.stat().st_size / 1024 / 1024, 1)
  183. return {"ok": True, "filename": out.name, "mb": size_mb}
  184. @app.get("/api/test/files")
  185. def api_test_list():
  186. files = []
  187. for p in sorted(TEST_RECORDINGS_DIR.iterdir()):
  188. if p.suffix.lower() in ALLOWED_AUDIO_EXTS:
  189. files.append({
  190. "filename": p.name,
  191. "mb": round(p.stat().st_size / 1024 / 1024, 1),
  192. })
  193. return {"files": files}
  194. @app.delete("/api/test/files/{filename}")
  195. def api_test_delete(filename: str):
  196. p = TEST_RECORDINGS_DIR / Path(filename).name # sanitise — no path traversal
  197. if p.exists():
  198. p.unlink()
  199. return {"ok": True}
  200. class PlaybackBody(BaseModel):
  201. filename: str
  202. speed: float = 1.0
  203. @app.post("/api/test/start")
  204. async def api_test_start(body: PlaybackBody):
  205. global _playback_task
  206. if _playback_task and not _playback_task.done():
  207. raise HTTPException(409, "Playback already running — stop it first")
  208. p = TEST_RECORDINGS_DIR / Path(body.filename).name
  209. if not p.exists():
  210. raise HTTPException(404, "File not found")
  211. speed = max(0.25, min(8.0, body.speed))
  212. _playback_status.update({"state": "starting", "file": p.name, "progress": 0, "error": None})
  213. _playback_task = asyncio.create_task(_stream_file(p, speed))
  214. return {"ok": True}
  215. @app.post("/api/test/stop")
  216. async def api_test_stop():
  217. global _playback_task
  218. if _playback_task and not _playback_task.done():
  219. _playback_task.cancel()
  220. try:
  221. await _playback_task
  222. except asyncio.CancelledError:
  223. pass
  224. _playback_status.update({"state": "idle", "file": None, "progress": 0, "elapsed": 0.0})
  225. return {"ok": True}
  226. @app.get("/api/test/status")
  227. def api_test_status():
  228. return _playback_status
  229. # ── Web UI ────────────────────────────────────────────────────────────────────
  230. HTML = """<!DOCTYPE html>
  231. <html lang="en">
  232. <head>
  233. <meta charset="UTF-8">
  234. <meta name="viewport" content="width=device-width, initial-scale=1">
  235. <title>Meeting Transcription for the Deaf</title>
  236. <style>
  237. *, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
  238. body { font-family: system-ui, sans-serif; background: #f0f4f8; color: #1e293b; }
  239. header {
  240. background: #1e3a5f; color: white; padding: 16px 24px;
  241. display: flex; align-items: center; gap: 16px;
  242. }
  243. header h1 { font-size: 1.2rem; font-weight: 600; flex: 1; }
  244. header small { opacity: .7; font-size: .8rem; }
  245. .toolbar {
  246. background: white; padding: 12px 24px;
  247. display: flex; gap: 12px; align-items: center;
  248. border-bottom: 1px solid #e2e8f0;
  249. }
  250. .toolbar input[type=search] {
  251. flex: 1; max-width: 340px; padding: 8px 12px;
  252. border: 1px solid #cbd5e1; border-radius: 6px; font-size: .95rem;
  253. }
  254. .count { color: #64748b; font-size: .9rem; margin-left: auto; }
  255. .btn {
  256. display: inline-flex; align-items: center; gap: 6px;
  257. padding: 8px 16px; border-radius: 6px; border: none;
  258. cursor: pointer; font-size: .9rem; font-weight: 500; transition: filter .15s;
  259. }
  260. .btn:hover:not(:disabled) { filter: brightness(.92); }
  261. .btn:disabled { opacity: .45; cursor: not-allowed; }
  262. .btn-primary { background: #2563eb; color: white; }
  263. .btn-danger { background: #dc2626; color: white; }
  264. .btn-ghost { background: #e2e8f0; color: #334155; }
  265. .btn-sm { padding: 4px 10px; font-size: .82rem; }
  266. table { width: 100%; border-collapse: collapse; background: white; }
  267. th {
  268. text-align: left; padding: 10px 16px; font-size: .8rem;
  269. font-weight: 600; text-transform: uppercase; letter-spacing: .05em;
  270. color: #64748b; background: #f8fafc; border-bottom: 1px solid #e2e8f0;
  271. }
  272. td { padding: 10px 16px; border-bottom: 1px solid #f1f5f9; vertical-align: middle; }
  273. tr:hover td { background: #f8fafc; }
  274. tr.hidden { display: none; }
  275. .sid { font-family: monospace; font-size: .85rem; color: #475569; }
  276. .name-cell { display: flex; align-items: center; gap: 8px; }
  277. .name-display { cursor: pointer; flex: 1; }
  278. .name-display:hover { text-decoration: underline; }
  279. .name-input {
  280. flex: 1; padding: 4px 8px; border: 1px solid #2563eb;
  281. border-radius: 4px; font-size: .95rem; outline: none;
  282. }
  283. .rec-badge {
  284. display: inline-flex; align-items: center; gap: 4px;
  285. font-size: .75rem; padding: 2px 8px; border-radius: 999px;
  286. font-weight: 500;
  287. }
  288. .rec-yes { background: #dcfce7; color: #166534; }
  289. .rec-no { background: #f1f5f9; color: #94a3b8; }
  290. .actions { display: flex; gap: 6px; }
  291. /* Modal */
  292. .modal-bg {
  293. display: none; position: fixed; inset: 0;
  294. background: rgba(0,0,0,.45); z-index: 100;
  295. align-items: center; justify-content: center;
  296. }
  297. .modal-bg.open { display: flex; }
  298. .modal {
  299. background: white; border-radius: 10px; padding: 28px;
  300. width: 420px; max-width: 95vw; box-shadow: 0 20px 60px rgba(0,0,0,.25);
  301. }
  302. .modal h2 { font-size: 1.1rem; margin-bottom: 16px; }
  303. .field { margin-bottom: 14px; }
  304. .field label { display: block; font-size: .85rem; font-weight: 500; margin-bottom: 4px; }
  305. .field input {
  306. width: 100%; padding: 8px 10px; border: 1px solid #cbd5e1;
  307. border-radius: 6px; font-size: .95rem;
  308. }
  309. .field input:focus { outline: 2px solid #2563eb; border-color: transparent; }
  310. .modal-actions { display: flex; gap: 8px; justify-content: flex-end; margin-top: 20px; }
  311. /* Upload drop zone */
  312. .upload-area {
  313. border: 2px dashed #cbd5e1; border-radius: 8px; padding: 16px;
  314. text-align: center; cursor: pointer; transition: border-color .2s;
  315. color: #64748b; font-size: .9rem;
  316. }
  317. .upload-area.drag { border-color: #2563eb; background: #eff6ff; }
  318. .upload-area input[type=file] { display: none; }
  319. /* Audio player */
  320. .audio-player { width: 180px; height: 32px; }
  321. /* Toast */
  322. #toast {
  323. position: fixed; bottom: 24px; right: 24px;
  324. background: #1e293b; color: white; padding: 10px 18px;
  325. border-radius: 8px; font-size: .9rem; transform: translateY(80px);
  326. transition: transform .25s; z-index: 200; pointer-events: none;
  327. }
  328. #toast.show { transform: translateY(0); }
  329. #toast.error { background: #dc2626; }
  330. .container { max-width: 1100px; margin: 0 auto; padding: 24px; }
  331. audio { vertical-align: middle; }
  332. /* ── Test Playback card ─────────────────────────────── */
  333. .pb-card {
  334. background: white; border-radius: 8px; margin-top: 20px;
  335. border: 1px solid #e2e8f0; overflow: hidden;
  336. }
  337. .pb-card-header {
  338. display: flex; justify-content: space-between; align-items: center;
  339. padding: 14px 20px; cursor: pointer; font-weight: 600; font-size: 1rem;
  340. background: #f8fafc; border-bottom: 1px solid #e2e8f0; user-select: none;
  341. }
  342. .pb-card-header:hover { background: #f1f5f9; }
  343. #pb-body { padding: 20px; }
  344. .pb-hint { color: #64748b; font-size: .88rem; margin-bottom: 16px; line-height: 1.5; }
  345. .pb-layout { display: flex; gap: 20px; flex-wrap: wrap; margin-bottom: 16px; }
  346. .pb-upload { flex: 1; min-width: 260px; }
  347. .pb-controls { min-width: 210px; display: flex; flex-direction: column; gap: 12px; }
  348. .pb-select {
  349. width: 100%; padding: 7px 10px; border: 1px solid #cbd5e1;
  350. border-radius: 6px; font-size: .9rem; background: white;
  351. }
  352. .pb-label { font-size: .85rem; font-weight: 500; display: block; margin-bottom: 4px; }
  353. .pb-status-bar { margin-top: 12px; }
  354. .pb-status-row {
  355. display: flex; justify-content: space-between;
  356. font-size: .85rem; margin-bottom: 5px; min-height: 18px;
  357. }
  358. .pb-progress-track {
  359. height: 8px; background: #e2e8f0; border-radius: 999px; overflow: hidden;
  360. }
  361. .pb-progress-fill {
  362. height: 100%; background: #2563eb; border-radius: 999px;
  363. width: 0%; transition: width .8s linear;
  364. }
  365. .pb-note { font-size: .78rem; color: #94a3b8; margin-top: 10px; }
  366. .pb-error { color: #dc2626; }
  367. </style>
  368. </head>
  369. <body>
  370. <header>
  371. <div>
  372. <h1>&#127908; Speaker Admin</h1>
  373. <small>Meeting Transcription for the Deaf — Speaker Name &amp; Voice Library</small>
  374. </div>
  375. </header>
  376. <div class="toolbar">
  377. <input type="search" id="search" placeholder="Search by ID or name…" oninput="filterTable()">
  378. <button class="btn btn-primary" onclick="openAddModal()">&#43; Add Speaker</button>
  379. <span class="count" id="count"></span>
  380. </div>
  381. <div class="container">
  382. <!-- Speaker table -->
  383. <table id="table">
  384. <thead>
  385. <tr>
  386. <th>Speaker ID</th>
  387. <th>Friendly Name</th>
  388. <th>Voice Sample</th>
  389. <th>Actions</th>
  390. </tr>
  391. </thead>
  392. <tbody id="tbody"></tbody>
  393. </table>
  394. <!-- Test Playback card -->
  395. <div class="pb-card">
  396. <div class="pb-card-header" onclick="togglePbCard()">
  397. <span>&#127911; Test Recording Playback</span>
  398. <span id="pb-chevron">&#9660;</span>
  399. </div>
  400. <div id="pb-body">
  401. <p class="pb-hint">
  402. Upload a full church service recording (WAV, MP3, FLAC, OGG, M4A) to test the
  403. transcription pipeline offline. The file streams to WhisperLiveKit exactly as a
  404. live microphone would — results appear on the e-ink display in real time.
  405. </p>
  406. <div class="pb-layout">
  407. <!-- Upload drop zone -->
  408. <div class="pb-upload">
  409. <div class="upload-area" id="test-drop"
  410. ondragover="pbDragOver(event)" ondragleave="pbDragLeave()"
  411. ondrop="pbDrop(event)"
  412. onclick="document.getElementById('test-file-input').click()">
  413. <input type="file" id="test-file-input" accept="audio/*"
  414. onchange="pbUpload(this.files[0])">
  415. <div>&#8679; Drop a recording here, or click to browse</div>
  416. <div style="font-size:.78rem;margin-top:4px;color:#94a3b8">
  417. WAV &middot; MP3 &middot; FLAC &middot; OGG &middot; M4A
  418. </div>
  419. </div>
  420. <div id="test-upload-status" style="min-height:20px;font-size:.85rem;margin-top:8px"></div>
  421. </div>
  422. <!-- Playback controls -->
  423. <div class="pb-controls">
  424. <div>
  425. <span class="pb-label">Playback Speed</span>
  426. <select id="pb-speed" class="pb-select">
  427. <option value="0.5">0.5&times; (half speed)</option>
  428. <option value="1">1&times; real-time</option>
  429. <option value="2">2&times; faster</option>
  430. <option value="4" selected>4&times; quick review</option>
  431. <option value="8">8&times; very fast</option>
  432. </select>
  433. </div>
  434. <button id="pb-stop-btn" class="btn btn-danger" onclick="pbStop()" disabled>
  435. &#9632; Stop Playback
  436. </button>
  437. </div>
  438. </div>
  439. <!-- Uploaded test files -->
  440. <table id="test-table">
  441. <thead>
  442. <tr>
  443. <th>Recording File</th>
  444. <th style="width:80px">Size</th>
  445. <th style="width:140px">Actions</th>
  446. </tr>
  447. </thead>
  448. <tbody id="test-tbody">
  449. <tr id="test-empty-row">
  450. <td colspan="3" style="color:#94a3b8;padding:16px 16px">
  451. No recordings uploaded yet
  452. </td>
  453. </tr>
  454. </tbody>
  455. </table>
  456. <!-- Status bar -->
  457. <div class="pb-status-bar">
  458. <div class="pb-status-row">
  459. <span id="pb-status-text" style="color:#64748b">Idle</span>
  460. <span id="pb-time-text" style="color:#64748b"></span>
  461. </div>
  462. <div class="pb-progress-track">
  463. <div class="pb-progress-fill" id="pb-fill"></div>
  464. </div>
  465. </div>
  466. <p class="pb-note">
  467. While a test recording plays, the live microphone in bridge.py continues to run.
  468. Keep the room quiet during testing to avoid mixing live audio with the recording.
  469. </p>
  470. </div>
  471. </div>
  472. </div><!-- /container -->
  473. <!-- Add speaker modal -->
  474. <div class="modal-bg" id="add-modal" onclick="closeAddModal(event)">
  475. <div class="modal">
  476. <h2>Add Speaker</h2>
  477. <div class="field">
  478. <label>Speaker ID
  479. <span style="color:#64748b;font-weight:normal;font-size:.8rem">
  480. (e.g. SPEAKER_00, or any unique key)
  481. </span>
  482. </label>
  483. <input id="new-id" placeholder="SPEAKER_00">
  484. </div>
  485. <div class="field">
  486. <label>Friendly Name</label>
  487. <input id="new-name" placeholder="Pastor John">
  488. </div>
  489. <div class="modal-actions">
  490. <button class="btn btn-ghost" onclick="closeAddModal()">Cancel</button>
  491. <button class="btn btn-primary" onclick="addSpeaker()">Add</button>
  492. </div>
  493. </div>
  494. </div>
  495. <!-- Voice sample upload modal -->
  496. <div class="modal-bg" id="upload-modal" onclick="closeUploadModal(event)">
  497. <div class="modal">
  498. <h2 id="upload-title">Upload Voice Sample</h2>
  499. <p style="color:#64748b;font-size:.85rem;margin-bottom:12px">
  500. Upload a 10–60 second clear speech recording.<br>
  501. Supported: WAV, MP3, M4A, OGG, FLAC, WebM
  502. </p>
  503. <div class="upload-area" id="drop-zone"
  504. ondragover="onDragOver(event)" ondragleave="onDragLeave(event)"
  505. ondrop="onDrop(event)" onclick="document.getElementById('file-input').click()">
  506. <input type="file" id="file-input" accept="audio/*" onchange="uploadFile(this.files[0])">
  507. <div>&#127926; Drag &amp; drop audio here, or click to browse</div>
  508. </div>
  509. <div id="upload-status" style="margin-top:10px;font-size:.85rem;color:#166534"></div>
  510. <div class="modal-actions">
  511. <button class="btn btn-ghost" onclick="closeUploadModal()">Close</button>
  512. </div>
  513. </div>
  514. </div>
  515. <div id="toast"></div>
  516. <script>
  517. let speakers = [];
  518. let uploadTarget = null;
  519. // ── Speaker table ─────────────────────────────────────────────────────────────
  520. async function load() {
  521. const res = await fetch('/api/speakers');
  522. const data = await res.json();
  523. speakers = data.speakers;
  524. render();
  525. }
  526. function render() {
  527. const tbody = document.getElementById('tbody');
  528. tbody.innerHTML = '';
  529. speakers.forEach(s => tbody.appendChild(makeRow(s)));
  530. document.getElementById('count').textContent =
  531. `${speakers.length} speaker${speakers.length !== 1 ? 's' : ''}`;
  532. filterTable();
  533. }
  534. function makeRow(s) {
  535. const tr = document.createElement('tr');
  536. tr.dataset.id = s.id;
  537. tr.dataset.name = s.name.toLowerCase();
  538. const recHtml = s.has_recording
  539. ? `<span class="rec-badge rec-yes">&#9654; Recorded</span>
  540. <audio class="audio-player" controls preload="none"
  541. src="/api/speakers/${encodeURIComponent(s.id)}/recording"></audio>`
  542. : `<span class="rec-badge rec-no">No sample</span>`;
  543. tr.innerHTML = `
  544. <td class="sid">${esc(s.id)}</td>
  545. <td>
  546. <div class="name-cell">
  547. <span class="name-display" onclick="startEdit(this, '${esc(s.id)}')"
  548. title="Click to edit">${esc(s.name)}</span>
  549. <input class="name-input" style="display:none"
  550. onblur="saveEdit(this,'${esc(s.id)}')"
  551. onkeydown="nameKeydown(event,this,'${esc(s.id)}')">
  552. </div>
  553. </td>
  554. <td>${recHtml}</td>
  555. <td>
  556. <div class="actions">
  557. <button class="btn btn-ghost btn-sm"
  558. onclick="openUploadModal('${esc(s.id)}', '${esc(s.name)}')">
  559. &#127926; ${s.has_recording ? 'Replace' : 'Upload'}
  560. </button>
  561. <button class="btn btn-danger btn-sm"
  562. onclick="deleteSpeaker('${esc(s.id)}')">&#128465;</button>
  563. </div>
  564. </td>`;
  565. return tr;
  566. }
  567. function esc(str) {
  568. return String(str)
  569. .replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;')
  570. .replace(/"/g,'&quot;').replace(/'/g,'&#39;');
  571. }
  572. function filterTable() {
  573. const q = document.getElementById('search').value.toLowerCase().trim();
  574. let visible = 0;
  575. document.querySelectorAll('#tbody tr').forEach(tr => {
  576. const match = !q || tr.dataset.id.includes(q) || tr.dataset.name.includes(q);
  577. tr.classList.toggle('hidden', !match);
  578. if (match) visible++;
  579. });
  580. document.getElementById('count').textContent =
  581. q ? `${visible} of ${speakers.length} speakers` : `${speakers.length} speakers`;
  582. }
  583. function startEdit(span, id) {
  584. const input = span.nextElementSibling;
  585. input.value = span.textContent;
  586. span.style.display = 'none';
  587. input.style.display = '';
  588. input.focus();
  589. input.select();
  590. }
  591. function nameKeydown(e, input, id) {
  592. if (e.key === 'Enter') { input.blur(); }
  593. if (e.key === 'Escape') { cancelEdit(input); }
  594. }
  595. function cancelEdit(input) {
  596. const span = input.previousElementSibling;
  597. input.style.display = 'none';
  598. span.style.display = '';
  599. }
  600. async function saveEdit(input, id) {
  601. const name = input.value.trim();
  602. const span = input.previousElementSibling;
  603. if (!name || name === span.textContent) { cancelEdit(input); return; }
  604. const res = await fetch(`/api/speakers/${encodeURIComponent(id)}`, {
  605. method: 'PUT',
  606. headers: {'Content-Type': 'application/json'},
  607. body: JSON.stringify({name})
  608. });
  609. if (res.ok) {
  610. span.textContent = name;
  611. const tr = input.closest('tr');
  612. tr.dataset.name = name.toLowerCase();
  613. toast('Saved');
  614. } else {
  615. toast('Save failed', true);
  616. }
  617. cancelEdit(input);
  618. }
  619. function openAddModal() {
  620. const nums = speakers
  621. .filter(s => /^SPEAKER_\d+$/.test(s.id))
  622. .map(s => parseInt(s.id.split('_')[1]));
  623. const next = nums.length ? Math.max(...nums) + 1 : 0;
  624. document.getElementById('new-id').value = `SPEAKER_${String(next).padStart(2,'0')}`;
  625. document.getElementById('new-name').value = '';
  626. document.getElementById('add-modal').classList.add('open');
  627. setTimeout(() => document.getElementById('new-name').focus(), 50);
  628. }
  629. function closeAddModal(e) {
  630. if (!e || e.target === document.getElementById('add-modal'))
  631. document.getElementById('add-modal').classList.remove('open');
  632. }
  633. async function addSpeaker() {
  634. const id = document.getElementById('new-id').value.trim();
  635. const name = document.getElementById('new-name').value.trim();
  636. if (!id || !name) { toast('ID and name are required', true); return; }
  637. const res = await fetch('/api/speakers', {
  638. method: 'POST',
  639. headers: {'Content-Type': 'application/json'},
  640. body: JSON.stringify({id, name})
  641. });
  642. if (res.ok) {
  643. closeAddModal();
  644. toast(`Added ${name}`);
  645. await load();
  646. } else {
  647. const err = await res.json().catch(() => ({detail:'Error'}));
  648. toast(err.detail || 'Failed', true);
  649. }
  650. }
  651. async function deleteSpeaker(id) {
  652. const s = speakers.find(x => x.id === id);
  653. if (!confirm(`Remove "${s?.name || id}" from the speaker list?`)) return;
  654. const res = await fetch(`/api/speakers/${encodeURIComponent(id)}`, {method:'DELETE'});
  655. if (res.ok) { toast('Removed'); await load(); }
  656. else { toast('Delete failed', true); }
  657. }
  658. function openUploadModal(id, name) {
  659. uploadTarget = id;
  660. document.getElementById('upload-title').textContent = `Voice Sample — ${name}`;
  661. document.getElementById('upload-status').textContent = '';
  662. document.getElementById('file-input').value = '';
  663. document.getElementById('upload-modal').classList.add('open');
  664. }
  665. function closeUploadModal(e) {
  666. if (!e || e.target === document.getElementById('upload-modal')) {
  667. document.getElementById('upload-modal').classList.remove('open');
  668. uploadTarget = null;
  669. load();
  670. }
  671. }
  672. function onDragOver(e) { e.preventDefault(); document.getElementById('drop-zone').classList.add('drag'); }
  673. function onDragLeave() { document.getElementById('drop-zone').classList.remove('drag'); }
  674. function onDrop(e) { e.preventDefault(); onDragLeave(); uploadFile(e.dataTransfer.files[0]); }
  675. async function uploadFile(file) {
  676. if (!file || !uploadTarget) return;
  677. const status = document.getElementById('upload-status');
  678. status.style.color = '#2563eb';
  679. status.textContent = `Uploading ${file.name} (${Math.round(file.size/1024)} KB)…`;
  680. const form = new FormData();
  681. form.append('file', file);
  682. const res = await fetch(`/api/speakers/${encodeURIComponent(uploadTarget)}/recording`, {
  683. method: 'POST', body: form
  684. });
  685. if (res.ok) {
  686. const data = await res.json();
  687. status.style.color = '#166534';
  688. status.textContent = `✓ Saved — ${data.file} (${data.kb} KB)`;
  689. toast('Recording saved');
  690. } else {
  691. status.style.color = '#dc2626';
  692. status.textContent = 'Upload failed';
  693. toast('Upload failed', true);
  694. }
  695. }
  696. // ── Toast ─────────────────────────────────────────────────────────────────────
  697. let toastTimer;
  698. function toast(msg, error = false) {
  699. const el = document.getElementById('toast');
  700. el.textContent = msg;
  701. el.className = 'show' + (error ? ' error' : '');
  702. clearTimeout(toastTimer);
  703. toastTimer = setTimeout(() => el.className = '', 2500);
  704. }
  705. // ── Test Playback ─────────────────────────────────────────────────────────────
  706. let pbExpanded = true;
  707. let pbPollTimer = null;
  708. let pbFiles = [];
  709. function togglePbCard() {
  710. pbExpanded = !pbExpanded;
  711. document.getElementById('pb-body').style.display = pbExpanded ? '' : 'none';
  712. document.getElementById('pb-chevron').textContent = pbExpanded ? '▼' : '▶';
  713. }
  714. async function loadTestFiles() {
  715. try {
  716. const res = await fetch('/api/test/files');
  717. const data = await res.json();
  718. pbFiles = data.files;
  719. } catch { pbFiles = []; }
  720. renderTestFiles();
  721. }
  722. function renderTestFiles() {
  723. const tbody = document.getElementById('test-tbody');
  724. const empty = document.getElementById('test-empty-row');
  725. tbody.innerHTML = '';
  726. if (pbFiles.length === 0) {
  727. tbody.appendChild(empty);
  728. return;
  729. }
  730. pbFiles.forEach(f => tbody.appendChild(makeTestRow(f)));
  731. }
  732. function makeTestRow(f) {
  733. const tr = document.createElement('tr');
  734. tr.innerHTML = `
  735. <td style="font-family:monospace;font-size:.88rem">${esc(f.filename)}</td>
  736. <td style="color:#64748b;white-space:nowrap">${f.mb} MB</td>
  737. <td>
  738. <div class="actions">
  739. <button class="btn btn-primary btn-sm"
  740. onclick="pbStart('${esc(f.filename)}')">&#9654; Play</button>
  741. <button class="btn btn-danger btn-sm"
  742. onclick="pbDeleteFile('${esc(f.filename)}')">&#128465;</button>
  743. </div>
  744. </td>`;
  745. return tr;
  746. }
  747. function pbDragOver(e) {
  748. e.preventDefault();
  749. document.getElementById('test-drop').classList.add('drag');
  750. }
  751. function pbDragLeave() {
  752. document.getElementById('test-drop').classList.remove('drag');
  753. }
  754. function pbDrop(e) {
  755. e.preventDefault();
  756. pbDragLeave();
  757. pbUpload(e.dataTransfer.files[0]);
  758. }
  759. async function pbUpload(file) {
  760. if (!file) return;
  761. const status = document.getElementById('test-upload-status');
  762. status.style.color = '#2563eb';
  763. status.textContent = `Uploading ${file.name} (${(file.size/1024/1024).toFixed(1)} MB)…`;
  764. const form = new FormData();
  765. form.append('file', file);
  766. const res = await fetch('/api/test/upload', { method: 'POST', body: form });
  767. if (res.ok) {
  768. const d = await res.json();
  769. status.style.color = '#166534';
  770. status.textContent = `✓ ${d.filename} (${d.mb} MB)`;
  771. toast('Recording uploaded');
  772. document.getElementById('test-file-input').value = '';
  773. await loadTestFiles();
  774. } else {
  775. const err = await res.json().catch(() => ({detail: 'Upload failed'}));
  776. status.style.color = '#dc2626';
  777. status.textContent = err.detail || 'Upload failed';
  778. toast(err.detail || 'Upload failed', true);
  779. }
  780. }
  781. async function pbStart(filename) {
  782. const speed = parseFloat(document.getElementById('pb-speed').value);
  783. const res = await fetch('/api/test/start', {
  784. method: 'POST',
  785. headers: {'Content-Type': 'application/json'},
  786. body: JSON.stringify({ filename, speed }),
  787. });
  788. if (res.ok) {
  789. document.getElementById('pb-stop-btn').disabled = false;
  790. toast(`Playing at ${speed}×`);
  791. pbStartPoll();
  792. } else {
  793. const err = await res.json().catch(() => ({detail: 'Failed to start'}));
  794. toast(err.detail || 'Failed to start', true);
  795. }
  796. }
  797. async function pbStop() {
  798. const res = await fetch('/api/test/stop', { method: 'POST' });
  799. pbStopPoll();
  800. if (res.ok) toast('Playback stopped');
  801. setPbIdle();
  802. }
  803. async function pbDeleteFile(filename) {
  804. if (!confirm(`Delete "${filename}"?`)) return;
  805. await fetch(`/api/test/files/${encodeURIComponent(filename)}`, { method: 'DELETE' });
  806. toast('Deleted');
  807. await loadTestFiles();
  808. }
  809. function pbStartPoll() {
  810. pbStopPoll();
  811. pbPollTimer = setInterval(pbPoll, 1000);
  812. pbPoll();
  813. }
  814. function pbStopPoll() {
  815. if (pbPollTimer) { clearInterval(pbPollTimer); pbPollTimer = null; }
  816. }
  817. async function pbPoll() {
  818. let data;
  819. try {
  820. const res = await fetch('/api/test/status');
  821. data = await res.json();
  822. } catch { return; }
  823. const fill = document.getElementById('pb-fill');
  824. const statusEl = document.getElementById('pb-status-text');
  825. const timeEl = document.getElementById('pb-time-text');
  826. fill.style.width = (data.progress || 0) + '%';
  827. const elapsed = data.elapsed ? fmtTime(data.elapsed) : '';
  828. const duration = data.duration ? fmtTime(data.duration) : '';
  829. if (data.state === 'loading' || data.state === 'starting') {
  830. statusEl.className = '';
  831. statusEl.textContent = 'Loading file…';
  832. timeEl.textContent = '';
  833. } else if (data.state === 'playing') {
  834. statusEl.className = '';
  835. statusEl.textContent = `Playing: ${data.file}`;
  836. timeEl.textContent = elapsed && duration ? `${elapsed} / ${duration}` : '';
  837. } else if (data.state === 'done') {
  838. statusEl.className = '';
  839. statusEl.textContent = `Done: ${data.file}`;
  840. timeEl.textContent = duration;
  841. fill.style.width = '100%';
  842. pbStopPoll();
  843. document.getElementById('pb-stop-btn').disabled = true;
  844. } else if (data.state === 'error') {
  845. statusEl.className = 'pb-error';
  846. statusEl.textContent = `Error: ${data.error}`;
  847. timeEl.textContent = '';
  848. pbStopPoll();
  849. document.getElementById('pb-stop-btn').disabled = true;
  850. } else {
  851. pbStopPoll();
  852. setPbIdle();
  853. }
  854. }
  855. function setPbIdle() {
  856. document.getElementById('pb-stop-btn').disabled = true;
  857. document.getElementById('pb-fill').style.width = '0%';
  858. const statusEl = document.getElementById('pb-status-text');
  859. statusEl.className = '';
  860. statusEl.textContent = 'Idle';
  861. document.getElementById('pb-time-text').textContent = '';
  862. }
  863. function fmtTime(secs) {
  864. const s = Math.floor(secs);
  865. const m = Math.floor(s / 60);
  866. return `${m}:${String(s % 60).padStart(2, '0')}`;
  867. }
  868. // ── Keyboard shortcuts ────────────────────────────────────────────────────────
  869. document.addEventListener('keydown', e => {
  870. if (e.key === 'Escape') { closeAddModal(); closeUploadModal(); }
  871. if ((e.ctrlKey || e.metaKey) && e.key === 'f') {
  872. e.preventDefault();
  873. document.getElementById('search').focus();
  874. }
  875. });
  876. // ── Boot ──────────────────────────────────────────────────────────────────────
  877. load();
  878. loadTestFiles();
  879. </script>
  880. </body>
  881. </html>
  882. """
  883. @app.get("/", response_class=HTMLResponse)
  884. def index():
  885. return HTML
  886. # ── Entry point ───────────────────────────────────────────────────────────────
  887. if __name__ == "__main__":
  888. print("[Admin] Speaker admin running at http://localhost:8001")
  889. uvicorn.run(app, host="0.0.0.0", port=8001, log_level="warning")