Fix local Companion package and worker setup

This commit is contained in:
Franz Rolfsvaag 2026-07-22 14:34:29 +02:00
parent 27555e258e
commit 37dda538b3
12 changed files with 110 additions and 19 deletions

View File

@ -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:

View File

@ -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.

View File

@ -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

View File

@ -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 };
}
}

View File

@ -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);

View File

@ -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 };

View File

@ -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 };

View File

@ -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.

View File

@ -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);

View File

@ -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",

View File

@ -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"

View File

@ -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);
}