From 391117f7051096c1153d6b265e90a59c43cc52c7 Mon Sep 17 00:00:00 2001 From: Keysat Date: Mon, 18 May 2026 15:58:13 -0500 Subject: [PATCH] v0.11.0:0 - Speech model patches panel (lifecycle for v0.10.0 overlays) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- image/Dockerfile | 6 + image/app/server.py | 40 ++++ image/app/speech_models.py | 319 +++++++++++++++++++++++++++++ image/app/static/app.js | 135 ++++++++++++ image/app/static/index.html | 24 +++ image/app/static/style.css | 94 +++++++++ package/startos/versions/v0_1_0.ts | 4 +- 7 files changed, 620 insertions(+), 2 deletions(-) create mode 100644 image/app/speech_models.py diff --git a/image/Dockerfile b/image/Dockerfile index f2a9b85..63a91e0 100644 --- a/image/Dockerfile +++ b/image/Dockerfile @@ -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 diff --git a/image/app/server.py b/image/app/server.py index d032915..a0a9eac 100644 --- a/image/app/server.py +++ b/image/app/server.py @@ -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. ~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") async def get_endpoints() -> dict: """Service-discovery summary. Stable shape; other apps on the LAN can poll this diff --git a/image/app/speech_models.py b/image/app/speech_models.py new file mode 100644 index 0000000..7242de6 --- /dev/null +++ b/image/app/speech_models.py @@ -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 ` while piping `payload` to its stdin. + This is the bash equivalent of `ssh ... '' < 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") diff --git a/image/app/static/app.js b/image/app/static/app.js index d9f816c..c059169 100644 --- a/image/app/static/app.js +++ b/image/app/static/app.js @@ -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 = `patches in sync`; + } else if (status === 'drift') { + statusPill = `spark-control has newer patches`; + } else if (status === 'missing') { + statusPill = `patches missing in container`; + } else { + statusPill = `unknown`; + } + + 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 + ? '✓ in sync' + : f.remote_sha == null + ? '✗ missing' + : '⚠ drift'; + const local = f.local_sha ? `${escapeHtml(f.local_sha)}` : ''; + const remote = f.remote_sha ? `${escapeHtml(f.remote_sha)}` : ''; + return ` +
+ ${escapeHtml(f.name)} + ${sync} + local ${local} → remote ${remote} +
+ `; + }).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 = ` +
+
parakeet-asr container
+ ${statusPill} +
+
+
+ Parakeet ASR + ${asrModel} + ${asrLoaded ? 'loaded' : 'not loaded'} +
+
+ Sortformer diarizer + ${diarModel} + ${diarLoaded ? 'loaded' : 'not loaded'} +
+
+
${fileRows}
+
+ Last reapply: ${escapeHtml(lastReapply)} · Last manual restart: ${escapeHtml(lastRestart)} +
+
+ + +
+ `; + + 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 = '
Starting…
'; + 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 ? '' : ''; + const extra = s.error ? `
${escapeHtml(s.error)}
` : ''; + return `
${mark} ${escapeHtml(s.step)}${s.name ? ` (${escapeHtml(s.name)})` : ''}${extra}
`; + }).join('') + `
Done — both models reloaded.
`; + } 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 ? '' : ''; + return `
${mark} ${escapeHtml(s.step)}${s.name ? ` (${escapeHtml(s.name)})` : ''}${s.error ? `
${escapeHtml(s.error)}
` : ''}
`; + }).join('') + : `
${escapeHtml(e.message)}
`; + steps.innerHTML = stepHtml + `
Failed.
`; + } 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(); diff --git a/image/app/static/index.html b/image/app/static/index.html index bedb0db..141e1fc 100644 --- a/image/app/static/index.html +++ b/image/app/static/index.html @@ -152,6 +152,30 @@ + +

LLM swap

diff --git a/image/app/static/style.css b/image/app/static/style.css index 67e1c96..e184c1b 100644 --- a/image/app/static/style.css +++ b/image/app/static/style.css @@ -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; +} diff --git a/package/startos/versions/v0_1_0.ts b/package/startos/versions/v0_1_0.ts index fa05889..f7cc143 100644 --- a/package/startos/versions/v0_1_0.ts +++ b/package/startos/versions/v0_1_0.ts @@ -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: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: { up: async ({ effects }) => {},