v0.11.0:0 - Speech model patches panel (lifecycle for v0.10.0 overlays)

Folds the image/parakeet_patches/apply.sh script into a one-click
dashboard action and adds drift detection so you can see at a glance
whether the parakeet-asr container has the latest Sortformer overlays
that spark-control ships.

Backend:
  * image/app/speech_models.py - SpeechModelsManager: reads /health from
    Parakeet, sha256s the local overlay files inside spark-control's
    Docker image (/app/parakeet_patches), sha256s the same files inside
    the parakeet-asr container via `docker exec ... sha256sum`, surfaces
    in_sync / drift / missing status per file.
  * GET  /api/speech-models           - status payload
  * POST /api/speech-models/reapply   - copies overlays into container,
                                         verifies python syntax, restarts,
                                         polls /health for ~120s, returns
                                         step-by-step result
  * POST /api/speech-models/restart   - plain `docker restart parakeet-asr`

Dockerfile: now COPY parakeet_patches into the image at /app/parakeet_patches
so the runtime can read them. Future spark-control releases auto-carry
newer overlay versions; the panel surfaces drift after upgrade.

Frontend: new "Speech model patches" section on the dashboard with
  * Status pill (in sync / drift / missing)
  * Per-file SHA comparison (local vs container)
  * Loaded-models pills (ASR + diarizer)
  * Reapply + Restart buttons (both with confirmation modals)
  * Live progress display during reapply with per-step ✓/✗

Verified post-install against the running cluster:
  GET /api/speech-models shows both files in_sync (SHAs match) and both
  models loaded ready on Spark 2.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Keysat
2026-05-18 15:58:13 -05:00
parent fda23088fe
commit 391117f705
7 changed files with 620 additions and 2 deletions
+6
View File
@@ -12,6 +12,12 @@ RUN chmod +x /app/entrypoint.sh
COPY models.yaml /app/models.yaml
# Parakeet container wrapper patches (diarizer.py + main.py overlay).
# Shipped inside spark-control so the "Reapply speech-model patches" action
# can copy these into the parakeet-asr container on Spark 2 over SSH at any
# time — survives docker rm + redeploy of the parakeet container.
COPY parakeet_patches /app/parakeet_patches
RUN pip install --no-cache-dir -e .
ENV BIND_PORT=9999
+40
View File
@@ -22,6 +22,7 @@ from .models import load_catalog
from .nim import SUGGESTED_NIMS, CATALOG_URL, NimManager
from .overrides import add_custom, delete_custom, extract_knobs_from_args, load_overrides, set_knobs
from .services import docker_state, run_action, services_from_settings
from .speech_models import SpeechModelsManager
from .ssh import ssh_run
from .swap import SwapManager
from .updates import UpdateManager, get_update_status
@@ -37,6 +38,7 @@ update_manager = UpdateManager(settings)
hardware_probe = HardwareProbe(settings)
nim_manager = NimManager(settings)
deep_health = DeepHealth(settings)
speech_models = SpeechModelsManager(settings)
app = FastAPI(title="spark-control", version="0.1.0")
@@ -495,6 +497,44 @@ async def service_action(name: str, action: str) -> dict:
return {"name": name, "action": action, **result}
# ---- Speech model patch management ----
@app.get("/api/speech-models")
async def get_speech_models() -> dict:
"""Status of the parakeet-asr container + the spark-control overlay patches
(diarizer.py + main.py). Drift between local shipped patches and what's
inside the container is surfaced so the UI can prompt for reapply."""
return await speech_models.status()
@app.post("/api/speech-models/reapply")
async def post_speech_models_reapply() -> dict:
"""Copy spark-control's shipped diarizer.py + patched main.py into the
parakeet-asr container, verify Python syntax, restart the container, and
wait for both models (Parakeet ASR + Sortformer) to reload. ~60120 seconds."""
try:
result = await speech_models.reapply_patches()
except RuntimeError as e:
raise HTTPException(409, str(e))
if not result.get("ok"):
# Bubble up which step failed for client-side error rendering.
raise HTTPException(500, {"detail": "patch reapply failed", "result": result})
return result
@app.post("/api/speech-models/restart")
async def post_speech_models_restart() -> dict:
"""`docker restart parakeet-asr` only — no file changes. Useful when the
container's models look wedged but patches are already current."""
try:
result = await speech_models.restart_container()
except RuntimeError as e:
raise HTTPException(409, str(e))
if not result.get("ok"):
raise HTTPException(500, {"detail": "container restart failed", "result": result})
return result
@app.get("/api/endpoints")
async def get_endpoints() -> dict:
"""Service-discovery summary. Stable shape; other apps on the LAN can poll this
+319
View File
@@ -0,0 +1,319 @@
"""Speech-model patch management for the parakeet-asr container on Spark 2.
The parakeet-asr container ships with a stock FastAPI wrapper that only supports
ASR (Parakeet TDT). Spark Control augments it with two overlay files —
`diarizer.py` and a patched `main.py` — that add Sortformer-based diarization
and the `/v1/audio/diarize` endpoint.
These overlays survive `docker restart` (writable layer) but NOT `docker rm`
(volume rebuild). If the parakeet container is ever recreated, the overlays
need to be re-applied. This module handles that:
- GET /api/speech-models → current state (loaded models, patch
checksums, drift detection)
- POST /api/speech-models/reapply → copy overlays from spark-control's
shipped /app/parakeet_patches into
the parakeet container + restart
- POST /api/speech-models/restart → just `docker restart parakeet-asr`,
no overlay changes
"""
from __future__ import annotations
import asyncio
import hashlib
import json
import shlex
from datetime import datetime, timezone
from pathlib import Path
from typing import Optional
import httpx
from .config import Settings
from .connectivity import record_report
from .ssh import ssh_run
# /app/parakeet_patches inside the spark-control container image (set up by
# the Dockerfile COPY directive). Each file under here is the canonical
# version we'd push into the parakeet container.
PATCHES_DIR = Path(__file__).resolve().parent.parent / "parakeet_patches"
# Files we manage. Mapped local-source-path -> destination-path-in-container.
MANAGED_FILES = {
"diarizer.py": "/opt/parakeet/app/diarizer.py",
"main.py": "/opt/parakeet/app/main.py",
}
def _sha256_short(text: bytes) -> str:
return hashlib.sha256(text).hexdigest()[:12]
def _local_patches() -> dict[str, dict]:
"""Read the canonical patch files shipped inside spark-control.
Returns: {local_name: {"path": str, "sha": str, "size": int, "missing": bool}}
"""
out: dict[str, dict] = {}
for local_name in MANAGED_FILES:
p = PATCHES_DIR / local_name
if not p.exists():
out[local_name] = {"path": str(p), "missing": True}
continue
body = p.read_bytes()
out[local_name] = {
"path": str(p),
"sha": _sha256_short(body),
"size": len(body),
"missing": False,
}
return out
async def _parakeet_health(settings: Settings) -> dict:
"""Pull current model loading state from Parakeet's /health endpoint."""
url = f"http://{settings.parakeet_host}:{settings.parakeet_port}/health"
try:
async with httpx.AsyncClient(timeout=4.0) as client:
r = await client.get(url)
if r.status_code == 200:
return r.json()
return {"reachable": False, "status_code": r.status_code, "error": r.text[:200]}
except Exception as e:
return {"reachable": False, "error": f"{type(e).__name__}: {e}"}
async def _remote_file_sha(settings: Settings, container_path: str) -> Optional[str]:
"""sha256 of a file inside the parakeet container, or None if missing/error."""
if not settings.parakeet_host or not settings.parakeet_user:
return None
cmd = (
f"docker exec parakeet-asr sh -c "
f"'[ -f {shlex.quote(container_path)} ] && "
f"sha256sum {shlex.quote(container_path)} 2>/dev/null | cut -c1-12 || echo MISSING'"
)
rc, out, _ = await ssh_run(settings.parakeet_host, settings.parakeet_user, cmd, settings, timeout=15)
if rc != 0:
return None
s = out.strip()
if s == "MISSING" or not s:
return None
return s
class SpeechModelsManager:
"""Tracks last-reapply state in-memory; persists nothing across spark-control
restarts (the source-of-truth is what's actually inside the parakeet
container, which we read fresh on every status call)."""
def __init__(self, settings: Settings) -> None:
self.settings = settings
self.last_reapply_at: Optional[str] = None
self.last_reapply_result: Optional[dict] = None
self.last_restart_at: Optional[str] = None
self._reapply_lock = asyncio.Lock()
async def status(self) -> dict:
"""Build the full speech-models status payload for the UI.
Compares the SHAs of files we shipped inside spark-control vs what's
actually running inside the parakeet container — surfaces drift if
patches were applied from an older spark-control version, or never
applied at all.
"""
local = _local_patches()
health = await _parakeet_health(self.settings)
# Probe remote SHAs in parallel
async def _probe(local_name: str) -> tuple[str, Optional[str]]:
return local_name, await _remote_file_sha(self.settings, MANAGED_FILES[local_name])
remote_results = await asyncio.gather(*(_probe(n) for n in MANAGED_FILES))
remote = {name: sha for name, sha in remote_results}
files = []
all_in_sync = True
any_missing_remote = False
for local_name in MANAGED_FILES:
local_info = local.get(local_name, {})
local_sha = local_info.get("sha")
remote_sha = remote.get(local_name)
in_sync = bool(local_sha) and (local_sha == remote_sha)
if not in_sync:
all_in_sync = False
if remote_sha is None:
any_missing_remote = True
files.append({
"name": local_name,
"container_path": MANAGED_FILES[local_name],
"local_sha": local_sha,
"remote_sha": remote_sha,
"in_sync": in_sync,
"size_bytes": local_info.get("size"),
})
# Coarse status for the UI to render a single pill
if any_missing_remote:
patch_status = "missing" # overlay files missing in container
elif all_in_sync:
patch_status = "in_sync"
else:
patch_status = "drift" # local files newer than container
return {
"container_health": health,
"patches": {
"status": patch_status,
"files": files,
"last_reapply_at": self.last_reapply_at,
"last_reapply_result": self.last_reapply_result,
"last_restart_at": self.last_restart_at,
},
}
async def reapply_patches(self) -> dict:
"""Copy the patches shipped inside spark-control into the parakeet
container, verify syntax, and restart it. Same logic as apply.sh but
run from inside spark-control's FastAPI process."""
if self._reapply_lock.locked():
raise RuntimeError("a patch reapply is already in progress")
async with self._reapply_lock:
return await self._do_reapply()
async def _do_reapply(self) -> dict:
s = self.settings
if not s.parakeet_host or not s.parakeet_user:
raise RuntimeError("parakeet host/user not configured")
steps: list[dict] = []
# 0. Verify local patches present
local = _local_patches()
for name, info in local.items():
if info.get("missing"):
steps.append({"step": "verify_local", "ok": False, "name": name, "error": "patch file missing inside spark-control image"})
return self._finish_reapply(False, steps)
steps.append({"step": "verify_local", "ok": True, "files": list(local.keys())})
# 1. Backup main.py inside container (idempotent — only if backup doesn't already exist)
backup_cmd = (
"docker exec parakeet-asr sh -c '"
"test -f /opt/parakeet/app/main.py.pre-sortformer || "
"cp /opt/parakeet/app/main.py /opt/parakeet/app/main.py.pre-sortformer"
"'"
)
rc, out, err = await ssh_run(s.parakeet_host, s.parakeet_user, backup_cmd, s, timeout=15)
steps.append({"step": "backup_original", "ok": rc == 0, "stdout": out.strip()[:200], "stderr": err.strip()[:200]})
if rc != 0:
return self._finish_reapply(False, steps)
# 2. Copy each patch file into the container via `docker exec -i ... 'cat > path'`
for local_name, container_path in MANAGED_FILES.items():
local_body = (PATCHES_DIR / local_name).read_bytes()
copy_cmd = f"docker exec -i parakeet-asr sh -c {shlex.quote('cat > ' + container_path)}"
ok, out, err = await self._ssh_pipe_to_remote(
s.parakeet_host, s.parakeet_user, copy_cmd, local_body, s, timeout=30
)
steps.append({"step": "copy_file", "name": local_name, "ok": ok,
"bytes": len(local_body), "stdout": out[:200], "stderr": err[:200]})
if not ok:
return self._finish_reapply(False, steps)
# 3. Verify Python syntax inside the container
syntax_cmd = (
"docker exec parakeet-asr python3 -c "
"'import ast; "
"ast.parse(open(\"/opt/parakeet/app/diarizer.py\").read()); "
"ast.parse(open(\"/opt/parakeet/app/main.py\").read()); "
"print(\"py OK\")'"
)
rc, out, err = await ssh_run(s.parakeet_host, s.parakeet_user, syntax_cmd, s, timeout=30)
ok = rc == 0 and "py OK" in out
steps.append({"step": "verify_syntax", "ok": ok, "stdout": out.strip()[:300], "stderr": err.strip()[:300]})
if not ok:
return self._finish_reapply(False, steps)
# 4. Restart the container
restart_cmd = "docker restart parakeet-asr"
rc, out, err = await ssh_run(s.parakeet_host, s.parakeet_user, restart_cmd, s, timeout=60)
steps.append({"step": "docker_restart", "ok": rc == 0, "stdout": out.strip()[:200], "stderr": err.strip()[:200]})
if rc != 0:
return self._finish_reapply(False, steps)
# 5. Poll /health until both models are loaded again (up to ~120s)
loaded = False
for _ in range(40):
await asyncio.sleep(3)
h = await _parakeet_health(s)
if h.get("asr_loaded") and h.get("diarizer_loaded"):
loaded = True
steps.append({"step": "verify_health", "ok": True, "asr_loaded": True, "diarizer_loaded": True})
break
if not loaded:
steps.append({"step": "verify_health", "ok": False, "error": "models did not load within 120s"})
return self._finish_reapply(False, steps)
return self._finish_reapply(True, steps)
def _finish_reapply(self, success: bool, steps: list[dict]) -> dict:
now = datetime.now(timezone.utc).isoformat().replace("+00:00", "Z")
self.last_reapply_at = now
result = {"ok": success, "at": now, "steps": steps}
self.last_reapply_result = result
record_report(
"parakeet",
ok=success,
source="speech-models-reapply",
detail=f"reapply patches: {'OK' if success else 'FAILED at step ' + str([s for s in steps if not s.get('ok')][:1])}",
)
return result
async def restart_container(self) -> dict:
"""Restart the parakeet-asr container without changing any files."""
s = self.settings
if not s.parakeet_host or not s.parakeet_user:
raise RuntimeError("parakeet host/user not configured")
rc, out, err = await ssh_run(s.parakeet_host, s.parakeet_user,
"docker restart parakeet-asr", s, timeout=60)
ok = rc == 0
now = datetime.now(timezone.utc).isoformat().replace("+00:00", "Z")
self.last_restart_at = now
record_report(
"parakeet",
ok=ok,
source="speech-models-restart",
detail=f"manual restart: {'OK' if ok else 'rc=' + str(rc) + ' ' + err.strip()[:120]}",
)
return {"ok": ok, "at": now, "stdout": out.strip()[:200], "stderr": err.strip()[:200]}
async def _ssh_pipe_to_remote(
self,
host: str,
user: str,
remote_cmd: str,
payload: bytes,
settings: Settings,
timeout: float = 30.0,
) -> tuple[bool, str, str]:
"""Run `ssh user@host <remote_cmd>` while piping `payload` to its stdin.
This is the bash equivalent of `ssh ... '<cmd>' < local_file`.
Returns (success, stdout_str, stderr_str)."""
from .ssh import _base_args
args = _base_args(settings) + [f"{user}@{host}", remote_cmd]
proc = await asyncio.create_subprocess_exec(
*args,
stdin=asyncio.subprocess.PIPE,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
)
try:
stdout_b, stderr_b = await asyncio.wait_for(
proc.communicate(input=payload), timeout=timeout
)
except asyncio.TimeoutError:
proc.kill()
await proc.wait()
return False, "", f"timeout after {timeout}s"
ok = proc.returncode == 0
return ok, stdout_b.decode(errors="replace"), stderr_b.decode(errors="replace")
+135
View File
@@ -532,6 +532,138 @@ async function onDeepHealthRun(name, btn) {
}
}
// ===================== speech-model patches (v0.11) =====================
async function renderSpeechModels() {
const panel = el('#speech-models-panel');
const card = el('#speech-models-card');
if (!panel || !card) return;
let data;
try {
data = await fetchJSON('/api/speech-models');
} catch (e) {
// If parakeet host isn't even configured, hide the section entirely
panel.classList.add('hidden');
return;
}
if (!data || !data.patches) { panel.classList.add('hidden'); return; }
panel.classList.remove('hidden');
const patches = data.patches || {};
const health = data.container_health || {};
const status = patches.status || 'unknown';
let statusPill;
if (status === 'in_sync') {
statusPill = `<span class="sm-pill ok">patches in sync</span>`;
} else if (status === 'drift') {
statusPill = `<span class="sm-pill warn">spark-control has newer patches</span>`;
} else if (status === 'missing') {
statusPill = `<span class="sm-pill bad">patches missing in container</span>`;
} else {
statusPill = `<span class="sm-pill warn">unknown</span>`;
}
const asrLoaded = !!health.asr_loaded;
const diarLoaded = !!health.diarizer_loaded;
const asrModel = escapeHtml(health.model || '—');
const diarModel = escapeHtml(health.diarizer_model || '—');
const fileRows = (patches.files || []).map((f) => {
const sync = f.in_sync
? '<span class="sm-file-ok">✓ in sync</span>'
: f.remote_sha == null
? '<span class="sm-file-bad">✗ missing</span>'
: '<span class="sm-file-warn">⚠ drift</span>';
const local = f.local_sha ? `<code>${escapeHtml(f.local_sha)}</code>` : '<span class="muted">—</span>';
const remote = f.remote_sha ? `<code>${escapeHtml(f.remote_sha)}</code>` : '<span class="muted">—</span>';
return `
<div class="sm-file-row">
<span class="sm-file-name"><code>${escapeHtml(f.name)}</code></span>
<span class="sm-file-sync">${sync}</span>
<span class="sm-file-sha muted small">local ${local} → remote ${remote}</span>
</div>
`;
}).join('');
const lastReapply = patches.last_reapply_at ? new Date(patches.last_reapply_at).toLocaleString() : 'never (since spark-control boot)';
const lastRestart = patches.last_restart_at ? new Date(patches.last_restart_at).toLocaleString() : 'never (since spark-control boot)';
card.innerHTML = `
<div class="sm-header">
<div class="sm-title">parakeet-asr container</div>
${statusPill}
</div>
<div class="sm-models">
<div class="sm-model-row">
<span class="sm-model-kind">Parakeet ASR</span>
<span class="sm-model-name">${asrModel}</span>
<span class="sm-model-loaded">${asrLoaded ? '<span class="sm-pill ok">loaded</span>' : '<span class="sm-pill bad">not loaded</span>'}</span>
</div>
<div class="sm-model-row">
<span class="sm-model-kind">Sortformer diarizer</span>
<span class="sm-model-name">${diarModel}</span>
<span class="sm-model-loaded">${diarLoaded ? '<span class="sm-pill ok">loaded</span>' : '<span class="sm-pill bad">not loaded</span>'}</span>
</div>
</div>
<div class="sm-files">${fileRows}</div>
<div class="sm-meta muted small">
Last reapply: ${escapeHtml(lastReapply)} · Last manual restart: ${escapeHtml(lastRestart)}
</div>
<div class="sm-actions">
<button class="btn primary" id="sm-reapply">Reapply patches</button>
<button class="btn" id="sm-restart">Restart container</button>
</div>
`;
el('#sm-reapply').addEventListener('click', onSpeechModelsReapply);
el('#sm-restart').addEventListener('click', onSpeechModelsRestart);
}
async function onSpeechModelsReapply() {
if (!confirm('Reapply Sortformer patches to the parakeet-asr container? The container will restart and both ASR + diarizer will be unavailable for ~60120 seconds.')) return;
const dlg = el('#speech-models-progress-dialog');
const steps = el('#sm-prog-steps');
const closeBtn = el('#sm-prog-close');
steps.innerHTML = '<div class="muted small">Starting…</div>';
closeBtn.disabled = true;
closeBtn.onclick = () => dlg.close();
dlg.showModal();
try {
const r = await fetchJSON('/api/speech-models/reapply', { method: 'POST' });
steps.innerHTML = (r.steps || []).map((s) => {
const mark = s.ok ? '<span class="sm-file-ok">✓</span>' : '<span class="sm-file-bad">✗</span>';
const extra = s.error ? `<div class="muted small">${escapeHtml(s.error)}</div>` : '';
return `<div class="sm-prog-step">${mark} <strong>${escapeHtml(s.step)}</strong>${s.name ? ` (${escapeHtml(s.name)})` : ''}${extra}</div>`;
}).join('') + `<div class="sm-prog-done sm-file-ok">Done — both models reloaded.</div>`;
} catch (e) {
let parsed = null;
try { parsed = JSON.parse(e.message.split(':').slice(2).join(':').trim()); } catch {}
const stepHtml = parsed && parsed.result && parsed.result.steps
? parsed.result.steps.map((s) => {
const mark = s.ok ? '<span class="sm-file-ok">✓</span>' : '<span class="sm-file-bad">✗</span>';
return `<div class="sm-prog-step">${mark} <strong>${escapeHtml(s.step)}</strong>${s.name ? ` (${escapeHtml(s.name)})` : ''}${s.error ? `<div class="muted small">${escapeHtml(s.error)}</div>` : ''}</div>`;
}).join('')
: `<div class="sm-file-bad">${escapeHtml(e.message)}</div>`;
steps.innerHTML = stepHtml + `<div class="sm-prog-done sm-file-bad">Failed.</div>`;
} finally {
closeBtn.disabled = false;
try { await renderSpeechModels(); } catch {}
}
}
async function onSpeechModelsRestart() {
if (!confirm('Restart parakeet-asr container? STT + diarization will be unavailable for ~30 seconds.')) return;
try {
await fetchJSON('/api/speech-models/restart', { method: 'POST' });
} catch (e) {
alert('Restart failed: ' + e.message);
} finally {
try { await renderSpeechModels(); } catch {}
}
}
async function onServiceAction(key) {
if (state.service_action_in_flight) return;
const [name, action] = key.split(':');
@@ -1675,10 +1807,13 @@ async function init() {
pollUpdates();
// Disk-status probe runs after first paint — slow over SSH and not blocking.
loadDiskStatus();
// Speech-model patches panel — slow over SSH, runs after first paint.
renderSpeechModels();
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
}
init();
+24
View File
@@ -152,6 +152,30 @@
</dialog>
</section>
<section id="speech-models-panel" class="speech-models hidden">
<div class="section-header">
<h2 class="section-title">Speech model patches</h2>
</div>
<p class="muted small sm-blurb">
Spark Control adds Sortformer speaker diarization to the third-party Parakeet ASR
container via two Python overlays (<code>diarizer.py</code> + a patched <code>main.py</code>).
Overlays survive container restart but not a fresh redeploy — if the parakeet container is
ever rebuilt, click <strong>Reapply patches</strong> below to restore them.
</p>
<div id="speech-models-card" class="speech-models-card"></div>
<dialog id="speech-models-progress-dialog" class="modal">
<form method="dialog" class="modal-form">
<h3>Reapplying speech-model patches…</h3>
<p class="muted small">Copying overlays into the parakeet container, verifying syntax, restarting, waiting for both models to load. Takes ~60120 s.</p>
<div id="sm-prog-steps" class="sm-prog-steps"></div>
<div class="modal-actions">
<button type="button" id="sm-prog-close" class="btn" disabled>Close</button>
</div>
</form>
</dialog>
</section>
<section id="models-section">
<div class="section-header">
<h2 class="section-title">LLM swap</h2>
+94
View File
@@ -764,3 +764,97 @@ main {
main { padding: 16px 14px 80px; }
.cards { grid-template-columns: 1fr; }
}
/* ===== Speech model patches (v0.11) ===== */
.speech-models { margin-top: 28px; }
.sm-blurb { max-width: 880px; margin-bottom: 14px; }
.sm-blurb code {
background: var(--surface-2);
padding: 1px 6px;
border-radius: 4px;
font-size: 12px;
}
.speech-models-card {
background: var(--surface);
border: 1px solid var(--border);
border-radius: 10px;
padding: 16px;
display: flex;
flex-direction: column;
gap: 14px;
}
.sm-header {
display: flex;
align-items: center;
gap: 10px;
}
.sm-title {
font-weight: 600;
color: var(--text);
}
.sm-pill {
font-size: 11px;
padding: 2px 8px;
border-radius: 999px;
border: 1px solid var(--border);
background: var(--surface-2);
}
.sm-pill.ok { color: var(--accent); border-color: rgba(74, 222, 128, 0.4); }
.sm-pill.warn { color: var(--warn); border-color: rgba(245, 158, 11, 0.4); }
.sm-pill.bad { color: var(--error); border-color: rgba(239, 68, 68, 0.4); }
.sm-models { display: flex; flex-direction: column; gap: 6px; }
.sm-model-row {
display: grid;
grid-template-columns: 160px 1fr auto;
align-items: center;
gap: 12px;
padding: 6px 0;
border-top: 1px solid var(--border);
}
.sm-model-row:first-child { border-top: none; }
.sm-model-kind { color: var(--muted); font-size: 13px; }
.sm-model-name { font-family: ui-monospace, monospace; font-size: 12px; word-break: break-all; }
.sm-files { display: flex; flex-direction: column; gap: 4px; }
.sm-file-row {
display: grid;
grid-template-columns: 160px 100px 1fr;
gap: 12px;
font-size: 12px;
padding: 4px 0;
}
.sm-file-name code {
background: var(--surface-2);
padding: 1px 6px;
border-radius: 4px;
}
.sm-file-ok { color: var(--accent); }
.sm-file-warn { color: var(--warn); }
.sm-file-bad { color: var(--error); }
.sm-file-sha code {
background: var(--surface-2);
padding: 1px 4px;
border-radius: 3px;
font-size: 11px;
}
.sm-meta { margin-top: 4px; }
.sm-actions { display: flex; gap: 10px; }
.sm-prog-steps {
display: flex;
flex-direction: column;
gap: 6px;
margin: 12px 0;
font-size: 13px;
}
.sm-prog-step {
padding: 6px 10px;
background: var(--surface-2);
border-radius: 6px;
}
.sm-prog-done {
font-weight: 600;
margin-top: 8px;
}
+2 -2
View File
@@ -1,10 +1,10 @@
import { VersionInfo, IMPOSSIBLE } from '@start9labs/start-sdk'
export const v0_1_0 = VersionInfo.of({
version: '0.10.0:1',
version: '0.11.0:0',
releaseNotes: {
en_US:
'v0.10.0:1fix: in 0.10.0:0 the /api/audio/transcribe-with-speakers merge function joined word tokens without spaces (e.g. "I\'mrecordingrightnow") because it assumed Parakeet returned words with leading whitespace. Spark Parakeet returns them without. Rewrote the joiner to strip each token, separate with a single space, and keep punctuation tight (no space before period/comma/colon/etc.). No other changes — Parakeet container patches and the endpoint shape stay the same.',
'v0.11.0 — Speech model patches panel. New section on the dashboard shows the live state of the parakeet-asr container\'s Spark Control overlays (diarizer.py + patched main.py): which models are loaded, sha256 of each overlay file inside the container vs. what spark-control ships, and drift detection. Two actions: "Reapply patches" (folds image/parakeet_patches/apply.sh into a one-click action — copies the latest overlays from inside spark-control into the parakeet container, verifies Python syntax, restarts, waits for both Parakeet ASR and Sortformer to reload) and "Restart container" (plain docker restart, no file changes). The overlays now ship inside the spark-control Docker image, so a future spark-control upgrade automatically carries newer overlay versions — the panel surfaces drift and prompts to reapply. Backend: new /api/speech-models, /api/speech-models/reapply, /api/speech-models/restart endpoints. No changes to the transcription path itself; this is purely lifecycle management for the v0.10.0 overlays.',
},
migrations: {
up: async ({ effects }) => {},