Build Lumi Companion shell and whisper worker

This commit is contained in:
Franz Rolfsvaag 2026-07-22 11:39:05 +02:00
parent 29cd4a0365
commit 2f31390602
31 changed files with 2093 additions and 50 deletions

19
TODO.md
View File

@ -9,20 +9,25 @@ core WebSocket-upgrade capability; single-use pairing and revocable devices;
TLS-only device transport; bounded PCM/session queues; revision-safe settings;
provider/delivery interfaces; worker supervision; caption stabilization; pinned
whisper.cpp/model manifests; short-lived JSONL diagnostics; protocol schemas;
and .NET/native companion boundaries with focused verification.
and .NET/native companion boundaries with focused verification. The companion now
has a Lumi-styled Avalonia 12 single-instance tray shell, guided real-boundary
test states, DPAPI pairing, local preferences/autostart, and bounded diagnostics.
A pinned native whisper.cpp worker builds on the CPU verification target and the
server offers consent-gated model download/load controls.
Release-blocking work remains:
- Build the real streaming whisper.cpp worker, install it through the manifest,
and benchmark `small.en`, quantized `small.en`, and `base.en` on the RTX 3060.
- Complete the Avalonia tray application, signed installer, DPAPI migration UX,
managed OBS bridge install/repair/uninstall, and single-instance/live-quit flow.
- Package the native whisper.cpp worker as the Windows CUDA runtime, then benchmark
`small.en`, quantized `small.en`, and `base.en` on the RTX 3060.
- Complete the signed companion installer, DPAPI migration UX, and managed OBS
bridge install/repair/uninstall service.
- Complete native selected-source capture, nested Program-scene evaluation,
resampling/IPC, bridge health, and source rename/missing recovery.
- Run the OBS 31+/Twitch compatibility spike and prove toggleable closed captions;
tune replacement/display duration from player behavior without open-caption fallback.
- Add setup/test/conflict-resolution UI and live settings synchronization to the
companion, then run the target-topology failure and performance matrix.
- Complete the remaining live source/setup steps, conflict-resolution UI, and
live settings synchronization, then run the target-topology failure and
performance matrix.
## Remaining DesignMotionHQ UX work — experimental-ux checkpoint (2026-07-22)

View File

@ -0,0 +1,101 @@

Microsoft Visual Studio Solution File, Format Version 12.00
# Visual Studio Version 17
VisualStudioVersion = 17.0.31903.59
MinimumVisualStudioVersion = 10.0.40219.1
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "src", "src", "{827E0CD3-B72D-47B6-A68D-7590B98EB39B}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Lumi.Companion.Protocol", "src\Lumi.Companion.Protocol\Lumi.Companion.Protocol.csproj", "{3703AF57-828D-4E2F-BFBA-A95833555A5A}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Lumi.Companion.Core", "src\Lumi.Companion.Core\Lumi.Companion.Core.csproj", "{0BDAE25E-C487-456D-A465-41A0CA48AF30}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Lumi.Companion.PluginHost", "src\Lumi.Companion.PluginHost\Lumi.Companion.PluginHost.csproj", "{64C2B7E5-9356-457A-BAF8-F18F67DAE4CF}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Lumi.Companion.App", "src\Lumi.Companion.App\Lumi.Companion.App.csproj", "{2F0041FD-FD06-4312-BF1E-180F0201EB97}"
EndProject
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "plugins", "plugins", "{07D57EEB-2F50-60C4-C011-FE4FA775C9A8}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Lumi.Companion.Transcription", "plugins\Lumi.Companion.Transcription\Lumi.Companion.Transcription.csproj", "{49C19CA8-4669-4C33-B3A8-9CB934CD1171}"
EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
Debug|x64 = Debug|x64
Debug|x86 = Debug|x86
Release|Any CPU = Release|Any CPU
Release|x64 = Release|x64
Release|x86 = Release|x86
EndGlobalSection
GlobalSection(ProjectConfigurationPlatforms) = postSolution
{3703AF57-828D-4E2F-BFBA-A95833555A5A}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{3703AF57-828D-4E2F-BFBA-A95833555A5A}.Debug|Any CPU.Build.0 = Debug|Any CPU
{3703AF57-828D-4E2F-BFBA-A95833555A5A}.Debug|x64.ActiveCfg = Debug|Any CPU
{3703AF57-828D-4E2F-BFBA-A95833555A5A}.Debug|x64.Build.0 = Debug|Any CPU
{3703AF57-828D-4E2F-BFBA-A95833555A5A}.Debug|x86.ActiveCfg = Debug|Any CPU
{3703AF57-828D-4E2F-BFBA-A95833555A5A}.Debug|x86.Build.0 = Debug|Any CPU
{3703AF57-828D-4E2F-BFBA-A95833555A5A}.Release|Any CPU.ActiveCfg = Release|Any CPU
{3703AF57-828D-4E2F-BFBA-A95833555A5A}.Release|Any CPU.Build.0 = Release|Any CPU
{3703AF57-828D-4E2F-BFBA-A95833555A5A}.Release|x64.ActiveCfg = Release|Any CPU
{3703AF57-828D-4E2F-BFBA-A95833555A5A}.Release|x64.Build.0 = Release|Any CPU
{3703AF57-828D-4E2F-BFBA-A95833555A5A}.Release|x86.ActiveCfg = Release|Any CPU
{3703AF57-828D-4E2F-BFBA-A95833555A5A}.Release|x86.Build.0 = Release|Any CPU
{0BDAE25E-C487-456D-A465-41A0CA48AF30}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{0BDAE25E-C487-456D-A465-41A0CA48AF30}.Debug|Any CPU.Build.0 = Debug|Any CPU
{0BDAE25E-C487-456D-A465-41A0CA48AF30}.Debug|x64.ActiveCfg = Debug|Any CPU
{0BDAE25E-C487-456D-A465-41A0CA48AF30}.Debug|x64.Build.0 = Debug|Any CPU
{0BDAE25E-C487-456D-A465-41A0CA48AF30}.Debug|x86.ActiveCfg = Debug|Any CPU
{0BDAE25E-C487-456D-A465-41A0CA48AF30}.Debug|x86.Build.0 = Debug|Any CPU
{0BDAE25E-C487-456D-A465-41A0CA48AF30}.Release|Any CPU.ActiveCfg = Release|Any CPU
{0BDAE25E-C487-456D-A465-41A0CA48AF30}.Release|Any CPU.Build.0 = Release|Any CPU
{0BDAE25E-C487-456D-A465-41A0CA48AF30}.Release|x64.ActiveCfg = Release|Any CPU
{0BDAE25E-C487-456D-A465-41A0CA48AF30}.Release|x64.Build.0 = Release|Any CPU
{0BDAE25E-C487-456D-A465-41A0CA48AF30}.Release|x86.ActiveCfg = Release|Any CPU
{0BDAE25E-C487-456D-A465-41A0CA48AF30}.Release|x86.Build.0 = Release|Any CPU
{64C2B7E5-9356-457A-BAF8-F18F67DAE4CF}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{64C2B7E5-9356-457A-BAF8-F18F67DAE4CF}.Debug|Any CPU.Build.0 = Debug|Any CPU
{64C2B7E5-9356-457A-BAF8-F18F67DAE4CF}.Debug|x64.ActiveCfg = Debug|Any CPU
{64C2B7E5-9356-457A-BAF8-F18F67DAE4CF}.Debug|x64.Build.0 = Debug|Any CPU
{64C2B7E5-9356-457A-BAF8-F18F67DAE4CF}.Debug|x86.ActiveCfg = Debug|Any CPU
{64C2B7E5-9356-457A-BAF8-F18F67DAE4CF}.Debug|x86.Build.0 = Debug|Any CPU
{64C2B7E5-9356-457A-BAF8-F18F67DAE4CF}.Release|Any CPU.ActiveCfg = Release|Any CPU
{64C2B7E5-9356-457A-BAF8-F18F67DAE4CF}.Release|Any CPU.Build.0 = Release|Any CPU
{64C2B7E5-9356-457A-BAF8-F18F67DAE4CF}.Release|x64.ActiveCfg = Release|Any CPU
{64C2B7E5-9356-457A-BAF8-F18F67DAE4CF}.Release|x64.Build.0 = Release|Any CPU
{64C2B7E5-9356-457A-BAF8-F18F67DAE4CF}.Release|x86.ActiveCfg = Release|Any CPU
{64C2B7E5-9356-457A-BAF8-F18F67DAE4CF}.Release|x86.Build.0 = Release|Any CPU
{2F0041FD-FD06-4312-BF1E-180F0201EB97}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{2F0041FD-FD06-4312-BF1E-180F0201EB97}.Debug|Any CPU.Build.0 = Debug|Any CPU
{2F0041FD-FD06-4312-BF1E-180F0201EB97}.Debug|x64.ActiveCfg = Debug|Any CPU
{2F0041FD-FD06-4312-BF1E-180F0201EB97}.Debug|x64.Build.0 = Debug|Any CPU
{2F0041FD-FD06-4312-BF1E-180F0201EB97}.Debug|x86.ActiveCfg = Debug|Any CPU
{2F0041FD-FD06-4312-BF1E-180F0201EB97}.Debug|x86.Build.0 = Debug|Any CPU
{2F0041FD-FD06-4312-BF1E-180F0201EB97}.Release|Any CPU.ActiveCfg = Release|Any CPU
{2F0041FD-FD06-4312-BF1E-180F0201EB97}.Release|Any CPU.Build.0 = Release|Any CPU
{2F0041FD-FD06-4312-BF1E-180F0201EB97}.Release|x64.ActiveCfg = Release|Any CPU
{2F0041FD-FD06-4312-BF1E-180F0201EB97}.Release|x64.Build.0 = Release|Any CPU
{2F0041FD-FD06-4312-BF1E-180F0201EB97}.Release|x86.ActiveCfg = Release|Any CPU
{2F0041FD-FD06-4312-BF1E-180F0201EB97}.Release|x86.Build.0 = Release|Any CPU
{49C19CA8-4669-4C33-B3A8-9CB934CD1171}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{49C19CA8-4669-4C33-B3A8-9CB934CD1171}.Debug|Any CPU.Build.0 = Debug|Any CPU
{49C19CA8-4669-4C33-B3A8-9CB934CD1171}.Debug|x64.ActiveCfg = Debug|Any CPU
{49C19CA8-4669-4C33-B3A8-9CB934CD1171}.Debug|x64.Build.0 = Debug|Any CPU
{49C19CA8-4669-4C33-B3A8-9CB934CD1171}.Debug|x86.ActiveCfg = Debug|Any CPU
{49C19CA8-4669-4C33-B3A8-9CB934CD1171}.Debug|x86.Build.0 = Debug|Any CPU
{49C19CA8-4669-4C33-B3A8-9CB934CD1171}.Release|Any CPU.ActiveCfg = Release|Any CPU
{49C19CA8-4669-4C33-B3A8-9CB934CD1171}.Release|Any CPU.Build.0 = Release|Any CPU
{49C19CA8-4669-4C33-B3A8-9CB934CD1171}.Release|x64.ActiveCfg = Release|Any CPU
{49C19CA8-4669-4C33-B3A8-9CB934CD1171}.Release|x64.Build.0 = Release|Any CPU
{49C19CA8-4669-4C33-B3A8-9CB934CD1171}.Release|x86.ActiveCfg = Release|Any CPU
{49C19CA8-4669-4C33-B3A8-9CB934CD1171}.Release|x86.Build.0 = Release|Any CPU
EndGlobalSection
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE
EndGlobalSection
GlobalSection(NestedProjects) = preSolution
{3703AF57-828D-4E2F-BFBA-A95833555A5A} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B}
{0BDAE25E-C487-456D-A465-41A0CA48AF30} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B}
{64C2B7E5-9356-457A-BAF8-F18F67DAE4CF} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B}
{2F0041FD-FD06-4312-BF1E-180F0201EB97} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B}
{49C19CA8-4669-4C33-B3A8-9CB934CD1171} = {07D57EEB-2F50-60C4-C011-FE4FA775C9A8}
EndGlobalSection
EndGlobal

View File

@ -1,9 +1,17 @@
# Lumi Companion (experimental transcription milestone)
This directory is the single Lumi Companion product boundary. The current milestone supplies the versioned protocol client, Windows credential protection, bounded outbound transport, plugin-process supervision, and same-user OBS bridge IPC. The Avalonia tray shell, installer, and signed native bridge package remain target-machine work and are not represented as complete.
This directory is the single Lumi Companion product boundary. The current milestone supplies the versioned protocol client, Windows credential protection, bounded outbound transport, plugin-process supervision, same-user OBS bridge IPC boundary, and an Avalonia tray shell. The shell is single-instance, closes to the tray, confirms quit during OBS streaming/recording, supports explicit pairing, and reports real setup/test blockers without presenting simulated success.
Build prerequisites: .NET 8 SDK on Windows x64. The native bridge additionally requires CMake, Visual Studio C++ tools, and the OBS 31+ SDK.
Build prerequisites: the current .NET SDK with the .NET 8 targeting pack on Windows x64. The app targets .NET 8; .NET SDK 10 is recommended for Avalonia 12 source-generator compatibility. The native bridge additionally requires CMake, Visual Studio C++ tools, and the OBS 31+ SDK.
The app accepts a `.lumi-pairing.json` bootstrap package. It exchanges the embedded token once and stores the returned device credential with Windows DPAPI. Do not commit bootstrap packages, credentials, generated installers, build output, or logs.
The companion never performs ASR in this MVP. Audio is normalized to 16 kHz mono signed 16-bit PCM, held in bounded memory, and sent to the paired Lumi host over TLS WebSockets. The OBS bridge talks only to the companion over a same-user named pipe.
Build all managed projects from the repository root:
```powershell
dotnet build companion/Lumi.Companion.sln -p:EnableWindowsTargeting=true
```
The signed installer, managed OBS bridge installation/repair service, and a target-machine OBS/Twitch acceptance run are still required. Until those pass, the UI deliberately stops its test at the unavailable real boundary.

View File

@ -12,13 +12,16 @@ public sealed class ObsBridgePipe : IAsyncDisposable
private readonly Func<JsonElement, Task> _onMessage;
private readonly Func<ReadOnlyMemory<byte>, Task> _onAudio;
private readonly CancellationTokenSource _lifetime = new();
private readonly SemaphoreSlim _writeLock = new(1, 1);
private Task? _loop;
private Stream? _connection;
public ObsBridgePipe(string userSidHash, Func<JsonElement, Task> onMessage, Func<ReadOnlyMemory<byte>, Task> onAudio)
{
_pipeName = $"Lumi.Companion.ObsBridge.v1.{userSidHash}";
_onMessage = onMessage; _onAudio = onAudio;
}
public event Action<bool>? ConnectionChanged;
public void Start() => _loop ??= Task.Run(() => ListenAsync(_lifetime.Token));
private async Task ListenAsync(CancellationToken cancellationToken)
{
@ -27,8 +30,11 @@ public sealed class ObsBridgePipe : IAsyncDisposable
await using var pipe = new NamedPipeServerStream(_pipeName, PipeDirection.InOut, 1, PipeTransmissionMode.Byte,
PipeOptions.Asynchronous | PipeOptions.CurrentUserOnly, 65536, 65536);
await pipe.WaitForConnectionAsync(cancellationToken);
_connection = pipe;
ConnectionChanged?.Invoke(true);
try { await ReadConnectionAsync(pipe, cancellationToken); }
catch (Exception) when (!cancellationToken.IsCancellationRequested) { /* reconnect without taking down the shell */ }
finally { _connection = null; ConnectionChanged?.Invoke(false); }
}
}
private async Task ReadConnectionAsync(Stream stream, CancellationToken cancellationToken)
@ -59,6 +65,20 @@ public sealed class ObsBridgePipe : IAsyncDisposable
var prefix = new byte[4]; BinaryPrimitives.WriteUInt32LittleEndian(prefix, (uint)body.Length);
await stream.WriteAsync(prefix, cancellationToken); await stream.WriteAsync(body, cancellationToken); await stream.FlushAsync(cancellationToken);
}
public async Task<bool> SendAsync(object message, CancellationToken cancellationToken = default)
{
var connection = _connection;
if (connection is null) return false;
await _writeLock.WaitAsync(cancellationToken);
try
{
if (!ReferenceEquals(connection, _connection)) return false;
await WriteAsync(connection, message, cancellationToken);
return true;
}
catch (IOException) { return false; }
finally { _writeLock.Release(); }
}
private static async Task ReadExactlyAsync(Stream stream, Memory<byte> buffer, CancellationToken cancellationToken)
{
var read = 0;
@ -69,5 +89,5 @@ public sealed class ObsBridgePipe : IAsyncDisposable
read += count;
}
}
public async ValueTask DisposeAsync() { _lifetime.Cancel(); if (_loop is not null) try { await _loop; } catch (OperationCanceledException) { } _lifetime.Dispose(); }
public async ValueTask DisposeAsync() { _lifetime.Cancel(); if (_loop is not null) try { await _loop; } catch (OperationCanceledException) { } _writeLock.Dispose(); _lifetime.Dispose(); }
}

View File

@ -0,0 +1,94 @@
<Application xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
x:Class="Lumi.Companion.App.App"
RequestedThemeVariant="Light">
<Application.Resources>
<Color x:Key="LumiInk">#182026</Color>
<Color x:Key="LumiMuted">#5A6872</Color>
<Color x:Key="LumiSea">#176B75</Color>
<Color x:Key="LumiSun">#E58B2B</Color>
<Color x:Key="LumiSuccess">#23845B</Color>
<Color x:Key="LumiWarning">#A96612</Color>
<Color x:Key="LumiDanger">#BD4D4D</Color>
<SolidColorBrush x:Key="LumiTextBrush" Color="{DynamicResource LumiInk}" />
<SolidColorBrush x:Key="LumiMutedBrush" Color="{DynamicResource LumiMuted}" />
<SolidColorBrush x:Key="LumiPrimaryBrush" Color="{DynamicResource LumiSea}" />
<SolidColorBrush x:Key="LumiAccentBrush" Color="{DynamicResource LumiSun}" />
<SolidColorBrush x:Key="LumiSuccessBrush" Color="{DynamicResource LumiSuccess}" />
<SolidColorBrush x:Key="LumiWarningBrush" Color="{DynamicResource LumiWarning}" />
<SolidColorBrush x:Key="LumiDangerBrush" Color="{DynamicResource LumiDanger}" />
<SolidColorBrush x:Key="LumiSurfaceBrush" Color="#FFFFFF" />
<SolidColorBrush x:Key="LumiSubtleBrush" Color="#F4F7F8" />
<SolidColorBrush x:Key="LumiBorderBrush" Color="#D8E0E3" />
</Application.Resources>
<Application.Styles>
<FluentTheme />
<Style Selector="Window">
<Setter Property="FontFamily" Value="Inter" />
<Setter Property="Background" Value="{DynamicResource LumiSurfaceBrush}" />
<Setter Property="Foreground" Value="{DynamicResource LumiTextBrush}" />
</Style>
<Style Selector="TextBlock.eyebrow">
<Setter Property="FontSize" Value="11" />
<Setter Property="FontWeight" Value="SemiBold" />
<Setter Property="Foreground" Value="{DynamicResource LumiMutedBrush}" />
</Style>
<Style Selector="TextBlock.pageTitle">
<Setter Property="FontSize" Value="32" />
<Setter Property="FontWeight" Value="Bold" />
<Setter Property="LineHeight" Value="36" />
</Style>
<Style Selector="TextBlock.sectionTitle">
<Setter Property="FontSize" Value="18" />
<Setter Property="FontWeight" Value="SemiBold" />
</Style>
<Style Selector="TextBlock.muted">
<Setter Property="Foreground" Value="{DynamicResource LumiMutedBrush}" />
<Setter Property="TextWrapping" Value="Wrap" />
<Setter Property="LineHeight" Value="21" />
</Style>
<Style Selector="Border.card">
<Setter Property="Background" Value="{DynamicResource LumiSurfaceBrush}" />
<Setter Property="BorderBrush" Value="{DynamicResource LumiBorderBrush}" />
<Setter Property="BorderThickness" Value="1" />
<Setter Property="CornerRadius" Value="14" />
<Setter Property="Padding" Value="20" />
</Style>
<Style Selector="Border.soft">
<Setter Property="Background" Value="{DynamicResource LumiSubtleBrush}" />
<Setter Property="CornerRadius" Value="12" />
<Setter Property="Padding" Value="16" />
</Style>
<Style Selector="Button.primary">
<Setter Property="Background" Value="{DynamicResource LumiPrimaryBrush}" />
<Setter Property="Foreground" Value="White" />
<Setter Property="CornerRadius" Value="9" />
<Setter Property="Padding" Value="18,10" />
<Setter Property="FontWeight" Value="SemiBold" />
</Style>
<Style Selector="Button.secondary">
<Setter Property="Background" Value="Transparent" />
<Setter Property="BorderBrush" Value="{DynamicResource LumiBorderBrush}" />
<Setter Property="BorderThickness" Value="1" />
<Setter Property="CornerRadius" Value="9" />
<Setter Property="Padding" Value="16,9" />
</Style>
<Style Selector="Button.nav">
<Setter Property="HorizontalContentAlignment" Value="Left" />
<Setter Property="Background" Value="Transparent" />
<Setter Property="BorderThickness" Value="0" />
<Setter Property="CornerRadius" Value="9" />
<Setter Property="Padding" Value="13,10" />
<Setter Property="Margin" Value="0,2" />
</Style>
<Style Selector="Button.nav.selected">
<Setter Property="Background" Value="#DDEEF0" />
<Setter Property="Foreground" Value="{DynamicResource LumiPrimaryBrush}" />
<Setter Property="FontWeight" Value="SemiBold" />
</Style>
<Style Selector="Button:focus-visible">
<Setter Property="BorderBrush" Value="{DynamicResource LumiPrimaryBrush}" />
<Setter Property="BorderThickness" Value="2" />
</Style>
</Application.Styles>
</Application>

View File

@ -0,0 +1,86 @@
using Avalonia;
using Avalonia.Controls;
using Avalonia.Controls.ApplicationLifetimes;
using Avalonia.Markup.Xaml;
using Avalonia.Threading;
namespace Lumi.Companion.App;
public partial class App : Application
{
private TrayIcon? _trayIcon;
private MainWindow? _mainWindow;
public override void Initialize() => AvaloniaXamlLoader.Load(this);
public override void OnFrameworkInitializationCompleted()
{
if (ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop)
{
desktop.ShutdownMode = ShutdownMode.OnExplicitShutdown;
var paths = CompanionPaths.ForCurrentUser();
var settings = new CompanionSettingsStore(paths.SettingsPath);
var runtime = new CompanionRuntime(paths, settings);
_mainWindow = new MainWindow(runtime, settings);
desktop.MainWindow = _mainWindow;
ConfigureTray(desktop, runtime);
Program.InstanceCoordinator!.ActivationRequested += () => Dispatcher.UIThread.Post(ShowMainWindow);
if (!Program.LaunchInBackground) _mainWindow.Show();
_ = runtime.InitializeAsync();
}
base.OnFrameworkInitializationCompleted();
}
private void ConfigureTray(IClassicDesktopStyleApplicationLifetime desktop, CompanionRuntime runtime)
{
var open = new NativeMenuItem("Open Lumi Companion");
var test = new NativeMenuItem("Run transcription test");
var health = new NativeMenuItem("Health: Starting") { IsEnabled = false };
var web = new NativeMenuItem("Open Lumi WebUI");
var quit = new NativeMenuItem("Quit");
open.Click += (_, _) => ShowMainWindow();
test.Click += async (_, _) =>
{
ShowMainWindow();
_mainWindow?.ShowPage(CompanionPage.Test);
await runtime.RunTestAsync();
};
web.Click += (_, _) => runtime.OpenLumiWebUi();
quit.Click += async (_, _) =>
{
if (runtime.State.RequiresQuitConfirmation) ShowMainWindow();
if (_mainWindow is null || !await _mainWindow.ConfirmQuitAsync()) return;
_mainWindow.AllowExit();
_trayIcon?.Dispose();
await runtime.DisposeAsync();
desktop.Shutdown();
};
_trayIcon = new TrayIcon
{
Icon = LumiIconFactory.Create(TrayHealth.Ready),
ToolTipText = "Lumi Companion — starting",
IsVisible = true,
Menu = new NativeMenu { Items = { open, test, web, health, new NativeMenuItemSeparator(), quit } }
};
_trayIcon.Clicked += (_, _) => ShowMainWindow();
TrayIcon.SetIcons(this, new TrayIcons { _trayIcon });
runtime.StateChanged += state => Dispatcher.UIThread.Post(() =>
{
if (_trayIcon is null) return;
_trayIcon.Icon = LumiIconFactory.Create(state.Health);
_trayIcon.ToolTipText = $"Lumi Companion — {state.Summary}";
health.Header = $"Health: {state.Summary}";
test.Header = state.TestRunning ? "Transcription test running…" : "Run transcription test";
test.IsEnabled = !state.TestRunning;
});
}
private void ShowMainWindow()
{
if (_mainWindow is null) return;
_mainWindow.Show();
_mainWindow.WindowState = WindowState.Normal;
_mainWindow.Activate();
}
}

View File

@ -0,0 +1,11 @@
namespace Lumi.Companion.App;
public sealed record CompanionPaths(string Root, string SettingsPath, string LogsDirectory, string BridgeDirectory)
{
public static CompanionPaths ForCurrentUser()
{
var local = Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData);
var root = Path.Combine(local, "Lumi", "Companion");
return new CompanionPaths(root, Path.Combine(root, "settings.json"), Path.Combine(root, "logs"), Path.Combine(root, "obs-bridge"));
}
}

View File

@ -0,0 +1,422 @@
using System.Diagnostics;
using System.Reflection;
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
using Lumi.Companion.Core;
using Lumi.Companion.Protocol;
using Lumi.Companion.Transcription;
using Microsoft.Win32;
namespace Lumi.Companion.App;
public sealed class CompanionRuntime : IAsyncDisposable
{
private const string RunKey = @"Software\Microsoft\Windows\CurrentVersion\Run";
private readonly CompanionPaths _paths;
private readonly CompanionSettingsStore _settings;
private readonly SecureCredentialStore _credentials;
private readonly HttpClient _http = new() { Timeout = TimeSpan.FromSeconds(15) };
private CompanionSocket? _socket;
private ObsBridgePipe? _obsBridge;
private TaskCompletionSource<bool>? _audioSignal;
private TaskCompletionSource<bool>? _captionSignal;
private bool _disposed;
public CompanionRuntime(CompanionPaths paths, CompanionSettingsStore settings)
{
_paths = paths;
_settings = settings;
_credentials = new SecureCredentialStore(paths.Root);
State = new CompanionState();
TestStages = CreateInitialTestStages();
ObsSources = [];
}
public CompanionState State { get; private set; }
public IReadOnlyList<TestStage> TestStages { get; private set; }
public IReadOnlyList<ObsSource> ObsSources { get; private set; }
public event Action<CompanionState>? StateChanged;
public event Action<IReadOnlyList<TestStage>>? TestStagesChanged;
public event Action<IReadOnlyList<ObsSource>>? ObsSourcesChanged;
public event Action<string, bool>? CaptionReceived;
public event Action<string>? LogAdded;
public async Task InitializeAsync()
{
try { await InitializeCoreAsync(); }
catch (Exception error)
{
SetState(State with { Health = TrayHealth.Failed, Detail = $"Companion startup could not finish. {Friendly(error)}" });
await WriteLogAsync("startup_failed", error.Message);
}
}
private async Task InitializeCoreAsync()
{
await _settings.LoadAsync();
StartObsBridgeBoundary();
ApplyAutoStart(_settings.Current.AutoStartWithWindows);
DeviceCredential? credential;
try { credential = _credentials.Load(); }
catch (Exception error)
{
SetState(State with { Health = TrayHealth.Failed, Detail = "The saved device credential could not be opened. Pair this computer again." });
await WriteLogAsync("credential_load_failed", error.Message);
return;
}
if (credential is null)
{
SetState(State with { ObsBridgeInstalled = DetectBridgeInstallation(), Detail = "Download a pairing package from Lumi, then open it here." });
return;
}
SetState(State with { Paired = true, DeviceName = Environment.MachineName, Host = credential.Host, ObsBridgeInstalled = DetectBridgeInstallation(), Detail = "Connecting securely to Lumi…" });
await ConnectAsync(credential);
}
public async Task PairAsync(string packagePath, CancellationToken cancellationToken = default)
{
SetState(State with { Health = TrayHealth.Degraded, Detail = "Validating the pairing package…" });
var client = new PairingClient(_http);
try
{
var credential = await client.PairAsync(packagePath, new
{
install_id = _credentials.GetOrCreateInstallId(),
name = Environment.MachineName,
companion_version = Version
}, cancellationToken);
_credentials.Save(credential);
SetState(State with { Paired = true, DeviceName = Environment.MachineName, Host = credential.Host, Detail = "Pairing complete. Connecting to Lumi…" });
await WriteLogAsync("paired", $"Paired {Environment.MachineName} with {credential.Host}.");
await ConnectAsync(credential, cancellationToken);
}
catch (Exception error)
{
SetState(State with { Health = TrayHealth.Failed, Detail = Friendly(error) });
await WriteLogAsync("pairing_failed", error.Message);
throw;
}
}
public async Task RetryConnectionAsync(CancellationToken cancellationToken = default)
{
var credential = _credentials.Load() ?? throw new InvalidOperationException("Pair this computer before reconnecting.");
await ConnectAsync(credential, cancellationToken);
}
private async Task ConnectAsync(DeviceCredential credential, CancellationToken cancellationToken = default)
{
if (_socket is not null) await _socket.DisposeAsync();
_socket = new CompanionSocket();
_socket.MessageReceived += OnServerMessageAsync;
_socket.Disconnected += error =>
{
if (_disposed) return;
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.");
};
SetState(State with { Connected = false, Detail = "Connecting securely to Lumi…" });
try
{
await _socket.ConnectAsync(credential, Version, "0.1.0", null, cancellationToken);
var bridgeInstalled = State.ObsBridgeInstalled || DetectBridgeInstallation();
SetState(State with
{
Paired = true,
Connected = true,
ObsBridgeInstalled = bridgeInstalled,
Health = bridgeInstalled ? TrayHealth.Ready : TrayHealth.Degraded,
Detail = bridgeInstalled ? "Lumi is connected. Waiting for OBS." : "Lumi is connected. Install the managed OBS integration to continue setup.",
Host = credential.Host,
LastConnectedAt = DateTimeOffset.Now
});
foreach (var source in ObsSources) await SendSourceUpdateAsync(source);
if (State.ObsConnected)
await _socket.SendAsync("obs_state", new { streaming = State.ObsStreaming, recording = State.ObsRecording, auto_start = _settings.Current.StartWithObs }, _socket.SessionId, cancellationToken);
await WriteLogAsync("connected", $"Connected to {credential.Host}.");
}
catch (Exception error)
{
SetState(State with { Paired = true, Connected = false, Health = TrayHealth.Degraded, Detail = $"Lumi could not be reached. {Friendly(error)}" });
await WriteLogAsync("connection_failed", error.Message);
}
}
public async Task RunTestAsync(CancellationToken cancellationToken = default)
{
if (State.TestRunning) return;
SetState(State with { TestRunning = true, Detail = "Running the transcription path check…" });
var stages = CreateInitialTestStages().ToArray();
TestStages = stages;
TestStagesChanged?.Invoke(TestStages);
try
{
await EvaluateStageAsync(stages, 0, State.ObsBridgeInstalled, "The managed OBS integration is installed.", "Install or repair the OBS integration before testing.", cancellationToken);
if (!State.ObsBridgeInstalled) return;
await EvaluateStageAsync(stages, 1, State.ObsConnected, "OBS reported a healthy local connection.", "Open OBS 31 or newer and retry.", cancellationToken);
if (!State.ObsConnected) return;
var selected = ObsSources.FirstOrDefault(source => source.Uuid == _settings.Current.PrimarySourceUuid);
await EvaluateStageAsync(stages, 2, selected is { Missing: false, Active: true }, "The selected microphone is available and active in Program.", selected is null ? "Choose a microphone on the Transcription page." : selected.Missing ? "The selected microphone is missing from OBS." : "Put the selected microphone in the active Program scene, then retry.", cancellationToken);
if (selected is not { Missing: false, Active: true }) return;
await EvaluateStageAsync(stages, 4, State.Connected, "The secure Lumi transport is ready.", "Reconnect to Lumi before testing.", cancellationToken);
if (!State.Connected) return;
_audioSignal = new(TaskCreationOptions.RunContinuationsAsynchronously);
_captionSignal = new(TaskCreationOptions.RunContinuationsAsynchronously);
await _socket!.SendAsync("start", new { mode = "test" }, _socket.SessionId, cancellationToken);
MarkStage(stages, 3, TestStageState.Running, "Speak normally into the selected microphone…");
if (!await WaitForAsync(_audioSignal.Task, TimeSpan.FromSeconds(10), cancellationToken))
{
MarkStage(stages, 3, TestStageState.Blocked, "No active microphone audio reached the companion within 10 seconds.");
return;
}
MarkStage(stages, 3, TestStageState.Passed, "Live microphone audio reached the companion.");
MarkStage(stages, 5, TestStageState.Running, "Waiting for server-hosted speech recognition…");
if (!await WaitForAsync(_captionSignal.Task, TimeSpan.FromSeconds(30), cancellationToken))
{
MarkStage(stages, 5, TestStageState.Blocked, "No caption returned within 30 seconds. Check the loaded model and host diagnostics.");
return;
}
MarkStage(stages, 5, TestStageState.Passed, "Speech recognition returned a stable result.");
MarkStage(stages, 6, TestStageState.Passed, "A caption returned over the secure connection.");
MarkStage(stages, 7, TestStageState.Passed, "The delivery adapter stayed in safe simulation mode.");
MarkStage(stages, 8, TestStageState.Passed, "The exact outgoing caption was rendered locally, not sent to Twitch.");
SetState(State with { Detail = "The safe end-to-end transcription test passed.", Health = TrayHealth.Ready });
}
finally
{
if (_socket is not null && State.Connected)
try { await _socket.SendAsync("stop", new { reason = "test_complete" }, _socket.SessionId, CancellationToken.None); } catch { }
_audioSignal = null;
_captionSignal = 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 });
}
}
private async Task EvaluateStageAsync(TestStage[] stages, int index, bool passed, string success, string blocked, CancellationToken cancellationToken)
{
MarkStage(stages, index, TestStageState.Running, "Checking…");
await Task.Delay(180, cancellationToken);
MarkStage(stages, index, passed ? TestStageState.Passed : TestStageState.Blocked, passed ? success : blocked);
}
private void MarkStage(TestStage[] stages, int index, TestStageState state, string detail)
{
stages[index] = stages[index] with { State = state, Detail = detail };
TestStages = stages.ToArray();
TestStagesChanged?.Invoke(TestStages);
}
public async Task SaveSettingsAsync(CompanionSettings value, CancellationToken cancellationToken = default)
{
await _settings.SaveAsync(value, cancellationToken);
ApplyAutoStart(value.AutoStartWithWindows);
await WriteLogAsync("settings_saved", "Local companion preferences updated.");
}
public async Task SelectSourceAsync(ObsSource source, CancellationToken cancellationToken = default)
{
await _settings.SaveAsync(_settings.Current with { PrimarySourceUuid = source.Uuid, PrimarySourceName = source.Name }, cancellationToken);
foreach (var item in ObsSources) await SendSourceUpdateAsync(item);
await WriteLogAsync("source_selected", $"Selected OBS source {source.Name}.");
}
public void OpenLumiWebUi()
{
if (!Uri.TryCreate(State.Host, UriKind.Absolute, out var uri)) return;
Process.Start(new ProcessStartInfo(uri.ToString()) { UseShellExecute = true });
}
public void OpenLogsDirectory()
{
Directory.CreateDirectory(_paths.LogsDirectory);
Process.Start(new ProcessStartInfo(_paths.LogsDirectory) { UseShellExecute = true });
}
public async Task ForgetDeviceAsync()
{
if (_socket is not null) { await _socket.DisposeAsync(); _socket = null; }
_credentials.Remove();
SetState(new CompanionState(ObsBridgeInstalled: DetectBridgeInstallation(), Detail: "Device removed. Pair this computer to reconnect."));
await WriteLogAsync("device_removed", "Saved device credential removed locally.");
}
private Task OnServerMessageAsync(ServerEnvelope message)
{
if (message.Type == "caption")
{
var text = message.Payload.TryGetProperty("stable_text", out var stable) ? stable.GetString() : null;
var simulated = message.Payload.TryGetProperty("delivery", out var delivery) && delivery.TryGetProperty("disposition", out var disposition) && disposition.GetString() == "simulated";
if (!string.IsNullOrWhiteSpace(text))
{
if (simulated) _captionSignal?.TrySetResult(true);
CaptionReceived?.Invoke(text, simulated);
if (!simulated) _ = _obsBridge?.SendAsync(new { type = "caption", payload = message.Payload });
}
}
if (message.Type == "error")
{
var serverMessage = message.Payload.TryGetProperty("message", out var value) ? value.GetString() : "Lumi reported an error.";
SetState(State with { Health = TrayHealth.Degraded, Detail = serverMessage ?? "Lumi reported an error." });
}
return Task.CompletedTask;
}
private void StartObsBridgeBoundary()
{
if (_obsBridge is not null) return;
var userKey = Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes($"{Environment.UserDomainName}\\{Environment.UserName}"))).Substring(0, 16);
_obsBridge = new ObsBridgePipe(userKey, OnObsMessageAsync, OnObsAudioAsync);
_obsBridge.ConnectionChanged += connected =>
{
var health = connected && State.Connected ? TrayHealth.Ready : State.Health;
SetState(State with { ObsBridgeInstalled = connected || State.ObsBridgeInstalled, ObsConnected = connected, Health = health, Detail = connected ? "OBS and Lumi are connected. Choose a microphone and run a safe test." : State.Detail });
_ = WriteLogAsync("obs_connection", connected ? "OBS bridge connected." : "OBS bridge disconnected.");
};
_obsBridge.Start();
}
private async Task OnObsMessageAsync(JsonElement message)
{
var type = message.GetProperty("type").GetString();
if (type == "obs_state")
{
var streaming = ReadBoolean(message, "streaming");
var recording = ReadBoolean(message, "recording");
SetState(State with { ObsConnected = true, ObsStreaming = streaming, ObsRecording = recording, Health = streaming ? TrayHealth.Operating : TrayHealth.Ready, Detail = streaming ? "OBS is live and companion services are active." : "OBS is connected and ready." });
if (_socket is not null && State.Connected)
await _socket.SendAsync("obs_state", new { streaming, recording, version = ReadString(message, "version"), auto_start = _settings.Current.StartWithObs }, _socket.SessionId, _lifetimeToken());
}
else if (type == "source_list" && message.TryGetProperty("sources", out var sources) && sources.ValueKind == JsonValueKind.Array)
{
ObsSources = sources.EnumerateArray().Select(source => new ObsSource(
ReadString(source, "source_uuid") ?? ReadString(source, "uuid") ?? string.Empty,
ReadString(source, "display_name") ?? ReadString(source, "name") ?? "OBS source",
ReadBoolean(source, "program_active") || ReadBoolean(source, "active"),
ReadBoolean(source, "source_missing") || ReadBoolean(source, "missing")))
.Where(source => Guid.TryParse(source.Uuid, out _)).ToArray();
ObsSourcesChanged?.Invoke(ObsSources);
foreach (var source in ObsSources) await SendSourceUpdateAsync(source);
}
else if (type == "source_state")
{
var source = new ObsSource(ReadString(message, "source_uuid") ?? string.Empty, ReadString(message, "display_name") ?? "OBS source", ReadBoolean(message, "program_active"), ReadBoolean(message, "source_missing"));
if (Guid.TryParse(source.Uuid, out _))
{
ObsSources = ObsSources.Where(item => item.Uuid != source.Uuid).Append(source).OrderBy(item => item.Name).ToArray();
ObsSourcesChanged?.Invoke(ObsSources);
await SendSourceUpdateAsync(source);
}
}
}
private Task OnObsAudioAsync(ReadOnlyMemory<byte> frame)
{
_audioSignal?.TrySetResult(true);
if (_socket is not null && State.Connected && !_socket.QueueBridgeAudio(frame))
_ = WriteLogAsync("audio_dropped", "An obsolete audio frame was dropped before network delivery.");
return Task.CompletedTask;
}
private async Task SendSourceUpdateAsync(ObsSource source)
{
if (_socket is null || !State.Connected) return;
await _socket.SendAsync("source_update", new
{
source_uuid = source.Uuid,
display_name = source.Name,
enabled = source.Uuid == _settings.Current.PrimarySourceUuid,
primary = source.Uuid == _settings.Current.PrimarySourceUuid,
program_active = source.Active,
source_missing = source.Missing,
single_speaker = true,
delivery_enabled = true
}, _socket.SessionId, _lifetimeToken());
}
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 CancellationToken _lifetimeToken() => _disposed ? new CancellationToken(true) : CancellationToken.None;
private static async Task<bool> WaitForAsync(Task task, TimeSpan timeout, CancellationToken cancellationToken)
{
using var timeoutSource = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
var delay = Task.Delay(timeout, timeoutSource.Token);
if (await Task.WhenAny(task, delay) != task) return false;
timeoutSource.Cancel();
await task;
return true;
}
private bool DetectBridgeInstallation()
{
if (File.Exists(Path.Combine(_paths.BridgeDirectory, "lumi-obs-bridge.dll"))) return true;
var obsData = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), "obs-studio", "plugins", "lumi-obs-bridge", "bin", "64bit", "lumi-obs-bridge.dll");
return File.Exists(obsData);
}
private void ApplyAutoStart(bool enabled)
{
if (!OperatingSystem.IsWindows()) return;
try
{
using var key = Registry.CurrentUser.CreateSubKey(RunKey);
if (enabled) key.SetValue("Lumi Companion", $"\"{Environment.ProcessPath}\" --background");
else key.DeleteValue("Lumi Companion", false);
}
catch (Exception error) { _ = WriteLogAsync("autostart_failed", error.Message); }
}
private async Task WriteLogAsync(string kind, string message)
{
try
{
Directory.CreateDirectory(_paths.LogsDirectory);
PruneLogs();
var path = Path.Combine(_paths.LogsDirectory, $"companion-{DateTime.UtcNow:yyyy-MM-dd}.jsonl");
var line = JsonSerializer.Serialize(new { timestamp = DateTimeOffset.UtcNow, kind, message }, ProtocolV1.JsonOptions);
await File.AppendAllTextAsync(path, line + Environment.NewLine);
LogAdded?.Invoke($"{DateTime.Now:HH:mm:ss} {message}");
}
catch { }
}
private void PruneLogs()
{
var files = new DirectoryInfo(_paths.LogsDirectory).GetFiles("*.jsonl").OrderBy(file => file.CreationTimeUtc).ToList();
foreach (var file in files.Where(file => file.CreationTimeUtc < DateTime.UtcNow.AddDays(-7))) file.Delete();
const long cap = 256L * 1024 * 1024;
var total = files.Where(file => file.Exists).Sum(file => file.Length);
foreach (var file in files.Where(file => file.Exists)) { if (total <= cap) break; total -= file.Length; file.Delete(); }
}
private void SetState(CompanionState state) { State = state; StateChanged?.Invoke(state); }
private static string Friendly(Exception error) => error switch
{
InvalidDataException => error.Message,
HttpRequestException => "Check the Lumi address, TLS certificate, and network connection.",
TaskCanceledException => "The connection timed out. Check that Lumi is reachable.",
_ => error.Message
};
private static TestStage[] CreateInitialTestStages() =>
[
new("OBS integration", "Waiting to check the managed bridge.", TestStageState.Waiting),
new("OBS connection", "Waiting for OBS.", TestStageState.Waiting),
new("Microphone", "Waiting for a selected source.", TestStageState.Waiting),
new("Audio capture", "Waiting for live microphone audio.", TestStageState.Waiting),
new("Lumi connection", "Waiting to verify secure transport.", TestStageState.Waiting),
new("Speech recognition", "Waiting for real server-hosted inference.", TestStageState.Waiting),
new("Caption return", "Waiting for a stable caption.", TestStageState.Waiting),
new("Delivery adapter", "Waiting for safe simulation mode.", TestStageState.Waiting),
new("Simulated output", "Nothing is sent to Twitch during this test.", TestStageState.Waiting)
];
public static string Version => Assembly.GetExecutingAssembly().GetName().Version?.ToString(3) ?? "0.1.0";
public async ValueTask DisposeAsync()
{
if (_disposed) return;
_disposed = true;
if (_socket is not null) await _socket.DisposeAsync();
if (_obsBridge is not null) await _obsBridge.DisposeAsync();
_http.Dispose();
}
}

View File

@ -0,0 +1,54 @@
using System.Text.Json;
using Lumi.Companion.Protocol;
namespace Lumi.Companion.App;
public sealed record CompanionSettings(
bool AutoStartWithWindows = false,
bool StartWithObs = true,
bool AdvancedMode = false,
string? PrimarySourceUuid = null,
string? PrimarySourceName = null);
public sealed class CompanionSettingsStore
{
private readonly string _path;
private readonly SemaphoreSlim _gate = new(1, 1);
public CompanionSettingsStore(string path) => _path = path;
public CompanionSettings Current { get; private set; } = new();
public event Action<CompanionSettings>? Changed;
public async Task LoadAsync(CancellationToken cancellationToken = default)
{
if (!File.Exists(_path)) return;
try
{
Current = JsonSerializer.Deserialize<CompanionSettings>(await File.ReadAllBytesAsync(_path, cancellationToken), ProtocolV1.JsonOptions) ?? new();
}
catch (JsonException)
{
Current = new();
}
Changed?.Invoke(Current);
}
public async Task SaveAsync(CompanionSettings settings, CancellationToken cancellationToken = default)
{
await _gate.WaitAsync(cancellationToken);
try
{
Directory.CreateDirectory(Path.GetDirectoryName(_path)!);
var temporary = $"{_path}.{Environment.ProcessId}.tmp";
try
{
await File.WriteAllBytesAsync(temporary, JsonSerializer.SerializeToUtf8Bytes(settings, ProtocolV1.JsonOptions), cancellationToken);
File.Move(temporary, _path, true);
}
finally { File.Delete(temporary); }
Current = settings;
}
finally { _gate.Release(); }
Changed?.Invoke(settings);
}
}

View File

@ -1,10 +1,42 @@
namespace Lumi.Companion.App;
public enum TrayHealth { Ready, Operating, Degraded, Failed }
public sealed record CompanionState(bool ObsStreaming, bool ObsRecording, bool TestRunning, TrayHealth Health)
public enum CompanionPage { Overview, Transcription, Test, Connection, Logs, Settings }
public enum TestStageState { Waiting, Running, Passed, Blocked, Failed }
public sealed record CompanionState(
bool Paired = false,
bool Connected = false,
bool ObsBridgeInstalled = false,
bool ObsConnected = false,
bool ObsStreaming = false,
bool ObsRecording = false,
bool TestRunning = false,
TrayHealth Health = TrayHealth.Degraded,
string Detail = "Pair Lumi Companion to get started.",
string? DeviceName = null,
string? Host = null,
DateTimeOffset? LastConnectedAt = null)
{
public string Summary => Health switch
{
TrayHealth.Operating => "Transcription active",
TrayHealth.Ready => "Ready",
TrayHealth.Failed => "Needs attention",
_ when !Paired => "Setup required",
_ when !Connected => "Lumi offline",
_ when !ObsBridgeInstalled => "OBS setup required",
_ => "Partially ready"
};
public bool RequiresQuitConfirmation => ObsStreaming || ObsRecording;
public string QuitWarning => ObsStreaming
? "OBS is streaming. Quitting Lumi Companion will stop transcription and closed captions, but it will not stop the OBS stream."
: ObsRecording ? "OBS is recording. Quitting Lumi Companion will stop active companion features." : string.Empty;
}
public sealed record TestStage(string Name, string Detail, TestStageState State);
public sealed record ObsSource(string Uuid, string Name, bool Active, bool Missing)
{
public override string ToString() => Missing ? $"{Name} (missing)" : Active ? $"{Name} (active)" : Name;
}

View File

@ -0,0 +1,31 @@
using Avalonia;
using Avalonia.Controls;
using Avalonia.Layout;
namespace Lumi.Companion.App;
internal sealed class DecisionWindow : Window
{
public DecisionWindow(string title, string message, string confirmLabel)
{
Title = title;
Width = 470;
SizeToContent = SizeToContent.Height;
CanResize = false;
WindowStartupLocation = WindowStartupLocation.CenterOwner;
var cancel = new Button { Content = "Cancel", Classes = { "secondary" } };
var confirm = new Button { Content = confirmLabel, Classes = { "primary" } };
cancel.Click += (_, _) => Close(false);
confirm.Click += (_, _) => Close(true);
Content = new StackPanel
{
Margin = new Thickness(28), Spacing = 18,
Children =
{
new TextBlock { Text = title, Classes = { "sectionTitle" } },
new TextBlock { Text = message, Classes = { "muted" }, TextWrapping = Avalonia.Media.TextWrapping.Wrap },
new StackPanel { Orientation = Orientation.Horizontal, HorizontalAlignment = HorizontalAlignment.Right, Spacing = 10, Children = { cancel, confirm } }
}
};
}
}

View File

@ -1 +1,20 @@
<Project Sdk="Microsoft.NET.Sdk"><PropertyGroup><OutputType>Exe</OutputType><TargetFramework>net8.0-windows</TargetFramework><Nullable>enable</Nullable><ImplicitUsings>enable</ImplicitUsings></PropertyGroup><ItemGroup><ProjectReference Include="../Lumi.Companion.Core/Lumi.Companion.Core.csproj" /><ProjectReference Include="../Lumi.Companion.Protocol/Lumi.Companion.Protocol.csproj" /></ItemGroup></Project>
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>WinExe</OutputType>
<TargetFramework>net8.0-windows</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<ApplicationManifest>app.manifest</ApplicationManifest>
<Version>0.1.0-experimental.1</Version>
<AssemblyVersion>0.1.0.0</AssemblyVersion>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="../Lumi.Companion.Core/Lumi.Companion.Core.csproj" />
<ProjectReference Include="../Lumi.Companion.Protocol/Lumi.Companion.Protocol.csproj" />
<ProjectReference Include="../../plugins/Lumi.Companion.Transcription/Lumi.Companion.Transcription.csproj" />
<PackageReference Include="Avalonia" Version="12.1.0" />
<PackageReference Include="Avalonia.Desktop" Version="12.1.0" />
<PackageReference Include="Avalonia.Fonts.Inter" Version="12.1.0" />
<PackageReference Include="Avalonia.Themes.Fluent" Version="12.1.0" />
</ItemGroup>
</Project>

View File

@ -0,0 +1,52 @@
using Avalonia.Controls;
namespace Lumi.Companion.App;
internal static class LumiIconFactory
{
public static WindowIcon Create(TrayHealth health)
{
const int size = 32;
var color = health switch
{
TrayHealth.Operating => (R: (byte)35, G: (byte)132, B: (byte)91),
TrayHealth.Degraded => (R: (byte)169, G: (byte)102, B: (byte)18),
TrayHealth.Failed => (R: (byte)189, G: (byte)77, B: (byte)77),
_ => (R: (byte)79, G: (byte)182, B: (byte)194)
};
var pixels = new byte[size * size * 4];
for (var y = 0; y < size; y++)
for (var x = 0; x < size; x++)
{
var target = ((size - 1 - y) * size + x) * 4;
var rounded = InsideRoundedSquare(x, y, size, 7);
var diamond = Math.Abs(x - 15.5) / 11.5 + Math.Abs(y - 15.5) / 11.5 <= 1;
pixels[target] = diamond && rounded ? (byte)255 : color.B;
pixels[target + 1] = diamond && rounded ? (byte)255 : color.G;
pixels[target + 2] = diamond && rounded ? (byte)255 : color.R;
pixels[target + 3] = rounded ? (byte)255 : (byte)0;
}
using var stream = new MemoryStream();
using var writer = new BinaryWriter(stream, System.Text.Encoding.UTF8, leaveOpen: true);
writer.Write((ushort)0); writer.Write((ushort)1); writer.Write((ushort)1);
writer.Write((byte)size); writer.Write((byte)size); writer.Write((byte)0); writer.Write((byte)0);
writer.Write((ushort)1); writer.Write((ushort)32);
var imageBytes = 40 + pixels.Length + size * 4;
writer.Write(imageBytes); writer.Write(22);
writer.Write(40); writer.Write(size); writer.Write(size * 2); writer.Write((ushort)1); writer.Write((ushort)32);
writer.Write(0); writer.Write(pixels.Length); writer.Write(0); writer.Write(0); writer.Write(0); writer.Write(0);
writer.Write(pixels); writer.Write(new byte[size * 4]);
stream.Position = 0;
return new WindowIcon(stream);
}
private static bool InsideRoundedSquare(int x, int y, int size, int radius)
{
if (x >= radius && x < size - radius || y >= radius && y < size - radius) return true;
var cx = x < radius ? radius : size - radius - 1;
var cy = y < radius ? radius : size - radius - 1;
var dx = x - cx; var dy = y - cy;
return dx * dx + dy * dy <= radius * radius;
}
}

View File

@ -0,0 +1,211 @@
<Window xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
x:Class="Lumi.Companion.App.MainWindow"
Title="Lumi Companion"
Width="1120" Height="760" MinWidth="900" MinHeight="620"
WindowStartupLocation="CenterScreen">
<Grid ColumnDefinitions="248,*">
<Border Grid.Column="0" Background="#F4F7F8" Padding="22,24">
<Grid RowDefinitions="Auto,Auto,*,Auto">
<StackPanel Orientation="Horizontal" Spacing="11">
<Border Width="34" Height="34" CornerRadius="9" Background="#28B7C8">
<Path Data="M 17,7 L 27,17 L 17,27 L 7,17 Z" Fill="White" Stretch="None" />
</Border>
<StackPanel VerticalAlignment="Center" Spacing="0">
<TextBlock Text="LUMI" Classes="eyebrow" />
<TextBlock Text="Companion" FontWeight="SemiBold" FontSize="16" />
</StackPanel>
</StackPanel>
<Border Grid.Row="1" Margin="0,25,0,20" Padding="11,9" CornerRadius="9" Background="#E6EEF0">
<Grid ColumnDefinitions="Auto,*" ColumnSpacing="9">
<Border x:Name="StatusMark" Width="10" Height="10" CornerRadius="5" Background="#A96612" VerticalAlignment="Center" />
<StackPanel Grid.Column="1" Spacing="1">
<TextBlock x:Name="StatusLabel" Text="Setup required" FontSize="13" FontWeight="SemiBold" />
<TextBlock x:Name="StatusSymbol" Text="○ Incomplete" FontSize="11" Foreground="{DynamicResource LumiMutedBrush}" />
</StackPanel>
</Grid>
</Border>
<StackPanel Grid.Row="2" x:Name="Navigation">
<Button x:Name="OverviewNav" Classes="nav selected" Content="Overview" Tag="Overview" />
<Button x:Name="TranscriptionNav" Classes="nav" Content="Transcription" Tag="Transcription" />
<Button x:Name="TestNav" Classes="nav" Content="Test" Tag="Test" />
<Button x:Name="ConnectionNav" Classes="nav" Content="Connection &amp; device" Tag="Connection" />
<Button x:Name="LogsNav" Classes="nav" Content="Logs &amp; diagnostics" Tag="Logs" />
<Button x:Name="SettingsNav" Classes="nav" Content="Settings" Tag="Settings" />
</StackPanel>
<TextBlock Grid.Row="3" Text="Runs quietly in the notification area" Classes="muted" FontSize="11" />
</Grid>
</Border>
<Grid Grid.Column="1">
<ScrollViewer x:Name="OverviewPage">
<StackPanel Margin="44,38,52,48" MaxWidth="760" HorizontalAlignment="Left" Spacing="24">
<StackPanel Spacing="6">
<TextBlock Text="AT A GLANCE" Classes="eyebrow" />
<TextBlock Text="Overview" Classes="pageTitle" />
<TextBlock x:Name="OverviewDetail" Text="Checking what is ready…" Classes="muted" FontSize="15" />
</StackPanel>
<Border Classes="soft" Padding="22">
<Grid ColumnDefinitions="*,Auto" ColumnSpacing="24">
<StackPanel Spacing="6">
<TextBlock x:Name="NextActionTitle" Text="Pair this computer" Classes="sectionTitle" />
<TextBlock x:Name="NextActionDetail" Text="Use a one-time pairing package from Lumi to connect this streaming computer." Classes="muted" MaxWidth="520" />
</StackPanel>
<Button Grid.Column="1" x:Name="NextActionButton" Classes="primary" Content="Choose pairing package" VerticalAlignment="Center" />
</Grid>
</Border>
<StackPanel Spacing="12">
<TextBlock Text="Setup progress" Classes="sectionTitle" />
<Grid ColumnDefinitions="*,*,*" ColumnSpacing="12">
<Border Classes="card">
<StackPanel Spacing="7">
<TextBlock x:Name="PairingStepSymbol" Text="○" FontSize="20" />
<TextBlock Text="Lumi connection" FontWeight="SemiBold" />
<TextBlock x:Name="PairingStepDetail" Text="Not paired" Classes="muted" FontSize="12" />
</StackPanel>
</Border>
<Border Grid.Column="1" Classes="card">
<StackPanel Spacing="7">
<TextBlock x:Name="ObsStepSymbol" Text="○" FontSize="20" />
<TextBlock Text="OBS integration" FontWeight="SemiBold" />
<TextBlock x:Name="ObsStepDetail" Text="Not installed" Classes="muted" FontSize="12" />
</StackPanel>
</Border>
<Border Grid.Column="2" Classes="card">
<StackPanel Spacing="7">
<TextBlock x:Name="TestStepSymbol" Text="○" FontSize="20" />
<TextBlock Text="Full path test" FontWeight="SemiBold" />
<TextBlock x:Name="TestStepDetail" Text="Not run" Classes="muted" FontSize="12" />
</StackPanel>
</Border>
</Grid>
</StackPanel>
<Border Classes="soft">
<TextBlock Text="Lumi Companion never runs speech recognition on this streaming computer. Audio stays in bounded memory and is sent to your paired Lumi host." Classes="muted" />
</Border>
</StackPanel>
</ScrollViewer>
<ScrollViewer x:Name="TranscriptionPage" IsVisible="False">
<StackPanel Margin="44,38,52,48" MaxWidth="760" HorizontalAlignment="Left" Spacing="25">
<StackPanel Spacing="6">
<TextBlock Text="CAPTURE" Classes="eyebrow" />
<TextBlock Text="Transcription" Classes="pageTitle" />
<TextBlock Text="Choose the OBS microphone Lumi should caption. Source identity remains stable if you rename it." Classes="muted" FontSize="15" />
</StackPanel>
<StackPanel Spacing="8">
<TextBlock Text="Primary microphone" FontWeight="SemiBold" />
<ComboBox x:Name="SourcePicker" IsEnabled="False" PlaceholderText="Open OBS after installing the managed integration" MinWidth="420" HorizontalAlignment="Left" />
<TextBlock x:Name="SourceHelp" Text="No OBS source list is available yet." Classes="muted" />
</StackPanel>
<Border Classes="soft">
<StackPanel Spacing="7">
<TextBlock Text="Optional second microphone" FontWeight="SemiBold" />
<TextBlock Text="The protocol supports more than one source, but source overlap policy remains disabled until the primary path is validated." Classes="muted" />
</StackPanel>
</Border>
</StackPanel>
</ScrollViewer>
<ScrollViewer x:Name="TestPage" IsVisible="False">
<StackPanel Margin="44,38,52,48" MaxWidth="760" HorizontalAlignment="Left" Spacing="24">
<StackPanel Spacing="6">
<TextBlock Text="SAFE CHECK" Classes="eyebrow" />
<TextBlock Text="Transcription test" Classes="pageTitle" />
<TextBlock Text="Exercises the real capture, network, speech recognition, and return path without sending anything to Twitch." Classes="muted" FontSize="15" />
</StackPanel>
<Button x:Name="RunTestButton" Classes="primary" Content="Run full path test" HorizontalAlignment="Left" />
<StackPanel x:Name="TestStagesPanel" Spacing="8" />
<Border Classes="soft">
<StackPanel Spacing="5">
<TextBlock Text="Simulated outgoing caption" FontWeight="SemiBold" />
<TextBlock x:Name="SimulatedCaption" Text="A real caption will appear here only after every upstream stage passes." Classes="muted" FontStyle="Italic" />
</StackPanel>
</Border>
</StackPanel>
</ScrollViewer>
<ScrollViewer x:Name="ConnectionPage" IsVisible="False">
<StackPanel Margin="44,38,52,48" MaxWidth="760" HorizontalAlignment="Left" Spacing="24">
<StackPanel Spacing="6">
<TextBlock Text="DEVICE" Classes="eyebrow" />
<TextBlock Text="Connection &amp; device" Classes="pageTitle" />
<TextBlock Text="The device credential is protected for your Windows account and never shared with OBS." Classes="muted" FontSize="15" />
</StackPanel>
<Grid ColumnDefinitions="150,*" RowDefinitions="Auto,Auto,Auto" RowSpacing="12">
<TextBlock Text="Computer" Classes="muted" /><TextBlock Grid.Column="1" x:Name="DeviceNameText" Text="Not paired" FontWeight="SemiBold" />
<TextBlock Grid.Row="1" Text="Lumi host" Classes="muted" /><TextBlock Grid.Row="1" Grid.Column="1" x:Name="HostText" Text="—" />
<TextBlock Grid.Row="2" Text="Last connected" Classes="muted" /><TextBlock Grid.Row="2" Grid.Column="1" x:Name="LastConnectedText" Text="Never" />
</Grid>
<StackPanel Orientation="Horizontal" Spacing="10">
<Button x:Name="ReconnectButton" Classes="secondary" Content="Retry connection" />
<Button x:Name="PairButton" Classes="secondary" Content="Choose pairing package" />
<Button x:Name="OpenWebButton" Classes="secondary" Content="Open Lumi WebUI" />
</StackPanel>
<Border Classes="soft">
<StackPanel Spacing="8">
<TextBlock Text="Remove this device" FontWeight="SemiBold" />
<TextBlock Text="Removes the local credential. You can also revoke this computer from Lumi." Classes="muted" />
<Button x:Name="ForgetButton" Classes="secondary" Content="Forget this device" HorizontalAlignment="Left" />
</StackPanel>
</Border>
</StackPanel>
</ScrollViewer>
<ScrollViewer x:Name="LogsPage" IsVisible="False">
<StackPanel Margin="44,38,52,48" MaxWidth="760" HorizontalAlignment="Left" Spacing="24">
<StackPanel Spacing="6">
<TextBlock Text="DIAGNOSTICS" Classes="eyebrow" />
<TextBlock Text="Logs &amp; diagnostics" Classes="pageTitle" />
<TextBlock Text="Short-lived diagnostic logs help explain connection and delivery failures. Raw audio is never written to disk." Classes="muted" FontSize="15" />
</StackPanel>
<Button x:Name="OpenLogsButton" Classes="secondary" Content="Open logs folder" HorizontalAlignment="Left" />
<Border Background="#182026" CornerRadius="12" Padding="16" MinHeight="230">
<StackPanel x:Name="RecentLogsPanel" Spacing="6">
<TextBlock Text="Waiting for companion events…" Foreground="#CFDADD" FontFamily="Consolas" FontSize="12" />
</StackPanel>
</Border>
<TextBlock Text="Logs are retained for 7 days and capped at 256 MiB. Caption text may appear in diagnostics during testing or troubleshooting." Classes="muted" FontSize="12" />
</StackPanel>
</ScrollViewer>
<ScrollViewer x:Name="SettingsPage" IsVisible="False">
<StackPanel Margin="44,38,52,48" MaxWidth="760" HorizontalAlignment="Left" Spacing="25">
<StackPanel Spacing="6">
<TextBlock Text="PREFERENCES" Classes="eyebrow" />
<TextBlock Text="Settings" Classes="pageTitle" />
<TextBlock Text="Only controls useful on the streaming computer live here. Model and server administration stay in Lumi." Classes="muted" FontSize="15" />
</StackPanel>
<StackPanel Spacing="16">
<Grid ColumnDefinitions="*,Auto" ColumnSpacing="24">
<StackPanel Spacing="3"><TextBlock Text="Start with Windows" FontWeight="SemiBold" /><TextBlock Text="Keep Lumi Companion ready in the notification area after sign-in." Classes="muted" /></StackPanel>
<ToggleSwitch Grid.Column="1" x:Name="AutoStartToggle" OffContent="Off" OnContent="On" />
</Grid>
<Grid ColumnDefinitions="*,Auto" ColumnSpacing="24">
<StackPanel Spacing="3"><TextBlock Text="Start companion features with OBS" FontWeight="SemiBold" /><TextBlock Text="Prepare selected features when OBS becomes available." Classes="muted" /></StackPanel>
<ToggleSwitch Grid.Column="1" x:Name="StartWithObsToggle" OffContent="Off" OnContent="On" />
</Grid>
<Grid ColumnDefinitions="*,Auto" ColumnSpacing="24">
<StackPanel Spacing="3"><TextBlock Text="Advanced mode" FontWeight="SemiBold" /><TextBlock Text="Reveal protocol and component details useful for troubleshooting." Classes="muted" /></StackPanel>
<ToggleSwitch Grid.Column="1" x:Name="AdvancedToggle" OffContent="Off" OnContent="On" />
</Grid>
</StackPanel>
<Border x:Name="AdvancedPanel" Classes="soft" IsVisible="False">
<StackPanel Spacing="6">
<TextBlock Text="Advanced details" FontWeight="SemiBold" />
<TextBlock Text="Protocol v1 · bounded in-memory audio · secure WebSocket transport · server-hosted inference" Classes="muted" />
</StackPanel>
</Border>
<Button x:Name="SaveSettingsButton" Classes="primary" Content="Save preferences" HorizontalAlignment="Left" />
<TextBlock x:Name="SettingsFeedback" Classes="muted" />
</StackPanel>
</ScrollViewer>
</Grid>
</Grid>
</Window>

View File

@ -0,0 +1,253 @@
using Avalonia;
using Avalonia.Controls;
using Avalonia.Interactivity;
using Avalonia.Media;
using Avalonia.Platform.Storage;
using Avalonia.Threading;
namespace Lumi.Companion.App;
public partial class MainWindow : Window
{
private readonly CompanionRuntime _runtime;
private readonly CompanionSettingsStore _settings;
private bool _allowExit;
private bool _testPassed;
private bool _renderingSources;
public MainWindow() : this(CreateDefaultServices()) { }
private MainWindow((CompanionRuntime Runtime, CompanionSettingsStore Settings) services)
: this(services.Runtime, services.Settings) { }
public MainWindow(CompanionRuntime runtime, CompanionSettingsStore settings)
{
_runtime = runtime;
_settings = settings;
InitializeComponent();
WireActions();
RenderState(runtime.State);
RenderTestStages(runtime.TestStages);
Closing += OnClosing;
runtime.StateChanged += state => Dispatcher.UIThread.Post(() => RenderState(state));
runtime.TestStagesChanged += stages => Dispatcher.UIThread.Post(() => RenderTestStages(stages));
runtime.ObsSourcesChanged += sources => Dispatcher.UIThread.Post(() => RenderSources(sources));
runtime.CaptionReceived += (text, simulated) => Dispatcher.UIThread.Post(() =>
{
if (simulated) SimulatedCaption.Text = text;
});
runtime.LogAdded += line => Dispatcher.UIThread.Post(() => AddLog(line));
settings.Changed += value => Dispatcher.UIThread.Post(() => RenderSettings(value));
}
private void WireActions()
{
foreach (var button in Navigation.Children.OfType<Button>()) button.Click += OnNavigate;
NextActionButton.Click += OnNextAction;
PairButton.Click += async (_, _) => await ChoosePairingPackageAsync();
ReconnectButton.Click += async (_, _) => await RunUiActionAsync(() => _runtime.RetryConnectionAsync(), ReconnectButton);
OpenWebButton.Click += (_, _) => _runtime.OpenLumiWebUi();
OpenLogsButton.Click += (_, _) => _runtime.OpenLogsDirectory();
RunTestButton.Click += async (_, _) => await RunUiActionAsync(() => _runtime.RunTestAsync(), RunTestButton);
ForgetButton.Click += async (_, _) => await ForgetDeviceAsync();
SaveSettingsButton.Click += async (_, _) => await SaveSettingsAsync();
AdvancedToggle.IsCheckedChanged += (_, _) => AdvancedPanel.IsVisible = AdvancedToggle.IsChecked == true;
SourcePicker.SelectionChanged += async (_, _) =>
{
if (!_renderingSources && SourcePicker.SelectedItem is ObsSource source) await _runtime.SelectSourceAsync(source);
};
}
private void OnNavigate(object? sender, RoutedEventArgs args)
{
if (sender is Button { Tag: string tag } && Enum.TryParse<CompanionPage>(tag, out var page)) ShowPage(page);
}
public void ShowPage(CompanionPage page)
{
var pages = new Dictionary<CompanionPage, Control>
{
[CompanionPage.Overview] = OverviewPage, [CompanionPage.Transcription] = TranscriptionPage,
[CompanionPage.Test] = TestPage, [CompanionPage.Connection] = ConnectionPage,
[CompanionPage.Logs] = LogsPage, [CompanionPage.Settings] = SettingsPage
};
foreach (var item in pages) item.Value.IsVisible = item.Key == page;
foreach (var button in Navigation.Children.OfType<Button>())
{
button.Classes.Set("selected", string.Equals(button.Tag as string, page.ToString(), StringComparison.Ordinal));
}
}
private async void OnNextAction(object? sender, RoutedEventArgs args)
{
if (!_runtime.State.Paired) { await ChoosePairingPackageAsync(); return; }
if (!_runtime.State.Connected) { await RunUiActionAsync(() => _runtime.RetryConnectionAsync(), NextActionButton); return; }
if (!_runtime.State.ObsBridgeInstalled) { ShowPage(CompanionPage.Connection); return; }
ShowPage(CompanionPage.Test);
}
private async Task ChoosePairingPackageAsync()
{
var files = await StorageProvider.OpenFilePickerAsync(new FilePickerOpenOptions
{
Title = "Choose a Lumi pairing package",
AllowMultiple = false,
FileTypeFilter = [new FilePickerFileType("Lumi pairing package") { Patterns = ["*.lumi-pairing.json", "*.json"] }]
});
var path = files.FirstOrDefault()?.TryGetLocalPath();
if (path is null) return;
await RunUiActionAsync(() => _runtime.PairAsync(path), PairButton);
}
private async Task RunUiActionAsync(Func<Task> action, Button button)
{
button.IsEnabled = false;
try { await action(); }
catch (Exception error) { AddLog($"{DateTime.Now:HH:mm:ss} {error.Message}"); }
finally { button.IsEnabled = true; }
}
private async Task SaveSettingsAsync()
{
SaveSettingsButton.IsEnabled = false;
SettingsFeedback.Text = "Saving…";
try
{
await _runtime.SaveSettingsAsync(_settings.Current with
{
AutoStartWithWindows = AutoStartToggle.IsChecked == true,
StartWithObs = StartWithObsToggle.IsChecked == true,
AdvancedMode = AdvancedToggle.IsChecked == true
});
SettingsFeedback.Text = "Preferences saved.";
}
catch (Exception error) { SettingsFeedback.Text = $"Could not save preferences: {error.Message}"; }
finally { SaveSettingsButton.IsEnabled = true; }
}
private async Task ForgetDeviceAsync()
{
var dialog = new DecisionWindow("Forget this device?", "This removes the protected credential from this computer. Pairing can be restored with a new package.", "Forget device");
if (!await dialog.ShowDialog<bool>(this)) return;
await _runtime.ForgetDeviceAsync();
}
private void RenderState(CompanionState state)
{
StatusLabel.Text = state.Summary;
StatusSymbol.Text = state.Health switch { TrayHealth.Ready => "✓ Ready", TrayHealth.Operating => "▶ Active", TrayHealth.Failed => "! Action required", _ => "△ Incomplete" };
StatusMark.Background = new SolidColorBrush(Color.Parse(state.Health switch { TrayHealth.Ready => "#176B75", TrayHealth.Operating => "#23845B", TrayHealth.Failed => "#BD4D4D", _ => "#A96612" }));
OverviewDetail.Text = state.Detail;
PairingStepSymbol.Text = state.Paired ? "✓" : "○";
PairingStepDetail.Text = state.Connected ? "Securely connected" : state.Paired ? "Paired, currently offline" : "Not paired";
ObsStepSymbol.Text = state.ObsConnected ? "✓" : state.ObsBridgeInstalled ? "◐" : "○";
ObsStepDetail.Text = state.ObsConnected ? "Connected to OBS" : state.ObsBridgeInstalled ? "Installed; waiting for OBS" : "Installation required";
TestStepSymbol.Text = _testPassed ? "✓" : "○";
TestStepDetail.Text = _testPassed ? "Passed" : "Not passed";
DeviceNameText.Text = state.DeviceName ?? "Not paired";
HostText.Text = state.Host ?? "—";
LastConnectedText.Text = state.LastConnectedAt?.ToString("g") ?? "Never";
OpenWebButton.IsEnabled = state.Host is not null;
ReconnectButton.IsEnabled = state.Paired && !state.Connected;
ForgetButton.IsEnabled = state.Paired;
RunTestButton.Content = state.TestRunning ? "Testing…" : "Run full path test";
RunTestButton.IsEnabled = !state.TestRunning;
if (!state.Paired)
{
NextActionTitle.Text = "Pair this computer";
NextActionDetail.Text = "Use a one-time pairing package from Lumi to connect this streaming computer.";
NextActionButton.Content = "Choose pairing package";
}
else if (!state.Connected)
{
NextActionTitle.Text = "Reconnect to Lumi";
NextActionDetail.Text = "Your device is paired, but Lumi cannot currently be reached.";
NextActionButton.Content = "Retry connection";
}
else if (!state.ObsBridgeInstalled)
{
NextActionTitle.Text = "Install the OBS integration";
NextActionDetail.Text = "The native bridge package is not installed yet. The signed installer and repair service remain required.";
NextActionButton.Content = "View connection details";
}
else
{
NextActionTitle.Text = "Run a safe transcription test";
NextActionDetail.Text = "Check the real end-to-end path without sending captions to Twitch.";
NextActionButton.Content = "Open test";
}
}
private void RenderSettings(CompanionSettings settings)
{
AutoStartToggle.IsChecked = settings.AutoStartWithWindows;
StartWithObsToggle.IsChecked = settings.StartWithObs;
AdvancedToggle.IsChecked = settings.AdvancedMode;
AdvancedPanel.IsVisible = settings.AdvancedMode;
if (settings.PrimarySourceUuid is not null)
SourcePicker.SelectedItem = _runtime.ObsSources.FirstOrDefault(source => source.Uuid == settings.PrimarySourceUuid);
}
private void RenderSources(IReadOnlyList<ObsSource> sources)
{
_renderingSources = true;
SourcePicker.ItemsSource = sources;
SourcePicker.IsEnabled = sources.Count > 0;
SourcePicker.SelectedItem = sources.FirstOrDefault(source => source.Uuid == _settings.Current.PrimarySourceUuid);
SourceHelp.Text = sources.Count == 0 ? "OBS is connected, but no audio sources were reported." : "Choose the source that carries the primary speaker.";
_renderingSources = false;
}
private void RenderTestStages(IReadOnlyList<TestStage> stages)
{
TestStagesPanel.Children.Clear();
foreach (var stage in stages)
{
var symbol = stage.State switch { TestStageState.Passed => "✓", TestStageState.Running => "…", TestStageState.Blocked => "△", TestStageState.Failed => "!", _ => "○" };
var row = new Grid { ColumnDefinitions = ColumnDefinitions.Parse("28,180,*"), ColumnSpacing = 10 };
row.Children.Add(new TextBlock { Text = symbol, FontSize = 17, VerticalAlignment = Avalonia.Layout.VerticalAlignment.Center });
var name = new TextBlock { Text = stage.Name, FontWeight = FontWeight.SemiBold, VerticalAlignment = Avalonia.Layout.VerticalAlignment.Center };
Grid.SetColumn(name, 1); row.Children.Add(name);
var detail = new TextBlock { Text = stage.Detail, Foreground = new SolidColorBrush(Color.Parse("#5A6872")), TextWrapping = TextWrapping.Wrap };
Grid.SetColumn(detail, 2); row.Children.Add(detail);
TestStagesPanel.Children.Add(new Border { Classes = { "soft" }, Child = row, Padding = new Thickness(14, 11) });
}
_testPassed = stages.Count > 0 && stages.All(stage => stage.State == TestStageState.Passed);
TestStepSymbol.Text = _testPassed ? "✓" : "○";
TestStepDetail.Text = _testPassed ? "Passed" : "Not passed";
RunTestButton.Content = _runtime.State.TestRunning ? "Testing…" : "Run full path test";
RunTestButton.IsEnabled = !_runtime.State.TestRunning;
}
private void AddLog(string line)
{
if (RecentLogsPanel.Children.Count == 1 && RecentLogsPanel.Children[0] is TextBlock text && text.Text?.StartsWith("Waiting") == true) RecentLogsPanel.Children.Clear();
RecentLogsPanel.Children.Insert(0, new TextBlock { Text = line, Foreground = new SolidColorBrush(Color.Parse("#CFDADD")), FontFamily = new FontFamily("Consolas"), FontSize = 12, TextWrapping = TextWrapping.Wrap });
while (RecentLogsPanel.Children.Count > 20) RecentLogsPanel.Children.RemoveAt(RecentLogsPanel.Children.Count - 1);
}
private void OnClosing(object? sender, WindowClosingEventArgs args)
{
if (_allowExit) return;
args.Cancel = true;
Hide();
}
public async Task<bool> ConfirmQuitAsync()
{
if (!_runtime.State.RequiresQuitConfirmation) return true;
var dialog = new DecisionWindow("Quit while OBS is active?", _runtime.State.QuitWarning, "Quit Companion");
return await dialog.ShowDialog<bool>(this);
}
public void AllowExit() => _allowExit = true;
private static (CompanionRuntime, CompanionSettingsStore) CreateDefaultServices()
{
var paths = CompanionPaths.ForCurrentUser();
var settings = new CompanionSettingsStore(paths.SettingsPath);
return (new CompanionRuntime(paths, settings), settings);
}
}

View File

@ -1,33 +1,42 @@
using Lumi.Companion.Core;
using Avalonia;
if (!OperatingSystem.IsWindows())
{
Console.Error.WriteLine("The first Lumi Companion build supports Windows x64 only.");
return 2;
}
namespace Lumi.Companion.App;
var appData = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), "Lumi", "Companion");
var credentialStore = new SecureCredentialStore(appData);
if (args is ["pair", var packagePath])
internal static class Program
{
using var http = new HttpClient { Timeout = TimeSpan.FromSeconds(30) };
var client = new PairingClient(http);
var credential = await client.PairAsync(packagePath, new
internal static SingleInstanceCoordinator? InstanceCoordinator { get; private set; }
internal static bool LaunchInBackground { get; private set; }
[STAThread]
public static int Main(string[] args)
{
install_id = credentialStore.GetOrCreateInstallId(), name = Environment.MachineName,
companion_version = "0.1.0-experimental.1", os = Environment.OSVersion.VersionString,
architecture = System.Runtime.InteropServices.RuntimeInformation.OSArchitecture.ToString()
}, CancellationToken.None);
credentialStore.Save(credential);
Console.WriteLine("Lumi Companion paired successfully. The device credential is protected for the current Windows user.");
return 0;
}
if (!OperatingSystem.IsWindows())
{
Console.Error.WriteLine("The first Lumi Companion release supports Windows x64 only.");
return 2;
}
if (credentialStore.Load() is null)
{
Console.WriteLine("Lumi Companion is not paired. Run: Lumi.Companion.App pair <package.lumi-pairing.json>");
return 1;
}
LaunchInBackground = args.Contains("--background", StringComparer.OrdinalIgnoreCase);
InstanceCoordinator = new SingleInstanceCoordinator();
if (!InstanceCoordinator.IsPrimary)
{
InstanceCoordinator.SignalPrimary();
InstanceCoordinator.Dispose();
return 0;
}
Console.WriteLine("Lumi Companion protocol foundation is installed. The Avalonia tray UI and managed OBS bridge installer are not included in this milestone.");
return 0;
try
{
return BuildAvaloniaApp().StartWithClassicDesktopLifetime(args);
}
finally
{
InstanceCoordinator.Dispose();
}
}
public static AppBuilder BuildAvaloniaApp() => AppBuilder.Configure<App>()
.UsePlatformDetect()
.WithInterFont()
.LogToTrace();
}

View File

@ -0,0 +1,62 @@
using System.IO.Pipes;
using System.Security.Cryptography;
using System.Text;
namespace Lumi.Companion.App;
internal sealed class SingleInstanceCoordinator : IDisposable
{
private readonly Mutex _mutex;
private readonly CancellationTokenSource _lifetime = new();
private readonly string _pipeName;
private Task? _listener;
public SingleInstanceCoordinator()
{
var userKey = Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes($"{Environment.UserDomainName}\\{Environment.UserName}"))).Substring(0, 16);
_pipeName = $"Lumi.Companion.Activate.v1.{userKey}";
_mutex = new Mutex(true, $"Local\\Lumi.Companion.v1.{userKey}", out var created);
IsPrimary = created;
if (created) _listener = Task.Run(() => ListenAsync(_lifetime.Token));
}
public bool IsPrimary { get; }
public event Action? ActivationRequested;
public void SignalPrimary()
{
try
{
using var pipe = new NamedPipeClientStream(".", _pipeName, PipeDirection.Out, PipeOptions.Asynchronous);
pipe.Connect(1000);
pipe.WriteByte(1);
pipe.Flush();
}
catch { }
}
private async Task ListenAsync(CancellationToken cancellationToken)
{
while (!cancellationToken.IsCancellationRequested)
{
try
{
await using var pipe = new NamedPipeServerStream(_pipeName, PipeDirection.In, 1, PipeTransmissionMode.Byte,
PipeOptions.Asynchronous | PipeOptions.CurrentUserOnly, 1, 1);
await pipe.WaitForConnectionAsync(cancellationToken);
if (pipe.ReadByte() == 1) ActivationRequested?.Invoke();
}
catch (OperationCanceledException) { break; }
catch when (!cancellationToken.IsCancellationRequested) { await Task.Delay(200, cancellationToken); }
}
}
public void Dispose()
{
_lifetime.Cancel();
try { _listener?.Wait(500); } catch { }
if (IsPrimary) try { _mutex.ReleaseMutex(); } catch { }
_mutex.Dispose();
_lifetime.Dispose();
}
}

View File

@ -0,0 +1,15 @@
<?xml version="1.0" encoding="utf-8"?>
<assembly manifestVersion="1.0" xmlns="urn:schemas-microsoft-com:asm.v1">
<assemblyIdentity version="1.0.0.0" name="Lumi.Companion.App" />
<compatibility xmlns="urn:schemas-microsoft-com:compatibility.v1">
<application>
<supportedOS Id="{8e0f7a12-bfb3-4fe8-b9a5-48fd50a15a9a}" />
</application>
</compatibility>
<application xmlns="urn:schemas-microsoft-com:asm.v3">
<windowsSettings>
<dpiAware xmlns="http://schemas.microsoft.com/SMI/2005/WindowsSettings">true/pm</dpiAware>
<longPathAware xmlns="http://schemas.microsoft.com/SMI/2016/WindowsSettings">true</longPathAware>
</windowsSettings>
</application>
</assembly>

View File

@ -10,10 +10,12 @@ public sealed class CompanionSocket : IAsyncDisposable
private ClientWebSocket? _socket;
private CancellationTokenSource? _lifetime;
private readonly SemaphoreSlim _sendLock = new(1, 1);
private readonly List<Task> _backgroundTasks = [];
private long _droppedFrames;
public long DroppedFrames => Interlocked.Read(ref _droppedFrames);
public Guid SessionId { get; private set; }
public event Func<ServerEnvelope, Task>? MessageReceived;
public event Action<Exception?>? Disconnected;
public async Task ConnectAsync(DeviceCredential credential, string companionVersion, string pluginVersion, string? obsVersion, CancellationToken cancellationToken)
{
@ -29,18 +31,31 @@ public sealed class CompanionSocket : IAsyncDisposable
throw new InvalidDataException("Lumi returned an incompatible companion handshake.");
SessionId = acknowledgement.SessionId.Value;
_lifetime = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
_ = Task.Run(() => SendAudioAsync(_lifetime.Token), _lifetime.Token);
_ = Task.Run(() => ReceiveAsync(_lifetime.Token), _lifetime.Token);
_backgroundTasks.Add(Task.Run(() => RunGuardedAsync(SendAudioAsync, _lifetime.Token), _lifetime.Token));
_backgroundTasks.Add(Task.Run(() => RunGuardedAsync(ReceiveAsync, _lifetime.Token), _lifetime.Token));
_backgroundTasks.Add(Task.Run(() => RunGuardedAsync(SendHeartbeatsAsync, _lifetime.Token), _lifetime.Token));
}
public bool QueueAudio(AudioFrame frame)
{
var encoded = ProtocolV1.EncodeAudio(frame);
return QueueEncodedAudio(ProtocolV1.EncodeAudio(frame));
}
public bool QueueEncodedAudio(ReadOnlyMemory<byte> encoded)
{
if (encoded.Length < ProtocolV1.AudioHeaderBytes || encoded.Length > ProtocolV1.AudioHeaderBytes + ProtocolV1.MaxAudioPayloadBytes ||
!encoded.Span[..4].SequenceEqual("LACP"u8)) return false;
if (_audio.Writer.TryWrite(encoded)) return true;
_audio.Reader.TryRead(out _);
Interlocked.Increment(ref _droppedFrames);
return _audio.Writer.TryWrite(encoded);
}
public bool QueueBridgeAudio(ReadOnlyMemory<byte> encoded)
{
if (encoded.Length < ProtocolV1.AudioHeaderBytes || !encoded.Span[..4].SequenceEqual("LACP"u8)) return false;
var normalized = encoded.ToArray();
SessionId.TryWriteBytes(normalized.AsSpan(20, 16), bigEndian: true, out _);
return QueueEncodedAudio(normalized);
}
public async Task SendAsync(string type, object payload, Guid? sessionId, CancellationToken cancellationToken)
{
if (_socket?.State != WebSocketState.Open) throw new InvalidOperationException("Lumi is not connected.");
@ -68,6 +83,22 @@ public sealed class CompanionSocket : IAsyncDisposable
if (MessageReceived is { } handler) await handler(message);
}
}
private async Task SendHeartbeatsAsync(CancellationToken cancellationToken)
{
using var timer = new PeriodicTimer(TimeSpan.FromSeconds(8));
while (await timer.WaitForNextTickAsync(cancellationToken))
await SendAsync("ping", new { }, SessionId, cancellationToken);
}
private async Task RunGuardedAsync(Func<CancellationToken, Task> operation, CancellationToken cancellationToken)
{
try { await operation(cancellationToken); }
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) { }
catch (Exception error)
{
_lifetime?.Cancel();
Disconnected?.Invoke(error);
}
}
private static async Task<ServerEnvelope> ReceiveMessageAsync(ClientWebSocket socket, CancellationToken cancellationToken)
{
using var body = new MemoryStream();
@ -87,7 +118,12 @@ public sealed class CompanionSocket : IAsyncDisposable
public async ValueTask DisposeAsync()
{
_lifetime?.Cancel();
if (_socket?.State == WebSocketState.Open) await _socket.CloseAsync(WebSocketCloseStatus.NormalClosure, "companion_exit", CancellationToken.None);
if (_socket?.State == WebSocketState.Open)
{
try { await _socket.CloseAsync(WebSocketCloseStatus.NormalClosure, "companion_exit", CancellationToken.None); }
catch (WebSocketException) { }
}
try { await Task.WhenAll(_backgroundTasks); } catch (OperationCanceledException) { }
_socket?.Dispose(); _lifetime?.Dispose(); _sendLock.Dispose();
}
}

View File

@ -13,7 +13,7 @@ OBS bridge boundary -> same-user companion IPC -> bounded companion queue
-> WebSocket -> companion delivery adapter -> OBS native caption boundary
```
The repository does not yet contain a validated whisper streaming worker or a complete signed OBS bridge installer. Accordingly, the admin page reports inference setup as required and this branch does not claim live Twitch acceptance.
The repository contains a pinned native whisper.cpp rolling-window worker and a CPU build/protocol smoke test. It has not yet been benchmarked with a model on the target RTX 3060, packaged as the Windows CUDA runtime, or validated end to end. The repository also does not contain a complete signed OBS bridge installer. Accordingly, this branch does not claim live Twitch acceptance.
## Trust and privacy
@ -28,11 +28,11 @@ The repository does not yet contain a validated whisper streaming worker or a co
1. Serve Lumi through HTTPS and enable `lumi_transcription` under Admin > Plugins.
2. Open Plugins > Transcription and create a pairing package. It is a bootstrap package for the generic companion, not a credential that should be shared or committed.
3. On the Windows streaming computer, build the .NET 8 projects under `companion/` and run `Lumi.Companion.App pair <package>` for the current protocol milestone.
3. On the Windows streaming computer, build `companion/Lumi.Companion.sln`, start `Lumi.Companion.App`, and choose the package from the Overview or Connection page.
4. Install a pinned runtime/model only after explicit confirmation. `small.en` is recommended; `small.en-q5_1` and `base.en` are fallbacks. Every artifact is checksum-verified before install.
5. Configure `LUMI_TRANSCRIPTION_WORKER` with the supervised streaming-worker executable once that worker is built for the target host.
The normal signed installer, Avalonia tray UI, managed bridge install/repair, source selector, and model benchmark wizard are not complete yet.
The Avalonia tray UI and host-side model download/load controls now exist. The normal signed installer, managed bridge install/repair, live OBS source enumeration, and measured model benchmark wizard are not complete yet.
## Operation and recovery

View File

@ -0,0 +1,163 @@
---
id: plugin.lumi_transcription
title: Lumi Transcription
scope: plugins
status: active
priority: 10
visibility: user
category: Plugin
tags: plugin, lumi_transcription
generated: true
editable: false
---
# Lumi Transcription
Server-hosted whisper.cpp transcription for Lumi Companion and OBS closed captions.
## Metadata
Plugin ID: lumi_transcription
Version: 0.1.0-experimental.1
Default state: enabled
## Web Routes
- /plugins/lumi_transcription
- GET /plugins/lumi_transcription
- GET /plugins/lumi_transcription/api/status
- POST /plugins/lumi_transcription/api/pairing-package
- POST /plugins/lumi_transcription/api/pair
- GET /plugins/lumi_transcription/api/devices
- POST /plugins/lumi_transcription/api/devices/:id/revoke
- POST /plugins/lumi_transcription/api/devices/:id/capabilities
- GET /plugins/lumi_transcription/api/settings
- PATCH /plugins/lumi_transcription/api/settings
- POST /plugins/lumi_transcription/api/models/:id/download
- POST /plugins/lumi_transcription/api/models/:id/load
- POST /plugins/lumi_transcription/api/runtime/:id/install
- GET /plugins/lumi_transcription/api/logs
## Route Reference
### MOUNT /plugins/lumi_transcription
- Purpose: Mounts the plugin router at this base WebUI path.
- Inputs: No request parameters detected by static analysis.
- Response format: Plugin router mount point.
- Access: Access is controlled by the mount options and individual plugin routes.
- Side effects: No direct route action; child routes handle requests.
- Limits/notes: Mount metadata is inferred from static source scanning.
### GET /plugins/lumi_transcription
- Purpose: Renders or serves the lumi_transcription plugin page.
- Inputs: No request parameters detected by static analysis.
- Response format: HTML page rendered from an EJS view
- Access: admin access expected
- Side effects: Usually read-only.
- Limits/notes: Generated from static route source analysis; confirm exact behavior in the handler before changing integrations.
### GET /plugins/lumi_transcription/api/status
- Purpose: Renders or serves the lumi_transcription plugin page for api status.
- Inputs: No request parameters detected by static analysis.
- Response format: JSON response
- Access: admin access expected
- Side effects: Usually read-only.
- Limits/notes: Generated from static route source analysis; confirm exact behavior in the handler before changing integrations. API consumers should expect JSON unless the response format says otherwise.
### POST /plugins/lumi_transcription/api/pairing-package
- Purpose: Processes the lumi_transcription plugin action for api pairing package.
- Inputs: No request parameters detected by static analysis.
- Response format: plain or HTML response
- Access: admin access expected; logged-in session required or used
- Side effects: Action route; side effects were not detected statically.
- Limits/notes: Generated from static route source analysis; confirm exact behavior in the handler before changing integrations. Errors are caught and usually returned as a flash message, JSON error, or error page. Most non-API POST routes are browser form submissions and usually redirect after completion. API consumers should expect JSON unless the response format says otherwise.
### POST /plugins/lumi_transcription/api/pair
- Purpose: Processes the lumi_transcription plugin action for api pair.
- Inputs: body: full submitted body is passed to a helper; exact fields are defined by the matching form/service
- Response format: Form/action response; exact format was not detected statically.
- Access: No explicit access guard detected in the route handler; check surrounding router/mount middleware.
- Side effects: Action route; side effects were not detected statically.
- Limits/notes: Generated from static route source analysis; confirm exact behavior in the handler before changing integrations. Errors are caught and usually returned as a flash message, JSON error, or error page. Most non-API POST routes are browser form submissions and usually redirect after completion. API consumers should expect JSON unless the response format says otherwise.
### GET /plugins/lumi_transcription/api/devices
- Purpose: Renders or serves the lumi_transcription plugin page for api devices.
- Inputs: No request parameters detected by static analysis.
- Response format: JSON response
- Access: admin access expected
- Side effects: Usually read-only.
- Limits/notes: Generated from static route source analysis; confirm exact behavior in the handler before changing integrations. API consumers should expect JSON unless the response format says otherwise.
### POST /plugins/lumi_transcription/api/devices/:id/revoke
- Purpose: Processes the lumi_transcription plugin action for api devices id revoke.
- Inputs: path params: `id`
- Response format: JSON response
- Access: admin access expected
- Side effects: Action route; side effects were not detected statically.
- Limits/notes: Generated from static route source analysis; confirm exact behavior in the handler before changing integrations. Most non-API POST routes are browser form submissions and usually redirect after completion. API consumers should expect JSON unless the response format says otherwise.
### POST /plugins/lumi_transcription/api/devices/:id/capabilities
- Purpose: Processes the lumi_transcription plugin action for api devices id capabilities.
- Inputs: path params: `id`; body: `capabilities`
- Response format: JSON response
- Access: admin access expected
- Side effects: writes or mutates server-side state
- Limits/notes: Generated from static route source analysis; confirm exact behavior in the handler before changing integrations. Most non-API POST routes are browser form submissions and usually redirect after completion. API consumers should expect JSON unless the response format says otherwise.
### GET /plugins/lumi_transcription/api/settings
- Purpose: Renders or serves the lumi_transcription plugin page for api settings.
- Inputs: No request parameters detected by static analysis.
- Response format: JSON response
- Access: No explicit access guard detected in the route handler; check surrounding router/mount middleware.
- Side effects: Usually read-only.
- Limits/notes: Generated from static route source analysis; confirm exact behavior in the handler before changing integrations. API consumers should expect JSON unless the response format says otherwise.
### PATCH /plugins/lumi_transcription/api/settings
- Purpose: Processes the lumi_transcription plugin action for api settings.
- Inputs: body: `changes`
- Response format: Form/action response; exact format was not detected statically.
- Access: No explicit access guard detected in the route handler; check surrounding router/mount middleware.
- Side effects: No side effects detected statically.
- Limits/notes: Generated from static route source analysis; confirm exact behavior in the handler before changing integrations. Errors are caught and usually returned as a flash message, JSON error, or error page. API consumers should expect JSON unless the response format says otherwise.
### POST /plugins/lumi_transcription/api/models/:id/download
- Purpose: Processes the lumi_transcription plugin action for api models id download.
- Inputs: path params: `id`; body: `confirmed`
- Response format: Form/action response; exact format was not detected statically.
- Access: admin access expected
- Side effects: Consumes submitted data; state mutation happens in called helpers if present.
- Limits/notes: Generated from static route source analysis; confirm exact behavior in the handler before changing integrations. Errors are caught and usually returned as a flash message, JSON error, or error page. Most non-API POST routes are browser form submissions and usually redirect after completion. API consumers should expect JSON unless the response format says otherwise.
### POST /plugins/lumi_transcription/api/models/:id/load
- Purpose: Processes the lumi_transcription plugin action for api models id load.
- Inputs: path params: `id`
- Response format: JSON response
- Access: admin access expected; logged-in session required or used
- Side effects: Action route; side effects were not detected statically.
- Limits/notes: Generated from static route source analysis; confirm exact behavior in the handler before changing integrations. Errors are caught and usually returned as a flash message, JSON error, or error page. Most non-API POST routes are browser form submissions and usually redirect after completion. API consumers should expect JSON unless the response format says otherwise.
### POST /plugins/lumi_transcription/api/runtime/:id/install
- Purpose: Processes the lumi_transcription plugin action for api runtime id install.
- Inputs: path params: `id`; body: `confirmed`
- Response format: Form/action response; exact format was not detected statically.
- Access: admin access expected
- Side effects: writes or mutates server-side state
- Limits/notes: Generated from static route source analysis; confirm exact behavior in the handler before changing integrations. Errors are caught and usually returned as a flash message, JSON error, or error page. Most non-API POST routes are browser form submissions and usually redirect after completion. API consumers should expect JSON unless the response format says otherwise.
### GET /plugins/lumi_transcription/api/logs
- Purpose: Renders or serves the lumi_transcription plugin page for api logs.
- Inputs: No request parameters detected by static analysis.
- Response format: JSON response
- Access: admin access expected
- Side effects: Usually read-only.
- Limits/notes: Generated from static route source analysis; confirm exact behavior in the handler before changing integrations. API consumers should expect JSON unless the response format says otherwise.
## Commands
- No plugin command triggers detected.
## Source
Plugin folder: plugins/lumi_transcription

View File

@ -108,7 +108,20 @@ class WhisperCppServerProvider extends TranscriptionProvider {
supervisor.on("crash", (event) => this.emit("provider_error", Object.assign(new Error("Whisper worker crashed."), { details: event })));
supervisor.on("error", (error) => this.emit("provider_error", error));
}
async loadModel(model) { this.worker.start(); this.model = model; this.worker.send({ type: "load_model", model }); return this.health(); }
async loadModel(model) {
this.worker.start();
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 onLoaded = (event) => { cleanup(); resolve(event); };
const onError = (event) => { cleanup(); reject(Object.assign(new Error(event.message || "The model could not be loaded."), { code: event.code })); };
const cleanup = () => { clearTimeout(timer); this.off("model_loaded", onLoaded); this.off("model_error", onError); };
this.once("model_loaded", onLoaded); this.once("model_error", onError);
});
this.worker.send({ type: "load_model", model });
const result = await loaded;
this.model = model;
return { ...(await this.health()), loaded: result };
}
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 addTrack(sessionId, track) { this.worker.send({ type: "add_track", session_id: sessionId, track }); }

View File

@ -0,0 +1,37 @@
cmake_minimum_required(VERSION 3.24)
project(lumi-whisper-worker VERSION 0.1.0 LANGUAGES CXX)
include(FetchContent)
option(LUMI_WHISPER_CUDA "Build whisper.cpp with CUDA support" OFF)
set(BUILD_SHARED_LIBS OFF CACHE BOOL "" FORCE)
set(WHISPER_BUILD_EXAMPLES OFF CACHE BOOL "" FORCE)
set(WHISPER_BUILD_TESTS OFF CACHE BOOL "" FORCE)
set(WHISPER_BUILD_SERVER OFF CACHE BOOL "" FORCE)
set(GGML_CUDA ${LUMI_WHISPER_CUDA} CACHE BOOL "" FORCE)
FetchContent_Declare(
whisper
GIT_REPOSITORY https://github.com/ggml-org/whisper.cpp.git
GIT_TAG f049fff95a089aa9969deb009cdd4892b3e74916
GIT_SHALLOW FALSE
)
FetchContent_Declare(
json
URL https://github.com/nlohmann/json/releases/download/v3.12.0/json.tar.xz
URL_HASH SHA256=42f6e95cad6ec532fd372391373363b62a14af6d771056dbfc86160e6dfff7aa
)
FetchContent_MakeAvailable(whisper json)
add_executable(lumi-whisper-worker src/main.cpp)
target_compile_features(lumi-whisper-worker PRIVATE cxx_std_20)
target_link_libraries(lumi-whisper-worker PRIVATE whisper nlohmann_json::nlohmann_json)
if(LUMI_WHISPER_CUDA)
target_compile_definitions(lumi-whisper-worker PRIVATE LUMI_BACKEND_CUDA=1)
endif()
if(MSVC)
target_compile_options(lumi-whisper-worker PRIVATE /W4 /EHsc)
else()
target_compile_options(lumi-whisper-worker PRIVATE -Wall -Wextra -Wpedantic)
endif()
install(TARGETS lumi-whisper-worker RUNTIME DESTINATION bin)

View File

@ -0,0 +1,12 @@
# Lumi whisper.cpp worker
This is the dedicated native inference process used by `WhisperWorkerSupervisor`. It reads the existing length-prefixed metadata/PCM protocol from standard input and emits newline-delimited structured events on standard output. Audio remains in bounded memory and is never written to disk.
The build pins whisper.cpp to the commit recorded in `runtime_manifest.json` and nlohmann/json to v3.12.0. CPU is the portable verification target; the Lumi server release uses the CUDA option:
```powershell
cmake -S . -B build -G Ninja -DLUMI_WHISPER_CUDA=ON -DCMAKE_BUILD_TYPE=Release
cmake --build build
```
The worker keeps at most six seconds per source, attempts an incremental decode around every 600 ms during speech, and finalizes after roughly 750 ms of measured silence. The server-side stabilizer remains authoritative for stable-prefix reconciliation and obsolete-revision suppression.

View File

@ -0,0 +1,219 @@
#include "whisper.h"
#include <nlohmann/json.hpp>
#include <algorithm>
#include <chrono>
#include <cmath>
#include <cstdint>
#include <cstring>
#include <iostream>
#include <memory>
#include <string>
#include <thread>
#include <unordered_map>
#include <vector>
using json = nlohmann::json;
using clock_type = std::chrono::steady_clock;
namespace {
constexpr std::size_t sample_rate = 16000;
constexpr std::size_t max_samples = sample_rate * 6;
constexpr std::size_t min_decode_samples = sample_rate / 2;
constexpr auto decode_interval = std::chrono::milliseconds(600);
constexpr std::uint64_t finalize_silence_us = 750000;
struct whisper_deleter { void operator()(whisper_context * value) const { if (value) whisper_free(value); } };
using whisper_pointer = std::unique_ptr<whisper_context, whisper_deleter>;
struct track_state {
std::vector<float> samples;
std::uint64_t audio_start_us = 0;
std::uint64_t audio_end_us = 0;
std::uint64_t last_voice_us = 0;
std::uint64_t last_decode_us = 0;
bool voiced = false;
bool new_utterance = true;
};
whisper_pointer context;
std::string model_id = "unknown";
std::string backend = "cpu";
std::unordered_map<std::string, track_state> tracks;
void emit(const json & value) {
std::cout << value.dump() << '\n' << std::flush;
}
void diagnostic(const std::string & state, const std::string & message) {
emit({{"type", "diagnostic"}, {"state", state}, {"message", message}});
}
bool read_exact(char * destination, std::size_t size) {
std::cin.read(destination, static_cast<std::streamsize>(size));
return static_cast<std::size_t>(std::cin.gcount()) == size;
}
bool read_packet(json & metadata, std::vector<std::uint8_t> & pcm) {
std::uint8_t header[8];
if (!read_exact(reinterpret_cast<char *>(header), sizeof(header))) return false;
auto read_u32 = [](const std::uint8_t * value) {
return static_cast<std::uint32_t>(value[0]) | (static_cast<std::uint32_t>(value[1]) << 8U) |
(static_cast<std::uint32_t>(value[2]) << 16U) | (static_cast<std::uint32_t>(value[3]) << 24U);
};
const auto json_size = read_u32(header);
const auto pcm_size = read_u32(header + 4);
if (json_size == 0 || json_size > 64 * 1024 || pcm_size > 5 * 32000 || pcm_size % 2 != 0) {
diagnostic("packet_rejected", "Worker packet exceeded its framing limits.");
return false;
}
std::string body(json_size, '\0');
pcm.resize(pcm_size);
if (!read_exact(body.data(), body.size()) || (pcm_size > 0 && !read_exact(reinterpret_cast<char *>(pcm.data()), pcm.size()))) return false;
metadata = json::parse(body, nullptr, false);
if (metadata.is_discarded() || !metadata.is_object()) {
diagnostic("packet_rejected", "Worker metadata was not valid JSON.");
return true;
}
return true;
}
std::string model_path(const json & model) {
for (const char * key : {"path", "file", "filename"}) {
if (model.contains(key) && model[key].is_string()) return model[key].get<std::string>();
}
return {};
}
void load_model(const json & packet) {
const auto model = packet.value("model", json::object());
const auto path = model_path(model);
if (path.empty()) {
emit({{"type", "model_error"}, {"code", "MODEL_PATH_REQUIRED"}, {"message", "The model packet did not include a local path."}});
return;
}
auto parameters = whisper_context_default_params();
context.reset(whisper_init_from_file_with_params(path.c_str(), parameters));
if (!context) {
emit({{"type", "model_error"}, {"code", "MODEL_LOAD_FAILED"}, {"message", "whisper.cpp could not load the selected model."}});
return;
}
model_id = model.value("id", "unknown");
#if defined(LUMI_BACKEND_CUDA)
backend = "cuda";
#else
backend = "cpu";
#endif
emit({{"type", "model_loaded"}, {"model_id", model_id}, {"backend", backend}});
}
std::string transcribe(const std::vector<float> & samples, double & inference_ms) {
if (!context) return {};
auto parameters = whisper_full_default_params(WHISPER_SAMPLING_GREEDY);
parameters.print_realtime = false;
parameters.print_progress = false;
parameters.print_timestamps = false;
parameters.print_special = false;
parameters.translate = false;
parameters.no_context = true;
parameters.single_segment = true;
parameters.language = "en";
parameters.n_threads = std::max(1U, std::min(4U, std::thread::hardware_concurrency()));
const auto started = clock_type::now();
const auto result = whisper_full(context.get(), parameters, samples.data(), static_cast<int>(samples.size()));
inference_ms = std::chrono::duration<double, std::milli>(clock_type::now() - started).count();
if (result != 0) return {};
std::string text;
const int segments = whisper_full_n_segments(context.get());
for (int index = 0; index < segments; ++index) text += whisper_full_get_segment_text(context.get(), index);
const auto first = text.find_first_not_of(" \t\r\n");
const auto last = text.find_last_not_of(" \t\r\n");
return first == std::string::npos ? std::string{} : text.substr(first, last - first + 1);
}
void decode_track(const std::string & session_id, const std::string & track_id, track_state & track, bool final) {
if (!context || track.samples.size() < min_decode_samples) return;
double inference_ms = 0;
const auto text = transcribe(track.samples, inference_ms);
if (!text.empty()) {
const bool incomplete = !final && std::isalnum(static_cast<unsigned char>(text.back())) != 0;
emit({
{"type", "hypothesis"}, {"session_id", session_id}, {"track_id", track_id}, {"text", text},
{"final", final}, {"new_utterance", track.new_utterance}, {"incomplete_word", incomplete},
{"audio_start_us", track.audio_start_us}, {"audio_end_us", track.audio_end_us},
{"inference_ms", inference_ms}, {"model_id", model_id}, {"backend", backend}
});
track.new_utterance = false;
}
track.last_decode_us = track.audio_end_us;
if (final) {
track.samples.clear();
track.voiced = false;
track.new_utterance = true;
track.audio_start_us = 0;
}
}
void audio(const json & packet, const std::vector<std::uint8_t> & pcm) {
if (!context || pcm.empty()) return;
const auto session_id = packet.value("session_id", "");
const auto track_id = packet.value("track_id", "");
if (session_id.empty() || track_id.empty()) return;
auto & track = tracks[session_id + "\n" + track_id];
const auto captured_us = packet.value("capture_timestamp_us", std::uint64_t{0});
if (track.samples.empty()) track.audio_start_us = captured_us;
const auto count = pcm.size() / 2;
double squares = 0;
for (std::size_t index = 0; index < count; ++index) {
const auto sample = static_cast<std::int16_t>(static_cast<std::uint16_t>(pcm[index * 2]) |
(static_cast<std::uint16_t>(pcm[index * 2 + 1]) << 8U));
const float normalized = static_cast<float>(sample) / 32768.0F;
track.samples.push_back(normalized);
squares += static_cast<double>(normalized) * normalized;
}
if (track.samples.size() > max_samples) {
const auto removed = track.samples.size() - max_samples;
track.samples.erase(track.samples.begin(), track.samples.begin() + static_cast<std::ptrdiff_t>(removed));
track.audio_start_us += static_cast<std::uint64_t>(removed * 1000000ULL / sample_rate);
}
const auto duration_us = static_cast<std::uint64_t>(count * 1000000ULL / sample_rate);
track.audio_end_us = captured_us + duration_us;
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; }
const bool final = track.voiced && 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;
if (final || (track.voiced && decode_due)) decode_track(session_id, track_id, track, final);
}
void handle(const json & packet, const std::vector<std::uint8_t> & pcm, bool & running) {
const auto type = packet.value("type", "");
if (type == "load_model") load_model(packet);
else if (type == "benchmark") emit({{"type", "benchmark"}, {"accepted", context != nullptr}, {"model_id", model_id}, {"backend", backend}});
else if (type == "start_session") emit({{"type", "session_started"}, {"session_id", packet.value("session", json::object()).value("id", "")}});
else if (type == "add_track") tracks.try_emplace(packet.value("session_id", "") + "\n" + packet.value("track", json::object()).value("source_uuid", ""));
else if (type == "audio") audio(packet, pcm);
else if (type == "remove_track") tracks.erase(packet.value("session_id", "") + "\n" + packet.value("track_id", ""));
else if (type == "stop_session") {
const auto prefix = packet.value("session_id", "") + "\n";
std::erase_if(tracks, [&](const auto & item) { return item.first.starts_with(prefix); });
}
else if (type == "shutdown") running = false;
else diagnostic("packet_rejected", "Unknown worker message type.");
}
}
int main() {
std::ios::sync_with_stdio(false);
whisper_log_set([](enum ggml_log_level, const char *, void *) {}, nullptr);
emit({{"type", "ready"}, {"protocol_version", 1}, {"backend", backend}});
bool running = true;
while (running && std::cin.good()) {
json packet;
std::vector<std::uint8_t> pcm;
if (!read_packet(packet, pcm)) break;
if (!packet.is_discarded()) handle(packet, pcm, running);
}
tracks.clear();
context.reset();
return 0;
}

View File

@ -44,6 +44,13 @@ module.exports = {
const models = new ArtifactManager(dataPath("models"));
const runtimeArchives = new ArtifactManager(dataPath("tmp"));
const runtimes = new ArtifactManager(dataPath("runtime"));
const selectedModelId = revisions.list().selected_model_id?.value;
const selectedModel = modelManifest.models.find((entry) => entry.id === selectedModelId);
if (supervisor.executable && selectedModel) {
const selectedStatus = models.status(selectedModel);
if (selectedStatus.valid) provider.loadModel({ ...selectedModel, path: selectedStatus.path })
.catch((error) => diagnosticLog.append({ kind: "worker", state: "model_restore_failed", model_id: selectedModel.id, message: error.message }));
}
const router = web.createRouter();
router.use("/assets", express.static(path.join(__dirname, "public")));
@ -101,6 +108,19 @@ module.exports = {
try { res.status(201).json({ ok: true, status: await models.download(entry, { confirmed: req.body.confirmed === true }) }); }
catch (error) { res.status(400).json({ ok: false, error: error.message }); }
});
router.post("/api/models/:id/load", requireAdmin, async (req, res) => {
const entry = modelManifest.models.find((candidate) => candidate.id === req.params.id);
if (!entry) return res.status(404).json({ ok: false, error: "Model was not found." });
const status = models.status(entry);
if (!status.valid) return res.status(409).json({ ok: false, error: "Download and verify this model before loading it." });
try {
const providerStatus = await provider.loadModel({ ...entry, path: status.path });
const current = revisions.list().selected_model_id;
revisions.apply([{ key: "selected_model_id", value: entry.id, base_revision: current?.revision || 0 }], req.session.user.id);
res.json({ ok: true, status: providerStatus });
}
catch (error) { res.status(500).json({ ok: false, error: error.message }); }
});
router.post("/api/runtime/:id/install", requireAdmin, async (req, res) => {
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." });

View File

@ -1 +1,2 @@
.transcription-shell{--accent:#85e3c4;--warn:#f5c56b;max-width:1120px;margin:0 auto;padding:clamp(1rem,3vw,2.5rem);display:grid;gap:1.35rem}.transcription-shell section{padding:1.35rem 0;border-top:1px solid color-mix(in srgb,currentColor 16%,transparent)}.transcription-hero{display:flex;justify-content:space-between;align-items:flex-start;gap:2rem;padding:1rem 0 2rem}.transcription-hero h1{font-size:clamp(2rem,5vw,4.25rem);line-height:.95;margin:.25rem 0 1rem}.transcription-hero p:not(.eyebrow){max-width:67ch;opacity:.78}.eyebrow{text-transform:uppercase;letter-spacing:.13em;font-size:.72rem;font-weight:750;opacity:.65;margin:0}.health-pill{display:inline-flex;align-items:center;gap:.55rem;padding:.55rem .8rem;border-radius:999px;white-space:nowrap;background:color-mix(in srgb,var(--warn) 16%,transparent)}.health-pill span{width:.65rem;height:.65rem;border-radius:50%;background:var(--warn)}.health-pill.is-ready{background:color-mix(in srgb,var(--accent) 16%,transparent)}.health-pill.is-ready span{background:var(--accent)}.section-heading{display:flex;justify-content:space-between;align-items:end;gap:1rem;margin-bottom:1rem}.section-heading h2{margin:.2rem 0 0;font-size:1.35rem}.setup-path ol{display:grid;grid-template-columns:repeat(4,1fr);list-style:none;padding:0;margin:0 0 1.25rem;counter-reset:steps}.setup-path li{counter-increment:steps;padding:0 1rem 1rem 2.25rem;position:relative;opacity:.62}.setup-path li:before{content:counter(steps);position:absolute;left:0;top:-.2rem;width:1.6rem;height:1.6rem;border:1px solid currentColor;border-radius:50%;display:grid;place-items:center;font-size:.75rem}.setup-path li.is-current{opacity:1}.setup-path li.is-current:before{background:var(--accent);color:#10251f;border-color:var(--accent)}.setup-path li span,.plain-list span{display:block;font-size:.88rem;opacity:.7;margin-top:.25rem}.primary-action{border:0;border-radius:.6rem;background:var(--accent);color:#10251f;font-weight:750;padding:.7rem 1rem;cursor:pointer}.primary-action:disabled{opacity:.55;cursor:wait}.inline-status{display:inline;margin-left:.8rem}.transcription-grid{display:grid;grid-template-columns:1fr 1fr;gap:2.5rem}.health-list{margin:0}.health-list div{display:flex;justify-content:space-between;gap:1rem;padding:.55rem 0}.health-list dt{opacity:.65}.notice,.privacy-note,.empty-state{padding:1rem;border-radius:.65rem;background:color-mix(in srgb,currentColor 6%,transparent);font-size:.9rem}.plain-list{list-style:none;padding:0;margin:0}.plain-list li{display:flex;justify-content:space-between;gap:1rem;padding:.7rem 0}.plain-list code{font-size:.72rem;opacity:.6}.model-row{display:grid;gap:.5rem}.model-row article{display:flex;justify-content:space-between;align-items:center;gap:1rem;padding:.9rem 0}.model-row h3,.model-row p{margin:0}.model-row p{font-size:.88rem;opacity:.7;margin-top:.2rem}.state-label{font-size:.78rem;font-weight:700;white-space:nowrap}@media(max-width:760px){.transcription-hero,.transcription-grid{display:grid;grid-template-columns:1fr}.setup-path ol{grid-template-columns:1fr}.health-pill{justify-self:start}.plain-list li{display:block}.plain-list code{display:block;margin-top:.5rem;overflow-wrap:anywhere}}
.model-actions{display:flex;align-items:center;justify-content:flex-end;gap:.65rem}.model-actions button{border:1px solid color-mix(in srgb,currentColor 24%,transparent);border-radius:.55rem;background:transparent;color:inherit;padding:.5rem .7rem;cursor:pointer}.model-actions button:disabled{opacity:.55;cursor:wait}@media(max-width:760px){.model-row article{align-items:flex-start}.model-actions{align-items:flex-end;flex-direction:column}}

View File

@ -19,4 +19,26 @@
} catch (error) { status.textContent = error.message; }
finally { button.disabled = false; }
});
root.addEventListener("click", async (event) => {
const download = event.target.closest("[data-download-model]");
const load = event.target.closest("[data-load-model]");
if (!download && !load) return;
const modelId = download?.dataset.downloadModel || load?.dataset.loadModel;
if (download && !window.confirm(`Download ${download.dataset.modelLabel} (${download.dataset.modelSize} MiB) to the Lumi host?`)) return;
const action = download ? "download" : "load";
const target = download || load;
target.disabled = true;
status.textContent = download ? "Downloading and verifying the model…" : "Loading the model into whisper.cpp…";
try {
const response = await fetch(`/plugins/lumi_transcription/api/models/${encodeURIComponent(modelId)}/${action}`, {
method: "POST", headers: { "Content-Type": "application/json", Accept: "application/json" },
body: download ? JSON.stringify({ confirmed: true }) : "{}"
});
const result = await response.json();
if (!response.ok) throw new Error(result.error || `Model ${action} failed.`);
status.textContent = download ? "Model verified. You can load it now." : "Model loaded. Run the host benchmark before live use.";
setTimeout(() => window.location.reload(), 700);
} catch (error) { status.textContent = error.message; target.disabled = false; }
});
})();

View File

@ -3,6 +3,13 @@
"provider": "whisper.cpp",
"tested_version": "v1.9.1",
"tested_commit": "f049fff95a089aa9969deb009cdd4892b3e74916",
"worker": {
"source": "backend/transcription/worker-native",
"executable": "lumi-whisper-worker.exe",
"protocol_version": 1,
"cuda_cmake_option": "LUMI_WHISPER_CUDA",
"whisper_commit": "f049fff95a089aa9969deb009cdd4892b3e74916"
},
"artifacts": [
{
"id": "windows-x64-cuda-12.4",

View File

@ -18,7 +18,7 @@ const { ArtifactManager, sha256File } = require("../backend/models/artifact_mana
const { BoundedQueue, SequenceTracker, RollingPcmBuffer } = require("../backend/sessions/bounded_queue");
const { SessionCoordinator } = require("../backend/sessions/session_coordinator");
const { CaptionStabilizer, LatestCaptionGate } = require("../backend/transcription/stabilizer");
const { WhisperWorkerSupervisor } = require("../backend/transcription/provider");
const { WhisperWorkerSupervisor, WhisperCppServerProvider } = require("../backend/transcription/provider");
const plugin = require("../index");
const { createWebUpgradeRegistry } = require("../../../src/services/web-upgrades");
@ -32,10 +32,11 @@ async function run() {
verifyStabilization();
await verifySessionLifecycle();
await verifyWorkerRestart();
await verifyNativeWorkerBoundary();
await verifyAuthenticatedGateway();
verifyArtifactsAndLogs(temp);
await verifyPluginIsolation();
console.log("Lumi transcription verification passed: protocol, pairing, revocation, revisions, queues, stabilization, lifecycle, worker recovery, artifacts, logs, and plugin isolation.");
console.log("Lumi transcription verification passed: protocol, pairing, revocation, revisions, queues, stabilization, lifecycle, native worker boundary, worker recovery, artifacts, logs, and plugin isolation.");
} finally { fs.rmSync(temp, { recursive: true, force: true }); }
}
@ -169,6 +170,26 @@ async function verifyWorkerRestart() {
supervisor.stopping = true; children[1].emit("exit", 0, null);
}
async function verifyNativeWorkerBoundary() {
class Worker extends EventEmitter {
start() { this.started = true; }
send(message) { if (message.type === "load_model") setImmediate(() => this.emit("message", { type: "model_loaded", model_id: message.model.id, backend: "cpu" })); return true; }
health() { return { healthy: true, state: "running" }; }
async stop() {}
}
const worker = new Worker();
const provider = new WhisperCppServerProvider(worker);
const health = await provider.loadModel({ id: "base.en", path: "verified-model.bin" });
assert.equal(worker.started, true);
assert.equal(health.model, "base.en");
const sourceRoot = path.join(__dirname, "../backend/transcription/worker-native");
const cmake = fs.readFileSync(path.join(sourceRoot, "CMakeLists.txt"), "utf8");
const source = fs.readFileSync(path.join(sourceRoot, "src/main.cpp"), "utf8");
assert.match(cmake, /f049fff95a089aa9969deb009cdd4892b3e74916/);
assert.match(source, /max_samples = sample_rate \* 6/);
assert.doesNotMatch(source, /ofstream|fwrite|WriteAllBytes/);
}
async function verifyAuthenticatedGateway() {
const db = new Database(":memory:");
const devices = new DeviceStore(db);

View File

@ -50,7 +50,14 @@
<div class="section-heading"><div><p class="eyebrow">Curated choices</p><h2 id="models-title">Speech models</h2></div></div>
<div class="model-row">
<% models.forEach((model) => { %>
<article><div><h3><%= model.label %></h3><p><%= model.recommended ? 'Recommended starting point' : 'Fallback option' %> · <%= Math.round(model.bytes / 1048576) %> MiB</p></div><span class="state-label"><%= model.status.valid ? 'Verified' : model.status.installed ? 'Checksum failed' : 'Not installed' %></span></article>
<article>
<div><h3><%= model.label %></h3><p><%= model.recommended ? 'Recommended starting point' : 'Fallback option' %> · <%= Math.round(model.bytes / 1048576) %> MiB</p></div>
<div class="model-actions">
<span class="state-label"><%= providerHealth.model === model.id ? 'Loaded' : model.status.valid ? 'Verified' : model.status.installed ? 'Checksum failed' : 'Not installed' %></span>
<% if (!model.status.valid) { %><button 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 type="button" data-load-model="<%= model.id %>">Load model</button><% } %>
</div>
</article>
<% }) %>
</div>
<p class="privacy-note"><strong>Privacy:</strong> raw audio is held only in bounded memory and is never written to disk by default. Diagnostic caption text is retained for seven days unless disabled.</p>