[--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)); }
diff --git a/plugins/lumi_transcription/scripts/build-worker.ps1 b/plugins/lumi_transcription/scripts/build-worker.ps1
index f602a78..ef4bc49 100644
--- a/plugins/lumi_transcription/scripts/build-worker.ps1
+++ b/plugins/lumi_transcription/scripts/build-worker.ps1
@@ -1,5 +1,8 @@
param(
- [switch]$Cuda
+ [switch]$Cuda,
+ [string]$CudaToolkitRoot = "",
+ [switch]$Clean,
+ [switch]$Package
)
$ErrorActionPreference = "Stop"
@@ -21,17 +24,55 @@ function Find-Tool([string]$name) {
$cmake = Find-Tool "cmake.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 |
Select-Object -First 1
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
$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"
@(
"@echo off",
('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%",
('"{0}" --build "{1}" --config Release' -f $cmake, $buildRoot),
"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"
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")
+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"
+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"
+}
diff --git a/plugins/lumi_transcription/tests/verify.js b/plugins/lumi_transcription/tests/verify.js
index 63ba710..c3c382e 100644
--- a/plugins/lumi_transcription/tests/verify.js
+++ b/plugins/lumi_transcription/tests/verify.js
@@ -40,6 +40,7 @@ async function run() {
verifyBenchmarkRetention();
verifyAdminDeviceRevocationUx();
await verifyBenchmarkStartRollback();
+ await verifyManualBenchmarkFinalization();
await verifySessionLifecycle();
await verifyProviderFailureFeedback();
await verifyWorkerRestart();
@@ -192,16 +193,26 @@ function verifyBenchmarkRetention() {
const source = crypto.randomUUID();
const id = store.start({ sessionId: "benchmark-session", deviceId: "device", source: { source_uuid: source, display_name: "Mic" } });
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: [
- { text: "hello", latency_ms: 700, confidence: .96, audio_start_ms: 0, audio_end_ms: 300 },
- { text: "world", latency_ms: 1400, confidence: .82, audio_start_ms: 300, audio_end_ms: 700 }
+ { text: "[_BEG_]", latency_ms: 400, confidence: .2, captured_at_ms: 1000 },
+ { 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" }
});
const result = store.finish("benchmark-session", "completed");
assert.equal(result.id, id);
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);
const secondId = store.start({ sessionId: "benchmark-session", deviceId: "device", source: { source_uuid: source, display_name: "Mic" } });
assert.notEqual(secondId, id);
@@ -227,6 +238,47 @@ function verifyBenchmarkRetention() {
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() {
class Provider extends EventEmitter {
constructor() { super(); this.starts = 0; }
@@ -341,9 +393,11 @@ async function verifyNativeWorkerBoundary() {
const cmake = fs.readFileSync(path.join(sourceRoot, "CMakeLists.txt"), "utf8");
const source = fs.readFileSync(path.join(sourceRoot, "src/main.cpp"), "utf8");
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, /token_timestamps = true/);
+ assert.match(source, /last_decode_at/);
+ assert.match(source, /data\.id >= whisper_token_eot/);
assert.match(source, /session_stopped/);
const bridge = fs.readFileSync(path.join(__dirname, "../../../companion/native/obs-bridge/src/plugin.cpp"), "utf8");
assert.match(bridge, /selection_state/);
@@ -415,6 +469,19 @@ function verifyArtifactsAndLogs(temp) {
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) };
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]");
const logsRoot = path.join(temp, "logs");
const logs = new JsonlDiagnosticLog(logsRoot, { retentionDays: 1, maxBytes: 100 });
diff --git a/plugins/lumi_transcription/views/settings.ejs b/plugins/lumi_transcription/views/settings.ejs
index 466fecb..fe19dd0 100644
--- a/plugins/lumi_transcription/views/settings.ejs
+++ b/plugins/lumi_transcription/views/settings.ejs
@@ -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.'; %>
Speech recognition needs attention <%= workerReason %> Reload the verified model below, then rerun the Companion test.
<% } %>
+
+ <% runtimeManifest.artifacts.forEach((artifact) => { %>
+
+ <%= artifact.backend === 'cuda' ? 'Install / upgrade GPU runtime' : 'Install CPU fallback' %>
+
+ <% }) %>
+
+ 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.
Shared GPU awareness Lumi warns and benchmarks when Lumi AI already occupies GPU memory. It never unloads AI models automatically.