admin.py 20 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575
  1. #!/usr/bin/env python3
  2. """
  3. admin.py — Speaker Admin Web Server
  4. Local web interface for managing speaker names and voice recordings.
  5. Runs on port 8001 alongside bridge.py.
  6. Access at: http://localhost:8001
  7. """
  8. import json
  9. import shutil
  10. from pathlib import Path
  11. from fastapi import FastAPI, HTTPException, UploadFile, File
  12. from fastapi.responses import HTMLResponse, FileResponse
  13. from pydantic import BaseModel
  14. import uvicorn
  15. SPEAKERS_FILE = Path(__file__).parent / "speakers.json"
  16. RECORDINGS_DIR = Path(__file__).parent / "recordings"
  17. RECORDINGS_DIR.mkdir(exist_ok=True)
  18. app = FastAPI(title="Speaker Admin")
  19. # ── Data helpers ──────────────────────────────────────────────────────────────
  20. def _load() -> dict[str, str]:
  21. if SPEAKERS_FILE.exists():
  22. try:
  23. return json.loads(SPEAKERS_FILE.read_text(encoding="utf-8"))
  24. except Exception:
  25. pass
  26. return {}
  27. def _save(data: dict[str, str]) -> None:
  28. SPEAKERS_FILE.write_text(
  29. json.dumps(data, indent=2, ensure_ascii=False), encoding="utf-8"
  30. )
  31. def _recording_path(sid: str) -> Path | None:
  32. for ext in (".wav", ".mp3", ".m4a", ".ogg", ".webm", ".flac"):
  33. p = RECORDINGS_DIR / f"{sid}{ext}"
  34. if p.exists():
  35. return p
  36. return None
  37. # ── API ───────────────────────────────────────────────────────────────────────
  38. class NameBody(BaseModel):
  39. name: str
  40. class AddBody(BaseModel):
  41. id: str
  42. name: str
  43. @app.get("/api/speakers")
  44. def api_list():
  45. speakers = _load()
  46. return {"speakers": [
  47. {"id": k, "name": v, "has_recording": _recording_path(k) is not None}
  48. for k, v in sorted(speakers.items())
  49. ]}
  50. @app.post("/api/speakers")
  51. def api_add(body: AddBody):
  52. speakers = _load()
  53. sid = body.id.strip()
  54. if not sid:
  55. raise HTTPException(400, "Speaker ID cannot be empty")
  56. if sid in speakers:
  57. raise HTTPException(400, f"'{sid}' already exists")
  58. speakers[sid] = body.name.strip()
  59. _save(speakers)
  60. return {"ok": True, "id": sid, "name": speakers[sid]}
  61. @app.put("/api/speakers/{sid}")
  62. def api_update(sid: str, body: NameBody):
  63. name = body.name.strip()
  64. if not name:
  65. raise HTTPException(400, "Name cannot be empty")
  66. speakers = _load()
  67. speakers[sid] = name
  68. _save(speakers)
  69. return {"ok": True}
  70. @app.delete("/api/speakers/{sid}")
  71. def api_delete(sid: str):
  72. speakers = _load()
  73. speakers.pop(sid, None)
  74. _save(speakers)
  75. rec = _recording_path(sid)
  76. if rec:
  77. rec.unlink()
  78. return {"ok": True}
  79. @app.post("/api/speakers/{sid}/recording")
  80. async def api_upload(sid: str, file: UploadFile = File(...)):
  81. suffix = Path(file.filename or "audio.wav").suffix.lower() or ".wav"
  82. rec = _recording_path(sid)
  83. if rec:
  84. rec.unlink()
  85. out = RECORDINGS_DIR / f"{sid}{suffix}"
  86. with out.open("wb") as f:
  87. shutil.copyfileobj(file.file, f)
  88. speakers = _load()
  89. if sid not in speakers:
  90. speakers[sid] = sid
  91. _save(speakers)
  92. size_kb = round(out.stat().st_size / 1024)
  93. return {"ok": True, "file": out.name, "kb": size_kb}
  94. @app.get("/api/speakers/{sid}/recording")
  95. def api_playback(sid: str):
  96. rec = _recording_path(sid)
  97. if not rec:
  98. raise HTTPException(404, "No recording found")
  99. return FileResponse(rec)
  100. # ── Web UI ────────────────────────────────────────────────────────────────────
  101. HTML = """<!DOCTYPE html>
  102. <html lang="en">
  103. <head>
  104. <meta charset="UTF-8">
  105. <meta name="viewport" content="width=device-width, initial-scale=1">
  106. <title>Speaker Admin</title>
  107. <style>
  108. *, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
  109. body { font-family: system-ui, sans-serif; background: #f0f4f8; color: #1e293b; }
  110. header {
  111. background: #1e3a5f; color: white; padding: 16px 24px;
  112. display: flex; align-items: center; gap: 16px;
  113. }
  114. header h1 { font-size: 1.2rem; font-weight: 600; flex: 1; }
  115. header small { opacity: .7; font-size: .8rem; }
  116. .toolbar {
  117. background: white; padding: 12px 24px;
  118. display: flex; gap: 12px; align-items: center;
  119. border-bottom: 1px solid #e2e8f0;
  120. }
  121. .toolbar input[type=search] {
  122. flex: 1; max-width: 340px; padding: 8px 12px;
  123. border: 1px solid #cbd5e1; border-radius: 6px; font-size: .95rem;
  124. }
  125. .count { color: #64748b; font-size: .9rem; margin-left: auto; }
  126. .btn {
  127. display: inline-flex; align-items: center; gap: 6px;
  128. padding: 8px 16px; border-radius: 6px; border: none;
  129. cursor: pointer; font-size: .9rem; font-weight: 500; transition: filter .15s;
  130. }
  131. .btn:hover { filter: brightness(.92); }
  132. .btn-primary { background: #2563eb; color: white; }
  133. .btn-danger { background: #dc2626; color: white; }
  134. .btn-ghost { background: #e2e8f0; color: #334155; }
  135. .btn-sm { padding: 4px 10px; font-size: .82rem; }
  136. table { width: 100%; border-collapse: collapse; background: white; }
  137. th {
  138. text-align: left; padding: 10px 16px; font-size: .8rem;
  139. font-weight: 600; text-transform: uppercase; letter-spacing: .05em;
  140. color: #64748b; background: #f8fafc; border-bottom: 1px solid #e2e8f0;
  141. }
  142. td { padding: 10px 16px; border-bottom: 1px solid #f1f5f9; vertical-align: middle; }
  143. tr:hover td { background: #f8fafc; }
  144. tr.hidden { display: none; }
  145. .sid { font-family: monospace; font-size: .85rem; color: #475569; }
  146. .name-cell { display: flex; align-items: center; gap: 8px; }
  147. .name-display { cursor: pointer; flex: 1; }
  148. .name-display:hover { text-decoration: underline; }
  149. .name-input {
  150. flex: 1; padding: 4px 8px; border: 1px solid #2563eb;
  151. border-radius: 4px; font-size: .95rem; outline: none;
  152. }
  153. .rec-badge {
  154. display: inline-flex; align-items: center; gap: 4px;
  155. font-size: .75rem; padding: 2px 8px; border-radius: 999px;
  156. font-weight: 500;
  157. }
  158. .rec-yes { background: #dcfce7; color: #166534; }
  159. .rec-no { background: #f1f5f9; color: #94a3b8; }
  160. .actions { display: flex; gap: 6px; }
  161. /* Modal */
  162. .modal-bg {
  163. display: none; position: fixed; inset: 0;
  164. background: rgba(0,0,0,.45); z-index: 100;
  165. align-items: center; justify-content: center;
  166. }
  167. .modal-bg.open { display: flex; }
  168. .modal {
  169. background: white; border-radius: 10px; padding: 28px;
  170. width: 420px; max-width: 95vw; box-shadow: 0 20px 60px rgba(0,0,0,.25);
  171. }
  172. .modal h2 { font-size: 1.1rem; margin-bottom: 16px; }
  173. .field { margin-bottom: 14px; }
  174. .field label { display: block; font-size: .85rem; font-weight: 500; margin-bottom: 4px; }
  175. .field input {
  176. width: 100%; padding: 8px 10px; border: 1px solid #cbd5e1;
  177. border-radius: 6px; font-size: .95rem;
  178. }
  179. .field input:focus { outline: 2px solid #2563eb; border-color: transparent; }
  180. .modal-actions { display: flex; gap: 8px; justify-content: flex-end; margin-top: 20px; }
  181. /* Upload */
  182. .upload-area {
  183. border: 2px dashed #cbd5e1; border-radius: 8px; padding: 16px;
  184. text-align: center; cursor: pointer; transition: border-color .2s;
  185. color: #64748b; font-size: .9rem;
  186. }
  187. .upload-area.drag { border-color: #2563eb; background: #eff6ff; }
  188. .upload-area input[type=file] { display: none; }
  189. /* Audio player */
  190. .audio-player { width: 180px; height: 32px; }
  191. /* Toast */
  192. #toast {
  193. position: fixed; bottom: 24px; right: 24px;
  194. background: #1e293b; color: white; padding: 10px 18px;
  195. border-radius: 8px; font-size: .9rem; transform: translateY(80px);
  196. transition: transform .25s; z-index: 200; pointer-events: none;
  197. }
  198. #toast.show { transform: translateY(0); }
  199. #toast.error { background: #dc2626; }
  200. .container { max-width: 1100px; margin: 0 auto; padding: 24px; }
  201. audio { vertical-align: middle; }
  202. </style>
  203. </head>
  204. <body>
  205. <header>
  206. <div>
  207. <h1>&#127908; Speaker Admin</h1>
  208. <small>Church Live Transcription — Speaker Name &amp; Voice Library</small>
  209. </div>
  210. </header>
  211. <div class="toolbar">
  212. <input type="search" id="search" placeholder="Search by ID or name…" oninput="filterTable()">
  213. <button class="btn btn-primary" onclick="openAddModal()">&#43; Add Speaker</button>
  214. <span class="count" id="count"></span>
  215. </div>
  216. <div class="container">
  217. <table id="table">
  218. <thead>
  219. <tr>
  220. <th>Speaker ID</th>
  221. <th>Friendly Name</th>
  222. <th>Voice Sample</th>
  223. <th>Actions</th>
  224. </tr>
  225. </thead>
  226. <tbody id="tbody"></tbody>
  227. </table>
  228. </div>
  229. <!-- Add modal -->
  230. <div class="modal-bg" id="add-modal" onclick="closeAddModal(event)">
  231. <div class="modal">
  232. <h2>Add Speaker</h2>
  233. <div class="field">
  234. <label>Speaker ID
  235. <span style="color:#64748b;font-weight:normal;font-size:.8rem">
  236. (e.g. SPEAKER_00, or any unique key)
  237. </span>
  238. </label>
  239. <input id="new-id" placeholder="SPEAKER_00">
  240. </div>
  241. <div class="field">
  242. <label>Friendly Name</label>
  243. <input id="new-name" placeholder="Pastor John">
  244. </div>
  245. <div class="modal-actions">
  246. <button class="btn btn-ghost" onclick="closeAddModal()">Cancel</button>
  247. <button class="btn btn-primary" onclick="addSpeaker()">Add</button>
  248. </div>
  249. </div>
  250. </div>
  251. <!-- Upload modal -->
  252. <div class="modal-bg" id="upload-modal" onclick="closeUploadModal(event)">
  253. <div class="modal">
  254. <h2 id="upload-title">Upload Voice Sample</h2>
  255. <p style="color:#64748b;font-size:.85rem;margin-bottom:12px">
  256. Upload a 10–60 second clear speech recording.<br>
  257. Supported: WAV, MP3, M4A, OGG, FLAC, WebM
  258. </p>
  259. <div class="upload-area" id="drop-zone"
  260. ondragover="onDragOver(event)" ondragleave="onDragLeave(event)"
  261. ondrop="onDrop(event)" onclick="document.getElementById('file-input').click()">
  262. <input type="file" id="file-input" accept="audio/*" onchange="uploadFile(this.files[0])">
  263. <div>&#127926; Drag &amp; drop audio here, or click to browse</div>
  264. </div>
  265. <div id="upload-status" style="margin-top:10px;font-size:.85rem;color:#166534"></div>
  266. <div class="modal-actions">
  267. <button class="btn btn-ghost" onclick="closeUploadModal()">Close</button>
  268. </div>
  269. </div>
  270. </div>
  271. <div id="toast"></div>
  272. <script>
  273. let speakers = [];
  274. let uploadTarget = null;
  275. // ── Load & render ─────────────────────────────────────────────────────────────
  276. async function load() {
  277. const res = await fetch('/api/speakers');
  278. const data = await res.json();
  279. speakers = data.speakers;
  280. render();
  281. }
  282. function render() {
  283. const tbody = document.getElementById('tbody');
  284. tbody.innerHTML = '';
  285. speakers.forEach(s => tbody.appendChild(makeRow(s)));
  286. document.getElementById('count').textContent = `${speakers.length} speaker${speakers.length !== 1 ? 's' : ''}`;
  287. filterTable();
  288. }
  289. function makeRow(s) {
  290. const tr = document.createElement('tr');
  291. tr.dataset.id = s.id;
  292. tr.dataset.name = s.name.toLowerCase();
  293. const recHtml = s.has_recording
  294. ? `<span class="rec-badge rec-yes">&#9654; Recorded</span>
  295. <audio class="audio-player" controls preload="none"
  296. src="/api/speakers/${encodeURIComponent(s.id)}/recording"></audio>`
  297. : `<span class="rec-badge rec-no">No sample</span>`;
  298. tr.innerHTML = `
  299. <td class="sid">${esc(s.id)}</td>
  300. <td>
  301. <div class="name-cell">
  302. <span class="name-display" onclick="startEdit(this, '${esc(s.id)}')"
  303. title="Click to edit">${esc(s.name)}</span>
  304. <input class="name-input" style="display:none"
  305. onblur="saveEdit(this,'${esc(s.id)}')"
  306. onkeydown="nameKeydown(event,this,'${esc(s.id)}')">
  307. </div>
  308. </td>
  309. <td>${recHtml}</td>
  310. <td>
  311. <div class="actions">
  312. <button class="btn btn-ghost btn-sm"
  313. onclick="openUploadModal('${esc(s.id)}', '${esc(s.name)}')">
  314. &#127926; ${s.has_recording ? 'Replace' : 'Upload'}
  315. </button>
  316. <button class="btn btn-danger btn-sm"
  317. onclick="deleteSpeaker('${esc(s.id)}')">&#128465;</button>
  318. </div>
  319. </td>`;
  320. return tr;
  321. }
  322. function esc(str) {
  323. return str.replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;')
  324. .replace(/"/g,'&quot;').replace(/'/g,'&#39;');
  325. }
  326. // ── Search ────────────────────────────────────────────────────────────────────
  327. function filterTable() {
  328. const q = document.getElementById('search').value.toLowerCase().trim();
  329. let visible = 0;
  330. document.querySelectorAll('#tbody tr').forEach(tr => {
  331. const match = !q || tr.dataset.id.includes(q) || tr.dataset.name.includes(q);
  332. tr.classList.toggle('hidden', !match);
  333. if (match) visible++;
  334. });
  335. document.getElementById('count').textContent =
  336. q ? `${visible} of ${speakers.length} speakers` : `${speakers.length} speakers`;
  337. }
  338. // ── Inline edit ───────────────────────────────────────────────────────────────
  339. function startEdit(span, id) {
  340. const input = span.nextElementSibling;
  341. input.value = span.textContent;
  342. span.style.display = 'none';
  343. input.style.display = '';
  344. input.focus();
  345. input.select();
  346. }
  347. function nameKeydown(e, input, id) {
  348. if (e.key === 'Enter') { input.blur(); }
  349. if (e.key === 'Escape') { cancelEdit(input); }
  350. }
  351. function cancelEdit(input) {
  352. const span = input.previousElementSibling;
  353. input.style.display = 'none';
  354. span.style.display = '';
  355. }
  356. async function saveEdit(input, id) {
  357. const name = input.value.trim();
  358. const span = input.previousElementSibling;
  359. if (!name || name === span.textContent) { cancelEdit(input); return; }
  360. const res = await fetch(`/api/speakers/${encodeURIComponent(id)}`, {
  361. method: 'PUT',
  362. headers: {'Content-Type': 'application/json'},
  363. body: JSON.stringify({name})
  364. });
  365. if (res.ok) {
  366. span.textContent = name;
  367. const tr = input.closest('tr');
  368. tr.dataset.name = name.toLowerCase();
  369. toast('Saved');
  370. } else {
  371. toast('Save failed', true);
  372. }
  373. cancelEdit(input);
  374. }
  375. // ── Add speaker ───────────────────────────────────────────────────────────────
  376. function openAddModal() {
  377. const nums = speakers
  378. .filter(s => /^SPEAKER_\d+$/.test(s.id))
  379. .map(s => parseInt(s.id.split('_')[1]));
  380. const next = nums.length ? Math.max(...nums) + 1 : 0;
  381. document.getElementById('new-id').value = `SPEAKER_${String(next).padStart(2,'0')}`;
  382. document.getElementById('new-name').value = '';
  383. document.getElementById('add-modal').classList.add('open');
  384. setTimeout(() => document.getElementById('new-name').focus(), 50);
  385. }
  386. function closeAddModal(e) {
  387. if (!e || e.target === document.getElementById('add-modal'))
  388. document.getElementById('add-modal').classList.remove('open');
  389. }
  390. async function addSpeaker() {
  391. const id = document.getElementById('new-id').value.trim();
  392. const name = document.getElementById('new-name').value.trim();
  393. if (!id || !name) { toast('ID and name are required', true); return; }
  394. const res = await fetch('/api/speakers', {
  395. method: 'POST',
  396. headers: {'Content-Type': 'application/json'},
  397. body: JSON.stringify({id, name})
  398. });
  399. if (res.ok) {
  400. closeAddModal();
  401. toast(`Added ${name}`);
  402. await load();
  403. } else {
  404. const err = await res.json().catch(() => ({detail:'Error'}));
  405. toast(err.detail || 'Failed', true);
  406. }
  407. }
  408. // ── Delete ────────────────────────────────────────────────────────────────────
  409. async function deleteSpeaker(id) {
  410. const s = speakers.find(x => x.id === id);
  411. if (!confirm(`Remove "${s?.name || id}" from the speaker list?`)) return;
  412. const res = await fetch(`/api/speakers/${encodeURIComponent(id)}`, {method:'DELETE'});
  413. if (res.ok) { toast('Removed'); await load(); }
  414. else { toast('Delete failed', true); }
  415. }
  416. // ── Upload modal ──────────────────────────────────────────────────────────────
  417. function openUploadModal(id, name) {
  418. uploadTarget = id;
  419. document.getElementById('upload-title').textContent = `Voice Sample — ${name}`;
  420. document.getElementById('upload-status').textContent = '';
  421. document.getElementById('file-input').value = '';
  422. document.getElementById('upload-modal').classList.add('open');
  423. }
  424. function closeUploadModal(e) {
  425. if (!e || e.target === document.getElementById('upload-modal')) {
  426. document.getElementById('upload-modal').classList.remove('open');
  427. uploadTarget = null;
  428. load();
  429. }
  430. }
  431. function onDragOver(e) { e.preventDefault(); document.getElementById('drop-zone').classList.add('drag'); }
  432. function onDragLeave() { document.getElementById('drop-zone').classList.remove('drag'); }
  433. function onDrop(e) { e.preventDefault(); onDragLeave(); uploadFile(e.dataTransfer.files[0]); }
  434. async function uploadFile(file) {
  435. if (!file || !uploadTarget) return;
  436. const status = document.getElementById('upload-status');
  437. status.style.color = '#2563eb';
  438. status.textContent = `Uploading ${file.name} (${Math.round(file.size/1024)} KB)…`;
  439. const form = new FormData();
  440. form.append('file', file);
  441. const res = await fetch(`/api/speakers/${encodeURIComponent(uploadTarget)}/recording`, {
  442. method: 'POST', body: form
  443. });
  444. if (res.ok) {
  445. const data = await res.json();
  446. status.style.color = '#166534';
  447. status.textContent = `✓ Saved — ${data.file} (${data.kb} KB)`;
  448. toast('Recording saved');
  449. } else {
  450. status.style.color = '#dc2626';
  451. status.textContent = 'Upload failed';
  452. toast('Upload failed', true);
  453. }
  454. }
  455. // ── Toast ─────────────────────────────────────────────────────────────────────
  456. let toastTimer;
  457. function toast(msg, error = false) {
  458. const el = document.getElementById('toast');
  459. el.textContent = msg;
  460. el.className = 'show' + (error ? ' error' : '');
  461. clearTimeout(toastTimer);
  462. toastTimer = setTimeout(() => el.className = '', 2500);
  463. }
  464. // ── Keyboard shortcuts ────────────────────────────────────────────────────────
  465. document.addEventListener('keydown', e => {
  466. if (e.key === 'Escape') {
  467. closeAddModal();
  468. closeUploadModal();
  469. }
  470. if ((e.ctrlKey || e.metaKey) && e.key === 'f') {
  471. e.preventDefault();
  472. document.getElementById('search').focus();
  473. }
  474. });
  475. // ── Boot ──────────────────────────────────────────────────────────────────────
  476. load();
  477. </script>
  478. </body>
  479. </html>
  480. """
  481. @app.get("/", response_class=HTMLResponse)
  482. def index():
  483. return HTML
  484. # ── Entry point ───────────────────────────────────────────────────────────────
  485. if __name__ == "__main__":
  486. print("[Admin] Speaker admin running at http://localhost:8001")
  487. uvicorn.run(app, host="0.0.0.0", port=8001, log_level="warning")