Accelerate server transcription and finalize tests

This commit is contained in:
Franz Rolfsvaag 2026-07-23 09:12:28 +02:00
parent 63e52e3f6a
commit 7760ed0a3f
22 changed files with 562 additions and 51 deletions

13
TODO.md
View File

@ -63,10 +63,19 @@ Experimental.10 deduplicates OBS source inventories and unchanged source-state
notifications at both ends, removing the high-volume source-update feedback seen notifications at both ends, removing the high-volume source-update feedback seen
with large scene collections while retaining the non-disconnecting safety budget. with large scene collections while retaining the non-disconnecting safety budget.
Experimental.11 removes server-side inference backlog, filters whisper.cpp
control tokens, preserves first-visible word latency while retaining finalized
confidence, and makes manual test completion wait for the final worker result.
The portable CUDA 12.4 worker targets RTX 3060 and newer NVIDIA GPUs, activates
from the Lumi WebUI without a host SDK dependency, and retains a CPU
compatibility package. On the RTX 3080 Ti acceptance phrase, `small.en` returned
the exact 19-word transcript at 300 ms average, 106 ms median, and 1,007 ms maximum
first-visible latency.
Release-blocking work remains: Release-blocking work remains:
- Package the native whisper.cpp worker as the Windows CUDA runtime, then benchmark - Repeat the `small.en` latency gate and benchmark quantized `small.en` and
`small.en`, quantized `small.en`, and `base.en` on the RTX 3060. `base.en` on the production RTX 3060.
- Sign the companion installer and add a rollback policy after the experimental - Sign the companion installer and add a rollback policy after the experimental
updater has target-machine acceptance evidence. updater has target-machine acceptance evidence.
- Complete target-machine bridge acceptance and source rename/missing recovery. - Complete target-machine bridge acceptance and source rename/missing recovery.

View File

@ -1,5 +1,5 @@
#ifndef AppVersion #ifndef AppVersion
#define AppVersion "0.1.0-experimental.10" #define AppVersion "0.1.0-experimental.11"
#endif #endif
#ifndef SourceRoot #ifndef SourceRoot
#error SourceRoot must point at the self-contained Companion publish directory. #error SourceRoot must point at the self-contained Companion publish directory.

View File

@ -1,5 +1,5 @@
param( param(
[string]$Version = "0.1.0-experimental.10", [string]$Version = "0.1.0-experimental.11",
[string]$BridgeVersion = "0.1.0-experimental.5" [string]$BridgeVersion = "0.1.0-experimental.5"
) )

View File

@ -303,18 +303,29 @@ public sealed class CompanionRuntime : IAsyncDisposable
if (!State.BenchmarkRunning || _socket is null || Interlocked.Exchange(ref _benchmarkStopping, 1) != 0) return; if (!State.BenchmarkRunning || _socket is null || Interlocked.Exchange(ref _benchmarkStopping, 1) != 0) return;
_benchmarkLifetime?.Cancel(); _benchmarkLifetime?.Cancel();
SetState(State with { BenchmarkDetail = reason == "silence_timeout" ? "Ten seconds of silence detected. Finalizing the test…" : "Finalizing the test…" }); SetState(State with { BenchmarkDetail = reason == "silence_timeout" ? "Ten seconds of silence detected. Finalizing the test…" : "Finalizing the test…" });
var finalized = false;
try try
{ {
await _socket.SendAsync("stop", new { reason }, _socket.SessionId, cancellationToken); await _socket.SendAsync("stop", new { reason }, _socket.SessionId, cancellationToken);
if (_benchmarkCompleteSignal is not null) if (_benchmarkCompleteSignal is not null)
await Task.WhenAny(_benchmarkCompleteSignal.Task, Task.Delay(TimeSpan.FromSeconds(7), cancellationToken)); {
var completed = await Task.WhenAny(_benchmarkCompleteSignal.Task, Task.Delay(TimeSpan.FromSeconds(20), cancellationToken));
if (completed != _benchmarkCompleteSignal.Task)
throw new TimeoutException("Lumi did not finalize the transcription test in time. The test was ended safely, but its final confidence result is unavailable.");
finalized = Benchmark.Words.Count == 0 || Benchmark.Confidence.Count > 0;
if (!finalized)
throw new InvalidOperationException("Lumi returned the test transcript without finalized confidence. The incomplete result was kept for troubleshooting.");
}
} }
finally finally
{ {
VoiceLevelChanged?.Invoke(-60); VoiceLevelChanged?.Invoke(-60);
SetState(State with { BenchmarkRunning = false, BenchmarkDetail = Benchmark.Words.Count > 0 ? $"Test complete with {Benchmark.Words.Count} measured words or phrases." : "The test ended without a finalized transcript." }); SetState(State with { BenchmarkRunning = false, BenchmarkDetail = finalized
? (Benchmark.Words.Count > 0 ? $"Test complete with {Benchmark.Words.Count} measured words or phrases." : "The test ended without recognized speech.")
: "The test ended, but Lumi did not return finalized confidence results." });
_sessionStartSignal = null; _sessionStartSignal = null;
_testFailure = null; _testFailure = null;
_benchmarkCompleteSignal = null;
Interlocked.Exchange(ref _benchmarkStopping, 0); Interlocked.Exchange(ref _benchmarkStopping, 0);
} }
} }
@ -605,7 +616,9 @@ public sealed class CompanionRuntime : IAsyncDisposable
{ {
if (State.BenchmarkRunning) _benchmarkLastVoiceAt = DateTimeOffset.UtcNow; if (State.BenchmarkRunning) _benchmarkLastVoiceAt = DateTimeOffset.UtcNow;
} }
var audioNeeded = State.TestRunning || State.BenchmarkRunning || State.ObsStreaming; var audioNeeded = State.TestRunning || (State.BenchmarkRunning
? Volatile.Read(ref _benchmarkStopping) == 0
: State.ObsStreaming);
if (audioNeeded && _socket is not null && State.Connected && !_socket.QueueBridgeAudio(frame)) if (audioNeeded && _socket is not null && State.Connected && !_socket.QueueBridgeAudio(frame))
_ = WriteLogAsync("audio_dropped", "An obsolete audio frame was dropped before network delivery."); _ = WriteLogAsync("audio_dropped", "An obsolete audio frame was dropped before network delivery.");
return Task.CompletedTask; return Task.CompletedTask;

View File

@ -5,7 +5,7 @@
<Nullable>enable</Nullable> <Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings> <ImplicitUsings>enable</ImplicitUsings>
<ApplicationManifest>app.manifest</ApplicationManifest> <ApplicationManifest>app.manifest</ApplicationManifest>
<Version>0.1.0-experimental.10</Version> <Version>0.1.0-experimental.11</Version>
<AssemblyVersion>0.1.0.0</AssemblyVersion> <AssemblyVersion>0.1.0.0</AssemblyVersion>
</PropertyGroup> </PropertyGroup>
<ItemGroup> <ItemGroup>

View File

@ -14,7 +14,7 @@ editable: false
Server-hosted whisper.cpp transcription for Lumi Companion and OBS closed captions. Server-hosted whisper.cpp transcription for Lumi Companion and OBS closed captions.
## Metadata ## Metadata
Plugin ID: lumi_transcription Plugin ID: lumi_transcription
Version: 0.1.0-experimental.10 Version: 0.1.0-experimental.11
Default state: enabled Default state: enabled
## Web Routes ## Web Routes
- /plugins/lumi_transcription - /plugins/lumi_transcription

View File

@ -47,8 +47,7 @@ class ArtifactManager {
} }
zip.extractAllTo(staged, true); zip.extractAllTo(staged, true);
for (const expected of entry.expected_paths || []) if (!findBasename(staged, expected)) throw new Error(`Runtime is missing ${expected}.`); for (const expected of entry.expected_paths || []) if (!findBasename(staged, expected)) throw new Error(`Runtime is missing ${expected}.`);
fs.rmSync(target, { recursive: true, force: true }); replaceDirectory(staged, target);
fs.renameSync(staged, target);
return { installed: true, path: target, backend: entry.backend, version: entry.id }; return { installed: true, path: target, backend: entry.backend, version: entry.id };
} }
} }
@ -56,5 +55,36 @@ class ArtifactManager {
function validateManifestEntry(entry) { if (!entry?.id || !/^https:\/\//.test(entry.url || "") || !/^[a-f0-9]{64}$/.test(entry.sha256 || "")) throw new Error("Artifact manifest entry is invalid."); } function validateManifestEntry(entry) { if (!entry?.id || !/^https:\/\//.test(entry.url || "") || !/^[a-f0-9]{64}$/.test(entry.sha256 || "")) throw new Error("Artifact manifest entry is invalid."); }
function sha256File(target) { const hash = crypto.createHash("sha256"); const fd = fs.openSync(target, "r"); const buffer = Buffer.alloc(1024 * 1024); try { let read; while ((read = fs.readSync(fd, buffer, 0, buffer.length, null)) > 0) hash.update(buffer.subarray(0, read)); } finally { fs.closeSync(fd); } return hash.digest("hex"); } function sha256File(target) { const hash = crypto.createHash("sha256"); const fd = fs.openSync(target, "r"); const buffer = Buffer.alloc(1024 * 1024); try { let read; while ((read = fs.readSync(fd, buffer, 0, buffer.length, null)) > 0) hash.update(buffer.subarray(0, read)); } finally { fs.closeSync(fd); } return hash.digest("hex"); }
function findBasename(root, basename) { const pending = [root]; while (pending.length) { const current = pending.pop(); for (const entry of fs.readdirSync(current, { withFileTypes: true })) { const target = path.join(current, entry.name); if (entry.isDirectory()) pending.push(target); else if (entry.name === basename) return target; } } return null; } function findBasename(root, basename) { const pending = [root]; while (pending.length) { const current = pending.pop(); for (const entry of fs.readdirSync(current, { withFileTypes: true })) { const target = path.join(current, entry.name); if (entry.isDirectory()) pending.push(target); else if (entry.name === basename) return target; } } return null; }
function replaceDirectory(staged, target) {
const backup = `${target}.${process.pid}.backup`;
fs.rmSync(backup, { recursive: true, force: true });
const hadTarget = fs.existsSync(target);
let backupDisposable = false;
try {
if (hadTarget) moveDirectory(target, backup);
moveDirectory(staged, target);
backupDisposable = true;
fs.rmSync(backup, { recursive: true, force: true });
} catch (error) {
fs.rmSync(target, { recursive: true, force: true });
if (hadTarget && fs.existsSync(backup)) {
moveDirectory(backup, target);
backupDisposable = true;
} else if (!hadTarget) backupDisposable = true;
throw error;
} finally {
fs.rmSync(staged, { recursive: true, force: true });
if (backupDisposable) fs.rmSync(backup, { recursive: true, force: true });
}
}
function moveDirectory(source, target) {
try { fs.renameSync(source, target); }
catch (error) {
if (!["EPERM", "EACCES", "EXDEV"].includes(error.code)) throw error;
fs.mkdirSync(target, { recursive: true });
fs.cpSync(source, target, { recursive: true, force: true });
fs.rmSync(source, { recursive: true, force: true });
}
}
module.exports = { ArtifactManager, sha256File, validateManifestEntry }; module.exports = { ArtifactManager, sha256File, validateManifestEntry };

View File

@ -4,15 +4,33 @@ const ROOT = path.resolve(__dirname, "..");
const DATA = path.join(ROOT, "data"); const DATA = path.join(ROOT, "data");
function ensureDataDirs() { for (const name of ["logs", "models", "runtime", "tmp"]) fs.mkdirSync(path.join(DATA, name), { recursive: true }); } function ensureDataDirs() { for (const name of ["logs", "models", "runtime", "tmp"]) fs.mkdirSync(path.join(DATA, name), { recursive: true }); }
function dataPath(...parts) { const target = path.resolve(DATA, ...parts); if (target !== DATA && !target.startsWith(`${DATA}${path.sep}`)) throw new Error("Path escapes transcription plugin data."); return target; } function dataPath(...parts) { const target = path.resolve(DATA, ...parts); if (target !== DATA && !target.startsWith(`${DATA}${path.sep}`)) throw new Error("Path escapes transcription plugin data."); return target; }
function activeWorkerExecutable() {
try {
const requested = fs.readFileSync(dataPath("runtime", "active-worker.txt"), "utf8").trim();
const target = path.resolve(requested);
const runtimeRoot = dataPath("runtime");
return target.startsWith(`${runtimeRoot}${path.sep}`) && fs.statSync(target).isFile() ? target : "";
} catch { return ""; }
}
function setActiveWorkerExecutable(target) {
const executable = path.resolve(String(target || ""));
const runtimeRoot = dataPath("runtime");
if (!executable.startsWith(`${runtimeRoot}${path.sep}`) || !fs.existsSync(executable) || !fs.statSync(executable).isFile())
throw new Error("Active transcription worker must be an installed runtime file.");
fs.writeFileSync(dataPath("runtime", "active-worker.txt"), `${executable}\n`, { encoding: "utf8", mode: 0o600 });
}
function resolveWorkerExecutable(override = "") { function resolveWorkerExecutable(override = "") {
const requested = String(override || "").trim(); const requested = String(override || "").trim();
if (requested) return fs.existsSync(requested) ? path.resolve(requested) : requested; if (requested) return fs.existsSync(requested) ? path.resolve(requested) : requested;
const executable = process.platform === "win32" ? "lumi-whisper-worker.exe" : "lumi-whisper-worker"; const executable = process.platform === "win32" ? "lumi-whisper-worker.exe" : "lumi-whisper-worker";
const candidates = [ const candidates = [
activeWorkerExecutable(),
dataPath("runtime", process.platform === "win32" ? "windows-x64-cuda-12.4" : `${process.platform}-${process.arch}-cuda`, "bin", executable),
dataPath("runtime", "worker", process.platform === "win32" ? "windows-x64-cuda" : `${process.platform}-${process.arch}-cuda`, "bin", executable), dataPath("runtime", "worker", process.platform === "win32" ? "windows-x64-cuda" : `${process.platform}-${process.arch}-cuda`, "bin", executable),
dataPath("runtime", process.platform === "win32" ? "windows-x64-cpu" : `${process.platform}-${process.arch}`, "bin", executable),
dataPath("runtime", "worker", process.platform === "win32" ? "windows-x64-cpu" : `${process.platform}-${process.arch}`, "bin", executable), dataPath("runtime", "worker", process.platform === "win32" ? "windows-x64-cpu" : `${process.platform}-${process.arch}`, "bin", executable),
path.join(ROOT, "backend", "transcription", "worker-native", "build", executable) path.join(ROOT, "backend", "transcription", "worker-native", "build", executable)
]; ];
return candidates.find((candidate) => fs.existsSync(candidate) && fs.statSync(candidate).isFile()) || ""; return candidates.find((candidate) => fs.existsSync(candidate) && fs.statSync(candidate).isFile()) || "";
} }
module.exports = { ROOT, DATA, ensureDataDirs, dataPath, resolveWorkerExecutable }; module.exports = { ROOT, DATA, ensureDataDirs, dataPath, resolveWorkerExecutable, setActiveWorkerExecutable };

View File

@ -115,8 +115,8 @@ class SessionCoordinator {
const session = this.require(sessionId); const session = this.require(sessionId);
clearTimeout(session.graceTimer); clearTimeout(session.graceTimer);
session.graceTimer = null; session.graceTimer = null;
await session.delivery.stop();
if (session.providerActive) await this.provider.stopSession(session.id); if (session.providerActive) await this.provider.stopSession(session.id);
await session.delivery.stop();
if (session.benchmarkId && this.benchmarks) { if (session.benchmarkId && this.benchmarks) {
const benchmarkStatus = reason === "silence_timeout" ? "silence_timeout" : reason === "provider_failed" ? "failed" : reason === "disconnect" || reason === "plugin_shutdown" ? "aborted" : "completed"; const benchmarkStatus = reason === "silence_timeout" ? "silence_timeout" : reason === "provider_failed" ? "failed" : reason === "disconnect" || reason === "plugin_shutdown" ? "aborted" : "completed";
const result = this.benchmarks.finish(session.id, benchmarkStatus); const result = this.benchmarks.finish(session.id, benchmarkStatus);
@ -196,10 +196,12 @@ class SessionCoordinator {
const session = this.sessions.get(raw.session_id); const session = this.sessions.get(raw.session_id);
const track = session?.tracks.get(raw.track_id || raw.source_uuid); const track = session?.tracks.get(raw.track_id || raw.source_uuid);
if (!session || !track || session.state !== "running") return; if (!session || !track || session.state !== "running") return;
const hypothesisText = cleanAnalysisText(raw.text);
if (!hypothesisText) return;
track.lastSpeechAt = this.now(); track.lastSpeechAt = this.now();
const primary = Array.from(session.tracks.values()).find((candidate) => candidate.primary); const primary = Array.from(session.tracks.values()).find((candidate) => candidate.primary);
if (primary && track !== primary && this.now() - primary.lastSpeechAt < 800) return; if (primary && track !== primary && this.now() - primary.lastSpeechAt < 800) return;
const stable = this.stabilizer.update(track.source_uuid, raw.text, { final: raw.final, newUtterance: raw.new_utterance, trailingIncomplete: raw.incomplete_word }); const stable = this.stabilizer.update(track.source_uuid, hypothesisText, { final: raw.final, newUtterance: raw.new_utterance, trailingIncomplete: raw.incomplete_word });
const event = { const event = {
session_id: session.id, source_uuid: track.source_uuid, session_id: session.id, source_uuid: track.source_uuid,
speaker_label: speakerLabel(session, track), ...stable, speaker_label: speakerLabel(session, track), ...stable,
@ -207,7 +209,13 @@ class SessionCoordinator {
latency: { capture_ms: raw.capture_ms || 0, network_ms: raw.network_ms || 0, queue_ms: raw.queue_ms || 0, inference_ms: raw.inference_ms || 0, stabilization_ms: stable.stabilization_ms, total_ms: raw.total_ms || 0 }, latency: { capture_ms: raw.capture_ms || 0, network_ms: raw.network_ms || 0, queue_ms: raw.queue_ms || 0, inference_ms: raw.inference_ms || 0, stabilization_ms: stable.stabilization_ms, total_ms: raw.total_ms || 0 },
model: { id: raw.model_id || "unknown", provider: "whisper.cpp", backend: raw.backend || "unknown" } model: { id: raw.model_id || "unknown", provider: "whisper.cpp", backend: raw.backend || "unknown" }
}; };
event.analysis = { transcript: String(raw.text || "").slice(0, 8000), words: Array.isArray(raw.words) ? raw.words.slice(0, 500) : [], emitted_at_ms: raw.emitted_at_ms || this.now() }; event.analysis = {
transcript: hypothesisText.slice(0, 8000),
words: Array.isArray(raw.words) ? raw.words.slice(0, 500)
.map((word) => ({ ...word, text: cleanAnalysisText(word?.text) }))
.filter((word) => word.text) : [],
emitted_at_ms: raw.emitted_at_ms || this.now()
};
if (session.mode === "benchmark") this.benchmarks?.record(session.id, event); if (session.mode === "benchmark") this.benchmarks?.record(session.id, event);
const result = await session.delivery.deliver(event); const result = await session.delivery.deliver(event);
this.log.append({ kind: "caption", session_id: session.id, source_uuid: track.source_uuid, caption_text: session.mode === "benchmark" ? undefined : event.stable_text, uncertain_text: session.mode === "benchmark" ? undefined : event.uncertain_text, revision: event.revision, final: event.final, delivery: result.disposition, latency: event.latency, model: event.model, benchmark_id: session.benchmarkId }); this.log.append({ kind: "caption", session_id: session.id, source_uuid: track.source_uuid, caption_text: session.mode === "benchmark" ? undefined : event.stable_text, uncertain_text: session.mode === "benchmark" ? undefined : event.uncertain_text, revision: event.revision, final: event.final, delivery: result.disposition, latency: event.latency, model: event.model, benchmark_id: session.benchmarkId });
@ -241,6 +249,9 @@ class SessionCoordinator {
function speakerLabel(session, track) { const enabled = Array.from(session.tracks.values()).filter((candidate) => candidate.enabled); return enabled.length === 1 && track.single_speaker ? null : track.speaker_label || track.display_name; } function speakerLabel(session, track) { const enabled = Array.from(session.tracks.values()).filter((candidate) => candidate.enabled); return enabled.length === 1 && track.single_speaker ? null : track.speaker_label || track.display_name; }
function serializeTrack(track) { return { source_uuid: track.source_uuid, display_name: track.display_name, speaker_label: track.speaker_label, enabled: track.enabled, primary: track.primary, single_speaker: track.single_speaker, delivery_enabled: track.delivery_enabled, program_active: track.program_active, source_missing: track.source_missing, last_activity_at: track.last_activity_at, sequence: track.sequence?.metrics?.() }; } function serializeTrack(track) { return { source_uuid: track.source_uuid, display_name: track.display_name, speaker_label: track.speaker_label, enabled: track.enabled, primary: track.primary, single_speaker: track.single_speaker, delivery_enabled: track.delivery_enabled, program_active: track.program_active, source_missing: track.source_missing, last_activity_at: track.last_activity_at, sequence: track.sequence?.metrics?.() }; }
function text(value, max) { return String(value || "").trim().slice(0, max); } function text(value, max) { return String(value || "").trim().slice(0, max); }
function cleanAnalysisText(value) {
return String(value || "").replace(/\[_(?:BEG_|TT_\d+)\]/gi, "").replace(/\[BLANK_AUDIO\]/gi, "").trim();
}
function isUuid(value) { return /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(String(value || "")); } function isUuid(value) { return /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(String(value || "")); }
function pathTestCaption(session, track, health, now) { function pathTestCaption(session, track, health, now) {
return { return {

View File

@ -110,7 +110,8 @@ class BenchmarkStore {
const selected = Array.from(captions.values()).sort((a, b) => a.received_at - b.received_at); const selected = Array.from(captions.values()).sort((a, b) => a.received_at - b.received_at);
const words = []; const words = [];
for (const revision of selected) { for (const revision of selected) {
for (const word of parseWords(revision.words_json)) words.push({ ...word, confidence: revision.final ? word.confidence : null, final: Boolean(revision.final) }); const history = revisions.filter((candidate) => candidate.caption_id === revision.caption_id);
for (const word of mergeRevisionWords(revision, history)) words.push({ ...word, confidence: revision.final ? word.confidence : null, final: Boolean(revision.final) });
} }
const latency = metricStats(words.map((word) => word.latency_ms)); const latency = metricStats(words.map((word) => word.latency_ms));
const confidence = metricStats(words.map((word) => word.confidence).filter(Number.isFinite)); const confidence = metricStats(words.map((word) => word.confidence).filter(Number.isFinite));
@ -119,7 +120,7 @@ class BenchmarkStore {
source_uuid: row.source_uuid, source_name: row.source_name, started_at: row.started_at, source_uuid: row.source_uuid, source_name: row.source_name, started_at: row.started_at,
ended_at: row.ended_at, duration_ms: Math.max(0, (row.ended_at || this.now()) - row.started_at), ended_at: row.ended_at, duration_ms: Math.max(0, (row.ended_at || this.now()) - row.started_at),
status: row.status, model_id: row.model_id, backend: row.backend, status: row.status, model_id: row.model_id, backend: row.backend,
transcript: selected.map((revision) => revision.text).filter(Boolean).join(" "), words, transcript: selected.map((revision) => cleanTranscript(revision.text)).filter(Boolean).join(" "), words,
stats: { latency, confidence }, revisions: revisions.length stats: { latency, confidence }, revisions: revisions.length
}; };
} }
@ -136,13 +137,42 @@ class BenchmarkStore {
function safeWord(word) { function safeWord(word) {
return { return {
text: String(word?.text || "").slice(0, 120), text: cleanWordText(word?.text).slice(0, 120),
latency_ms: finite(word?.latency_ms), confidence: clamp(word?.confidence, 0, 1), latency_ms: finite(word?.latency_ms), confidence: clamp(word?.confidence, 0, 1),
audio_start_ms: finite(word?.audio_start_ms), audio_end_ms: finite(word?.audio_end_ms), audio_start_ms: finite(word?.audio_start_ms), audio_end_ms: finite(word?.audio_end_ms),
captured_at_ms: finite(word?.captured_at_ms) captured_at_ms: finite(word?.captured_at_ms)
}; };
} }
function parseWords(value) { try { const parsed = JSON.parse(value); return Array.isArray(parsed) ? parsed.map(safeWord).filter((word) => word.text) : []; } catch { return []; } } function parseWords(value) { try { const parsed = JSON.parse(value); return Array.isArray(parsed) ? parsed.map(safeWord).filter((word) => word.text) : []; } catch { return []; } }
function mergeRevisionWords(selected, revisions) {
const words = withOccurrences(parseWords(selected.words_json));
const candidates = revisions.flatMap((revision) => withOccurrences(parseWords(revision.words_json)));
return words.map((word) => {
const matches = candidates.filter((candidate) => candidate.key === word.key && candidate.occurrence === word.occurrence &&
Math.abs(candidate.captured_at_ms - word.captured_at_ms) <= 1500);
const { key: _key, occurrence: _occurrence, ...plain } = word;
return matches.length ? { ...plain, latency_ms: Math.min(...matches.map((candidate) => candidate.latency_ms)) } : plain;
});
}
function withOccurrences(words) {
const seen = new Map();
return words.map((word) => {
const key = comparableWord(word.text);
const occurrence = seen.get(key) || 0;
seen.set(key, occurrence + 1);
return { ...word, key, occurrence };
});
}
function cleanWordText(value) {
return String(value || "")
.replace(/\[_(?:BEG_|TT_\d+)\]/gi, "")
.replace(/\[BLANK_AUDIO\]/gi, "")
.trim();
}
function cleanTranscript(value) {
return cleanWordText(value).replace(/\s+/g, " ").trim();
}
function comparableWord(value) { return cleanWordText(value).toLocaleLowerCase().replace(/[^\p{L}\p{N}]+/gu, ""); }
function finite(value) { const number = Number(value); return Number.isFinite(number) ? Math.max(0, number) : 0; } function finite(value) { const number = Number(value); return Number.isFinite(number) ? Math.max(0, number) : 0; }
function clamp(value, min, max) { return Math.min(max, Math.max(min, finite(value))); } function clamp(value, min, max) { return Math.min(max, Math.max(min, finite(value))); }
function metricStats(input) { function metricStats(input) {

View File

@ -191,7 +191,7 @@ class WhisperCppServerProvider extends TranscriptionProvider {
async stopSession(sessionId) { async stopSession(sessionId) {
this.sessions.delete(sessionId); this.sessions.delete(sessionId);
const stopped = new Promise((resolve) => { const stopped = new Promise((resolve) => {
const timer = setTimeout(() => { cleanup(); resolve(false); }, 5000); const timer = setTimeout(() => { cleanup(); resolve(false); }, 15000);
const onStopped = (event) => { if (event.session_id !== sessionId) return; cleanup(); resolve(true); }; const onStopped = (event) => { if (event.session_id !== sessionId) return; cleanup(); resolve(true); };
const cleanup = () => { clearTimeout(timer); this.off("session_stopped", onStopped); }; const cleanup = () => { clearTimeout(timer); this.off("session_stopped", onStopped); };
this.on("session_stopped", onStopped); this.on("session_stopped", onStopped);

View File

@ -9,6 +9,18 @@ set(WHISPER_BUILD_EXAMPLES OFF CACHE BOOL "" FORCE)
set(WHISPER_BUILD_TESTS OFF CACHE BOOL "" FORCE) set(WHISPER_BUILD_TESTS OFF CACHE BOOL "" FORCE)
set(WHISPER_BUILD_SERVER OFF CACHE BOOL "" FORCE) set(WHISPER_BUILD_SERVER OFF CACHE BOOL "" FORCE)
set(GGML_CUDA ${LUMI_WHISPER_CUDA} CACHE BOOL "" FORCE) set(GGML_CUDA ${LUMI_WHISPER_CUDA} CACHE BOOL "" FORCE)
# Build a portable AVX2 host path instead of inheriting the build machine's
# AVX-512 features. The GPU package must run on ordinary RTX 3060-era hosts.
set(GGML_NATIVE OFF CACHE BOOL "" FORCE)
set(GGML_AVX ON CACHE BOOL "" FORCE)
set(GGML_AVX2 ON CACHE BOOL "" FORCE)
set(GGML_AVX512 OFF CACHE BOOL "" FORCE)
if(LUMI_WHISPER_CUDA)
# The RTX 3060 production baseline and RTX 3080 Ti development host are
# both Ampere (SM 8.6). Include PTX for forward compatibility as well.
set(LUMI_CUDA_ARCHITECTURES "86-real;86-virtual" CACHE STRING "CUDA architectures included in the portable worker")
set(CMAKE_CUDA_ARCHITECTURES "${LUMI_CUDA_ARCHITECTURES}" CACHE STRING "" FORCE)
endif()
FetchContent_Declare( FetchContent_Declare(
whisper whisper

View File

@ -23,7 +23,8 @@ using clock_type = std::chrono::steady_clock;
namespace { namespace {
constexpr std::size_t sample_rate = 16000; constexpr std::size_t sample_rate = 16000;
constexpr std::size_t max_samples = sample_rate * 6; constexpr std::size_t max_samples = sample_rate * 15;
constexpr std::size_t preroll_samples = sample_rate * 2 / 5;
constexpr std::size_t min_decode_samples = sample_rate / 2; constexpr std::size_t min_decode_samples = sample_rate / 2;
constexpr auto decode_interval = std::chrono::milliseconds(600); constexpr auto decode_interval = std::chrono::milliseconds(600);
constexpr std::uint64_t finalize_silence_us = 750000; constexpr std::uint64_t finalize_silence_us = 750000;
@ -37,7 +38,7 @@ struct track_state {
std::uint64_t audio_end_us = 0; std::uint64_t audio_end_us = 0;
std::uint64_t captured_at_ms = 0; std::uint64_t captured_at_ms = 0;
std::uint64_t last_voice_us = 0; std::uint64_t last_voice_us = 0;
std::uint64_t last_decode_us = 0; clock_type::time_point last_decode_at{};
bool voiced = false; bool voiced = false;
bool new_utterance = true; bool new_utterance = true;
}; };
@ -138,6 +139,9 @@ transcription_result transcribe(const std::vector<float> & samples, double & inf
parameters.single_segment = true; parameters.single_segment = true;
parameters.token_timestamps = true; parameters.token_timestamps = true;
parameters.split_on_word = true; parameters.split_on_word = true;
parameters.max_tokens = 96;
parameters.suppress_blank = true;
parameters.suppress_nst = true;
parameters.language = "en"; parameters.language = "en";
parameters.n_threads = std::max(1U, std::min(4U, std::thread::hardware_concurrency())); parameters.n_threads = std::max(1U, std::min(4U, std::thread::hardware_concurrency()));
const auto started = clock_type::now(); const auto started = clock_type::now();
@ -152,13 +156,14 @@ transcription_result transcribe(const std::vector<float> & samples, double & inf
for (int index = 0; index < tokens; ++index) { for (int index = 0; index < tokens; ++index) {
const char * value = whisper_full_get_token_text(context.get(), segment, index); const char * value = whisper_full_get_token_text(context.get(), segment, index);
if (!value || !*value) continue; if (!value || !*value) continue;
const auto data = whisper_full_get_token_data(context.get(), segment, index);
if (data.id >= whisper_token_eot(context.get())) continue;
std::string raw(value); std::string raw(value);
if (raw.starts_with("<|")) continue; if (raw.starts_with("<|")) continue;
const auto first = raw.find_first_not_of(" \t\r\n"); const auto first = raw.find_first_not_of(" \t\r\n");
if (first == std::string::npos) continue; if (first == std::string::npos) continue;
const auto last = raw.find_last_not_of(" \t\r\n"); const auto last = raw.find_last_not_of(" \t\r\n");
const bool begins_word = first > 0 || output.words.empty(); const bool begins_word = first > 0 || output.words.empty();
const auto data = whisper_full_get_token_data(context.get(), segment, index);
if (begins_word) output.words.push_back({}); if (begins_word) output.words.push_back({});
auto & word = output.words.back(); auto & word = output.words.back();
word.text += raw.substr(first, last - first + 1); word.text += raw.substr(first, last - first + 1);
@ -203,13 +208,14 @@ void decode_track(const std::string & session_id, const std::string & track_id,
}); });
track.new_utterance = false; track.new_utterance = false;
} }
track.last_decode_us = track.audio_end_us; track.last_decode_at = clock_type::now();
if (final) { if (final) {
track.samples.clear(); track.samples.clear();
track.voiced = false; track.voiced = false;
track.new_utterance = true; track.new_utterance = true;
track.audio_start_us = 0; track.audio_start_us = 0;
track.captured_at_ms = 0; track.captured_at_ms = 0;
track.last_decode_at = {};
} }
} }
@ -236,10 +242,20 @@ void audio(const json & packet, const std::vector<std::uint8_t> & pcm) {
const auto duration_us = static_cast<std::uint64_t>(count * 1000000ULL / sample_rate); const auto duration_us = static_cast<std::uint64_t>(count * 1000000ULL / sample_rate);
track.audio_end_us = captured_us + duration_us; track.audio_end_us = captured_us + duration_us;
const auto rms = std::sqrt(squares / static_cast<double>(std::max<std::size_t>(1, count))); const auto rms = std::sqrt(squares / static_cast<double>(std::max<std::size_t>(1, count)));
if (rms >= 0.012) { track.voiced = true; track.last_voice_us = track.audio_end_us; } if (rms >= 0.012) {
track.voiced = true;
track.last_voice_us = track.audio_end_us;
} else if (!track.voiced && track.samples.size() > preroll_samples) {
const auto removed = track.samples.size() - preroll_samples;
track.samples.erase(track.samples.begin(), track.samples.begin() + static_cast<std::ptrdiff_t>(removed));
const auto removed_us = static_cast<std::uint64_t>(removed * 1000000ULL / sample_rate);
track.audio_start_us += removed_us;
track.captured_at_ms += removed_us / 1000ULL;
}
const bool final = track.voiced && (track.samples.size() >= max_samples || const bool final = track.voiced && (track.samples.size() >= max_samples ||
(track.audio_end_us > track.last_voice_us && track.audio_end_us - track.last_voice_us >= finalize_silence_us)); (track.audio_end_us > track.last_voice_us && track.audio_end_us - track.last_voice_us >= finalize_silence_us));
const bool decode_due = track.last_decode_us == 0 || track.audio_end_us - track.last_decode_us >= static_cast<std::uint64_t>(decode_interval.count()) * 1000ULL; const auto now = clock_type::now();
const bool decode_due = track.last_decode_at == clock_type::time_point{} || now - track.last_decode_at >= decode_interval;
if (final || (track.voiced && decode_due)) decode_track(session_id, track_id, track, final); if (final || (track.voiced && decode_due)) decode_track(session_id, track_id, track, final);
} }

View File

@ -1,17 +1,17 @@
{ {
"schema_version": 1, "schema_version": 1,
"version": "0.1.0-experimental.10", "version": "0.1.0-experimental.11",
"signed": false, "signed": false,
"release_notes": "Prevents OBS capture bursts from rate-limiting the Companion connection, sends steady 20 ms audio frames, and removes duplicate source-state traffic from large scene collections.", "release_notes": "Manual transcription tests now wait for finalized confidence results, stop sending obsolete audio while finalizing, and clearly report incomplete server finalization.",
"installer": { "installer": {
"id": "windows-x64-installer", "id": "windows-x64-installer",
"platform": "win32", "platform": "win32",
"architecture": "x64", "architecture": "x64",
"label": "Windows x64 per-user installer", "label": "Windows x64 per-user installer",
"filename": "Lumi.Companion-Setup.exe", "filename": "Lumi.Companion-Setup.exe",
"url": "https://git.rolfsvaag.no/Rolfsvaag_Datateknikk/Lumi/releases/download/companion-v0.1.0-experimental.10/Lumi.Companion-Setup.exe", "url": "https://git.rolfsvaag.no/Rolfsvaag_Datateknikk/Lumi/releases/download/companion-v0.1.0-experimental.11/Lumi.Companion-Setup.exe",
"sha256": "8207620c1fa2a3f6371c7f5fad40f9a467146a3c44068ed6ae655ee701d98e6a", "sha256": "17ac5b059b3d358c6dc11caeea3ba96c962edc063db25d06c9f2a0e91e4d2e12",
"bytes": 32031916 "bytes": 32034881
}, },
"artifacts": [ "artifacts": [
{ {
@ -20,9 +20,9 @@
"architecture": "x64", "architecture": "x64",
"label": "Windows x64 self-contained", "label": "Windows x64 self-contained",
"filename": "Lumi.Companion-win-x64.zip", "filename": "Lumi.Companion-win-x64.zip",
"url": "https://git.rolfsvaag.no/Rolfsvaag_Datateknikk/Lumi/releases/download/companion-v0.1.0-experimental.10/Lumi.Companion-win-x64.zip", "url": "https://git.rolfsvaag.no/Rolfsvaag_Datateknikk/Lumi/releases/download/companion-v0.1.0-experimental.11/Lumi.Companion-win-x64.zip",
"sha256": "f5a00c9bb778790caec596da475c33b3f7c8e6e67921da75e1e8e3b463bee624", "sha256": "a981d798ee123f982342a4b90b65084bf9da85ca2955cccb069abd0184fc38be",
"bytes": 41757850, "bytes": 41757483,
"entrypoint": "Lumi.Companion.App.exe" "entrypoint": "Lumi.Companion.App.exe"
} }
] ]

View File

@ -13,7 +13,7 @@ const { ArtifactManager } = require("./backend/models/artifact_manager");
const { SessionCoordinator } = require("./backend/sessions/session_coordinator"); const { SessionCoordinator } = require("./backend/sessions/session_coordinator");
const { WhisperWorkerSupervisor, WhisperCppServerProvider } = require("./backend/transcription/provider"); const { WhisperWorkerSupervisor, WhisperCppServerProvider } = require("./backend/transcription/provider");
const { BenchmarkStore } = require("./backend/tests/benchmark_store"); const { BenchmarkStore } = require("./backend/tests/benchmark_store");
const { ensureDataDirs, dataPath, resolveWorkerExecutable } = require("./backend/paths"); const { ensureDataDirs, dataPath, resolveWorkerExecutable, setActiveWorkerExecutable } = require("./backend/paths");
const modelManifest = require("./models_manifest.json"); const modelManifest = require("./models_manifest.json");
const runtimeManifest = require("./runtime_manifest.json"); const runtimeManifest = require("./runtime_manifest.json");
const manifest = require("./plugin.json"); const manifest = require("./plugin.json");
@ -185,9 +185,31 @@ module.exports = {
const entry = runtimeManifest.artifacts.find((candidate) => candidate.id === req.params.id); const entry = runtimeManifest.artifacts.find((candidate) => candidate.id === req.params.id);
if (!entry) return res.status(404).json({ ok: false, error: "Runtime was not found." }); if (!entry) return res.status(404).json({ ok: false, error: "Runtime was not found." });
try { try {
if ((await provider.health()).sessions > 0) return res.status(409).json({ ok: false, error: "End active transcription sessions before changing the inference runtime." });
const filename = `${entry.id}.zip`; const filename = `${entry.id}.zip`;
const archive = await runtimeArchives.download({ ...entry, filename }, { confirmed: req.body.confirmed === true }); const archive = await runtimeArchives.download({ ...entry, filename }, { confirmed: req.body.confirmed === true });
res.status(201).json({ ok: true, status: runtimes.installZip(entry, archive.path) }); const selected = modelManifest.models.find((candidate) => candidate.id === revisions.list().selected_model_id?.value);
const selectedStatus = selected ? models.status(selected) : null;
const previousExecutable = supervisor.executable;
let activated;
try {
await provider.stop();
const installed = runtimes.installZip(entry, archive.path);
const executable = path.join(installed.path, "bin", process.platform === "win32" ? "lumi-whisper-worker.exe" : "lumi-whisper-worker");
if (!fs.existsSync(executable)) throw new Error("Installed runtime did not contain the Lumi transcription worker.");
supervisor.executable = executable;
activated = selected && selectedStatus?.valid
? await provider.loadModel({ ...selected, path: selectedStatus.path })
: await provider.health();
setActiveWorkerExecutable(executable);
res.status(201).json({ ok: true, status: installed, activated });
} catch (activationError) {
await provider.stop().catch(() => {});
supervisor.executable = previousExecutable;
if (previousExecutable && selected && selectedStatus?.valid)
await provider.loadModel({ ...selected, path: selectedStatus.path }).catch(() => {});
throw activationError;
}
} catch (error) { res.status(400).json({ ok: false, error: error.message }); } } catch (error) { res.status(400).json({ ok: false, error: error.message }); }
}); });
router.get("/api/logs", requireAdmin, (_req, res) => res.json({ files: diagnosticLog.files().map(({ path: _path, ...entry }) => entry) })); router.get("/api/logs", requireAdmin, (_req, res) => res.json({ files: diagnosticLog.files().map(({ path: _path, ...entry }) => entry) }));

View File

@ -1,7 +1,7 @@
{ {
"id": "lumi_transcription", "id": "lumi_transcription",
"name": "Lumi Transcription", "name": "Lumi Transcription",
"version": "0.1.0-experimental.10", "version": "0.1.0-experimental.11",
"description": "Server-hosted whisper.cpp transcription for Lumi Companion and OBS closed captions.", "description": "Server-hosted whisper.cpp transcription for Lumi Companion and OBS closed captions.",
"main": "index.js", "main": "index.js",
"channel": "experimental", "channel": "experimental",

View File

@ -90,6 +90,25 @@
} }
}); });
root.addEventListener("click", async (event) => {
const runtime = event.target.closest("[data-install-runtime]");
if (!runtime) return;
const size = Number(runtime.dataset.runtimeSize) || 0;
if (!window.confirm(`Download and activate ${runtime.dataset.runtimeLabel}${size ? ` (${size} MiB)` : ""} on the Lumi host? Active transcription tests must be stopped first.`)) return;
runtime.disabled = true;
status.textContent = "Downloading, verifying, and activating the inference runtime…";
try {
const response = await fetch(`/plugins/lumi_transcription/api/runtime/${encodeURIComponent(runtime.dataset.installRuntime)}/install`, {
method: "POST", headers: { "Content-Type": "application/json", Accept: "application/json" },
body: JSON.stringify({ confirmed: true })
});
const result = await response.json();
if (!response.ok) throw new Error(result.error || "The inference runtime could not be installed.");
status.textContent = "Inference runtime verified and activated.";
setTimeout(() => window.location.reload(), 700);
} catch (error) { status.textContent = error.message; runtime.disabled = false; }
});
root.addEventListener("click", async (event) => { root.addEventListener("click", async (event) => {
const download = event.target.closest("[data-download-model]"); const download = event.target.closest("[data-download-model]");
const load = event.target.closest("[data-load-model]"); const load = event.target.closest("[data-load-model]");

View File

@ -17,9 +17,12 @@
"architecture": "x64", "architecture": "x64",
"backend": "cuda", "backend": "cuda",
"cuda": "12.4", "cuda": "12.4",
"url": "https://github.com/ggml-org/whisper.cpp/releases/download/v1.9.1/whisper-cublas-12.4.0-bin-x64.zip", "label": "NVIDIA GPU runtime (RTX 3060 and newer)",
"sha256": "106a2030eff8998e4ef320fe72e263a78449e9040386ee27c41ea80b001b601b", "url": "https://git.rolfsvaag.no/Rolfsvaag_Datateknikk/Lumi/releases/download/companion-v0.1.0-experimental.11/lumi-whisper-worker-windows-x64-cuda.zip",
"expected_paths": ["whisper-cli.exe"], "sha256": "ab8f5e7ac1d8cfc6453574d69a4b61e5ab2f892d27707486c614b13c3a5debef",
"bytes": 469478354,
"expected_paths": ["lumi-whisper-worker.exe", "cudart64_12.dll", "cublas64_12.dll", "cublasLt64_12.dll"],
"minimum_gpu": "NVIDIA RTX 3060",
"tested": true "tested": true
}, },
{ {
@ -27,11 +30,13 @@
"platform": "win32", "platform": "win32",
"architecture": "x64", "architecture": "x64",
"backend": "cpu", "backend": "cpu",
"url": "https://github.com/ggml-org/whisper.cpp/releases/download/v1.9.1/whisper-bin-x64.zip", "label": "CPU compatibility fallback",
"sha256": "7d8be46ecd31828e1eb7a2ecdd0d6b314feafd82163038ab6092594b0a063539", "url": "https://git.rolfsvaag.no/Rolfsvaag_Datateknikk/Lumi/releases/download/companion-v0.1.0-experimental.11/lumi-whisper-worker-windows-x64-cpu.zip",
"expected_paths": ["whisper-cli.exe"], "sha256": "044f06396765ce64a32c4fb3701def8f1c27ebab71dde95f0e4c1b7d32134c6b",
"tested": false, "bytes": 631098,
"note": "CPU fallback remains disabled until the host benchmark meets the latency target." "expected_paths": ["lumi-whisper-worker.exe"],
"tested": true,
"note": "Compatibility fallback only; the NVIDIA runtime is required for Lumi's low-latency target."
} }
] ]
} }

View File

@ -0,0 +1,189 @@
const fs = require("fs");
const path = require("path");
const { spawn } = require("child_process");
const options = parseArguments(process.argv.slice(2));
if (!options.worker || !options.audio || !options.model) {
console.error("Usage: node benchmark-worker.js --worker <exe> --audio <16-kHz-mono.wav> --model <ggml.bin> [--max-latency-ms 2160] [--expect-transcript \"...\"]");
process.exit(2);
}
run().catch((error) => {
console.error(error.stack || error.message);
process.exit(1);
});
async function run() {
const pcm = readPcmWave(options.audio);
const child = spawn(options.worker, [], {
stdio: ["pipe", "pipe", "pipe"],
windowsHide: true,
env: process.env
});
const messages = [];
let stdout = "";
let stderr = "";
child.stderr.on("data", (chunk) => { stderr = `${stderr}${chunk}`.slice(-8000); });
child.stdout.on("data", (chunk) => {
stdout += chunk;
let newline;
while ((newline = stdout.indexOf("\n")) >= 0) {
const line = stdout.slice(0, newline).trim();
stdout = stdout.slice(newline + 1);
if (!line) continue;
try { messages.push(JSON.parse(line)); } catch { }
}
});
const exited = new Promise((resolve) => child.once("exit", (code) => resolve(code)));
await waitFor(messages, (message) => message.type === "ready", 20000, exited, stderr);
send(child, { type: "load_model", model: { id: "benchmark-model", path: path.resolve(options.model) } });
const loaded = await waitFor(messages, (message) => ["model_loaded", "model_error"].includes(message.type), 30000, exited, stderr);
if (loaded.type !== "model_loaded") throw new Error(loaded.message || "Model loading failed.");
const sessionId = "benchmark-session";
const trackId = "benchmark-track";
send(child, { type: "start_session", session: { id: sessionId, mode: "benchmark" } });
send(child, { type: "add_track", session_id: sessionId, track: { source_uuid: trackId } });
const frameBytes = 640;
const started = Date.now();
let sequence = 0;
for (let offset = 0; offset < pcm.length; offset += frameBytes) {
const dueAt = started + sequence * 20;
const wait = dueAt - Date.now();
if (wait > 0) await delay(wait);
const frame = Buffer.alloc(frameBytes);
pcm.copy(frame, 0, offset, Math.min(pcm.length, offset + frameBytes));
const capturedAt = Date.now();
send(child, {
type: "audio", session_id: sessionId, track_id: trackId, sequence,
capture_timestamp_us: capturedAt * 1000, captured_at: capturedAt
}, frame);
sequence += 1;
}
for (let index = 0; index < 50; index += 1) {
const dueAt = started + sequence * 20;
const wait = dueAt - Date.now();
if (wait > 0) await delay(wait);
const capturedAt = Date.now();
send(child, {
type: "audio", session_id: sessionId, track_id: trackId, sequence,
capture_timestamp_us: capturedAt * 1000, captured_at: capturedAt
}, Buffer.alloc(frameBytes));
sequence += 1;
}
await waitFor(messages, (message) => message.type === "hypothesis" && message.final, 15000, exited, stderr);
send(child, { type: "stop_session", session_id: sessionId });
await waitFor(messages, (message) => message.type === "session_stopped" && message.session_id === sessionId, 5000, exited, stderr);
send(child, { type: "shutdown" });
await Promise.race([exited, delay(3000)]);
const hypotheses = messages.filter((message) => message.type === "hypothesis");
const selected = hypotheses.findLast((message) => message.final) || hypotheses.at(-1);
if (!selected) throw new Error("The worker did not produce a hypothesis.");
const words = mergeFirstSeen(selected.words || [], hypotheses);
const latencies = words.map((word) => word.latency_ms).filter(Number.isFinite);
const inference = hypotheses.map((message) => message.inference_ms).filter(Number.isFinite);
const result = {
backend: loaded.backend,
audio_ms: Math.round(pcm.length / 32),
revisions: hypotheses.length,
transcript: selected.text,
word_count: words.length,
latency_ms: statistics(latencies),
inference_ms: statistics(inference),
control_tokens_present: words.some((word) => /\[_(?:BEG_|TT_\d+)\]/i.test(word.text)),
transcript_matches_expected: options.expectTranscript === undefined ? null
: comparableTranscript(selected.text) === comparableTranscript(options.expectTranscript)
};
console.log(JSON.stringify(result, null, 2));
if (result.control_tokens_present) throw new Error("Whisper control tokens leaked into word analysis.");
if (result.transcript_matches_expected === false)
throw new Error(`Transcript did not match the expected acceptance phrase: ${selected.text}`);
if (Number.isFinite(options.maxLatencyMs) && result.latency_ms.max > options.maxLatencyMs)
throw new Error(`Maximum first-seen word latency ${result.latency_ms.max} ms exceeds ${options.maxLatencyMs} ms.`);
}
function mergeFirstSeen(selected, hypotheses) {
const chosen = withOccurrences(selected);
const candidates = hypotheses.flatMap((hypothesis) => withOccurrences(Array.isArray(hypothesis.words) ? hypothesis.words : []));
return chosen.map((word) => {
const matches = candidates.filter((candidate) => candidate.key === word.key && candidate.occurrence === word.occurrence &&
Math.abs(Number(candidate.captured_at_ms) - Number(word.captured_at_ms)) <= 1500);
const { key: _key, occurrence: _occurrence, ...plain } = word;
return matches.length ? { ...plain, latency_ms: Math.min(...matches.map((candidate) => Number(candidate.latency_ms))) } : plain;
}).filter((word) => comparable(word.text));
}
function withOccurrences(words) {
const seen = new Map();
return words.map((word) => {
const key = comparable(word.text);
const occurrence = seen.get(key) || 0;
seen.set(key, occurrence + 1);
return { ...word, key, occurrence };
});
}
function comparable(value) { return String(value || "").toLocaleLowerCase().replace(/[^\p{L}\p{N}]+/gu, ""); }
function comparableTranscript(value) { return String(value || "").toLocaleLowerCase().match(/[\p{L}\p{N}]+/gu)?.join(" ") || ""; }
function statistics(values) {
if (!values.length) return { count: 0, min: null, average: null, median: null, p99: null, max: null };
const sorted = [...values].sort((left, right) => left - right);
return {
count: sorted.length, min: sorted[0],
average: sorted.reduce((sum, value) => sum + value, 0) / sorted.length,
median: percentile(sorted, .5), p99: percentile(sorted, .99), max: sorted.at(-1)
};
}
function percentile(values, ratio) {
const position = (values.length - 1) * ratio;
const lower = Math.floor(position); const upper = Math.ceil(position);
return values[lower] + (values[upper] - values[lower]) * (position - lower);
}
function send(child, metadata, pcm = Buffer.alloc(0)) {
const json = Buffer.from(JSON.stringify(metadata));
const header = Buffer.alloc(8);
header.writeUInt32LE(json.length, 0);
header.writeUInt32LE(pcm.length, 4);
child.stdin.write(Buffer.concat([header, json, pcm]));
}
async function waitFor(messages, predicate, timeoutMs, exited, stderr) {
const started = Date.now();
while (Date.now() - started < timeoutMs) {
const match = messages.find(predicate);
if (match) return match;
const exit = await Promise.race([exited, delay(20).then(() => null)]);
if (exit !== null) throw new Error(`Worker exited with code ${exit}.${stderr ? ` ${stderr.trim()}` : ""}`);
}
throw new Error(`Worker response timed out after ${timeoutMs} ms.${stderr ? ` ${stderr.trim()}` : ""}`);
}
function readPcmWave(filename) {
const body = fs.readFileSync(filename);
if (body.toString("ascii", 0, 4) !== "RIFF" || body.toString("ascii", 8, 12) !== "WAVE") throw new Error("Audio input is not a WAV file.");
let offset = 12;
let format = null;
let pcm = null;
while (offset + 8 <= body.length) {
const type = body.toString("ascii", offset, offset + 4);
const size = body.readUInt32LE(offset + 4);
const start = offset + 8;
if (type === "fmt ") format = {
codec: body.readUInt16LE(start), channels: body.readUInt16LE(start + 2),
sampleRate: body.readUInt32LE(start + 4), bits: body.readUInt16LE(start + 14)
};
if (type === "data") pcm = body.subarray(start, start + size);
offset = start + size + (size % 2);
}
if (!format || format.codec !== 1 || format.channels !== 1 || format.sampleRate !== 16000 || format.bits !== 16 || !pcm)
throw new Error("Audio input must be 16-kHz mono PCM16.");
return pcm;
}
function parseArguments(args) {
const result = {};
for (let index = 0; index < args.length; index += 2) {
const key = args[index]?.replace(/^--/, "").replace(/-([a-z])/g, (_match, letter) => letter.toUpperCase());
if (key) result[key] = args[index + 1];
}
result.maxLatencyMs = result.maxLatencyMs === undefined ? null : Number(result.maxLatencyMs);
return result;
}
function delay(ms) { return new Promise((resolve) => setTimeout(resolve, ms)); }

View File

@ -1,5 +1,8 @@
param( param(
[switch]$Cuda [switch]$Cuda,
[string]$CudaToolkitRoot = "",
[switch]$Clean,
[switch]$Package
) )
$ErrorActionPreference = "Stop" $ErrorActionPreference = "Stop"
@ -21,17 +24,55 @@ function Find-Tool([string]$name) {
$cmake = Find-Tool "cmake.exe" $cmake = Find-Tool "cmake.exe"
$ninja = Find-Tool "ninja.exe" $ninja = Find-Tool "ninja.exe"
$cudaRoot = if ($Cuda -and $CudaToolkitRoot) {
Get-Item -LiteralPath $CudaToolkitRoot -ErrorAction Stop
} elseif ($Cuda) {
Get-ChildItem "$env:ProgramFiles\NVIDIA GPU Computing Toolkit\CUDA\v*" -Directory -ErrorAction SilentlyContinue |
Where-Object { Test-Path (Join-Path $_.FullName "bin\nvcc.exe") } |
Sort-Object FullName -Descending |
Select-Object -First 1
} else { $null }
if ($Cuda -and !$cudaRoot) { throw "The CUDA Toolkit was not found. Install it before building the CUDA worker." }
if ($Cuda -and !(Test-Path (Join-Path $cudaRoot.FullName "bin\nvcc.exe"))) {
throw "The selected CUDA Toolkit does not contain bin\\nvcc.exe."
}
$cudaToolRoot = $null
if ($cudaRoot) {
$cudaToolRoot = Join-Path $env:TEMP "lumi-cuda-sdk"
if (Test-Path $cudaToolRoot) { Remove-Item $cudaToolRoot -Force -Recurse }
New-Item -ItemType Junction -Path $cudaToolRoot -Target $cudaRoot.FullName | Out-Null
}
$vcvars = Get-ChildItem "${env:ProgramFiles(x86)}\Microsoft Visual Studio\2022\*\VC\Auxiliary\Build\vcvars64.bat" -ErrorAction SilentlyContinue | $vcvars = Get-ChildItem "${env:ProgramFiles(x86)}\Microsoft Visual Studio\2022\*\VC\Auxiliary\Build\vcvars64.bat" -ErrorAction SilentlyContinue |
Select-Object -First 1 Select-Object -First 1
if (!$vcvars) { throw "Visual Studio 2022 C++ Build Tools were not found." } if (!$vcvars) { throw "Visual Studio 2022 C++ Build Tools were not found." }
$windowsSdkBin = Get-ChildItem "${env:ProgramFiles(x86)}\Windows Kits\10\bin\*\x64" -Directory -ErrorAction SilentlyContinue |
Where-Object { (Test-Path (Join-Path $_.FullName "rc.exe")) -and (Test-Path (Join-Path $_.FullName "mt.exe")) } |
Sort-Object FullName -Descending |
Select-Object -First 1
if (!$windowsSdkBin) { throw "The Windows SDK resource and manifest tools were not found." }
$resourceCompiler = (Join-Path $windowsSdkBin.FullName "rc.exe").Replace("\", "/")
$manifestTool = (Join-Path $windowsSdkBin.FullName "mt.exe").Replace("\", "/")
$windowsSdkVersion = $windowsSdkBin.Parent.Name
$windowsSdkRoot = Join-Path "${env:ProgramFiles(x86)}" "Windows Kits\10"
$windowsSdkInclude = Join-Path $windowsSdkRoot "Include\$windowsSdkVersion"
$windowsSdkLib = Join-Path $windowsSdkRoot "Lib\$windowsSdkVersion"
if ($Clean -and (Test-Path $buildRoot)) { Remove-Item $buildRoot -Force -Recurse }
New-Item -ItemType Directory -Force -Path $buildRoot, $targetRoot | Out-Null New-Item -ItemType Directory -Force -Path $buildRoot, $targetRoot | Out-Null
$cudaValue = if ($Cuda) { "ON" } else { "OFF" } $cudaValue = if ($Cuda) { "ON" } else { "OFF" }
$cudaArgument = if ($cudaRoot) {
'-DCUDAToolkit_ROOT="{0}" -DCMAKE_CUDA_COMPILER="{1}"' -f $cudaToolRoot.Replace("\", "/"), (Join-Path $cudaToolRoot "bin\nvcc.exe").Replace("\", "/")
} else { "" }
$buildScript = Join-Path $buildRoot "build-worker.cmd" $buildScript = Join-Path $buildRoot "build-worker.cmd"
@( @(
"@echo off", "@echo off",
('call "{0}" >nul' -f $vcvars.FullName), ('call "{0}" >nul' -f $vcvars.FullName),
('"{0}" -S "{1}" -B "{2}" -G Ninja -DLUMI_WHISPER_CUDA={3} -DCMAKE_BUILD_TYPE=Release -DCMAKE_MAKE_PROGRAM="{4}"' -f $cmake, $sourceRoot, $buildRoot, $cudaValue, $ninja), 'set "PATHEXT=.COM;.EXE;.BAT;.CMD"',
('set "PATH={0};%PATH%"' -f $windowsSdkBin.FullName),
$(if ($cudaRoot) { 'set "PATH={0};%PATH%"' -f (Join-Path $cudaToolRoot "bin") } else { "rem CUDA is disabled" }),
('set "INCLUDE=%INCLUDE%;{0};{1};{2};{3}"' -f (Join-Path $windowsSdkInclude "ucrt"), (Join-Path $windowsSdkInclude "shared"), (Join-Path $windowsSdkInclude "um"), (Join-Path $windowsSdkInclude "winrt")),
('set "LIB=%LIB%;{0};{1}"' -f (Join-Path $windowsSdkLib "ucrt\x64"), (Join-Path $windowsSdkLib "um\x64")),
('"{0}" -S "{1}" -B "{2}" -G Ninja -DLUMI_WHISPER_CUDA={3} -DCMAKE_BUILD_TYPE=Release -DCMAKE_MAKE_PROGRAM="{4}" -DCMAKE_RC_COMPILER="{5}" -DCMAKE_MT="{6}" {7}' -f $cmake, $sourceRoot, $buildRoot, $cudaValue, $ninja, $resourceCompiler, $manifestTool, $cudaArgument),
"if errorlevel 1 exit /b %errorlevel%", "if errorlevel 1 exit /b %errorlevel%",
('"{0}" --build "{1}" --config Release' -f $cmake, $buildRoot), ('"{0}" --build "{1}" --config Release' -f $cmake, $buildRoot),
"exit /b %errorlevel%" "exit /b %errorlevel%"
@ -43,4 +84,25 @@ if ($process.ExitCode -ne 0) { throw "Worker build failed with exit code $($proc
$worker = Join-Path $buildRoot "lumi-whisper-worker.exe" $worker = Join-Path $buildRoot "lumi-whisper-worker.exe"
if (!(Test-Path $worker)) { throw "The build completed without producing lumi-whisper-worker.exe." } if (!(Test-Path $worker)) { throw "The build completed without producing lumi-whisper-worker.exe." }
Copy-Item -Force $worker (Join-Path $targetRoot "lumi-whisper-worker.exe") Copy-Item -Force $worker (Join-Path $targetRoot "lumi-whisper-worker.exe")
if ($Cuda) {
Get-ChildItem $targetRoot -Filter "*.dll" -ErrorAction SilentlyContinue | Remove-Item -Force
foreach ($pattern in @("cudart64_*.dll", "cublas64_*.dll", "cublasLt64_*.dll")) {
$dependency = Get-ChildItem (Join-Path $cudaRoot.FullName "bin") -Filter $pattern -File |
Sort-Object Name |
Select-Object -First 1
if (!$dependency) { throw "The selected CUDA Toolkit is missing $pattern." }
Copy-Item -Force $dependency.FullName $targetRoot
}
}
Write-Host "Installed Lumi whisper worker ($backend) in $targetRoot" Write-Host "Installed Lumi whisper worker ($backend) in $targetRoot"
if ($Package) {
$archive = Join-Path $pluginRoot "data\tmp\lumi-whisper-worker-windows-x64-$backend.zip"
New-Item -ItemType Directory -Force -Path (Split-Path -Parent $archive) | Out-Null
Remove-Item $archive -Force -ErrorAction SilentlyContinue
Compress-Archive -Path $targetRoot -DestinationPath $archive -CompressionLevel Optimal
$file = Get-Item $archive
$sha = (Get-FileHash $archive -Algorithm SHA256).Hash.ToLowerInvariant()
Write-Host "Runtime package: $archive"
Write-Host "Runtime bytes: $($file.Length)"
Write-Host "Runtime SHA256: $sha"
}

View File

@ -40,6 +40,7 @@ async function run() {
verifyBenchmarkRetention(); verifyBenchmarkRetention();
verifyAdminDeviceRevocationUx(); verifyAdminDeviceRevocationUx();
await verifyBenchmarkStartRollback(); await verifyBenchmarkStartRollback();
await verifyManualBenchmarkFinalization();
await verifySessionLifecycle(); await verifySessionLifecycle();
await verifyProviderFailureFeedback(); await verifyProviderFailureFeedback();
await verifyWorkerRestart(); await verifyWorkerRestart();
@ -192,16 +193,26 @@ function verifyBenchmarkRetention() {
const source = crypto.randomUUID(); const source = crypto.randomUUID();
const id = store.start({ sessionId: "benchmark-session", deviceId: "device", source: { source_uuid: source, display_name: "Mic" } }); const id = store.start({ sessionId: "benchmark-session", deviceId: "device", source: { source_uuid: source, display_name: "Mic" } });
store.record("benchmark-session", { store.record("benchmark-session", {
caption_id: "caption", revision: 1, final: true, stable_text: "hello world", caption_id: "caption", revision: 1, final: false, stable_text: "hello world",
analysis: { transcript: "hello world", words: [ analysis: { transcript: "hello world", words: [
{ text: "hello", latency_ms: 700, confidence: .96, audio_start_ms: 0, audio_end_ms: 300 }, { text: "[_BEG_]", latency_ms: 400, confidence: .2, captured_at_ms: 1000 },
{ text: "world", latency_ms: 1400, confidence: .82, audio_start_ms: 300, audio_end_ms: 700 } { text: "hello", latency_ms: 500, confidence: .7, audio_start_ms: 0, audio_end_ms: 300, captured_at_ms: 1000 },
{ text: "world[_TT_300]", latency_ms: 900, confidence: .6, audio_start_ms: 300, audio_end_ms: 700, captured_at_ms: 1300 }
] }, latency: { inference_ms: 400 }, model: { id: "small.en", backend: "cpu" }
});
store.record("benchmark-session", {
caption_id: "caption", revision: 2, final: true, stable_text: "hello world",
analysis: { transcript: "hello world", words: [
{ text: "hello", latency_ms: 700, confidence: .96, audio_start_ms: 0, audio_end_ms: 300, captured_at_ms: 1000 },
{ text: "world", latency_ms: 1400, confidence: .82, audio_start_ms: 300, audio_end_ms: 700, captured_at_ms: 1300 }
] }, latency: { inference_ms: 500 }, model: { id: "small.en", backend: "cpu" } ] }, latency: { inference_ms: 500 }, model: { id: "small.en", backend: "cpu" }
}); });
const result = store.finish("benchmark-session", "completed"); const result = store.finish("benchmark-session", "completed");
assert.equal(result.id, id); assert.equal(result.id, id);
assert.equal(result.words.length, 2); assert.equal(result.words.length, 2);
assert.equal(result.stats.latency.average, 1050); assert.deepEqual(result.words.map((word) => word.text), ["hello", "world"]);
assert.equal(result.stats.latency.average, 700);
assert.ok(Math.abs(result.stats.confidence.average - .89) < 1e-9);
assert.equal(metricStats([1, 2, 3]).median, 2); assert.equal(metricStats([1, 2, 3]).median, 2);
const secondId = store.start({ sessionId: "benchmark-session", deviceId: "device", source: { source_uuid: source, display_name: "Mic" } }); const secondId = store.start({ sessionId: "benchmark-session", deviceId: "device", source: { source_uuid: source, display_name: "Mic" } });
assert.notEqual(secondId, id); assert.notEqual(secondId, id);
@ -227,6 +238,47 @@ function verifyBenchmarkRetention() {
legacy.close(); legacy.close();
} }
async function verifyManualBenchmarkFinalization() {
class Provider extends EventEmitter {
async health() { return { healthy: true, model_ready: true, state: "running" }; }
async startSession() {}
async addTrack() {}
async stopSession(sessionId) {
this.emit("hypothesis", {
session_id: sessionId, track_id: source, text: "manual ending", final: true, new_utterance: true,
words: [{ text: "manual", latency_ms: 600, confidence: .94, captured_at_ms: 1000 }],
model_id: "small.en", backend: "cpu"
});
await new Promise((resolve) => setImmediate(resolve));
}
}
const provider = new Provider();
const source = crypto.randomUUID();
let deliveryStopped = false;
let finalRecorded = false;
const sent = [];
const benchmarks = {
start: () => "benchmark-id",
record: (_sessionId, event) => { finalRecorded = event.final && !deliveryStopped; },
finish: () => ({ id: "benchmark-id", status: "completed", transcript: "manual ending", stats: { latency: metricStats([600]), confidence: metricStats([.94]) }, words: [{ text: "manual", latency_ms: 600, confidence: .94, final: true }] })
};
const coordinator = new SessionCoordinator({
provider, benchmarks,
deliveryFactory: () => ({
start: async () => {}, stop: async () => { deliveryStopped = true; }, pause: async () => {}, resume: async () => {},
deliver: async () => ({ disposition: "simulated" })
})
});
const { session } = coordinator.create({ id: "device" }, (type, payload) => sent.push({ type, payload }));
coordinator.updateSource(session.id, { source_uuid: source, display_name: "Mic", primary: true, program_active: true });
await coordinator.start(session.id, { mode: "benchmark" });
await coordinator.stop(session.id, "benchmark_complete");
assert.equal(finalRecorded, true);
assert.equal(sent.at(-1).type, "benchmark_complete");
assert.equal(sent.at(-1).payload.stats.confidence.average, .94);
await coordinator.close();
}
async function verifyBenchmarkStartRollback() { async function verifyBenchmarkStartRollback() {
class Provider extends EventEmitter { class Provider extends EventEmitter {
constructor() { super(); this.starts = 0; } constructor() { super(); this.starts = 0; }
@ -341,9 +393,11 @@ async function verifyNativeWorkerBoundary() {
const cmake = fs.readFileSync(path.join(sourceRoot, "CMakeLists.txt"), "utf8"); const cmake = fs.readFileSync(path.join(sourceRoot, "CMakeLists.txt"), "utf8");
const source = fs.readFileSync(path.join(sourceRoot, "src/main.cpp"), "utf8"); const source = fs.readFileSync(path.join(sourceRoot, "src/main.cpp"), "utf8");
assert.match(cmake, /f049fff95a089aa9969deb009cdd4892b3e74916/); assert.match(cmake, /f049fff95a089aa9969deb009cdd4892b3e74916/);
assert.match(source, /max_samples = sample_rate \* 6/); assert.match(source, /max_samples = sample_rate \* 15/);
assert.match(source, /_setmode\(_fileno\(stdin\), _O_BINARY\)/); assert.match(source, /_setmode\(_fileno\(stdin\), _O_BINARY\)/);
assert.match(source, /token_timestamps = true/); assert.match(source, /token_timestamps = true/);
assert.match(source, /last_decode_at/);
assert.match(source, /data\.id >= whisper_token_eot/);
assert.match(source, /session_stopped/); assert.match(source, /session_stopped/);
const bridge = fs.readFileSync(path.join(__dirname, "../../../companion/native/obs-bridge/src/plugin.cpp"), "utf8"); const bridge = fs.readFileSync(path.join(__dirname, "../../../companion/native/obs-bridge/src/plugin.cpp"), "utf8");
assert.match(bridge, /selection_state/); assert.match(bridge, /selection_state/);
@ -415,6 +469,19 @@ function verifyArtifactsAndLogs(temp) {
const file = path.join(artifactRoot, "model.bin"); fs.writeFileSync(file, "verified"); const file = path.join(artifactRoot, "model.bin"); fs.writeFileSync(file, "verified");
const entry = { id: "model", filename: "model.bin", url: "https://example.invalid/model.bin", sha256: sha256File(file) }; const entry = { id: "model", filename: "model.bin", url: "https://example.invalid/model.bin", sha256: sha256File(file) };
assert.equal(new ArtifactManager(artifactRoot).status(entry).valid, true); assert.equal(new ArtifactManager(artifactRoot).status(entry).valid, true);
const runtimeZip = path.join(temp, "runtime.zip");
const archive = new (require("adm-zip"))();
archive.addFile("bin/lumi-whisper-worker.exe", Buffer.from("portable-worker"));
archive.writeZip(runtimeZip);
const runtimeEntry = {
id: "windows-x64-cpu", url: "https://example.invalid/runtime.zip", sha256: sha256File(runtimeZip),
expected_paths: ["lumi-whisper-worker.exe"], backend: "cpu"
};
const runtimeRoot = path.join(temp, "runtime");
const manager = new ArtifactManager(runtimeRoot);
manager.installZip(runtimeEntry, runtimeZip);
manager.installZip(runtimeEntry, runtimeZip);
assert.equal(fs.readFileSync(path.join(runtimeRoot, runtimeEntry.id, "bin", "lumi-whisper-worker.exe"), "utf8"), "portable-worker");
assert.equal(sanitize({ pcm: Buffer.alloc(10), device_secret: "secret", stable_text: "hello" }, false).pcm, "[redacted]"); assert.equal(sanitize({ pcm: Buffer.alloc(10), device_secret: "secret", stable_text: "hello" }, false).pcm, "[redacted]");
const logsRoot = path.join(temp, "logs"); const logsRoot = path.join(temp, "logs");
const logs = new JsonlDiagnosticLog(logsRoot, { retentionDays: 1, maxBytes: 100 }); const logs = new JsonlDiagnosticLog(logsRoot, { retentionDays: 1, maxBytes: 100 });

View File

@ -45,6 +45,14 @@
<% 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.'; %> <% 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 warning"><strong>Speech recognition needs attention</strong><p><%= workerReason %> Reload the verified model below, then rerun the Companion test.</p></div>
<% } %> <% } %>
<div class="button-row runtime-actions">
<% runtimeManifest.artifacts.forEach((artifact) => { %>
<button class="button <%= artifact.backend === 'cpu' ? 'subtle' : '' %>" type="button" data-install-runtime="<%= artifact.id %>" data-runtime-label="<%= artifact.label %>" data-runtime-size="<%= Math.ceil((artifact.bytes || 0) / 1048576) %>">
<%= artifact.backend === 'cuda' ? 'Install / upgrade GPU runtime' : 'Install CPU fallback' %>
</button>
<% }) %>
</div>
<p class="hint">The low-latency package targets <%= runtimeManifest.artifacts.find((artifact) => artifact.backend === 'cuda')?.minimum_gpu || 'a supported NVIDIA GPU' %> and newer. Runtime changes activate immediately when no transcription session is active.</p>
<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> <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>