Accelerate server transcription and finalize tests
This commit is contained in:
parent
63e52e3f6a
commit
7760ed0a3f
13
TODO.md
13
TODO.md
@ -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.
|
||||||
|
|||||||
@ -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.
|
||||||
|
|||||||
@ -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"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@ -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;
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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 };
|
||||||
|
|||||||
@ -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 };
|
||||||
|
|||||||
@ -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 {
|
||||||
|
|||||||
@ -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) {
|
||||||
|
|||||||
@ -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);
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -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"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
|||||||
@ -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) }));
|
||||||
|
|||||||
@ -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",
|
||||||
|
|||||||
@ -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]");
|
||||||
|
|||||||
@ -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."
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
189
plugins/lumi_transcription/scripts/benchmark-worker.js
Normal file
189
plugins/lumi_transcription/scripts/benchmark-worker.js
Normal 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)); }
|
||||||
@ -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"
|
||||||
|
}
|
||||||
|
|||||||
@ -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 });
|
||||||
|
|||||||
@ -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>
|
||||||
|
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user