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:
@@ -12,6 +12,12 @@ RUN chmod +x /app/entrypoint.sh
|
|||||||
|
|
||||||
COPY models.yaml /app/models.yaml
|
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 .
|
RUN pip install --no-cache-dir -e .
|
||||||
|
|
||||||
ENV BIND_PORT=9999
|
ENV BIND_PORT=9999
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ from .models import load_catalog
|
|||||||
from .nim import SUGGESTED_NIMS, CATALOG_URL, NimManager
|
from .nim import SUGGESTED_NIMS, CATALOG_URL, NimManager
|
||||||
from .overrides import add_custom, delete_custom, extract_knobs_from_args, load_overrides, set_knobs
|
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 .services import docker_state, run_action, services_from_settings
|
||||||
|
from .speech_models import SpeechModelsManager
|
||||||
from .ssh import ssh_run
|
from .ssh import ssh_run
|
||||||
from .swap import SwapManager
|
from .swap import SwapManager
|
||||||
from .updates import UpdateManager, get_update_status
|
from .updates import UpdateManager, get_update_status
|
||||||
@@ -37,6 +38,7 @@ update_manager = UpdateManager(settings)
|
|||||||
hardware_probe = HardwareProbe(settings)
|
hardware_probe = HardwareProbe(settings)
|
||||||
nim_manager = NimManager(settings)
|
nim_manager = NimManager(settings)
|
||||||
deep_health = DeepHealth(settings)
|
deep_health = DeepHealth(settings)
|
||||||
|
speech_models = SpeechModelsManager(settings)
|
||||||
|
|
||||||
app = FastAPI(title="spark-control", version="0.1.0")
|
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}
|
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. ~60–120 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")
|
@app.get("/api/endpoints")
|
||||||
async def get_endpoints() -> dict:
|
async def get_endpoints() -> dict:
|
||||||
"""Service-discovery summary. Stable shape; other apps on the LAN can poll this
|
"""Service-discovery summary. Stable shape; other apps on the LAN can poll this
|
||||||
|
|||||||
@@ -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")
|
||||||
@@ -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 ~60–120 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) {
|
async function onServiceAction(key) {
|
||||||
if (state.service_action_in_flight) return;
|
if (state.service_action_in_flight) return;
|
||||||
const [name, action] = key.split(':');
|
const [name, action] = key.split(':');
|
||||||
@@ -1675,10 +1807,13 @@ async function init() {
|
|||||||
pollUpdates();
|
pollUpdates();
|
||||||
// Disk-status probe runs after first paint — slow over SSH and not blocking.
|
// Disk-status probe runs after first paint — slow over SSH and not blocking.
|
||||||
loadDiskStatus();
|
loadDiskStatus();
|
||||||
|
// Speech-model patches panel — slow over SSH, runs after first paint.
|
||||||
|
renderSpeechModels();
|
||||||
setInterval(pollStatus, 5000);
|
setInterval(pollStatus, 5000);
|
||||||
setInterval(pollHardware, 8000); // every 8s
|
setInterval(pollHardware, 8000); // every 8s
|
||||||
setInterval(pollUpdates, 300000); // every 5 min
|
setInterval(pollUpdates, 300000); // every 5 min
|
||||||
setInterval(loadDiskStatus, 60000); // every 60s — disk state changes rarely
|
setInterval(loadDiskStatus, 60000); // every 60s — disk state changes rarely
|
||||||
|
setInterval(renderSpeechModels, 120000); // every 2 min — patches change rarely
|
||||||
}
|
}
|
||||||
|
|
||||||
init();
|
init();
|
||||||
|
|||||||
@@ -152,6 +152,30 @@
|
|||||||
</dialog>
|
</dialog>
|
||||||
</section>
|
</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 ~60–120 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">
|
<section id="models-section">
|
||||||
<div class="section-header">
|
<div class="section-header">
|
||||||
<h2 class="section-title">LLM swap</h2>
|
<h2 class="section-title">LLM swap</h2>
|
||||||
|
|||||||
@@ -764,3 +764,97 @@ main {
|
|||||||
main { padding: 16px 14px 80px; }
|
main { padding: 16px 14px 80px; }
|
||||||
.cards { grid-template-columns: 1fr; }
|
.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;
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
import { VersionInfo, IMPOSSIBLE } from '@start9labs/start-sdk'
|
import { VersionInfo, IMPOSSIBLE } from '@start9labs/start-sdk'
|
||||||
|
|
||||||
export const v0_1_0 = VersionInfo.of({
|
export const v0_1_0 = VersionInfo.of({
|
||||||
version: '0.10.0:1',
|
version: '0.11.0:0',
|
||||||
releaseNotes: {
|
releaseNotes: {
|
||||||
en_US:
|
en_US:
|
||||||
'v0.10.0:1 — fix: 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: {
|
migrations: {
|
||||||
up: async ({ effects }) => {},
|
up: async ({ effects }) => {},
|
||||||
|
|||||||
Reference in New Issue
Block a user