Benjamin Harris 1 mēnesi atpakaļ
vecāks
revīzija
d264cc1be8
2 mainītis faili ar 495 papildinājumiem un 33 dzēšanām
  1. 494 33
      bridge/admin.py
  2. 1 0
      bridge/requirements.txt

+ 494 - 33
bridge/admin.py

@@ -2,12 +2,14 @@
 """
 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.
 
 Access at: http://localhost:8001
 """
 
+import asyncio
 import json
 import shutil
 from pathlib import Path
@@ -16,14 +18,20 @@ from fastapi import FastAPI, HTTPException, UploadFile, File
 from fastapi.responses import HTMLResponse, FileResponse
 from pydantic import BaseModel
 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)
+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")
 
-# ── Data helpers ──────────────────────────────────────────────────────────────
+# ── Speaker data helpers ──────────────────────────────────────────────────────
 
 def _load() -> dict[str, str]:
     if SPEAKERS_FILE.exists():
@@ -48,7 +56,7 @@ def _recording_path(sid: str) -> Path | None:
     return None
 
 
-# ── API ───────────────────────────────────────────────────────────────────────
+# ── Speaker API ───────────────────────────────────────────────────────────────
 
 class NameBody(BaseModel):
     name: str
@@ -127,6 +135,156 @@ def api_playback(sid: str):
     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 ────────────────────────────────────────────────────────────────────
 
 HTML = """<!DOCTYPE html>
@@ -134,12 +292,11 @@ HTML = """<!DOCTYPE html>
 <head>
 <meta charset="UTF-8">
 <meta name="viewport" content="width=device-width, initial-scale=1">
-<title>Speaker Admin</title>
+<title>Meeting Transcription for the Deaf</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;
@@ -163,7 +320,8 @@ HTML = """<!DOCTYPE html>
     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:hover:not(:disabled) { filter: brightness(.92); }
+  .btn:disabled { opacity: .45; cursor: not-allowed; }
   .btn-primary  { background: #2563eb; color: white; }
   .btn-danger   { background: #dc2626; color: white; }
   .btn-ghost    { background: #e2e8f0; color: #334155; }
@@ -220,7 +378,7 @@ HTML = """<!DOCTYPE html>
   .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 drop zone */
   .upload-area {
     border: 2px dashed #cbd5e1; border-radius: 8px; padding: 16px;
     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; }
 
   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>
 </head>
 <body>
@@ -252,7 +449,7 @@ HTML = """<!DOCTYPE html>
 <header>
   <div>
     <h1>&#127908; Speaker Admin</h1>
-    <small>Church Live Transcription — Speaker Name &amp; Voice Library</small>
+    <small>Meeting Transcription for the Deaf — Speaker Name &amp; Voice Library</small>
   </div>
 </header>
 
@@ -263,6 +460,8 @@ HTML = """<!DOCTYPE html>
 </div>
 
 <div class="container">
+
+  <!-- Speaker table -->
   <table id="table">
     <thead>
       <tr>
@@ -274,9 +473,94 @@ HTML = """<!DOCTYPE html>
     </thead>
     <tbody id="tbody"></tbody>
   </table>
-</div>
 
-<!-- Add modal -->
+  <!-- Test Playback card -->
+  <div class="pb-card">
+    <div class="pb-card-header" onclick="togglePbCard()">
+      <span>&#127911; Test Recording Playback</span>
+      <span id="pb-chevron">&#9660;</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>&#8679; Drop a recording here, or click to browse</div>
+            <div style="font-size:.78rem;margin-top:4px;color:#94a3b8">
+              WAV &middot; MP3 &middot; FLAC &middot; OGG &middot; 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&times; (half speed)</option>
+              <option value="1">1&times; real-time</option>
+              <option value="2">2&times; faster</option>
+              <option value="4" selected>4&times; quick review</option>
+              <option value="8">8&times; very fast</option>
+            </select>
+          </div>
+          <button id="pb-stop-btn" class="btn btn-danger" onclick="pbStop()" disabled>
+            &#9632; 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">
     <h2>Add Speaker</h2>
@@ -299,7 +583,7 @@ HTML = """<!DOCTYPE html>
   </div>
 </div>
 
-<!-- Upload modal -->
+<!-- Voice sample upload modal -->
 <div class="modal-bg" id="upload-modal" onclick="closeUploadModal(event)">
   <div class="modal">
     <h2 id="upload-title">Upload Voice Sample</h2>
@@ -323,13 +607,13 @@ HTML = """<!DOCTYPE html>
 <div id="toast"></div>
 
 <script>
-let speakers = [];
+let speakers    = [];
 let uploadTarget = null;
 
-// ── Load & render ─────────────────────────────────────────────────────────────
+// ── Speaker table ─────────────────────────────────────────────────────────────
 
 async function load() {
-  const res = await fetch('/api/speakers');
+  const res  = await fetch('/api/speakers');
   const data = await res.json();
   speakers = data.speakers;
   render();
@@ -339,7 +623,8 @@ 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' : ''}`;
+  document.getElementById('count').textContent =
+    `${speakers.length} speaker${speakers.length !== 1 ? 's' : ''}`;
   filterTable();
 }
 
@@ -380,12 +665,11 @@ function makeRow(s) {
 }
 
 function esc(str) {
-  return str.replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;')
-            .replace(/"/g,'&quot;').replace(/'/g,'&#39;');
+  return String(str)
+    .replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;')
+    .replace(/"/g,'&quot;').replace(/'/g,'&#39;');
 }
 
-// ── Search ────────────────────────────────────────────────────────────────────
-
 function filterTable() {
   const q = document.getElementById('search').value.toLowerCase().trim();
   let visible = 0;
@@ -398,8 +682,6 @@ function filterTable() {
     q ? `${visible} of ${speakers.length} speakers` : `${speakers.length} speakers`;
 }
 
-// ── Inline edit ───────────────────────────────────────────────────────────────
-
 function startEdit(span, id) {
   const input = span.nextElementSibling;
   input.value = span.textContent;
@@ -440,8 +722,6 @@ async function saveEdit(input, id) {
   cancelEdit(input);
 }
 
-// ── Add speaker ───────────────────────────────────────────────────────────────
-
 function openAddModal() {
   const nums = speakers
     .filter(s => /^SPEAKER_\d+$/.test(s.id))
@@ -477,8 +757,6 @@ async function addSpeaker() {
   }
 }
 
-// ── Delete ────────────────────────────────────────────────────────────────────
-
 async function deleteSpeaker(id) {
   const s = speakers.find(x => x.id === id);
   if (!confirm(`Remove "${s?.name || id}" from the speaker list?`)) return;
@@ -487,8 +765,6 @@ async function deleteSpeaker(id) {
   else        { toast('Delete failed', true); }
 }
 
-// ── Upload modal ──────────────────────────────────────────────────────────────
-
 function openUploadModal(id, name) {
   uploadTarget = id;
   document.getElementById('upload-title').textContent = `Voice Sample — ${name}`;
@@ -542,13 +818,197 @@ function toast(msg, error = false) {
   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)}')">&#9654; Play</button>
+        <button class="btn btn-danger btn-sm"
+                onclick="pbDeleteFile('${esc(f.filename)}')">&#128465;</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 ────────────────────────────────────────────────────────
 
 document.addEventListener('keydown', e => {
-  if (e.key === 'Escape') {
-    closeAddModal();
-    closeUploadModal();
-  }
+  if (e.key === 'Escape') { closeAddModal(); closeUploadModal(); }
   if ((e.ctrlKey || e.metaKey) && e.key === 'f') {
     e.preventDefault();
     document.getElementById('search').focus();
@@ -558,6 +1018,7 @@ document.addEventListener('keydown', e => {
 // ── Boot ──────────────────────────────────────────────────────────────────────
 
 load();
+loadTestFiles();
 </script>
 </body>
 </html>

+ 1 - 0
bridge/requirements.txt

@@ -5,3 +5,4 @@ numpy>=1.24
 fastapi>=0.111
 uvicorn>=0.29
 python-multipart>=0.0.9
+miniaudio>=1.59