138 lines
12 KiB
Plaintext
138 lines
12 KiB
Plaintext
<section class="card transcription-overview" data-transcription-admin>
|
||
<div class="section-header">
|
||
<%- include("../../../src/web/views/partials/page-header", {
|
||
eyebrow: "Experimental companion",
|
||
pageTitle: "Lumi Transcription",
|
||
description: "Server-hosted speech recognition for selected OBS sources, managed through one Lumi Companion application."
|
||
}) %>
|
||
<span class="status-indicator <%= providerHealth.healthy ? 'status-success' : 'status-warning' %>">
|
||
<%= providerHealth.healthy ? 'Inference ready' : 'Setup required' %>
|
||
</span>
|
||
</div>
|
||
<div class="callout warning">
|
||
<strong>Live Twitch delivery is not accepted yet</strong>
|
||
<p>The native OBS 31+ caption path still needs target-machine validation. Lumi will not present a local simulated caption as live Twitch success.</p>
|
||
</div>
|
||
</section>
|
||
|
||
<section class="card setup-path" aria-labelledby="setup-title" data-transcription-admin>
|
||
<div class="section-header">
|
||
<div><span class="eyebrow">First usable path</span><h2 id="setup-title">Setup progress</h2><p class="hint">Complete one clear step at a time. Existing server administration remains in Lumi; streaming-computer controls remain in Companion.</p></div>
|
||
</div>
|
||
<ol>
|
||
<li class="<%= devices.some((device) => !device.revoked_at) ? 'is-complete' : 'is-current' %>"><strong>Download and pair Companion</strong><span>Create a short-lived, single-use package for the streaming computer.</span></li>
|
||
<li><strong>Install the OBS integration</strong><span>Companion owns installation and repair; the bridge has no separate settings.</span></li>
|
||
<li class="<%= providerHealth.model_ready ? 'is-complete' : '' %>"><strong>Load and benchmark a model</strong><span>Small English is the recommended starting point.</span></li>
|
||
<li><strong>Select and test a microphone</strong><span>The real safe test must pass before live delivery is enabled.</span></li>
|
||
</ol>
|
||
<div class="inline-actions">
|
||
<button class="button" type="button" data-create-pairing>Download Companion</button>
|
||
<a class="button subtle" href="#speech-models">Review speech models</a>
|
||
</div>
|
||
<p class="hint" role="status" aria-live="polite" data-status></p>
|
||
</section>
|
||
|
||
<div class="transcription-grid" data-transcription-admin>
|
||
<section class="card" aria-labelledby="runtime-title">
|
||
<div class="section-header"><div><span class="eyebrow">Lumi host</span><h2 id="runtime-title">Inference health</h2><p class="hint">Speech recognition runs on the Lumi server, never on the streaming computer.</p></div></div>
|
||
<div class="dashboard-metric-grid transcription-metrics">
|
||
<div><span>Provider</span><strong>whisper.cpp <%= runtimeManifest.tested_version %></strong></div>
|
||
<div><span>Worker</span><strong><%= providerHealth.state %></strong></div>
|
||
<div><span>Selected model</span><strong><%= providerHealth.model ? providerHealth.model + (providerHealth.model_ready ? '' : ' · not ready') : 'Not loaded' %></strong></div>
|
||
<div><span>Active sessions</span><strong><%= providerHealth.sessions || 0 %></strong></div>
|
||
</div>
|
||
<% if (!providerHealth.healthy) { const workerReason = providerHealth.last_error?.message || (providerHealth.last_exit ? `Last exit code: ${providerHealth.last_exit.code ?? 'unknown'}` : null) || providerHealth.last_diagnostic?.message || 'No worker failure detail has been reported yet.'; %>
|
||
<div class="callout warning"><strong>Speech recognition needs attention</strong><p><%= workerReason %> Reload the verified model below, then rerun the Companion test.</p></div>
|
||
<% } %>
|
||
<div class="callout info"><strong>Shared GPU awareness</strong><p>Lumi warns and benchmarks when Lumi AI already occupies GPU memory. It never unloads AI models automatically.</p></div>
|
||
</section>
|
||
|
||
<section class="card" aria-labelledby="devices-title">
|
||
<div class="section-header"><div><span class="eyebrow">Access</span><h2 id="devices-title">Paired devices</h2><p class="hint">Active credentials are shown first. Switch to revoked history when needed; revoked records are permanently removed after 30 days.</p></div><span class="badge"><%= devices.length %> active</span></div>
|
||
<div class="analysis-mode device-view-toggle" role="group" aria-label="Paired device status">
|
||
<button class="button" type="button" data-device-view="active" aria-pressed="true">Active (<%= devices.length %>)</button>
|
||
<button class="button subtle" type="button" data-device-view="revoked" aria-pressed="false">Revoked (<%= revokedDevices.length %>)</button>
|
||
</div>
|
||
<div data-active-devices>
|
||
<% if (!devices.length) { %><div class="empty-state"><strong>No active Companion is paired</strong><p>Use Download Companion to create a package that expires after 15 minutes and works once.</p></div><% } %>
|
||
<% if (devices.length) { %>
|
||
<ul class="transcription-device-list">
|
||
<% devices.forEach((device) => { %>
|
||
<li><div><strong><%= device.name %></strong><span class="hint">Last connected <%= new Date(device.last_connected_at).toLocaleString() %><% if (device.metadata?.companion_version) { %> · Companion <%= device.metadata.companion_version %><% } %></span></div><span class="badge success">Allowed</span></li>
|
||
<% }) %>
|
||
</ul>
|
||
<% } %>
|
||
</div>
|
||
<div data-revoked-devices hidden>
|
||
<% if (!revokedDevices.length) { %><div class="empty-state"><strong>No revoked device history</strong><p>Revoked credentials will appear here for up to 30 days.</p></div><% } %>
|
||
<% if (revokedDevices.length) { %>
|
||
<ul class="transcription-device-list">
|
||
<% revokedDevices.forEach((device) => { %><li><div><strong><%= device.name %></strong><span class="hint">Revoked <%= new Date(device.revoked_at).toLocaleString() %> · permanent removal by <%= new Date(device.revoked_at + 30 * 86400000).toLocaleDateString() %></span></div><span class="badge danger">Revoked</span></li><% }) %>
|
||
</ul>
|
||
<% } %>
|
||
</div>
|
||
</section>
|
||
</div>
|
||
|
||
<section class="card benchmark-history" aria-labelledby="benchmark-history-title" data-transcription-admin>
|
||
<div class="section-header">
|
||
<div><span class="eyebrow">Last hour</span><h2 id="benchmark-history-title">Transcription test analysis</h2><p class="hint">Inspect each dedicated Companion test by latency, finalized confidence, or both. Test transcripts and measurements are permanently deleted after one hour.</p></div>
|
||
<span class="badge"><%= benchmarks.length %> retained</span>
|
||
</div>
|
||
<% if (!benchmarks.length) { %>
|
||
<div class="empty-state"><strong>No dedicated tests in the last hour</strong><p>Start Accuracy & latency test from the Companion Test page. Full-path checks are intentionally not retained here.</p></div>
|
||
<% } %>
|
||
<% benchmarks.forEach((test) => { const metricRows = [
|
||
['Minimum', 'min'], ['Low 1% avg', 'low_1_average'], ['Median', 'median'], ['Average', 'average'],
|
||
['P99', 'p99'], ['High 1% avg', 'high_1_average'], ['Maximum', 'max']
|
||
]; %>
|
||
<details class="benchmark-test" data-benchmark-test data-analysis-mode="combined">
|
||
<summary>
|
||
<span><strong><%= new Date(test.started_at).toLocaleString() %></strong><small><%= test.source_name %> · <%= test.model_id || 'model pending' %> / <%= test.backend || 'backend pending' %> · <%= Math.round(test.duration_ms / 1000) %>s</small></span>
|
||
<span class="badge <%= test.status === 'completed' || test.status === 'silence_timeout' ? 'success' : test.status === 'running' ? 'info' : 'warning' %>"><%= test.status.replaceAll('_', ' ') %></span>
|
||
</summary>
|
||
<div class="benchmark-body">
|
||
<div class="analysis-mode" role="group" aria-label="Transcript analysis coloring">
|
||
<button class="button subtle" type="button" data-analysis-select="latency" aria-pressed="false">Latency</button>
|
||
<button class="button subtle" type="button" data-analysis-select="confidence" aria-pressed="false">Confidence</button>
|
||
<button class="button" type="button" data-analysis-select="combined" aria-pressed="true">Combined</button>
|
||
</div>
|
||
<div class="benchmark-legends">
|
||
<div class="benchmark-legend" aria-label="Latency color meaning"><strong>Latency</strong><span class="legend-fast">Green <750 ms</span><span class="legend-good">Blue <1,250 ms</span><span class="legend-trouble">Yellow <2,000 ms</span><span class="legend-borderline">Orange <3,000 ms</span><span class="legend-critical">Red ≥3,000 ms</span></div>
|
||
<div class="benchmark-legend" aria-label="Confidence color meaning"><strong>Confidence</strong><span class="legend-fast">Green ≥92%</span><span class="legend-good">Blue ≥80%</span><span class="legend-trouble">Yellow ≥65%</span><span class="legend-borderline">Orange ≥45%</span><span class="legend-critical">Red <45%</span></div>
|
||
</div>
|
||
<div class="benchmark-transcript" aria-label="Measured transcript">
|
||
<% if (!test.words.length) { %><p class="hint"><%= test.transcript || 'No finalized words were measured.' %></p><% } %>
|
||
<% test.words.forEach((word) => { const confidenceLabel = Number.isFinite(word.confidence) ? `${(word.confidence * 100).toFixed(1)}%` : 'not finalized'; %>
|
||
<span class="benchmark-word" tabindex="0" data-latency="<%= word.latency_ms %>" data-confidence="<%= Number.isFinite(word.confidence) ? word.confidence : '' %>" data-tooltip="Latency <%= Math.round(word.latency_ms) %> ms · Confidence <%= confidenceLabel %> · Audio <%= Math.round(word.audio_start_ms) %>–<%= Math.round(word.audio_end_ms) %> ms"><%= word.text %></span>
|
||
<% }) %>
|
||
</div>
|
||
<div class="benchmark-stat-columns">
|
||
<% [['Latency', test.stats.latency, (value) => `${Math.round(value)} ms`], ['Final confidence', test.stats.confidence, (value) => `${(value * 100).toFixed(1)}%`]].forEach(([label, stats, format]) => { %>
|
||
<section><h3><%= label %></h3><div class="benchmark-stat-grid">
|
||
<% metricRows.forEach(([metricLabel, key]) => { %><div><span><%= metricLabel %></span><strong><%= Number.isFinite(stats[key]) ? format(stats[key]) : '—' %></strong></div><% }) %>
|
||
</div><p class="hint"><%= stats.count %> measured values</p></section>
|
||
<% }) %>
|
||
</div>
|
||
</div>
|
||
</details>
|
||
<% }) %>
|
||
</section>
|
||
|
||
<section class="card" id="speech-models" aria-labelledby="models-title" data-transcription-admin>
|
||
<div class="section-header"><div><span class="eyebrow">Curated choices</span><h2 id="models-title">Speech models</h2><p class="hint">Downloads require confirmation and checksum verification before a model can load.</p></div></div>
|
||
<div class="model-row">
|
||
<% models.forEach((model) => { %>
|
||
<article>
|
||
<div><h3><%= model.label %></h3><p class="hint"><%= model.recommended ? 'Recommended starting point' : 'Fallback option' %> · <%= Math.round(model.bytes / 1048576) %> MiB</p></div>
|
||
<div class="model-actions">
|
||
<span class="badge <%= providerHealth.model === model.id && providerHealth.model_ready ? 'success' : model.status.valid ? 'info' : model.status.installed ? 'danger' : 'muted' %>"><%= providerHealth.model === model.id && providerHealth.model_ready ? 'Loaded' : providerHealth.model === model.id && model.status.valid ? 'Reload required' : model.status.valid ? 'Verified' : model.status.installed ? 'Checksum failed' : 'Not installed' %></span>
|
||
<% if (!model.status.valid) { %><button class="button subtle" type="button" data-download-model="<%= model.id %>" data-model-label="<%= model.label %>" data-model-size="<%= Math.round(model.bytes / 1048576) %>">Download</button><% } %>
|
||
<% if (model.status.valid && (providerHealth.model !== model.id || !providerHealth.model_ready)) { %><button class="button subtle" type="button" data-load-model="<%= model.id %>"><%= providerHealth.model === model.id ? 'Reload model' : 'Load model' %></button><% } %>
|
||
</div>
|
||
</article>
|
||
<% }) %>
|
||
</div>
|
||
<div class="callout info"><strong>Audio privacy</strong><p>Raw audio is held only in bounded memory and is never written to disk by default. Diagnostic caption text is retained for seven days unless disabled.</p></div>
|
||
</section>
|