Keep transcription worker failures recoverable

This commit is contained in:
Franz Rolfsvaag 2026-07-22 17:35:27 +02:00
parent cc8d455b4e
commit df23b57078
13 changed files with 224 additions and 44 deletions

View File

@ -27,6 +27,11 @@ selected-source PCM capture; nested Program-scene evaluation; bounded same-user
IPC; bridge health; and native caption submission. Experimental.2 must be replaced IPC; bridge health; and native caption submission. Experimental.2 must be replaced
manually once to bootstrap the updater. manually once to bootstrap the updater.
Experimental.4 contains Windows speech-path reliability fixes: native PCM stdin
is explicitly binary-safe, broken worker pipes no longer terminate Lumi, the
worker restarts and reloads the selected model automatically, and the Companion
test reports the server failure at Speech recognition instead of hanging.
Release-blocking work remains: Release-blocking work remains:
- Package the native whisper.cpp worker as the Windows CUDA runtime, then benchmark - Package the native whisper.cpp worker as the Windows CUDA runtime, then benchmark

View File

@ -1,5 +1,6 @@
param( param(
[string]$Version = "0.1.0-experimental.3" [string]$Version = "0.1.0-experimental.4",
[string]$BridgeVersion = "0.1.0-experimental.3"
) )
$ErrorActionPreference = "Stop" $ErrorActionPreference = "Stop"
@ -10,12 +11,12 @@ $publishRoot = Join-Path $outputRoot "publish"
$stageRoot = Join-Path $outputRoot "package" $stageRoot = Join-Path $outputRoot "package"
$archive = Join-Path $outputRoot "Lumi.Companion-win-x64.zip" $archive = Join-Path $outputRoot "Lumi.Companion-win-x64.zip"
& (Join-Path $PSScriptRoot "build-obs-bridge.ps1") -BridgeVersion $Version & (Join-Path $PSScriptRoot "build-obs-bridge.ps1") -BridgeVersion $BridgeVersion
Remove-Item $publishRoot, $stageRoot -Recurse -Force -ErrorAction SilentlyContinue Remove-Item $publishRoot, $stageRoot -Recurse -Force -ErrorAction SilentlyContinue
New-Item -ItemType Directory -Force -Path $publishRoot, $stageRoot | Out-Null New-Item -ItemType Directory -Force -Path $publishRoot, $stageRoot | Out-Null
$localDotnet = Join-Path $HOME ".dotnet-sdk\dotnet.exe" $localDotnet = Join-Path $HOME ".dotnet-sdk\dotnet.exe"
$dotnet = if (Test-Path $localDotnet) { $localDotnet } else { (Get-Command dotnet.exe -ErrorAction Stop).Source } $dotnet = if (Test-Path $localDotnet) { $localDotnet } else { (Get-Command dotnet.exe -ErrorAction Stop).Source }
$publishArguments = @("publish", "`"$project`"", "-c", "Release", "-r", "win-x64", "--self-contained", "true", "-o", "`"$publishRoot`"", $publishArguments = @("publish", "`"$project`"", "-c", "Release", "-r", "win-x64", "--self-contained", "true", "--disable-build-servers", "-o", "`"$publishRoot`"",
"-p:PublishSingleFile=true", "-p:IncludeNativeLibrariesForSelfExtract=true", "-p:DebugType=None", "-p:Version=$Version") "-p:PublishSingleFile=true", "-p:IncludeNativeLibrariesForSelfExtract=true", "-p:DebugType=None", "-p:Version=$Version")
$published = Start-Process -FilePath $dotnet -ArgumentList $publishArguments -NoNewWindow -Wait -PassThru $published = Start-Process -FilePath $dotnet -ArgumentList $publishArguments -NoNewWindow -Wait -PassThru
if ($published.ExitCode) { throw "Companion publish failed." } if ($published.ExitCode) { throw "Companion publish failed." }

View File

@ -24,6 +24,8 @@ public sealed class CompanionRuntime : IAsyncDisposable
private ObsBridgePipe? _obsBridge; private ObsBridgePipe? _obsBridge;
private TaskCompletionSource<bool>? _audioSignal; private TaskCompletionSource<bool>? _audioSignal;
private TaskCompletionSource<bool>? _captionSignal; private TaskCompletionSource<bool>? _captionSignal;
private TaskCompletionSource<bool>? _sessionStartSignal;
private TaskCompletionSource<string>? _testFailure;
private bool _disposed; private bool _disposed;
private CompanionUpdate? _availableUpdate; private CompanionUpdate? _availableUpdate;
@ -127,6 +129,7 @@ public sealed class CompanionRuntime : IAsyncDisposable
_socket.Disconnected += error => _socket.Disconnected += error =>
{ {
if (_disposed) return; if (_disposed) return;
_testFailure?.TrySetResult(error is null ? "The Lumi connection closed during the test." : $"The Lumi connection closed during the test: {Friendly(error)}");
SetState(State with { Connected = false, Health = TrayHealth.Degraded, Detail = "The secure Lumi connection closed. Retry when the host is available." }); SetState(State with { Connected = false, Health = TrayHealth.Degraded, Detail = "The secure Lumi connection closed. Retry when the host is available." });
_ = WriteLogAsync("disconnected", error?.Message ?? "Connection closed."); _ = WriteLogAsync("disconnected", error?.Message ?? "Connection closed.");
}; };
@ -178,18 +181,29 @@ public sealed class CompanionRuntime : IAsyncDisposable
if (!State.Connected) return; if (!State.Connected) return;
_audioSignal = new(TaskCreationOptions.RunContinuationsAsynchronously); _audioSignal = new(TaskCreationOptions.RunContinuationsAsynchronously);
_captionSignal = new(TaskCreationOptions.RunContinuationsAsynchronously); _captionSignal = new(TaskCreationOptions.RunContinuationsAsynchronously);
_sessionStartSignal = new(TaskCreationOptions.RunContinuationsAsynchronously);
_testFailure = new(TaskCreationOptions.RunContinuationsAsynchronously);
await _socket!.SendAsync("start", new { mode = "test" }, _socket.SessionId, cancellationToken); await _socket!.SendAsync("start", new { mode = "test" }, _socket.SessionId, cancellationToken);
MarkStage(stages, 3, TestStageState.Running, "Speak normally into the selected microphone…"); var sessionStart = await WaitForTestSignalAsync(_sessionStartSignal.Task, TimeSpan.FromSeconds(8), cancellationToken);
if (!await WaitForAsync(_audioSignal.Task, TimeSpan.FromSeconds(10), cancellationToken)) if (!sessionStart.Completed)
{ {
MarkStage(stages, 3, TestStageState.Blocked, "No active microphone audio reached the companion within 10 seconds."); MarkStage(stages, 3, TestStageState.Blocked, "Audio capture was not attempted because the server inference session did not start.");
MarkStage(stages, 5, TestStageState.Blocked, sessionStart.Failure ?? "Lumi did not confirm that speech recognition started within 8 seconds.");
return;
}
MarkStage(stages, 3, TestStageState.Running, "Speak normally into the selected microphone…");
var audio = await WaitForTestSignalAsync(_audioSignal.Task, TimeSpan.FromSeconds(10), cancellationToken);
if (!audio.Completed)
{
MarkStage(stages, 3, TestStageState.Blocked, audio.Failure ?? "No active microphone audio reached the companion within 10 seconds.");
return; return;
} }
MarkStage(stages, 3, TestStageState.Passed, "Live microphone audio reached the companion."); MarkStage(stages, 3, TestStageState.Passed, "Live microphone audio reached the companion.");
MarkStage(stages, 5, TestStageState.Running, "Waiting for server-hosted speech recognition…"); MarkStage(stages, 5, TestStageState.Running, "Waiting for server-hosted speech recognition…");
if (!await WaitForAsync(_captionSignal.Task, TimeSpan.FromSeconds(30), cancellationToken)) var caption = await WaitForTestSignalAsync(_captionSignal.Task, TimeSpan.FromSeconds(30), cancellationToken);
if (!caption.Completed)
{ {
MarkStage(stages, 5, TestStageState.Blocked, "No caption returned within 30 seconds. Check the loaded model and host diagnostics."); MarkStage(stages, 5, TestStageState.Blocked, caption.Failure ?? "No caption returned within 30 seconds. Check the loaded model and host diagnostics.");
return; return;
} }
MarkStage(stages, 5, TestStageState.Passed, "Speech recognition returned a stable result."); MarkStage(stages, 5, TestStageState.Passed, "Speech recognition returned a stable result.");
@ -204,6 +218,8 @@ public sealed class CompanionRuntime : IAsyncDisposable
try { await _socket.SendAsync("stop", new { reason = "test_complete" }, _socket.SessionId, CancellationToken.None); } catch { } try { await _socket.SendAsync("stop", new { reason = "test_complete" }, _socket.SessionId, CancellationToken.None); } catch { }
_audioSignal = null; _audioSignal = null;
_captionSignal = null; _captionSignal = null;
_sessionStartSignal = null;
_testFailure = null;
SetState(State with { TestRunning = false, Detail = TestStages.Any(stage => stage.State == TestStageState.Blocked) ? "The test stopped at the first unavailable real boundary." : State.Detail }); SetState(State with { TestRunning = false, Detail = TestStages.Any(stage => stage.State == TestStageState.Blocked) ? "The test stopped at the first unavailable real boundary." : State.Detail });
} }
} }
@ -341,6 +357,9 @@ public sealed class CompanionRuntime : IAsyncDisposable
private Task OnServerMessageAsync(ServerEnvelope message) private Task OnServerMessageAsync(ServerEnvelope message)
{ {
if (message.Type == "status" && message.Payload.TryGetProperty("kind", out var kind) && kind.GetString() == "session" &&
message.Payload.TryGetProperty("state", out var state) && state.GetString() == "running")
_sessionStartSignal?.TrySetResult(true);
if (message.Type == "caption") if (message.Type == "caption")
{ {
var text = message.Payload.TryGetProperty("stable_text", out var stable) ? stable.GetString() : null; var text = message.Payload.TryGetProperty("stable_text", out var stable) ? stable.GetString() : null;
@ -355,6 +374,7 @@ public sealed class CompanionRuntime : IAsyncDisposable
if (message.Type == "error") if (message.Type == "error")
{ {
var serverMessage = message.Payload.TryGetProperty("message", out var value) ? value.GetString() : "Lumi reported an error."; var serverMessage = message.Payload.TryGetProperty("message", out var value) ? value.GetString() : "Lumi reported an error.";
_testFailure?.TrySetResult(serverMessage ?? "Lumi reported an inference error.");
SetState(State with { Health = TrayHealth.Degraded, Detail = serverMessage ?? "Lumi reported an error." }); SetState(State with { Health = TrayHealth.Degraded, Detail = serverMessage ?? "Lumi reported an error." });
} }
return Task.CompletedTask; return Task.CompletedTask;
@ -444,14 +464,16 @@ public sealed class CompanionRuntime : IAsyncDisposable
private static bool ReadBoolean(JsonElement value, string name) => value.TryGetProperty(name, out var property) && property.ValueKind == JsonValueKind.True; private static bool ReadBoolean(JsonElement value, string name) => value.TryGetProperty(name, out var property) && property.ValueKind == JsonValueKind.True;
private static string? ReadString(JsonElement value, string name) => value.TryGetProperty(name, out var property) && property.ValueKind == JsonValueKind.String ? property.GetString() : null; private static string? ReadString(JsonElement value, string name) => value.TryGetProperty(name, out var property) && property.ValueKind == JsonValueKind.String ? property.GetString() : null;
private CancellationToken _lifetimeToken() => _disposed ? new CancellationToken(true) : CancellationToken.None; private CancellationToken _lifetimeToken() => _disposed ? new CancellationToken(true) : CancellationToken.None;
private static async Task<bool> WaitForAsync(Task task, TimeSpan timeout, CancellationToken cancellationToken) private async Task<(bool Completed, string? Failure)> WaitForTestSignalAsync(Task signal, TimeSpan timeout, CancellationToken cancellationToken)
{ {
var failure = _testFailure?.Task ?? throw new InvalidOperationException("The test failure signal is unavailable.");
using var timeoutSource = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); using var timeoutSource = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
var delay = Task.Delay(timeout, timeoutSource.Token); var delay = Task.Delay(timeout, timeoutSource.Token);
if (await Task.WhenAny(task, delay) != task) return false; var completed = await Task.WhenAny(signal, failure, delay);
timeoutSource.Cancel(); if (completed == signal) { timeoutSource.Cancel(); await signal; return (true, null); }
await task; if (completed == failure) { timeoutSource.Cancel(); return (false, await failure); }
return true; cancellationToken.ThrowIfCancellationRequested();
return (false, null);
} }
private bool DetectBridgeInstallation() => _bridgeManager.Inspect().Valid; private bool DetectBridgeInstallation() => _bridgeManager.Inspect().Valid;
@ -505,6 +527,8 @@ public sealed class CompanionRuntime : IAsyncDisposable
private static string Friendly(Exception error) => error switch private static string Friendly(Exception error) => error switch
{ {
InvalidDataException => error.Message, InvalidDataException => error.Message,
System.Net.WebSockets.WebSocketException => "The Lumi server connection ended unexpectedly. The host may have restarted; reconnect and review transcription diagnostics.",
EndOfStreamException => error.Message,
HttpRequestException => "Check the Lumi address, TLS certificate, and network connection.", HttpRequestException => "Check the Lumi address, TLS certificate, and network connection.",
TaskCanceledException => "The connection timed out. Check that Lumi is reachable.", TaskCanceledException => "The connection timed out. Check that Lumi is reachable.",
_ => error.Message _ => error.Message

View File

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

View File

@ -12,6 +12,7 @@ public sealed class CompanionSocket : IAsyncDisposable
private readonly SemaphoreSlim _sendLock = new(1, 1); private readonly SemaphoreSlim _sendLock = new(1, 1);
private readonly List<Task> _backgroundTasks = []; private readonly List<Task> _backgroundTasks = [];
private long _droppedFrames; private long _droppedFrames;
private int _disconnectNotified;
public long DroppedFrames => Interlocked.Read(ref _droppedFrames); public long DroppedFrames => Interlocked.Read(ref _droppedFrames);
public Guid SessionId { get; private set; } public Guid SessionId { get; private set; }
public event Func<ServerEnvelope, Task>? MessageReceived; public event Func<ServerEnvelope, Task>? MessageReceived;
@ -19,6 +20,7 @@ public sealed class CompanionSocket : IAsyncDisposable
public async Task ConnectAsync(DeviceCredential credential, string companionVersion, string pluginVersion, string? obsVersion, CancellationToken cancellationToken) public async Task ConnectAsync(DeviceCredential credential, string companionVersion, string pluginVersion, string? obsVersion, CancellationToken cancellationToken)
{ {
_disconnectNotified = 0;
_socket = new ClientWebSocket(); _socket = new ClientWebSocket();
_socket.Options.SetRequestHeader("Authorization", $"LumiDevice {credential.DeviceId}.{credential.DeviceSecret}"); _socket.Options.SetRequestHeader("Authorization", $"LumiDevice {credential.DeviceId}.{credential.DeviceSecret}");
var host = new Uri(credential.Host); var host = new Uri(credential.Host);
@ -98,7 +100,7 @@ public sealed class CompanionSocket : IAsyncDisposable
catch (Exception error) catch (Exception error)
{ {
_lifetime?.Cancel(); _lifetime?.Cancel();
Disconnected?.Invoke(error); if (Interlocked.Exchange(ref _disconnectNotified, 1) == 0) Disconnected?.Invoke(error);
} }
} }
private static async Task<ServerEnvelope> ReceiveMessageAsync(ClientWebSocket socket, CancellationToken cancellationToken) private static async Task<ServerEnvelope> ReceiveMessageAsync(ClientWebSocket socket, CancellationToken cancellationToken)
@ -109,7 +111,12 @@ public sealed class CompanionSocket : IAsyncDisposable
do do
{ {
result = await socket.ReceiveAsync(new ArraySegment<byte>(buffer), cancellationToken); result = await socket.ReceiveAsync(new ArraySegment<byte>(buffer), cancellationToken);
if (result.MessageType == WebSocketMessageType.Close) throw new EndOfStreamException("Lumi closed the companion connection."); if (result.MessageType == WebSocketMessageType.Close)
{
if (socket.State == WebSocketState.CloseReceived)
try { await socket.CloseOutputAsync(WebSocketCloseStatus.NormalClosure, "close_ack", CancellationToken.None); } catch (WebSocketException) { }
throw new EndOfStreamException($"Lumi closed the companion connection ({result.CloseStatus?.ToString() ?? "no status"}: {result.CloseStatusDescription ?? "no reason"}).");
}
body.Write(buffer, 0, result.Count); body.Write(buffer, 0, result.Count);
if (body.Length > ProtocolV1.MaxJsonBytes) throw new InvalidDataException("Lumi message exceeds the protocol limit."); if (body.Length > ProtocolV1.MaxJsonBytes) throw new InvalidDataException("Lumi message exceeds the protocol limit.");
} while (!result.EndOfMessage); } while (!result.EndOfMessage);

View File

@ -11,7 +11,12 @@ class SessionCoordinator {
this.graceMs = options.graceMs || 30000; this.graceMs = options.graceMs || 30000;
this.sessions = new Map(); this.sessions = new Map();
this.stabilizer = new CaptionStabilizer({ now: this.now }); this.stabilizer = new CaptionStabilizer({ now: this.now });
this.provider.on?.("hypothesis", (event) => this.onHypothesis(event)); this.provider.on?.("hypothesis", (event) => Promise.resolve(this.onHypothesis(event)).catch((error) => {
this.log.append({ kind: "inference_result_error", code: error.code || null, message: error.message });
}));
this.provider.on?.("provider_error", (error) => Promise.resolve(this.onProviderError(error)).catch((failure) => {
this.log.append({ kind: "provider_failure_handler_error", message: failure.message });
}));
} }
create(device, send, resumeSessionId = null) { create(device, send, resumeSessionId = null) {
const resumable = resumeSessionId && this.sessions.get(resumeSessionId); const resumable = resumeSessionId && this.sessions.get(resumeSessionId);
@ -66,7 +71,12 @@ class SessionCoordinator {
const tracks = Array.from(session.tracks.values()).filter((track) => track.enabled && !track.source_missing); const tracks = Array.from(session.tracks.values()).filter((track) => track.enabled && !track.source_missing);
if (!tracks.length) throw Object.assign(new Error("Select an available OBS audio source first."), { code: "NO_TRACKS" }); if (!tracks.length) throw Object.assign(new Error("Select an available OBS audio source first."), { code: "NO_TRACKS" });
const providerHealth = await this.provider.health(); const providerHealth = await this.provider.health();
if (!providerHealth.healthy) throw Object.assign(new Error("The whisper.cpp worker is not ready. Install and load a model first."), { code: "PROVIDER_UNAVAILABLE", details: providerHealth }); if (!providerHealth.healthy) {
const reason = providerHealth.last_error?.message ||
(providerHealth.last_exit ? `The worker last exited with code ${providerHealth.last_exit.code ?? "unknown"}.` : null) ||
`Worker state is ${providerHealth.state || "unavailable"}.`;
throw Object.assign(new Error(`Speech recognition is not ready. ${reason}`), { code: "PROVIDER_UNAVAILABLE", details: providerHealth });
}
await this.provider.startSession({ id: session.id, mode }); await this.provider.startSession({ id: session.id, mode });
for (const track of tracks) await this.provider.addTrack(session.id, serializeTrack(track)); for (const track of tracks) await this.provider.addTrack(session.id, serializeTrack(track));
await session.delivery.start({ testMode: mode === "test" }); await session.delivery.start({ testMode: mode === "test" });
@ -152,6 +162,24 @@ class SessionCoordinator {
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: event.stable_text, uncertain_text: event.uncertain_text, revision: event.revision, final: event.final, delivery: result.disposition, latency: event.latency, model: event.model }); this.log.append({ kind: "caption", session_id: session.id, source_uuid: track.source_uuid, caption_text: event.stable_text, uncertain_text: event.uncertain_text, revision: event.revision, final: event.final, delivery: result.disposition, latency: event.latency, model: event.model });
} }
async onProviderError(error) {
const affected = Array.from(this.sessions.values()).filter((session) => session.state === "running" || session.state === "grace");
for (const session of affected) {
clearTimeout(session.graceTimer);
session.graceTimer = null;
session.graceUntil = 0;
session.state = "idle";
session.mode = null;
for (const track of session.tracks.values()) track.buffer.clear();
try { await session.delivery.stop(); } catch { }
session.send("error", {
code: error.code || "PROVIDER_FAILED",
message: `Speech recognition stopped: ${error.message}`,
recoverable: true
}, session.id);
this.log.append({ kind: "session", state: "inference_failed", session_id: session.id, code: error.code || "PROVIDER_FAILED", message: error.message });
}
}
} }
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; }

View File

@ -27,6 +27,10 @@ class WhisperWorkerSupervisor extends EventEmitter {
this.stopping = false; this.stopping = false;
this.restartTimes = []; this.restartTimes = [];
this.stdoutBuffer = ""; this.stdoutBuffer = "";
this.lastExit = null;
this.lastError = null;
this.lastDiagnostic = null;
this.startedAt = null;
} }
start() { start() {
if (this.child) return; if (this.child) return;
@ -35,9 +39,13 @@ class WhisperWorkerSupervisor extends EventEmitter {
this.state = "starting"; this.state = "starting";
const child = this.spawn(this.executable, this.args, { stdio: ["pipe", "pipe", "pipe"], windowsHide: true }); const child = this.spawn(this.executable, this.args, { stdio: ["pipe", "pipe", "pipe"], windowsHide: true });
this.child = child; this.child = child;
this.startedAt = Date.now();
child.stdout.on("data", (chunk) => this.onStdout(chunk)); child.stdout.on("data", (chunk) => this.onStdout(chunk));
child.stderr.on("data", (chunk) => this.emit("diagnostic", { level: "warning", message: String(chunk).trim().slice(0, 1000) })); child.stderr.on("data", (chunk) => this.onDiagnostic(String(chunk).trim().slice(0, 1000)));
child.on("error", (error) => this.emit("error", error)); child.stdin.on("error", (error) => this.onIoError("stdin", error));
child.stdout.on("error", (error) => this.onIoError("stdout", error));
child.stderr.on("error", (error) => this.onIoError("stderr", error));
child.on("error", (error) => this.onProcessError(error));
child.on("exit", (code, signal) => this.onExit(code, signal)); child.on("exit", (code, signal) => this.onExit(code, signal));
child.stdin.on("drain", () => this.flush()); child.stdin.on("drain", () => this.flush());
this.state = "running"; this.state = "running";
@ -59,7 +67,9 @@ class WhisperWorkerSupervisor extends EventEmitter {
flush() { flush() {
if (!this.child || this.state !== "running") return; if (!this.child || this.state !== "running") return;
let packet; let packet;
try {
while ((packet = this.queue.shift())) if (!this.child.stdin.write(packet)) break; while ((packet = this.queue.shift())) if (!this.child.stdin.write(packet)) break;
} catch (error) { this.onIoError("stdin", error); }
} }
async stop() { async stop() {
this.stopping = true; this.stopping = true;
@ -84,32 +94,71 @@ class WhisperWorkerSupervisor extends EventEmitter {
this.stdoutBuffer = this.stdoutBuffer.slice(index + 1); this.stdoutBuffer = this.stdoutBuffer.slice(index + 1);
if (!line) continue; if (!line) continue;
try { this.emit("message", JSON.parse(line)); } try { this.emit("message", JSON.parse(line)); }
catch { this.emit("diagnostic", { level: "warning", message: "Whisper worker emitted malformed output." }); } catch { this.onDiagnostic("Whisper worker emitted malformed output."); }
} }
} }
onDiagnostic(message) {
if (!message) return;
this.lastDiagnostic = { message, at: Date.now() };
this.emit("diagnostic", { level: "warning", message });
}
onIoError(stream, error) {
if (this.stopping) return;
this.lastError = { stream, code: error.code || null, message: error.message, at: Date.now() };
this.emit("error", Object.assign(new Error(`Whisper worker ${stream} failed: ${error.message}`), { code: error.code, stream }));
}
onProcessError(error) {
this.lastError = { stream: "process", code: error.code || null, message: error.message, at: Date.now() };
this.emit("error", error);
}
onExit(code, signal) { onExit(code, signal) {
this.child = null; this.child = null;
this.lastExit = { code, signal: signal || null, at: Date.now() };
if (this.stopping) { this.state = "stopped"; return; } if (this.stopping) { this.state = "stopped"; return; }
this.state = "failed"; this.state = "failed";
this.emit("crash", { code, signal }); this.emit("state", { state: this.state });
this.emit("crash", { code, signal, at: this.lastExit.at });
const now = Date.now(); const now = Date.now();
this.restartTimes = this.restartTimes.filter((time) => now - time < this.restartWindowMs); this.restartTimes = this.restartTimes.filter((time) => now - time < this.restartWindowMs);
if (this.restartTimes.length >= this.maxRestarts) return; if (this.restartTimes.length >= this.maxRestarts) return;
this.restartTimes.push(now); this.restartTimes.push(now);
setTimeout(() => { try { this.start(); } catch (error) { this.emit("error", error); } }, 250).unref?.(); setTimeout(() => { try { this.start(); } catch (error) { this.emit("error", error); } }, 250).unref?.();
} }
health() { return { healthy: this.state === "running", state: this.state, queue: this.queue.metrics(), restarts_in_window: this.restartTimes.length }; } health() {
return {
healthy: this.state === "running", state: this.state, queue: this.queue.metrics(),
restarts_in_window: this.restartTimes.length, started_at: this.startedAt,
last_exit: this.lastExit, last_error: this.lastError, last_diagnostic: this.lastDiagnostic
};
}
} }
class WhisperCppServerProvider extends TranscriptionProvider { class WhisperCppServerProvider extends TranscriptionProvider {
constructor(supervisor) { constructor(supervisor) {
super(); this.worker = supervisor; this.model = null; this.sessions = new Set(); super(); this.worker = supervisor; this.model = null; this.desiredModel = null; this.modelReady = false; this.loading = false; this.recovering = false; this.sessions = new Set();
supervisor.on("message", (message) => this.emit(message.type || "message", message)); supervisor.on("message", (message) => this.emit(message.type || "message", message));
supervisor.on("crash", (event) => this.emit("provider_error", Object.assign(new Error("Whisper worker crashed."), { details: event }))); supervisor.on("crash", (event) => {
this.modelReady = false;
this.sessions.clear();
this.emit("provider_error", Object.assign(new Error(`Whisper worker exited unexpectedly${event.code == null ? "" : ` with code ${event.code}`}.`), { code: "WORKER_CRASHED", details: event }));
});
supervisor.on("error", (error) => this.emit("provider_error", error)); supervisor.on("error", (error) => this.emit("provider_error", error));
supervisor.on("state", ({ state }) => {
if (state === "running" && this.desiredModel && !this.modelReady && !this.loading && !this.recovering) this.recoverModel();
});
} }
async loadModel(model) { async loadModel(model) {
this.desiredModel = model;
this.loading = true;
try {
this.worker.start(); this.worker.start();
const result = await this.requestModel(model);
this.model = model;
this.modelReady = true;
return { ...(await this.health()), loaded: result };
} finally { this.loading = false; }
}
async requestModel(model) {
const loaded = new Promise((resolve, reject) => { const loaded = new Promise((resolve, reject) => {
const timer = setTimeout(() => { cleanup(); reject(new Error("The whisper.cpp worker did not confirm model loading in time.")); }, 30000); 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 onLoaded = (event) => { cleanup(); resolve(event); };
@ -120,18 +169,36 @@ class WhisperCppServerProvider extends TranscriptionProvider {
this.once("provider_error", onProviderError); this.once("provider_error", onProviderError);
}); });
this.worker.send({ type: "load_model", model }); this.worker.send({ type: "load_model", model });
const result = await loaded; return loaded;
this.model = model; }
return { ...(await this.health()), loaded: result }; recoverModel() {
this.recovering = true;
this.requestModel(this.desiredModel).then((result) => {
this.model = this.desiredModel;
this.modelReady = true;
this.emit("recovered", { model_id: result.model_id || this.model?.id || null, backend: result.backend || null });
}).catch((error) => this.emit("provider_error", Object.assign(error, { code: error.code || "MODEL_RECOVERY_FAILED" })))
.finally(() => { this.recovering = false; });
} }
async benchmark(options = {}) { this.worker.send({ type: "benchmark", options }); return { accepted: true, model: this.model?.id || null }; } async benchmark(options = {}) { this.worker.send({ type: "benchmark", options }); return { accepted: true, model: this.model?.id || null }; }
async startSession(session) { this.sessions.add(session.id); this.worker.send({ type: "start_session", session }); } async startSession(session) {
if (!this.modelReady) throw coded("PROVIDER_UNAVAILABLE", "The speech model is not ready in the worker.");
this.sessions.add(session.id); this.worker.send({ type: "start_session", session });
}
async addTrack(sessionId, track) { this.worker.send({ type: "add_track", session_id: sessionId, track }); } async addTrack(sessionId, track) { this.worker.send({ type: "add_track", session_id: sessionId, track }); }
async pushAudio(sessionId, trackId, frame) { return this.worker.send({ type: "audio", session_id: sessionId, track_id: trackId, sequence: frame.sequence, capture_timestamp_us: frame.capture_timestamp_us, captured_at: Date.now() }, frame.pcm); } async pushAudio(sessionId, trackId, frame) { return this.worker.send({ type: "audio", session_id: sessionId, track_id: trackId, sequence: frame.sequence, capture_timestamp_us: frame.capture_timestamp_us, captured_at: Date.now() }, frame.pcm); }
async removeTrack(sessionId, trackId) { this.worker.send({ type: "remove_track", session_id: sessionId, track_id: trackId }); } async removeTrack(sessionId, trackId) { this.worker.send({ type: "remove_track", session_id: sessionId, track_id: trackId }); }
async stopSession(sessionId) { this.sessions.delete(sessionId); this.worker.send({ type: "stop_session", session_id: sessionId }); } async stopSession(sessionId) { this.sessions.delete(sessionId); this.worker.send({ type: "stop_session", session_id: sessionId }); }
async health() { return { provider: "whisper_cpp_server", model: this.model?.id || null, sessions: this.sessions.size, ...this.worker.health() }; } async health() {
async stop() { this.sessions.clear(); await this.worker.stop(); } const worker = this.worker.health();
return {
...worker, provider: "whisper_cpp_server", model: this.model?.id || this.desiredModel?.id || null,
model_ready: this.modelReady, sessions: this.sessions.size,
healthy: worker.healthy && this.modelReady,
state: !worker.healthy ? worker.state : this.modelReady ? "running" : this.recovering || this.loading ? "loading_model" : "model_unavailable"
};
}
async stop() { this.sessions.clear(); this.modelReady = false; await this.worker.stop(); }
} }
function coded(code, message) { return Object.assign(new Error(message), { code }); } function coded(code, message) { return Object.assign(new Error(message), { code }); }

View File

@ -13,6 +13,11 @@
#include <unordered_map> #include <unordered_map>
#include <vector> #include <vector>
#if defined(_WIN32)
#include <fcntl.h>
#include <io.h>
#endif
using json = nlohmann::json; using json = nlohmann::json;
using clock_type = std::chrono::steady_clock; using clock_type = std::chrono::steady_clock;
@ -203,6 +208,11 @@ void handle(const json & packet, const std::vector<std::uint8_t> & pcm, bool & r
} }
int main() { int main() {
#if defined(_WIN32)
// PCM frames are transported over stdin. Windows text mode treats 0x1A in
// ordinary audio as EOF, which closes the worker and breaks the host pipe.
if (_setmode(_fileno(stdin), _O_BINARY) == -1) return 2;
#endif
std::ios::sync_with_stdio(false); std::ios::sync_with_stdio(false);
whisper_log_set([](enum ggml_log_level, const char *, void *) {}, nullptr); whisper_log_set([](enum ggml_log_level, const char *, void *) {}, nullptr);
emit({{"type", "ready"}, {"protocol_version", 1}, {"backend", backend}}); emit({{"type", "ready"}, {"protocol_version", 1}, {"backend", backend}});

View File

@ -1,8 +1,8 @@
{ {
"schema_version": 1, "schema_version": 1,
"version": "0.1.0-experimental.3", "version": "0.1.0-experimental.4",
"signed": false, "signed": false,
"release_notes": "Adds user-approved in-place updates and Companion-managed OBS integration maintenance.", "release_notes": "Reports server inference failures at Speech recognition and handles abrupt WebSocket disconnects without leaving the test hanging.",
"artifacts": [ "artifacts": [
{ {
"id": "windows-x64-self-contained", "id": "windows-x64-self-contained",
@ -10,9 +10,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.3/Lumi.Companion-win-x64.zip", "url": "https://git.rolfsvaag.no/Rolfsvaag_Datateknikk/Lumi/releases/download/companion-v0.1.0-experimental.4/Lumi.Companion-win-x64.zip",
"sha256": "913bc719653b7f5b5bbd1b96f136e503654aa6913e8225c12a46ce19d9d91811", "sha256": "20b73e929c6eba816b5ff5737ced053cdcb1e737fac831bad7260f22d3cd2fe3",
"bytes": 43034535, "bytes": 41666903,
"entrypoint": "Lumi.Companion.App.exe" "entrypoint": "Lumi.Companion.App.exe"
} }
] ]

View File

@ -36,7 +36,12 @@ module.exports = {
}); });
supervisor.on("diagnostic", (entry) => diagnosticLog.append({ kind: "worker", ...entry })); supervisor.on("diagnostic", (entry) => diagnosticLog.append({ kind: "worker", ...entry }));
supervisor.on("error", (error) => diagnosticLog.append({ kind: "worker", state: "error", message: error.message })); supervisor.on("error", (error) => diagnosticLog.append({ kind: "worker", state: "error", message: error.message }));
supervisor.on("crash", (entry) => diagnosticLog.append({ kind: "worker", state: "crashed", exit_code: entry.code, signal: entry.signal, occurred_at: entry.at }));
supervisor.on("state", (entry) => diagnosticLog.append({ kind: "worker", state: entry.state }));
const provider = new WhisperCppServerProvider(supervisor); const provider = new WhisperCppServerProvider(supervisor);
provider.on("diagnostic", (entry) => diagnosticLog.append({ kind: "worker", state: entry.state || "diagnostic", level: entry.level || null, message: entry.message || null }));
provider.on("provider_error", (error) => diagnosticLog.append({ kind: "worker", state: "provider_error", code: error.code || null, message: error.message, details: error.details || null }));
provider.on("recovered", (entry) => diagnosticLog.append({ kind: "worker", state: "recovered", ...entry }));
const sessions = new SessionCoordinator({ const sessions = new SessionCoordinator({
provider, provider,
deliveryFactory: (send) => new CompanionCaptionDeliveryAdapter(send), deliveryFactory: (send) => new CompanionCaptionDeliveryAdapter(send),

View File

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

View File

@ -37,6 +37,7 @@ async function run() {
verifyQueues(); verifyQueues();
verifyStabilization(); verifyStabilization();
await verifySessionLifecycle(); await verifySessionLifecycle();
await verifyProviderFailureFeedback();
await verifyWorkerRestart(); await verifyWorkerRestart();
await verifyNativeWorkerBoundary(); await verifyNativeWorkerBoundary();
await verifyAuthenticatedGateway(); await verifyAuthenticatedGateway();
@ -98,6 +99,7 @@ function verifyLocalhostTransportPolicy() {
} }
function verifyCompanionVersionOrdering() { function verifyCompanionVersionOrdering() {
assert.equal(plugin.compareVersions("0.1.0-experimental.4", "0.1.0-experimental.3"), 1);
assert.equal(plugin.compareVersions("0.1.0-experimental.3", "0.1.0-experimental.2"), 1); assert.equal(plugin.compareVersions("0.1.0-experimental.3", "0.1.0-experimental.2"), 1);
assert.equal(plugin.compareVersions("0.1.0-experimental.2", "0.1.0-experimental.2"), 0); assert.equal(plugin.compareVersions("0.1.0-experimental.2", "0.1.0-experimental.2"), 0);
assert.equal(plugin.compareVersions("0.1.0", "0.1.0-experimental.9"), 1); assert.equal(plugin.compareVersions("0.1.0", "0.1.0-experimental.9"), 1);
@ -189,6 +191,29 @@ async function verifySessionLifecycle() {
await coordinator.close(); await coordinator.close();
} }
async function verifyProviderFailureFeedback() {
class Provider extends EventEmitter {
async health() { return { healthy: true, state: "running", model_ready: true }; }
async startSession() {} async addTrack() {} async stopSession() {}
}
const provider = new Provider();
const sent = [];
const coordinator = new SessionCoordinator({
provider,
deliveryFactory: () => ({ start: async () => {}, stop: async () => {}, pause: async () => {}, resume: async () => {} })
});
const { session } = coordinator.create({ id: "device" }, (type, payload) => sent.push({ type, payload }));
const source = crypto.randomUUID();
coordinator.updateSource(session.id, { source_uuid: source, display_name: "Mic", primary: true, program_active: true });
await coordinator.start(session.id, { mode: "test" });
provider.emit("provider_error", Object.assign(new Error("worker exited with code 3"), { code: "WORKER_CRASHED" }));
await new Promise((resolve) => setImmediate(resolve));
assert.equal(coordinator.status(session.id).state, "idle");
assert.equal(sent.at(-1).type, "error");
assert.match(sent.at(-1).payload.message, /worker exited with code 3/);
await coordinator.close();
}
async function verifyWorkerRestart() { async function verifyWorkerRestart() {
const children = []; const children = [];
const fakeSpawn = () => { const fakeSpawn = () => {
@ -197,12 +222,16 @@ async function verifyWorkerRestart() {
children.push(child); return child; children.push(child); return child;
}; };
const supervisor = new WhisperWorkerSupervisor({ executable: "fake-worker", spawn: fakeSpawn, maxRestarts: 1 }); const supervisor = new WhisperWorkerSupervisor({ executable: "fake-worker", spawn: fakeSpawn, maxRestarts: 1 });
supervisor.on("error", () => {}); let streamErrors = 0;
supervisor.on("error", () => { streamErrors += 1; });
supervisor.start(); supervisor.start();
assert.equal(supervisor.send({ type: "audio", captured_at: Date.now() }, Buffer.alloc(640)), true); assert.equal(supervisor.send({ type: "audio", captured_at: Date.now() }, Buffer.alloc(640)), true);
children[0].emit("exit", 1, null); children[0].emit("exit", 1, null);
await new Promise((resolve) => setTimeout(resolve, 320)); await new Promise((resolve) => setTimeout(resolve, 320));
assert.equal(children.length, 2); assert.equal(children.length, 2);
children[1].stdin.emit("error", Object.assign(new Error("broken pipe"), { code: "EPIPE" }));
assert.equal(streamErrors, 1);
assert.equal(supervisor.health().last_error.code, "EPIPE");
supervisor.stopping = true; children[1].emit("exit", 0, null); supervisor.stopping = true; children[1].emit("exit", 0, null);
} }
@ -223,6 +252,7 @@ async function verifyNativeWorkerBoundary() {
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 \* 6/);
assert.match(source, /_setmode\(_fileno\(stdin\), _O_BINARY\)/);
assert.doesNotMatch(source, /ofstream|fwrite|WriteAllBytes/); assert.doesNotMatch(source, /ofstream|fwrite|WriteAllBytes/);
} }

View File

@ -22,7 +22,7 @@
<ol> <ol>
<li class="<%= devices.some((device) => !device.revoked_at) ? 'is-complete' : 'is-current' %>"><strong>Download and pair Companion</strong><span>Create a short-lived, single-use package for the streaming computer.</span></li> <li class="<%= devices.some((device) => !device.revoked_at) ? 'is-complete' : 'is-current' %>"><strong>Download and pair Companion</strong><span>Create a short-lived, single-use package for the streaming computer.</span></li>
<li><strong>Install the OBS integration</strong><span>Companion owns installation and repair; the bridge has no separate settings.</span></li> <li><strong>Install the OBS integration</strong><span>Companion owns installation and repair; the bridge has no separate settings.</span></li>
<li class="<%= providerHealth.model ? 'is-complete' : '' %>"><strong>Load and benchmark a model</strong><span>Small English is the recommended starting point.</span></li> <li class="<%= providerHealth.model_ready ? 'is-complete' : '' %>"><strong>Load and benchmark a model</strong><span>Small English is the recommended starting point.</span></li>
<li><strong>Select and test a microphone</strong><span>The real safe test must pass before live delivery is enabled.</span></li> <li><strong>Select and test a microphone</strong><span>The real safe test must pass before live delivery is enabled.</span></li>
</ol> </ol>
<div class="inline-actions"> <div class="inline-actions">
@ -38,9 +38,12 @@
<div class="dashboard-metric-grid transcription-metrics"> <div class="dashboard-metric-grid transcription-metrics">
<div><span>Provider</span><strong>whisper.cpp <%= runtimeManifest.tested_version %></strong></div> <div><span>Provider</span><strong>whisper.cpp <%= runtimeManifest.tested_version %></strong></div>
<div><span>Worker</span><strong><%= providerHealth.state %></strong></div> <div><span>Worker</span><strong><%= providerHealth.state %></strong></div>
<div><span>Selected model</span><strong><%= providerHealth.model || 'Not loaded' %></strong></div> <div><span>Selected model</span><strong><%= providerHealth.model ? providerHealth.model + (providerHealth.model_ready ? '' : ' · not ready') : 'Not loaded' %></strong></div>
<div><span>Active sessions</span><strong><%= providerHealth.sessions || 0 %></strong></div> <div><span>Active sessions</span><strong><%= providerHealth.sessions || 0 %></strong></div>
</div> </div>
<% 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 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>
@ -64,9 +67,9 @@
<article> <article>
<div><h3><%= model.label %></h3><p class="hint"><%= model.recommended ? 'Recommended starting point' : 'Fallback option' %> · <%= Math.round(model.bytes / 1048576) %> MiB</p></div> <div><h3><%= model.label %></h3><p class="hint"><%= model.recommended ? 'Recommended starting point' : 'Fallback option' %> · <%= Math.round(model.bytes / 1048576) %> MiB</p></div>
<div class="model-actions"> <div class="model-actions">
<span class="badge <%= providerHealth.model === model.id ? 'success' : model.status.valid ? 'info' : model.status.installed ? 'danger' : 'muted' %>"><%= providerHealth.model === model.id ? 'Loaded' : model.status.valid ? 'Verified' : model.status.installed ? 'Checksum failed' : 'Not installed' %></span> <span class="badge <%= providerHealth.model === model.id && providerHealth.model_ready ? 'success' : model.status.valid ? 'info' : model.status.installed ? 'danger' : 'muted' %>"><%= providerHealth.model === model.id && providerHealth.model_ready ? 'Loaded' : providerHealth.model === model.id && model.status.valid ? 'Reload required' : model.status.valid ? 'Verified' : model.status.installed ? 'Checksum failed' : 'Not installed' %></span>
<% if (!model.status.valid) { %><button class="button subtle" type="button" data-download-model="<%= model.id %>" data-model-label="<%= model.label %>" data-model-size="<%= Math.round(model.bytes / 1048576) %>">Download</button><% } %> <% if (!model.status.valid) { %><button class="button subtle" type="button" data-download-model="<%= model.id %>" data-model-label="<%= model.label %>" data-model-size="<%= Math.round(model.bytes / 1048576) %>">Download</button><% } %>
<% if (model.status.valid && providerHealth.model !== model.id) { %><button class="button subtle" type="button" data-load-model="<%= model.id %>">Load model</button><% } %> <% if (model.status.valid && (providerHealth.model !== model.id || !providerHealth.model_ready)) { %><button class="button subtle" type="button" data-load-model="<%= model.id %>"><%= providerHealth.model === model.id ? 'Reload model' : 'Load model' %></button><% } %>
</div> </div>
</article> </article>
<% }) %> <% }) %>