|
@@ -2,12 +2,14 @@
|
|
|
"""
|
|
"""
|
|
|
admin.py — Speaker Admin Web Server
|
|
admin.py — Speaker Admin Web Server
|
|
|
|
|
|
|
|
-Local web interface for managing speaker names and voice recordings.
|
|
|
|
|
|
|
+Local web interface for managing speaker names, voice recordings,
|
|
|
|
|
+and test recording playback.
|
|
|
Runs on port 8001 alongside bridge.py.
|
|
Runs on port 8001 alongside bridge.py.
|
|
|
|
|
|
|
|
Access at: http://localhost:8001
|
|
Access at: http://localhost:8001
|
|
|
"""
|
|
"""
|
|
|
|
|
|
|
|
|
|
+import asyncio
|
|
|
import json
|
|
import json
|
|
|
import shutil
|
|
import shutil
|
|
|
from pathlib import Path
|
|
from pathlib import Path
|
|
@@ -16,14 +18,20 @@ from fastapi import FastAPI, HTTPException, UploadFile, File
|
|
|
from fastapi.responses import HTMLResponse, FileResponse
|
|
from fastapi.responses import HTMLResponse, FileResponse
|
|
|
from pydantic import BaseModel
|
|
from pydantic import BaseModel
|
|
|
import uvicorn
|
|
import uvicorn
|
|
|
|
|
+import websockets
|
|
|
|
|
|
|
|
-SPEAKERS_FILE = Path(__file__).parent / "speakers.json"
|
|
|
|
|
-RECORDINGS_DIR = Path(__file__).parent / "recordings"
|
|
|
|
|
|
|
+SPEAKERS_FILE = Path(__file__).parent / "speakers.json"
|
|
|
|
|
+RECORDINGS_DIR = Path(__file__).parent / "recordings"
|
|
|
|
|
+TEST_RECORDINGS_DIR = Path(__file__).parent / "test_recordings"
|
|
|
RECORDINGS_DIR.mkdir(exist_ok=True)
|
|
RECORDINGS_DIR.mkdir(exist_ok=True)
|
|
|
|
|
+TEST_RECORDINGS_DIR.mkdir(exist_ok=True)
|
|
|
|
|
+
|
|
|
|
|
+ALLOWED_AUDIO_EXTS = {".wav", ".mp3", ".m4a", ".ogg", ".flac", ".webm", ".aiff"}
|
|
|
|
|
+WS_URL = "ws://localhost:8000/asr"
|
|
|
|
|
|
|
|
app = FastAPI(title="Speaker Admin")
|
|
app = FastAPI(title="Speaker Admin")
|
|
|
|
|
|
|
|
-# ── Data helpers ──────────────────────────────────────────────────────────────
|
|
|
|
|
|
|
+# ── Speaker data helpers ──────────────────────────────────────────────────────
|
|
|
|
|
|
|
|
def _load() -> dict[str, str]:
|
|
def _load() -> dict[str, str]:
|
|
|
if SPEAKERS_FILE.exists():
|
|
if SPEAKERS_FILE.exists():
|
|
@@ -48,7 +56,7 @@ def _recording_path(sid: str) -> Path | None:
|
|
|
return None
|
|
return None
|
|
|
|
|
|
|
|
|
|
|
|
|
-# ── API ───────────────────────────────────────────────────────────────────────
|
|
|
|
|
|
|
+# ── Speaker API ───────────────────────────────────────────────────────────────
|
|
|
|
|
|
|
|
class NameBody(BaseModel):
|
|
class NameBody(BaseModel):
|
|
|
name: str
|
|
name: str
|
|
@@ -127,6 +135,156 @@ def api_playback(sid: str):
|
|
|
return FileResponse(rec)
|
|
return FileResponse(rec)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
+# ── Test playback state ───────────────────────────────────────────────────────
|
|
|
|
|
+
|
|
|
|
|
+_playback_task: asyncio.Task | None = None
|
|
|
|
|
+_playback_status: dict = {
|
|
|
|
|
+ "state": "idle", # idle | loading | playing | done | error
|
|
|
|
|
+ "file": None,
|
|
|
|
|
+ "progress": 0, # 0–100
|
|
|
|
|
+ "elapsed": 0.0, # seconds streamed so far
|
|
|
|
|
+ "duration": 0.0, # total file duration in seconds
|
|
|
|
|
+ "error": None,
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+async def _stream_file(filepath: Path, speed: float) -> None:
|
|
|
|
|
+ global _playback_status
|
|
|
|
|
+ try:
|
|
|
|
|
+ import miniaudio # type: ignore
|
|
|
|
|
+ except ImportError:
|
|
|
|
|
+ _playback_status.update({
|
|
|
|
|
+ "state": "error",
|
|
|
|
|
+ "error": "miniaudio not installed — run: pip install miniaudio",
|
|
|
|
|
+ })
|
|
|
|
|
+ return
|
|
|
|
|
+
|
|
|
|
|
+ try:
|
|
|
|
|
+ _playback_status["state"] = "loading"
|
|
|
|
|
+
|
|
|
|
|
+ duration = 0.0
|
|
|
|
|
+ try:
|
|
|
|
|
+ info = miniaudio.get_file_info(str(filepath))
|
|
|
|
|
+ duration = info.duration
|
|
|
|
|
+ except Exception:
|
|
|
|
|
+ pass
|
|
|
|
|
+
|
|
|
|
|
+ _playback_status.update({
|
|
|
|
|
+ "state": "playing",
|
|
|
|
|
+ "duration": round(duration, 1),
|
|
|
|
|
+ "elapsed": 0.0,
|
|
|
|
|
+ "progress": 0,
|
|
|
|
|
+ })
|
|
|
|
|
+
|
|
|
|
|
+ chunk_frames = 4096
|
|
|
|
|
+ chunk_secs = chunk_frames / 16000
|
|
|
|
|
+ elapsed = 0.0
|
|
|
|
|
+
|
|
|
|
|
+ async with websockets.connect(WS_URL, max_size=2**23) as ws:
|
|
|
|
|
+ stream = miniaudio.stream_file(
|
|
|
|
|
+ str(filepath),
|
|
|
|
|
+ output_format=miniaudio.SampleFormat.SIGNED16,
|
|
|
|
|
+ nchannels=1,
|
|
|
|
|
+ sample_rate=16000,
|
|
|
|
|
+ frames_to_read=chunk_frames,
|
|
|
|
|
+ )
|
|
|
|
|
+ for chunk in stream:
|
|
|
|
|
+ await ws.send(bytes(chunk))
|
|
|
|
|
+ elapsed += chunk_secs
|
|
|
|
|
+ _playback_status["elapsed"] = round(elapsed, 1)
|
|
|
|
|
+ _playback_status["progress"] = (
|
|
|
|
|
+ min(99, round(elapsed / duration * 100)) if duration else 0
|
|
|
|
|
+ )
|
|
|
|
|
+ await asyncio.sleep(chunk_secs / speed)
|
|
|
|
|
+
|
|
|
|
|
+ _playback_status.update({
|
|
|
|
|
+ "state": "done",
|
|
|
|
|
+ "progress": 100,
|
|
|
|
|
+ "elapsed": round(duration, 1),
|
|
|
|
|
+ })
|
|
|
|
|
+
|
|
|
|
|
+ except asyncio.CancelledError:
|
|
|
|
|
+ _playback_status.update({
|
|
|
|
|
+ "state": "idle", "file": None, "progress": 0, "elapsed": 0.0,
|
|
|
|
|
+ })
|
|
|
|
|
+
|
|
|
|
|
+ except Exception as exc:
|
|
|
|
|
+ _playback_status.update({"state": "error", "error": str(exc), "progress": 0})
|
|
|
|
|
+ print(f"[Playback] {exc}")
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+# ── Test recording API ────────────────────────────────────────────────────────
|
|
|
|
|
+
|
|
|
|
|
+@app.post("/api/test/upload")
|
|
|
|
|
+async def api_test_upload(file: UploadFile = File(...)):
|
|
|
|
|
+ suffix = Path(file.filename or "recording.wav").suffix.lower()
|
|
|
|
|
+ if suffix not in ALLOWED_AUDIO_EXTS:
|
|
|
|
|
+ raise HTTPException(400, f"Unsupported format '{suffix}'. Use WAV, MP3, FLAC, OGG, or M4A.")
|
|
|
|
|
+ stem = Path(file.filename).stem[:80] # limit filename length
|
|
|
|
|
+ out = TEST_RECORDINGS_DIR / f"{stem}{suffix}"
|
|
|
|
|
+ with out.open("wb") as f:
|
|
|
|
|
+ shutil.copyfileobj(file.file, f)
|
|
|
|
|
+ size_mb = round(out.stat().st_size / 1024 / 1024, 1)
|
|
|
|
|
+ return {"ok": True, "filename": out.name, "mb": size_mb}
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+@app.get("/api/test/files")
|
|
|
|
|
+def api_test_list():
|
|
|
|
|
+ files = []
|
|
|
|
|
+ for p in sorted(TEST_RECORDINGS_DIR.iterdir()):
|
|
|
|
|
+ if p.suffix.lower() in ALLOWED_AUDIO_EXTS:
|
|
|
|
|
+ files.append({
|
|
|
|
|
+ "filename": p.name,
|
|
|
|
|
+ "mb": round(p.stat().st_size / 1024 / 1024, 1),
|
|
|
|
|
+ })
|
|
|
|
|
+ return {"files": files}
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+@app.delete("/api/test/files/{filename}")
|
|
|
|
|
+def api_test_delete(filename: str):
|
|
|
|
|
+ p = TEST_RECORDINGS_DIR / Path(filename).name # sanitise — no path traversal
|
|
|
|
|
+ if p.exists():
|
|
|
|
|
+ p.unlink()
|
|
|
|
|
+ return {"ok": True}
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+class PlaybackBody(BaseModel):
|
|
|
|
|
+ filename: str
|
|
|
|
|
+ speed: float = 1.0
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+@app.post("/api/test/start")
|
|
|
|
|
+async def api_test_start(body: PlaybackBody):
|
|
|
|
|
+ global _playback_task
|
|
|
|
|
+ if _playback_task and not _playback_task.done():
|
|
|
|
|
+ raise HTTPException(409, "Playback already running — stop it first")
|
|
|
|
|
+ p = TEST_RECORDINGS_DIR / Path(body.filename).name
|
|
|
|
|
+ if not p.exists():
|
|
|
|
|
+ raise HTTPException(404, "File not found")
|
|
|
|
|
+ speed = max(0.25, min(8.0, body.speed))
|
|
|
|
|
+ _playback_status.update({"state": "starting", "file": p.name, "progress": 0, "error": None})
|
|
|
|
|
+ _playback_task = asyncio.create_task(_stream_file(p, speed))
|
|
|
|
|
+ return {"ok": True}
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+@app.post("/api/test/stop")
|
|
|
|
|
+async def api_test_stop():
|
|
|
|
|
+ global _playback_task
|
|
|
|
|
+ if _playback_task and not _playback_task.done():
|
|
|
|
|
+ _playback_task.cancel()
|
|
|
|
|
+ try:
|
|
|
|
|
+ await _playback_task
|
|
|
|
|
+ except asyncio.CancelledError:
|
|
|
|
|
+ pass
|
|
|
|
|
+ _playback_status.update({"state": "idle", "file": None, "progress": 0, "elapsed": 0.0})
|
|
|
|
|
+ return {"ok": True}
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+@app.get("/api/test/status")
|
|
|
|
|
+def api_test_status():
|
|
|
|
|
+ return _playback_status
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
# ── Web UI ────────────────────────────────────────────────────────────────────
|
|
# ── Web UI ────────────────────────────────────────────────────────────────────
|
|
|
|
|
|
|
|
HTML = """<!DOCTYPE html>
|
|
HTML = """<!DOCTYPE html>
|
|
@@ -134,12 +292,11 @@ HTML = """<!DOCTYPE html>
|
|
|
<head>
|
|
<head>
|
|
|
<meta charset="UTF-8">
|
|
<meta charset="UTF-8">
|
|
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
|
-<title>Speaker Admin</title>
|
|
|
|
|
|
|
+<title>Meeting Transcription for the Deaf</title>
|
|
|
<style>
|
|
<style>
|
|
|
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
|
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
|
|
|
|
|
|
|
body { font-family: system-ui, sans-serif; background: #f0f4f8; color: #1e293b; }
|
|
body { font-family: system-ui, sans-serif; background: #f0f4f8; color: #1e293b; }
|
|
|
-
|
|
|
|
|
header {
|
|
header {
|
|
|
background: #1e3a5f; color: white; padding: 16px 24px;
|
|
background: #1e3a5f; color: white; padding: 16px 24px;
|
|
|
display: flex; align-items: center; gap: 16px;
|
|
display: flex; align-items: center; gap: 16px;
|
|
@@ -163,7 +320,8 @@ HTML = """<!DOCTYPE html>
|
|
|
padding: 8px 16px; border-radius: 6px; border: none;
|
|
padding: 8px 16px; border-radius: 6px; border: none;
|
|
|
cursor: pointer; font-size: .9rem; font-weight: 500; transition: filter .15s;
|
|
cursor: pointer; font-size: .9rem; font-weight: 500; transition: filter .15s;
|
|
|
}
|
|
}
|
|
|
- .btn:hover { filter: brightness(.92); }
|
|
|
|
|
|
|
+ .btn:hover:not(:disabled) { filter: brightness(.92); }
|
|
|
|
|
+ .btn:disabled { opacity: .45; cursor: not-allowed; }
|
|
|
.btn-primary { background: #2563eb; color: white; }
|
|
.btn-primary { background: #2563eb; color: white; }
|
|
|
.btn-danger { background: #dc2626; color: white; }
|
|
.btn-danger { background: #dc2626; color: white; }
|
|
|
.btn-ghost { background: #e2e8f0; color: #334155; }
|
|
.btn-ghost { background: #e2e8f0; color: #334155; }
|
|
@@ -220,7 +378,7 @@ HTML = """<!DOCTYPE html>
|
|
|
.field input:focus { outline: 2px solid #2563eb; border-color: transparent; }
|
|
.field input:focus { outline: 2px solid #2563eb; border-color: transparent; }
|
|
|
.modal-actions { display: flex; gap: 8px; justify-content: flex-end; margin-top: 20px; }
|
|
.modal-actions { display: flex; gap: 8px; justify-content: flex-end; margin-top: 20px; }
|
|
|
|
|
|
|
|
- /* Upload */
|
|
|
|
|
|
|
+ /* Upload drop zone */
|
|
|
.upload-area {
|
|
.upload-area {
|
|
|
border: 2px dashed #cbd5e1; border-radius: 8px; padding: 16px;
|
|
border: 2px dashed #cbd5e1; border-radius: 8px; padding: 16px;
|
|
|
text-align: center; cursor: pointer; transition: border-color .2s;
|
|
text-align: center; cursor: pointer; transition: border-color .2s;
|
|
@@ -245,6 +403,45 @@ HTML = """<!DOCTYPE html>
|
|
|
.container { max-width: 1100px; margin: 0 auto; padding: 24px; }
|
|
.container { max-width: 1100px; margin: 0 auto; padding: 24px; }
|
|
|
|
|
|
|
|
audio { vertical-align: middle; }
|
|
audio { vertical-align: middle; }
|
|
|
|
|
+
|
|
|
|
|
+ /* ── Test Playback card ─────────────────────────────── */
|
|
|
|
|
+ .pb-card {
|
|
|
|
|
+ background: white; border-radius: 8px; margin-top: 20px;
|
|
|
|
|
+ border: 1px solid #e2e8f0; overflow: hidden;
|
|
|
|
|
+ }
|
|
|
|
|
+ .pb-card-header {
|
|
|
|
|
+ display: flex; justify-content: space-between; align-items: center;
|
|
|
|
|
+ padding: 14px 20px; cursor: pointer; font-weight: 600; font-size: 1rem;
|
|
|
|
|
+ background: #f8fafc; border-bottom: 1px solid #e2e8f0; user-select: none;
|
|
|
|
|
+ }
|
|
|
|
|
+ .pb-card-header:hover { background: #f1f5f9; }
|
|
|
|
|
+ #pb-body { padding: 20px; }
|
|
|
|
|
+ .pb-hint { color: #64748b; font-size: .88rem; margin-bottom: 16px; line-height: 1.5; }
|
|
|
|
|
+
|
|
|
|
|
+ .pb-layout { display: flex; gap: 20px; flex-wrap: wrap; margin-bottom: 16px; }
|
|
|
|
|
+ .pb-upload { flex: 1; min-width: 260px; }
|
|
|
|
|
+ .pb-controls { min-width: 210px; display: flex; flex-direction: column; gap: 12px; }
|
|
|
|
|
+
|
|
|
|
|
+ .pb-select {
|
|
|
|
|
+ width: 100%; padding: 7px 10px; border: 1px solid #cbd5e1;
|
|
|
|
|
+ border-radius: 6px; font-size: .9rem; background: white;
|
|
|
|
|
+ }
|
|
|
|
|
+ .pb-label { font-size: .85rem; font-weight: 500; display: block; margin-bottom: 4px; }
|
|
|
|
|
+
|
|
|
|
|
+ .pb-status-bar { margin-top: 12px; }
|
|
|
|
|
+ .pb-status-row {
|
|
|
|
|
+ display: flex; justify-content: space-between;
|
|
|
|
|
+ font-size: .85rem; margin-bottom: 5px; min-height: 18px;
|
|
|
|
|
+ }
|
|
|
|
|
+ .pb-progress-track {
|
|
|
|
|
+ height: 8px; background: #e2e8f0; border-radius: 999px; overflow: hidden;
|
|
|
|
|
+ }
|
|
|
|
|
+ .pb-progress-fill {
|
|
|
|
|
+ height: 100%; background: #2563eb; border-radius: 999px;
|
|
|
|
|
+ width: 0%; transition: width .8s linear;
|
|
|
|
|
+ }
|
|
|
|
|
+ .pb-note { font-size: .78rem; color: #94a3b8; margin-top: 10px; }
|
|
|
|
|
+ .pb-error { color: #dc2626; }
|
|
|
</style>
|
|
</style>
|
|
|
</head>
|
|
</head>
|
|
|
<body>
|
|
<body>
|
|
@@ -252,7 +449,7 @@ HTML = """<!DOCTYPE html>
|
|
|
<header>
|
|
<header>
|
|
|
<div>
|
|
<div>
|
|
|
<h1>🎤 Speaker Admin</h1>
|
|
<h1>🎤 Speaker Admin</h1>
|
|
|
- <small>Church Live Transcription — Speaker Name & Voice Library</small>
|
|
|
|
|
|
|
+ <small>Meeting Transcription for the Deaf — Speaker Name & Voice Library</small>
|
|
|
</div>
|
|
</div>
|
|
|
</header>
|
|
</header>
|
|
|
|
|
|
|
@@ -263,6 +460,8 @@ HTML = """<!DOCTYPE html>
|
|
|
</div>
|
|
</div>
|
|
|
|
|
|
|
|
<div class="container">
|
|
<div class="container">
|
|
|
|
|
+
|
|
|
|
|
+ <!-- Speaker table -->
|
|
|
<table id="table">
|
|
<table id="table">
|
|
|
<thead>
|
|
<thead>
|
|
|
<tr>
|
|
<tr>
|
|
@@ -274,9 +473,94 @@ HTML = """<!DOCTYPE html>
|
|
|
</thead>
|
|
</thead>
|
|
|
<tbody id="tbody"></tbody>
|
|
<tbody id="tbody"></tbody>
|
|
|
</table>
|
|
</table>
|
|
|
-</div>
|
|
|
|
|
|
|
|
|
|
-<!-- Add modal -->
|
|
|
|
|
|
|
+ <!-- Test Playback card -->
|
|
|
|
|
+ <div class="pb-card">
|
|
|
|
|
+ <div class="pb-card-header" onclick="togglePbCard()">
|
|
|
|
|
+ <span>🎧 Test Recording Playback</span>
|
|
|
|
|
+ <span id="pb-chevron">▼</span>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ <div id="pb-body">
|
|
|
|
|
+ <p class="pb-hint">
|
|
|
|
|
+ Upload a full church service recording (WAV, MP3, FLAC, OGG, M4A) to test the
|
|
|
|
|
+ transcription pipeline offline. The file streams to WhisperLiveKit exactly as a
|
|
|
|
|
+ live microphone would — results appear on the e-ink display in real time.
|
|
|
|
|
+ </p>
|
|
|
|
|
+
|
|
|
|
|
+ <div class="pb-layout">
|
|
|
|
|
+ <!-- Upload drop zone -->
|
|
|
|
|
+ <div class="pb-upload">
|
|
|
|
|
+ <div class="upload-area" id="test-drop"
|
|
|
|
|
+ ondragover="pbDragOver(event)" ondragleave="pbDragLeave()"
|
|
|
|
|
+ ondrop="pbDrop(event)"
|
|
|
|
|
+ onclick="document.getElementById('test-file-input').click()">
|
|
|
|
|
+ <input type="file" id="test-file-input" accept="audio/*"
|
|
|
|
|
+ onchange="pbUpload(this.files[0])">
|
|
|
|
|
+ <div>⇧ Drop a recording here, or click to browse</div>
|
|
|
|
|
+ <div style="font-size:.78rem;margin-top:4px;color:#94a3b8">
|
|
|
|
|
+ WAV · MP3 · FLAC · OGG · M4A
|
|
|
|
|
+ </div>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ <div id="test-upload-status" style="min-height:20px;font-size:.85rem;margin-top:8px"></div>
|
|
|
|
|
+ </div>
|
|
|
|
|
+
|
|
|
|
|
+ <!-- Playback controls -->
|
|
|
|
|
+ <div class="pb-controls">
|
|
|
|
|
+ <div>
|
|
|
|
|
+ <span class="pb-label">Playback Speed</span>
|
|
|
|
|
+ <select id="pb-speed" class="pb-select">
|
|
|
|
|
+ <option value="0.5">0.5× (half speed)</option>
|
|
|
|
|
+ <option value="1">1× real-time</option>
|
|
|
|
|
+ <option value="2">2× faster</option>
|
|
|
|
|
+ <option value="4" selected>4× quick review</option>
|
|
|
|
|
+ <option value="8">8× very fast</option>
|
|
|
|
|
+ </select>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ <button id="pb-stop-btn" class="btn btn-danger" onclick="pbStop()" disabled>
|
|
|
|
|
+ ■ Stop Playback
|
|
|
|
|
+ </button>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ </div>
|
|
|
|
|
+
|
|
|
|
|
+ <!-- Uploaded test files -->
|
|
|
|
|
+ <table id="test-table">
|
|
|
|
|
+ <thead>
|
|
|
|
|
+ <tr>
|
|
|
|
|
+ <th>Recording File</th>
|
|
|
|
|
+ <th style="width:80px">Size</th>
|
|
|
|
|
+ <th style="width:140px">Actions</th>
|
|
|
|
|
+ </tr>
|
|
|
|
|
+ </thead>
|
|
|
|
|
+ <tbody id="test-tbody">
|
|
|
|
|
+ <tr id="test-empty-row">
|
|
|
|
|
+ <td colspan="3" style="color:#94a3b8;padding:16px 16px">
|
|
|
|
|
+ No recordings uploaded yet
|
|
|
|
|
+ </td>
|
|
|
|
|
+ </tr>
|
|
|
|
|
+ </tbody>
|
|
|
|
|
+ </table>
|
|
|
|
|
+
|
|
|
|
|
+ <!-- Status bar -->
|
|
|
|
|
+ <div class="pb-status-bar">
|
|
|
|
|
+ <div class="pb-status-row">
|
|
|
|
|
+ <span id="pb-status-text" style="color:#64748b">Idle</span>
|
|
|
|
|
+ <span id="pb-time-text" style="color:#64748b"></span>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ <div class="pb-progress-track">
|
|
|
|
|
+ <div class="pb-progress-fill" id="pb-fill"></div>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ </div>
|
|
|
|
|
+
|
|
|
|
|
+ <p class="pb-note">
|
|
|
|
|
+ While a test recording plays, the live microphone in bridge.py continues to run.
|
|
|
|
|
+ Keep the room quiet during testing to avoid mixing live audio with the recording.
|
|
|
|
|
+ </p>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ </div>
|
|
|
|
|
+
|
|
|
|
|
+</div><!-- /container -->
|
|
|
|
|
+
|
|
|
|
|
+<!-- Add speaker modal -->
|
|
|
<div class="modal-bg" id="add-modal" onclick="closeAddModal(event)">
|
|
<div class="modal-bg" id="add-modal" onclick="closeAddModal(event)">
|
|
|
<div class="modal">
|
|
<div class="modal">
|
|
|
<h2>Add Speaker</h2>
|
|
<h2>Add Speaker</h2>
|
|
@@ -299,7 +583,7 @@ HTML = """<!DOCTYPE html>
|
|
|
</div>
|
|
</div>
|
|
|
</div>
|
|
</div>
|
|
|
|
|
|
|
|
-<!-- Upload modal -->
|
|
|
|
|
|
|
+<!-- Voice sample upload modal -->
|
|
|
<div class="modal-bg" id="upload-modal" onclick="closeUploadModal(event)">
|
|
<div class="modal-bg" id="upload-modal" onclick="closeUploadModal(event)">
|
|
|
<div class="modal">
|
|
<div class="modal">
|
|
|
<h2 id="upload-title">Upload Voice Sample</h2>
|
|
<h2 id="upload-title">Upload Voice Sample</h2>
|
|
@@ -323,13 +607,13 @@ HTML = """<!DOCTYPE html>
|
|
|
<div id="toast"></div>
|
|
<div id="toast"></div>
|
|
|
|
|
|
|
|
<script>
|
|
<script>
|
|
|
-let speakers = [];
|
|
|
|
|
|
|
+let speakers = [];
|
|
|
let uploadTarget = null;
|
|
let uploadTarget = null;
|
|
|
|
|
|
|
|
-// ── Load & render ─────────────────────────────────────────────────────────────
|
|
|
|
|
|
|
+// ── Speaker table ─────────────────────────────────────────────────────────────
|
|
|
|
|
|
|
|
async function load() {
|
|
async function load() {
|
|
|
- const res = await fetch('/api/speakers');
|
|
|
|
|
|
|
+ const res = await fetch('/api/speakers');
|
|
|
const data = await res.json();
|
|
const data = await res.json();
|
|
|
speakers = data.speakers;
|
|
speakers = data.speakers;
|
|
|
render();
|
|
render();
|
|
@@ -339,7 +623,8 @@ function render() {
|
|
|
const tbody = document.getElementById('tbody');
|
|
const tbody = document.getElementById('tbody');
|
|
|
tbody.innerHTML = '';
|
|
tbody.innerHTML = '';
|
|
|
speakers.forEach(s => tbody.appendChild(makeRow(s)));
|
|
speakers.forEach(s => tbody.appendChild(makeRow(s)));
|
|
|
- document.getElementById('count').textContent = `${speakers.length} speaker${speakers.length !== 1 ? 's' : ''}`;
|
|
|
|
|
|
|
+ document.getElementById('count').textContent =
|
|
|
|
|
+ `${speakers.length} speaker${speakers.length !== 1 ? 's' : ''}`;
|
|
|
filterTable();
|
|
filterTable();
|
|
|
}
|
|
}
|
|
|
|
|
|
|
@@ -380,12 +665,11 @@ function makeRow(s) {
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
function esc(str) {
|
|
function esc(str) {
|
|
|
- return str.replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>')
|
|
|
|
|
- .replace(/"/g,'"').replace(/'/g,''');
|
|
|
|
|
|
|
+ return String(str)
|
|
|
|
|
+ .replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>')
|
|
|
|
|
+ .replace(/"/g,'"').replace(/'/g,''');
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
-// ── Search ────────────────────────────────────────────────────────────────────
|
|
|
|
|
-
|
|
|
|
|
function filterTable() {
|
|
function filterTable() {
|
|
|
const q = document.getElementById('search').value.toLowerCase().trim();
|
|
const q = document.getElementById('search').value.toLowerCase().trim();
|
|
|
let visible = 0;
|
|
let visible = 0;
|
|
@@ -398,8 +682,6 @@ function filterTable() {
|
|
|
q ? `${visible} of ${speakers.length} speakers` : `${speakers.length} speakers`;
|
|
q ? `${visible} of ${speakers.length} speakers` : `${speakers.length} speakers`;
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
-// ── Inline edit ───────────────────────────────────────────────────────────────
|
|
|
|
|
-
|
|
|
|
|
function startEdit(span, id) {
|
|
function startEdit(span, id) {
|
|
|
const input = span.nextElementSibling;
|
|
const input = span.nextElementSibling;
|
|
|
input.value = span.textContent;
|
|
input.value = span.textContent;
|
|
@@ -440,8 +722,6 @@ async function saveEdit(input, id) {
|
|
|
cancelEdit(input);
|
|
cancelEdit(input);
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
-// ── Add speaker ───────────────────────────────────────────────────────────────
|
|
|
|
|
-
|
|
|
|
|
function openAddModal() {
|
|
function openAddModal() {
|
|
|
const nums = speakers
|
|
const nums = speakers
|
|
|
.filter(s => /^SPEAKER_\d+$/.test(s.id))
|
|
.filter(s => /^SPEAKER_\d+$/.test(s.id))
|
|
@@ -477,8 +757,6 @@ async function addSpeaker() {
|
|
|
}
|
|
}
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
-// ── Delete ────────────────────────────────────────────────────────────────────
|
|
|
|
|
-
|
|
|
|
|
async function deleteSpeaker(id) {
|
|
async function deleteSpeaker(id) {
|
|
|
const s = speakers.find(x => x.id === id);
|
|
const s = speakers.find(x => x.id === id);
|
|
|
if (!confirm(`Remove "${s?.name || id}" from the speaker list?`)) return;
|
|
if (!confirm(`Remove "${s?.name || id}" from the speaker list?`)) return;
|
|
@@ -487,8 +765,6 @@ async function deleteSpeaker(id) {
|
|
|
else { toast('Delete failed', true); }
|
|
else { toast('Delete failed', true); }
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
-// ── Upload modal ──────────────────────────────────────────────────────────────
|
|
|
|
|
-
|
|
|
|
|
function openUploadModal(id, name) {
|
|
function openUploadModal(id, name) {
|
|
|
uploadTarget = id;
|
|
uploadTarget = id;
|
|
|
document.getElementById('upload-title').textContent = `Voice Sample — ${name}`;
|
|
document.getElementById('upload-title').textContent = `Voice Sample — ${name}`;
|
|
@@ -542,13 +818,197 @@ function toast(msg, error = false) {
|
|
|
toastTimer = setTimeout(() => el.className = '', 2500);
|
|
toastTimer = setTimeout(() => el.className = '', 2500);
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
|
|
+// ── Test Playback ─────────────────────────────────────────────────────────────
|
|
|
|
|
+
|
|
|
|
|
+let pbExpanded = true;
|
|
|
|
|
+let pbPollTimer = null;
|
|
|
|
|
+let pbFiles = [];
|
|
|
|
|
+
|
|
|
|
|
+function togglePbCard() {
|
|
|
|
|
+ pbExpanded = !pbExpanded;
|
|
|
|
|
+ document.getElementById('pb-body').style.display = pbExpanded ? '' : 'none';
|
|
|
|
|
+ document.getElementById('pb-chevron').textContent = pbExpanded ? '▼' : '▶';
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+async function loadTestFiles() {
|
|
|
|
|
+ try {
|
|
|
|
|
+ const res = await fetch('/api/test/files');
|
|
|
|
|
+ const data = await res.json();
|
|
|
|
|
+ pbFiles = data.files;
|
|
|
|
|
+ } catch { pbFiles = []; }
|
|
|
|
|
+ renderTestFiles();
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+function renderTestFiles() {
|
|
|
|
|
+ const tbody = document.getElementById('test-tbody');
|
|
|
|
|
+ const empty = document.getElementById('test-empty-row');
|
|
|
|
|
+ tbody.innerHTML = '';
|
|
|
|
|
+ if (pbFiles.length === 0) {
|
|
|
|
|
+ tbody.appendChild(empty);
|
|
|
|
|
+ return;
|
|
|
|
|
+ }
|
|
|
|
|
+ pbFiles.forEach(f => tbody.appendChild(makeTestRow(f)));
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+function makeTestRow(f) {
|
|
|
|
|
+ const tr = document.createElement('tr');
|
|
|
|
|
+ tr.innerHTML = `
|
|
|
|
|
+ <td style="font-family:monospace;font-size:.88rem">${esc(f.filename)}</td>
|
|
|
|
|
+ <td style="color:#64748b;white-space:nowrap">${f.mb} MB</td>
|
|
|
|
|
+ <td>
|
|
|
|
|
+ <div class="actions">
|
|
|
|
|
+ <button class="btn btn-primary btn-sm"
|
|
|
|
|
+ onclick="pbStart('${esc(f.filename)}')">▶ Play</button>
|
|
|
|
|
+ <button class="btn btn-danger btn-sm"
|
|
|
|
|
+ onclick="pbDeleteFile('${esc(f.filename)}')">🗑</button>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ </td>`;
|
|
|
|
|
+ return tr;
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+function pbDragOver(e) {
|
|
|
|
|
+ e.preventDefault();
|
|
|
|
|
+ document.getElementById('test-drop').classList.add('drag');
|
|
|
|
|
+}
|
|
|
|
|
+function pbDragLeave() {
|
|
|
|
|
+ document.getElementById('test-drop').classList.remove('drag');
|
|
|
|
|
+}
|
|
|
|
|
+function pbDrop(e) {
|
|
|
|
|
+ e.preventDefault();
|
|
|
|
|
+ pbDragLeave();
|
|
|
|
|
+ pbUpload(e.dataTransfer.files[0]);
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+async function pbUpload(file) {
|
|
|
|
|
+ if (!file) return;
|
|
|
|
|
+ const status = document.getElementById('test-upload-status');
|
|
|
|
|
+ status.style.color = '#2563eb';
|
|
|
|
|
+ status.textContent = `Uploading ${file.name} (${(file.size/1024/1024).toFixed(1)} MB)…`;
|
|
|
|
|
+ const form = new FormData();
|
|
|
|
|
+ form.append('file', file);
|
|
|
|
|
+ const res = await fetch('/api/test/upload', { method: 'POST', body: form });
|
|
|
|
|
+ if (res.ok) {
|
|
|
|
|
+ const d = await res.json();
|
|
|
|
|
+ status.style.color = '#166534';
|
|
|
|
|
+ status.textContent = `✓ ${d.filename} (${d.mb} MB)`;
|
|
|
|
|
+ toast('Recording uploaded');
|
|
|
|
|
+ document.getElementById('test-file-input').value = '';
|
|
|
|
|
+ await loadTestFiles();
|
|
|
|
|
+ } else {
|
|
|
|
|
+ const err = await res.json().catch(() => ({detail: 'Upload failed'}));
|
|
|
|
|
+ status.style.color = '#dc2626';
|
|
|
|
|
+ status.textContent = err.detail || 'Upload failed';
|
|
|
|
|
+ toast(err.detail || 'Upload failed', true);
|
|
|
|
|
+ }
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+async function pbStart(filename) {
|
|
|
|
|
+ const speed = parseFloat(document.getElementById('pb-speed').value);
|
|
|
|
|
+ const res = await fetch('/api/test/start', {
|
|
|
|
|
+ method: 'POST',
|
|
|
|
|
+ headers: {'Content-Type': 'application/json'},
|
|
|
|
|
+ body: JSON.stringify({ filename, speed }),
|
|
|
|
|
+ });
|
|
|
|
|
+ if (res.ok) {
|
|
|
|
|
+ document.getElementById('pb-stop-btn').disabled = false;
|
|
|
|
|
+ toast(`Playing at ${speed}×`);
|
|
|
|
|
+ pbStartPoll();
|
|
|
|
|
+ } else {
|
|
|
|
|
+ const err = await res.json().catch(() => ({detail: 'Failed to start'}));
|
|
|
|
|
+ toast(err.detail || 'Failed to start', true);
|
|
|
|
|
+ }
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+async function pbStop() {
|
|
|
|
|
+ const res = await fetch('/api/test/stop', { method: 'POST' });
|
|
|
|
|
+ pbStopPoll();
|
|
|
|
|
+ if (res.ok) toast('Playback stopped');
|
|
|
|
|
+ setPbIdle();
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+async function pbDeleteFile(filename) {
|
|
|
|
|
+ if (!confirm(`Delete "${filename}"?`)) return;
|
|
|
|
|
+ await fetch(`/api/test/files/${encodeURIComponent(filename)}`, { method: 'DELETE' });
|
|
|
|
|
+ toast('Deleted');
|
|
|
|
|
+ await loadTestFiles();
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+function pbStartPoll() {
|
|
|
|
|
+ pbStopPoll();
|
|
|
|
|
+ pbPollTimer = setInterval(pbPoll, 1000);
|
|
|
|
|
+ pbPoll();
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+function pbStopPoll() {
|
|
|
|
|
+ if (pbPollTimer) { clearInterval(pbPollTimer); pbPollTimer = null; }
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+async function pbPoll() {
|
|
|
|
|
+ let data;
|
|
|
|
|
+ try {
|
|
|
|
|
+ const res = await fetch('/api/test/status');
|
|
|
|
|
+ data = await res.json();
|
|
|
|
|
+ } catch { return; }
|
|
|
|
|
+
|
|
|
|
|
+ const fill = document.getElementById('pb-fill');
|
|
|
|
|
+ const statusEl = document.getElementById('pb-status-text');
|
|
|
|
|
+ const timeEl = document.getElementById('pb-time-text');
|
|
|
|
|
+
|
|
|
|
|
+ fill.style.width = (data.progress || 0) + '%';
|
|
|
|
|
+
|
|
|
|
|
+ const elapsed = data.elapsed ? fmtTime(data.elapsed) : '';
|
|
|
|
|
+ const duration = data.duration ? fmtTime(data.duration) : '';
|
|
|
|
|
+
|
|
|
|
|
+ if (data.state === 'loading' || data.state === 'starting') {
|
|
|
|
|
+ statusEl.className = '';
|
|
|
|
|
+ statusEl.textContent = 'Loading file…';
|
|
|
|
|
+ timeEl.textContent = '';
|
|
|
|
|
+
|
|
|
|
|
+ } else if (data.state === 'playing') {
|
|
|
|
|
+ statusEl.className = '';
|
|
|
|
|
+ statusEl.textContent = `Playing: ${data.file}`;
|
|
|
|
|
+ timeEl.textContent = elapsed && duration ? `${elapsed} / ${duration}` : '';
|
|
|
|
|
+
|
|
|
|
|
+ } else if (data.state === 'done') {
|
|
|
|
|
+ statusEl.className = '';
|
|
|
|
|
+ statusEl.textContent = `Done: ${data.file}`;
|
|
|
|
|
+ timeEl.textContent = duration;
|
|
|
|
|
+ fill.style.width = '100%';
|
|
|
|
|
+ pbStopPoll();
|
|
|
|
|
+ document.getElementById('pb-stop-btn').disabled = true;
|
|
|
|
|
+
|
|
|
|
|
+ } else if (data.state === 'error') {
|
|
|
|
|
+ statusEl.className = 'pb-error';
|
|
|
|
|
+ statusEl.textContent = `Error: ${data.error}`;
|
|
|
|
|
+ timeEl.textContent = '';
|
|
|
|
|
+ pbStopPoll();
|
|
|
|
|
+ document.getElementById('pb-stop-btn').disabled = true;
|
|
|
|
|
+
|
|
|
|
|
+ } else {
|
|
|
|
|
+ pbStopPoll();
|
|
|
|
|
+ setPbIdle();
|
|
|
|
|
+ }
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+function setPbIdle() {
|
|
|
|
|
+ document.getElementById('pb-stop-btn').disabled = true;
|
|
|
|
|
+ document.getElementById('pb-fill').style.width = '0%';
|
|
|
|
|
+ const statusEl = document.getElementById('pb-status-text');
|
|
|
|
|
+ statusEl.className = '';
|
|
|
|
|
+ statusEl.textContent = 'Idle';
|
|
|
|
|
+ document.getElementById('pb-time-text').textContent = '';
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+function fmtTime(secs) {
|
|
|
|
|
+ const s = Math.floor(secs);
|
|
|
|
|
+ const m = Math.floor(s / 60);
|
|
|
|
|
+ return `${m}:${String(s % 60).padStart(2, '0')}`;
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
// ── Keyboard shortcuts ────────────────────────────────────────────────────────
|
|
// ── Keyboard shortcuts ────────────────────────────────────────────────────────
|
|
|
|
|
|
|
|
document.addEventListener('keydown', e => {
|
|
document.addEventListener('keydown', e => {
|
|
|
- if (e.key === 'Escape') {
|
|
|
|
|
- closeAddModal();
|
|
|
|
|
- closeUploadModal();
|
|
|
|
|
- }
|
|
|
|
|
|
|
+ if (e.key === 'Escape') { closeAddModal(); closeUploadModal(); }
|
|
|
if ((e.ctrlKey || e.metaKey) && e.key === 'f') {
|
|
if ((e.ctrlKey || e.metaKey) && e.key === 'f') {
|
|
|
e.preventDefault();
|
|
e.preventDefault();
|
|
|
document.getElementById('search').focus();
|
|
document.getElementById('search').focus();
|
|
@@ -558,6 +1018,7 @@ document.addEventListener('keydown', e => {
|
|
|
// ── Boot ──────────────────────────────────────────────────────────────────────
|
|
// ── Boot ──────────────────────────────────────────────────────────────────────
|
|
|
|
|
|
|
|
load();
|
|
load();
|
|
|
|
|
+loadTestFiles();
|
|
|
</script>
|
|
</script>
|
|
|
</body>
|
|
</body>
|
|
|
</html>
|
|
</html>
|