From 2f31390602954708dc0ace7c2c0c1e7fa1b2bba7 Mon Sep 17 00:00:00 2001 From: Franz Rolfsvaag Date: Wed, 22 Jul 2026 11:39:05 +0200 Subject: [PATCH] Build Lumi Companion shell and whisper worker --- TODO.md | 19 +- companion/Lumi.Companion.sln | 101 +++++ companion/README.md | 12 +- .../ObsBridgePipe.cs | 22 +- companion/src/Lumi.Companion.App/App.axaml | 94 ++++ companion/src/Lumi.Companion.App/App.axaml.cs | 86 ++++ .../src/Lumi.Companion.App/CompanionPaths.cs | 11 + .../Lumi.Companion.App/CompanionRuntime.cs | 422 ++++++++++++++++++ .../CompanionSettingsStore.cs | 54 +++ .../src/Lumi.Companion.App/CompanionState.cs | 34 +- .../src/Lumi.Companion.App/DecisionWindow.cs | 31 ++ .../Lumi.Companion.App.csproj | 21 +- .../src/Lumi.Companion.App/LumiIconFactory.cs | 52 +++ .../src/Lumi.Companion.App/MainWindow.axaml | 211 +++++++++ .../Lumi.Companion.App/MainWindow.axaml.cs | 253 +++++++++++ companion/src/Lumi.Companion.App/Program.cs | 63 +-- .../SingleInstanceCoordinator.cs | 62 +++ companion/src/Lumi.Companion.App/app.manifest | 15 + .../Lumi.Companion.Core/CompanionSocket.cs | 44 +- docs/lumi-companion-transcription.md | 6 +- knowledge/plugins/lumi-transcription.md | 163 +++++++ .../backend/transcription/provider.js | 15 +- .../worker-native/CMakeLists.txt | 37 ++ .../transcription/worker-native/README.md | 12 + .../transcription/worker-native/src/main.cpp | 219 +++++++++ plugins/lumi_transcription/index.js | 20 + .../public/transcription.css | 1 + .../public/transcription.js | 22 + .../lumi_transcription/runtime_manifest.json | 7 + plugins/lumi_transcription/tests/verify.js | 25 +- plugins/lumi_transcription/views/settings.ejs | 9 +- 31 files changed, 2093 insertions(+), 50 deletions(-) create mode 100644 companion/Lumi.Companion.sln create mode 100644 companion/src/Lumi.Companion.App/App.axaml create mode 100644 companion/src/Lumi.Companion.App/App.axaml.cs create mode 100644 companion/src/Lumi.Companion.App/CompanionPaths.cs create mode 100644 companion/src/Lumi.Companion.App/CompanionRuntime.cs create mode 100644 companion/src/Lumi.Companion.App/CompanionSettingsStore.cs create mode 100644 companion/src/Lumi.Companion.App/DecisionWindow.cs create mode 100644 companion/src/Lumi.Companion.App/LumiIconFactory.cs create mode 100644 companion/src/Lumi.Companion.App/MainWindow.axaml create mode 100644 companion/src/Lumi.Companion.App/MainWindow.axaml.cs create mode 100644 companion/src/Lumi.Companion.App/SingleInstanceCoordinator.cs create mode 100644 companion/src/Lumi.Companion.App/app.manifest create mode 100644 knowledge/plugins/lumi-transcription.md create mode 100644 plugins/lumi_transcription/backend/transcription/worker-native/CMakeLists.txt create mode 100644 plugins/lumi_transcription/backend/transcription/worker-native/README.md create mode 100644 plugins/lumi_transcription/backend/transcription/worker-native/src/main.cpp diff --git a/TODO.md b/TODO.md index c59bf3b..2b4e9c2 100644 --- a/TODO.md +++ b/TODO.md @@ -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) diff --git a/companion/Lumi.Companion.sln b/companion/Lumi.Companion.sln new file mode 100644 index 0000000..3463a21 --- /dev/null +++ b/companion/Lumi.Companion.sln @@ -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 diff --git a/companion/README.md b/companion/README.md index 7124691..4859bb5 100644 --- a/companion/README.md +++ b/companion/README.md @@ -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. diff --git a/companion/plugins/Lumi.Companion.Transcription/ObsBridgePipe.cs b/companion/plugins/Lumi.Companion.Transcription/ObsBridgePipe.cs index bff2baf..b6ee5b3 100644 --- a/companion/plugins/Lumi.Companion.Transcription/ObsBridgePipe.cs +++ b/companion/plugins/Lumi.Companion.Transcription/ObsBridgePipe.cs @@ -12,13 +12,16 @@ public sealed class ObsBridgePipe : IAsyncDisposable private readonly Func _onMessage; private readonly Func, 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 onMessage, Func, Task> onAudio) { _pipeName = $"Lumi.Companion.ObsBridge.v1.{userSidHash}"; _onMessage = onMessage; _onAudio = onAudio; } + public event Action? 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 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 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(); } } diff --git a/companion/src/Lumi.Companion.App/App.axaml b/companion/src/Lumi.Companion.App/App.axaml new file mode 100644 index 0000000..e638259 --- /dev/null +++ b/companion/src/Lumi.Companion.App/App.axaml @@ -0,0 +1,94 @@ + + + #182026 + #5A6872 + #176B75 + #E58B2B + #23845B + #A96612 + #BD4D4D + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/companion/src/Lumi.Companion.App/App.axaml.cs b/companion/src/Lumi.Companion.App/App.axaml.cs new file mode 100644 index 0000000..0b10384 --- /dev/null +++ b/companion/src/Lumi.Companion.App/App.axaml.cs @@ -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(); + } +} diff --git a/companion/src/Lumi.Companion.App/CompanionPaths.cs b/companion/src/Lumi.Companion.App/CompanionPaths.cs new file mode 100644 index 0000000..e22c623 --- /dev/null +++ b/companion/src/Lumi.Companion.App/CompanionPaths.cs @@ -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")); + } +} diff --git a/companion/src/Lumi.Companion.App/CompanionRuntime.cs b/companion/src/Lumi.Companion.App/CompanionRuntime.cs new file mode 100644 index 0000000..b6df2de --- /dev/null +++ b/companion/src/Lumi.Companion.App/CompanionRuntime.cs @@ -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? _audioSignal; + private TaskCompletionSource? _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 TestStages { get; private set; } + public IReadOnlyList ObsSources { get; private set; } + public event Action? StateChanged; + public event Action>? TestStagesChanged; + public event Action>? ObsSourcesChanged; + public event Action? CaptionReceived; + public event Action? 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 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 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(); + } +} diff --git a/companion/src/Lumi.Companion.App/CompanionSettingsStore.cs b/companion/src/Lumi.Companion.App/CompanionSettingsStore.cs new file mode 100644 index 0000000..a34d384 --- /dev/null +++ b/companion/src/Lumi.Companion.App/CompanionSettingsStore.cs @@ -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? Changed; + + public async Task LoadAsync(CancellationToken cancellationToken = default) + { + if (!File.Exists(_path)) return; + try + { + Current = JsonSerializer.Deserialize(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); + } +} diff --git a/companion/src/Lumi.Companion.App/CompanionState.cs b/companion/src/Lumi.Companion.App/CompanionState.cs index 1204ffc..e993039 100644 --- a/companion/src/Lumi.Companion.App/CompanionState.cs +++ b/companion/src/Lumi.Companion.App/CompanionState.cs @@ -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; +} diff --git a/companion/src/Lumi.Companion.App/DecisionWindow.cs b/companion/src/Lumi.Companion.App/DecisionWindow.cs new file mode 100644 index 0000000..e8cb94a --- /dev/null +++ b/companion/src/Lumi.Companion.App/DecisionWindow.cs @@ -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 } } + } + }; + } +} diff --git a/companion/src/Lumi.Companion.App/Lumi.Companion.App.csproj b/companion/src/Lumi.Companion.App/Lumi.Companion.App.csproj index fc376c1..cc6274e 100644 --- a/companion/src/Lumi.Companion.App/Lumi.Companion.App.csproj +++ b/companion/src/Lumi.Companion.App/Lumi.Companion.App.csproj @@ -1 +1,20 @@ -Exenet8.0-windowsenableenable + + + WinExe + net8.0-windows + enable + enable + app.manifest + 0.1.0-experimental.1 + 0.1.0.0 + + + + + + + + + + + diff --git a/companion/src/Lumi.Companion.App/LumiIconFactory.cs b/companion/src/Lumi.Companion.App/LumiIconFactory.cs new file mode 100644 index 0000000..1c1be70 --- /dev/null +++ b/companion/src/Lumi.Companion.App/LumiIconFactory.cs @@ -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; + } +} diff --git a/companion/src/Lumi.Companion.App/MainWindow.axaml b/companion/src/Lumi.Companion.App/MainWindow.axaml new file mode 100644 index 0000000..87f34d7 --- /dev/null +++ b/companion/src/Lumi.Companion.App/MainWindow.axaml @@ -0,0 +1,211 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + <% } %> + <% if (model.status.valid && providerHealth.model !== model.id) { %><% } %> + + <% }) %>

Privacy: 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.