|
@@ -0,0 +1,575 @@
|
|
|
|
|
+#!/usr/bin/env python3
|
|
|
|
|
+"""
|
|
|
|
|
+admin.py — Speaker Admin Web Server
|
|
|
|
|
+
|
|
|
|
|
+Local web interface for managing speaker names and voice recordings.
|
|
|
|
|
+Runs on port 8001 alongside bridge.py.
|
|
|
|
|
+
|
|
|
|
|
+Access at: http://localhost:8001
|
|
|
|
|
+"""
|
|
|
|
|
+
|
|
|
|
|
+import json
|
|
|
|
|
+import shutil
|
|
|
|
|
+from pathlib import Path
|
|
|
|
|
+
|
|
|
|
|
+from fastapi import FastAPI, HTTPException, UploadFile, File
|
|
|
|
|
+from fastapi.responses import HTMLResponse, FileResponse
|
|
|
|
|
+from pydantic import BaseModel
|
|
|
|
|
+import uvicorn
|
|
|
|
|
+
|
|
|
|
|
+SPEAKERS_FILE = Path(__file__).parent / "speakers.json"
|
|
|
|
|
+RECORDINGS_DIR = Path(__file__).parent / "recordings"
|
|
|
|
|
+RECORDINGS_DIR.mkdir(exist_ok=True)
|
|
|
|
|
+
|
|
|
|
|
+app = FastAPI(title="Speaker Admin")
|
|
|
|
|
+
|
|
|
|
|
+# ── Data helpers ──────────────────────────────────────────────────────────────
|
|
|
|
|
+
|
|
|
|
|
+def _load() -> dict[str, str]:
|
|
|
|
|
+ if SPEAKERS_FILE.exists():
|
|
|
|
|
+ try:
|
|
|
|
|
+ return json.loads(SPEAKERS_FILE.read_text(encoding="utf-8"))
|
|
|
|
|
+ except Exception:
|
|
|
|
|
+ pass
|
|
|
|
|
+ return {}
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+def _save(data: dict[str, str]) -> None:
|
|
|
|
|
+ SPEAKERS_FILE.write_text(
|
|
|
|
|
+ json.dumps(data, indent=2, ensure_ascii=False), encoding="utf-8"
|
|
|
|
|
+ )
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+def _recording_path(sid: str) -> Path | None:
|
|
|
|
|
+ for ext in (".wav", ".mp3", ".m4a", ".ogg", ".webm", ".flac"):
|
|
|
|
|
+ p = RECORDINGS_DIR / f"{sid}{ext}"
|
|
|
|
|
+ if p.exists():
|
|
|
|
|
+ return p
|
|
|
|
|
+ return None
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+# ── API ───────────────────────────────────────────────────────────────────────
|
|
|
|
|
+
|
|
|
|
|
+class NameBody(BaseModel):
|
|
|
|
|
+ name: str
|
|
|
|
|
+
|
|
|
|
|
+class AddBody(BaseModel):
|
|
|
|
|
+ id: str
|
|
|
|
|
+ name: str
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+@app.get("/api/speakers")
|
|
|
|
|
+def api_list():
|
|
|
|
|
+ speakers = _load()
|
|
|
|
|
+ return {"speakers": [
|
|
|
|
|
+ {"id": k, "name": v, "has_recording": _recording_path(k) is not None}
|
|
|
|
|
+ for k, v in sorted(speakers.items())
|
|
|
|
|
+ ]}
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+@app.post("/api/speakers")
|
|
|
|
|
+def api_add(body: AddBody):
|
|
|
|
|
+ speakers = _load()
|
|
|
|
|
+ sid = body.id.strip()
|
|
|
|
|
+ if not sid:
|
|
|
|
|
+ raise HTTPException(400, "Speaker ID cannot be empty")
|
|
|
|
|
+ if sid in speakers:
|
|
|
|
|
+ raise HTTPException(400, f"'{sid}' already exists")
|
|
|
|
|
+ speakers[sid] = body.name.strip()
|
|
|
|
|
+ _save(speakers)
|
|
|
|
|
+ return {"ok": True, "id": sid, "name": speakers[sid]}
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+@app.put("/api/speakers/{sid}")
|
|
|
|
|
+def api_update(sid: str, body: NameBody):
|
|
|
|
|
+ name = body.name.strip()
|
|
|
|
|
+ if not name:
|
|
|
|
|
+ raise HTTPException(400, "Name cannot be empty")
|
|
|
|
|
+ speakers = _load()
|
|
|
|
|
+ speakers[sid] = name
|
|
|
|
|
+ _save(speakers)
|
|
|
|
|
+ return {"ok": True}
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+@app.delete("/api/speakers/{sid}")
|
|
|
|
|
+def api_delete(sid: str):
|
|
|
|
|
+ speakers = _load()
|
|
|
|
|
+ speakers.pop(sid, None)
|
|
|
|
|
+ _save(speakers)
|
|
|
|
|
+ rec = _recording_path(sid)
|
|
|
|
|
+ if rec:
|
|
|
|
|
+ rec.unlink()
|
|
|
|
|
+ return {"ok": True}
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+@app.post("/api/speakers/{sid}/recording")
|
|
|
|
|
+async def api_upload(sid: str, file: UploadFile = File(...)):
|
|
|
|
|
+ suffix = Path(file.filename or "audio.wav").suffix.lower() or ".wav"
|
|
|
|
|
+ rec = _recording_path(sid)
|
|
|
|
|
+ if rec:
|
|
|
|
|
+ rec.unlink()
|
|
|
|
|
+ out = RECORDINGS_DIR / f"{sid}{suffix}"
|
|
|
|
|
+ with out.open("wb") as f:
|
|
|
|
|
+ shutil.copyfileobj(file.file, f)
|
|
|
|
|
+ speakers = _load()
|
|
|
|
|
+ if sid not in speakers:
|
|
|
|
|
+ speakers[sid] = sid
|
|
|
|
|
+ _save(speakers)
|
|
|
|
|
+ size_kb = round(out.stat().st_size / 1024)
|
|
|
|
|
+ return {"ok": True, "file": out.name, "kb": size_kb}
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+@app.get("/api/speakers/{sid}/recording")
|
|
|
|
|
+def api_playback(sid: str):
|
|
|
|
|
+ rec = _recording_path(sid)
|
|
|
|
|
+ if not rec:
|
|
|
|
|
+ raise HTTPException(404, "No recording found")
|
|
|
|
|
+ return FileResponse(rec)
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+# ── Web UI ────────────────────────────────────────────────────────────────────
|
|
|
|
|
+
|
|
|
|
|
+HTML = """<!DOCTYPE html>
|
|
|
|
|
+<html lang="en">
|
|
|
|
|
+<head>
|
|
|
|
|
+<meta charset="UTF-8">
|
|
|
|
|
+<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
|
|
|
+<title>Speaker Admin</title>
|
|
|
|
|
+<style>
|
|
|
|
|
+ *, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
|
|
|
|
+
|
|
|
|
|
+ body { font-family: system-ui, sans-serif; background: #f0f4f8; color: #1e293b; }
|
|
|
|
|
+
|
|
|
|
|
+ header {
|
|
|
|
|
+ background: #1e3a5f; color: white; padding: 16px 24px;
|
|
|
|
|
+ display: flex; align-items: center; gap: 16px;
|
|
|
|
|
+ }
|
|
|
|
|
+ header h1 { font-size: 1.2rem; font-weight: 600; flex: 1; }
|
|
|
|
|
+ header small { opacity: .7; font-size: .8rem; }
|
|
|
|
|
+
|
|
|
|
|
+ .toolbar {
|
|
|
|
|
+ background: white; padding: 12px 24px;
|
|
|
|
|
+ display: flex; gap: 12px; align-items: center;
|
|
|
|
|
+ border-bottom: 1px solid #e2e8f0;
|
|
|
|
|
+ }
|
|
|
|
|
+ .toolbar input[type=search] {
|
|
|
|
|
+ flex: 1; max-width: 340px; padding: 8px 12px;
|
|
|
|
|
+ border: 1px solid #cbd5e1; border-radius: 6px; font-size: .95rem;
|
|
|
|
|
+ }
|
|
|
|
|
+ .count { color: #64748b; font-size: .9rem; margin-left: auto; }
|
|
|
|
|
+
|
|
|
|
|
+ .btn {
|
|
|
|
|
+ display: inline-flex; align-items: center; gap: 6px;
|
|
|
|
|
+ padding: 8px 16px; border-radius: 6px; border: none;
|
|
|
|
|
+ cursor: pointer; font-size: .9rem; font-weight: 500; transition: filter .15s;
|
|
|
|
|
+ }
|
|
|
|
|
+ .btn:hover { filter: brightness(.92); }
|
|
|
|
|
+ .btn-primary { background: #2563eb; color: white; }
|
|
|
|
|
+ .btn-danger { background: #dc2626; color: white; }
|
|
|
|
|
+ .btn-ghost { background: #e2e8f0; color: #334155; }
|
|
|
|
|
+ .btn-sm { padding: 4px 10px; font-size: .82rem; }
|
|
|
|
|
+
|
|
|
|
|
+ table { width: 100%; border-collapse: collapse; background: white; }
|
|
|
|
|
+ th {
|
|
|
|
|
+ text-align: left; padding: 10px 16px; font-size: .8rem;
|
|
|
|
|
+ font-weight: 600; text-transform: uppercase; letter-spacing: .05em;
|
|
|
|
|
+ color: #64748b; background: #f8fafc; border-bottom: 1px solid #e2e8f0;
|
|
|
|
|
+ }
|
|
|
|
|
+ td { padding: 10px 16px; border-bottom: 1px solid #f1f5f9; vertical-align: middle; }
|
|
|
|
|
+ tr:hover td { background: #f8fafc; }
|
|
|
|
|
+ tr.hidden { display: none; }
|
|
|
|
|
+
|
|
|
|
|
+ .sid { font-family: monospace; font-size: .85rem; color: #475569; }
|
|
|
|
|
+
|
|
|
|
|
+ .name-cell { display: flex; align-items: center; gap: 8px; }
|
|
|
|
|
+ .name-display { cursor: pointer; flex: 1; }
|
|
|
|
|
+ .name-display:hover { text-decoration: underline; }
|
|
|
|
|
+ .name-input {
|
|
|
|
|
+ flex: 1; padding: 4px 8px; border: 1px solid #2563eb;
|
|
|
|
|
+ border-radius: 4px; font-size: .95rem; outline: none;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ .rec-badge {
|
|
|
|
|
+ display: inline-flex; align-items: center; gap: 4px;
|
|
|
|
|
+ font-size: .75rem; padding: 2px 8px; border-radius: 999px;
|
|
|
|
|
+ font-weight: 500;
|
|
|
|
|
+ }
|
|
|
|
|
+ .rec-yes { background: #dcfce7; color: #166534; }
|
|
|
|
|
+ .rec-no { background: #f1f5f9; color: #94a3b8; }
|
|
|
|
|
+
|
|
|
|
|
+ .actions { display: flex; gap: 6px; }
|
|
|
|
|
+
|
|
|
|
|
+ /* Modal */
|
|
|
|
|
+ .modal-bg {
|
|
|
|
|
+ display: none; position: fixed; inset: 0;
|
|
|
|
|
+ background: rgba(0,0,0,.45); z-index: 100;
|
|
|
|
|
+ align-items: center; justify-content: center;
|
|
|
|
|
+ }
|
|
|
|
|
+ .modal-bg.open { display: flex; }
|
|
|
|
|
+ .modal {
|
|
|
|
|
+ background: white; border-radius: 10px; padding: 28px;
|
|
|
|
|
+ width: 420px; max-width: 95vw; box-shadow: 0 20px 60px rgba(0,0,0,.25);
|
|
|
|
|
+ }
|
|
|
|
|
+ .modal h2 { font-size: 1.1rem; margin-bottom: 16px; }
|
|
|
|
|
+ .field { margin-bottom: 14px; }
|
|
|
|
|
+ .field label { display: block; font-size: .85rem; font-weight: 500; margin-bottom: 4px; }
|
|
|
|
|
+ .field input {
|
|
|
|
|
+ width: 100%; padding: 8px 10px; border: 1px solid #cbd5e1;
|
|
|
|
|
+ border-radius: 6px; font-size: .95rem;
|
|
|
|
|
+ }
|
|
|
|
|
+ .field input:focus { outline: 2px solid #2563eb; border-color: transparent; }
|
|
|
|
|
+ .modal-actions { display: flex; gap: 8px; justify-content: flex-end; margin-top: 20px; }
|
|
|
|
|
+
|
|
|
|
|
+ /* Upload */
|
|
|
|
|
+ .upload-area {
|
|
|
|
|
+ border: 2px dashed #cbd5e1; border-radius: 8px; padding: 16px;
|
|
|
|
|
+ text-align: center; cursor: pointer; transition: border-color .2s;
|
|
|
|
|
+ color: #64748b; font-size: .9rem;
|
|
|
|
|
+ }
|
|
|
|
|
+ .upload-area.drag { border-color: #2563eb; background: #eff6ff; }
|
|
|
|
|
+ .upload-area input[type=file] { display: none; }
|
|
|
|
|
+
|
|
|
|
|
+ /* Audio player */
|
|
|
|
|
+ .audio-player { width: 180px; height: 32px; }
|
|
|
|
|
+
|
|
|
|
|
+ /* Toast */
|
|
|
|
|
+ #toast {
|
|
|
|
|
+ position: fixed; bottom: 24px; right: 24px;
|
|
|
|
|
+ background: #1e293b; color: white; padding: 10px 18px;
|
|
|
|
|
+ border-radius: 8px; font-size: .9rem; transform: translateY(80px);
|
|
|
|
|
+ transition: transform .25s; z-index: 200; pointer-events: none;
|
|
|
|
|
+ }
|
|
|
|
|
+ #toast.show { transform: translateY(0); }
|
|
|
|
|
+ #toast.error { background: #dc2626; }
|
|
|
|
|
+
|
|
|
|
|
+ .container { max-width: 1100px; margin: 0 auto; padding: 24px; }
|
|
|
|
|
+
|
|
|
|
|
+ audio { vertical-align: middle; }
|
|
|
|
|
+</style>
|
|
|
|
|
+</head>
|
|
|
|
|
+<body>
|
|
|
|
|
+
|
|
|
|
|
+<header>
|
|
|
|
|
+ <div>
|
|
|
|
|
+ <h1>🎤 Speaker Admin</h1>
|
|
|
|
|
+ <small>Church Live Transcription — Speaker Name & Voice Library</small>
|
|
|
|
|
+ </div>
|
|
|
|
|
+</header>
|
|
|
|
|
+
|
|
|
|
|
+<div class="toolbar">
|
|
|
|
|
+ <input type="search" id="search" placeholder="Search by ID or name…" oninput="filterTable()">
|
|
|
|
|
+ <button class="btn btn-primary" onclick="openAddModal()">+ Add Speaker</button>
|
|
|
|
|
+ <span class="count" id="count"></span>
|
|
|
|
|
+</div>
|
|
|
|
|
+
|
|
|
|
|
+<div class="container">
|
|
|
|
|
+ <table id="table">
|
|
|
|
|
+ <thead>
|
|
|
|
|
+ <tr>
|
|
|
|
|
+ <th>Speaker ID</th>
|
|
|
|
|
+ <th>Friendly Name</th>
|
|
|
|
|
+ <th>Voice Sample</th>
|
|
|
|
|
+ <th>Actions</th>
|
|
|
|
|
+ </tr>
|
|
|
|
|
+ </thead>
|
|
|
|
|
+ <tbody id="tbody"></tbody>
|
|
|
|
|
+ </table>
|
|
|
|
|
+</div>
|
|
|
|
|
+
|
|
|
|
|
+<!-- Add modal -->
|
|
|
|
|
+<div class="modal-bg" id="add-modal" onclick="closeAddModal(event)">
|
|
|
|
|
+ <div class="modal">
|
|
|
|
|
+ <h2>Add Speaker</h2>
|
|
|
|
|
+ <div class="field">
|
|
|
|
|
+ <label>Speaker ID
|
|
|
|
|
+ <span style="color:#64748b;font-weight:normal;font-size:.8rem">
|
|
|
|
|
+ (e.g. SPEAKER_00, or any unique key)
|
|
|
|
|
+ </span>
|
|
|
|
|
+ </label>
|
|
|
|
|
+ <input id="new-id" placeholder="SPEAKER_00">
|
|
|
|
|
+ </div>
|
|
|
|
|
+ <div class="field">
|
|
|
|
|
+ <label>Friendly Name</label>
|
|
|
|
|
+ <input id="new-name" placeholder="Pastor John">
|
|
|
|
|
+ </div>
|
|
|
|
|
+ <div class="modal-actions">
|
|
|
|
|
+ <button class="btn btn-ghost" onclick="closeAddModal()">Cancel</button>
|
|
|
|
|
+ <button class="btn btn-primary" onclick="addSpeaker()">Add</button>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ </div>
|
|
|
|
|
+</div>
|
|
|
|
|
+
|
|
|
|
|
+<!-- Upload modal -->
|
|
|
|
|
+<div class="modal-bg" id="upload-modal" onclick="closeUploadModal(event)">
|
|
|
|
|
+ <div class="modal">
|
|
|
|
|
+ <h2 id="upload-title">Upload Voice Sample</h2>
|
|
|
|
|
+ <p style="color:#64748b;font-size:.85rem;margin-bottom:12px">
|
|
|
|
|
+ Upload a 10–60 second clear speech recording.<br>
|
|
|
|
|
+ Supported: WAV, MP3, M4A, OGG, FLAC, WebM
|
|
|
|
|
+ </p>
|
|
|
|
|
+ <div class="upload-area" id="drop-zone"
|
|
|
|
|
+ ondragover="onDragOver(event)" ondragleave="onDragLeave(event)"
|
|
|
|
|
+ ondrop="onDrop(event)" onclick="document.getElementById('file-input').click()">
|
|
|
|
|
+ <input type="file" id="file-input" accept="audio/*" onchange="uploadFile(this.files[0])">
|
|
|
|
|
+ <div>🎶 Drag & drop audio here, or click to browse</div>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ <div id="upload-status" style="margin-top:10px;font-size:.85rem;color:#166534"></div>
|
|
|
|
|
+ <div class="modal-actions">
|
|
|
|
|
+ <button class="btn btn-ghost" onclick="closeUploadModal()">Close</button>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ </div>
|
|
|
|
|
+</div>
|
|
|
|
|
+
|
|
|
|
|
+<div id="toast"></div>
|
|
|
|
|
+
|
|
|
|
|
+<script>
|
|
|
|
|
+let speakers = [];
|
|
|
|
|
+let uploadTarget = null;
|
|
|
|
|
+
|
|
|
|
|
+// ── Load & render ─────────────────────────────────────────────────────────────
|
|
|
|
|
+
|
|
|
|
|
+async function load() {
|
|
|
|
|
+ const res = await fetch('/api/speakers');
|
|
|
|
|
+ const data = await res.json();
|
|
|
|
|
+ speakers = data.speakers;
|
|
|
|
|
+ render();
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+function render() {
|
|
|
|
|
+ const tbody = document.getElementById('tbody');
|
|
|
|
|
+ tbody.innerHTML = '';
|
|
|
|
|
+ speakers.forEach(s => tbody.appendChild(makeRow(s)));
|
|
|
|
|
+ document.getElementById('count').textContent = `${speakers.length} speaker${speakers.length !== 1 ? 's' : ''}`;
|
|
|
|
|
+ filterTable();
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+function makeRow(s) {
|
|
|
|
|
+ const tr = document.createElement('tr');
|
|
|
|
|
+ tr.dataset.id = s.id;
|
|
|
|
|
+ tr.dataset.name = s.name.toLowerCase();
|
|
|
|
|
+
|
|
|
|
|
+ const recHtml = s.has_recording
|
|
|
|
|
+ ? `<span class="rec-badge rec-yes">▶ Recorded</span>
|
|
|
|
|
+ <audio class="audio-player" controls preload="none"
|
|
|
|
|
+ src="/api/speakers/${encodeURIComponent(s.id)}/recording"></audio>`
|
|
|
|
|
+ : `<span class="rec-badge rec-no">No sample</span>`;
|
|
|
|
|
+
|
|
|
|
|
+ tr.innerHTML = `
|
|
|
|
|
+ <td class="sid">${esc(s.id)}</td>
|
|
|
|
|
+ <td>
|
|
|
|
|
+ <div class="name-cell">
|
|
|
|
|
+ <span class="name-display" onclick="startEdit(this, '${esc(s.id)}')"
|
|
|
|
|
+ title="Click to edit">${esc(s.name)}</span>
|
|
|
|
|
+ <input class="name-input" style="display:none"
|
|
|
|
|
+ onblur="saveEdit(this,'${esc(s.id)}')"
|
|
|
|
|
+ onkeydown="nameKeydown(event,this,'${esc(s.id)}')">
|
|
|
|
|
+ </div>
|
|
|
|
|
+ </td>
|
|
|
|
|
+ <td>${recHtml}</td>
|
|
|
|
|
+ <td>
|
|
|
|
|
+ <div class="actions">
|
|
|
|
|
+ <button class="btn btn-ghost btn-sm"
|
|
|
|
|
+ onclick="openUploadModal('${esc(s.id)}', '${esc(s.name)}')">
|
|
|
|
|
+ 🎶 ${s.has_recording ? 'Replace' : 'Upload'}
|
|
|
|
|
+ </button>
|
|
|
|
|
+ <button class="btn btn-danger btn-sm"
|
|
|
|
|
+ onclick="deleteSpeaker('${esc(s.id)}')">🗑</button>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ </td>`;
|
|
|
|
|
+ return tr;
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+function esc(str) {
|
|
|
|
|
+ return str.replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>')
|
|
|
|
|
+ .replace(/"/g,'"').replace(/'/g,''');
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+// ── Search ────────────────────────────────────────────────────────────────────
|
|
|
|
|
+
|
|
|
|
|
+function filterTable() {
|
|
|
|
|
+ const q = document.getElementById('search').value.toLowerCase().trim();
|
|
|
|
|
+ let visible = 0;
|
|
|
|
|
+ document.querySelectorAll('#tbody tr').forEach(tr => {
|
|
|
|
|
+ const match = !q || tr.dataset.id.includes(q) || tr.dataset.name.includes(q);
|
|
|
|
|
+ tr.classList.toggle('hidden', !match);
|
|
|
|
|
+ if (match) visible++;
|
|
|
|
|
+ });
|
|
|
|
|
+ document.getElementById('count').textContent =
|
|
|
|
|
+ q ? `${visible} of ${speakers.length} speakers` : `${speakers.length} speakers`;
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+// ── Inline edit ───────────────────────────────────────────────────────────────
|
|
|
|
|
+
|
|
|
|
|
+function startEdit(span, id) {
|
|
|
|
|
+ const input = span.nextElementSibling;
|
|
|
|
|
+ input.value = span.textContent;
|
|
|
|
|
+ span.style.display = 'none';
|
|
|
|
|
+ input.style.display = '';
|
|
|
|
|
+ input.focus();
|
|
|
|
|
+ input.select();
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+function nameKeydown(e, input, id) {
|
|
|
|
|
+ if (e.key === 'Enter') { input.blur(); }
|
|
|
|
|
+ if (e.key === 'Escape') { cancelEdit(input); }
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+function cancelEdit(input) {
|
|
|
|
|
+ const span = input.previousElementSibling;
|
|
|
|
|
+ input.style.display = 'none';
|
|
|
|
|
+ span.style.display = '';
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+async function saveEdit(input, id) {
|
|
|
|
|
+ const name = input.value.trim();
|
|
|
|
|
+ const span = input.previousElementSibling;
|
|
|
|
|
+ if (!name || name === span.textContent) { cancelEdit(input); return; }
|
|
|
|
|
+ const res = await fetch(`/api/speakers/${encodeURIComponent(id)}`, {
|
|
|
|
|
+ method: 'PUT',
|
|
|
|
|
+ headers: {'Content-Type': 'application/json'},
|
|
|
|
|
+ body: JSON.stringify({name})
|
|
|
|
|
+ });
|
|
|
|
|
+ if (res.ok) {
|
|
|
|
|
+ span.textContent = name;
|
|
|
|
|
+ const tr = input.closest('tr');
|
|
|
|
|
+ tr.dataset.name = name.toLowerCase();
|
|
|
|
|
+ toast('Saved');
|
|
|
|
|
+ } else {
|
|
|
|
|
+ toast('Save failed', true);
|
|
|
|
|
+ }
|
|
|
|
|
+ cancelEdit(input);
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+// ── Add speaker ───────────────────────────────────────────────────────────────
|
|
|
|
|
+
|
|
|
|
|
+function openAddModal() {
|
|
|
|
|
+ const nums = speakers
|
|
|
|
|
+ .filter(s => /^SPEAKER_\d+$/.test(s.id))
|
|
|
|
|
+ .map(s => parseInt(s.id.split('_')[1]));
|
|
|
|
|
+ const next = nums.length ? Math.max(...nums) + 1 : 0;
|
|
|
|
|
+ document.getElementById('new-id').value = `SPEAKER_${String(next).padStart(2,'0')}`;
|
|
|
|
|
+ document.getElementById('new-name').value = '';
|
|
|
|
|
+ document.getElementById('add-modal').classList.add('open');
|
|
|
|
|
+ setTimeout(() => document.getElementById('new-name').focus(), 50);
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+function closeAddModal(e) {
|
|
|
|
|
+ if (!e || e.target === document.getElementById('add-modal'))
|
|
|
|
|
+ document.getElementById('add-modal').classList.remove('open');
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+async function addSpeaker() {
|
|
|
|
|
+ const id = document.getElementById('new-id').value.trim();
|
|
|
|
|
+ const name = document.getElementById('new-name').value.trim();
|
|
|
|
|
+ if (!id || !name) { toast('ID and name are required', true); return; }
|
|
|
|
|
+ const res = await fetch('/api/speakers', {
|
|
|
|
|
+ method: 'POST',
|
|
|
|
|
+ headers: {'Content-Type': 'application/json'},
|
|
|
|
|
+ body: JSON.stringify({id, name})
|
|
|
|
|
+ });
|
|
|
|
|
+ if (res.ok) {
|
|
|
|
|
+ closeAddModal();
|
|
|
|
|
+ toast(`Added ${name}`);
|
|
|
|
|
+ await load();
|
|
|
|
|
+ } else {
|
|
|
|
|
+ const err = await res.json().catch(() => ({detail:'Error'}));
|
|
|
|
|
+ toast(err.detail || 'Failed', true);
|
|
|
|
|
+ }
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+// ── Delete ────────────────────────────────────────────────────────────────────
|
|
|
|
|
+
|
|
|
|
|
+async function deleteSpeaker(id) {
|
|
|
|
|
+ const s = speakers.find(x => x.id === id);
|
|
|
|
|
+ if (!confirm(`Remove "${s?.name || id}" from the speaker list?`)) return;
|
|
|
|
|
+ const res = await fetch(`/api/speakers/${encodeURIComponent(id)}`, {method:'DELETE'});
|
|
|
|
|
+ if (res.ok) { toast('Removed'); await load(); }
|
|
|
|
|
+ else { toast('Delete failed', true); }
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+// ── Upload modal ──────────────────────────────────────────────────────────────
|
|
|
|
|
+
|
|
|
|
|
+function openUploadModal(id, name) {
|
|
|
|
|
+ uploadTarget = id;
|
|
|
|
|
+ document.getElementById('upload-title').textContent = `Voice Sample — ${name}`;
|
|
|
|
|
+ document.getElementById('upload-status').textContent = '';
|
|
|
|
|
+ document.getElementById('file-input').value = '';
|
|
|
|
|
+ document.getElementById('upload-modal').classList.add('open');
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+function closeUploadModal(e) {
|
|
|
|
|
+ if (!e || e.target === document.getElementById('upload-modal')) {
|
|
|
|
|
+ document.getElementById('upload-modal').classList.remove('open');
|
|
|
|
|
+ uploadTarget = null;
|
|
|
|
|
+ load();
|
|
|
|
|
+ }
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+function onDragOver(e) { e.preventDefault(); document.getElementById('drop-zone').classList.add('drag'); }
|
|
|
|
|
+function onDragLeave() { document.getElementById('drop-zone').classList.remove('drag'); }
|
|
|
|
|
+function onDrop(e) { e.preventDefault(); onDragLeave(); uploadFile(e.dataTransfer.files[0]); }
|
|
|
|
|
+
|
|
|
|
|
+async function uploadFile(file) {
|
|
|
|
|
+ if (!file || !uploadTarget) return;
|
|
|
|
|
+ const status = document.getElementById('upload-status');
|
|
|
|
|
+ status.style.color = '#2563eb';
|
|
|
|
|
+ status.textContent = `Uploading ${file.name} (${Math.round(file.size/1024)} KB)…`;
|
|
|
|
|
+ const form = new FormData();
|
|
|
|
|
+ form.append('file', file);
|
|
|
|
|
+ const res = await fetch(`/api/speakers/${encodeURIComponent(uploadTarget)}/recording`, {
|
|
|
|
|
+ method: 'POST', body: form
|
|
|
|
|
+ });
|
|
|
|
|
+ if (res.ok) {
|
|
|
|
|
+ const data = await res.json();
|
|
|
|
|
+ status.style.color = '#166534';
|
|
|
|
|
+ status.textContent = `✓ Saved — ${data.file} (${data.kb} KB)`;
|
|
|
|
|
+ toast('Recording saved');
|
|
|
|
|
+ } else {
|
|
|
|
|
+ status.style.color = '#dc2626';
|
|
|
|
|
+ status.textContent = 'Upload failed';
|
|
|
|
|
+ toast('Upload failed', true);
|
|
|
|
|
+ }
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+// ── Toast ─────────────────────────────────────────────────────────────────────
|
|
|
|
|
+
|
|
|
|
|
+let toastTimer;
|
|
|
|
|
+function toast(msg, error = false) {
|
|
|
|
|
+ const el = document.getElementById('toast');
|
|
|
|
|
+ el.textContent = msg;
|
|
|
|
|
+ el.className = 'show' + (error ? ' error' : '');
|
|
|
|
|
+ clearTimeout(toastTimer);
|
|
|
|
|
+ toastTimer = setTimeout(() => el.className = '', 2500);
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+// ── Keyboard shortcuts ────────────────────────────────────────────────────────
|
|
|
|
|
+
|
|
|
|
|
+document.addEventListener('keydown', e => {
|
|
|
|
|
+ if (e.key === 'Escape') {
|
|
|
|
|
+ closeAddModal();
|
|
|
|
|
+ closeUploadModal();
|
|
|
|
|
+ }
|
|
|
|
|
+ if ((e.ctrlKey || e.metaKey) && e.key === 'f') {
|
|
|
|
|
+ e.preventDefault();
|
|
|
|
|
+ document.getElementById('search').focus();
|
|
|
|
|
+ }
|
|
|
|
|
+});
|
|
|
|
|
+
|
|
|
|
|
+// ── Boot ──────────────────────────────────────────────────────────────────────
|
|
|
|
|
+
|
|
|
|
|
+load();
|
|
|
|
|
+</script>
|
|
|
|
|
+</body>
|
|
|
|
|
+</html>
|
|
|
|
|
+"""
|
|
|
|
|
+
|
|
|
|
|
+@app.get("/", response_class=HTMLResponse)
|
|
|
|
|
+def index():
|
|
|
|
|
+ return HTML
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+# ── Entry point ───────────────────────────────────────────────────────────────
|
|
|
|
|
+
|
|
|
|
|
+if __name__ == "__main__":
|
|
|
|
|
+ print("[Admin] Speaker admin running at http://localhost:8001")
|
|
|
|
|
+ uvicorn.run(app, host="0.0.0.0", port=8001, log_level="warning")
|