From 37dda538b3fb6f5518d0c480cb08b7d0ee310ff4 Mon Sep 17 00:00:00 2001 From: Franz Rolfsvaag Date: Wed, 22 Jul 2026 14:34:29 +0200 Subject: [PATCH] Fix local Companion package and worker setup --- TODO.md | 4 +- docs/lumi-companion-transcription.md | 2 +- knowledge/plugins/lumi-transcription.md | 2 +- .../backend/companion/package_service.js | 17 ++++--- .../backend/models/artifact_manager.js | 1 + plugins/lumi_transcription/backend/paths.js | 13 +++++- .../backend/transcription/provider.js | 8 +++- .../transcription/worker-native/README.md | 8 ++++ plugins/lumi_transcription/index.js | 9 ++-- plugins/lumi_transcription/plugin.json | 2 +- .../scripts/build-worker.ps1 | 46 +++++++++++++++++++ plugins/lumi_transcription/tests/verify.js | 17 ++++++- 12 files changed, 110 insertions(+), 19 deletions(-) create mode 100644 plugins/lumi_transcription/scripts/build-worker.ps1 diff --git a/TODO.md b/TODO.md index c49a0cf..cfcec07 100644 --- a/TODO.md +++ b/TODO.md @@ -18,7 +18,9 @@ server offers consent-gated model download/load controls. The Transcription settings now render inside the shared Lumi shell, and Admin shows a compact Companion health/download section beneath its shortcut cards. A checksum-pinned self-contained Windows bundle includes a one-time pairing file and pairs after -extraction without a separate import workflow. +extraction without a separate import workflow. The pinned CPU whisper worker now +has a reproducible Windows build/install script and is discovered automatically; +the production CUDA build and target benchmark remain pending below. Release-blocking work remains: diff --git a/docs/lumi-companion-transcription.md b/docs/lumi-companion-transcription.md index 869b843..74f95ff 100644 --- a/docs/lumi-companion-transcription.md +++ b/docs/lumi-companion-transcription.md @@ -30,7 +30,7 @@ The repository contains a pinned native whisper.cpp rolling-window worker and a 2. Use **Download Companion** in Admin or Plugins > Transcription. Lumi downloads and checksum-verifies the pinned Windows artifact, adds a short-lived single-use pairing file, and returns a private ZIP. 3. On the Windows streaming computer, extract the complete ZIP and start `Lumi.Companion.App.exe` within 15 minutes. Companion discovers the adjacent pairing file, exchanges it once, stores the credential with current-user DPAPI, and removes the pairing file after success. 4. Install a pinned runtime/model only after explicit confirmation. `small.en` is recommended; `small.en-q5_1` and `base.en` are fallbacks. Every artifact is checksum-verified before install. -5. Configure `LUMI_TRANSCRIPTION_WORKER` with the supervised streaming-worker executable once that worker is built for the target host. +5. Build the supervised worker with `plugins/lumi_transcription/scripts/build-worker.ps1`. Lumi discovers the installed CUDA worker first and the CPU worker second. `LUMI_TRANSCRIPTION_WORKER` remains an explicit override for nonstandard installations. The Avalonia tray UI, self-contained paired ZIP, shared Lumi settings shell, Admin summary, and host-side model download/load controls now exist. The ZIP is not code-signed, so Windows may warn. The normal signed installer, managed bridge install/repair, live OBS source enumeration, and measured model benchmark wizard are not complete yet. diff --git a/knowledge/plugins/lumi-transcription.md b/knowledge/plugins/lumi-transcription.md index ce12138..e15b3cb 100644 --- a/knowledge/plugins/lumi-transcription.md +++ b/knowledge/plugins/lumi-transcription.md @@ -14,7 +14,7 @@ editable: false Server-hosted whisper.cpp transcription for Lumi Companion and OBS closed captions. ## Metadata Plugin ID: lumi_transcription -Version: 0.1.0-experimental.2 +Version: 0.1.0-experimental.3 Default state: enabled ## Web Routes - /plugins/lumi_transcription diff --git a/plugins/lumi_transcription/backend/companion/package_service.js b/plugins/lumi_transcription/backend/companion/package_service.js index b2387fb..a6aa9ef 100644 --- a/plugins/lumi_transcription/backend/companion/package_service.js +++ b/plugins/lumi_transcription/backend/companion/package_service.js @@ -6,22 +6,27 @@ const { ArtifactManager } = require("../models/artifact_manager"); class CompanionPackageService { constructor(root, manifest, options = {}) { this.root = root; - this.manifest = manifest; + this.manifestPath = typeof manifest === "string" ? manifest : null; + this.manifest = typeof manifest === "string" ? null : manifest; this.artifacts = new ArtifactManager(root, { fetch: options.fetch }); fs.mkdirSync(root, { recursive: true }); } - entry() { return this.manifest?.artifacts?.find((entry) => entry.platform === "win32" && entry.architecture === "x64") || null; } + currentManifest() { return this.manifestPath ? JSON.parse(fs.readFileSync(this.manifestPath, "utf8")) : this.manifest; } + + entry(manifest = this.currentManifest()) { return manifest?.artifacts?.find((entry) => entry.platform === "win32" && entry.architecture === "x64") || null; } status() { - const entry = this.entry(); + const manifest = this.currentManifest(); + const entry = this.entry(manifest); if (!entry) return { available: false, installed: false, valid: false, reason: "No Windows Companion artifact is configured." }; const artifact = this.artifacts.status(entry); - return { available: true, version: this.manifest.version, artifact: entry.id, ...artifact }; + return { available: true, version: manifest.version, artifact: entry.id, ...artifact }; } async build(pairing) { - const entry = this.entry(); + const manifest = this.currentManifest(); + const entry = this.entry(manifest); if (!entry) throw new Error("No Windows Companion artifact is configured."); let status = this.artifacts.status(entry); if (!status.valid) status = await this.artifacts.download(entry, { confirmed: true }); @@ -47,7 +52,7 @@ class CompanionPackageService { "Do not share this ZIP. Its pairing package works once and expires after 15 minutes.", "Windows may warn because this experimental build is not code-signed yet.", "" ].join("\r\n"), "utf8")); - return { buffer: output.toBuffer(), filename: `Lumi-Companion-${this.manifest.version}-paired.zip`, pairingName }; + return { buffer: output.toBuffer(), filename: `Lumi-Companion-${manifest.version}-paired.zip`, pairingName }; } } diff --git a/plugins/lumi_transcription/backend/models/artifact_manager.js b/plugins/lumi_transcription/backend/models/artifact_manager.js index ef54844..9489da1 100644 --- a/plugins/lumi_transcription/backend/models/artifact_manager.js +++ b/plugins/lumi_transcription/backend/models/artifact_manager.js @@ -25,6 +25,7 @@ class ArtifactManager { if (!handle.write(chunk)) await new Promise((resolve) => handle.once("drain", resolve)); } await new Promise((resolve, reject) => handle.end((error) => error ? reject(error) : resolve())); + if (Number.isSafeInteger(entry.bytes) && fs.statSync(partial).size !== entry.bytes) throw new Error(`Downloaded size mismatch for ${entry.id}.`); const actual = sha256File(partial); if (actual !== entry.sha256) throw new Error(`Checksum mismatch for ${entry.id}.`); fs.renameSync(partial, target); diff --git a/plugins/lumi_transcription/backend/paths.js b/plugins/lumi_transcription/backend/paths.js index 8fa23df..ce63c07 100644 --- a/plugins/lumi_transcription/backend/paths.js +++ b/plugins/lumi_transcription/backend/paths.js @@ -4,4 +4,15 @@ const ROOT = path.resolve(__dirname, ".."); 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 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; } -module.exports = { ROOT, DATA, ensureDataDirs, dataPath }; +function resolveWorkerExecutable(override = "") { + const requested = String(override || "").trim(); + if (requested) return fs.existsSync(requested) ? path.resolve(requested) : requested; + const executable = process.platform === "win32" ? "lumi-whisper-worker.exe" : "lumi-whisper-worker"; + const candidates = [ + dataPath("runtime", "worker", process.platform === "win32" ? "windows-x64-cuda" : `${process.platform}-${process.arch}-cuda`, "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) + ]; + return candidates.find((candidate) => fs.existsSync(candidate) && fs.statSync(candidate).isFile()) || ""; +} +module.exports = { ROOT, DATA, ensureDataDirs, dataPath, resolveWorkerExecutable }; diff --git a/plugins/lumi_transcription/backend/transcription/provider.js b/plugins/lumi_transcription/backend/transcription/provider.js index 82ca43f..4972b26 100644 --- a/plugins/lumi_transcription/backend/transcription/provider.js +++ b/plugins/lumi_transcription/backend/transcription/provider.js @@ -30,7 +30,7 @@ class WhisperWorkerSupervisor extends EventEmitter { } start() { if (this.child) return; - if (!this.executable) throw new Error("Whisper worker executable is not configured."); + if (!this.executable) throw coded("WORKER_NOT_CONFIGURED", "The whisper.cpp worker is not installed. Build or install the Lumi transcription runtime, then retry."); this.stopping = false; this.state = "starting"; const child = this.spawn(this.executable, this.args, { stdio: ["pipe", "pipe", "pipe"], windowsHide: true }); @@ -114,8 +114,10 @@ class WhisperCppServerProvider extends TranscriptionProvider { const timer = setTimeout(() => { cleanup(); reject(new Error("The whisper.cpp worker did not confirm model loading in time.")); }, 30000); const onLoaded = (event) => { cleanup(); resolve(event); }; const onError = (event) => { cleanup(); reject(Object.assign(new Error(event.message || "The model could not be loaded."), { code: event.code })); }; - const cleanup = () => { clearTimeout(timer); this.off("model_loaded", onLoaded); this.off("model_error", onError); }; + const onProviderError = (error) => { cleanup(); reject(error); }; + const cleanup = () => { clearTimeout(timer); this.off("model_loaded", onLoaded); this.off("model_error", onError); this.off("provider_error", onProviderError); }; this.once("model_loaded", onLoaded); this.once("model_error", onError); + this.once("provider_error", onProviderError); }); this.worker.send({ type: "load_model", model }); const result = await loaded; @@ -132,4 +134,6 @@ class WhisperCppServerProvider extends TranscriptionProvider { async stop() { this.sessions.clear(); await this.worker.stop(); } } +function coded(code, message) { return Object.assign(new Error(message), { code }); } + module.exports = { TranscriptionProvider, WhisperWorkerSupervisor, WhisperCppServerProvider }; diff --git a/plugins/lumi_transcription/backend/transcription/worker-native/README.md b/plugins/lumi_transcription/backend/transcription/worker-native/README.md index 364513a..0d10105 100644 --- a/plugins/lumi_transcription/backend/transcription/worker-native/README.md +++ b/plugins/lumi_transcription/backend/transcription/worker-native/README.md @@ -10,3 +10,11 @@ cmake --build build ``` The worker keeps at most six seconds per source, attempts an incremental decode around every 600 ms during speech, and finalizes after roughly 750 ms of measured silence. The server-side stabilizer remains authoritative for stable-prefix reconciliation and obsolete-revision suppression. + +On Windows, the repository setup script builds into a short temporary path and installs the executable where the plugin discovers it automatically: + +```powershell +powershell -ExecutionPolicy Bypass -File plugins/lumi_transcription/scripts/build-worker.ps1 +``` + +Pass `-Cuda` only after installing a compatible NVIDIA CUDA toolkit. Lumi prefers the installed CUDA worker and falls back to the CPU worker; `LUMI_TRANSCRIPTION_WORKER` remains an explicit override. diff --git a/plugins/lumi_transcription/index.js b/plugins/lumi_transcription/index.js index e8909c5..a317d89 100644 --- a/plugins/lumi_transcription/index.js +++ b/plugins/lumi_transcription/index.js @@ -12,10 +12,9 @@ const { JsonlDiagnosticLog } = require("./backend/logs/jsonl_log"); const { ArtifactManager } = require("./backend/models/artifact_manager"); const { SessionCoordinator } = require("./backend/sessions/session_coordinator"); const { WhisperWorkerSupervisor, WhisperCppServerProvider } = require("./backend/transcription/provider"); -const { ensureDataDirs, dataPath } = require("./backend/paths"); +const { ensureDataDirs, dataPath, resolveWorkerExecutable } = require("./backend/paths"); const modelManifest = require("./models_manifest.json"); const runtimeManifest = require("./runtime_manifest.json"); -const companionManifest = require("./companion_manifest.json"); const manifest = require("./plugin.json"); const PLUGIN_ID = "lumi_transcription"; @@ -31,7 +30,7 @@ module.exports = { const cleanupTimer = setInterval(() => diagnosticLog.cleanup(), 60 * 60 * 1000); cleanupTimer.unref?.(); const supervisor = new WhisperWorkerSupervisor({ - executable: process.env.LUMI_TRANSCRIPTION_WORKER || "", + executable: resolveWorkerExecutable(process.env.LUMI_TRANSCRIPTION_WORKER), args: process.env.LUMI_TRANSCRIPTION_WORKER_ARGS ? JSON.parse(process.env.LUMI_TRANSCRIPTION_WORKER_ARGS) : [] }); supervisor.on("diagnostic", (entry) => diagnosticLog.append({ kind: "worker", ...entry })); @@ -47,7 +46,7 @@ module.exports = { const models = new ArtifactManager(dataPath("models")); const runtimeArchives = new ArtifactManager(dataPath("tmp")); const runtimes = new ArtifactManager(dataPath("runtime")); - const companionPackages = new CompanionPackageService(dataPath("companion"), companionManifest); + const companionPackages = new CompanionPackageService(dataPath("companion"), path.join(__dirname, "companion_manifest.json")); const selectedModelId = revisions.list().selected_model_id?.value; const selectedModel = modelManifest.models.find((entry) => entry.id === selectedModelId); if (supervisor.executable && selectedModel) { @@ -140,7 +139,7 @@ module.exports = { revisions.apply([{ key: "selected_model_id", value: entry.id, base_revision: current?.revision || 0 }], req.session.user.id); res.json({ ok: true, status: providerStatus }); } - catch (error) { res.status(500).json({ ok: false, error: error.message }); } + catch (error) { res.status(error.code === "WORKER_NOT_CONFIGURED" ? 409 : 500).json({ ok: false, code: error.code || "MODEL_LOAD_FAILED", error: error.message }); } }); router.post("/api/runtime/:id/install", requireAdmin, async (req, res) => { const entry = runtimeManifest.artifacts.find((candidate) => candidate.id === req.params.id); diff --git a/plugins/lumi_transcription/plugin.json b/plugins/lumi_transcription/plugin.json index 1546c24..7419d4d 100644 --- a/plugins/lumi_transcription/plugin.json +++ b/plugins/lumi_transcription/plugin.json @@ -1,7 +1,7 @@ { "id": "lumi_transcription", "name": "Lumi Transcription", - "version": "0.1.0-experimental.2", + "version": "0.1.0-experimental.3", "description": "Server-hosted whisper.cpp transcription for Lumi Companion and OBS closed captions.", "main": "index.js", "channel": "experimental", diff --git a/plugins/lumi_transcription/scripts/build-worker.ps1 b/plugins/lumi_transcription/scripts/build-worker.ps1 new file mode 100644 index 0000000..f602a78 --- /dev/null +++ b/plugins/lumi_transcription/scripts/build-worker.ps1 @@ -0,0 +1,46 @@ +param( + [switch]$Cuda +) + +$ErrorActionPreference = "Stop" +$pluginRoot = Split-Path -Parent $PSScriptRoot +$sourceRoot = Join-Path $pluginRoot "backend\transcription\worker-native" +$backend = if ($Cuda) { "cuda" } else { "cpu" } +$buildRoot = Join-Path $env:TEMP "lumi-whisper-worker-$backend" +$targetRoot = Join-Path $pluginRoot "data\runtime\worker\windows-x64-$backend\bin" + +function Find-Tool([string]$name) { + $command = Get-Command $name -ErrorAction SilentlyContinue + if ($command) { return $command.Source } + $candidate = Get-ChildItem (Join-Path $env:APPDATA "Python\Python*\Scripts\$name") -ErrorAction SilentlyContinue | + Sort-Object FullName -Descending | + Select-Object -First 1 + if ($candidate) { return $candidate.FullName } + throw "$name was not found. Install CMake and Ninja before building the transcription worker." +} + +$cmake = Find-Tool "cmake.exe" +$ninja = Find-Tool "ninja.exe" +$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." } + +New-Item -ItemType Directory -Force -Path $buildRoot, $targetRoot | Out-Null +$cudaValue = if ($Cuda) { "ON" } else { "OFF" } +$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), + "if errorlevel 1 exit /b %errorlevel%", + ('"{0}" --build "{1}" --config Release' -f $cmake, $buildRoot), + "exit /b %errorlevel%" +) | Set-Content -Encoding Ascii $buildScript +$commandProcessor = Join-Path $env:SystemRoot "System32\cmd.exe" +$process = Start-Process -FilePath $commandProcessor -ArgumentList "/d /c $buildScript" -Wait -PassThru -NoNewWindow +if ($process.ExitCode -ne 0) { throw "Worker build failed with exit code $($process.ExitCode)." } + +$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") +Write-Host "Installed Lumi whisper worker ($backend) in $targetRoot" diff --git a/plugins/lumi_transcription/tests/verify.js b/plugins/lumi_transcription/tests/verify.js index 623127c..e003cf0 100644 --- a/plugins/lumi_transcription/tests/verify.js +++ b/plugins/lumi_transcription/tests/verify.js @@ -13,6 +13,7 @@ const { DeviceStore } = require("../backend/companion/device_store"); const { normalizeHost } = require("../backend/companion/device_store"); const { CompanionPackageService, safeArchivePath } = require("../backend/companion/package_service"); const { CompanionGateway } = require("../backend/companion/gateway"); +const { resolveWorkerExecutable } = require("../backend/paths"); const protocol = require("../backend/companion/protocol"); const { RevisionStore } = require("../backend/config/revision_store"); const { JsonlDiagnosticLog, sanitize } = require("../backend/logs/jsonl_log"); @@ -30,6 +31,7 @@ async function run() { verifyProtocol(); verifyPairingAndRevocation(); verifyLocalhostTransportPolicy(); + verifyWorkerResolution(temp); verifyRevisions(); verifyQueues(); verifyStabilization(); @@ -94,6 +96,13 @@ function verifyLocalhostTransportPolicy() { db.close(); } +function verifyWorkerResolution(temp) { + const executable = path.join(temp, process.platform === "win32" ? "worker.exe" : "worker"); + fs.writeFileSync(executable, "worker"); + assert.equal(resolveWorkerExecutable(executable), path.resolve(executable)); + assert.throws(() => new WhisperWorkerSupervisor({ executable: "" }).start(), (error) => error.code === "WORKER_NOT_CONFIGURED"); +} + function verifyRevisions() { const db = new Database(":memory:"); const store = new RevisionStore(db, { now: () => 1234 }); @@ -277,7 +286,7 @@ async function verifyCompanionPackage(temp) { zip.writeZip(sourcePath); const manifest = { version: "test", artifacts: [{ id: "windows-x64", platform: "win32", architecture: "x64", filename: "companion.zip", - entrypoint: "Lumi.Companion.App.exe", url: "https://example.invalid/companion.zip", sha256: sha256File(sourcePath) + entrypoint: "Lumi.Companion.App.exe", url: "https://example.invalid/companion.zip", sha256: sha256File(sourcePath), bytes: fs.statSync(sourcePath).size }] }; const service = new CompanionPackageService(root, manifest); const bundle = await service.build({ pairing_id: "pairing", bootstrap: { format: "lumi-companion-bootstrap-v1", token: "single-use" } }); @@ -285,6 +294,12 @@ async function verifyCompanionPackage(temp) { assert.equal(output.readAsText("Lumi.Companion.App.exe"), "portable-app"); assert.match(output.readAsText("lumi-companion-pairing.lumi-pairing.json"), /single-use/); assert.match(output.readAsText("START-HERE.txt"), /works once/i); + const manifestPath = path.join(root, "manifest.json"); + fs.writeFileSync(manifestPath, JSON.stringify(manifest)); + const liveManifestService = new CompanionPackageService(root, manifestPath); + assert.equal(liveManifestService.status().version, "test"); + fs.writeFileSync(manifestPath, JSON.stringify({ ...manifest, version: "refreshed" })); + assert.equal(liveManifestService.status().version, "refreshed"); assert.throws(() => safeArchivePath("../escape.exe"), /unsafe path/i); }