v0.12.0:0 - WhisperX as a one-click dashboard install + managed service

Replaces the manual rsync+build+run with a proper spark-control feature.
First in the audio path that doesn't require shell access on Spark 2.

What's in the box
─────────────────
* image/whisperx_container/   - the build context (Dockerfile, requirements,
  app/main.py FastAPI wrapper). Mainline pipeline: faster-whisper for STT +
  pyannote 3.1 for diarization + wav2vec2 forced alignment. Single endpoint
  /v1/audio/transcribe-with-speakers returns the exact same shape spark-
  control's existing endpoint does, so the recap-relay PR spec needs no
  changes when we cut over.

* image/app/whisperx_install.py - install manager. ships build context to
  Spark 2 over SSH, runs `docker build`, runs `docker run` with 40 GB
  memory cap (vs Sortformer's unbounded which thrashed Spark 2 on a 90-min
  file), polls /health until both Whisper + pyannote report loaded.

* Audio proxy: /api/audio/transcribe-with-speakers now prefers WhisperX
  when its /health reports diarizer_loaded=true, falls back to the legacy
  Parakeet + Sortformer path otherwise. Same response shape either way.
  Clean cutover, easy rollback (`docker rm whisperx-asr`).

* Dashboard (Audio / Speech tab):
  - "Add WhisperX" banner appears when not installed, with a primary
    "Install WhisperX" button. One click triggers the install.
  - Build progress dialog with phase + elapsed timer + live build log via
    SSE (`/api/whisperx/install/{job_id}/stream`).
  - After install, WhisperX auto-registers as a managed service alongside
    Parakeet and Magpie (Start/Restart/Stop, deep-check, auto-restart).
  - Banner self-hides once /api/whisperx/status reports healthy.

New endpoints
─────────────
  GET  /api/whisperx/status
  POST /api/whisperx/install
  GET  /api/whisperx/install/{job_id}
  GET  /api/whisperx/install/{job_id}/stream  (SSE phase + log)

Config additions (env)
──────────────────────
  WHISPERX_HOST       (defaults to spark2_host)
  WHISPERX_USER       (defaults to spark2_user)
  WHISPERX_CONTAINER  (default: whisperx-asr)
  WHISPERX_PORT       (default: 8002)
  WHISPERX_MODEL      (default: medium; tiny/base/small/medium/large-v3)

Dockerfile
──────────
Added COPY whisperx_container /app/whisperx_container so the runtime
install manager can read the build context from inside the spark-control
image and ship it over SSH.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Keysat
2026-05-18 21:02:26 -05:00
parent cfc1c408d4
commit 5a0bfba6a3
14 changed files with 1033 additions and 3 deletions
+119
View File
@@ -664,6 +664,117 @@ async function onSpeechModelsRestart() {
}
}
// ===================== WhisperX install (v0.12) =====================
const wxState = {
job_id: null,
eventsource: null,
timer_handle: null,
started_at: null,
};
async function renderWhisperXBanner() {
const card = el('#whisperx-install-card');
if (!card) return;
let status;
try {
status = await fetchJSON('/api/whisperx/status');
} catch {
card.classList.add('hidden');
return;
}
if (status.installed && status.healthy) {
card.classList.add('hidden');
} else if (status.configured) {
card.classList.remove('hidden');
} else {
card.classList.add('hidden');
}
}
async function onWhisperXInstall() {
if (wxState.job_id) {
// Just re-attach to the running job
showWhisperXDialog();
return;
}
if (!confirm('Install WhisperX on Spark 2? This builds a new Docker image (~1015 min first time, mostly downloading pyannote + whisper weights). Parakeet/Magpie stay untouched.')) return;
try {
const r = await fetchJSON('/api/whisperx/install', { method: 'POST' });
attachToWhisperXInstall(r.job_id);
} catch (e) {
alert('Failed to start WhisperX install: ' + e.message);
}
}
function showWhisperXDialog() {
el('#whisperx-progress-dialog').showModal();
}
function attachToWhisperXInstall(jobId) {
wxState.job_id = jobId;
el('#wx-prog-title').textContent = 'Installing WhisperX…';
el('#wx-prog-phase').textContent = 'Starting…';
el('#wx-prog-log').textContent = '';
showWhisperXDialog();
// Tick a timer
wxState.started_at = Date.now();
if (wxState.timer_handle) clearInterval(wxState.timer_handle);
wxState.timer_handle = setInterval(() => {
const sec = Math.max(0, Math.floor((Date.now() - wxState.started_at) / 1000));
const m = Math.floor(sec / 60);
el('#wx-prog-elapsed').textContent = `${m}:${(sec % 60).toString().padStart(2, '0')}`;
}, 500);
// Backfill snapshot then connect SSE
fetchJSON(`/api/whisperx/install/${jobId}`).then((snap) => {
el('#wx-prog-phase').textContent = snap.phase || 'Working…';
el('#wx-prog-log').textContent = (snap.lines || []).join('\n');
el('#wx-prog-log').scrollTop = el('#wx-prog-log').scrollHeight;
if (snap.finished_at) {
handleWhisperXDone(snap);
return;
}
const es = new EventSource(`/api/whisperx/install/${jobId}/stream`);
wxState.eventsource = es;
es.onmessage = (ev) => {
try {
const log = el('#wx-prog-log');
log.textContent += JSON.parse(ev.data).line + '\n';
log.scrollTop = log.scrollHeight;
} catch {}
};
es.addEventListener('phase', (ev) => {
try { el('#wx-prog-phase').textContent = JSON.parse(ev.data).phase; } catch {}
});
es.addEventListener('done', (ev) => {
try { handleWhisperXDone(JSON.parse(ev.data)); } catch {}
es.close();
wxState.eventsource = null;
});
es.onerror = () => { es.close(); wxState.eventsource = null; };
}).catch(() => {});
}
function handleWhisperXDone(d) {
if (wxState.timer_handle) { clearInterval(wxState.timer_handle); wxState.timer_handle = null; }
wxState.job_id = null;
const rc = d.returncode;
if (d.state === 'failed' || (rc !== 0 && rc != null)) {
el('#wx-prog-title').textContent = `WhisperX install failed (rc=${rc})`;
el('#wx-prog-phase').textContent = 'Failed — check the build log below';
} else {
el('#wx-prog-title').textContent = 'WhisperX installed';
el('#wx-prog-phase').textContent = 'Ready ✓ — appears in Always-on services below';
// Refresh services + banner state
setTimeout(() => {
renderServices();
renderWhisperXBanner();
}, 1000);
}
}
async function onServiceAction(key) {
if (state.service_action_in_flight) return;
const [name, action] = key.split(':');
@@ -1860,6 +1971,11 @@ async function init() {
} catch {}
setupDashboardTabs();
setupEndpointCollapse();
// WhisperX install button
const wxBtn = el('#wx-install');
if (wxBtn) wxBtn.addEventListener('click', onWhisperXInstall);
const wxCloseBtn = el('#wx-prog-close');
if (wxCloseBtn) wxCloseBtn.addEventListener('click', () => el('#whisperx-progress-dialog').close());
await loadModels();
await pollStatus();
await renderServices();
@@ -1869,11 +1985,14 @@ async function init() {
loadDiskStatus();
// Speech-model patches panel — slow over SSH, runs after first paint.
renderSpeechModels();
// WhisperX install banner — show only when not yet installed/healthy.
renderWhisperXBanner();
setInterval(pollStatus, 5000);
setInterval(pollHardware, 8000); // every 8s
setInterval(pollUpdates, 300000); // every 5 min
setInterval(loadDiskStatus, 60000); // every 60s — disk state changes rarely
setInterval(renderSpeechModels, 120000); // every 2 min — patches change rarely
setInterval(renderWhisperXBanner, 60000); // every 60s — auto-hides banner after install
}
init();
+40
View File
@@ -103,6 +103,46 @@
<div class="tab-content" id="tab-audio" role="tabpanel" aria-labelledby="tab-audio-trigger">
<section id="whisperx-install-card" class="whisperx-install hidden">
<div class="wx-install-body">
<div class="wx-install-title">
<strong>Add WhisperX</strong>
<span class="tag ok">recommended</span>
</div>
<p class="muted small">
WhisperX is a single-container speech pipeline (faster-whisper for transcription + pyannote 3.1 for diarization)
designed to handle long audio cleanly. Replaces the Parakeet + Sortformer combo we patched together,
which crashed on a 90-min meeting. Pulled and built directly on Spark 2 (~1015 min first time;
you only do this once).
</p>
<p class="muted small">
Requires a Hugging Face token at <code>~/.cache/huggingface/token</code> on Spark 2 (already set up).
</p>
<div class="wx-install-actions">
<button id="wx-install" class="btn primary">Install WhisperX</button>
</div>
</div>
</section>
<dialog id="whisperx-progress-dialog" class="modal">
<form method="dialog" class="modal-form">
<h3 id="wx-prog-title">Installing WhisperX…</h3>
<div class="phase-row">
<span class="spinner"></span>
<div class="phase" id="wx-prog-phase">Starting…</div>
<span class="spacer"></span>
<span class="timer" id="wx-prog-elapsed">0:00</span>
</div>
<details open>
<summary class="muted small">Build log</summary>
<pre id="wx-prog-log" class="log"></pre>
</details>
<div class="modal-actions">
<button type="button" id="wx-prog-close" class="btn">Close</button>
</div>
</form>
</dialog>
<section id="services-panel" class="services hidden">
<div class="section-header">
<h2 class="section-title">Always-on services</h2>
+13
View File
@@ -906,3 +906,16 @@ main {
}
.tab-content { display: none; }
.tab-content.active { display: block; }
/* ===== WhisperX install banner (v0.12) ===== */
.whisperx-install {
background: var(--surface);
border: 1px solid var(--info);
border-radius: var(--radius);
padding: 16px 18px;
margin-bottom: 20px;
}
.wx-install-body { display: flex; flex-direction: column; gap: 10px; }
.wx-install-title { display: flex; align-items: center; gap: 10px; }
.wx-install-title strong { font-size: 15px; color: var(--text); }
.wx-install-actions { display: flex; gap: 10px; margin-top: 4px; }