Compare commits

...

23 Commits

Author SHA1 Message Date
Franz Rolfsvaag
9bfca09be5 Pin stable Companion release artifacts 2026-07-24 14:45:50 +02:00
Franz Rolfsvaag
19f599b2b5 Release Lumi Companion and server plugins 2026-07-24 14:44:27 +02:00
Franz Rolfsvaag
7760ed0a3f Accelerate server transcription and finalize tests 2026-07-23 09:12:28 +02:00
Franz Rolfsvaag
63e52e3f6a Refresh transcription plugin knowledge 2026-07-22 21:42:29 +02:00
Franz Rolfsvaag
fc5a9029ea Deduplicate OBS source state traffic 2026-07-22 21:41:03 +02:00
Franz Rolfsvaag
27ae8dcb81 Bound OBS source inventory bursts 2026-07-22 21:36:50 +02:00
Franz Rolfsvaag
d87fe397ce Refresh transcription plugin knowledge 2026-07-22 21:33:30 +02:00
Franz Rolfsvaag
2e09754b9e Prevent audio rate limits from closing Companion 2026-07-22 21:29:27 +02:00
Franz Rolfsvaag
b2c9d4b8ac Refresh Companion plugin knowledge 2026-07-22 21:19:11 +02:00
Franz Rolfsvaag
eb127eadeb Fix installed OBS integration discovery 2026-07-22 21:18:19 +02:00
Franz Rolfsvaag
739e96eee6 Preserve Companion identity during installation 2026-07-22 21:03:17 +02:00
Franz Rolfsvaag
88d9718b8d Refresh Companion plugin knowledge 2026-07-22 21:00:20 +02:00
Franz Rolfsvaag
7c571364c4 Install Companion and manage paired access 2026-07-22 20:57:50 +02:00
Franz Rolfsvaag
2e6de64e61 Make transcription readiness reusable 2026-07-22 20:35:45 +02:00
Franz Rolfsvaag
16f5f1fbb6 Add transcription accuracy and latency tests 2026-07-22 18:27:05 +02:00
Franz Rolfsvaag
df23b57078 Keep transcription worker failures recoverable 2026-07-22 17:35:27 +02:00
Franz Rolfsvaag
cc8d455b4e Refresh transcription route knowledge 2026-07-22 15:51:53 +02:00
Franz Rolfsvaag
02bdec7584 Add Companion updates and managed OBS bridge 2026-07-22 15:47:56 +02:00
Franz Rolfsvaag
37dda538b3 Fix local Companion package and worker setup 2026-07-22 14:34:29 +02:00
Franz Rolfsvaag
27555e258e Integrate Companion into Lumi admin 2026-07-22 14:18:20 +02:00
Franz Rolfsvaag
2f31390602 Build Lumi Companion shell and whisper worker 2026-07-22 11:39:05 +02:00
Franz Rolfsvaag
29cd4a0365 Add experimental Companion and OBS bridge boundaries 2026-07-22 11:02:04 +02:00
Franz Rolfsvaag
134d3efa74 Add Lumi transcription server and protocol foundation 2026-07-22 11:01:49 +02:00
156 changed files with 16124 additions and 53 deletions

View File

@ -37,3 +37,15 @@ AUTO_UPDATE_ENABLED=false
AUTO_UPDATE_INTERVAL_MINUTES=60 AUTO_UPDATE_INTERVAL_MINUTES=60
GIT_REMOTE=origin GIT_REMOTE=origin
GIT_BRANCH=main GIT_BRANCH=main
# Lumi host operator identity shown in generated Companion pairing packages.
# Set these when distributing Companion from a self-hosted Lumi installation.
LUMI_OPERATOR_NAME=
LUMI_OPERATOR_CONTACT=
LUMI_OPERATOR_PRIVACY_URL=
# Runtime mode and bind host
# Lumi auto-detects development mode from NODE_ENV, a loopback bind host, or a
# source checkout. Explicit overrides always win.
# LUMI_DEV_MODE=true
# LUMI_HOST=127.0.0.1

7
.gitignore vendored
View File

@ -16,6 +16,12 @@ plugins/*/data/**
*.sqlite-* *.sqlite-*
npm-debug.log npm-debug.log
/dist/ /dist/
companion/**/bin/
companion/**/obj/
companion/installer/output/
companion/src/Lumi.Companion.App/components/obs-bridge/
companion/**/logs/
*.lumi-pairing.json
security-audit-*.json security-audit-*.json
security-audit-*.md security-audit-*.md
taskfile.txt taskfile.txt
@ -25,3 +31,4 @@ Twitch.png
twitch-credentials-lumi.png twitch-credentials-lumi.png
.secrets .secrets
.secrets-* .secrets-*
DEVNOTES.md

View File

@ -1,5 +1,13 @@
# Lumi changelog # Lumi changelog
## 0.2.26
- Added stable Lumi Companion distribution with a durable per-user installer, shared paired-device authentication, managed OBS integration, preserved identity, user-approved updates, complete legal notices, and matching application/tray branding.
- Added server-hosted transcription with voice-free readiness checks, dedicated confidence and latency benchmarks, resilient worker recovery, detailed WebUI analysis, and no speech inference on the streaming computer.
- Added Spotify-backed Song Overlay capture, transparent and live-updating OBS rendering, configurable announcements, and a shared-template `!music` command.
- Made stable core updates snapshot, synchronize, verify, and restore bundled plugins as one transaction while preserving plugin data and local-only plugins, so hosts on `0.2.25` receive the full release in one update.
- Stopped notification-area flicker by caching health icons, coalescing queued runtime state, and avoiding unchanged native icon/menu assignments.
## 0.2.25 ## 0.2.25
- Added verified, snapshot-backed production deployment switching between `main` and exact `experimental-*` branches, including bundled plugin code, preserved plugin data, automatic failed-apply restoration, and graceful wrapper restarts. - Added verified, snapshot-backed production deployment switching between `main` and exact `experimental-*` branches, including bundled plugin code, preserved plugin data, automatic failed-apply restoration, and graceful wrapper restarts.

View File

@ -55,6 +55,11 @@ You can set these in `.env` or change role IDs in **Admin → Settings**.
Use **Admin → Plugins** to install, enable, update, or uninstall plugins. Use **Admin → Plugins** to install, enable, update, or uninstall plugins.
You can also create a local plugin from the WebUI. You can also create a local plugin from the WebUI.
The experimental `experimental-companion` branch includes the independent Lumi
Companion transcription foundation. Its current scope, trust boundaries, setup,
and unverified target-machine work are documented in
[`docs/lumi-companion-transcription.md`](docs/lumi-companion-transcription.md).
## Updates and recovery ## Updates and recovery
Use **Admin → Updates** for version-aware core and plugin updates. Lumi reads Use **Admin → Updates** for version-aware core and plugin updates. Lumi reads

83
TODO.md
View File

@ -2,6 +2,89 @@
This file tracks larger Lumi work that cannot safely be completed in one pass. Keep pending work under the relevant category and move completed items to the Done section with a short note. This file tracks larger Lumi work that cannot safely be completed in one pass. Keep pending work under the relevant category and move completed items to the Done section with a short note.
## Lumi Companion transcription — experimental-companion (2026-07-22)
Foundation implemented locally: independent `lumi_transcription` plugin; generic
core WebSocket-upgrade capability; single-use pairing and revocable devices;
HTTPS/WSS device transport with exact localhost-origin HTTP allowed only for
locally generated pairing packages; 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. 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. The Transcription
settings now render inside the shared Lumi shell, and Admin shows a compact
Companion health/download section beneath its shortcut cards. A checksum-pinned
self-contained Windows bundle includes a one-time pairing file and pairs after
extraction without a separate import workflow. The pinned CPU whisper worker now
has a reproducible Windows build/install script and is discovered automatically;
the production CUDA build and target benchmark remain pending below. Companion
experimental.3 adds user-approved, checksum-verified, stream-safe in-place updates;
a reproducible native OBS 31+ bridge build; one-click UAC install/repair/removal;
selected-source PCM capture; nested Program-scene evaluation; bounded same-user
IPC; bridge health; and native caption submission. Experimental.2 must be replaced
manually once to bootstrap the updater.
Experimental.4 contains Windows speech-path reliability fixes: native PCM stdin
is explicitly binary-safe, broken worker pipes no longer terminate Lumi, the
worker restarts and reloads the selected model automatically, and the Companion
test reports the server failure at Speech recognition instead of hanging.
Experimental.5 retries and acknowledges the saved OBS source attachment after
reconnects, waits for finalized path-test captions, and adds a separate
user-controlled accuracy/latency test with a ten-second silence cutoff. The host
retains per-word latency/confidence inspection data for one hour, while paired
device management now defaults to active credentials and purges revoked history
after 30 days.
Experimental.6 makes the full-path readiness check voice-free, preserves a valid
pass across restarts until a relevant source/model/bridge configuration changes,
adds a live dBFS meter to the dedicated speech test, and fixes reusable benchmark
sessions so failed starts cannot leave worker activity behind. Companion now also
reports verified OBS integration and path readiness to the WebUI setup progress.
Experimental.7 replaces the portable paired app download with a durable per-user
Windows installer while preserving the verified in-place updater and DPAPI data.
It also makes OBS bridge repair recoverable with fresh validation and actionable
elevated-process errors, and adds timed permanent device revocation in the WebUI.
Experimental.8 makes the installed single-file build discover and verify its OBS
payload deterministically, adds an executable-level package diagnostic, and shows
OBS maintenance failures directly instead of leaving them only in diagnostics.
Experimental.9 coalesces OBS callbacks into fixed 20 ms network frames and gives
audio and initial OBS source inventory their own soft gateway budgets, so normal
or pathological capture cadence cannot rate-limit and disconnect the Companion
control connection.
Experimental.10 deduplicates OBS source inventories and unchanged source-state
notifications at both ends, removing the high-volume source-update feedback seen
with large scene collections while retaining the non-disconnecting safety budget.
Experimental.11 removes server-side inference backlog, filters whisper.cpp
control tokens, preserves first-visible word latency while retaining finalized
confidence, and makes manual test completion wait for the final worker result.
The portable CUDA 12.4 worker targets RTX 3060 and newer NVIDIA GPUs, activates
from the Lumi WebUI without a host SDK dependency, and retains a CPU
compatibility package. On the RTX 3080 Ti acceptance phrase, `small.en` returned
the exact 19-word transcript at 300 ms average, 106 ms median, and 1,007 ms maximum
first-visible latency.
Release-blocking work remains:
- Repeat the `small.en` latency gate and benchmark quantized `small.en` and
`base.en` on the production RTX 3060.
- Sign the companion installer and add a rollback policy after the experimental
updater has target-machine acceptance evidence.
- Complete target-machine bridge acceptance 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.
- 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) ## Remaining DesignMotionHQ UX work — experimental-ux checkpoint (2026-07-22)
The shared UX foundation and representative settings, navigation, theme, command, The shared UX foundation and representative settings, navigation, theme, command,

View File

@ -0,0 +1,131 @@

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.Abstractions", "src\Lumi.Companion.Abstractions\Lumi.Companion.Abstractions.csproj", "{0B3C2C62-2CCB-43C9-9AF9-769AC7F22285}"
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
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Lumi.Companion.SongOverlay", "plugins\Lumi.Companion.SongOverlay\Lumi.Companion.SongOverlay.csproj", "{F72BAFAD-17D3-4EF2-8690-B3B5646556C2}"
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
{0B3C2C62-2CCB-43C9-9AF9-769AC7F22285}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{0B3C2C62-2CCB-43C9-9AF9-769AC7F22285}.Debug|Any CPU.Build.0 = Debug|Any CPU
{0B3C2C62-2CCB-43C9-9AF9-769AC7F22285}.Debug|x64.ActiveCfg = Debug|Any CPU
{0B3C2C62-2CCB-43C9-9AF9-769AC7F22285}.Debug|x64.Build.0 = Debug|Any CPU
{0B3C2C62-2CCB-43C9-9AF9-769AC7F22285}.Debug|x86.ActiveCfg = Debug|Any CPU
{0B3C2C62-2CCB-43C9-9AF9-769AC7F22285}.Debug|x86.Build.0 = Debug|Any CPU
{0B3C2C62-2CCB-43C9-9AF9-769AC7F22285}.Release|Any CPU.ActiveCfg = Release|Any CPU
{0B3C2C62-2CCB-43C9-9AF9-769AC7F22285}.Release|Any CPU.Build.0 = Release|Any CPU
{0B3C2C62-2CCB-43C9-9AF9-769AC7F22285}.Release|x64.ActiveCfg = Release|Any CPU
{0B3C2C62-2CCB-43C9-9AF9-769AC7F22285}.Release|x64.Build.0 = Release|Any CPU
{0B3C2C62-2CCB-43C9-9AF9-769AC7F22285}.Release|x86.ActiveCfg = Release|Any CPU
{0B3C2C62-2CCB-43C9-9AF9-769AC7F22285}.Release|x86.Build.0 = Release|Any CPU
{F72BAFAD-17D3-4EF2-8690-B3B5646556C2}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{F72BAFAD-17D3-4EF2-8690-B3B5646556C2}.Debug|Any CPU.Build.0 = Debug|Any CPU
{F72BAFAD-17D3-4EF2-8690-B3B5646556C2}.Debug|x64.ActiveCfg = Debug|Any CPU
{F72BAFAD-17D3-4EF2-8690-B3B5646556C2}.Debug|x64.Build.0 = Debug|Any CPU
{F72BAFAD-17D3-4EF2-8690-B3B5646556C2}.Debug|x86.ActiveCfg = Debug|Any CPU
{F72BAFAD-17D3-4EF2-8690-B3B5646556C2}.Debug|x86.Build.0 = Debug|Any CPU
{F72BAFAD-17D3-4EF2-8690-B3B5646556C2}.Release|Any CPU.ActiveCfg = Release|Any CPU
{F72BAFAD-17D3-4EF2-8690-B3B5646556C2}.Release|Any CPU.Build.0 = Release|Any CPU
{F72BAFAD-17D3-4EF2-8690-B3B5646556C2}.Release|x64.ActiveCfg = Release|Any CPU
{F72BAFAD-17D3-4EF2-8690-B3B5646556C2}.Release|x64.Build.0 = Release|Any CPU
{F72BAFAD-17D3-4EF2-8690-B3B5646556C2}.Release|x86.ActiveCfg = Release|Any CPU
{F72BAFAD-17D3-4EF2-8690-B3B5646556C2}.Release|x86.Build.0 = Release|Any CPU
{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
{0B3C2C62-2CCB-43C9-9AF9-769AC7F22285} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B}
{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}
{F72BAFAD-17D3-4EF2-8690-B3B5646556C2} = {07D57EEB-2F50-60C4-C011-FE4FA775C9A8}
EndGlobalSection
EndGlobal

100
companion/README.md Normal file
View File

@ -0,0 +1,100 @@
# Lumi Companion
Lumi Companion is the Windows streaming-computer client for Lumi. It provides a
single paired-device shell for Companion plugins, including server-hosted
transcription and Song Overlay capture. The app is single-instance, installs per
Windows user, closes to the notification area, and preserves pairing and settings
across updates.
## Install and pair
From Lumi, open **Admin > Companion** and choose **Download Companion**. The
private ZIP contains:
- the checksum-pinned per-user Windows installer;
- a one-time `.lumi-pairing.json` package;
- a Host Operator notice and start instructions.
Extract the ZIP and run `Lumi.Companion-Setup.exe`. Setup installs to
`%LocalAppData%\Programs\Lumi Companion`, displays the licence and privacy
notices, identifies the paired Lumi host, imports the adjacent pairing package,
and opens the installed copy. The extracted download can then be deleted.
Existing portable or installed builds retain the current Windows user's
DPAPI-protected device identity, so reinstalling does not create a duplicate
paired device. Pairing packages expire after 15 minutes, work once, and must not
be shared or committed.
## Security boundaries
- Companion plugins inherit the shell's paired-device authentication. Plugins do
not store or request separate Lumi credentials.
- Production Companion traffic requires HTTPS. Plain HTTP/WebSocket traffic is
accepted only when the device was paired from the exact matching loopback Lumi
origin and the request remains on loopback.
- The OBS integration communicates with Companion through a same-user named pipe.
- Transcription audio is normalized to 16 kHz mono signed 16-bit PCM, kept in
bounded memory, and sent to the paired Lumi host. Companion does not perform
local speech recognition.
- Installing or repairing the OBS integration requests elevation only while OBS
is closed and only to copy the verified managed component into OBS's shared
ProgramData plugin directory.
## Updates
Companion checks after connecting and every six hours. Updates are
user-approved, size- and checksum-verified, and refused while OBS is streaming or
recording. Applying an update replaces the installed executable, bundled
components, and legal bundle, then restarts Companion. Pairing credentials,
settings, and plugin state remain in the per-user data directory.
Localhost development builds can also receive checksum-addressed same-version
updates without publishing a release. See
[Localhost development updates](../docs/local-development-updates.md).
## Build and verify
Requirements:
- Windows x64;
- a current .NET SDK with the .NET 8 targeting pack;
- CMake and Visual Studio C++ tools for the native OBS bridge;
- Inno Setup 6 for the installer.
Build the managed solution:
```powershell
dotnet build companion/Lumi.Companion.sln -c Release -p:EnableWindowsTargeting=true
```
Build the pinned OBS bridge, self-contained archive, legal bundle, and installer:
```powershell
companion/scripts/publish-companion.ps1
```
The publish script generates the shared application/tray icon, verifies the OBS
payload inside the published executable, collects NuGet notices, and packages the
corresponding source for the GPL-licensed OBS bridge.
Run Song Overlay's focused verification with:
```powershell
companion/scripts/verify-song-overlay.ps1
```
Generated installers, pairing packages, credentials, build output, and logs must
not be committed.
## Distribution and legal notices
The release is currently unsigned, so Windows may show an unknown-publisher
warning. The installer and installed `legal` directory include the Companion
licence, default privacy notice, third-party notices, exact licence texts, and
the corresponding OBS bridge source bundle.
Self-hosters should set `LUMI_OPERATOR_NAME`, `LUMI_OPERATOR_CONTACT`, and
`LUMI_OPERATOR_PRIVACY_URL`. Those values identify the Host Operator responsible
for its hosted service and server-side processing; OokamiKunTV is the software
developer and is not automatically the operator of an independently hosted Lumi
installation.

View File

@ -0,0 +1,14 @@
# OBS 31+ native caption compatibility spike
Implementation source inspection confirms OBS 31 exposes `obs_output_output_caption_text2` and `obs_frontend_get_streaming_output`. The bridge calls the caption API only from its IPC worker against the active streaming output; it never calls it from an audio/render callback.
This is not Twitch acceptance evidence. The following target-topology test remains mandatory before live delivery can be labelled ready:
1. Build and install the signed bridge through the companion on Windows 11 with OBS 31+.
2. Start a private Twitch stream using the production service/output configuration.
3. Submit incrementally revised text with 1.53 second display durations.
4. Verify the Twitch web player and at least one mobile client expose a user-toggleable CC control.
5. Record whether OBS/Twitch queue, replace, or drop revisions and tune the adapter to prevent stale caption backlog.
6. Capture OBS CPU, render/encoder missed frames, audio dropouts, bridge queue drops, and network throughput before/after.
Until this passes, the companion must allow the exact simulated delivery stream in Test mode but refuse to describe Twitch closed captions as verified. It must never substitute a baked-in open-caption overlay.

View File

@ -0,0 +1,23 @@
# Transcription target-topology acceptance report
Status: not run. Do not replace blank measurements with estimates.
Topology: Windows 11 streaming PC / OBS version ___ / Windows Server 2022 Lumi host / RTX 3060 driver ___ / wired LAN / Twitch test URL or evidence reference ___.
| Measurement | Before | Live transcription | Result |
| --- | ---: | ---: | --- |
| Companion CPU / working set | ___ | ___ | ___ |
| OBS CPU | ___ | ___ | ___ |
| OBS render missed frames | ___ | ___ | ___ |
| OBS encoder missed frames | ___ | ___ | ___ |
| Audio dropouts | ___ | ___ | ___ |
| Bridge queue drops | ___ | ___ | ___ |
| Network throughput | ___ | ___ | ___ |
| Whisper CPU / GPU / VRAM / RAM | n/a | ___ | ___ |
| Model load time | n/a | ___ | ___ |
| Decode latency / real-time factor | n/a | ___ | ___ |
| First stable caption p50 / p95 | n/a | ___ | ___ |
| End-to-end caption p50 / p95 | n/a | ___ | ___ |
| Maximum queue depth / backlog growth | n/a | ___ | ___ |
Confirm: test-mode real path ___; Twitch player toggleable CC ___; stable sustained speech ___; no stale dump after network flap ___; 30-second stream grace/resume ___; raw audio absent from disk ___; fallback activation evidence ___.

View File

@ -0,0 +1,38 @@
# Song Overlay Companion integration
This integration adds a provider-neutral media event source to Lumi Companion. Spotify is the only available provider in this release, but the Companion protocol and runtime isolate provider-specific behavior behind `IMediaProvider`.
## Navigation contract
Every Companion plugin implements `ICompanionPluginContribution` and receives one nested root in both navigation surfaces:
- Tray menu: core actions, `Plugins`, one submenu per plugin, then health/update/log/settings controls and Quit.
- App sidebar: Overview, `PLUGINS`, one expandable root per plugin, then Connection, Logs, and Settings at the bottom.
Plugin actions are caught at the shell boundary so a routine plugin exception does not terminate Companion. The Song Overlay runtime also catches provider callbacks, transport failures, heartbeats, and initialization failures and reports an actionable plugin status.
## Song Overlay flow
1. Windows Global System Media Transport Controls exposes Spotify playback state.
2. The provider raises media, playback, timeline, and session-availability events.
3. The runtime sends only meaningful deltas to Lumi: track change, inferred next/previous, play, resume, pause, stop, seek, metadata enrichment, or a sparse recovery heartbeat.
4. Playback progress is projected from the last event. Continuous progress messages are not sent.
5. Cover art is resized and sent only with track metadata.
6. The existing Lumi `plugins/now_playing` endpoint updates the Song Overlay source and optional chat announcement.
## Build and verification
From the repository root on Windows:
```powershell
dotnet build companion/Lumi.Companion.sln -c Release -p:EnableWindowsTargeting=true
node plugins/now_playing/tests/verify.js
```
Or run:
```powershell
companion/scripts/verify-song-overlay.ps1
```
The optional Spotify Web API connection is used only for metadata enrichment such as exact links and release year. Core playback detection works through Windows without Spotify authorization.

View File

@ -0,0 +1,241 @@
#ifndef AppVersion
#define AppVersion "0.1.0"
#endif
#ifndef SourceRoot
#error SourceRoot must point at the self-contained Companion publish directory.
#endif
#ifndef OutputRoot
#error OutputRoot must point at the installer output directory.
#endif
[Setup]
AppId={{EDEB45E4-41CB-4D7A-A479-4073A1A08B93}
AppName=Lumi Companion
AppVersion={#AppVersion}
AppVerName=Lumi Companion {#AppVersion}
AppPublisher=OokamiKunTV
AppCopyright=Copyright (c) 2026 OokamiKunTV
DefaultDirName={localappdata}\Programs\Lumi Companion
DefaultGroupName=Lumi Companion
DisableProgramGroupPage=yes
PrivilegesRequired=lowest
ArchitecturesAllowed=x64compatible
ArchitecturesInstallIn64BitMode=x64compatible
OutputDir={#OutputRoot}
OutputBaseFilename=Lumi.Companion-Setup
Compression=lzma2/ultra64
SolidCompression=yes
WizardStyle=modern
SetupLogging=yes
CloseApplications=yes
CloseApplicationsFilter=Lumi.Companion.App.exe
RestartApplications=no
UninstallDisplayName=Lumi Companion
UninstallDisplayIcon={app}\Lumi.Companion.App.exe
VersionInfoCompany=OokamiKunTV
VersionInfoCopyright=Copyright (c) 2026 OokamiKunTV
VersionInfoDescription=Lumi Companion installer
VersionInfoProductName=Lumi Companion
VersionInfoProductTextVersion={#AppVersion}
LicenseFile={#SourceRoot}\legal\LUMI-COMPANION-LICENCE.txt
InfoBeforeFile={#SourceRoot}\legal\PRIVACY-NOTICE.txt
InfoAfterFile={#SourceRoot}\legal\THIRD-PARTY-NOTICES.txt
[Files]
Source: "{#SourceRoot}\Lumi.Companion.App.exe"; DestDir: "{app}"; Flags: ignoreversion
Source: "{#SourceRoot}\components\*"; DestDir: "{app}\components"; Flags: ignoreversion recursesubdirs createallsubdirs
Source: "{#SourceRoot}\legal\*"; DestDir: "{app}\legal"; Flags: ignoreversion recursesubdirs createallsubdirs
[Icons]
Name: "{autoprograms}\Lumi Companion"; Filename: "{app}\Lumi.Companion.App.exe"
Name: "{autoprograms}\Lumi Companion - Legal and Privacy"; Filename: "{app}\legal\LEGAL-README.txt"
[Run]
Filename: "{app}\Lumi.Companion.App.exe"; Description: "Open Lumi Companion"; Flags: nowait postinstall skipifsilent
[Code]
var
PairingPackagePath: String;
PairingHost: String;
PairingOperatorName: String;
PairingOperatorContact: String;
PairingPrivacyUrl: String;
HostAcknowledgementPage: TInputOptionWizardPage;
function LocatePairingPackage: String;
var
FindRec: TFindRec;
begin
Result := '';
if FindFirst(ExpandConstant('{src}\*.lumi-pairing.json'), FindRec) then
begin
try
Result := AddBackslash(ExpandConstant('{src}')) + FindRec.Name;
finally
FindClose(FindRec);
end;
end;
end;
function JsonStringValue(const Json: String; const Key: String): String;
var
Marker: String;
Tail: String;
MarkerPos: Integer;
ColonPos: Integer;
EndQuotePos: Integer;
begin
Result := '';
Marker := '"' + Key + '"';
MarkerPos := Pos(Marker, Json);
if MarkerPos = 0 then
Exit;
Tail := Copy(Json, MarkerPos + Length(Marker), Length(Json));
ColonPos := Pos(':', Tail);
if ColonPos = 0 then
Exit;
Tail := Trim(Copy(Tail, ColonPos + 1, Length(Tail)));
if (Length(Tail) < 2) or (Tail[1] <> '"') then
Exit;
Tail := Copy(Tail, 2, Length(Tail));
EndQuotePos := Pos('"', Tail);
if EndQuotePos = 0 then
Exit;
Result := Copy(Tail, 1, EndQuotePos - 1);
end;
function LoadPairingJson(const PackagePath: String; var Json: String): Boolean;
var
Lines: TArrayOfString;
Index: Integer;
begin
Result := False;
Json := '';
if (PackagePath = '') or not LoadStringsFromFile(PackagePath, Lines) then
Exit;
for Index := 0 to GetArrayLength(Lines) - 1 do
begin
if Index > 0 then
Json := Json + #10;
Json := Json + Lines[Index];
end;
Result := True;
end;
function ReadPairingHost(const Json: String): String;
var
Candidate: String;
LowerCandidate: String;
begin
Result := '';
Candidate := JsonStringValue(Json, 'host');
LowerCandidate := Lowercase(Candidate);
if ((Pos('https://', LowerCandidate) = 1) or (Pos('http://', LowerCandidate) = 1)) and
(Length(Candidate) <= 500) then
Result := Candidate;
end;
procedure InitializeWizard;
var
HostDisplay: String;
Explanation: String;
OperatorDetails: String;
Json: String;
begin
PairingPackagePath := LocatePairingPackage;
if LoadPairingJson(PairingPackagePath, Json) then
begin
PairingHost := ReadPairingHost(Json);
PairingOperatorName := Copy(JsonStringValue(Json, 'operator_name'), 1, 200);
PairingOperatorContact := Copy(JsonStringValue(Json, 'operator_contact'), 1, 300);
PairingPrivacyUrl := Copy(JsonStringValue(Json, 'privacy_url'), 1, 500);
end;
if PairingHost <> '' then
HostDisplay := PairingHost
else
HostDisplay := 'No paired Lumi host was identified beside this installer.';
OperatorDetails := '';
if PairingOperatorName <> '' then
OperatorDetails := OperatorDetails + #13#10 + 'Operator name: ' + PairingOperatorName;
if PairingOperatorContact <> '' then
OperatorDetails := OperatorDetails + #13#10 + 'Operator contact: ' + PairingOperatorContact;
if PairingPrivacyUrl <> '' then
OperatorDetails := OperatorDetails + #13#10 + 'Privacy information: ' + PairingPrivacyUrl;
Explanation :=
'This copy of Lumi Companion is intended to connect to:' + #13#10#13#10 +
HostDisplay + OperatorDetails + #13#10#13#10 +
'The person or organisation controlling that Lumi installation is the Host Operator. ' +
'The Host Operator is responsible for the Hosted Service it controls, including server configuration, ' +
'access, server-side data processing, retention, integrations, and required user notices. ' +
'OokamiKunTV is the software developer and is not automatically the operator of an independently hosted Lumi installation. ' +
'Responsibility always follows the actual facts and applicable law.';
HostAcknowledgementPage := CreateInputOptionPage(
wpInfoBefore,
'Lumi host and data responsibility',
'Review the host that will receive Companion data.',
Explanation,
False,
False);
HostAcknowledgementPage.Add(
'I understand which Lumi host this Companion will use and that the Host Operator controls its server-side service and processing.');
end;
function NextButtonClick(CurPageID: Integer): Boolean;
begin
Result := True;
{ A silent deployment is an administrator/operator action and has no wizard
UI in which an interactive acknowledgement can be collected. }
if WizardSilent then
Exit;
if (HostAcknowledgementPage <> nil) and (CurPageID = HostAcknowledgementPage.ID) and
not HostAcknowledgementPage.Values[0] then
begin
MsgBox(
'Confirm that you understand the host and operator responsibility before continuing.',
mbInformation,
MB_OK);
Result := False;
end;
end;
procedure ImportPairingPackage;
var
SourcePath: String;
TargetPath: String;
begin
{ Portable and installed builds share the same DPAPI credential directory. Do
not create another authorized device merely because an existing user moves
the executable into its durable installed location. }
if FileExists(ExpandConstant('{localappdata}\Lumi\Companion\device.credential')) then
Exit;
SourcePath := PairingPackagePath;
if SourcePath = '' then
SourcePath := LocatePairingPackage;
if SourcePath = '' then
Exit;
TargetPath := AddBackslash(ExpandConstant('{app}')) + ExtractFileName(SourcePath);
if not CopyFile(SourcePath, TargetPath, False) then
MsgBox(
'Lumi Companion was installed, but its one-time pairing package could not be imported. ' +
'Open Companion and choose the pairing file manually.',
mbError,
MB_OK);
end;
procedure CurStepChanged(CurStep: TSetupStep);
begin
if CurStep = ssPostInstall then
ImportPairingPackage;
end;

View File

@ -0,0 +1,10 @@
LUMI COMPANION — HOST OPERATOR NOTICE
Paired Lumi host: {{HOST_URL}}
Operator name: {{OPERATOR_NAME}}
Operator contact: {{OPERATOR_CONTACT}}
Privacy information: {{PRIVACY_URL}}
This package connects Companion to the Lumi installation at the host shown above. The person or organisation controlling that installation is responsible for its Hosted Service, including server configuration, user access, server-side processing, retention, integrations, notices, and compliance obligations.
OokamiKunTV is the developer of Lumi Companion and is not automatically the operator of an independently hosted Lumi installation. Responsibility follows the actual facts and applicable law. The host URL is a technical identifier and may not by itself identify the operators legal name.

View File

@ -0,0 +1,12 @@
# Legal maintainer notes
This bundle is a practical baseline, not a substitute for review by a qualified lawyer familiar with the actual distribution, business model, and jurisdictions.
Before a public or paid release:
- publish a stable developer contact channel and, if applicable, the legal person/business name and address;
- let self-hosters configure an operator name, contact address, and privacy URL in generated pairing packages;
- confirm the project-wide licence strategy, especially the GPL-2.0-or-later OBS bridge boundary;
- retain the generated `third-party/nuget/NuGet-LICENCE-INVENTORY.txt` and copied package notices from the exact resolved .NET/NuGet dependency set for each release;
- preserve the corresponding-source archive for every distributed OBS bridge binary for as long as the relevant GPL obligations require; and
- review consumer terms, data-processing roles, and any paid-service terms before charging users.

View File

@ -0,0 +1,10 @@
LUMI COMPANION — LEGAL AND PRIVACY DOCUMENTS
- LUMI-COMPANION-LICENCE.txt: licence terms for developer-authored proprietary portions.
- PRIVACY-NOTICE.txt: default client-side data behaviour and Host Operator responsibility.
- THIRD-PARTY-NOTICES.txt: third-party components and licence locations.
- third-party directory: full third-party licence and notice files supplied with this build.
- third-party\nuget\NuGet-LICENCE-INVENTORY.txt: generated inventory and package-supplied notices for the exact restored NuGet graph.
- Lumi-OBS-Bridge-Source.zip: corresponding source and build materials for the GPL-licensed OBS bridge.
The installer also displays the paired Lumi host URL when a pairing package is present. The person or organisation controlling that host is responsible for its own Hosted Service and server-side processing, subject to the actual facts and applicable law.

View File

@ -0,0 +1,102 @@
LUMI COMPANION SOFTWARE LICENCE AND TERMS OF USE
Effective date: 23 July 2026
PLEASE READ THESE TERMS BEFORE INSTALLING OR USING LUMI COMPANION.
These terms govern the Lumi Companion desktop application and installer, except for components that are supplied under separate open-source or third-party licences. By installing, copying, pairing, updating, or using Lumi Companion, you accept these terms. If you do not accept them, do not install or use the software.
1. PARTIES AND DEFINITIONS
“Developer” means OokamiKunTV, the developer and copyright holder of Lumi Companion and the Lumi project components authored by OokamiKunTV.
“Companion” means the Lumi Companion application, installer, updates, documentation, and developer-authored components supplied with it.
“Host Operator” means the person or organisation that controls the Lumi installation to which Companion is paired. The paired host URL shown by the installer identifies the technical endpoint, but may not by itself identify the Host Operators legal name.
“Hosted Service” means the Lumi installation, its configuration, accounts, models, storage, integrations, moderation, transcription, and other server-side functions operated by the Host Operator.
2. SEPARATE RESPONSIBILITIES
Companion is client software. It connects to a separately operated Lumi host selected through a pairing package or by the user.
The Host Operator is responsible for the Hosted Service it controls, including its configuration, availability, security, access management, user notices, legal basis for processing, retention, integrations, and compliance with laws applicable to that operation. The Developer is not automatically the Host Operator merely because the Host Operator uses Lumi software.
Where the Developer also operates the paired Lumi host, the Developer may additionally have responsibilities in that separate role. Legal responsibility always follows the actual facts and applicable law; these terms do not reassign a statutory responsibility that cannot lawfully be reassigned.
3. LICENCE GRANT
Subject to these terms, the Developer grants you a limited, non-exclusive, non-transferable, revocable licence to install and use Companion on devices you own or control for personal or internal organisational use with a Lumi installation you are authorised to access.
A Host Operator may redistribute an unmodified official Companion installer together with a pairing package to users authorised to access that Host Operators Lumi installation. No right is granted to sell, rebrand, misrepresent, or distribute modified proprietary Companion binaries without the Developers permission.
You may make reasonable backup copies. You may not remove proprietary notices, bypass access controls, use Companion to gain unauthorised access, or reverse engineer proprietary portions except to the extent that applicable law expressly permits this despite this restriction.
Open-source components are governed only by their respective licences. Nothing in these terms limits rights granted under those licences.
4. EXPERIMENTAL SOFTWARE AND USER RESPONSIBILITY
Companion may be identified as experimental, preview, beta, or pre-release software. It may contain defects, change without notice, fail to interoperate with OBS, Windows, hardware, networks, third-party services, or a particular Lumi host, and may be discontinued.
You are responsible for deciding whether Companion is suitable for your use, maintaining backups, testing changes before relying on them, protecting your accounts and devices, reviewing the Host Operators policies, and obtaining any permissions required before capturing or transmitting audio, captions, account information, or other data.
Companion is not designed for emergency, medical, safety-critical, legal-compliance, life-support, or other use where failure could foreseeably cause death, personal injury, or substantial physical damage.
5. DATA AND NETWORK FUNCTIONS
Companion stores local settings, an installation identifier, diagnostic logs, and a Windows user-protected device credential. When paired, it communicates with the paired Lumi host and may transmit selected OBS audio, device and component status, configuration information, test data, captions, and diagnostics needed for enabled functions.
The Privacy Notice supplied with Companion explains the default client-side behaviour. The Host Operator must provide information about server-side processing for the Hosted Service. You must not assume that the Developer receives or controls data sent to an independently operated Lumi host.
6. UPDATES, CHANGES, AND THIRD-PARTY SERVICES
Companion may check the paired Lumi host for updates. Installation of an update remains subject to the behaviour presented by the application. Updates may add, remove, or change features and may be required for compatibility or security.
Companion may interoperate with third-party software and services, including OBS Studio and Windows. Those products are controlled by their respective providers and may be subject to separate terms. The Developer does not control and is not responsible for their continued availability, behaviour, policies, or changes.
7. SUPPORT
Unless the Developer or Host Operator separately agrees in writing, no support, maintenance, service level, response time, continued hosting, compatibility period, or update schedule is promised.
8. NO WARRANTY
TO THE MAXIMUM EXTENT PERMITTED BY APPLICABLE LAW, COMPANION IS PROVIDED “AS IS” AND “AS AVAILABLE”, WITH ALL FAULTS AND WITHOUT WARRANTIES, REPRESENTATIONS, CONDITIONS, OR GUARANTEES OF ANY KIND, WHETHER EXPRESS, IMPLIED, STATUTORY, OR OTHERWISE.
TO THE MAXIMUM EXTENT PERMITTED BY LAW, THE DEVELOPER DISCLAIMS IMPLIED WARRANTIES OR CONDITIONS OF MERCHANTABILITY, SATISFACTORY QUALITY, FITNESS FOR A PARTICULAR PURPOSE, TITLE, NON-INFRINGEMENT, ACCURACY, SECURITY, AVAILABILITY, COMPATIBILITY, AND ERROR-FREE OR UNINTERRUPTED OPERATION.
No statement, documentation, test result, roadmap, or assistance creates a warranty unless the Developer expressly agrees to it in a signed written agreement.
9. LIMITATION OF LIABILITY
TO THE MAXIMUM EXTENT PERMITTED BY APPLICABLE LAW, THE DEVELOPER WILL NOT BE LIABLE FOR INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, PUNITIVE, OR CONSEQUENTIAL LOSS; LOSS OF DATA, CONTENT, PRIVACY, REVENUE, PROFITS, GOODWILL, BUSINESS, OPPORTUNITY, OR USE; SERVICE INTERRUPTION; DEVICE OR SOFTWARE DAMAGE; THIRD-PARTY CLAIMS; OR THE CONDUCT, SECURITY, AVAILABILITY, OR DATA PROCESSING OF AN INDEPENDENT HOST OPERATOR OR THIRD-PARTY SERVICE.
TO THE MAXIMUM EXTENT PERMITTED BY LAW, THE DEVELOPERS TOTAL AGGREGATE LIABILITY ARISING FROM OR RELATING TO COMPANION WILL NOT EXCEED THE AMOUNT YOU PAID DIRECTLY TO THE DEVELOPER FOR COMPANION DURING THE TWELVE MONTHS BEFORE THE EVENT GIVING RISE TO THE CLAIM.
These exclusions and limits do not apply where liability cannot lawfully be excluded or limited, including mandatory consumer rights and, where applicable, liability for fraud, intentional misconduct, gross negligence, or death or personal injury caused by negligence.
10. BUSINESS-USER INDEMNITY
If you use Companion on behalf of a business or organisation, that business or organisation will, to the extent permitted by law, defend and indemnify the Developer against third-party claims, losses, and reasonable costs arising from its unlawful use of Companion, infringement of third-party rights, failure to obtain required permissions, or operation of a Lumi host in breach of law. This section does not apply to consumers acting wholly outside a trade or profession.
11. TERMINATION
This licence ends automatically if you materially breach these terms. On termination, you must stop using proprietary portions of Companion and remove copies under your control, except where retention is required by law or permitted by an applicable open-source licence. Sections intended by their nature to survive termination remain effective.
12. GOVERNING LAW AND MANDATORY RIGHTS
These terms are governed by the laws of Norway, excluding conflict-of-law rules, unless mandatory law requires otherwise. Norwegian courts have non-exclusive jurisdiction where legally permitted.
If you are a consumer, you retain all mandatory protections and rights available under the law of your country of residence. Nothing in these terms waives a right or remedy that cannot legally be waived, restricts your right to bring proceedings in a court available under mandatory law, or requires you to accept a lower level of protection than mandatory consumer law provides.
13. GENERAL
If any provision is invalid or unenforceable, it will be limited or removed only to the minimum extent necessary, and the remaining provisions will continue in effect. Failure to enforce a provision is not a waiver. These terms, the Privacy Notice, and applicable third-party licences form the agreement for Companion unless a separate written agreement expressly replaces them.
The English version controls to the extent permitted by law. A translation may be provided for convenience.
14. CONTACT AND OPERATOR INFORMATION
Developer: OokamiKunTV
Project: Lumi / Lumi Companion
Developer contact: use the official Lumi project support or contact channel from which the software was obtained.
For Hosted Service, account, server-side data, moderation, retention, or access questions, contact the Host Operator identified through the paired Lumi host. The installer displays the host endpoint when a pairing package is present.

View File

@ -0,0 +1,79 @@
LUMI COMPANION PRIVACY NOTICE
Effective date: 23 July 2026
This notice describes the default data behaviour of the Lumi Companion Windows application. It does not replace the privacy notice of the person or organisation operating the Lumi host to which Companion is paired.
1. WHO IS RESPONSIBLE
Companion is client software developed by OokamiKunTV. It can connect to Lumi installations operated by different people or organisations.
The person or organisation operating the paired Lumi installation is normally responsible for deciding why and how server-side personal data is processed through that installation. That operator is referred to here as the “Host Operator”. The installer shows the paired host URL when it can read one from the adjacent pairing package.
The host URL identifies a technical endpoint, not necessarily the Host Operators full legal identity. Obtain the operators identity, contact details, purposes, legal bases, recipients, retention periods, and rights information from the operator or the paired Lumi WebUI.
OokamiKunTV is not automatically responsible for processing performed by an independently operated Lumi host. If OokamiKunTV also operates the paired host or receives information for support, security, or another stated purpose, responsibility for that processing follows the actual arrangement and applicable law. This notice does not override statutory controller or processor roles.
2. DATA STORED ON THE WINDOWS COMPUTER
Companion may store the following under the current Windows user profile:
- settings, selected source identifiers, and feature preferences;
- a random installation identifier;
- a device credential containing the paired host address, device identifier, secret, capabilities, and protocol version;
- short-lived diagnostic log files containing timestamps, connection and component events, error messages, host addresses, device names, and potentially caption or test text;
- staged update files while an update is being prepared; and
- a copied one-time pairing package until it is successfully used, found redundant, manually removed, or expires.
The device credential is protected with Windows Data Protection API for the current Windows user. This reduces casual disclosure but does not protect against every compromise of the Windows account or device.
Diagnostic logs are pruned after seven days and capped at a combined 256 MiB by default. Uninstalling Companion may leave the per-user settings, credential, installation identifier, logs, and update directory in %LocalAppData%\Lumi\Companion so that reinstalling does not silently create a new authorised device. Use “Forget this device” before uninstalling when available, revoke the device from the Lumi host, and delete that folder manually if you want the remaining local data removed.
3. DATA SENT TO THE PAIRED LUMI HOST
Depending on the features you enable, Companion may send:
- the computer or device name, installation identifier, Companion version, protocol version, capabilities, and component status;
- OBS state and selected-source information;
- audio from the OBS source you select, converted to 16 kHz mono signed 16-bit PCM and transmitted in bounded frames;
- readiness, test, benchmark, connection, and diagnostic information; and
- settings or control messages required for enabled Companion features.
Raw audio is held in bounded memory and is not written to disk by Companion by default. Audio is transmitted to the paired Lumi host for server-side processing. The Host Operator controls the server-side speech model, storage, logs, caption delivery, integrations, and retention. Review the Host Operators notice before enabling capture.
You are responsible for having authority to capture and transmit audio or other information, including informing participants and obtaining consent where required.
4. NETWORK CONNECTIONS
Companion connects to:
- the host and exchange URL contained in the pairing package;
- the paired Lumi host over secure HTTP and WebSocket connections, except for an exact matching loopback host where local unencrypted transport is permitted; and
- an update artifact URL supplied by the paired Lumi host when you approve or initiate an update.
Companion does not include advertising or general-purpose analytics telemetry in the inspected release. It does not send data directly to OokamiKunTV merely because OokamiKunTV wrote the software. An independently operated host may add integrations or server-side logging outside Companions control.
5. PURPOSES AND LEGAL BASES
Local processing is performed to pair the device, remember settings, operate enabled features, diagnose failures, verify components, and apply approved updates.
The Host Operator determines the purposes and legal bases for server-side processing. Depending on the context, those bases may include performance of a contract, legitimate interests, consent, legal obligations, or another basis available under applicable law. Contact the Host Operator for the basis that applies to your use.
6. RETENTION AND DELETION
Local diagnostic logs follow the default limits described above. Credentials and settings remain until removed by the user, the application, or the operating system. Pairing tokens are designed to be single-use and expire after the period stated in the pairing package.
Server-side retention is controlled by the Host Operator and may differ from local retention. Deleting local data does not automatically delete data already sent to the host. Revoking a device on the host does not necessarily delete prior server-side logs or content.
7. SECURITY
Companion validates the pairing origin, normally requires HTTPS, protects the device credential with Windows user-scoped encryption, verifies update checksums, and keeps raw audio in bounded memory by default. No security measure is perfect. Keep Windows, OBS, Companion, and the Lumi host updated; protect the Windows account; and revoke devices that are lost, shared, or no longer trusted.
8. YOUR RIGHTS
Depending on applicable law, you may have rights to information, access, correction, deletion, restriction, objection, portability, withdrawal of consent, and complaint to a supervisory authority.
For data held by the paired Lumi host, direct requests to the Host Operator. For local data, use Companion controls where available, revoke the device from the host, and remove the local Companion data directory. For information actually received and controlled by OokamiKunTV, use the official Lumi project contact channel through which that processing occurred.
9. CHANGES
This notice may be updated when Companions data behaviour changes. The effective date identifies the version of the notice supplied with the installer. Material changes should be presented with a new release or through the relevant Host Operator.

View File

@ -0,0 +1,42 @@
LUMI COMPANION THIRD-PARTY AND OPEN-SOURCE NOTICES
Effective date: 23 July 2026
Lumi Companion contains or interoperates with software supplied under separate licences. Those licences govern the relevant components and take priority over the Lumi Companion Software Licence for those components.
1. Avalonia UI
Copyright (c) AvaloniaUI OÜ. Licensed under the MIT License.
Licence file: third-party\Avalonia-MIT.txt
Project: https://github.com/AvaloniaUI/Avalonia
2. Microsoft .NET Runtime and libraries
Copyright (c) .NET Foundation and Contributors. Licensed primarily under the MIT License and incorporating additional third-party material.
Licence files: third-party\dotnet-MIT.txt and third-party\dotnet-THIRD-PARTY-NOTICES.txt
Project: https://github.com/dotnet/runtime
The release build also generates third-party\nuget\NuGet-LICENCE-INVENTORY.txt from the exact restored package graph and copies package-supplied licence, notice, copying, and copyright files into adjacent package directories. This covers transitive packages and native assets, including Avalonia rendering dependencies, that are present in the actual build.
3. Inter font family
Copyright (c) The Inter Project Authors. Licensed under the SIL Open Font License, Version 1.1.
Licence file: third-party\Inter-OFL-1.1.txt
Project: https://github.com/rsms/inter
4. nlohmann/json 3.12.0
Copyright (c) 2013-2025 Niels Lohmann. Licensed primarily under the MIT License. The upstream project also identifies incorporated material under compatible licences, including Apache-2.0 and public-domain/CC0 material.
Licence files: third-party\nlohmann-json-MIT.txt and third-party\Apache-2.0.txt
Project: https://github.com/nlohmann/json
5. OBS Studio / libobs and Lumi OBS Bridge
OBS Studio is distributed under the GNU General Public License, version 2 or any later version. Lumi Companion does not distribute OBS Studio itself, but the bundled Lumi OBS Bridge links with OBS/libobs and is distributed under GPL-2.0-or-later.
Licence file: third-party\GPL-2.0-or-later.txt
Corresponding source: Lumi-OBS-Bridge-Source.zip installed in the legal directory.
OBS project: https://github.com/obsproject/obs-studio
6. Inno Setup
The Windows installer was created with Inno Setup by Jordan Russell and Martijn Laan. Inno Setup is supplied under its own licence.
Licence file: third-party\Inno-Setup-License.txt
Project: https://jrsoftware.org/isinfo.php
7. Windows and third-party products
Microsoft Windows, Windows Data Protection API, OBS Studio, and other named products are trademarks or products of their respective owners. Their inclusion or mention does not imply sponsorship or endorsement.
The source archive and licence files are provided to preserve the rights granted by the applicable open-source licences. If a required notice is believed to be missing, report it through the official Lumi project contact channel so it can be corrected.

View File

@ -0,0 +1,73 @@
Apache License
Version 2.0, January 2004
http://www.apache.org/licenses/
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
1. Definitions.
"License" shall mean the terms and conditions for use, reproduction, and distribution as defined by Sections 1 through 9 of this document.
"Licensor" shall mean the copyright owner or entity authorized by the copyright owner that is granting the License.
"Legal Entity" shall mean the union of the acting entity and all other entities that control, are controlled by, or are under common control with that entity. For the purposes of this definition, "control" means (i) the power, direct or indirect, to cause the direction or management of such entity, whether by contract or otherwise, or (ii) ownership of fifty percent (50%) or more of the outstanding shares, or (iii) beneficial ownership of such entity.
"You" (or "Your") shall mean an individual or Legal Entity exercising permissions granted by this License.
"Source" form shall mean the preferred form for making modifications, including but not limited to software source code, documentation source, and configuration files.
"Object" form shall mean any form resulting from mechanical transformation or translation of a Source form, including but not limited to compiled object code, generated documentation, and conversions to other media types.
"Work" shall mean the work of authorship, whether in Source or Object form, made available under the License, as indicated by a copyright notice that is included in or attached to the work (an example is provided in the Appendix below).
"Derivative Works" shall mean any work, whether in Source or Object form, that is based on (or derived from) the Work and for which the editorial revisions, annotations, elaborations, or other modifications represent, as a whole, an original work of authorship. For the purposes of this License, Derivative Works shall not include works that remain separable from, or merely link (or bind by name) to the interfaces of, the Work and Derivative Works thereof.
"Contribution" shall mean any work of authorship, including the original version of the Work and any modifications or additions to that Work or Derivative Works thereof, that is intentionally submitted to Licensor for inclusion in the Work by the copyright owner or by an individual or Legal Entity authorized to submit on behalf of the copyright owner. For the purposes of this definition, "submitted" means any form of electronic, verbal, or written communication sent to the Licensor or its representatives, including but not limited to communication on electronic mailing lists, source code control systems, and issue tracking systems that are managed by, or on behalf of, the Licensor for the purpose of discussing and improving the Work, but excluding communication that is conspicuously marked or otherwise designated in writing by the copyright owner as "Not a Contribution."
"Contributor" shall mean Licensor and any individual or Legal Entity on behalf of whom a Contribution has been received by Licensor and subsequently incorporated within the Work.
2. Grant of Copyright License. Subject to the terms and conditions of this License, each Contributor hereby grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable copyright license to reproduce, prepare Derivative Works of, publicly display, publicly perform, sublicense, and distribute the Work and such Derivative Works in Source or Object form.
3. Grant of Patent License. Subject to the terms and conditions of this License, each Contributor hereby grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable (except as stated in this section) patent license to make, have made, use, offer to sell, sell, import, and otherwise transfer the Work, where such license applies only to those patent claims licensable by such Contributor that are necessarily infringed by their Contribution(s) alone or by combination of their Contribution(s) with the Work to which such Contribution(s) was submitted. If You institute patent litigation against any entity (including a cross-claim or counterclaim in a lawsuit) alleging that the Work or a Contribution incorporated within the Work constitutes direct or contributory patent infringement, then any patent licenses granted to You under this License for that Work shall terminate as of the date such litigation is filed.
4. Redistribution. You may reproduce and distribute copies of the Work or Derivative Works thereof in any medium, with or without modifications, and in Source or Object form, provided that You meet the following conditions:
(a) You must give any other recipients of the Work or Derivative Works a copy of this License; and
(b) You must cause any modified files to carry prominent notices stating that You changed the files; and
(c) You must retain, in the Source form of any Derivative Works that You distribute, all copyright, patent, trademark, and attribution notices from the Source form of the Work, excluding those notices that do not pertain to any part of the Derivative Works; and
(d) If the Work includes a "NOTICE" text file as part of its distribution, then any Derivative Works that You distribute must include a readable copy of the attribution notices contained within such NOTICE file, excluding those notices that do not pertain to any part of the Derivative Works, in at least one of the following places: within a NOTICE text file distributed as part of the Derivative Works; within the Source form or documentation, if provided along with the Derivative Works; or, within a display generated by the Derivative Works, if and wherever such third-party notices normally appear. The contents of the NOTICE file are for informational purposes only and do not modify the License. You may add Your own attribution notices within Derivative Works that You distribute, alongside or as an addendum to the NOTICE text from the Work, provided that such additional attribution notices cannot be construed as modifying the License.
You may add Your own copyright statement to Your modifications and may provide additional or different license terms and conditions for use, reproduction, or distribution of Your modifications, or for any such Derivative Works as a whole, provided Your use, reproduction, and distribution of the Work otherwise complies with the conditions stated in this License.
5. Submission of Contributions. Unless You explicitly state otherwise, any Contribution intentionally submitted for inclusion in the Work by You to the Licensor shall be under the terms and conditions of this License, without any additional terms or conditions. Notwithstanding the above, nothing herein shall supersede or modify the terms of any separate license agreement you may have executed with Licensor regarding such Contributions.
6. Trademarks. This License does not grant permission to use the trade names, trademarks, service marks, or product names of the Licensor, except as required for reasonable and customary use in describing the origin of the Work and reproducing the content of the NOTICE file.
7. Disclaimer of Warranty. Unless required by applicable law or agreed to in writing, Licensor provides the Work (and each Contributor provides its Contributions) on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied, including, without limitation, any warranties or conditions of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A PARTICULAR PURPOSE. You are solely responsible for determining the appropriateness of using or redistributing the Work and assume any risks associated with Your exercise of permissions under this License.
8. Limitation of Liability. In no event and under no legal theory, whether in tort (including negligence), contract, or otherwise, unless required by applicable law (such as deliberate and grossly negligent acts) or agreed to in writing, shall any Contributor be liable to You for damages, including any direct, indirect, special, incidental, or consequential damages of any character arising as a result of this License or out of the use or inability to use the Work (including but not limited to damages for loss of goodwill, work stoppage, computer failure or malfunction, or any and all other commercial damages or losses), even if such Contributor has been advised of the possibility of such damages.
9. Accepting Warranty or Additional Liability. While redistributing the Work or Derivative Works thereof, You may choose to offer, and charge a fee for, acceptance of support, warranty, indemnity, or other liability obligations and/or rights consistent with this License. However, in accepting such obligations, You may act only on Your own behalf and on Your sole responsibility, not on behalf of any other Contributor, and only if You agree to indemnify, defend, and hold each Contributor harmless for any liability incurred by, or claims asserted against, such Contributor by reason of your accepting any such warranty or additional liability.
END OF TERMS AND CONDITIONS
APPENDIX: How to apply the Apache License to your work.
To apply the Apache License to your work, attach the following boilerplate notice, with the fields enclosed by brackets "[]" replaced with your own identifying information. (Don't include the brackets!) The text should be enclosed in the appropriate comment syntax for the file format. We also recommend that a file or class name and description of purpose be included on the same "printed page" as the copyright notice for easier identification within third-party archives.
Copyright [yyyy] [name of copyright owner]
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.

View File

@ -0,0 +1,22 @@
The MIT License (MIT)
Copyright (c) AvaloniaUI OÜ
All Rights Reserved
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

View File

@ -0,0 +1,339 @@
GNU GENERAL PUBLIC LICENSE
Version 2, June 1991
Copyright (C) 1989, 1991 Free Software Foundation, Inc.,
51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA
Everyone is permitted to copy and distribute verbatim copies
of this license document, but changing it is not allowed.
Preamble
The licenses for most software are designed to take away your
freedom to share and change it. By contrast, the GNU General Public
License is intended to guarantee your freedom to share and change free
software--to make sure the software is free for all its users. This
General Public License applies to most of the Free Software
Foundation's software and to any other program whose authors commit to
using it. (Some other Free Software Foundation software is covered by
the GNU Lesser General Public License instead.) You can apply it to
your programs, too.
When we speak of free software, we are referring to freedom, not
price. Our General Public Licenses are designed to make sure that you
have the freedom to distribute copies of free software (and charge for
this service if you wish), that you receive source code or can get it
if you want it, that you can change the software or use pieces of it
in new free programs; and that you know you can do these things.
To protect your rights, we need to make restrictions that forbid
anyone to deny you these rights or to ask you to surrender the rights.
These restrictions translate to certain responsibilities for you if you
distribute copies of the software, or if you modify it.
For example, if you distribute copies of such a program, whether
gratis or for a fee, you must give the recipients all the rights that
you have. You must make sure that they, too, receive or can get the
source code. And you must show them these terms so they know their
rights.
We protect your rights with two steps: (1) copyright the software, and
(2) offer you this license which gives you legal permission to copy,
distribute and/or modify the software.
Also, for each author's protection and ours, we want to make certain
that everyone understands that there is no warranty for this free
software. If the software is modified by someone else and passed on, we
want its recipients to know that what they have is not the original, so
that any problems introduced by others will not reflect on the original
authors' reputations.
Finally, any free program is threatened constantly by software
patents. We wish to avoid the danger that redistributors of a free
program will individually obtain patent licenses, in effect making the
program proprietary. To prevent this, we have made it clear that any
patent must be licensed for everyone's free use or not licensed at all.
The precise terms and conditions for copying, distribution and
modification follow.
GNU GENERAL PUBLIC LICENSE
TERMS AND CONDITIONS FOR COPYING, DISTRIBUTION AND MODIFICATION
0. This License applies to any program or other work which contains
a notice placed by the copyright holder saying it may be distributed
under the terms of this General Public License. The "Program", below,
refers to any such program or work, and a "work based on the Program"
means either the Program or any derivative work under copyright law:
that is to say, a work containing the Program or a portion of it,
either verbatim or with modifications and/or translated into another
language. (Hereinafter, translation is included without limitation in
the term "modification".) Each licensee is addressed as "you".
Activities other than copying, distribution and modification are not
covered by this License; they are outside its scope. The act of
running the Program is not restricted, and the output from the Program
is covered only if its contents constitute a work based on the
Program (independent of having been made by running the Program).
Whether that is true depends on what the Program does.
1. You may copy and distribute verbatim copies of the Program's
source code as you receive it, in any medium, provided that you
conspicuously and appropriately publish on each copy an appropriate
copyright notice and disclaimer of warranty; keep intact all the
notices that refer to this License and to the absence of any warranty;
and give any other recipients of the Program a copy of this License
along with the Program.
You may charge a fee for the physical act of transferring a copy, and
you may at your option offer warranty protection in exchange for a fee.
2. You may modify your copy or copies of the Program or any portion
of it, thus forming a work based on the Program, and copy and
distribute such modifications or work under the terms of Section 1
above, provided that you also meet all of these conditions:
a) You must cause the modified files to carry prominent notices
stating that you changed the files and the date of any change.
b) You must cause any work that you distribute or publish, that in
whole or in part contains or is derived from the Program or any
part thereof, to be licensed as a whole at no charge to all third
parties under the terms of this License.
c) If the modified program normally reads commands interactively
when run, you must cause it, when started running for such
interactive use in the most ordinary way, to print or display an
announcement including an appropriate copyright notice and a
notice that there is no warranty (or else, saying that you provide
a warranty) and that users may redistribute the program under
these conditions, and telling the user how to view a copy of this
License. (Exception: if the Program itself is interactive but
does not normally print such an announcement, your work based on
the Program is not required to print an announcement.)
These requirements apply to the modified work as a whole. If
identifiable sections of that work are not derived from the Program,
and can be reasonably considered independent and separate works in
themselves, then this License, and its terms, do not apply to those
sections when you distribute them as separate works. But when you
distribute the same sections as part of a whole which is a work based
on the Program, the distribution of the whole must be on the terms of
this License, whose permissions for other licensees extend to the
entire whole, and thus to each and every part regardless of who wrote it.
Thus, it is not the intent of this section to claim rights or contest
your rights to work written entirely by you; rather, the intent is to
exercise the right to control the distribution of derivative or
collective works based on the Program.
In addition, mere aggregation of another work not based on the Program
with the Program (or with a work based on the Program) on a volume of
a storage or distribution medium does not bring the other work under
the scope of this License.
3. You may copy and distribute the Program (or a work based on it,
under Section 2) in object code or executable form under the terms of
Sections 1 and 2 above provided that you also do one of the following:
a) Accompany it with the complete corresponding machine-readable
source code, which must be distributed under the terms of Sections
1 and 2 above on a medium customarily used for software interchange; or,
b) Accompany it with a written offer, valid for at least three
years, to give any third party, for a charge no more than your
cost of physically performing source distribution, a complete
machine-readable copy of the corresponding source code, to be
distributed under the terms of Sections 1 and 2 above on a medium
customarily used for software interchange; or,
c) Accompany it with the information you received as to the offer
to distribute corresponding source code. (This alternative is
allowed only for noncommercial distribution and only if you
received the program in object code or executable form with such
an offer, in accord with Subsection b above.)
The source code for a work means the preferred form of the work for
making modifications to it. For an executable work, complete source
code means all the source code for all modules it contains, plus any
associated interface definition files, plus the scripts used to
control compilation and installation of the executable. However, as a
special exception, the source code distributed need not include
anything that is normally distributed (in either source or binary
form) with the major components (compiler, kernel, and so on) of the
operating system on which the executable runs, unless that component
itself accompanies the executable.
If distribution of executable or object code is made by offering
access to copy from a designated place, then offering equivalent
access to copy the source code from the same place counts as
distribution of the source code, even though third parties are not
compelled to copy the source along with the object code.
4. You may not copy, modify, sublicense, or distribute the Program
except as expressly provided under this License. Any attempt
otherwise to copy, modify, sublicense or distribute the Program is
void, and will automatically terminate your rights under this License.
However, parties who have received copies, or rights, from you under
this License will not have their licenses terminated so long as such
parties remain in full compliance.
5. You are not required to accept this License, since you have not
signed it. However, nothing else grants you permission to modify or
distribute the Program or its derivative works. These actions are
prohibited by law if you do not accept this License. Therefore, by
modifying or distributing the Program (or any work based on the
Program), you indicate your acceptance of this License to do so, and
all its terms and conditions for copying, distributing or modifying
the Program or works based on it.
6. Each time you redistribute the Program (or any work based on the
Program), the recipient automatically receives a license from the
original licensor to copy, distribute or modify the Program subject to
these terms and conditions. You may not impose any further
restrictions on the recipients' exercise of the rights granted herein.
You are not responsible for enforcing compliance by third parties to
this License.
7. If, as a consequence of a court judgment or allegation of patent
infringement or for any other reason (not limited to patent issues),
conditions are imposed on you (whether by court order, agreement or
otherwise) that contradict the conditions of this License, they do not
excuse you from the conditions of this License. If you cannot
distribute so as to satisfy simultaneously your obligations under this
License and any other pertinent obligations, then as a consequence you
may not distribute the Program at all. For example, if a patent
license would not permit royalty-free redistribution of the Program by
all those who receive copies directly or indirectly through you, then
the only way you could satisfy both it and this License would be to
refrain entirely from distribution of the Program.
If any portion of this section is held invalid or unenforceable under
any particular circumstance, the balance of the section is intended to
apply and the section as a whole is intended to apply in other
circumstances.
It is not the purpose of this section to induce you to infringe any
patents or other property right claims or to contest validity of any
such claims; this section has the sole purpose of protecting the
integrity of the free software distribution system, which is
implemented by public license practices. Many people have made
generous contributions to the wide range of software distributed
through that system in reliance on consistent application of that
system; it is up to the author/donor to decide if he or she is willing
to distribute software through any other system and a licensee cannot
impose that choice.
This section is intended to make thoroughly clear what is believed to
be a consequence of the rest of this License.
8. If the distribution and/or use of the Program is restricted in
certain countries either by patents or by copyrighted interfaces, the
original copyright holder who places the Program under this License
may add an explicit geographical distribution limitation excluding
those countries, so that distribution is permitted only in or among
countries not thus excluded. In such case, this License incorporates
the limitation as if written in the body of this License.
9. The Free Software Foundation may publish revised and/or new versions
of the General Public License from time to time. Such new versions will
be similar in spirit to the present version, but may differ in detail to
address new problems or concerns.
Each version is given a distinguishing version number. If the Program
specifies a version number of this License which applies to it and "any
later version", you have the option of following the terms and conditions
either of that version or of any later version published by the Free
Software Foundation. If the Program does not specify a version number of
this License, you may choose any version ever published by the Free Software
Foundation.
10. If you wish to incorporate parts of the Program into other free
programs whose distribution conditions are different, write to the author
to ask for permission. For software which is copyrighted by the Free
Software Foundation, write to the Free Software Foundation; we sometimes
make exceptions for this. Our decision will be guided by the two goals
of preserving the free status of all derivatives of our free software and
of promoting the sharing and reuse of software generally.
NO WARRANTY
11. BECAUSE THE PROGRAM IS LICENSED FREE OF CHARGE, THERE IS NO WARRANTY
FOR THE PROGRAM, TO THE EXTENT PERMITTED BY APPLICABLE LAW. EXCEPT WHEN
OTHERWISE STATED IN WRITING THE COPYRIGHT HOLDERS AND/OR OTHER PARTIES
PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY OF ANY KIND, EITHER EXPRESSED
OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF
MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE. THE ENTIRE RISK AS
TO THE QUALITY AND PERFORMANCE OF THE PROGRAM IS WITH YOU. SHOULD THE
PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF ALL NECESSARY SERVICING,
REPAIR OR CORRECTION.
12. IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MAY MODIFY AND/OR
REDISTRIBUTE THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES,
INCLUDING ANY GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING
OUT OF THE USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED
TO LOSS OF DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY
YOU OR THIRD PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER
PROGRAMS), EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE
POSSIBILITY OF SUCH DAMAGES.
END OF TERMS AND CONDITIONS
How to Apply These Terms to Your New Programs
If you develop a new program, and you want it to be of the greatest
possible use to the public, the best way to achieve this is to make it
free software which everyone can redistribute and change under these terms.
To do so, attach the following notices to the program. It is safest
to attach them to the start of each source file to most effectively
convey the exclusion of warranty; and each file should have at least
the "copyright" line and a pointer to where the full notice is found.
<one line to give the program's name and a brief idea of what it does.>
Copyright (C) <year> <name of author>
This program is free software; you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation; either version 2 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License along
with this program; if not, write to the Free Software Foundation, Inc.,
51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA.
Also add information on how to contact you by electronic and paper mail.
If the program is interactive, make it output a short notice like this
when it starts in an interactive mode:
Gnomovision version 69, Copyright (C) year name of author
Gnomovision comes with ABSOLUTELY NO WARRANTY; for details type `show w'.
This is free software, and you are welcome to redistribute it
under certain conditions; type `show c' for details.
The hypothetical commands `show w' and `show c' should show the appropriate
parts of the General Public License. Of course, the commands you use may
be called something other than `show w' and `show c'; they could even be
mouse-clicks or menu items--whatever suits your program.
You should also get your employer (if you work as a programmer) or your
school, if any, to sign a "copyright disclaimer" for the program, if
necessary. Here is a sample; alter the names:
Yoyodyne, Inc., hereby disclaims all copyright interest in the program
`Gnomovision' (which makes passes at compilers) written by James Hacker.
<signature of Ty Coon>, 1 April 1989
Ty Coon, President of Vice
This General Public License does not permit incorporating your program into
proprietary programs. If your program is a subroutine library, you may
consider it more useful to permit linking proprietary applications with the
library. If this is what you want to do, use the GNU Lesser General
Public License instead of this License.

View File

@ -0,0 +1,32 @@
Inno Setup License
==================
Except where otherwise noted, all of the documentation and software included in the Inno
Setup package is copyrighted by Jordan Russell.
Copyright (C) 1997-2026 Jordan Russell. All rights reserved.
Portions Copyright (C) 2000-2026 Martijn Laan. All rights reserved.
This software is provided "as-is," without any express or implied warranty. In no event shall
the author be held liable for any damages arising from the use of this software.
Permission is granted to anyone to use this software for any purpose, including commercial
applications, and to alter and redistribute it, provided that the following conditions are met:
1. All redistributions of source code files must retain all copyright notices that are currently
in place, and this list of conditions without modification.
2. All redistributions in binary form must retain all occurrences of the above copyright notice
and web site addresses that are currently in place (for example, in the About boxes).
3. The origin of this software must not be misrepresented; you must not claim that you wrote
the original software. If you use this software to distribute a product, an acknowledgment
in the product documentation would be appreciated but is not required.
4. Modified versions in source or binary form must be plainly marked as such, and must not
be misrepresented as being the original software.
Jordan Russell
jr-2020 AT jrsoftware.org
https://jrsoftware.org/

View File

@ -0,0 +1,92 @@
Copyright (c) 2016 The Inter Project Authors (https://github.com/rsms/inter)
This Font Software is licensed under the SIL Open Font License, Version 1.1.
This license is copied below, and is also available with a FAQ at:
http://scripts.sil.org/OFL
-----------------------------------------------------------
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
-----------------------------------------------------------
PREAMBLE
The goals of the Open Font License (OFL) are to stimulate worldwide
development of collaborative font projects, to support the font creation
efforts of academic and linguistic communities, and to provide a free and
open framework in which fonts may be shared and improved in partnership
with others.
The OFL allows the licensed fonts to be used, studied, modified and
redistributed freely as long as they are not sold by themselves. The
fonts, including any derivative works, can be bundled, embedded,
redistributed and/or sold with any software provided that any reserved
names are not used by derivative works. The fonts and derivatives,
however, cannot be released under any other type of license. The
requirement for fonts to remain under this license does not apply
to any document created using the fonts or their derivatives.
DEFINITIONS
"Font Software" refers to the set of files released by the Copyright
Holder(s) under this license and clearly marked as such. This may
include source files, build scripts and documentation.
"Reserved Font Name" refers to any names specified as such after the
copyright statement(s).
"Original Version" refers to the collection of Font Software components as
distributed by the Copyright Holder(s).
"Modified Version" refers to any derivative made by adding to, deleting,
or substituting -- in part or in whole -- any of the components of the
Original Version, by changing formats or by porting the Font Software to a
new environment.
"Author" refers to any designer, engineer, programmer, technical
writer or other person who contributed to the Font Software.
PERMISSION AND CONDITIONS
Permission is hereby granted, free of charge, to any person obtaining
a copy of the Font Software, to use, study, copy, merge, embed, modify,
redistribute, and sell modified and unmodified copies of the Font
Software, subject to the following conditions:
1) Neither the Font Software nor any of its individual components,
in Original or Modified Versions, may be sold by itself.
2) Original or Modified Versions of the Font Software may be bundled,
redistributed and/or sold with any software, provided that each copy
contains the above copyright notice and this license. These can be
included either as stand-alone text files, human-readable headers or
in the appropriate machine-readable metadata fields within text or
binary files as long as those fields can be easily viewed by the user.
3) No Modified Version of the Font Software may use the Reserved Font
Name(s) unless explicit written permission is granted by the corresponding
Copyright Holder. This restriction only applies to the primary font name as
presented to the users.
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
Software shall not be used to promote, endorse or advertise any
Modified Version, except to acknowledge the contribution(s) of the
Copyright Holder(s) and the Author(s) or with their explicit written
permission.
5) The Font Software, modified or unmodified, in part or in whole,
must be distributed entirely under this license, and must not be
distributed under any other license. The requirement for fonts to
remain under this license does not apply to any document created
using the Font Software.
TERMINATION
This license becomes null and void if any of the above conditions are
not met.
DISCLAIMER
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
OTHER DEALINGS IN THE FONT SOFTWARE.

View File

@ -0,0 +1,23 @@
The MIT License (MIT)
Copyright (c) .NET Foundation and Contributors
All rights reserved.
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,21 @@
MIT License
Copyright (c) 2013-2025 Niels Lohmann
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

View File

@ -0,0 +1,39 @@
# SPDX-License-Identifier: GPL-2.0-or-later
# Copyright (c) 2026 OokamiKunTV
cmake_minimum_required(VERSION 3.28)
project(lumi-obs-bridge VERSION 0.1.0 LANGUAGES CXX)
set(LUMI_BRIDGE_VERSION "0.1.0-development" CACHE STRING "Lumi Companion bridge release version")
set(CMAKE_CXX_STANDARD 20)
set(CMAKE_CXX_STANDARD_REQUIRED ON)
include(FetchContent)
FetchContent_Declare(json URL https://github.com/nlohmann/json/releases/download/v3.12.0/json.tar.xz
URL_HASH SHA256=42f6e95cad6ec532fd372391373363b62a14af6d771056dbfc86160e6dfff7aa)
FetchContent_MakeAvailable(json)
if(OBS_SOURCE_DIR AND OBS_IMPORT_DIR)
set(OBS_DATA_PATH "data")
set(OBS_PLUGIN_PATH "obs-plugins")
set(OBS_PLUGIN_DESTINATION "obs-plugins/64bit")
set(OBS_RELEASE_CANDIDATE 0)
set(OBS_BETA 0)
configure_file("${OBS_SOURCE_DIR}/libobs/obsconfig.h.in" "${CMAKE_CURRENT_BINARY_DIR}/config/obsconfig.h" @ONLY)
add_library(obs-lib SHARED IMPORTED)
set_target_properties(obs-lib PROPERTIES IMPORTED_IMPLIB "${OBS_IMPORT_DIR}/obs.lib")
target_include_directories(obs-lib INTERFACE "${OBS_SOURCE_DIR}/libobs" "${CMAKE_CURRENT_BINARY_DIR}/config")
add_library(obs-frontend SHARED IMPORTED)
set_target_properties(obs-frontend PROPERTIES IMPORTED_IMPLIB "${OBS_IMPORT_DIR}/obs-frontend-api.lib")
target_include_directories(obs-frontend INTERFACE "${OBS_SOURCE_DIR}/frontend/api")
else()
find_package(libobs REQUIRED)
find_package(obs-frontend-api REQUIRED)
add_library(obs-lib ALIAS OBS::libobs)
add_library(obs-frontend ALIAS OBS::obs-frontend-api)
endif()
add_library(lumi-obs-bridge MODULE src/plugin.cpp)
target_include_directories(lumi-obs-bridge PRIVATE include)
target_compile_definitions(lumi-obs-bridge PRIVATE NOMINMAX WIN32_LEAN_AND_MEAN LUMI_BRIDGE_VERSION="${LUMI_BRIDGE_VERSION}")
target_link_libraries(lumi-obs-bridge PRIVATE obs-lib obs-frontend nlohmann_json::nlohmann_json bcrypt)
set_target_properties(lumi-obs-bridge PROPERTIES PREFIX "" OUTPUT_NAME "lumi-obs-bridge")

View File

@ -0,0 +1,339 @@
GNU GENERAL PUBLIC LICENSE
Version 2, June 1991
Copyright (C) 1989, 1991 Free Software Foundation, Inc.,
51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA
Everyone is permitted to copy and distribute verbatim copies
of this license document, but changing it is not allowed.
Preamble
The licenses for most software are designed to take away your
freedom to share and change it. By contrast, the GNU General Public
License is intended to guarantee your freedom to share and change free
software--to make sure the software is free for all its users. This
General Public License applies to most of the Free Software
Foundation's software and to any other program whose authors commit to
using it. (Some other Free Software Foundation software is covered by
the GNU Lesser General Public License instead.) You can apply it to
your programs, too.
When we speak of free software, we are referring to freedom, not
price. Our General Public Licenses are designed to make sure that you
have the freedom to distribute copies of free software (and charge for
this service if you wish), that you receive source code or can get it
if you want it, that you can change the software or use pieces of it
in new free programs; and that you know you can do these things.
To protect your rights, we need to make restrictions that forbid
anyone to deny you these rights or to ask you to surrender the rights.
These restrictions translate to certain responsibilities for you if you
distribute copies of the software, or if you modify it.
For example, if you distribute copies of such a program, whether
gratis or for a fee, you must give the recipients all the rights that
you have. You must make sure that they, too, receive or can get the
source code. And you must show them these terms so they know their
rights.
We protect your rights with two steps: (1) copyright the software, and
(2) offer you this license which gives you legal permission to copy,
distribute and/or modify the software.
Also, for each author's protection and ours, we want to make certain
that everyone understands that there is no warranty for this free
software. If the software is modified by someone else and passed on, we
want its recipients to know that what they have is not the original, so
that any problems introduced by others will not reflect on the original
authors' reputations.
Finally, any free program is threatened constantly by software
patents. We wish to avoid the danger that redistributors of a free
program will individually obtain patent licenses, in effect making the
program proprietary. To prevent this, we have made it clear that any
patent must be licensed for everyone's free use or not licensed at all.
The precise terms and conditions for copying, distribution and
modification follow.
GNU GENERAL PUBLIC LICENSE
TERMS AND CONDITIONS FOR COPYING, DISTRIBUTION AND MODIFICATION
0. This License applies to any program or other work which contains
a notice placed by the copyright holder saying it may be distributed
under the terms of this General Public License. The "Program", below,
refers to any such program or work, and a "work based on the Program"
means either the Program or any derivative work under copyright law:
that is to say, a work containing the Program or a portion of it,
either verbatim or with modifications and/or translated into another
language. (Hereinafter, translation is included without limitation in
the term "modification".) Each licensee is addressed as "you".
Activities other than copying, distribution and modification are not
covered by this License; they are outside its scope. The act of
running the Program is not restricted, and the output from the Program
is covered only if its contents constitute a work based on the
Program (independent of having been made by running the Program).
Whether that is true depends on what the Program does.
1. You may copy and distribute verbatim copies of the Program's
source code as you receive it, in any medium, provided that you
conspicuously and appropriately publish on each copy an appropriate
copyright notice and disclaimer of warranty; keep intact all the
notices that refer to this License and to the absence of any warranty;
and give any other recipients of the Program a copy of this License
along with the Program.
You may charge a fee for the physical act of transferring a copy, and
you may at your option offer warranty protection in exchange for a fee.
2. You may modify your copy or copies of the Program or any portion
of it, thus forming a work based on the Program, and copy and
distribute such modifications or work under the terms of Section 1
above, provided that you also meet all of these conditions:
a) You must cause the modified files to carry prominent notices
stating that you changed the files and the date of any change.
b) You must cause any work that you distribute or publish, that in
whole or in part contains or is derived from the Program or any
part thereof, to be licensed as a whole at no charge to all third
parties under the terms of this License.
c) If the modified program normally reads commands interactively
when run, you must cause it, when started running for such
interactive use in the most ordinary way, to print or display an
announcement including an appropriate copyright notice and a
notice that there is no warranty (or else, saying that you provide
a warranty) and that users may redistribute the program under
these conditions, and telling the user how to view a copy of this
License. (Exception: if the Program itself is interactive but
does not normally print such an announcement, your work based on
the Program is not required to print an announcement.)
These requirements apply to the modified work as a whole. If
identifiable sections of that work are not derived from the Program,
and can be reasonably considered independent and separate works in
themselves, then this License, and its terms, do not apply to those
sections when you distribute them as separate works. But when you
distribute the same sections as part of a whole which is a work based
on the Program, the distribution of the whole must be on the terms of
this License, whose permissions for other licensees extend to the
entire whole, and thus to each and every part regardless of who wrote it.
Thus, it is not the intent of this section to claim rights or contest
your rights to work written entirely by you; rather, the intent is to
exercise the right to control the distribution of derivative or
collective works based on the Program.
In addition, mere aggregation of another work not based on the Program
with the Program (or with a work based on the Program) on a volume of
a storage or distribution medium does not bring the other work under
the scope of this License.
3. You may copy and distribute the Program (or a work based on it,
under Section 2) in object code or executable form under the terms of
Sections 1 and 2 above provided that you also do one of the following:
a) Accompany it with the complete corresponding machine-readable
source code, which must be distributed under the terms of Sections
1 and 2 above on a medium customarily used for software interchange; or,
b) Accompany it with a written offer, valid for at least three
years, to give any third party, for a charge no more than your
cost of physically performing source distribution, a complete
machine-readable copy of the corresponding source code, to be
distributed under the terms of Sections 1 and 2 above on a medium
customarily used for software interchange; or,
c) Accompany it with the information you received as to the offer
to distribute corresponding source code. (This alternative is
allowed only for noncommercial distribution and only if you
received the program in object code or executable form with such
an offer, in accord with Subsection b above.)
The source code for a work means the preferred form of the work for
making modifications to it. For an executable work, complete source
code means all the source code for all modules it contains, plus any
associated interface definition files, plus the scripts used to
control compilation and installation of the executable. However, as a
special exception, the source code distributed need not include
anything that is normally distributed (in either source or binary
form) with the major components (compiler, kernel, and so on) of the
operating system on which the executable runs, unless that component
itself accompanies the executable.
If distribution of executable or object code is made by offering
access to copy from a designated place, then offering equivalent
access to copy the source code from the same place counts as
distribution of the source code, even though third parties are not
compelled to copy the source along with the object code.
4. You may not copy, modify, sublicense, or distribute the Program
except as expressly provided under this License. Any attempt
otherwise to copy, modify, sublicense or distribute the Program is
void, and will automatically terminate your rights under this License.
However, parties who have received copies, or rights, from you under
this License will not have their licenses terminated so long as such
parties remain in full compliance.
5. You are not required to accept this License, since you have not
signed it. However, nothing else grants you permission to modify or
distribute the Program or its derivative works. These actions are
prohibited by law if you do not accept this License. Therefore, by
modifying or distributing the Program (or any work based on the
Program), you indicate your acceptance of this License to do so, and
all its terms and conditions for copying, distributing or modifying
the Program or works based on it.
6. Each time you redistribute the Program (or any work based on the
Program), the recipient automatically receives a license from the
original licensor to copy, distribute or modify the Program subject to
these terms and conditions. You may not impose any further
restrictions on the recipients' exercise of the rights granted herein.
You are not responsible for enforcing compliance by third parties to
this License.
7. If, as a consequence of a court judgment or allegation of patent
infringement or for any other reason (not limited to patent issues),
conditions are imposed on you (whether by court order, agreement or
otherwise) that contradict the conditions of this License, they do not
excuse you from the conditions of this License. If you cannot
distribute so as to satisfy simultaneously your obligations under this
License and any other pertinent obligations, then as a consequence you
may not distribute the Program at all. For example, if a patent
license would not permit royalty-free redistribution of the Program by
all those who receive copies directly or indirectly through you, then
the only way you could satisfy both it and this License would be to
refrain entirely from distribution of the Program.
If any portion of this section is held invalid or unenforceable under
any particular circumstance, the balance of the section is intended to
apply and the section as a whole is intended to apply in other
circumstances.
It is not the purpose of this section to induce you to infringe any
patents or other property right claims or to contest validity of any
such claims; this section has the sole purpose of protecting the
integrity of the free software distribution system, which is
implemented by public license practices. Many people have made
generous contributions to the wide range of software distributed
through that system in reliance on consistent application of that
system; it is up to the author/donor to decide if he or she is willing
to distribute software through any other system and a licensee cannot
impose that choice.
This section is intended to make thoroughly clear what is believed to
be a consequence of the rest of this License.
8. If the distribution and/or use of the Program is restricted in
certain countries either by patents or by copyrighted interfaces, the
original copyright holder who places the Program under this License
may add an explicit geographical distribution limitation excluding
those countries, so that distribution is permitted only in or among
countries not thus excluded. In such case, this License incorporates
the limitation as if written in the body of this License.
9. The Free Software Foundation may publish revised and/or new versions
of the General Public License from time to time. Such new versions will
be similar in spirit to the present version, but may differ in detail to
address new problems or concerns.
Each version is given a distinguishing version number. If the Program
specifies a version number of this License which applies to it and "any
later version", you have the option of following the terms and conditions
either of that version or of any later version published by the Free
Software Foundation. If the Program does not specify a version number of
this License, you may choose any version ever published by the Free Software
Foundation.
10. If you wish to incorporate parts of the Program into other free
programs whose distribution conditions are different, write to the author
to ask for permission. For software which is copyrighted by the Free
Software Foundation, write to the Free Software Foundation; we sometimes
make exceptions for this. Our decision will be guided by the two goals
of preserving the free status of all derivatives of our free software and
of promoting the sharing and reuse of software generally.
NO WARRANTY
11. BECAUSE THE PROGRAM IS LICENSED FREE OF CHARGE, THERE IS NO WARRANTY
FOR THE PROGRAM, TO THE EXTENT PERMITTED BY APPLICABLE LAW. EXCEPT WHEN
OTHERWISE STATED IN WRITING THE COPYRIGHT HOLDERS AND/OR OTHER PARTIES
PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY OF ANY KIND, EITHER EXPRESSED
OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF
MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE. THE ENTIRE RISK AS
TO THE QUALITY AND PERFORMANCE OF THE PROGRAM IS WITH YOU. SHOULD THE
PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF ALL NECESSARY SERVICING,
REPAIR OR CORRECTION.
12. IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MAY MODIFY AND/OR
REDISTRIBUTE THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES,
INCLUDING ANY GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING
OUT OF THE USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED
TO LOSS OF DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY
YOU OR THIRD PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER
PROGRAMS), EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE
POSSIBILITY OF SUCH DAMAGES.
END OF TERMS AND CONDITIONS
How to Apply These Terms to Your New Programs
If you develop a new program, and you want it to be of the greatest
possible use to the public, the best way to achieve this is to make it
free software which everyone can redistribute and change under these terms.
To do so, attach the following notices to the program. It is safest
to attach them to the start of each source file to most effectively
convey the exclusion of warranty; and each file should have at least
the "copyright" line and a pointer to where the full notice is found.
<one line to give the program's name and a brief idea of what it does.>
Copyright (C) <year> <name of author>
This program is free software; you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation; either version 2 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License along
with this program; if not, write to the Free Software Foundation, Inc.,
51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA.
Also add information on how to contact you by electronic and paper mail.
If the program is interactive, make it output a short notice like this
when it starts in an interactive mode:
Gnomovision version 69, Copyright (C) year name of author
Gnomovision comes with ABSOLUTELY NO WARRANTY; for details type `show w'.
This is free software, and you are welcome to redistribute it
under certain conditions; type `show c' for details.
The hypothetical commands `show w' and `show c' should show the appropriate
parts of the General Public License. Of course, the commands you use may
be called something other than `show w' and `show c'; they could even be
mouse-clicks or menu items--whatever suits your program.
You should also get your employer (if you work as a programmer) or your
school, if any, to sign a "copyright disclaimer" for the program, if
necessary. Here is a sample; alter the names:
Yoyodyne, Inc., hereby disclaims all copyright interest in the program
`Gnomovision' (which makes passes at compilers) written by James Hacker.
<signature of Ty Coon>, 1 April 1989
Ty Coon, President of Vice
This General Public License does not permit incorporating your program into
proprietary programs. If your program is a subroutine library, you may
consider it more useful to permit linking proprietary applications with the
library. If this is what you want to do, use the GNU Lesser General
Public License instead of this License.

View File

@ -0,0 +1,9 @@
# Lumi OBS Bridge
The Lumi OBS Bridge is the native OBS module bundled with Lumi Companion.
Copyright (c) 2026 OokamiKunTV.
This component is free software licensed under the GNU General Public License, version 2 or (at your option) any later version (`GPL-2.0-or-later`). See `COPYING` for the complete licence.
The release build includes a corresponding-source archive containing this directory and the build script used to produce the distributed binary. OBS Studio and nlohmann/json remain available from their upstream projects under their respective licences.

View File

@ -0,0 +1,2 @@
Module.Name="Lumi Companion OBS Integration"
Module.Description="Connects selected OBS audio sources and native captions to Lumi Companion."

View File

@ -0,0 +1,35 @@
// SPDX-License-Identifier: GPL-2.0-or-later
// Copyright (c) 2026 OokamiKunTV
#pragma once
#include <array>
#include <atomic>
#include <cstddef>
#include <optional>
namespace lumi {
template <typename T, std::size_t Capacity> class bounded_spsc_queue {
static_assert(Capacity > 1);
std::array<T, Capacity> values_{};
alignas(64) std::atomic<std::size_t> head_{0};
alignas(64) std::atomic<std::size_t> tail_{0};
std::atomic<std::uint64_t> dropped_{0};
public:
bool try_push(T value) noexcept {
const auto head = head_.load(std::memory_order_relaxed);
const auto next = (head + 1) % Capacity;
if (next == tail_.load(std::memory_order_acquire)) { dropped_.fetch_add(1, std::memory_order_relaxed); return false; }
values_[head] = std::move(value);
head_.store(next, std::memory_order_release);
return true;
}
std::optional<T> try_pop() noexcept {
const auto tail = tail_.load(std::memory_order_relaxed);
if (tail == head_.load(std::memory_order_acquire)) return std::nullopt;
T value = std::move(values_[tail]);
tail_.store((tail + 1) % Capacity, std::memory_order_release);
return value;
}
std::uint64_t dropped() const noexcept { return dropped_.load(std::memory_order_relaxed); }
};
}

View File

@ -0,0 +1,476 @@
// SPDX-License-Identifier: GPL-2.0-or-later
// Copyright (c) 2026 OokamiKunTV
#include <obs-module.h>
#include <obs-frontend-api.h>
#include <nlohmann/json.hpp>
#include <windows.h>
#include <bcrypt.h>
#include <algorithm>
#include <array>
#include <atomic>
#include <chrono>
#include <cmath>
#include <condition_variable>
#include <cstdint>
#include <cstring>
#include <mutex>
#include <optional>
#include <set>
#include <string>
#include <thread>
#include <vector>
#include "bounded_spsc_queue.hpp"
#pragma comment(lib, "bcrypt.lib")
#ifndef LUMI_BRIDGE_VERSION
#define LUMI_BRIDGE_VERSION "0.1.0-development"
#endif
OBS_DECLARE_MODULE()
OBS_MODULE_USE_DEFAULT_LOCALE("lumi-obs-bridge", "en-US")
namespace {
using json = nlohmann::json;
constexpr uint8_t protocol_version = 1;
constexpr size_t max_json_bytes = 64 * 1024;
constexpr size_t max_input_frames = 4096;
constexpr uint32_t output_sample_rate = 16000;
constexpr char bridge_version[] = LUMI_BRIDGE_VERSION;
struct raw_audio_packet {
std::array<float, max_input_frames> mono{};
std::array<uint8_t, 16> source_uuid{};
uint64_t timestamp_ns = 0;
uint32_t frames = 0;
uint32_t sample_rate = 48000;
bool active = false;
bool muted = false;
};
struct source_description {
std::string uuid;
std::string name;
bool program_active = false;
};
static lumi::bounded_spsc_queue<raw_audio_packet, 32> audio_queue;
static std::condition_variable worker_signal;
static std::mutex worker_signal_mutex;
static std::thread worker_thread;
static std::atomic_bool stopping{false};
static std::atomic_bool source_list_dirty{true};
static std::atomic_bool obs_state_dirty{true};
static std::atomic_uint32_t audio_sequence{0};
static obs_source_t *captured_source = nullptr;
static std::mutex captured_source_mutex;
static std::string wide_to_utf8(const std::wstring &value)
{
if (value.empty()) return {};
const int bytes = WideCharToMultiByte(CP_UTF8, 0, value.data(), static_cast<int>(value.size()), nullptr, 0, nullptr, nullptr);
std::string output(static_cast<size_t>(bytes), '\0');
WideCharToMultiByte(CP_UTF8, 0, value.data(), static_cast<int>(value.size()), output.data(), bytes, nullptr, nullptr);
return output;
}
static std::wstring environment_value(const wchar_t *name)
{
const DWORD needed = GetEnvironmentVariableW(name, nullptr, 0);
if (!needed) return {};
std::wstring value(needed - 1, L'\0');
GetEnvironmentVariableW(name, value.data(), needed);
return value;
}
static std::string current_user_hash()
{
const auto identity = wide_to_utf8(environment_value(L"USERDOMAIN") + L"\\" + environment_value(L"USERNAME"));
BCRYPT_ALG_HANDLE algorithm = nullptr;
BCRYPT_HASH_HANDLE hash = nullptr;
std::array<uint8_t, 32> digest{};
if (BCryptOpenAlgorithmProvider(&algorithm, BCRYPT_SHA256_ALGORITHM, nullptr, 0) < 0) return {};
DWORD object_size = 0, result_size = 0;
BCryptGetProperty(algorithm, BCRYPT_OBJECT_LENGTH, reinterpret_cast<PUCHAR>(&object_size), sizeof(object_size), &result_size, 0);
std::vector<uint8_t> object(object_size);
const bool ok = BCryptCreateHash(algorithm, &hash, object.data(), object_size, nullptr, 0, 0) >= 0 &&
BCryptHashData(hash, reinterpret_cast<PUCHAR>(const_cast<char *>(identity.data())), static_cast<ULONG>(identity.size()), 0) >= 0 &&
BCryptFinishHash(hash, digest.data(), static_cast<ULONG>(digest.size()), 0) >= 0;
if (hash) BCryptDestroyHash(hash);
BCryptCloseAlgorithmProvider(algorithm, 0);
if (!ok) return {};
constexpr char hex[] = "0123456789ABCDEF";
std::string output;
output.reserve(16);
for (size_t index = 0; index < 8; ++index) {
output.push_back(hex[digest[index] >> 4]);
output.push_back(hex[digest[index] & 0x0f]);
}
return output;
}
static bool parse_uuid(const std::string &value, std::array<uint8_t, 16> &output)
{
std::string hex;
for (const auto character : value) if (character != '-') hex.push_back(character);
if (hex.size() != 32) return false;
const auto nibble = [](char value) -> int {
if (value >= '0' && value <= '9') return value - '0';
if (value >= 'a' && value <= 'f') return value - 'a' + 10;
if (value >= 'A' && value <= 'F') return value - 'A' + 10;
return -1;
};
for (size_t index = 0; index < output.size(); ++index) {
const auto high = nibble(hex[index * 2]);
const auto low = nibble(hex[index * 2 + 1]);
if (high < 0 || low < 0) return false;
output[index] = static_cast<uint8_t>((high << 4) | low);
}
return true;
}
static void write_u16(std::vector<uint8_t> &value, size_t offset, uint16_t input)
{
value[offset] = static_cast<uint8_t>(input);
value[offset + 1] = static_cast<uint8_t>(input >> 8);
}
static void write_u32(std::vector<uint8_t> &value, size_t offset, uint32_t input)
{
for (size_t index = 0; index < 4; ++index) value[offset + index] = static_cast<uint8_t>(input >> (index * 8));
}
static void write_u64(std::vector<uint8_t> &value, size_t offset, uint64_t input)
{
for (size_t index = 0; index < 8; ++index) value[offset + index] = static_cast<uint8_t>(input >> (index * 8));
}
static bool write_exact(HANDLE pipe, const uint8_t *data, size_t size)
{
while (size > 0) {
DWORD written = 0;
const auto chunk = static_cast<DWORD>(std::min<size_t>(size, 64 * 1024));
if (!WriteFile(pipe, data, chunk, &written, nullptr) || written == 0) return false;
data += written;
size -= written;
}
return true;
}
static bool read_exact(HANDLE pipe, uint8_t *data, size_t size)
{
while (size > 0) {
DWORD read = 0;
if (!ReadFile(pipe, data, static_cast<DWORD>(size), &read, nullptr) || read == 0) return false;
data += read;
size -= read;
}
return true;
}
static bool write_frame(HANDLE pipe, const uint8_t *data, size_t size)
{
std::array<uint8_t, 4> prefix{
static_cast<uint8_t>(size), static_cast<uint8_t>(size >> 8),
static_cast<uint8_t>(size >> 16), static_cast<uint8_t>(size >> 24)};
return write_exact(pipe, prefix.data(), prefix.size()) && write_exact(pipe, data, size);
}
static bool write_json(HANDLE pipe, const json &message)
{
const auto body = message.dump();
return body.size() <= max_json_bytes && write_frame(pipe, reinterpret_cast<const uint8_t *>(body.data()), body.size());
}
static bool scene_item_active(obs_scene_t *, obs_sceneitem_t *item, void *data);
static void collect_scene_sources(obs_source_t *scene_source, std::set<std::string> &active, std::set<std::string> &visited)
{
if (!scene_source) return;
const char *uuid = obs_source_get_uuid(scene_source);
if (uuid && !visited.insert(uuid).second) return;
obs_scene_t *scene = obs_scene_from_source(scene_source);
if (!scene) scene = obs_group_from_source(scene_source);
if (scene) {
std::pair values{&active, &visited};
obs_scene_enum_items(scene, scene_item_active, &values);
}
}
static bool scene_item_active(obs_scene_t *, obs_sceneitem_t *item, void *data)
{
if (!obs_sceneitem_visible(item)) return true;
auto &sets = *static_cast<std::pair<std::set<std::string> *, std::set<std::string> *> *>(data);
obs_source_t *source = obs_sceneitem_get_source(item);
if (!source) return true;
if (const char *uuid = obs_source_get_uuid(source)) sets.first->insert(uuid);
collect_scene_sources(source, *sets.first, *sets.second);
return true;
}
static std::set<std::string> program_sources()
{
std::set<std::string> active, visited;
obs_source_t *scene = obs_frontend_get_current_scene();
collect_scene_sources(scene, active, visited);
if (scene) obs_source_release(scene);
return active;
}
static bool enumerate_source(void *data, obs_source_t *source)
{
if (!source || !(obs_source_get_output_flags(source) & OBS_SOURCE_AUDIO)) return true;
auto &values = *static_cast<std::pair<std::vector<source_description> *, const std::set<std::string> *> *>(data);
const char *uuid = obs_source_get_uuid(source);
if (!uuid || !*uuid) return true;
values.first->push_back({uuid, obs_source_get_name(source) ? obs_source_get_name(source) : "OBS source", values.second->contains(uuid)});
return true;
}
static std::vector<source_description> enumerate_sources()
{
const auto active = program_sources();
std::vector<source_description> sources;
std::pair values{&sources, &active};
obs_enum_sources(enumerate_source, &values);
std::sort(sources.begin(), sources.end(), [](const auto &left, const auto &right) { return left.name < right.name; });
return sources;
}
static void on_audio(void *, obs_source_t *source, const audio_data *audio, bool muted)
{
if (!audio || !source || audio->frames == 0 || audio->frames > max_input_frames) return;
obs_audio_info info{};
if (!obs_get_audio_info(&info) || !info.samples_per_sec) return;
const auto channels = std::clamp<uint32_t>(get_audio_channels(info.speakers), 1, MAX_AUDIO_CHANNELS);
raw_audio_packet packet;
packet.frames = audio->frames;
packet.sample_rate = info.samples_per_sec;
packet.timestamp_ns = audio->timestamp;
packet.active = obs_source_active(source);
packet.muted = muted || obs_source_muted(source);
const char *uuid = obs_source_get_uuid(source);
if (!uuid || !parse_uuid(uuid, packet.source_uuid)) return;
for (uint32_t frame = 0; frame < audio->frames; ++frame) {
float sample = 0.0f;
for (uint32_t channel = 0; channel < channels; ++channel) {
if (audio->data[channel]) sample += reinterpret_cast<const float *>(audio->data[channel])[frame];
}
packet.mono[frame] = sample / static_cast<float>(channels);
}
if (audio_queue.try_push(std::move(packet))) worker_signal.notify_one();
}
static void detach_source()
{
std::scoped_lock lock(captured_source_mutex);
if (!captured_source) return;
obs_source_remove_audio_capture_callback(captured_source, on_audio, nullptr);
obs_source_release(captured_source);
captured_source = nullptr;
}
static bool select_source(const std::string &uuid)
{
detach_source();
if (uuid.empty()) return true;
obs_source_t *source = obs_get_source_by_uuid(uuid.c_str());
if (!source || !(obs_source_get_output_flags(source) & OBS_SOURCE_AUDIO)) {
if (source) obs_source_release(source);
return false;
}
{
std::scoped_lock lock(captured_source_mutex);
captured_source = source;
obs_source_add_audio_capture_callback(captured_source, on_audio, nullptr);
}
return true;
}
static std::vector<uint8_t> encode_audio(const raw_audio_packet &packet)
{
const auto output_frames = std::min<uint32_t>(3200, static_cast<uint32_t>((static_cast<uint64_t>(packet.frames) * output_sample_rate) / packet.sample_rate));
std::vector<uint8_t> frame(64 + static_cast<size_t>(output_frames) * 2, 0);
std::memcpy(frame.data(), "LACP", 4);
frame[4] = protocol_version;
frame[5] = static_cast<uint8_t>((packet.active ? 1 : 0) | (packet.muted ? 2 : 0));
write_u16(frame, 6, 64);
write_u32(frame, 8, audio_sequence.fetch_add(1, std::memory_order_relaxed));
write_u64(frame, 12, packet.timestamp_ns / 1000);
std::copy(packet.source_uuid.begin(), packet.source_uuid.end(), frame.begin() + 36);
write_u32(frame, 52, output_sample_rate);
write_u16(frame, 56, 1);
write_u16(frame, 58, 16);
write_u32(frame, 60, output_frames * 2);
for (uint32_t index = 0; index < output_frames; ++index) {
const auto source_position = std::min<uint32_t>(packet.frames - 1, static_cast<uint32_t>((static_cast<uint64_t>(index) * packet.sample_rate) / output_sample_rate));
const auto sample = static_cast<int16_t>(std::lrint(std::clamp(packet.mono[source_position], -1.0f, 1.0f) * 32767.0f));
write_u16(frame, 64 + static_cast<size_t>(index) * 2, static_cast<uint16_t>(sample));
}
return frame;
}
static json source_list_message()
{
json sources = json::array();
for (const auto &source : enumerate_sources()) {
sources.push_back({{"source_uuid", source.uuid}, {"display_name", source.name}, {"program_active", source.program_active}, {"source_missing", false}});
}
return {{"type", "source_list"}, {"protocol_version", protocol_version}, {"sources", std::move(sources)}};
}
static json obs_state_message()
{
return {{"type", "obs_state"}, {"protocol_version", protocol_version}, {"version", obs_get_version_string()},
{"streaming", obs_frontend_streaming_active()}, {"recording", obs_frontend_recording_active()}};
}
static bool output_caption(const std::string &text, double display_seconds)
{
if (!obs_frontend_streaming_active() || text.empty()) return false;
obs_output_t *output = obs_frontend_get_streaming_output();
if (!output) return false;
obs_output_output_caption_text2(output, text.c_str(), std::clamp(display_seconds, 0.5, 10.0));
obs_output_release(output);
return true;
}
static std::optional<json> handle_command(const json &message)
{
const auto type = message.value("type", "");
if (type == "select_sources") {
const auto uuid = message.value("primary_source_uuid", "");
const auto installed = select_source(uuid);
source_list_dirty.store(true, std::memory_order_release);
blog(installed ? LOG_INFO : LOG_WARNING, "[Lumi Companion] %s selected OBS audio source %s",
installed ? "Attached" : "Could not attach", uuid.c_str());
return json{{"type", "selection_state"}, {"protocol_version", protocol_version}, {"source_uuid", uuid}, {"attached", installed}};
} else if (type == "caption" && message.contains("payload")) {
const auto &payload = message["payload"];
const auto text = payload.value("stable_text", "");
const auto duration = payload.value("display_seconds", 2.0);
output_caption(text, duration);
}
return std::nullopt;
}
static bool read_available_command(HANDLE pipe)
{
DWORD available = 0;
if (!PeekNamedPipe(pipe, nullptr, 0, nullptr, &available, nullptr)) return false;
if (available < 4) return true;
std::array<uint8_t, 4> prefix{};
if (!read_exact(pipe, prefix.data(), prefix.size())) return false;
const uint32_t size = static_cast<uint32_t>(prefix[0]) | (static_cast<uint32_t>(prefix[1]) << 8) |
(static_cast<uint32_t>(prefix[2]) << 16) | (static_cast<uint32_t>(prefix[3]) << 24);
if (size == 0 || size > max_json_bytes) return false;
std::vector<uint8_t> body(size);
if (!read_exact(pipe, body.data(), body.size())) return false;
try {
const auto response = handle_command(json::parse(body.begin(), body.end()));
if (response && !write_json(pipe, *response)) return false;
}
catch (const std::exception &error) { blog(LOG_WARNING, "[Lumi Companion] Ignored invalid IPC command: %s", error.what()); }
return true;
}
static HANDLE connect_pipe()
{
const auto hash = current_user_hash();
if (hash.empty()) return INVALID_HANDLE_VALUE;
const auto name = L"\\\\.\\pipe\\Lumi.Companion.ObsBridge.v1." + std::wstring(hash.begin(), hash.end());
return CreateFileW(name.c_str(), GENERIC_READ | GENERIC_WRITE, 0, nullptr, OPEN_EXISTING, FILE_ATTRIBUTE_NORMAL, nullptr);
}
static void run_pipe_worker()
{
while (!stopping.load(std::memory_order_acquire)) {
HANDLE pipe = connect_pipe();
if (pipe == INVALID_HANDLE_VALUE) {
std::unique_lock lock(worker_signal_mutex);
worker_signal.wait_for(lock, std::chrono::seconds(2));
continue;
}
blog(LOG_INFO, "[Lumi Companion] connected to the same-user Companion IPC endpoint");
bool connected = write_json(pipe, {{"type", "hello"}, {"protocol_version", protocol_version}, {"obs_version", obs_get_version_string()}, {"bridge_version", bridge_version}});
source_list_dirty.store(true, std::memory_order_release);
obs_state_dirty.store(true, std::memory_order_release);
auto health_sent = std::chrono::steady_clock::now();
while (connected && !stopping.load(std::memory_order_acquire)) {
connected = read_available_command(pipe);
if (connected && source_list_dirty.exchange(false, std::memory_order_acq_rel)) connected = write_json(pipe, source_list_message());
if (connected && obs_state_dirty.exchange(false, std::memory_order_acq_rel)) connected = write_json(pipe, obs_state_message());
while (connected) {
auto packet = audio_queue.try_pop();
if (!packet) break;
const auto encoded = encode_audio(*packet);
connected = write_frame(pipe, encoded.data(), encoded.size());
}
const auto now = std::chrono::steady_clock::now();
if (connected && now - health_sent >= std::chrono::seconds(10)) {
connected = write_json(pipe, {{"type", "health"}, {"protocol_version", protocol_version},
{"status", "healthy"}, {"audio_frames_dropped", audio_queue.dropped()}});
health_sent = now;
}
std::unique_lock lock(worker_signal_mutex);
worker_signal.wait_for(lock, std::chrono::milliseconds(10));
}
CloseHandle(pipe);
blog(LOG_INFO, "[Lumi Companion] disconnected from Companion IPC; retrying safely");
}
}
static void frontend_event(obs_frontend_event event, void *)
{
switch (event) {
case OBS_FRONTEND_EVENT_STREAMING_STARTED:
case OBS_FRONTEND_EVENT_STREAMING_STOPPED:
case OBS_FRONTEND_EVENT_RECORDING_STARTED:
case OBS_FRONTEND_EVENT_RECORDING_STOPPED:
obs_state_dirty.store(true, std::memory_order_release);
break;
case OBS_FRONTEND_EVENT_SCENE_CHANGED:
case OBS_FRONTEND_EVENT_SCENE_LIST_CHANGED:
case OBS_FRONTEND_EVENT_SCENE_COLLECTION_CHANGED:
case OBS_FRONTEND_EVENT_FINISHED_LOADING:
source_list_dirty.store(true, std::memory_order_release);
break;
default:
break;
}
worker_signal.notify_one();
}
} // namespace
const char *obs_module_description(void)
{
return "Companion-managed Lumi audio and native-caption bridge";
}
bool obs_module_load(void)
{
const char *version = obs_get_version_string();
const int major = version ? std::atoi(version) : 0;
if (major < 31) {
blog(LOG_ERROR, "[Lumi Companion] OBS %s is unsupported; version 31 or newer is required", version ? version : "unknown");
return false;
}
stopping.store(false, std::memory_order_release);
obs_frontend_add_event_callback(frontend_event, nullptr);
worker_thread = std::thread(run_pipe_worker);
blog(LOG_INFO, "[Lumi Companion] bridge %s loaded", bridge_version);
return true;
}
void obs_module_unload(void)
{
obs_frontend_remove_event_callback(frontend_event, nullptr);
detach_source();
stopping.store(true, std::memory_order_release);
worker_signal.notify_all();
if (worker_thread.joinable()) worker_thread.join();
blog(LOG_INFO, "[Lumi Companion] bridge unloaded");
}

View File

@ -0,0 +1,15 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net8.0-windows10.0.19041.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<UseSystemDrawing>true</UseSystemDrawing>
<AssemblyName>Lumi.Companion.SongOverlay</AssemblyName>
<RootNamespace>Lumi.Companion.SongOverlay</RootNamespace>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="../../src/Lumi.Companion.Abstractions/Lumi.Companion.Abstractions.csproj" />
<PackageReference Include="System.Drawing.Common" Version="8.0.0" />
<PackageReference Include="System.Security.Cryptography.ProtectedData" Version="8.0.0" />
</ItemGroup>
</Project>

View File

@ -0,0 +1,53 @@
using System.Text.Json.Serialization;
namespace Lumi.Companion.SongOverlay;
internal sealed record MediaTrack(
string Key,
string Title,
string Artist,
string Album,
string ReleaseYear,
string Link,
long DurationMilliseconds,
CoverPayload? Cover);
internal sealed record MediaSnapshot(
string Provider,
string Status,
long PositionMilliseconds,
long DurationMilliseconds,
double PlaybackRate,
MediaTrack? Track,
DateTimeOffset CapturedAt);
internal sealed record CoverPayload(
[property: JsonPropertyName("mime")] string Mime,
[property: JsonPropertyName("base64")] string Base64,
[property: JsonPropertyName("primary")] string Primary,
[property: JsonPropertyName("secondary")] string Secondary);
internal sealed record TrackPayload(
[property: JsonPropertyName("key")] string Key,
[property: JsonPropertyName("title")] string Title,
[property: JsonPropertyName("artist")] string Artist,
[property: JsonPropertyName("album")] string Album,
[property: JsonPropertyName("release_year")] string ReleaseYear,
[property: JsonPropertyName("link")] string Link,
[property: JsonPropertyName("cover")] CoverPayload? Cover);
internal sealed record PlaybackPayload(
[property: JsonPropertyName("status")] string Status,
[property: JsonPropertyName("position_ms")] long PositionMilliseconds,
[property: JsonPropertyName("duration_ms")] long DurationMilliseconds,
[property: JsonPropertyName("rate")] double Rate);
internal sealed record NowPlayingEventPayload(
[property: JsonPropertyName("protocol_version")] int ProtocolVersion,
[property: JsonPropertyName("provider")] string Provider,
[property: JsonPropertyName("session_id")] string SessionId,
[property: JsonPropertyName("sequence")] long Sequence,
[property: JsonPropertyName("event")] string Event,
[property: JsonPropertyName("occurred_at")] long OccurredAt,
[property: JsonPropertyName("playback")] PlaybackPayload Playback,
[property: JsonPropertyName("track")] TrackPayload? Track);

View File

@ -0,0 +1,24 @@
namespace Lumi.Companion.SongOverlay.Providers;
internal interface IMediaProvider : IAsyncDisposable
{
string Id { get; }
string DisplayName { get; }
event EventHandler<ProviderStateChangedEventArgs>? StateChanged;
event EventHandler<string>? AvailabilityChanged;
Task StartAsync(CancellationToken cancellationToken);
Task StopAsync(CancellationToken cancellationToken);
Task<MediaSnapshot?> GetSnapshotAsync(CancellationToken cancellationToken);
}
internal sealed class ProviderStateChangedEventArgs : EventArgs
{
public ProviderStateChangedEventArgs(string reason, MediaSnapshot? snapshot)
{
Reason = reason;
Snapshot = snapshot;
}
public string Reason { get; }
public MediaSnapshot? Snapshot { get; }
}

View File

@ -0,0 +1,322 @@
using System.Drawing;
using System.Drawing.Imaging;
using System.Security.Cryptography;
using System.Text;
using Windows.Media.Control;
using Windows.Storage.Streams;
namespace Lumi.Companion.SongOverlay.Providers;
internal sealed class SpotifyWindowsMediaProvider : IMediaProvider
{
private const int MaxCoverInputBytes = 3 * 1024 * 1024;
private const int MaxCoverOutputBytes = 500 * 1024;
private readonly Func<SongOverlaySettings> _settings;
private readonly Func<MediaTrack, CancellationToken, Task<MediaTrack>> _enrich;
private readonly Action<string, Exception?> _log;
private GlobalSystemMediaTransportControlsSessionManager? _manager;
private GlobalSystemMediaTransportControlsSession? _session;
private MediaSnapshot? _last;
private MediaTrack? _trackCache;
private bool _started;
private readonly SemaphoreSlim _refreshLock = new(1, 1);
public SpotifyWindowsMediaProvider(Func<SongOverlaySettings> settings, Func<MediaTrack, CancellationToken, Task<MediaTrack>> enrich, Action<string, Exception?> log)
{
_settings = settings;
_enrich = enrich;
_log = log;
}
public string Id => "spotify";
public string DisplayName => "Spotify";
public event EventHandler<ProviderStateChangedEventArgs>? StateChanged;
public event EventHandler<string>? AvailabilityChanged;
public async Task StartAsync(CancellationToken cancellationToken)
{
if (_started) return;
_manager = await GlobalSystemMediaTransportControlsSessionManager.RequestAsync();
_manager.CurrentSessionChanged += OnSessionCollectionChanged;
_manager.SessionsChanged += OnSessionCollectionChanged;
_started = true;
await SelectSessionAsync(cancellationToken).ConfigureAwait(false);
}
public async Task StopAsync(CancellationToken cancellationToken)
{
if (!_started) return;
if (_manager is not null)
{
_manager.CurrentSessionChanged -= OnSessionCollectionChanged;
_manager.SessionsChanged -= OnSessionCollectionChanged;
}
DetachSession();
_manager = null;
_started = false;
await Task.CompletedTask;
}
public Task<MediaSnapshot?> GetSnapshotAsync(CancellationToken cancellationToken) => RefreshAsync("snapshot", true, cancellationToken);
private async void OnSessionCollectionChanged(GlobalSystemMediaTransportControlsSessionManager sender, object args)
{
try { await SelectSessionAsync(CancellationToken.None).ConfigureAwait(false); }
catch (Exception error) { _log("Could not refresh Spotify media sessions", error); }
}
private async Task SelectSessionAsync(CancellationToken cancellationToken)
{
var selected = _manager?.GetSessions().FirstOrDefault(IsSpotifySession);
if (ReferenceEquals(selected, _session))
{
if (_session is not null) await RefreshAndPublishAsync("snapshot", false, cancellationToken).ConfigureAwait(false);
return;
}
DetachSession();
_session = selected;
if (_session is null)
{
AvailabilityChanged?.Invoke(this, "Spotify is not exposing a Windows media session.");
if (_last is not null)
{
_last = null;
StateChanged?.Invoke(this, new ProviderStateChangedEventArgs("stop", null));
}
return;
}
_session.MediaPropertiesChanged += OnMediaPropertiesChanged;
_session.PlaybackInfoChanged += OnPlaybackInfoChanged;
_session.TimelinePropertiesChanged += OnTimelinePropertiesChanged;
AvailabilityChanged?.Invoke(this, "Spotify media session connected.");
await RefreshAndPublishAsync("snapshot", true, cancellationToken).ConfigureAwait(false);
}
private void DetachSession()
{
if (_session is null) return;
_session.MediaPropertiesChanged -= OnMediaPropertiesChanged;
_session.PlaybackInfoChanged -= OnPlaybackInfoChanged;
_session.TimelinePropertiesChanged -= OnTimelinePropertiesChanged;
_session = null;
_trackCache = null;
}
private async void OnMediaPropertiesChanged(GlobalSystemMediaTransportControlsSession sender, MediaPropertiesChangedEventArgs args)
{
try { await RefreshAndPublishAsync("media", true, CancellationToken.None).ConfigureAwait(false); }
catch (Exception error) { _log("Spotify media-property update failed", error); }
}
private async void OnPlaybackInfoChanged(GlobalSystemMediaTransportControlsSession sender, PlaybackInfoChangedEventArgs args)
{
try { await RefreshAndPublishAsync("playback", false, CancellationToken.None).ConfigureAwait(false); }
catch (Exception error) { _log("Spotify playback update failed", error); }
}
private async void OnTimelinePropertiesChanged(GlobalSystemMediaTransportControlsSession sender, TimelinePropertiesChangedEventArgs args)
{
try { await RefreshAndPublishAsync("timeline", false, CancellationToken.None).ConfigureAwait(false); }
catch (Exception error) { _log("Spotify timeline update failed", error); }
}
private async Task RefreshAndPublishAsync(string reason, bool enrichTrack, CancellationToken cancellationToken)
{
var snapshot = await RefreshAsync(reason, enrichTrack, cancellationToken).ConfigureAwait(false);
if (snapshot is null) return;
StateChanged?.Invoke(this, new ProviderStateChangedEventArgs(reason, snapshot));
}
private async Task<MediaSnapshot?> RefreshAsync(string reason, bool enrichTrack, CancellationToken cancellationToken)
{
if (_session is null) return null;
await _refreshLock.WaitAsync(cancellationToken).ConfigureAwait(false);
try
{
var playback = _session.GetPlaybackInfo();
var timeline = _session.GetTimelineProperties();
var duration = Math.Max(0, (long)timeline.EndTime.TotalMilliseconds);
var position = Math.Max(0, (long)timeline.Position.TotalMilliseconds);
var status = playback.PlaybackStatus switch
{
GlobalSystemMediaTransportControlsSessionPlaybackStatus.Playing => "playing",
GlobalSystemMediaTransportControlsSessionPlaybackStatus.Paused => "paused",
GlobalSystemMediaTransportControlsSessionPlaybackStatus.Stopped => "stopped",
GlobalSystemMediaTransportControlsSessionPlaybackStatus.Closed => "closed",
_ => "unknown"
};
var capturedAt = DateTimeOffset.UtcNow;
var rate = playback.PlaybackRate ?? 1d;
if (rate <= 0) rate = 1d;
if (status == "playing" && timeline.LastUpdatedTime != default)
{
var elapsed = Math.Max(0, (capturedAt - timeline.LastUpdatedTime).TotalMilliseconds);
position += (long)(elapsed * rate);
if (duration > 0) position = Math.Min(duration, position);
}
var needsMediaRefresh = _trackCache is null || reason is "media" or "snapshot";
if (needsMediaRefresh)
{
var media = await _session.TryGetMediaPropertiesAsync();
var title = Clean(media.Title);
var artist = Clean(media.Artist);
var album = Clean(media.AlbumTitle);
if (!string.IsNullOrWhiteSpace(title) || !string.IsNullOrWhiteSpace(artist))
{
var key = Fingerprint(title, artist, album, duration);
var cover = _settings().SendCoverArt ? await ReadCoverAsync(media.Thumbnail, cancellationToken).ConfigureAwait(false) : null;
var fallbackLink = _settings().UseSearchLinkFallback
? "https://open.spotify.com/search/" + Uri.EscapeDataString(string.Join(" ", new[] { title, artist }.Where(value => value.Length > 0)))
: "";
var track = new MediaTrack(key, title, artist, album, "", fallbackLink, duration, cover);
_trackCache = track;
if (enrichTrack) _ = EnrichAndPublishAsync(track);
}
else
{
_trackCache = null;
}
}
else if (_trackCache is not null && _trackCache.DurationMilliseconds != duration)
{
_trackCache = _trackCache with { DurationMilliseconds = duration };
}
var snapshot = new MediaSnapshot(Id, status, position, duration, rate, _trackCache, capturedAt);
_last = snapshot;
return snapshot;
}
finally { _refreshLock.Release(); }
}
private async Task EnrichAndPublishAsync(MediaTrack track)
{
try
{
var enriched = await _enrich(track, CancellationToken.None).ConfigureAwait(false);
if (enriched == track) return;
await _refreshLock.WaitAsync().ConfigureAwait(false);
try
{
if (_trackCache?.Key != track.Key) return;
_trackCache = enriched;
}
finally { _refreshLock.Release(); }
await RefreshAndPublishAsync("metadata", false, CancellationToken.None).ConfigureAwait(false);
}
catch (Exception error) { _log("Spotify metadata enrichment update failed", error); }
}
private static bool IsSpotifySession(GlobalSystemMediaTransportControlsSession session)
{
var source = session.SourceAppUserModelId ?? "";
return source.Contains("spotify", StringComparison.OrdinalIgnoreCase);
}
private static async Task<CoverPayload?> ReadCoverAsync(IRandomAccessStreamReference? reference, CancellationToken cancellationToken)
{
if (reference is null) return null;
try
{
using var stream = await reference.OpenReadAsync();
var length = checked((uint)Math.Min(stream.Size, MaxCoverInputBytes));
if (length == 0) return null;
using var reader = new DataReader(stream.GetInputStreamAt(0));
var loaded = await reader.LoadAsync(length);
cancellationToken.ThrowIfCancellationRequested();
if (loaded == 0) return null;
var bytes = new byte[checked((int)loaded)];
reader.ReadBytes(bytes);
var mime = string.IsNullOrWhiteSpace(stream.ContentType) ? "image/jpeg" : stream.ContentType;
return CreateCoverPayload(bytes, mime);
}
catch { return null; }
}
internal static CoverPayload? CreateCoverPayload(byte[] bytes, string sourceMime)
{
if (bytes.Length == 0 || bytes.Length > MaxCoverInputBytes) return null;
try
{
var encoded = ResizeJpeg(bytes, 512, 82L);
if (encoded.Length > MaxCoverOutputBytes) encoded = ResizeJpeg(bytes, 320, 68L);
if (encoded.Length == 0 || encoded.Length > MaxCoverOutputBytes) return null;
var colors = ExtractColors(encoded);
return new CoverPayload("image/jpeg", Convert.ToBase64String(encoded), colors.Primary, colors.Secondary);
}
catch { return null; }
}
private static byte[] ResizeJpeg(byte[] bytes, int maximumDimension, long quality)
{
using var input = new MemoryStream(bytes);
using var original = new Bitmap(input);
var ratio = Math.Min(1d, Math.Min((double)maximumDimension / original.Width, (double)maximumDimension / original.Height));
var width = Math.Max(1, (int)Math.Round(original.Width * ratio));
var height = Math.Max(1, (int)Math.Round(original.Height * ratio));
using var resized = new Bitmap(width, height, PixelFormat.Format24bppRgb);
using (var graphics = Graphics.FromImage(resized))
{
graphics.Clear(Color.Black);
graphics.CompositingQuality = System.Drawing.Drawing2D.CompositingQuality.HighQuality;
graphics.InterpolationMode = System.Drawing.Drawing2D.InterpolationMode.HighQualityBicubic;
graphics.SmoothingMode = System.Drawing.Drawing2D.SmoothingMode.HighQuality;
graphics.PixelOffsetMode = System.Drawing.Drawing2D.PixelOffsetMode.HighQuality;
graphics.DrawImage(original, 0, 0, width, height);
}
using var output = new MemoryStream();
var codec = ImageCodecInfo.GetImageEncoders().First(item => item.FormatID == ImageFormat.Jpeg.Guid);
using var parameters = new EncoderParameters(1);
parameters.Param[0] = new EncoderParameter(System.Drawing.Imaging.Encoder.Quality, quality);
resized.Save(output, codec, parameters);
return output.ToArray();
}
internal static (string Primary, string Secondary) ExtractColors(byte[] bytes)
{
try
{
using var memory = new MemoryStream(bytes);
using var bitmap = new Bitmap(memory);
var samples = new List<Color>();
var stepX = Math.Max(1, bitmap.Width / 12);
var stepY = Math.Max(1, bitmap.Height / 12);
for (var y = stepY / 2; y < bitmap.Height; y += stepY)
for (var x = stepX / 2; x < bitmap.Width; x += stepX)
{
var color = bitmap.GetPixel(x, y);
if (color.A > 100) samples.Add(color);
}
if (samples.Count == 0) return ("#1f2937", "#111827");
var primary = Average(samples.OrderByDescending(c => Saturation(c)).Take(Math.Max(1, samples.Count / 3)));
var secondary = Average(samples.OrderBy(c => Brightness(c)).Take(Math.Max(1, samples.Count / 3)));
return (Hex(primary), Hex(secondary));
}
catch { return ("#1f2937", "#111827"); }
}
private static Color Average(IEnumerable<Color> colors)
{
var list = colors.ToList();
return list.Count == 0 ? Color.FromArgb(31, 41, 55) : Color.FromArgb(
(int)list.Average(c => c.R), (int)list.Average(c => c.G), (int)list.Average(c => c.B));
}
private static double Saturation(Color c) => c.GetSaturation();
private static double Brightness(Color c) => c.GetBrightness();
private static string Hex(Color c) => $"#{c.R:X2}{c.G:X2}{c.B:X2}";
private static string Clean(string? value) => string.Join(" ", (value ?? "").Split((char[]?)null, StringSplitOptions.RemoveEmptyEntries)).Trim();
private static string Fingerprint(string title, string artist, string album, long duration)
{
var input = Encoding.UTF8.GetBytes($"{title}\u001f{artist}\u001f{album}\u001f{duration}");
return Convert.ToHexString(SHA256.HashData(input)).ToLowerInvariant();
}
public async ValueTask DisposeAsync()
{
await StopAsync(CancellationToken.None).ConfigureAwait(false);
_refreshLock.Dispose();
}
}

View File

@ -0,0 +1,383 @@
using Lumi.Companion.Abstractions;
using Lumi.Companion.SongOverlay.Providers;
using Lumi.Companion.SongOverlay.Spotify;
namespace Lumi.Companion.SongOverlay;
public sealed class SongOverlayRuntime : ICompanionPluginContribution, IAsyncDisposable
{
private const string PluginId = "now_playing";
private readonly SongOverlaySettingsStore _settingsStore;
private readonly SongOverlaySecretProtector _secrets = new();
private readonly SongOverlayTransport _transport;
private readonly string _logDirectory;
private IMediaProvider? _provider;
private SpotifyWebApiEnricher? _spotify;
private CancellationTokenSource? _lifetime;
private PeriodicTimer? _heartbeat;
private Task? _heartbeatTask;
private readonly SemaphoreSlim _eventLock = new(1, 1);
private MediaSnapshot? _lastObserved;
private MediaSnapshot? _lastDelivered;
private readonly List<string> _trackHistory = [];
private int _historyIndex = -1;
private long _sequence;
private readonly string _sessionId = Guid.NewGuid().ToString("N");
private bool _initialized;
public SongOverlayRuntime(
string pluginDataDirectory,
string logDirectory,
ICompanionPluginTransport hostTransport)
{
Directory.CreateDirectory(pluginDataDirectory);
_logDirectory = logDirectory;
_settingsStore = new SongOverlaySettingsStore(Path.Combine(pluginDataDirectory, "settings.json"));
_transport = new SongOverlayTransport(hostTransport);
Actions =
[
new CompanionPluginAction("send", () => "Send current song now", SendSnapshotAsync, () => _initialized && Settings.Enabled, 10),
new CompanionPluginAction("toggle", () => Settings.Enabled ? "Disable monitoring" : "Enable monitoring", ToggleEnabledAsync, () => _initialized, 20)
];
}
public CompanionPluginDescriptor Descriptor { get; } = new(
PluginId,
"Song Overlay",
new Version(0, 1, 1),
"Reads provider-neutral Windows media-session events and sends minimal playback changes to Lumi.",
200);
public IReadOnlyList<CompanionPluginPage> Pages { get; } =
[
new CompanionPluginPage("SongOverlay", "Overview & settings", 10)
];
public IReadOnlyList<CompanionPluginAction> Actions { get; }
public CompanionPluginStatus Status { get; private set; } = new(CompanionPluginHealth.Ready, "Starting", "Waiting for initialization.");
public SongOverlaySettings Settings => _settingsStore.Current;
public bool IsInitialized => _initialized;
public bool IsSpotifyEnrichmentConnected => _spotify?.IsConfigured == true;
public bool UsesCompanionAuthentication => _transport.IsConfigured;
public string ProviderStatus => Status.Detail ?? Status.Summary;
public string? CurrentTrack => _lastObserved?.Track is null ? null : $"{_lastObserved.Track.Title} — {_lastObserved.Track.Artist}";
public Uri? EffectiveLumiBaseUri => _transport.BaseUri;
public event Action? Changed;
public async Task InitializeAsync(CancellationToken cancellationToken = default)
{
await _settingsStore.LoadAsync(cancellationToken).ConfigureAwait(false);
if (string.IsNullOrWhiteSpace(Settings.ProviderId)) Settings.ProviderId = "spotify";
// Re-save through the current schema so legacy plugin-specific Lumi host
// and connection-key fields are removed after the shared transport upgrade.
await _settingsStore.SaveAsync(Settings, cancellationToken).ConfigureAwait(false);
_spotify = new SpotifyWebApiEnricher(Settings, _secrets, SaveSettingsSync, Log);
_initialized = true;
await RestartProviderAsync(cancellationToken).ConfigureAwait(false);
}
public async Task SaveSettingsAsync(bool restartProvider, CancellationToken cancellationToken = default)
{
Settings.HeartbeatSeconds = Math.Clamp(Settings.HeartbeatSeconds, 15, 300);
Settings.SeekThresholdMilliseconds = Math.Clamp(Settings.SeekThresholdMilliseconds, 500, 10000);
Settings.ProviderId = string.IsNullOrWhiteSpace(Settings.ProviderId) ? "spotify" : Settings.ProviderId.Trim().ToLowerInvariant();
await _settingsStore.SaveAsync(Settings, cancellationToken).ConfigureAwait(false);
if (restartProvider) await RestartProviderAsync(cancellationToken).ConfigureAwait(false);
RaiseChanged();
}
public async Task ToggleEnabledAsync(CancellationToken cancellationToken = default)
{
Settings.Enabled = !Settings.Enabled;
await SaveSettingsAsync(restartProvider: true, cancellationToken).ConfigureAwait(false);
}
public async Task ConnectSpotifyAsync(CancellationToken cancellationToken = default)
{
EnsureInitialized();
await _spotify!.AuthorizeAsync(cancellationToken).ConfigureAwait(false);
SaveSettingsSync();
RaiseChanged();
if (_transport.IsConfigured)
await SendSnapshotAsync(cancellationToken).ConfigureAwait(false);
}
public void DisconnectSpotify()
{
_spotify?.Disconnect();
RaiseChanged();
}
public async Task SendSnapshotAsync(CancellationToken cancellationToken = default)
{
EnsureInitialized();
if (!_transport.IsConfigured) throw new InvalidOperationException("Pair Companion with Lumi before sending song updates.");
if (_provider is null) throw new InvalidOperationException("No media provider is running.");
var snapshot = await _provider.GetSnapshotAsync(cancellationToken).ConfigureAwait(false);
if (snapshot is null) throw new InvalidOperationException("Spotify is not currently exposing playback information.");
await SendAsync("snapshot", snapshot, includeTrack: true, cancellationToken).ConfigureAwait(false);
}
private async Task RestartProviderAsync(CancellationToken cancellationToken)
{
await StopProviderAsync().ConfigureAwait(false);
if (!Settings.Enabled)
{
SetStatus(CompanionPluginHealth.Ready, "Disabled", "Song Overlay monitoring is disabled.");
return;
}
_lastObserved = null;
_lastDelivered = null;
_lifetime = new CancellationTokenSource();
_provider = Settings.ProviderId switch
{
"spotify" => new SpotifyWindowsMediaProvider(() => Settings, EnrichTrackAsync, Log),
_ => throw new InvalidOperationException($"Provider '{Settings.ProviderId}' is not installed.")
};
_provider.StateChanged += OnProviderStateChanged;
_provider.AvailabilityChanged += OnAvailabilityChanged;
try
{
await _provider.StartAsync(cancellationToken).ConfigureAwait(false);
StartHeartbeat();
var initial = await _provider.GetSnapshotAsync(cancellationToken).ConfigureAwait(false);
if (!_transport.IsConfigured)
SetStatus(CompanionPluginHealth.Warning, "Pairing required", "Spotify monitoring is ready, but Companion must be paired before Lumi can receive updates.");
else if (initial is null)
SetStatus(CompanionPluginHealth.Warning, "Waiting for Spotify", "Spotify is not currently exposing a Windows media session.");
else
SetStatus(CompanionPluginHealth.Healthy, "Monitoring", Describe(initial));
}
catch (Exception error)
{
SetStatus(CompanionPluginHealth.Error, "Provider failed", "Could not start the Spotify media provider: " + error.Message);
throw;
}
}
private async Task StopProviderAsync()
{
_lifetime?.Cancel();
_heartbeat?.Dispose();
if (_heartbeatTask is not null)
{
try { await _heartbeatTask.ConfigureAwait(false); } catch { }
}
_heartbeat = null;
_heartbeatTask = null;
if (_provider is not null)
{
_provider.StateChanged -= OnProviderStateChanged;
_provider.AvailabilityChanged -= OnAvailabilityChanged;
await _provider.DisposeAsync().ConfigureAwait(false);
}
_provider = null;
_lifetime?.Dispose();
_lifetime = null;
}
private void StartHeartbeat()
{
_heartbeat = new PeriodicTimer(TimeSpan.FromSeconds(Math.Clamp(Settings.HeartbeatSeconds, 15, 300)));
var token = _lifetime?.Token ?? CancellationToken.None;
_heartbeatTask = Task.Run(async () =>
{
try
{
while (_heartbeat is not null && await _heartbeat.WaitForNextTickAsync(token).ConfigureAwait(false))
{
var current = _lastObserved;
if (current is null || !_transport.IsConfigured) continue;
await SendAsync("heartbeat", current, includeTrack: _lastDelivered?.Track?.Key != current.Track?.Key, token).ConfigureAwait(false);
}
}
catch (OperationCanceledException) { }
catch (Exception error) { Log("Song Overlay heartbeat failed", error); }
}, token);
}
private void OnAvailabilityChanged(object? sender, string message)
{
if (!_transport.IsConfigured)
{
SetStatus(CompanionPluginHealth.Warning, "Pairing required", message);
return;
}
var waiting = message.Contains("not exposing", StringComparison.OrdinalIgnoreCase);
SetStatus(waiting ? CompanionPluginHealth.Warning : CompanionPluginHealth.Healthy,
waiting ? "Waiting for Spotify" : "Monitoring", message);
}
private void OnProviderStateChanged(object? sender, ProviderStateChangedEventArgs args) =>
_ = HandleStateChangeAsync(args.Reason, args.Snapshot, _lifetime?.Token ?? CancellationToken.None);
private async Task HandleStateChangeAsync(string reason, MediaSnapshot? snapshot, CancellationToken cancellationToken)
{
await _eventLock.WaitAsync(cancellationToken).ConfigureAwait(false);
try
{
if (snapshot is null)
{
if (_lastObserved is null) return;
var stopped = _lastObserved with { Status = "stopped", PositionMilliseconds = 0, Track = null, CapturedAt = DateTimeOffset.UtcNow };
_lastObserved = null;
if (_transport.IsConfigured) await SendAsync("stop", stopped, false, cancellationToken).ConfigureAwait(false);
else RaiseChanged();
return;
}
var previous = _lastObserved;
_lastObserved = snapshot;
RaiseChanged();
if (!_transport.IsConfigured)
{
SetStatus(CompanionPluginHealth.Warning, "Pairing required", Describe(snapshot));
return;
}
var currentTrackKey = snapshot.Track?.Key;
var trackChanged = !string.IsNullOrWhiteSpace(currentTrackKey)
&& !string.Equals(currentTrackKey, previous?.Track?.Key, StringComparison.Ordinal);
if (trackChanged)
{
await SendAsync(ClassifyTrackDirection(currentTrackKey!), snapshot, true, cancellationToken).ConfigureAwait(false);
return;
}
if (previous is null)
{
await SendAsync("snapshot", snapshot, true, cancellationToken).ConfigureAwait(false);
return;
}
if (reason == "metadata")
{
await SendAsync("snapshot", snapshot, true, cancellationToken).ConfigureAwait(false);
return;
}
if (!string.Equals(previous.Status, snapshot.Status, StringComparison.Ordinal))
{
var playbackEvent = snapshot.Status switch
{
"paused" => "pause",
"stopped" or "closed" => "stop",
"playing" when previous.Status == "paused" => "resume",
"playing" => "play",
_ => "snapshot"
};
await SendAsync(playbackEvent, snapshot, false, cancellationToken).ConfigureAwait(false);
return;
}
if (reason == "timeline")
{
var expected = ProjectPosition(previous, snapshot.CapturedAt);
if (Math.Abs(snapshot.PositionMilliseconds - expected) >= Math.Clamp(Settings.SeekThresholdMilliseconds, 500, 10000))
await SendAsync("seek", snapshot, false, cancellationToken).ConfigureAwait(false);
}
}
catch (OperationCanceledException) { }
catch (Exception error)
{
Log("Could not process a Song Overlay event", error);
SetStatus(CompanionPluginHealth.Warning, "Delivery delayed", "Playback was detected, but Lumi could not be updated: " + error.Message);
}
finally { _eventLock.Release(); }
}
private async Task SendAsync(string eventName, MediaSnapshot snapshot, bool includeTrack, CancellationToken cancellationToken)
{
var track = includeTrack && snapshot.Track is not null
? new TrackPayload(snapshot.Track.Key, snapshot.Track.Title, snapshot.Track.Artist, snapshot.Track.Album,
snapshot.Track.ReleaseYear, snapshot.Track.Link, snapshot.Track.Cover)
: null;
var payload = new NowPlayingEventPayload(
1,
snapshot.Provider,
_sessionId,
Interlocked.Increment(ref _sequence),
eventName,
snapshot.CapturedAt.ToUnixTimeMilliseconds(),
new PlaybackPayload(snapshot.Status, snapshot.PositionMilliseconds, snapshot.DurationMilliseconds, snapshot.PlaybackRate),
track);
var response = await _transport.SendJsonAsync("/plugins/now_playing/api/companion/state", payload, cancellationToken).ConfigureAwait(false);
if (!response.IsSuccessStatusCode)
{
throw new InvalidOperationException($"Lumi rejected the update with HTTP {response.StatusCode}: {Trim(response.Body, 240)}");
}
_lastDelivered = snapshot;
SetStatus(CompanionPluginHealth.Healthy, snapshot.Status == "playing" ? "Playing" : "Connected", Describe(snapshot));
}
private async Task<MediaTrack> EnrichTrackAsync(MediaTrack track, CancellationToken cancellationToken)
{
if (_spotify?.IsConfigured != true) return track;
SpotifyEnrichment? enriched = null;
foreach (var delay in new[] { 0, 500, 1000 })
{
if (delay > 0) await Task.Delay(delay, cancellationToken).ConfigureAwait(false);
enriched = await _spotify.EnrichAsync(track, cancellationToken).ConfigureAwait(false);
if (enriched is not null) break;
}
if (enriched is null) return track;
CoverPayload? cover = track.Cover;
if (enriched.CoverBytes is { Length: > 0 } bytes && Settings.SendCoverArt)
cover = SpotifyWindowsMediaProvider.CreateCoverPayload(bytes, enriched.CoverMime) ?? cover;
return track with
{
Link = string.IsNullOrWhiteSpace(enriched.Link) ? track.Link : enriched.Link,
ReleaseYear = string.IsNullOrWhiteSpace(enriched.ReleaseYear) ? track.ReleaseYear : enriched.ReleaseYear,
Cover = cover
};
}
private string ClassifyTrackDirection(string key)
{
if (_historyIndex > 0 && _trackHistory[_historyIndex - 1] == key) { _historyIndex--; return "previous"; }
if (_historyIndex >= 0 && _historyIndex + 1 < _trackHistory.Count && _trackHistory[_historyIndex + 1] == key) { _historyIndex++; return "next"; }
if (_historyIndex + 1 < _trackHistory.Count) _trackHistory.RemoveRange(_historyIndex + 1, _trackHistory.Count - _historyIndex - 1);
_trackHistory.Add(key);
if (_trackHistory.Count > 50) _trackHistory.RemoveAt(0);
_historyIndex = _trackHistory.Count - 1;
return "track_changed";
}
private static long ProjectPosition(MediaSnapshot snapshot, DateTimeOffset at)
{
if (snapshot.Status != "playing") return snapshot.PositionMilliseconds;
var elapsed = Math.Max(0, (at - snapshot.CapturedAt).TotalMilliseconds);
return Math.Min(snapshot.DurationMilliseconds > 0 ? snapshot.DurationMilliseconds : long.MaxValue,
snapshot.PositionMilliseconds + (long)(elapsed * snapshot.PlaybackRate));
}
private void SaveSettingsSync() => _settingsStore.Save(Settings);
private void EnsureInitialized()
{
if (!_initialized) throw new InvalidOperationException("The Song Overlay companion plugin has not initialized yet.");
}
private void SetStatus(CompanionPluginHealth health, string summary, string detail)
{
Status = new CompanionPluginStatus(health, summary, detail);
RaiseChanged();
}
private void RaiseChanged() => Changed?.Invoke();
private void Log(string message, Exception? error = null)
{
try
{
Directory.CreateDirectory(_logDirectory);
File.AppendAllText(Path.Combine(_logDirectory, $"song-overlay-{DateTime.UtcNow:yyyyMMdd}.log"),
$"{DateTimeOffset.Now:O}\t{message}{(error is null ? "" : "\t" + error)}{Environment.NewLine}");
}
catch { }
}
private static string Describe(MediaSnapshot snapshot) => snapshot.Track is null
? "Connected; no active song."
: $"{snapshot.Status}: {snapshot.Track.Title} — {snapshot.Track.Artist}";
private static string Trim(string value, int max) => value.Length <= max ? value : value[..max];
public async ValueTask DisposeAsync()
{
await StopProviderAsync().ConfigureAwait(false);
_spotify?.Dispose();
_eventLock.Dispose();
}
}

View File

@ -0,0 +1,28 @@
using System.Security.Cryptography;
using System.Text;
namespace Lumi.Companion.SongOverlay;
internal sealed class SongOverlaySecretProtector
{
private static readonly byte[] Entropy = "Lumi.Companion.SongOverlay.v1"u8.ToArray();
public string Protect(string value)
{
if (string.IsNullOrWhiteSpace(value)) return "";
return Convert.ToBase64String(ProtectedData.Protect(Encoding.UTF8.GetBytes(value), Entropy, DataProtectionScope.CurrentUser));
}
public string Unprotect(string value)
{
if (string.IsNullOrWhiteSpace(value)) return "";
try
{
return Encoding.UTF8.GetString(ProtectedData.Unprotect(Convert.FromBase64String(value), Entropy, DataProtectionScope.CurrentUser));
}
catch (CryptographicException)
{
return "";
}
}
}

View File

@ -0,0 +1,13 @@
namespace Lumi.Companion.SongOverlay;
public sealed class SongOverlaySettings
{
public bool Enabled { get; set; } = true;
public string ProviderId { get; set; } = "spotify";
public int HeartbeatSeconds { get; set; } = 30;
public int SeekThresholdMilliseconds { get; set; } = 1500;
public bool SendCoverArt { get; set; } = true;
public bool UseSearchLinkFallback { get; set; } = true;
public string SpotifyClientId { get; set; } = "";
public string ProtectedSpotifyRefreshToken { get; set; } = "";
}

View File

@ -0,0 +1,68 @@
using System.Text.Json;
namespace Lumi.Companion.SongOverlay;
internal sealed class SongOverlaySettingsStore(string path)
{
private readonly SemaphoreSlim _gate = new(1, 1);
public SongOverlaySettings Current { get; private set; } = new();
public async Task LoadAsync(CancellationToken cancellationToken = default)
{
if (!File.Exists(path)) return;
try
{
Current = JsonSerializer.Deserialize<SongOverlaySettings>(
await File.ReadAllBytesAsync(path, cancellationToken),
SongOverlayJson.Options) ?? new();
}
catch (JsonException)
{
Current = new();
}
}
public void Save(SongOverlaySettings settings)
{
_gate.Wait();
try
{
Directory.CreateDirectory(Path.GetDirectoryName(path)!);
var temporary = $"{path}.{Environment.ProcessId}.tmp";
try
{
File.WriteAllBytes(temporary, JsonSerializer.SerializeToUtf8Bytes(settings, SongOverlayJson.Options));
File.Move(temporary, path, true);
}
finally { File.Delete(temporary); }
Current = settings;
}
finally { _gate.Release(); }
}
public async Task SaveAsync(SongOverlaySettings 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, SongOverlayJson.Options), cancellationToken);
File.Move(temporary, path, true);
}
finally { File.Delete(temporary); }
Current = settings;
}
finally { _gate.Release(); }
}
}
internal static class SongOverlayJson
{
public static readonly JsonSerializerOptions Options = new(JsonSerializerDefaults.Web)
{
DefaultIgnoreCondition = System.Text.Json.Serialization.JsonIgnoreCondition.WhenWritingNull
};
}

View File

@ -0,0 +1,16 @@
using Lumi.Companion.Abstractions;
namespace Lumi.Companion.SongOverlay;
internal sealed class SongOverlayTransport(ICompanionPluginTransport host)
{
public Uri? BaseUri => host.LumiBaseUri;
public bool IsConfigured => host.IsAuthenticated;
public Task<CompanionPluginHttpResponse> SendJsonAsync(
string relativePath,
object payload,
CancellationToken cancellationToken = default) =>
host.PostJsonAsync(relativePath, payload, cancellationToken);
}

View File

@ -0,0 +1,256 @@
using System.Diagnostics;
using System.Net;
using System.Net.Http.Headers;
using System.Net.Sockets;
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
namespace Lumi.Companion.SongOverlay.Spotify;
internal sealed record SpotifyEnrichment(string Link, string ReleaseYear, byte[]? CoverBytes, string CoverMime);
internal sealed class SpotifyWebApiEnricher : IDisposable
{
private const string Scope = "user-read-currently-playing";
private readonly HttpClient _http = new() { Timeout = TimeSpan.FromSeconds(15) };
private readonly SongOverlaySecretProtector _secrets;
private readonly Action _save;
private readonly Action<string, Exception?> _log;
private readonly SongOverlaySettings _settings;
private string _accessToken = "";
private DateTimeOffset _accessTokenExpiresAt = DateTimeOffset.MinValue;
public SpotifyWebApiEnricher(SongOverlaySettings settings, SongOverlaySecretProtector secrets, Action save, Action<string, Exception?> log)
{
_settings = settings;
_secrets = secrets;
_save = save;
_log = log;
_http.DefaultRequestHeaders.UserAgent.ParseAdd("Lumi-Companion-NowPlaying/0.1.0");
}
public bool IsConfigured => !string.IsNullOrWhiteSpace(_settings.SpotifyClientId) && !string.IsNullOrWhiteSpace(_settings.ProtectedSpotifyRefreshToken);
public async Task AuthorizeAsync(CancellationToken cancellationToken)
{
var clientId = _settings.SpotifyClientId.Trim();
if (string.IsNullOrWhiteSpace(clientId)) throw new InvalidOperationException("Enter your Spotify application Client ID first.");
var verifier = Base64Url(RandomNumberGenerator.GetBytes(64));
var challenge = Base64Url(SHA256.HashData(Encoding.ASCII.GetBytes(verifier)));
var state = Base64Url(RandomNumberGenerator.GetBytes(24));
var port = FindFreePort();
var redirectUri = $"http://127.0.0.1:{port}/callback/";
using var listener = new HttpListener();
listener.Prefixes.Add(redirectUri);
listener.Start();
var authorizationUrl = "https://accounts.spotify.com/authorize?" + BuildQuery(new Dictionary<string, string>
{
["client_id"] = clientId,
["response_type"] = "code",
["redirect_uri"] = redirectUri,
["scope"] = Scope,
["code_challenge_method"] = "S256",
["code_challenge"] = challenge,
["state"] = state
});
Process.Start(new ProcessStartInfo(authorizationUrl) { UseShellExecute = true });
using var timeout = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
timeout.CancelAfter(TimeSpan.FromMinutes(3));
var contextTask = listener.GetContextAsync();
var completed = await Task.WhenAny(contextTask, Task.Delay(Timeout.InfiniteTimeSpan, timeout.Token)).ConfigureAwait(false);
if (completed != contextTask) throw new TimeoutException("Spotify authorization timed out.");
var context = await contextTask.ConfigureAwait(false);
var query = context.Request.QueryString;
var responseText = "Spotify is connected to Lumi Companion. You can close this page.";
try
{
if (!string.Equals(query["state"], state, StringComparison.Ordinal)) throw new InvalidOperationException("Spotify returned an invalid authorization state.");
if (!string.IsNullOrWhiteSpace(query["error"])) throw new InvalidOperationException("Spotify authorization was declined: " + query["error"]);
var code = query["code"] ?? throw new InvalidOperationException("Spotify did not return an authorization code.");
var token = await ExchangeAsync(new Dictionary<string, string>
{
["client_id"] = clientId,
["grant_type"] = "authorization_code",
["code"] = code,
["redirect_uri"] = redirectUri,
["code_verifier"] = verifier
}, cancellationToken).ConfigureAwait(false);
ApplyToken(token);
if (string.IsNullOrWhiteSpace(token.RefreshToken)) throw new InvalidOperationException("Spotify did not provide a refresh token.");
_settings.ProtectedSpotifyRefreshToken = _secrets.Protect(token.RefreshToken);
_save();
}
catch
{
responseText = "Spotify could not be connected to Lumi Companion. Return to the app for details.";
throw;
}
finally
{
var bytes = Encoding.UTF8.GetBytes($"<!doctype html><meta charset=\"utf-8\"><title>Lumi Companion</title><style>body{{font:18px system-ui;max-width:720px;margin:15vh auto;padding:24px;background:#111827;color:#f9fafb}}</style><h1>Lumi Companion</h1><p>{WebUtility.HtmlEncode(responseText)}</p>");
context.Response.ContentType = "text/html; charset=utf-8";
context.Response.ContentLength64 = bytes.Length;
await context.Response.OutputStream.WriteAsync(bytes, cancellationToken).ConfigureAwait(false);
context.Response.Close();
}
}
public void Disconnect()
{
_accessToken = "";
_accessTokenExpiresAt = DateTimeOffset.MinValue;
_settings.ProtectedSpotifyRefreshToken = "";
_save();
}
public async Task<SpotifyEnrichment?> EnrichAsync(MediaTrack track, CancellationToken cancellationToken)
{
if (!IsConfigured) return null;
try
{
var token = await GetAccessTokenAsync(cancellationToken).ConfigureAwait(false);
if (string.IsNullOrWhiteSpace(token)) return null;
using var response = await SendCurrentlyPlayingAsync(token, cancellationToken).ConfigureAwait(false);
if (response.StatusCode == HttpStatusCode.NoContent) return null;
if (response.StatusCode == HttpStatusCode.Unauthorized)
{
_accessToken = "";
token = await GetAccessTokenAsync(cancellationToken).ConfigureAwait(false);
if (string.IsNullOrWhiteSpace(token)) return null;
using var retry = await SendCurrentlyPlayingAsync(token, cancellationToken).ConfigureAwait(false);
if (retry.StatusCode == HttpStatusCode.NoContent || !retry.IsSuccessStatusCode) return null;
return await ParseEnrichmentAsync(retry, track, cancellationToken).ConfigureAwait(false);
}
if (!response.IsSuccessStatusCode) return null;
return await ParseEnrichmentAsync(response, track, cancellationToken).ConfigureAwait(false);
}
catch (Exception error)
{
_log("Spotify metadata enrichment failed", error);
return null;
}
}
private async Task<HttpResponseMessage> SendCurrentlyPlayingAsync(string token, CancellationToken cancellationToken)
{
using var request = new HttpRequestMessage(HttpMethod.Get, "https://api.spotify.com/v1/me/player/currently-playing?additional_types=track");
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token);
return await _http.SendAsync(request, cancellationToken).ConfigureAwait(false);
}
private async Task<SpotifyEnrichment?> ParseEnrichmentAsync(HttpResponseMessage response, MediaTrack track, CancellationToken cancellationToken)
{
using var document = JsonDocument.Parse(await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false));
if (!document.RootElement.TryGetProperty("item", out var item) || item.ValueKind != JsonValueKind.Object) return null;
var title = String(item, "name");
var artists = item.TryGetProperty("artists", out var artistArray) && artistArray.ValueKind == JsonValueKind.Array
? string.Join(", ", artistArray.EnumerateArray().Select(value => String(value, "name")).Where(value => value.Length > 0))
: "";
if (!LooseMatch(track.Title, title) || (!string.IsNullOrWhiteSpace(track.Artist) && !LooseMatch(track.Artist, artists))) return null;
var link = item.TryGetProperty("external_urls", out var urls) ? String(urls, "spotify") : "";
var releaseYear = "";
string imageUrl = "";
if (item.TryGetProperty("album", out var album))
{
var releaseDate = String(album, "release_date");
if (releaseDate.Length >= 4) releaseYear = releaseDate[..4];
if (album.TryGetProperty("images", out var images) && images.ValueKind == JsonValueKind.Array)
imageUrl = images.EnumerateArray().Select(value => String(value, "url")).FirstOrDefault(value => value.Length > 0) ?? "";
}
byte[]? cover = null;
var mime = "image/jpeg";
if (!string.IsNullOrWhiteSpace(imageUrl))
{
using var imageResponse = await _http.GetAsync(imageUrl, cancellationToken).ConfigureAwait(false);
if (imageResponse.IsSuccessStatusCode)
{
cover = await imageResponse.Content.ReadAsByteArrayAsync(cancellationToken).ConfigureAwait(false);
if (cover.Length > 3 * 1024 * 1024) cover = null;
mime = imageResponse.Content.Headers.ContentType?.MediaType ?? mime;
}
}
return new SpotifyEnrichment(link, releaseYear, cover, mime);
}
private async Task<string> GetAccessTokenAsync(CancellationToken cancellationToken)
{
if (!string.IsNullOrWhiteSpace(_accessToken) && _accessTokenExpiresAt > DateTimeOffset.UtcNow.AddMinutes(1)) return _accessToken;
var refreshToken = _secrets.Unprotect(_settings.ProtectedSpotifyRefreshToken);
if (string.IsNullOrWhiteSpace(refreshToken)) return "";
try
{
var token = await ExchangeAsync(new Dictionary<string, string>
{
["client_id"] = _settings.SpotifyClientId.Trim(),
["grant_type"] = "refresh_token",
["refresh_token"] = refreshToken
}, cancellationToken).ConfigureAwait(false);
ApplyToken(token);
if (!string.IsNullOrWhiteSpace(token.RefreshToken))
{
_settings.ProtectedSpotifyRefreshToken = _secrets.Protect(token.RefreshToken);
_save();
}
return _accessToken;
}
catch (SpotifyInvalidGrantException)
{
Disconnect();
return "";
}
}
private async Task<TokenResponse> ExchangeAsync(Dictionary<string, string> values, CancellationToken cancellationToken)
{
using var response = await _http.PostAsync("https://accounts.spotify.com/api/token", new FormUrlEncodedContent(values), cancellationToken).ConfigureAwait(false);
var body = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false);
if (!response.IsSuccessStatusCode)
{
if (body.Contains("invalid_grant", StringComparison.OrdinalIgnoreCase)) throw new SpotifyInvalidGrantException();
throw new InvalidOperationException($"Spotify token exchange failed with HTTP {(int)response.StatusCode}.");
}
using var document = JsonDocument.Parse(body);
return new TokenResponse(
String(document.RootElement, "access_token"),
String(document.RootElement, "refresh_token"),
document.RootElement.TryGetProperty("expires_in", out var expires) ? expires.GetInt32() : 3600);
}
private void ApplyToken(TokenResponse token)
{
_accessToken = token.AccessToken;
_accessTokenExpiresAt = DateTimeOffset.UtcNow.AddSeconds(Math.Max(60, token.ExpiresIn));
}
private static string String(JsonElement element, string property) =>
element.TryGetProperty(property, out var value) && value.ValueKind == JsonValueKind.String ? value.GetString() ?? "" : "";
private static bool LooseMatch(string left, string right)
{
static string Normalize(string value) => new(value.ToLowerInvariant().Where(char.IsLetterOrDigit).ToArray());
var a = Normalize(left); var b = Normalize(right);
return a.Length > 0 && b.Length > 0 && (a == b || a.Contains(b, StringComparison.Ordinal) || b.Contains(a, StringComparison.Ordinal));
}
private static int FindFreePort()
{
var listener = new TcpListener(IPAddress.Loopback, 0);
listener.Start();
var port = ((IPEndPoint)listener.LocalEndpoint).Port;
listener.Stop();
return port;
}
private static string BuildQuery(IEnumerable<KeyValuePair<string, string>> values) =>
string.Join("&", values.Select(pair => $"{Uri.EscapeDataString(pair.Key)}={Uri.EscapeDataString(pair.Value)}"));
private static string Base64Url(byte[] bytes) => Convert.ToBase64String(bytes).TrimEnd('=').Replace('+', '-').Replace('/', '_');
public void Dispose() => _http.Dispose();
private sealed record TokenResponse(string AccessToken, string RefreshToken, int ExpiresIn);
private sealed class SpotifyInvalidGrantException : Exception { }
}

View File

@ -0,0 +1,16 @@
{
"id": "now_playing",
"name": "Song Overlay",
"version": "0.1.2",
"provider_api": 1,
"providers": ["spotify"],
"capabilities": [
"windows.media-session.read",
"network.lumi",
"network.spotify.optional"
],
"navigation": {
"root": "Song Overlay",
"pages": ["SongOverlay"]
}
}

View File

@ -0,0 +1 @@
<Project Sdk="Microsoft.NET.Sdk"><PropertyGroup><TargetFramework>net8.0-windows</TargetFramework><Nullable>enable</Nullable><ImplicitUsings>enable</ImplicitUsings></PropertyGroup><ItemGroup><ProjectReference Include="../../src/Lumi.Companion.Core/Lumi.Companion.Core.csproj" /><ProjectReference Include="../../src/Lumi.Companion.Protocol/Lumi.Companion.Protocol.csproj" /></ItemGroup></Project>

View File

@ -0,0 +1,93 @@
using System.Buffers.Binary;
using System.IO.Pipes;
using System.Text.Json;
using Lumi.Companion.Protocol;
namespace Lumi.Companion.Transcription;
public sealed class ObsBridgePipe : IAsyncDisposable
{
private static readonly HashSet<string> AllowedTypes = ["hello", "source_list", "source_state", "selection_state", "obs_state", "health"];
private readonly string _pipeName;
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)
{
while (!cancellationToken.IsCancellationRequested)
{
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)
{
var lengthBytes = new byte[4];
while (!cancellationToken.IsCancellationRequested)
{
await ReadExactlyAsync(stream, lengthBytes, cancellationToken);
var length = BinaryPrimitives.ReadUInt32LittleEndian(lengthBytes);
if (length is 0 or > ProtocolV1.MaxJsonBytes) throw new InvalidDataException("OBS bridge message exceeds its limit.");
var message = new byte[(int)length];
await ReadExactlyAsync(stream, message, cancellationToken);
if (length >= 4 && message.AsSpan(0, 4).SequenceEqual("LACP"u8))
{
if (length > ProtocolV1.AudioHeaderBytes + ProtocolV1.MaxAudioPayloadBytes) throw new InvalidDataException("OBS audio frame exceeds its limit.");
await _onAudio(message); continue;
}
using var json = JsonDocument.Parse(message);
var type = json.RootElement.TryGetProperty("type", out var property) ? property.GetString() : null;
if (type is null || !AllowedTypes.Contains(type)) throw new InvalidDataException("OBS bridge message type is not allowed.");
await _onMessage(json.RootElement.Clone());
}
}
public static async Task WriteAsync(Stream stream, object message, CancellationToken cancellationToken)
{
var body = JsonSerializer.SerializeToUtf8Bytes(message, ProtocolV1.JsonOptions);
if (body.Length > ProtocolV1.MaxJsonBytes) throw new InvalidDataException("Companion IPC message exceeds its limit.");
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;
while (read < buffer.Length)
{
var count = await stream.ReadAsync(buffer[read..], cancellationToken);
if (count == 0) throw new EndOfStreamException();
read += count;
}
}
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,107 @@
param(
[string]$ObsVersion = "31.1.1",
[string]$BridgeVersion = "0.1.0",
[string]$CacheRoot = "$env:LOCALAPPDATA\LumiCompanionBuild"
)
$ErrorActionPreference = "Stop"
$repoRoot = (Resolve-Path (Join-Path $PSScriptRoot "..\..")).Path
$nativeRoot = Join-Path $repoRoot "companion\native\obs-bridge"
$componentRoot = Join-Path $repoRoot "companion\src\Lumi.Companion.App\components\obs-bridge"
$obsRoot = Join-Path $CacheRoot "obs-$ObsVersion"
$runtimeArchive = Join-Path $obsRoot "obs-windows.zip"
$sourceArchive = Join-Path $obsRoot "obs-source.zip"
$runtimeRoot = Join-Path $obsRoot "runtime"
$sourceParent = Join-Path $obsRoot "source"
$sourceRoot = Join-Path $sourceParent "obs-studio-$ObsVersion"
$importRoot = Join-Path $obsRoot "imports"
$buildRoot = Join-Path $obsRoot "bridge-build"
$runtimeUrl = "https://github.com/obsproject/obs-studio/releases/download/$ObsVersion/OBS-Studio-$ObsVersion-Windows-x64.zip"
$sourceUrl = "https://github.com/obsproject/obs-studio/archive/refs/tags/$ObsVersion.zip"
$runtimeSha256 = "9d8dceb77acd8af04af23f877061f63c9bef78ca73d2093d0ccba1bb9104173f"
$sourceSha256 = "2c8427c10b55ac6d68008df2e9a3e82f4647aaad18f105e30d4713c2de678ccf"
function Get-VerifiedArchive([string]$Url, [string]$Path, [string]$ExpectedSha256) {
if (!(Test-Path $Path) -or (Get-FileHash $Path -Algorithm SHA256).Hash.ToLowerInvariant() -ne $ExpectedSha256) {
New-Item -ItemType Directory -Force -Path (Split-Path $Path) | Out-Null
$partial = "$Path.partial"
Remove-Item $partial -Force -ErrorAction SilentlyContinue
Invoke-WebRequest -Uri $Url -OutFile $partial
if ((Get-FileHash $partial -Algorithm SHA256).Hash.ToLowerInvariant() -ne $ExpectedSha256) {
Remove-Item $partial -Force -ErrorAction SilentlyContinue
throw "Checksum mismatch while downloading $Url"
}
Move-Item $partial $Path -Force
}
}
function New-ImportLibrary([string]$Dll, [string]$Name, [string]$Dumpbin, [string]$LibExe) {
$definition = Join-Path $importRoot "$Name.def"
$dumpOutputPath = Join-Path $importRoot "$Name.exports.txt"
$dump = Start-Process -FilePath $Dumpbin -ArgumentList @("/nologo", "/exports", "`"$Dll`"") -RedirectStandardOutput $dumpOutputPath -NoNewWindow -Wait -PassThru
if ($dump.ExitCode) { throw "Could not inspect exports for $Name." }
$dumpOutput = Get-Content $dumpOutputPath
$exports = @($dumpOutput | ForEach-Object {
if ($_ -match '^\s+\d+\s+[0-9A-F]+\s+[0-9A-F]+\s+(\S+)') { $Matches[1] }
})
@("LIBRARY $Name", "EXPORTS") + ($exports | ForEach-Object { " $_" }) | Set-Content -Encoding Ascii $definition
& $LibExe /nologo /machine:x64 "/def:$definition" "/out:$(Join-Path $importRoot "$Name.lib")"
if ($LASTEXITCODE) { throw "Could not create the $Name import library." }
}
Get-VerifiedArchive $runtimeUrl $runtimeArchive $runtimeSha256
Get-VerifiedArchive $sourceUrl $sourceArchive $sourceSha256
if (!(Test-Path (Join-Path $runtimeRoot "bin\64bit\obs.dll"))) {
Remove-Item $runtimeRoot -Recurse -Force -ErrorAction SilentlyContinue
Expand-Archive $runtimeArchive $runtimeRoot
}
if (!(Test-Path (Join-Path $sourceRoot "libobs\obs-module.h"))) {
Remove-Item $sourceParent -Recurse -Force -ErrorAction SilentlyContinue
Expand-Archive $sourceArchive $sourceParent
}
$vswhere = Join-Path ${env:ProgramFiles(x86)} "Microsoft Visual Studio\Installer\vswhere.exe"
$vsRoot = if (Test-Path $vswhere) { & $vswhere -latest -products * -requires Microsoft.VisualStudio.Component.VC.Tools.x86.x64 -property installationPath }
if (!$vsRoot) { $vsRoot = Join-Path ${env:ProgramFiles(x86)} "Microsoft Visual Studio\2022\BuildTools" }
if (!(Test-Path $vsRoot)) { throw "Visual Studio 2022 C++ Build Tools are required." }
$vcTools = Get-ChildItem (Join-Path $vsRoot "VC\Tools\MSVC") -Directory | Sort-Object { [version]$_.Name } | Select-Object -Last 1
$toolRoot = Join-Path $vcTools.FullName "bin\Hostx64\x64"
$dumpbin = Join-Path $toolRoot "dumpbin.exe"
$lib = Join-Path $toolRoot "lib.exe"
$cmakeCommand = Get-Command cmake.exe -ErrorAction SilentlyContinue
$cmake = if ($cmakeCommand) { $cmakeCommand.Source } else {
Get-ChildItem (Join-Path $env:APPDATA "Python") -Filter cmake.exe -File -Recurse -ErrorAction SilentlyContinue | Select-Object -First 1 -ExpandProperty FullName
}
if (!$cmake) { throw "CMake 3.28 or newer is required." }
New-Item -ItemType Directory -Force -Path $importRoot | Out-Null
New-ImportLibrary (Join-Path $runtimeRoot "bin\64bit\obs.dll") "obs" $dumpbin $lib
New-ImportLibrary (Join-Path $runtimeRoot "bin\64bit\obs-frontend-api.dll") "obs-frontend-api" $dumpbin $lib
$configureArguments = @(
"-S", $nativeRoot,
"-B", $buildRoot,
"-G", "Visual Studio 17 2022",
"-A", "x64",
"-DOBS_SOURCE_DIR=$sourceRoot",
"-DOBS_IMPORT_DIR=$importRoot",
"-DLUMI_BRIDGE_VERSION=$BridgeVersion"
)
& $cmake @configureArguments
if ($LASTEXITCODE) { throw "OBS bridge configuration failed." }
& $cmake --build $buildRoot --config Release
if ($LASTEXITCODE) { throw "OBS bridge build failed." }
New-Item -ItemType Directory -Force -Path $componentRoot | Out-Null
$bridgeDll = Join-Path $buildRoot "Release\lumi-obs-bridge.dll"
Copy-Item $bridgeDll (Join-Path $componentRoot "lumi-obs-bridge.dll") -Force
Copy-Item (Join-Path $nativeRoot "data\locale\en-US.ini") (Join-Path $componentRoot "en-US.ini") -Force
$manifest = [ordered]@{
version = $BridgeVersion
sha256 = (Get-FileHash $bridgeDll -Algorithm SHA256).Hash.ToLowerInvariant()
obs_minimum_version = "31.0.0"
}
$manifestJson = $manifest | ConvertTo-Json
[IO.File]::WriteAllText((Join-Path $componentRoot "manifest.json"), $manifestJson, [Text.UTF8Encoding]::new($false))
Write-Host "Built OBS bridge $BridgeVersion at $componentRoot"

View File

@ -0,0 +1,98 @@
param(
[Parameter(Mandatory = $true)][string]$RepoRoot,
[Parameter(Mandatory = $true)][string]$OutputRoot
)
$ErrorActionPreference = "Stop"
$repo = (Resolve-Path $RepoRoot).Path
$assetFiles = @(Get-ChildItem (Join-Path $repo "companion") -Filter project.assets.json -File -Recurse -ErrorAction SilentlyContinue |
Where-Object { $_.FullName -notmatch '[\\/]installer[\\/]output[\\/]' })
if ($assetFiles.Count -eq 0) { throw "No restored NuGet project.assets.json files were found after publish." }
Remove-Item $OutputRoot -Recurse -Force -ErrorAction SilentlyContinue
New-Item -ItemType Directory -Force -Path $OutputRoot | Out-Null
function Get-ChildText([System.Xml.XmlNode]$Node, [string]$Name) {
if (!$Node) { return $null }
$child = $Node.SelectSingleNode("*[local-name()='$Name']")
if (!$child) { return $null }
return $child.InnerText.Trim()
}
$packages = @{}
foreach ($assetFile in $assetFiles) {
$assets = Get-Content $assetFile.FullName -Raw | ConvertFrom-Json
$packageFolders = @($assets.packageFolders.PSObject.Properties.Name)
foreach ($libraryProperty in $assets.libraries.PSObject.Properties) {
if ($libraryProperty.Value.type -ne "package") { continue }
$parts = $libraryProperty.Name -split '/', 2
if ($parts.Count -ne 2) { continue }
$id = $parts[0]
$version = $parts[1]
$key = "$($id.ToLowerInvariant())/$($version.ToLowerInvariant())"
if ($packages.ContainsKey($key)) { continue }
$packageRoot = $null
foreach ($folder in $packageFolders) {
$candidate = Join-Path $folder ($key -replace '/', [IO.Path]::DirectorySeparatorChar)
if (Test-Path $candidate) { $packageRoot = (Resolve-Path $candidate).Path; break }
}
if (!$packageRoot) { throw "Restored package files are missing for $id $version." }
$packages[$key] = [ordered]@{ Id = $id; Version = $version; Root = $packageRoot }
}
}
$inventory = [Collections.Generic.List[string]]::new()
$inventory.Add("LUMI COMPANION - EXACT NUGET PACKAGE LICENCE INVENTORY")
$inventory.Add("Generated: $([DateTime]::UtcNow.ToString('yyyy-MM-dd HH:mm:ss')) UTC")
$inventory.Add("")
$inventory.Add("This file is generated from the restored project.assets.json files used by the Companion build. Package-specific licence, notice, copying, and copyright files found in the restored packages are copied into the adjacent package directories.")
$inventory.Add("")
foreach ($package in @($packages.Values | Sort-Object Id, Version)) {
$safeName = ("$($package.Id)-$($package.Version)" -replace '[^A-Za-z0-9._-]', '_')
$packageOutput = Join-Path $OutputRoot $safeName
New-Item -ItemType Directory -Force -Path $packageOutput | Out-Null
$nuspec = Get-ChildItem $package.Root -Filter *.nuspec -File | Select-Object -First 1
$metadata = $null
if ($nuspec) {
[xml]$nuspecXml = Get-Content $nuspec.FullName -Raw
$metadata = $nuspecXml.SelectSingleNode("/*[local-name()='package']/*[local-name()='metadata']")
}
$licenseText = Get-ChildText $metadata "license"
if (!$licenseText) { $licenseText = Get-ChildText $metadata "licenseUrl" }
$authorsText = Get-ChildText $metadata "authors"
$copyrightText = Get-ChildText $metadata "copyright"
$projectUrlText = Get-ChildText $metadata "projectUrl"
$license = if ($licenseText) { $licenseText } else { "Not declared in package metadata" }
$authors = if ($authorsText) { $authorsText } else { "Not declared" }
$copyright = if ($copyrightText) { $copyrightText } else { "Not declared" }
$projectUrl = if ($projectUrlText) { $projectUrlText } else { "Not declared" }
$copied = [Collections.Generic.List[string]]::new()
$noticeFiles = @(Get-ChildItem $package.Root -File -Recurse -ErrorAction SilentlyContinue | Where-Object {
$_.Length -le 5MB -and $_.Name -match '^(license|licence|notice|third[-_. ]?party|thirdparty|copying|copyright)([._ -].*)?$'
})
foreach ($notice in $noticeFiles) {
$rootPrefix = $package.Root.TrimEnd([char[]]"\/")
$relative = $notice.FullName.Substring($rootPrefix.Length).TrimStart([char[]]"\/")
$targetName = ($relative -replace '[\\/:*?"<>|]', '__')
$target = Join-Path $packageOutput $targetName
Copy-Item $notice.FullName $target -Force
$copied.Add($targetName)
}
if ($copied.Count -eq 0) { Remove-Item $packageOutput -Force }
$inventory.Add("Package: $($package.Id) $($package.Version)")
$inventory.Add("Authors: $authors")
$inventory.Add("Copyright: $copyright")
$inventory.Add("Licence declaration: $license")
$inventory.Add("Project: $projectUrl")
$inventory.Add("Copied notice files: $(if ($copied.Count) { $copied -join ', ' } else { 'none found in restored package' })")
$inventory.Add("")
}
[IO.File]::WriteAllLines((Join-Path $OutputRoot "NuGet-LICENCE-INVENTORY.txt"), $inventory, [Text.UTF8Encoding]::new($false))
Write-Host "Collected legal metadata for $($packages.Count) restored NuGet packages."

View File

@ -0,0 +1,94 @@
param(
[string]$OutputPath = ""
)
$ErrorActionPreference = "Stop"
$repoRoot = (Resolve-Path (Join-Path $PSScriptRoot "..\..")).Path
if ([string]::IsNullOrWhiteSpace($OutputPath)) {
$OutputPath = Join-Path $repoRoot "companion\src\Lumi.Companion.App\Assets\Lumi.Companion.ico"
}
$OutputPath = [System.IO.Path]::GetFullPath($OutputPath)
$sizes = @(16, 32, 48, 256)
function New-LumiIconFrame {
param([int]$Size)
$radius = [Math]::Max(3, [Math]::Round($Size * 0.22))
$center = ($Size - 1) / 2.0
$diamondRadius = $Size * 0.36
$maskStride = [Math]::Ceiling($Size / 32.0) * 4
$pixels = New-Object byte[] ($Size * $Size * 4)
for ($y = 0; $y -lt $Size; $y++) {
for ($x = 0; $x -lt $Size; $x++) {
$inside = $true
if (!(($x -ge $radius -and $x -lt $Size - $radius) -or ($y -ge $radius -and $y -lt $Size - $radius))) {
$cornerX = if ($x -lt $radius) { $radius } else { $Size - $radius - 1 }
$cornerY = if ($y -lt $radius) { $radius } else { $Size - $radius - 1 }
$dx = $x - $cornerX
$dy = $y - $cornerY
$inside = (($dx * $dx) + ($dy * $dy)) -le ($radius * $radius)
}
$diamond = (([Math]::Abs($x - $center) / $diamondRadius) + ([Math]::Abs($y - $center) / $diamondRadius)) -le 1
$target = ((($Size - 1 - $y) * $Size) + $x) * 4
$pixels[$target] = if ($diamond -and $inside) { 255 } else { 200 }
$pixels[$target + 1] = if ($diamond -and $inside) { 255 } else { 183 }
$pixels[$target + 2] = if ($diamond -and $inside) { 255 } else { 40 }
$pixels[$target + 3] = if ($inside) { 255 } else { 0 }
}
}
$stream = [System.IO.MemoryStream]::new()
$writer = [System.IO.BinaryWriter]::new($stream)
$writer.Write([int]40)
$writer.Write([int]$Size)
$writer.Write([int]($Size * 2))
$writer.Write([uint16]1)
$writer.Write([uint16]32)
$writer.Write([int]0)
$writer.Write([int]$pixels.Length)
$writer.Write([int]0)
$writer.Write([int]0)
$writer.Write([int]0)
$writer.Write([int]0)
$writer.Write($pixels)
$writer.Write((New-Object byte[] ($maskStride * $Size)))
$writer.Flush()
$result = $stream.ToArray()
$writer.Dispose()
$stream.Dispose()
return ,$result
}
[byte[][]]$frames = @($sizes | ForEach-Object { ,(New-LumiIconFrame -Size $_) })
$directorySize = 6 + ($frames.Count * 16)
$offset = $directorySize
$output = [System.IO.MemoryStream]::new()
$binary = [System.IO.BinaryWriter]::new($output)
$binary.Write([uint16]0)
$binary.Write([uint16]1)
$binary.Write([uint16]$frames.Count)
for ($index = 0; $index -lt $frames.Count; $index++) {
$size = $sizes[$index]
$frame = $frames[$index]
$encodedSize = if ($size -eq 256) { 0 } else { $size }
$binary.Write([byte]$encodedSize)
$binary.Write([byte]$encodedSize)
$binary.Write([byte]0)
$binary.Write([byte]0)
$binary.Write([uint16]1)
$binary.Write([uint16]32)
$binary.Write([int]$frame.Length)
$binary.Write([int]$offset)
$offset += $frame.Length
}
foreach ($frame in $frames) { $binary.Write($frame) }
$binary.Flush()
$directory = Split-Path -Parent $OutputPath
[System.IO.Directory]::CreateDirectory($directory) | Out-Null
[System.IO.File]::WriteAllBytes($OutputPath, $output.ToArray())
$binary.Dispose()
$output.Dispose()
Write-Host "Generated Lumi Companion icon: $OutputPath"

View File

@ -0,0 +1,139 @@
param(
[string]$Version = "0.1.0",
[string]$BridgeVersion = "0.1.0",
[string]$ObsVersion = "31.1.1"
)
$ErrorActionPreference = "Stop"
$repoRoot = (Resolve-Path (Join-Path $PSScriptRoot "..\..")).Path
$project = Join-Path $repoRoot "companion\src\Lumi.Companion.App\Lumi.Companion.App.csproj"
$outputRoot = Join-Path $repoRoot "companion\installer\output"
$publishRoot = Join-Path $outputRoot "publish"
$stageRoot = Join-Path $outputRoot "package"
$sourceStageRoot = Join-Path $outputRoot "obs-bridge-source"
$archive = Join-Path $outputRoot "Lumi.Companion-win-x64.zip"
$installer = Join-Path $outputRoot "Lumi.Companion-Setup.exe"
$legalSourceRoot = Join-Path $repoRoot "companion\legal"
$publishLegalRoot = Join-Path $publishRoot "legal"
function Compress-ArchiveWithRetry {
param(
[Parameter(Mandatory = $true)][string]$Path,
[Parameter(Mandatory = $true)][string]$DestinationPath,
[int]$Attempts = 8
)
for ($attempt = 1; $attempt -le $Attempts; $attempt++) {
try {
Remove-Item $DestinationPath -Force -ErrorAction SilentlyContinue
Compress-Archive -Path $Path -DestinationPath $DestinationPath -CompressionLevel Optimal -ErrorAction Stop
return
} catch {
if ($attempt -eq $Attempts) { throw }
Start-Sleep -Seconds 2
}
}
}
& (Join-Path $PSScriptRoot "generate-companion-icon.ps1")
& (Join-Path $PSScriptRoot "build-obs-bridge.ps1") -BridgeVersion $BridgeVersion -ObsVersion $ObsVersion
Remove-Item $publishRoot, $stageRoot, $sourceStageRoot -Recurse -Force -ErrorAction SilentlyContinue
New-Item -ItemType Directory -Force -Path $publishRoot, $stageRoot | Out-Null
$localDotnet = Join-Path $HOME ".dotnet-sdk\dotnet.exe"
$dotnet = if (Test-Path $localDotnet) { $localDotnet } else { (Get-Command dotnet.exe -ErrorAction Stop).Source }
$publishArguments = @("publish", $project, "-c", "Release", "-r", "win-x64", "--self-contained", "true", "--disable-build-servers", "-o", $publishRoot,
"-p:PublishSingleFile=true", "-p:IncludeNativeLibrariesForSelfExtract=true", "-p:DebugType=None", "-p:Version=$Version")
& $dotnet @publishArguments
if ($LASTEXITCODE) { throw "Companion publish failed." }
$bridgeDiagnostic = Join-Path $outputRoot "obs-bridge-package-diagnostic.json"
Remove-Item $bridgeDiagnostic -Force -ErrorAction SilentlyContinue
& (Join-Path $publishRoot "Lumi.Companion.App.exe") --diagnose-obs-bridge $bridgeDiagnostic
if ($LASTEXITCODE -ne 0) {
$detail = if (Test-Path $bridgeDiagnostic) { Get-Content $bridgeDiagnostic -Raw } else { "No diagnostic result was written." }
throw "Published OBS integration payload failed its executable-level check: $detail"
}
Remove-Item $bridgeDiagnostic -Force -ErrorAction SilentlyContinue
# Copy the release legal bundle beside the application so installed and portable
# builds carry the same terms, privacy notice, and third-party attributions.
New-Item -ItemType Directory -Force -Path $publishLegalRoot | Out-Null
foreach ($name in @(
"LUMI-COMPANION-LICENCE.txt",
"PRIVACY-NOTICE.txt",
"THIRD-PARTY-NOTICES.txt",
"LEGAL-README.txt"
)) {
Copy-Item (Join-Path $legalSourceRoot $name) (Join-Path $publishLegalRoot $name) -Force
}
Copy-Item (Join-Path $legalSourceRoot "third-party") (Join-Path $publishLegalRoot "third-party") -Recurse -Force
& (Join-Path $PSScriptRoot "collect-nuget-notices.ps1") -RepoRoot $repoRoot -OutputRoot (Join-Path $publishLegalRoot "third-party\nuget")
# The bundled OBS module links with GPL-licensed OBS/libobs. Ship the exact Lumi
# bridge source and build materials with every binary distribution.
$sourceCompanionRoot = Join-Path $sourceStageRoot "companion"
$sourceNativeParent = Join-Path $sourceCompanionRoot "native"
$sourceScriptsRoot = Join-Path $sourceCompanionRoot "scripts"
New-Item -ItemType Directory -Force -Path $sourceNativeParent, $sourceScriptsRoot | Out-Null
Copy-Item (Join-Path $repoRoot "companion\native\obs-bridge") (Join-Path $sourceNativeParent "obs-bridge") -Recurse -Force
Copy-Item (Join-Path $repoRoot "companion\scripts\build-obs-bridge.ps1") (Join-Path $sourceScriptsRoot "build-obs-bridge.ps1") -Force
$upstreamSourceRoot = Join-Path $sourceStageRoot "upstream-source"
New-Item -ItemType Directory -Force -Path $upstreamSourceRoot | Out-Null
$bridgeCacheRoot = Join-Path $env:LOCALAPPDATA "LumiCompanionBuild\obs-$ObsVersion"
$obsSourceArchive = Join-Path $bridgeCacheRoot "obs-source.zip"
$jsonSourceRoot = Join-Path $bridgeCacheRoot "bridge-build\_deps\json-src"
if (!(Test-Path $obsSourceArchive)) { throw "The exact OBS source archive used for the bridge build is missing." }
if (!(Test-Path $jsonSourceRoot)) { throw "The exact nlohmann/json source used for the bridge build is missing." }
Copy-Item $obsSourceArchive (Join-Path $upstreamSourceRoot "OBS-Studio-$ObsVersion-source.zip") -Force
Copy-Item $jsonSourceRoot (Join-Path $upstreamSourceRoot "nlohmann-json-3.12.0") -Recurse -Force
@"
Lumi OBS Bridge corresponding source
Release version: $BridgeVersion
This archive contains the Lumi-authored source and build script used for the
GPL-2.0-or-later OBS bridge distributed with Lumi Companion.
The archive includes the Lumi bridge source, its build script, the exact OBS Studio
$ObsVersion source archive used for its interfaces, and the exact nlohmann/json
3.12.0 source used in the binary. Checksums and upstream locations remain recorded
in the build script. OBS Studio binaries are not distributed by Companion.
Licence: companion/native/obs-bridge/COPYING
"@ | Set-Content -Encoding UTF8 (Join-Path $sourceStageRoot "SOURCE-README.txt")
$bridgeSourceArchive = Join-Path $publishLegalRoot "Lumi-OBS-Bridge-Source.zip"
Compress-ArchiveWithRetry -Path (Join-Path $sourceStageRoot "*") -DestinationPath $bridgeSourceArchive
Copy-Item (Join-Path $publishRoot "Lumi.Companion.App.exe") $stageRoot
Copy-Item (Join-Path $publishRoot "components") $stageRoot -Recurse
Copy-Item (Join-Path $publishRoot "legal") $stageRoot -Recurse
Remove-Item $archive -Force -ErrorAction SilentlyContinue
Compress-ArchiveWithRetry -Path (Join-Path $stageRoot "*") -DestinationPath $archive
$isccCandidates = @(
(Join-Path $env:LOCALAPPDATA "Programs\Inno Setup 6\ISCC.exe"),
(Join-Path ${env:ProgramFiles(x86)} "Inno Setup 6\ISCC.exe"),
(Join-Path $env:ProgramFiles "Inno Setup 6\ISCC.exe")
)
$iscc = $isccCandidates | Where-Object { $_ -and (Test-Path $_) } | Select-Object -First 1
if (-not $iscc) { throw "Inno Setup 6 is required to build the durable per-user Companion installer." }
Remove-Item $installer -Force -ErrorAction SilentlyContinue
$compileArguments = @(
"/DAppVersion=$Version",
"/DSourceRoot=$publishRoot",
"/DOutputRoot=$outputRoot",
(Join-Path $repoRoot "companion\installer\Lumi.Companion.iss")
)
& $iscc @compileArguments
if ($LASTEXITCODE -ne 0 -or -not (Test-Path $installer)) { throw "Companion installer compilation failed." }
$file = Get-Item $archive
$sha = (Get-FileHash $archive -Algorithm SHA256).Hash.ToLowerInvariant()
$setupFile = Get-Item $installer
$setupSha = (Get-FileHash $installer -Algorithm SHA256).Hash.ToLowerInvariant()
Write-Host "Published Lumi Companion $Version"
Write-Host "Artifact: $archive"
Write-Host "Bytes: $($file.Length)"
Write-Host "SHA256: $sha"
Write-Host "Installer: $installer"
Write-Host "Installer bytes: $($setupFile.Length)"
Write-Host "Installer SHA256: $setupSha"

View File

@ -0,0 +1,72 @@
param(
[Parameter(Mandatory = $true)][string]$OutputArchive,
[Parameter(Mandatory = $true)][string]$ManifestPath
)
$ErrorActionPreference = "Stop"
$repoRoot = (Resolve-Path (Join-Path $PSScriptRoot "..\..")).Path
$project = Join-Path $repoRoot "companion\src\Lumi.Companion.App\Lumi.Companion.App.csproj"
$outputArchive = [System.IO.Path]::GetFullPath($OutputArchive)
$manifest = [System.IO.Path]::GetFullPath($ManifestPath)
$workRoot = Join-Path ([System.IO.Path]::GetDirectoryName($outputArchive)) "work"
$publishRoot = Join-Path $workRoot "publish"
$stageRoot = Join-Path $workRoot "package"
if (!(Test-Path $manifest)) { throw "The development build manifest is missing." }
& (Join-Path $PSScriptRoot "generate-companion-icon.ps1")
Remove-Item $workRoot -Recurse -Force -ErrorAction SilentlyContinue
New-Item -ItemType Directory -Force -Path $publishRoot, $stageRoot | Out-Null
New-Item -ItemType Directory -Force -Path ([System.IO.Path]::GetDirectoryName($outputArchive)) | Out-Null
$localDotnet = Join-Path $HOME ".dotnet-sdk\dotnet.exe"
$dotnet = if (Test-Path $localDotnet) { $localDotnet } else { (Get-Command dotnet.exe -ErrorAction Stop).Source }
$arguments = @(
"publish", "`"$project`"",
"-c", "Release",
"-r", "win-x64",
"--self-contained", "true",
"--disable-build-servers",
"-o", "`"$publishRoot`"",
"-p:PublishSingleFile=true",
"-p:IncludeNativeLibrariesForSelfExtract=true",
"-p:DebugType=None"
)
$published = Start-Process -FilePath $dotnet -ArgumentList $arguments -NoNewWindow -Wait -PassThru
if ($published.ExitCode -ne 0) { throw "Companion development publish failed." }
$app = Join-Path $publishRoot "Lumi.Companion.App.exe"
if (!(Test-Path $app)) { throw "The Companion development publish did not create Lumi.Companion.App.exe." }
Copy-Item $app $stageRoot -Force
$componentsSource = Join-Path $publishRoot "components"
if (Test-Path $componentsSource) { Copy-Item $componentsSource (Join-Path $stageRoot "components") -Recurse -Force }
$legalSource = Join-Path $repoRoot "companion\legal"
$legalTarget = Join-Path $stageRoot "legal"
if (Test-Path $legalSource) {
New-Item -ItemType Directory -Force -Path $legalTarget | Out-Null
foreach ($name in @("LUMI-COMPANION-LICENCE.txt", "PRIVACY-NOTICE.txt", "THIRD-PARTY-NOTICES.txt", "LEGAL-README.txt")) {
$source = Join-Path $legalSource $name
if (Test-Path $source) { Copy-Item $source (Join-Path $legalTarget $name) -Force }
}
$thirdParty = Join-Path $legalSource "third-party"
if (Test-Path $thirdParty) { Copy-Item $thirdParty (Join-Path $legalTarget "third-party") -Recurse -Force }
}
Copy-Item $manifest (Join-Path $stageRoot ".lumi-dev-build.json") -Force
Remove-Item $outputArchive -Force -ErrorAction SilentlyContinue
$archiveItems = Get-ChildItem $stageRoot -Force | Select-Object -ExpandProperty FullName
$archiveCreated = $false
for ($attempt = 1; $attempt -le 20; $attempt++) {
try {
Compress-Archive -Path $archiveItems -DestinationPath $outputArchive -CompressionLevel Optimal -ErrorAction Stop
$archiveCreated = $true
break
}
catch {
Remove-Item $outputArchive -Force -ErrorAction SilentlyContinue
if ($attempt -eq 20) { throw }
Start-Sleep -Milliseconds 250
}
}
if (-not $archiveCreated) { throw "The Companion development archive could not be created." }
Remove-Item $workRoot -Recurse -Force -ErrorAction SilentlyContinue
Write-Host "Created localhost development update: $outputArchive"

View File

@ -0,0 +1,15 @@
$ErrorActionPreference = 'Stop'
$repoRoot = (Resolve-Path (Join-Path $PSScriptRoot '..\..')).Path
Push-Location $repoRoot
try {
Write-Host 'Building Lumi Companion and all registered Companion plugins...'
dotnet build 'companion/Lumi.Companion.sln' -c Release -p:EnableWindowsTargeting=true
Write-Host 'Verifying the existing Lumi Song Overlay server plugin...'
node 'plugins/now_playing/tests/verify.js'
Write-Host 'Song Overlay Companion verification passed.' -ForegroundColor Green
}
finally {
Pop-Location
}

View File

@ -0,0 +1,22 @@
namespace Lumi.Companion.Abstractions;
public sealed record CompanionPluginHttpResponse(
int StatusCode,
string Body)
{
public bool IsSuccessStatusCode => StatusCode is >= 200 and <= 299;
}
/// <summary>
/// A plugin-scoped view of the Companion shell's authenticated Lumi transport.
/// Plugins never receive, store, or refresh the paired device credential.
/// </summary>
public interface ICompanionPluginTransport
{
Uri? LumiBaseUri { get; }
bool IsAuthenticated { get; }
Task<CompanionPluginHttpResponse> PostJsonAsync(
string relativePath,
object payload,
CancellationToken cancellationToken = default);
}

View File

@ -0,0 +1,7 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
</PropertyGroup>
</Project>

View File

@ -0,0 +1,47 @@
namespace Lumi.Companion.Abstractions;
public enum CompanionPluginHealth
{
Ready,
Healthy,
Warning,
Error
}
public sealed record CompanionPluginDescriptor(
string Id,
string Name,
Version Version,
string Description,
int Order = 100);
public sealed record CompanionPluginPage(
string Key,
string Label,
int Order = 100);
public sealed record CompanionPluginStatus(
CompanionPluginHealth Health,
string Summary,
string? Detail = null);
public sealed record CompanionPluginAction(
string Id,
Func<string> Label,
Func<CancellationToken, Task> Execute,
Func<bool>? CanExecute = null,
int Order = 100);
/// <summary>
/// A UI-neutral contribution contract. The Avalonia shell owns rendering and
/// guarantees that each plugin receives one nested root in both the sidebar and
/// tray menu instead of leaking loose actions into the global navigation.
/// </summary>
public interface ICompanionPluginContribution
{
CompanionPluginDescriptor Descriptor { get; }
IReadOnlyList<CompanionPluginPage> Pages { get; }
IReadOnlyList<CompanionPluginAction> Actions { get; }
CompanionPluginStatus Status { get; }
event Action? Changed;
}

View File

@ -0,0 +1,98 @@
<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="Expander.pluginRoot">
<Setter Property="HorizontalAlignment" Value="Stretch" />
<Setter Property="Margin" Value="0,1" />
</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,264 @@
using Avalonia;
using Avalonia.Controls;
using Avalonia.Controls.ApplicationLifetimes;
using Avalonia.Markup.Xaml;
using Avalonia.Threading;
using Lumi.Companion.Abstractions;
using Lumi.Companion.SongOverlay;
namespace Lumi.Companion.App;
public partial class App : Application
{
private TrayIcon? _trayIcon;
private MainWindow? _mainWindow;
private SongOverlayRuntime? _songOverlay;
private IReadOnlyList<ICompanionPluginContribution> _plugins = [];
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);
_songOverlay = new SongOverlayRuntime(
Path.Combine(paths.Root, "plugins", "now_playing"),
paths.LogsDirectory,
runtime.CreatePluginTransport("now_playing"));
_plugins = [new TranscriptionPluginContribution(runtime), _songOverlay];
_mainWindow = new MainWindow(runtime, settings, _songOverlay, _plugins);
desktop.MainWindow = _mainWindow;
ConfigureTray(desktop, runtime, _plugins);
runtime.UpdateRestartRequested += async () =>
{
_mainWindow.AllowExit();
_trayIcon?.Dispose();
if (_songOverlay is not null) await _songOverlay.DisposeAsync();
await runtime.DisposeAsync();
desktop.Shutdown();
};
Program.InstanceCoordinator!.ActivationRequested += () => Dispatcher.UIThread.Post(ShowMainWindow);
if (!Program.LaunchInBackground) _mainWindow.Show();
_ = InitializeRuntimesAsync(runtime, _songOverlay);
}
base.OnFrameworkInitializationCompleted();
}
private static async Task InitializeRuntimesAsync(CompanionRuntime runtime, SongOverlayRuntime songOverlay)
{
await runtime.InitializeAsync();
try { await songOverlay.InitializeAsync(); }
catch { /* The plugin publishes its own actionable status without taking down the shell. */ }
}
private void ConfigureTray(IClassicDesktopStyleApplicationLifetime desktop, CompanionRuntime runtime, IReadOnlyList<ICompanionPluginContribution> plugins)
{
var open = new NativeMenuItem("Open Lumi Companion");
var web = new NativeMenuItem("Open Lumi WebUI");
open.Click += (_, _) => ShowMainWindow();
web.Click += (_, _) => runtime.OpenLumiWebUi();
var menu = new NativeMenu();
menu.Items.Add(open);
menu.Items.Add(web);
menu.Items.Add(new NativeMenuItemSeparator());
menu.Items.Add(new NativeMenuItem("Plugins") { IsEnabled = false });
var pluginViews = new List<PluginTrayView>();
foreach (var plugin in plugins.OrderBy(item => item.Descriptor.Order).ThenBy(item => item.Descriptor.Name, StringComparer.OrdinalIgnoreCase))
{
var submenu = new NativeMenu();
var firstPage = plugin.Pages.OrderBy(item => item.Order).FirstOrDefault();
if (firstPage is not null)
{
var openPlugin = new NativeMenuItem($"Open {plugin.Descriptor.Name}");
openPlugin.Click += (_, _) => ShowPluginPage(firstPage.Key);
submenu.Items.Add(openPlugin);
if (plugin.Actions.Count > 0) submenu.Items.Add(new NativeMenuItemSeparator());
}
var actionViews = new List<(CompanionPluginAction Action, NativeMenuItem Item)>();
foreach (var action in plugin.Actions.OrderBy(item => item.Order))
{
var actionItem = new NativeMenuItem(action.Label());
actionItem.Click += async (_, _) => await RunPluginActionAsync(plugin, action);
submenu.Items.Add(actionItem);
actionViews.Add((action, actionItem));
}
if (submenu.Items.Count > 0) submenu.Items.Add(new NativeMenuItemSeparator());
var status = new NativeMenuItem($"Status: {plugin.Status.Summary}") { IsEnabled = false };
submenu.Items.Add(status);
var root = new NativeMenuItem(plugin.Descriptor.Name) { Menu = submenu };
menu.Items.Add(root);
var view = new PluginTrayView(plugin, root, status, actionViews);
pluginViews.Add(view);
plugin.Changed += () => Dispatcher.UIThread.Post(() => RefreshPluginTray(view));
}
menu.Items.Add(new NativeMenuItemSeparator());
var health = new NativeMenuItem("Health: Starting") { IsEnabled = false };
var update = new NativeMenuItem("Updates: Checking…") { IsEnabled = false };
var logs = new NativeMenuItem("Logs & diagnostics");
var settings = new NativeMenuItem("Settings");
var quit = new NativeMenuItem("Quit");
logs.Click += (_, _) => { runtime.OpenLogsDirectory(); };
settings.Click += (_, _) => { ShowMainWindow(); _mainWindow?.ShowPage(CompanionPage.Settings); };
update.Click += (_, _) => { ShowMainWindow(); _mainWindow?.ShowPage(CompanionPage.Overview); };
quit.Click += async (_, _) =>
{
if (runtime.State.RequiresQuitConfirmation) ShowMainWindow();
if (_mainWindow is null || !await _mainWindow.ConfirmQuitAsync()) return;
_mainWindow.AllowExit();
_trayIcon?.Dispose();
if (_songOverlay is not null) await _songOverlay.DisposeAsync();
await runtime.DisposeAsync();
desktop.Shutdown();
};
menu.Items.Add(health);
menu.Items.Add(update);
menu.Items.Add(logs);
menu.Items.Add(settings);
menu.Items.Add(new NativeMenuItemSeparator());
menu.Items.Add(quit);
_trayIcon = new TrayIcon
{
Icon = LumiIconFactory.Create(TrayHealth.Ready),
ToolTipText = "Lumi Companion — starting",
IsVisible = true,
Menu = menu
};
_trayIcon.Clicked += (_, _) => ShowMainWindow();
TrayIcon.SetIcons(this, new TrayIcons { _trayIcon });
var renderedHealth = TrayHealth.Ready;
var renderedToolTip = _trayIcon.ToolTipText;
var renderedHealthHeader = health.Header?.ToString();
var renderedUpdateHeader = update.Header?.ToString();
var renderedUpdateEnabled = update.IsEnabled;
var refreshGate = new object();
CompanionState? pendingState = null;
var refreshQueued = false;
void ApplyTrayState(CompanionState state)
{
if (_trayIcon is null) return;
if (state.Health != renderedHealth)
{
_trayIcon.Icon = LumiIconFactory.Create(state.Health);
renderedHealth = state.Health;
}
var nextToolTip = state.UpdateAvailable
? $"Lumi Companion — update {state.AvailableVersion} available"
: $"Lumi Companion — {state.Summary}";
if (!string.Equals(nextToolTip, renderedToolTip, StringComparison.Ordinal))
{
_trayIcon.ToolTipText = nextToolTip;
renderedToolTip = nextToolTip;
}
var nextHealthHeader = $"Health: {state.Summary}";
if (!string.Equals(nextHealthHeader, renderedHealthHeader, StringComparison.Ordinal))
{
health.Header = nextHealthHeader;
renderedHealthHeader = nextHealthHeader;
}
var nextUpdateHeader = state.UpdateAvailable
? $"Update available: {state.AvailableVersion}"
: "Updates: Current";
if (!string.Equals(nextUpdateHeader, renderedUpdateHeader, StringComparison.Ordinal))
{
update.Header = nextUpdateHeader;
renderedUpdateHeader = nextUpdateHeader;
}
if (state.UpdateAvailable != renderedUpdateEnabled)
{
update.IsEnabled = state.UpdateAvailable;
renderedUpdateEnabled = state.UpdateAvailable;
}
}
void DrainTrayRefresh()
{
CompanionState? latest;
lock (refreshGate)
{
latest = pendingState;
pendingState = null;
}
if (latest is not null) ApplyTrayState(latest);
lock (refreshGate)
{
if (pendingState is null)
{
refreshQueued = false;
return;
}
}
Dispatcher.UIThread.Post(DrainTrayRefresh, DispatcherPriority.Background);
}
runtime.StateChanged += state =>
{
lock (refreshGate)
{
pendingState = state;
if (refreshQueued) return;
refreshQueued = true;
}
Dispatcher.UIThread.Post(DrainTrayRefresh, DispatcherPriority.Background);
};
foreach (var view in pluginViews) RefreshPluginTray(view);
}
private async Task RunPluginActionAsync(ICompanionPluginContribution plugin, CompanionPluginAction action)
{
try
{
await action.Execute(CancellationToken.None);
}
catch
{
var page = plugin.Pages.OrderBy(item => item.Order).FirstOrDefault();
if (page is not null) ShowPluginPage(page.Key);
}
}
private static void RefreshPluginTray(PluginTrayView view)
{
view.Root.Header = view.Plugin.Descriptor.Name;
view.Status.Header = $"Status: {view.Plugin.Status.Summary}";
foreach (var (action, item) in view.Actions)
{
item.Header = action.Label();
item.IsEnabled = action.CanExecute?.Invoke() ?? true;
}
}
private void ShowPluginPage(string pageKey)
{
ShowMainWindow();
if (Enum.TryParse<CompanionPage>(pageKey, out var page)) _mainWindow?.ShowPage(page);
}
private void ShowMainWindow()
{
if (_mainWindow is null) return;
_mainWindow.Show();
_mainWindow.WindowState = WindowState.Normal;
_mainWindow.Activate();
}
private sealed record PluginTrayView(
ICompanionPluginContribution Plugin,
NativeMenuItem Root,
NativeMenuItem Status,
IReadOnlyList<(CompanionPluginAction Action, NativeMenuItem Item)> Actions);
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 279 KiB

View File

@ -0,0 +1,11 @@
namespace Lumi.Companion.App;
public sealed record CompanionPaths(string Root, string SettingsPath, string LogsDirectory, string BridgeDirectory, string UpdatesDirectory)
{
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"), Path.Combine(root, "updates"));
}
}

View File

@ -0,0 +1,953 @@
using System.Buffers.Binary;
using System.Diagnostics;
using System.Reflection;
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
using Lumi.Companion.Abstractions;
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 const string PathValidationContract = "voice-free-path-v1";
private readonly CompanionPaths _paths;
private readonly CompanionSettingsStore _settings;
private readonly SecureCredentialStore _credentials;
private readonly HttpClient _http = new() { Timeout = TimeSpan.FromSeconds(15) };
private readonly UpdateService _updates;
private readonly ObsBridgeManager _bridgeManager = new();
private readonly CancellationTokenSource _maintenanceLifetime = new();
private CompanionSocket? _socket;
private ObsBridgePipe? _obsBridge;
private TaskCompletionSource<bool>? _captionSignal;
private TaskCompletionSource<bool>? _sessionStartSignal;
private TaskCompletionSource<bool>? _sessionStopSignal;
private TaskCompletionSource<string>? _testFailure;
private TaskCompletionSource<(string SourceUuid, bool Attached)>? _bridgeSelectionSignal;
private bool? _bridgeSelectionAttached;
private readonly SemaphoreSlim _bridgeSelectionGate = new(1, 1);
private readonly Dictionary<string, BenchmarkCaptionRevision> _benchmarkCaptions = [];
private CancellationTokenSource? _benchmarkLifetime;
private TaskCompletionSource<bool>? _benchmarkCompleteSignal;
private DateTimeOffset? _benchmarkLastVoiceAt;
private int _benchmarkStopping;
private long _lastVoiceMeterAt;
private bool _disposed;
private CompanionUpdate? _availableUpdate;
private bool _serverReady;
private string? _serverReadinessFingerprint;
private string _serverReadinessDetail = "Waiting for Lumi to report speech recognition readiness.";
public CompanionRuntime(CompanionPaths paths, CompanionSettingsStore settings)
{
_paths = paths;
_settings = settings;
_credentials = new SecureCredentialStore(paths.Root);
_updates = new UpdateService(_http, paths);
State = new CompanionState();
TestStages = CreateInitialTestStages();
ObsSources = [];
Benchmark = EmptyBenchmark("Not started");
}
public CompanionState State { get; private set; }
public IReadOnlyList<TestStage> TestStages { get; private set; }
public IReadOnlyList<ObsSource> ObsSources { get; private set; }
public BenchmarkSnapshot Benchmark { get; private set; }
public event Action<CompanionState>? StateChanged;
public event Action<IReadOnlyList<TestStage>>? TestStagesChanged;
public event Action<IReadOnlyList<ObsSource>>? ObsSourcesChanged;
public event Action<BenchmarkSnapshot>? BenchmarkChanged;
public event Action<double>? VoiceLevelChanged;
public event Action<string, bool>? CaptionReceived;
public event Action<string>? LogAdded;
public event Func<Task>? UpdateRestartRequested;
public ICompanionPluginTransport CreatePluginTransport(string pluginId) =>
new CompanionPluginTransport(_http, () => _credentials.Load(), pluginId);
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();
_ = RunUpdateChecksAsync(_maintenanceLifetime.Token);
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)
{
var bundledPairing = FindBundledPairingPackage();
if (bundledPairing is not null)
{
await PairAsync(bundledPairing);
try { File.Delete(bundledPairing); } catch { }
return;
}
SetState(WithBridgeState(State with { Detail = "Download a pairing package from Lumi, then open it here." }));
return;
}
// An installer migration preserves the current-user DPAPI credential. If a
// bootstrap was copied alongside it, it is unnecessary and should not remain
// on disk where a later local reset could accidentally consume it.
var redundantPairing = FindBundledPairingPackage();
if (redundantPairing is not null) try { File.Delete(redundantPairing); } catch { }
SetState(WithBridgeState(State with { Paired = true, DeviceName = Environment.MachineName, Host = credential.Host, 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();
_serverReady = false;
_serverReadinessFingerprint = null;
_serverReadinessDetail = "Waiting for Lumi to report speech recognition readiness.";
_socket = new CompanionSocket();
_socket.MessageReceived += OnServerMessageAsync;
_socket.Disconnected += error =>
{
if (_disposed) return;
_testFailure?.TrySetResult(error is null ? "The Lumi connection closed during the test." : $"The Lumi connection closed during the test: {Friendly(error)}");
_benchmarkLifetime?.Cancel();
SetState(State with { Connected = false, BenchmarkRunning = false, Health = TrayHealth.Degraded, Detail = "The secure Lumi connection closed. Retry when the host is available.", BenchmarkDetail = State.BenchmarkRunning ? "The benchmark was aborted because the Lumi connection closed." : State.BenchmarkDetail });
_ = 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);
await SendRuntimeStateAsync(cancellationToken);
await _socket.SendAsync("readiness", new { }, _socket.SessionId, cancellationToken);
await WriteLogAsync("connected", $"Connected to {credential.Host}.");
await CheckForUpdatesAsync(cancellationToken);
}
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 || State.BenchmarkRunning) 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);
var bridgeAttached = selected is { Missing: false } && await SyncBridgeSelectionAsync(cancellationToken);
await EvaluateStageAsync(stages, 2, selected is { Missing: false, Active: true } && bridgeAttached, "The selected microphone is available, active in Program, and attached for capture.", selected is null ? "Choose a microphone on the Transcription page." : selected.Missing ? "The selected microphone is missing from OBS." : !bridgeAttached ? "Companion could not attach the selected OBS source. Keep OBS open and retry." : "Put the selected microphone in the active Program scene, then retry.", cancellationToken);
if (selected is not { Missing: false, Active: true } || !bridgeAttached) return;
await EvaluateStageAsync(stages, 4, State.Connected, "The secure Lumi transport is ready.", "Reconnect to Lumi before testing.", cancellationToken);
if (!State.Connected) return;
_captionSignal = new(TaskCreationOptions.RunContinuationsAsynchronously);
_sessionStartSignal = new(TaskCreationOptions.RunContinuationsAsynchronously);
_sessionStopSignal = new(TaskCreationOptions.RunContinuationsAsynchronously);
_testFailure = new(TaskCreationOptions.RunContinuationsAsynchronously);
await _socket!.SendAsync("start", new { mode = "test" }, _socket.SessionId, cancellationToken);
var sessionStart = await WaitForTestSignalAsync(_sessionStartSignal.Task, TimeSpan.FromSeconds(8), cancellationToken);
if (!sessionStart.Completed)
{
MarkStage(stages, 3, TestStageState.Blocked, "Audio capture was not attempted because the server inference session did not start.");
MarkStage(stages, 5, TestStageState.Blocked, sessionStart.Failure ?? "Lumi did not confirm that speech recognition started within 8 seconds.");
return;
}
MarkStage(stages, 3, TestStageState.Passed, "The selected OBS source is attached. You do not need to speak for this check.");
MarkStage(stages, 5, TestStageState.Passed, "Lumi reports that the selected speech model is loaded and ready.");
MarkStage(stages, 6, TestStageState.Running, "Checking the safe caption return path…");
var caption = await WaitForTestSignalAsync(_captionSignal.Task, TimeSpan.FromSeconds(8), cancellationToken);
if (!caption.Completed)
{
MarkStage(stages, 6, TestStageState.Blocked, caption.Failure ?? "The safe test caption did not return within 8 seconds.");
return;
}
MarkStage(stages, 6, TestStageState.Passed, "A safe test 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.");
var fingerprint = CurrentPathFingerprint() ?? throw new InvalidOperationException("Lumi readiness changed while the check was running. Try the check again.");
await _settings.SaveAsync(_settings.Current with { PathTestPassedAt = DateTimeOffset.UtcNow, PathTestFingerprint = fingerprint }, cancellationToken);
RefreshPathReadiness("The voice-free full-path check passed. It stays valid until a relevant setup or model configuration changes.");
await SendRuntimeStateAsync(cancellationToken);
SetState(State with { Detail = "Everything is ready. The dedicated transcript test is available when you want to measure real speech.", Health = TrayHealth.Ready });
}
finally
{
if (_socket is not null && State.Connected)
try
{
await _socket.SendAsync("stop", new { reason = "test_complete" }, _socket.SessionId, CancellationToken.None);
if (_sessionStopSignal is not null) await Task.WhenAny(_sessionStopSignal.Task, Task.Delay(TimeSpan.FromSeconds(5)));
}
catch { }
_captionSignal = null;
_sessionStartSignal = null;
_sessionStopSignal = null;
_testFailure = null;
SetState(State with { TestRunning = false, Detail = TestStages.Any(stage => stage.State == TestStageState.Blocked) ? "The test stopped at the first unavailable real boundary." : State.Detail });
}
}
public async Task StartBenchmarkAsync(CancellationToken cancellationToken = default)
{
if (State.BenchmarkRunning || State.TestRunning) return;
if (!State.Connected || _socket is null) throw new InvalidOperationException("Reconnect to Lumi before starting the transcription test.");
if (!State.ObsConnected) throw new InvalidOperationException("Open OBS and wait for the managed integration to connect.");
var selected = ObsSources.FirstOrDefault(source => source.Uuid == _settings.Current.PrimarySourceUuid);
if (selected is not { Missing: false, Active: true }) throw new InvalidOperationException("Choose an available microphone that is active in the OBS Program scene.");
if (!await SyncBridgeSelectionAsync(cancellationToken)) throw new InvalidOperationException("Companion could not attach the selected OBS source. Keep OBS open and retry.");
_benchmarkCaptions.Clear();
_sessionStartSignal = new(TaskCreationOptions.RunContinuationsAsynchronously);
_testFailure = new(TaskCreationOptions.RunContinuationsAsynchronously);
_benchmarkCompleteSignal = new(TaskCreationOptions.RunContinuationsAsynchronously);
_benchmarkLastVoiceAt = DateTimeOffset.UtcNow;
Interlocked.Exchange(ref _benchmarkStopping, 0);
Benchmark = EmptyBenchmark("Starting", DateTimeOffset.UtcNow);
BenchmarkChanged?.Invoke(Benchmark);
VoiceLevelChanged?.Invoke(-60);
SetState(State with { BenchmarkRunning = true, BenchmarkDetail = "Starting server-hosted accuracy and latency measurement…", Health = TrayHealth.Operating });
try
{
await _socket.SendAsync("start", new { mode = "benchmark" }, _socket.SessionId, cancellationToken);
var started = await WaitForTestSignalAsync(_sessionStartSignal.Task, TimeSpan.FromSeconds(8), cancellationToken);
if (!started.Completed)
throw new InvalidOperationException(started.Failure ?? "Lumi did not start the benchmark within 8 seconds.");
_benchmarkLifetime?.Cancel();
_benchmarkLifetime?.Dispose();
_benchmarkLifetime = CancellationTokenSource.CreateLinkedTokenSource(_maintenanceLifetime.Token);
_ = MonitorBenchmarkSilenceAsync(_benchmarkLifetime.Token);
SetState(State with { BenchmarkDetail = "Listening. Speak naturally; end the test manually, or remain silent for 10 seconds." });
await WriteLogAsync("benchmark_started", $"Started transcription benchmark for {selected.Name}.");
}
catch (Exception error)
{
_benchmarkLifetime?.Cancel();
_sessionStartSignal = null;
_testFailure = null;
_benchmarkCompleteSignal = null;
SetState(State with { BenchmarkRunning = false, BenchmarkDetail = Friendly(error), Health = TrayHealth.Degraded });
throw;
}
}
public async Task StopBenchmarkAsync(string reason = "benchmark_complete", CancellationToken cancellationToken = default)
{
if (!State.BenchmarkRunning || _socket is null || Interlocked.Exchange(ref _benchmarkStopping, 1) != 0) return;
_benchmarkLifetime?.Cancel();
SetState(State with { BenchmarkDetail = reason == "silence_timeout" ? "Ten seconds of silence detected. Finalizing the test…" : "Finalizing the test…" });
var finalized = false;
try
{
await _socket.SendAsync("stop", new { reason }, _socket.SessionId, cancellationToken);
if (_benchmarkCompleteSignal is not null)
{
var completed = await Task.WhenAny(_benchmarkCompleteSignal.Task, Task.Delay(TimeSpan.FromSeconds(20), cancellationToken));
if (completed != _benchmarkCompleteSignal.Task)
throw new TimeoutException("Lumi did not finalize the transcription test in time. The test was ended safely, but its final confidence result is unavailable.");
finalized = Benchmark.Words.Count == 0 || Benchmark.Confidence.Count > 0;
if (!finalized)
throw new InvalidOperationException("Lumi returned the test transcript without finalized confidence. The incomplete result was kept for troubleshooting.");
}
}
finally
{
VoiceLevelChanged?.Invoke(-60);
SetState(State with { BenchmarkRunning = false, BenchmarkDetail = finalized
? (Benchmark.Words.Count > 0 ? $"Test complete with {Benchmark.Words.Count} measured words or phrases." : "The test ended without recognized speech.")
: "The test ended, but Lumi did not return finalized confidence results." });
_sessionStartSignal = null;
_testFailure = null;
_benchmarkCompleteSignal = null;
Interlocked.Exchange(ref _benchmarkStopping, 0);
}
}
private async Task MonitorBenchmarkSilenceAsync(CancellationToken cancellationToken)
{
try
{
using var timer = new PeriodicTimer(TimeSpan.FromMilliseconds(500));
while (await timer.WaitForNextTickAsync(cancellationToken))
{
if (!State.BenchmarkRunning) return;
if (_benchmarkLastVoiceAt is { } lastVoice && DateTimeOffset.UtcNow - lastVoice >= TimeSpan.FromSeconds(10))
{
await StopBenchmarkAsync("silence_timeout", CancellationToken.None);
return;
}
}
}
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) { }
}
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 CheckForUpdatesAsync(CancellationToken cancellationToken = default)
{
var credential = _credentials.Load();
if (credential is null) { SetState(State with { UpdateDetail = "Pair this computer before checking for updates." }); return; }
try
{
SetState(State with { UpdateDetail = "Checking for Companion updates…" });
_availableUpdate = await _updates.CheckAsync(credential, Version, cancellationToken);
var updateDetail = _availableUpdate is null
? $"Lumi Companion {Version} is current."
: _availableUpdate.Development
? $"Local development update ready: {string.Join(", ", _availableUpdate.ChangedComponents?.Select(item => item.Id) ?? new[] { "Companion source" })}."
: $"Lumi Companion {_availableUpdate.Version} is ready to install when OBS is idle.";
SetState(State with
{
UpdateAvailable = _availableUpdate is not null,
AvailableVersion = _availableUpdate?.Version,
UpdateDetail = updateDetail
});
await WriteLogAsync(_availableUpdate is null ? "update_current" : "update_available", updateDetail);
}
catch (Exception error)
{
SetState(State with { UpdateDetail = $"Update check could not finish. {Friendly(error)}" });
await WriteLogAsync("update_check_failed", error.Message);
}
}
public async Task ApplyUpdateAsync(CancellationToken cancellationToken = default)
{
if (State.ObsStreaming || State.ObsRecording) throw new InvalidOperationException("Stop streaming and recording before updating Lumi Companion.");
var update = _availableUpdate ?? throw new InvalidOperationException("No Companion update is ready to install.");
try
{
var credential = _credentials.Load() ?? throw new InvalidOperationException("The paired Companion credential is unavailable.");
SetState(State with { UpdateDetail = update.Development ? "Building, downloading, and verifying the localhost development update…" : $"Downloading and verifying {update.Version}…" });
var staged = await _updates.StageAsync(update, credential, cancellationToken);
if (State.ObsStreaming || State.ObsRecording) throw new InvalidOperationException("OBS started output while the update was downloading. Stop streaming and recording, then try again.");
_updates.LaunchApplier(staged);
SetState(State with { UpdateDetail = "Update verified. Restarting Lumi Companion…" });
await WriteLogAsync("update_staged", $"Verified Companion {update.Version}; restarting to apply it.");
if (UpdateRestartRequested is { } restart) await restart();
}
catch (Exception error)
{
SetState(State with { UpdateDetail = $"Update could not finish. {Friendly(error)}" });
await WriteLogAsync("update_failed", error.Message);
throw;
}
}
public async Task InstallOrRepairObsBridgeAsync(CancellationToken cancellationToken = default)
{
try
{
var result = await _bridgeManager.InstallOrRepairAsync(cancellationToken);
SetState(WithBridgeState(State with { Detail = "OBS integration installed. Start or restart OBS to connect it to Companion." }));
RefreshPathReadiness();
await SendRuntimeStateAsync(cancellationToken);
await WriteLogAsync("obs_bridge_installed", $"Installed managed OBS integration {result.Version}.");
}
catch (Exception error)
{
SetState(State with { ObsBridgeDetail = $"OBS integration maintenance could not finish. {Friendly(error)}" });
await WriteLogAsync("obs_bridge_install_failed", error.Message);
throw;
}
}
public async Task RemoveObsBridgeAsync()
{
try
{
await _bridgeManager.RemoveAsync();
SetState(WithBridgeState(State with { ObsConnected = false, Detail = "OBS integration removed. Other Companion features remain installed." }));
RefreshPathReadiness();
await SendRuntimeStateAsync(CancellationToken.None);
await WriteLogAsync("obs_bridge_removed", "Removed the managed OBS integration.");
}
catch (Exception error)
{
SetState(State with { ObsBridgeDetail = $"OBS integration removal could not finish. {Friendly(error)}" });
await WriteLogAsync("obs_bridge_remove_failed", error.Message);
throw;
}
}
private async Task RunUpdateChecksAsync(CancellationToken cancellationToken)
{
using var timer = new PeriodicTimer(TimeSpan.FromHours(6));
try { while (await timer.WaitForNextTickAsync(cancellationToken)) await CheckForUpdatesAsync(cancellationToken); }
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) { }
}
public async Task SelectSourceAsync(ObsSource source, CancellationToken cancellationToken = default)
{
await _settings.SaveAsync(_settings.Current with { PrimarySourceUuid = source.Uuid, PrimarySourceName = source.Name }, cancellationToken);
await SyncBridgeSelectionAsync(cancellationToken);
foreach (var item in ObsSources) await SendSourceUpdateAsync(item);
RefreshPathReadiness();
await SendRuntimeStateAsync(cancellationToken);
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(WithBridgeState(new CompanionState(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 == "status" && message.Payload.TryGetProperty("kind", out var statusKind) && statusKind.GetString() == "readiness")
{
_serverReady = ReadBoolean(message.Payload, "ready");
_serverReadinessFingerprint = ReadString(message.Payload, "fingerprint");
_serverReadinessDetail = ReadString(message.Payload, "detail") ?? "Lumi did not provide speech recognition readiness details.";
RefreshPathReadiness();
_ = SendRuntimeStateAsync(_lifetimeToken());
}
if (message.Type == "status" && message.Payload.TryGetProperty("kind", out var kind) && kind.GetString() == "session" &&
message.Payload.TryGetProperty("state", out var state))
{
if (state.GetString() == "running")
{
_sessionStartSignal?.TrySetResult(true);
if (message.Payload.TryGetProperty("benchmark_id", out var benchmarkId) && benchmarkId.ValueKind == JsonValueKind.String)
{
Benchmark = Benchmark with { Id = benchmarkId.GetString(), Status = "Running" };
BenchmarkChanged?.Invoke(Benchmark);
}
}
else if (state.GetString() == "idle") _sessionStopSignal?.TrySetResult(true);
}
if (message.Type == "caption")
{
var stableText = message.Payload.TryGetProperty("stable_text", out var stable) ? stable.GetString() : null;
var uncertainText = message.Payload.TryGetProperty("uncertain_text", out var uncertain) ? uncertain.GetString() : null;
var text = string.Join(" ", new[] { stableText, uncertainText }.Where(value => !string.IsNullOrWhiteSpace(value)));
var simulated = message.Payload.TryGetProperty("delivery", out var delivery) && delivery.TryGetProperty("disposition", out var disposition) && disposition.GetString() == "simulated";
if (!string.IsNullOrWhiteSpace(text))
{
var final = ReadBoolean(message.Payload, "final");
if (simulated && final) _captionSignal?.TrySetResult(true);
CaptionReceived?.Invoke(text, simulated);
if (!simulated) _ = _obsBridge?.SendAsync(new { type = "caption", payload = message.Payload });
if (State.BenchmarkRunning) UpdateBenchmarkCaption(message.Payload, text, final);
}
}
if (message.Type == "benchmark_complete")
{
ApplyBenchmarkCompletion(message.Payload);
_benchmarkCompleteSignal?.TrySetResult(true);
}
if (message.Type == "error")
{
var serverMessage = message.Payload.TryGetProperty("message", out var value) ? value.GetString() : "Lumi reported an error.";
_testFailure?.TrySetResult(serverMessage ?? "Lumi reported an inference error.");
_benchmarkLifetime?.Cancel();
SetState(State with { Health = TrayHealth.Degraded, Detail = serverMessage ?? "Lumi reported an error.", BenchmarkRunning = false, BenchmarkDetail = serverMessage ?? "Lumi reported an inference 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;
if (!connected) _bridgeSelectionAttached = null;
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 });
RefreshPathReadiness();
_ = WriteLogAsync("obs_connection", connected ? "OBS bridge connected." : "OBS bridge disconnected.");
if (connected) _ = SyncBridgeSelectionAsync();
_ = SendRuntimeStateAsync(_lifetimeToken());
};
_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." });
RefreshPathReadiness();
await SendRuntimeStateAsync(_lifetimeToken(), ReadString(message, "version"));
}
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 _))
.GroupBy(source => source.Uuid)
.Select(group => group.Last())
.ToArray();
_bridgeSelectionAttached = null;
ObsSourcesChanged?.Invoke(ObsSources);
foreach (var source in ObsSources) await SendSourceUpdateAsync(source);
RefreshPathReadiness();
_ = SyncBridgeSelectionAsync(_lifetimeToken());
}
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 _))
{
var previous = ObsSources.FirstOrDefault(item => item.Uuid == source.Uuid);
if (previous == source) return;
ObsSources = ObsSources.Where(item => item.Uuid != source.Uuid).Append(source).OrderBy(item => item.Name).ToArray();
ObsSourcesChanged?.Invoke(ObsSources);
await SendSourceUpdateAsync(source);
RefreshPathReadiness();
}
}
else if (type == "selection_state")
{
_bridgeSelectionSignal?.TrySetResult((ReadString(message, "source_uuid") ?? string.Empty, ReadBoolean(message, "attached")));
}
}
private Task OnObsAudioAsync(ReadOnlyMemory<byte> frame)
{
var level = MeasureDbfs(frame.Span);
if (State.BenchmarkRunning && Environment.TickCount64 - Interlocked.Read(ref _lastVoiceMeterAt) >= 50)
{
Interlocked.Exchange(ref _lastVoiceMeterAt, Environment.TickCount64);
VoiceLevelChanged?.Invoke(level);
}
if (level >= -38.5)
{
if (State.BenchmarkRunning) _benchmarkLastVoiceAt = DateTimeOffset.UtcNow;
}
var audioNeeded = State.TestRunning || (State.BenchmarkRunning
? Volatile.Read(ref _benchmarkStopping) == 0
: State.ObsStreaming);
if (audioNeeded && _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 async Task<bool> SyncBridgeSelectionAsync(CancellationToken cancellationToken = default)
{
if (_obsBridge is null || string.IsNullOrWhiteSpace(_settings.Current.PrimarySourceUuid)) { _bridgeSelectionAttached = false; RefreshPathReadiness(); return false; }
await _bridgeSelectionGate.WaitAsync(cancellationToken);
try
{
var sourceUuid = _settings.Current.PrimarySourceUuid;
var signal = new TaskCompletionSource<(string SourceUuid, bool Attached)>(TaskCreationOptions.RunContinuationsAsynchronously);
_bridgeSelectionSignal = signal;
var sent = await _obsBridge.SendAsync(new
{
type = "select_sources",
protocol_version = 1,
source_uuids = new[] { sourceUuid },
primary_source_uuid = sourceUuid
}, cancellationToken);
var attached = false;
if (sent)
{
var completed = await Task.WhenAny(signal.Task, Task.Delay(TimeSpan.FromSeconds(2), cancellationToken));
if (completed != signal.Task) { cancellationToken.ThrowIfCancellationRequested(); attached = true; }
else
{
var result = await signal.Task;
attached = result.Attached && string.Equals(result.SourceUuid, sourceUuid, StringComparison.OrdinalIgnoreCase);
}
}
_bridgeSelectionAttached = attached;
RefreshPathReadiness();
return attached;
}
finally
{
_bridgeSelectionSignal = null;
_bridgeSelectionGate.Release();
}
}
private void UpdateBenchmarkCaption(JsonElement payload, string text, bool final)
{
var captionId = ReadString(payload, "caption_id") ?? Guid.NewGuid().ToString();
var revision = payload.TryGetProperty("revision", out var revisionValue) && revisionValue.TryGetInt32(out var parsedRevision) ? parsedRevision : 0;
if (_benchmarkCaptions.TryGetValue(captionId, out var current) && current.Revision >= revision) return;
var words = new List<BenchmarkWord>();
if (payload.TryGetProperty("analysis", out var analysis) && analysis.TryGetProperty("words", out var wordValues) && wordValues.ValueKind == JsonValueKind.Array)
{
foreach (var word in wordValues.EnumerateArray())
{
var wordText = ReadString(word, "text");
if (string.IsNullOrWhiteSpace(wordText)) continue;
words.Add(new BenchmarkWord(wordText, ReadDouble(word, "latency_ms"), final ? Math.Clamp(ReadDouble(word, "confidence"), 0, 1) : null, final));
}
}
_benchmarkCaptions[captionId] = new BenchmarkCaptionRevision(revision, final, text, words, DateTimeOffset.UtcNow);
PublishLiveBenchmark();
}
private void PublishLiveBenchmark()
{
var captions = _benchmarkCaptions.Values.OrderBy(value => value.ReceivedAt).ToArray();
var words = captions.SelectMany(value => value.Words).ToArray();
Benchmark = Benchmark with
{
Transcript = string.Join(" ", captions.Select(value => value.Text).Where(value => !string.IsNullOrWhiteSpace(value))),
Words = words,
Latency = CalculateMetric(words.Select(word => word.LatencyMs)),
Confidence = CalculateMetric(words.Where(word => word.Confidence.HasValue).Select(word => word.Confidence!.Value)),
Status = State.BenchmarkRunning ? "Running" : Benchmark.Status
};
BenchmarkChanged?.Invoke(Benchmark);
}
private void ApplyBenchmarkCompletion(JsonElement payload)
{
var words = new List<BenchmarkWord>();
if (payload.TryGetProperty("words", out var values) && values.ValueKind == JsonValueKind.Array)
{
foreach (var word in values.EnumerateArray())
{
var text = ReadString(word, "text");
if (string.IsNullOrWhiteSpace(text)) continue;
var confidence = word.TryGetProperty("confidence", out var confidenceValue) && confidenceValue.ValueKind == JsonValueKind.Number ? confidenceValue.GetDouble() : (double?)null;
words.Add(new BenchmarkWord(text, ReadDouble(word, "latency_ms"), confidence, ReadBoolean(word, "final")));
}
}
var finalWords = words.Count > 0 ? words : Benchmark.Words;
Benchmark = new BenchmarkSnapshot(
ReadString(payload, "id") ?? Benchmark.Id,
ReadString(payload, "transcript") ?? Benchmark.Transcript,
finalWords,
ParseMetric(payload, "latency") ?? CalculateMetric(finalWords.Select(word => word.LatencyMs)),
ParseMetric(payload, "confidence") ?? CalculateMetric(finalWords.Where(word => word.Confidence.HasValue).Select(word => word.Confidence!.Value)),
Benchmark.StartedAt,
ReadString(payload, "status") ?? "Completed");
BenchmarkChanged?.Invoke(Benchmark);
}
private static MetricStatistics? ParseMetric(JsonElement payload, string name)
{
if (!payload.TryGetProperty("stats", out var stats) || !stats.TryGetProperty(name, out var value)) return null;
return new MetricStatistics(ReadInt(value, "count"), ReadNullableDouble(value, "min"), ReadNullableDouble(value, "low_1_average"),
ReadNullableDouble(value, "median"), ReadNullableDouble(value, "average"), ReadNullableDouble(value, "p99"),
ReadNullableDouble(value, "high_1_average"), ReadNullableDouble(value, "max"));
}
private static MetricStatistics CalculateMetric(IEnumerable<double> input)
{
var values = input.Where(double.IsFinite).Order().ToArray();
if (values.Length == 0) return new(0, null, null, null, null, null, null, null);
var tail = Math.Max(1, (int)Math.Ceiling(values.Length * 0.01));
return new(values.Length, values[0], values.Take(tail).Average(), Percentile(values, 0.5), values.Average(), Percentile(values, 0.99), values.TakeLast(tail).Average(), values[^1]);
}
private static double Percentile(double[] values, double ratio)
{
var position = (values.Length - 1) * ratio;
var lower = (int)Math.Floor(position);
var upper = (int)Math.Ceiling(position);
return values[lower] + (values[upper] - values[lower]) * (position - lower);
}
private async Task SendRuntimeStateAsync(CancellationToken cancellationToken, string? obsVersion = null)
{
if (_socket is null || !State.Connected) return;
var bridge = _bridgeManager.Inspect();
var fingerprint = CurrentPathFingerprint();
var pathValid = _serverReady && fingerprint is not null && _settings.Current.PathTestPassedAt.HasValue &&
string.Equals(_settings.Current.PathTestFingerprint, fingerprint, StringComparison.Ordinal);
await _socket.SendAsync("obs_state", new
{
streaming = State.ObsStreaming,
recording = State.ObsRecording,
version = obsVersion,
auto_start = _settings.Current.StartWithObs,
bridge_installed = bridge.Valid,
bridge_connected = State.ObsConnected,
bridge_version = bridge.Version,
path_test_valid = pathValid,
path_test_at = pathValid ? _settings.Current.PathTestPassedAt?.ToUnixTimeMilliseconds() : null
}, _socket.SessionId, cancellationToken);
}
private string? CurrentPathFingerprint()
{
var bridge = _bridgeManager.Inspect();
if (!_serverReady || string.IsNullOrWhiteSpace(_serverReadinessFingerprint) || !bridge.Valid ||
string.IsNullOrWhiteSpace(State.Host) || string.IsNullOrWhiteSpace(_settings.Current.PrimarySourceUuid)) return null;
if (State.ObsConnected && _bridgeSelectionAttached == false) return null;
if (State.ObsConnected && ObsSources.FirstOrDefault(source => source.Uuid == _settings.Current.PrimarySourceUuid) is not { Missing: false, Active: true }) return null;
var value = string.Join("|", PathValidationContract, State.Host, _settings.Current.PrimarySourceUuid, bridge.Version, _serverReadinessFingerprint);
return Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(value))).ToLowerInvariant();
}
private void RefreshPathReadiness(string? validDetail = null)
{
var current = CurrentPathFingerprint();
var valid = current is not null && _settings.Current.PathTestPassedAt.HasValue &&
string.Equals(_settings.Current.PathTestFingerprint, current, StringComparison.Ordinal);
var detail = valid
? validDetail ?? $"Passed {_settings.Current.PathTestPassedAt!.Value.LocalDateTime:g}; no relevant setup or model changes detected."
: !_serverReady ? _serverReadinessDetail
: !_bridgeManager.Inspect().Valid ? "Install or repair the managed OBS integration."
: string.IsNullOrWhiteSpace(_settings.Current.PrimarySourceUuid) ? "Choose the primary OBS microphone first."
: State.ObsConnected && _bridgeSelectionAttached == false ? "Companion could not attach the saved OBS microphone. Re-select it or restart OBS."
: State.ObsConnected && ObsSources.FirstOrDefault(source => source.Uuid == _settings.Current.PrimarySourceUuid) is not { Missing: false, Active: true } ? "The saved microphone is not currently available in the active OBS Program scene."
: _settings.Current.PathTestPassedAt.HasValue ? "A relevant source, model, or integration setting changed. Run the short voice-free check when convenient."
: "Run this short check once. It does not require speaking and remains valid until a relevant configuration changes.";
var health = State.ObsStreaming ? TrayHealth.Operating : valid && State.Connected && State.ObsConnected ? TrayHealth.Ready : State.Connected ? TrayHealth.Degraded : State.Health;
var overview = valid && State.Connected && State.ObsConnected && !State.ObsStreaming
? "Everything is ready. Lumi will let you know if a relevant connection or configuration needs attention."
: State.Detail;
SetState(State with { PathTestValid = valid, PathTestDetail = detail, Health = health, Detail = overview });
}
private static double MeasureDbfs(ReadOnlySpan<byte> frame)
{
if (frame.Length <= ProtocolV1.AudioHeaderBytes || (frame[5] & 1) == 0 || (frame[5] & 2) != 0) return -60;
var samples = frame[ProtocolV1.AudioHeaderBytes..];
double squares = 0;
var count = samples.Length / 2;
for (var index = 0; index < count; index += 1)
{
var value = BinaryPrimitives.ReadInt16LittleEndian(samples.Slice(index * 2, 2)) / 32768.0;
squares += value * value;
}
if (count == 0) return -60;
var rms = Math.Sqrt(squares / count);
return Math.Clamp(rms <= 0 ? -60 : 20 * Math.Log10(rms), -60, 0);
}
private static BenchmarkSnapshot EmptyBenchmark(string status, DateTimeOffset? startedAt = null) =>
new(null, string.Empty, [], CalculateMetric([]), CalculateMetric([]), startedAt, status);
private static double ReadDouble(JsonElement value, string name) => value.TryGetProperty(name, out var property) && property.ValueKind == JsonValueKind.Number ? property.GetDouble() : 0;
private static double? ReadNullableDouble(JsonElement value, string name) => value.TryGetProperty(name, out var property) && property.ValueKind == JsonValueKind.Number ? property.GetDouble() : null;
private static int ReadInt(JsonElement value, string name) => value.TryGetProperty(name, out var property) && property.TryGetInt32(out var result) ? result : 0;
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 async Task<(bool Completed, string? Failure)> WaitForTestSignalAsync(Task signal, TimeSpan timeout, CancellationToken cancellationToken)
{
var failure = _testFailure?.Task ?? throw new InvalidOperationException("The test failure signal is unavailable.");
using var timeoutSource = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
var delay = Task.Delay(timeout, timeoutSource.Token);
var completed = await Task.WhenAny(signal, failure, delay);
if (completed == signal) { timeoutSource.Cancel(); await signal; return (true, null); }
if (completed == failure) { timeoutSource.Cancel(); return (false, await failure); }
cancellationToken.ThrowIfCancellationRequested();
return (false, null);
}
private bool DetectBridgeInstallation() => _bridgeManager.Inspect().Valid;
private CompanionState WithBridgeState(CompanionState state) { var bridge = _bridgeManager.Inspect(); return state with { ObsBridgeInstalled = bridge.Valid, ObsBridgeRepairNeeded = bridge.Installed && !bridge.Valid, ObsBridgePackageAvailable = bridge.PackageAvailable, ObsBridgeDetail = bridge.Detail }; }
private static string? FindBundledPairingPackage()
{
try
{
return Directory.EnumerateFiles(AppContext.BaseDirectory, "*.lumi-pairing.json", SearchOption.TopDirectoryOnly).Take(2).SingleOrDefault();
}
catch { return null; }
}
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,
System.Net.WebSockets.WebSocketException => "The Lumi server connection ended unexpectedly. The host may have restarted; reconnect and review transcription diagnostics.",
EndOfStreamException => error.Message,
HttpRequestException => "Check the Lumi address, TLS certificate, and network connection.",
TaskCanceledException => "The connection timed out. Check that Lumi is reachable.",
_ => error.Message
};
private sealed record BenchmarkCaptionRevision(int Revision, bool Final, string Text, IReadOnlyList<BenchmarkWord> Words, DateTimeOffset ReceivedAt);
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 source", "Waiting to validate the selected OBS source. Speaking is not required.", TestStageState.Waiting),
new("Lumi connection", "Waiting to verify secure transport.", TestStageState.Waiting),
new("Speech recognition", "Waiting for the server-hosted model readiness report.", TestStageState.Waiting),
new("Caption return", "Waiting for a safe test 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().GetCustomAttribute<AssemblyInformationalVersionAttribute>()?.InformationalVersion ?? "0.1.0").Split('+')[0];
public static string DisplayVersion => DevelopmentBuildManifest.Load() is { BuildChecksum.Length: >= 8 } build
? $"{Version} · Local development {build.BuildChecksum[..8]}"
: Version;
public async ValueTask DisposeAsync()
{
if (_disposed) return;
if (State.BenchmarkRunning) try { await StopBenchmarkAsync("disconnect", CancellationToken.None); } catch { }
_disposed = true;
_maintenanceLifetime.Cancel();
_benchmarkLifetime?.Cancel();
if (_socket is not null) await _socket.DisposeAsync();
if (_obsBridge is not null) await _obsBridge.DisposeAsync();
_benchmarkLifetime?.Dispose(); _bridgeSelectionGate.Dispose();
_http.Dispose(); _maintenanceLifetime.Dispose();
}
}

View File

@ -0,0 +1,56 @@
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,
DateTimeOffset? PathTestPassedAt = null,
string? PathTestFingerprint = 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

@ -0,0 +1,57 @@
namespace Lumi.Companion.App;
public enum TrayHealth { Ready, Operating, Degraded, Failed }
public enum CompanionPage { Overview, Transcription, Test, SongOverlay, 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,
bool BenchmarkRunning = false,
TrayHealth Health = TrayHealth.Degraded,
string Detail = "Pair Lumi Companion to get started.",
string? DeviceName = null,
string? Host = null,
DateTimeOffset? LastConnectedAt = null,
bool UpdateAvailable = false,
string? AvailableVersion = null,
string UpdateDetail = "Checking for updates…",
string BenchmarkDetail = "Start a dedicated test, speak naturally, then end it when you have enough material.",
bool PathTestValid = false,
string PathTestDetail = "Run once after setup or a relevant configuration change.",
bool ObsBridgeRepairNeeded = false,
bool ObsBridgePackageAvailable = false,
string ObsBridgeDetail = "Checking the managed OBS integration…")
{
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 || BenchmarkRunning;
public string QuitWarning => BenchmarkRunning
? "A transcription accuracy and latency test is running. Quitting will safely end and mark the test as aborted."
: 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 BenchmarkWord(string Text, double LatencyMs, double? Confidence, bool Final);
public sealed record MetricStatistics(int Count, double? Minimum, double? LowOnePercentAverage, double? Median, double? Average, double? P99, double? HighOnePercentAverage, double? Maximum);
public sealed record BenchmarkSnapshot(string? Id, string Transcript, IReadOnlyList<BenchmarkWord> Words, MetricStatistics Latency, MetricStatistics Confidence, DateTimeOffset? StartedAt, string Status);
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

@ -0,0 +1,37 @@
using System.Text.Json;
using System.Text.Json.Serialization;
using Lumi.Companion.Protocol;
namespace Lumi.Companion.App;
public sealed record DevelopmentBuildManifest(
[property: JsonPropertyName("schema_version")] int SchemaVersion,
[property: JsonPropertyName("mode")] string Mode,
[property: JsonPropertyName("build_checksum")] string BuildChecksum,
[property: JsonPropertyName("generated_at")] string GeneratedAt,
[property: JsonPropertyName("components")] IReadOnlyDictionary<string, string> Components)
{
public static DevelopmentBuildManifest? Load()
{
var path = Path.Combine(AppContext.BaseDirectory, ".lumi-dev-build.json");
if (!File.Exists(path)) return null;
try
{
var manifest = JsonSerializer.Deserialize<DevelopmentBuildManifest>(File.ReadAllText(path), ProtocolV1.JsonOptions);
return manifest is { SchemaVersion: 1 } && IsSha256(manifest.BuildChecksum)
? manifest with { Components = Sanitize(manifest.Components) }
: null;
}
catch { return null; }
}
private static IReadOnlyDictionary<string, string> Sanitize(IReadOnlyDictionary<string, string>? input)
{
if (input is null) return new Dictionary<string, string>();
return input
.Where(entry => entry.Key.Length is > 0 and <= 160 && IsSha256(entry.Value))
.ToDictionary(entry => entry.Key, entry => entry.Value.ToLowerInvariant(), StringComparer.OrdinalIgnoreCase);
}
private static bool IsSha256(string? value) => value is { Length: 64 } && value.All(Uri.IsHexDigit);
}

View File

@ -0,0 +1,32 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>WinExe</OutputType>
<TargetFramework>net8.0-windows10.0.19041.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<ApplicationManifest>app.manifest</ApplicationManifest>
<ApplicationIcon>Assets\Lumi.Companion.ico</ApplicationIcon>
<Version>0.1.0</Version>
<AssemblyVersion>0.1.0.0</AssemblyVersion>
</PropertyGroup>
<ItemGroup>
<AvaloniaResource Include="Assets\Lumi.Companion.ico" />
<ProjectReference Include="../Lumi.Companion.Core/Lumi.Companion.Core.csproj" />
<ProjectReference Include="../Lumi.Companion.Protocol/Lumi.Companion.Protocol.csproj" />
<ProjectReference Include="../Lumi.Companion.Abstractions/Lumi.Companion.Abstractions.csproj" />
<ProjectReference Include="../../plugins/Lumi.Companion.Transcription/Lumi.Companion.Transcription.csproj" />
<ProjectReference Include="../../plugins/Lumi.Companion.SongOverlay/Lumi.Companion.SongOverlay.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>
<ItemGroup Condition="Exists('components/obs-bridge/lumi-obs-bridge.dll')">
<Content Include="components/obs-bridge/lumi-obs-bridge.dll" CopyToOutputDirectory="PreserveNewest" CopyToPublishDirectory="PreserveNewest" ExcludeFromSingleFile="true" />
<Content Include="components/obs-bridge/manifest.json" CopyToOutputDirectory="PreserveNewest" CopyToPublishDirectory="PreserveNewest" />
<Content Include="components/obs-bridge/en-US.ini" CopyToOutputDirectory="PreserveNewest" CopyToPublishDirectory="PreserveNewest" />
<EmbeddedResource Include="components/obs-bridge/lumi-obs-bridge.dll" LogicalName="Lumi.Companion.ObsBridge.dll" />
<EmbeddedResource Include="components/obs-bridge/manifest.json" LogicalName="Lumi.Companion.ObsBridge.manifest.json" />
<EmbeddedResource Include="components/obs-bridge/en-US.ini" LogicalName="Lumi.Companion.ObsBridge.en-US.ini" />
</ItemGroup>
</Project>

View File

@ -0,0 +1,66 @@
using Avalonia.Controls;
namespace Lumi.Companion.App;
internal static class LumiIconFactory
{
private static readonly Dictionary<TrayHealth, WindowIcon> Icons = new();
private static readonly object IconGate = new();
public static WindowIcon Create(TrayHealth health)
{
lock (IconGate)
{
if (Icons.TryGetValue(health, out var icon)) return icon;
icon = CreateUncached(health);
Icons[health] = icon;
return icon;
}
}
private static WindowIcon CreateUncached(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)40, G: (byte)183, B: (byte)200)
};
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,350 @@
<Window xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
x:Class="Lumi.Companion.App.MainWindow"
Title="Lumi Companion"
Icon="/Assets/Lumi.Companion.ico"
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>
<Grid Grid.Row="2" RowDefinitions="Auto,*,Auto">
<Button x:Name="OverviewNav" Classes="nav selected" Content="Overview" Tag="Overview" />
<ScrollViewer Grid.Row="1" Margin="0,14,0,14" VerticalScrollBarVisibility="Auto">
<StackPanel Spacing="6">
<TextBlock Text="PLUGINS" Classes="eyebrow" Margin="12,0,0,4" />
<StackPanel x:Name="PluginNavigation" Spacing="4" />
</StackPanel>
</ScrollViewer>
<StackPanel Grid.Row="2" x:Name="TechnicalNavigation">
<Border Height="1" Background="{DynamicResource LumiBorderBrush}" Margin="0,0,0,8" />
<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>
</Grid>
<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>
<Border x:Name="UpdatePanel" Classes="soft" Padding="18" IsVisible="False">
<Grid ColumnDefinitions="*,Auto" ColumnSpacing="20">
<StackPanel Spacing="4">
<TextBlock x:Name="UpdateTitle" Text="Companion update available" FontWeight="SemiBold" />
<TextBlock x:Name="UpdateDetail" Text="A verified update is ready." Classes="muted" />
</StackPanel>
<Button Grid.Column="1" x:Name="ApplyUpdateButton" Classes="primary" Content="Update Companion" 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="Checks the selected OBS source, secure connection, model readiness, and safe caption return path. You do not need to speak, and nothing is sent 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>
<Border Classes="card">
<StackPanel Spacing="16">
<StackPanel Spacing="5">
<TextBlock Text="ACCURACY &amp; LATENCY" Classes="eyebrow" />
<TextBlock Text="Dedicated transcription test" Classes="sectionTitle" />
<TextBlock Text="Speak naturally for as long as needed. End the test yourself, or Lumi will finalize it after 10 seconds of silence. Nothing is sent to Twitch." Classes="muted" />
</StackPanel>
<Grid ColumnDefinitions="Auto,Auto,*,Auto" ColumnSpacing="10">
<Button x:Name="StartBenchmarkButton" Classes="primary" Content="Start dedicated test" />
<Button Grid.Column="1" x:Name="StopBenchmarkButton" Classes="secondary" Content="End test" IsEnabled="False" />
<ProgressBar Grid.Column="2" x:Name="VoiceLevelMeter" Minimum="-60" Maximum="0" Value="-60" Height="12" MinWidth="220" VerticalAlignment="Center" Foreground="{DynamicResource LumiDangerBrush}" />
<TextBlock Grid.Column="3" x:Name="VoiceLevelText" Text="-60 dBFS" Classes="muted" FontSize="11" VerticalAlignment="Center" MinWidth="58" />
</Grid>
<TextBlock Text="Input level: green from -30 to -12 dBFS; orange within 10 dB outside that range; red otherwise." Classes="muted" FontSize="11" />
<TextBlock x:Name="BenchmarkStatusText" Text="Not started." Classes="muted" />
<Border Classes="soft">
<StackPanel Spacing="5">
<TextBlock Text="Measured transcript" FontWeight="SemiBold" />
<TextBlock x:Name="BenchmarkTranscript" Text="Finalized speech will appear here." Classes="muted" TextWrapping="Wrap" />
</StackPanel>
</Border>
<StackPanel Spacing="8">
<TextBlock Text="Latency" FontWeight="SemiBold" />
<WrapPanel x:Name="LatencyStatsPanel" Orientation="Horizontal" />
</StackPanel>
<StackPanel Spacing="8">
<TextBlock Text="Final confidence" FontWeight="SemiBold" />
<WrapPanel x:Name="ConfidenceStatsPanel" Orientation="Horizontal" />
</StackPanel>
<TextBlock Text="Low and high 1% values are tail averages. Confidence is included only after an utterance is finalized." Classes="muted" FontSize="12" />
</StackPanel>
</Border>
</StackPanel>
</ScrollViewer>
<ScrollViewer x:Name="SongOverlayPage" IsVisible="False">
<StackPanel Margin="44,38,52,48" MaxWidth="760" HorizontalAlignment="Left" Spacing="24">
<StackPanel Spacing="6">
<TextBlock Text="MEDIA" Classes="eyebrow" />
<TextBlock Text="Song Overlay" Classes="pageTitle" />
<TextBlock Text="Spotify is the first media provider. The provider contract remains generic so later services can use the same event and overlay pipeline." Classes="muted" FontSize="15" />
</StackPanel>
<Border Classes="soft">
<Grid ColumnDefinitions="*,Auto" ColumnSpacing="20">
<StackPanel Spacing="4">
<TextBlock x:Name="SongOverlayStatusTitle" Text="Starting" FontWeight="SemiBold" />
<TextBlock x:Name="SongOverlayStatusDetail" Text="Waiting for the media provider…" Classes="muted" />
<TextBlock x:Name="SongOverlayCurrentTrack" Text="No active song" Classes="muted" FontSize="12" />
</StackPanel>
<ToggleSwitch Grid.Column="1" x:Name="SongOverlayEnabledToggle" OffContent="Off" OnContent="On" VerticalAlignment="Center" />
</Grid>
</Border>
<StackPanel Spacing="10">
<TextBlock Text="Lumi authentication" Classes="sectionTitle" />
<Border Classes="soft">
<StackPanel Spacing="4">
<TextBlock x:Name="SongOverlayLumiAuthenticationText" Text="Uses the Companion device pairing" FontWeight="SemiBold" />
<TextBlock Text="Song Overlay inherits the protected Companion credential automatically. Revoking or forgetting this Companion also revokes plugin access; no separate plugin key is needed." Classes="muted" />
</StackPanel>
</Border>
</StackPanel>
<StackPanel Spacing="12">
<TextBlock Text="Playback provider" Classes="sectionTitle" />
<Grid ColumnDefinitions="150,*" RowDefinitions="Auto,Auto,Auto" RowSpacing="10" ColumnSpacing="12">
<TextBlock Text="Provider" Classes="muted" VerticalAlignment="Center" />
<ComboBox Grid.Column="1" x:Name="SongOverlayProviderPicker" SelectedIndex="0" IsEnabled="False">
<ComboBoxItem Content="Spotify" Tag="spotify" />
</ComboBox>
<TextBlock Grid.Row="1" Text="Recovery heartbeat" Classes="muted" VerticalAlignment="Center" />
<NumericUpDown Grid.Row="1" Grid.Column="1" x:Name="SongOverlayHeartbeatBox" Minimum="15" Maximum="300" Increment="5" FormatString="0 seconds" HorizontalAlignment="Left" Width="160" />
<TextBlock Grid.Row="2" Text="Seek sensitivity" Classes="muted" VerticalAlignment="Center" />
<NumericUpDown Grid.Row="2" Grid.Column="1" x:Name="SongOverlaySeekBox" Minimum="500" Maximum="10000" Increment="250" FormatString="0 ms" HorizontalAlignment="Left" Width="160" />
</Grid>
<CheckBox x:Name="SongOverlayCoverToggle" Content="Send cover art only with track metadata" />
<CheckBox x:Name="SongOverlaySearchLinkToggle" Content="Use a Spotify search link when an exact song link is unavailable" />
</StackPanel>
<Border Classes="card">
<StackPanel Spacing="12">
<StackPanel Spacing="4">
<TextBlock Text="SPOTIFY-SPECIFIC" Classes="eyebrow" />
<TextBlock Text="Optional metadata enrichment" Classes="sectionTitle" />
<TextBlock Text="Windows normally supplies title, artist, album, timeline and artwork. Connecting your own Spotify application adds an exact link, release year and official artwork when available. Spotify is contacted only when track metadata changes or needs recovery." Classes="muted" />
</StackPanel>
<TextBox x:Name="SongOverlaySpotifyClientIdBox" PlaceholderText="Spotify application Client ID" />
<StackPanel Orientation="Horizontal" Spacing="10">
<Button x:Name="ConnectSongOverlaySpotifyButton" Classes="secondary" Content="Connect Spotify metadata" />
<Button x:Name="DisconnectSongOverlaySpotifyButton" Classes="secondary" Content="Disconnect" />
</StackPanel>
<TextBlock x:Name="SongOverlaySpotifyStatus" Text="Not connected. Core playback detection still works through Windows." Classes="muted" />
</StackPanel>
</Border>
<StackPanel Orientation="Horizontal" Spacing="10">
<Button x:Name="SaveSongOverlayButton" Classes="primary" Content="Save Song Overlay" />
<Button x:Name="SendSongOverlayStateButton" Classes="secondary" Content="Send current song now" />
</StackPanel>
<TextBlock x:Name="SongOverlayFeedback" Classes="muted" />
</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="10">
<TextBlock Text="Managed OBS integration" FontWeight="SemiBold" />
<TextBlock x:Name="BridgeStatusText" Text="Checking the bundled integration…" Classes="muted" />
<StackPanel Orientation="Horizontal" Spacing="10">
<Button x:Name="InstallBridgeButton" Classes="primary" Content="Install integration" />
<Button x:Name="RemoveBridgeButton" Classes="secondary" Content="Remove integration" />
</StackPanel>
<TextBlock Text="Close OBS before install, repair, or removal. Restart OBS afterward so it can load the verified plugin." Classes="muted" FontSize="12" />
</StackPanel>
</Border>
<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>
<Border Classes="soft">
<Grid ColumnDefinitions="*,Auto" ColumnSpacing="20">
<StackPanel Spacing="4"><TextBlock Text="Companion updates" FontWeight="SemiBold" /><TextBlock x:Name="UpdateStatusText" Text="Checking for updates…" Classes="muted" /><TextBlock Text="Current version" Classes="muted" FontSize="11" /><TextBlock x:Name="CurrentVersionText" Text="—" FontSize="12" /></StackPanel>
<Button Grid.Column="1" x:Name="CheckUpdateButton" Classes="secondary" Content="Check now" VerticalAlignment="Center" />
</Grid>
</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,482 @@
using Avalonia;
using Avalonia.Controls;
using Avalonia.Interactivity;
using Avalonia.Media;
using Avalonia.Platform.Storage;
using Avalonia.Threading;
using Lumi.Companion.Abstractions;
using Lumi.Companion.SongOverlay;
namespace Lumi.Companion.App;
public partial class MainWindow : Window
{
private readonly CompanionRuntime _runtime;
private readonly CompanionSettingsStore _settings;
private readonly SongOverlayRuntime _songOverlay;
private readonly IReadOnlyList<ICompanionPluginContribution> _plugins;
private readonly Dictionary<CompanionPage, Button> _navigationButtons = [];
private bool _allowExit;
private bool _renderingSources;
private bool _renderingSongOverlay;
public MainWindow() : this(CreateDefaultServices()) { }
private MainWindow((CompanionRuntime Runtime, CompanionSettingsStore Settings, SongOverlayRuntime SongOverlay, IReadOnlyList<ICompanionPluginContribution> Plugins) services)
: this(services.Runtime, services.Settings, services.SongOverlay, services.Plugins) { }
public MainWindow(CompanionRuntime runtime, CompanionSettingsStore settings, SongOverlayRuntime songOverlay, IReadOnlyList<ICompanionPluginContribution> plugins)
{
_runtime = runtime;
_settings = settings;
_songOverlay = songOverlay;
_plugins = plugins;
InitializeComponent();
BuildPluginNavigation();
WireActions();
RenderState(runtime.State);
RenderTestStages(runtime.TestStages);
RenderBenchmark(runtime.Benchmark);
RenderSongOverlay();
Closing += OnClosing;
runtime.StateChanged += state => Dispatcher.UIThread.Post(() => { RenderState(state); RenderSongOverlay(); });
runtime.TestStagesChanged += stages => Dispatcher.UIThread.Post(() => RenderTestStages(stages));
runtime.ObsSourcesChanged += sources => Dispatcher.UIThread.Post(() => RenderSources(sources));
runtime.BenchmarkChanged += benchmark => Dispatcher.UIThread.Post(() => RenderBenchmark(benchmark));
runtime.VoiceLevelChanged += level => Dispatcher.UIThread.Post(() => RenderVoiceLevel(level));
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));
songOverlay.Changed += () => Dispatcher.UIThread.Post(RenderSongOverlay);
}
private void BuildPluginNavigation()
{
PluginNavigation.Children.Clear();
foreach (var plugin in _plugins.OrderBy(item => item.Descriptor.Order).ThenBy(item => item.Descriptor.Name, StringComparer.OrdinalIgnoreCase))
{
var children = new StackPanel { Spacing = 2, Margin = new Thickness(8, 3, 0, 6) };
foreach (var page in plugin.Pages.OrderBy(item => item.Order))
{
if (!Enum.TryParse<CompanionPage>(page.Key, out var parsed)) continue;
var button = new Button { Content = page.Label, Tag = page.Key, HorizontalContentAlignment = Avalonia.Layout.HorizontalAlignment.Left };
button.Classes.Add("nav");
button.Click += OnNavigate;
children.Children.Add(button);
_navigationButtons[parsed] = button;
}
var expander = new Expander
{
Header = new TextBlock { Text = plugin.Descriptor.Name, FontWeight = FontWeight.SemiBold },
IsExpanded = true,
Content = children,
HorizontalAlignment = Avalonia.Layout.HorizontalAlignment.Stretch
};
expander.Classes.Add("pluginRoot");
PluginNavigation.Children.Add(expander);
}
_navigationButtons[CompanionPage.Overview] = OverviewNav;
_navigationButtons[CompanionPage.Connection] = ConnectionNav;
_navigationButtons[CompanionPage.Logs] = LogsNav;
_navigationButtons[CompanionPage.Settings] = SettingsNav;
}
private void WireActions()
{
OverviewNav.Click += OnNavigate;
foreach (var button in TechnicalNavigation.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);
StartBenchmarkButton.Click += async (_, _) => await RunUiActionAsync(() => _runtime.StartBenchmarkAsync(), StartBenchmarkButton);
StopBenchmarkButton.Click += async (_, _) => await RunUiActionAsync(() => _runtime.StopBenchmarkAsync(), StopBenchmarkButton);
ForgetButton.Click += async (_, _) => await ForgetDeviceAsync();
SaveSettingsButton.Click += async (_, _) => await SaveSettingsAsync();
CheckUpdateButton.Click += async (_, _) => await RunUiActionAsync(() => _runtime.CheckForUpdatesAsync(), CheckUpdateButton);
ApplyUpdateButton.Click += async (_, _) => await ApplyUpdateAsync();
InstallBridgeButton.Click += async (_, _) => await InstallBridgeAsync();
RemoveBridgeButton.Click += async (_, _) => await RemoveBridgeAsync();
AdvancedToggle.IsCheckedChanged += (_, _) => AdvancedPanel.IsVisible = AdvancedToggle.IsChecked == true;
SaveSongOverlayButton.Click += async (_, _) => await SaveSongOverlayAsync();
SendSongOverlayStateButton.Click += async (_, _) => await RunSongOverlayActionAsync(() => _songOverlay.SendSnapshotAsync(), SendSongOverlayStateButton);
ConnectSongOverlaySpotifyButton.Click += async (_, _) =>
{
await SaveSongOverlayAsync(restartProvider: false);
await RunSongOverlayActionAsync(() => _songOverlay.ConnectSpotifyAsync(), ConnectSongOverlaySpotifyButton);
};
DisconnectSongOverlaySpotifyButton.Click += (_, _) => { _songOverlay.DisconnectSpotify(); RenderSongOverlay(); };
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.SongOverlay] = SongOverlayPage,
[CompanionPage.Connection] = ConnectionPage,
[CompanionPage.Logs] = LogsPage,
[CompanionPage.Settings] = SettingsPage
};
foreach (var item in pages) item.Value.IsVisible = item.Key == page;
foreach (var item in _navigationButtons)
item.Value.Classes.Set("selected", item.Key == page);
}
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 { RenderState(_runtime.State); }
}
private async Task InstallBridgeAsync()
{
InstallBridgeButton.IsEnabled = false;
BridgeStatusText.Text = "Checking the packaged integration and requesting Windows approval…";
try { await _runtime.InstallOrRepairObsBridgeAsync(); }
catch (Exception error)
{
AddLog($"{DateTime.Now:HH:mm:ss} {error.Message}");
RenderState(_runtime.State);
var dialog = new DecisionWindow("OBS integration could not be installed", error.Message, "Close");
await dialog.ShowDialog<bool>(this);
}
finally { RenderState(_runtime.State); }
}
private async Task SaveSongOverlayAsync(bool restartProvider = true)
{
if (_renderingSongOverlay) return;
SaveSongOverlayButton.IsEnabled = false;
SongOverlayFeedback.Text = "Saving…";
try
{
var settings = _songOverlay.Settings;
settings.Enabled = SongOverlayEnabledToggle.IsChecked == true;
settings.ProviderId = "spotify";
settings.HeartbeatSeconds = Decimal.ToInt32(SongOverlayHeartbeatBox.Value ?? 30);
settings.SeekThresholdMilliseconds = Decimal.ToInt32(SongOverlaySeekBox.Value ?? 1500);
settings.SendCoverArt = SongOverlayCoverToggle.IsChecked == true;
settings.UseSearchLinkFallback = SongOverlaySearchLinkToggle.IsChecked == true;
settings.SpotifyClientId = SongOverlaySpotifyClientIdBox.Text?.Trim() ?? "";
await _songOverlay.SaveSettingsAsync(restartProvider);
SongOverlayFeedback.Text = "Song Overlay settings saved.";
}
catch (Exception error)
{
SongOverlayFeedback.Text = $"Could not save Song Overlay: {error.Message}";
AddLog($"{DateTime.Now:HH:mm:ss} Song Overlay: {error.Message}");
}
finally
{
SaveSongOverlayButton.IsEnabled = true;
RenderSongOverlay();
}
}
private async Task RunSongOverlayActionAsync(Func<Task> action, Button button)
{
button.IsEnabled = false;
try
{
await action();
SongOverlayFeedback.Text = "Song Overlay action completed.";
}
catch (Exception error)
{
SongOverlayFeedback.Text = error.Message;
AddLog($"{DateTime.Now:HH:mm:ss} Song Overlay: {error.Message}");
}
finally
{
RenderSongOverlay();
}
}
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 async Task ApplyUpdateAsync()
{
if (_runtime.State.RequiresQuitConfirmation)
{
var blocked = new DecisionWindow("Update after the stream?", "Companion updates never interrupt streaming or recording. Stop OBS output, then choose Update Companion again.", "Got it");
await blocked.ShowDialog<bool>(this);
return;
}
var dialog = new DecisionWindow("Update Lumi Companion?", $"Download, verify, and install {_runtime.State.AvailableVersion}. Companion will restart and keep your pairing and settings.", "Update Companion");
if (!await dialog.ShowDialog<bool>(this)) return;
await RunUiActionAsync(() => _runtime.ApplyUpdateAsync(), ApplyUpdateButton);
}
private async Task RemoveBridgeAsync()
{
var dialog = new DecisionWindow("Remove the OBS integration?", "This removes only the Companion-managed OBS plugin. Pairing, preferences, and Lumi Companion remain installed.", "Remove integration");
if (!await dialog.ShowDialog<bool>(this)) return;
await RunUiActionAsync(() => _runtime.RemoveObsBridgeAsync(), RemoveBridgeButton);
}
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 = state.PathTestValid ? "✓" : "○";
TestStepDetail.Text = state.PathTestDetail;
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 && !state.BenchmarkRunning;
StartBenchmarkButton.IsEnabled = !state.TestRunning && !state.BenchmarkRunning;
StopBenchmarkButton.IsEnabled = state.BenchmarkRunning;
BenchmarkStatusText.Text = state.BenchmarkDetail;
UpdatePanel.IsVisible = state.UpdateAvailable;
UpdateTitle.Text = state.UpdateAvailable ? $"Lumi Companion {state.AvailableVersion} is available" : "Lumi Companion is current";
UpdateDetail.Text = state.UpdateDetail;
ApplyUpdateButton.IsEnabled = state.UpdateAvailable && !state.ObsStreaming && !state.ObsRecording;
CurrentVersionText.Text = CompanionRuntime.DisplayVersion;
UpdateStatusText.Text = state.UpdateDetail;
BridgeStatusText.Text = state.ObsBridgeDetail;
InstallBridgeButton.Content = state.ObsBridgeRepairNeeded ? "Repair integration" : state.ObsBridgeInstalled ? "Reinstall integration" : "Install integration";
// The maintenance action performs a fresh package/process check and reports the
// actual blocker, so a stale OBS connection signal must never strand repair.
InstallBridgeButton.IsEnabled = !state.TestRunning && !state.BenchmarkRunning;
RemoveBridgeButton.IsEnabled = state.ObsBridgeInstalled || state.ObsBridgeRepairNeeded;
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 = state.ObsBridgeDetail;
NextActionButton.Content = "Manage OBS integration";
}
else
{
NextActionTitle.Text = state.PathTestValid ? "Ready when you are" : "Run one short readiness check";
NextActionDetail.Text = state.PathTestValid ? state.PathTestDetail : "No speaking is required. The result remains valid until a relevant setup or model setting changes.";
NextActionButton.Content = state.PathTestValid ? "View status" : "Open voice-free check";
}
}
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 RenderSongOverlay()
{
_renderingSongOverlay = true;
try
{
var settings = _songOverlay.Settings;
SongOverlayStatusTitle.Text = _songOverlay.Status.Summary;
SongOverlayStatusDetail.Text = _songOverlay.ProviderStatus;
SongOverlayCurrentTrack.Text = _songOverlay.CurrentTrack is { Length: > 0 } current ? $"Current: {current}" : "No active song";
SongOverlayEnabledToggle.IsChecked = settings.Enabled;
SongOverlayLumiAuthenticationText.Text = _songOverlay.UsesCompanionAuthentication
? $"Authenticated through Companion · {_songOverlay.EffectiveLumiBaseUri}"
: "Pair Companion to enable Song Overlay delivery";
SongOverlayHeartbeatBox.Value = Math.Clamp(settings.HeartbeatSeconds, 15, 300);
SongOverlaySeekBox.Value = Math.Clamp(settings.SeekThresholdMilliseconds, 500, 10000);
SongOverlayCoverToggle.IsChecked = settings.SendCoverArt;
SongOverlaySearchLinkToggle.IsChecked = settings.UseSearchLinkFallback;
if (!SongOverlaySpotifyClientIdBox.IsFocused) SongOverlaySpotifyClientIdBox.Text = settings.SpotifyClientId;
SongOverlaySpotifyStatus.Text = _songOverlay.IsSpotifyEnrichmentConnected
? "Connected. Exact links, release year and official artwork can be enriched on song changes."
: "Not connected. Core playback detection still works through Windows.";
DisconnectSongOverlaySpotifyButton.IsEnabled = _songOverlay.IsSpotifyEnrichmentConnected;
SendSongOverlayStateButton.IsEnabled = _songOverlay.IsInitialized && settings.Enabled && _songOverlay.UsesCompanionAuthentication;
}
finally { _renderingSongOverlay = false; }
}
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) });
}
var currentPass = stages.Count > 0 && stages.All(stage => stage.State == TestStageState.Passed);
var valid = currentPass || _runtime.State.PathTestValid;
TestStepSymbol.Text = valid ? "✓" : "○";
TestStepDetail.Text = currentPass ? "Passed just now; this result will be reused while the relevant setup stays unchanged." : _runtime.State.PathTestDetail;
RunTestButton.Content = _runtime.State.TestRunning ? "Testing…" : "Run full path test";
RunTestButton.IsEnabled = !_runtime.State.TestRunning && !_runtime.State.BenchmarkRunning;
}
private void RenderBenchmark(BenchmarkSnapshot benchmark)
{
BenchmarkTranscript.Text = string.IsNullOrWhiteSpace(benchmark.Transcript) ? "Finalized speech will appear here." : benchmark.Transcript;
RenderMetric(LatencyStatsPanel, benchmark.Latency, value => $"{value:0} ms");
RenderMetric(ConfidenceStatsPanel, benchmark.Confidence, value => $"{value:P0}");
}
private void RenderVoiceLevel(double level)
{
var clamped = Math.Clamp(level, -60, 0);
VoiceLevelMeter.Value = clamped;
VoiceLevelText.Text = $"{clamped:0} dBFS";
var color = clamped >= -30 && clamped <= -12 ? "#23845B" :
clamped >= -40 && clamped <= -2 ? "#E58B2B" : "#BD4D4D";
VoiceLevelMeter.Foreground = new SolidColorBrush(Color.Parse(color));
}
private static void RenderMetric(Panel panel, MetricStatistics metric, Func<double, string> formatter)
{
panel.Children.Clear();
var values = new (string Label, double? Value)[]
{
("Minimum", metric.Minimum), ("Low 1% avg", metric.LowOnePercentAverage),
("Median", metric.Median), ("Average", metric.Average), ("P99", metric.P99),
("High 1% avg", metric.HighOnePercentAverage), ("Maximum", metric.Maximum)
};
foreach (var item in values)
{
var content = new StackPanel { Spacing = 2 };
content.Children.Add(new TextBlock { Text = item.Label, FontSize = 11, Foreground = new SolidColorBrush(Color.Parse("#5A6872")) });
content.Children.Add(new TextBlock { Text = item.Value.HasValue ? formatter(item.Value.Value) : "—", FontWeight = FontWeight.SemiBold });
panel.Children.Add(new Border { Classes = { "soft" }, Child = content, MinWidth = 96, Margin = new Thickness(0, 0, 8, 8), Padding = new Thickness(12, 9) });
}
}
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, SongOverlayRuntime, IReadOnlyList<ICompanionPluginContribution>) CreateDefaultServices()
{
var paths = CompanionPaths.ForCurrentUser();
var settings = new CompanionSettingsStore(paths.SettingsPath);
var runtime = new CompanionRuntime(paths, settings);
var songOverlay = new SongOverlayRuntime(
Path.Combine(paths.Root, "plugins", "now_playing"),
paths.LogsDirectory,
runtime.CreatePluginTransport("now_playing"));
ICompanionPluginContribution[] plugins = [new TranscriptionPluginContribution(runtime), songOverlay];
return (runtime, settings, songOverlay, plugins);
}
}

View File

@ -0,0 +1,246 @@
using System.Diagnostics;
using System.ComponentModel;
using System.Reflection;
using System.Security.Cryptography;
using System.Security.Principal;
using System.Text.Json;
using System.Text.Json.Serialization;
using Lumi.Companion.Protocol;
namespace Lumi.Companion.App;
public sealed class ObsBridgeManager
{
private readonly string _installRoot = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.CommonApplicationData), "obs-studio", "plugins", "lumi-obs-bridge");
private string? _packageFailure;
private string InstalledDll => Path.Combine(_installRoot, "bin", "64bit", "lumi-obs-bridge.dll");
private string InstalledManifest => Path.Combine(_installRoot, "lumi-managed.json");
public ObsBridgeStatus Inspect()
{
var package = LoadPackage();
var installed = ReadManifest(InstalledManifest);
var packageAvailable = package is not null;
var fileInstalled = File.Exists(InstalledDll);
var valid = packageAvailable && fileInstalled && HashFile(InstalledDll) == package!.Manifest.Sha256 && installed?.Version == package.Manifest.Version;
return new ObsBridgeStatus(packageAvailable, fileInstalled, valid, package?.Manifest.Version,
valid ? $"OBS integration {package!.Manifest.Version} is installed. Restart OBS if it was open during the last repair." :
!packageAvailable ? $"The bundled OBS integration could not be verified. {_packageFailure ?? "The packaged component was not found."}" :
fileInstalled ? "The OBS integration is outdated or damaged. Repair it while OBS is closed." : "The OBS integration is ready to install.");
}
public async Task<ObsBridgeStatus> InstallOrRepairAsync(CancellationToken cancellationToken = default)
{
EnsureObsClosed();
var status = Inspect();
if (!status.PackageAvailable) throw new InvalidOperationException(status.Detail);
if (!IsElevated())
{
await RunElevatedAsync("install", cancellationToken);
var elevatedResult = Inspect();
if (!elevatedResult.Valid) throw new InvalidDataException("The OBS integration did not pass verification after installation.");
return elevatedResult;
}
return await InstallDirectAsync(cancellationToken);
}
private async Task<ObsBridgeStatus> InstallDirectAsync(CancellationToken cancellationToken)
{
var package = LoadPackage() ?? throw new InvalidDataException("The bundled OBS integration could not be verified.");
var manifest = package.Manifest;
var bin = Path.GetDirectoryName(InstalledDll)!;
var locale = Path.Combine(_installRoot, "data", "locale");
Directory.CreateDirectory(bin);
Directory.CreateDirectory(locale);
await WriteAtomicAsync(package.Dll, InstalledDll, cancellationToken);
if (package.Locale is not null) await WriteAtomicAsync(package.Locale, Path.Combine(locale, "en-US.ini"), cancellationToken);
var marker = JsonSerializer.SerializeToUtf8Bytes(manifest, ProtocolV1.JsonOptions);
var temporary = $"{InstalledManifest}.{Environment.ProcessId}.tmp";
await File.WriteAllBytesAsync(temporary, marker, cancellationToken);
File.Move(temporary, InstalledManifest, true);
var result = Inspect();
if (!result.Valid) throw new InvalidDataException("The OBS integration did not pass verification after installation.");
return result;
}
public Task RemoveAsync()
{
EnsureObsClosed();
return RemoveCoreAsync();
}
private async Task RemoveCoreAsync()
{
if (!IsElevated()) await RunElevatedAsync("remove", CancellationToken.None);
else if (Directory.Exists(_installRoot)) Directory.Delete(_installRoot, true);
}
public static bool IsMaintenanceRequest(string[] args) => args.Length is 2 or 3 && args[0].Equals("--manage-obs-bridge", StringComparison.OrdinalIgnoreCase);
public static bool IsDiagnosticRequest(string[] args) => args.Length == 2 && args[0].Equals("--diagnose-obs-bridge", StringComparison.OrdinalIgnoreCase);
public static int RunDiagnostic(string[] args)
{
if (!IsDiagnosticRequest(args)) return 10;
try
{
var status = new ObsBridgeManager().Inspect();
File.WriteAllBytes(args[1], JsonSerializer.SerializeToUtf8Bytes(status, ProtocolV1.JsonOptions));
return status.PackageAvailable ? 0 : 9;
}
catch (Exception error)
{
try { File.WriteAllText(args[1], JsonSerializer.Serialize(new { packageAvailable = false, detail = error.Message }, ProtocolV1.JsonOptions)); } catch { }
return 9;
}
}
public static int RunMaintenance(string[] args)
{
if (!IsMaintenanceRequest(args) || !IsElevated()) return 6;
try
{
var manager = new ObsBridgeManager();
EnsureObsClosed();
if (args[1].Equals("install", StringComparison.OrdinalIgnoreCase)) manager.InstallDirectAsync(CancellationToken.None).GetAwaiter().GetResult();
else if (args[1].Equals("remove", StringComparison.OrdinalIgnoreCase)) { if (Directory.Exists(manager._installRoot)) Directory.Delete(manager._installRoot, true); }
else return 7;
return 0;
}
catch (Exception error)
{
if (args.Length == 3) try { File.WriteAllText(args[2], error.Message); } catch { }
return 8;
}
}
private static bool IsElevated()
{
if (!OperatingSystem.IsWindows()) return false;
using var identity = WindowsIdentity.GetCurrent();
return new WindowsPrincipal(identity).IsInRole(WindowsBuiltInRole.Administrator);
}
private static async Task RunElevatedAsync(string action, CancellationToken cancellationToken)
{
if (string.IsNullOrWhiteSpace(Environment.ProcessPath)) throw new InvalidOperationException("The packaged Companion application is required to manage OBS integration.");
var resultPath = Path.Combine(Path.GetTempPath(), $"lumi-obs-maintenance-{Guid.NewGuid():N}.txt");
var start = new ProcessStartInfo(Environment.ProcessPath) { UseShellExecute = true, Verb = "runas", WindowStyle = ProcessWindowStyle.Hidden };
start.ArgumentList.Add("--manage-obs-bridge");
start.ArgumentList.Add(action);
start.ArgumentList.Add(resultPath);
try
{
using var process = Process.Start(start) ?? throw new InvalidOperationException("The OBS integration maintenance process could not start.");
await process.WaitForExitAsync(cancellationToken);
if (process.ExitCode != 0)
{
string? detail = null;
try { if (File.Exists(resultPath)) detail = File.ReadAllText(resultPath).Trim(); } catch { }
throw new InvalidOperationException(string.IsNullOrWhiteSpace(detail) ? "OBS integration maintenance did not complete." : detail);
}
}
catch (Win32Exception error) when (error.NativeErrorCode == 1223)
{
throw new InvalidOperationException("Administrator approval was cancelled. Lumi only requests it to manage the OBS plugin under ProgramData.");
}
finally { try { File.Delete(resultPath); } catch { } }
}
private static void EnsureObsClosed()
{
var processes = Process.GetProcessesByName("obs64");
foreach (var process in processes) process.Dispose();
if (processes.Length > 0)
throw new InvalidOperationException("Close OBS before installing, repairing, or removing the managed integration. Companion will never modify a loaded OBS plugin.");
}
private static ObsBridgeManifest? ReadManifest(string path) { try { return JsonSerializer.Deserialize<ObsBridgeManifest>(File.ReadAllBytes(path), ProtocolV1.JsonOptions); } catch { return null; } }
private ObsBridgePackage? LoadPackage()
{
var failures = new List<string>();
var roots = CandidatePackageRoots();
foreach (var root in roots)
{
try
{
var manifestBytes = File.ReadAllBytes(Path.Combine(root, "manifest.json"));
var dll = File.ReadAllBytes(Path.Combine(root, "lumi-obs-bridge.dll"));
var manifest = ParseManifest(manifestBytes);
var actualHash = HashBytes(dll);
if (!actualHash.Equals(manifest.Sha256, StringComparison.OrdinalIgnoreCase))
throw new InvalidDataException($"OBS bridge checksum mismatch (expected {manifest.Sha256}, got {actualHash}).");
_packageFailure = null;
return new ObsBridgePackage(manifest, dll, File.Exists(Path.Combine(root, "en-US.ini")) ? File.ReadAllBytes(Path.Combine(root, "en-US.ini")) : null);
}
catch (Exception error) { failures.Add($"{root}: {error.Message}"); }
}
try
{
var assembly = Assembly.GetExecutingAssembly();
var manifestBytes = ReadResource(assembly, "Lumi.Companion.ObsBridge.manifest.json");
var dll = ReadResource(assembly, "Lumi.Companion.ObsBridge.dll");
var manifest = ParseManifest(manifestBytes);
var actualHash = HashBytes(dll);
if (!actualHash.Equals(manifest.Sha256, StringComparison.OrdinalIgnoreCase))
throw new InvalidDataException($"Embedded OBS bridge checksum mismatch (expected {manifest.Sha256}, got {actualHash}).");
_packageFailure = null;
return new ObsBridgePackage(manifest, dll, TryReadResource(assembly, "Lumi.Companion.ObsBridge.en-US.ini"));
}
catch (Exception error)
{
failures.Add($"embedded payload: {error.Message}");
_packageFailure = string.Join(" ", failures);
return null;
}
}
private static IReadOnlyList<string> CandidatePackageRoots()
{
var bases = new List<string?>
{
Path.GetDirectoryName(Environment.ProcessPath),
AppContext.BaseDirectory,
Path.GetDirectoryName(Environment.GetCommandLineArgs().FirstOrDefault()),
Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), "Programs", "Lumi Companion")
};
try { bases.Add(Path.GetDirectoryName(Process.GetCurrentProcess().MainModule?.FileName)); } catch { }
return bases.Where(value => !string.IsNullOrWhiteSpace(value))
.Select(value => Path.Combine(value!, "components", "obs-bridge"))
.Distinct(StringComparer.OrdinalIgnoreCase).ToArray();
}
private static ObsBridgeManifest ParseManifest(byte[] value)
{
var start = value.Length >= 3 && value[0] == 0xEF && value[1] == 0xBB && value[2] == 0xBF ? 3 : 0;
using var document = JsonDocument.Parse(value.AsMemory(start));
var root = document.RootElement;
var version = root.GetProperty("version").GetString();
var sha256 = root.GetProperty("sha256").GetString();
var minimum = root.GetProperty("obs_minimum_version").GetString();
if (string.IsNullOrWhiteSpace(version) || string.IsNullOrWhiteSpace(minimum) || sha256 is null || sha256.Length != 64 || !sha256.All(Uri.IsHexDigit))
throw new InvalidDataException("The OBS bridge manifest is invalid.");
return new ObsBridgeManifest(version, sha256.ToLowerInvariant(), minimum);
}
private static byte[] ReadResource(Assembly assembly, string name) { using var stream = assembly.GetManifestResourceStream(name) ?? throw new FileNotFoundException($"Embedded resource {name} is missing."); using var body = new MemoryStream(); stream.CopyTo(body); return body.ToArray(); }
private static byte[]? TryReadResource(Assembly assembly, string name) { try { return ReadResource(assembly, name); } catch { return null; } }
private static string HashFile(string path) { using var stream = File.OpenRead(path); return Convert.ToHexString(SHA256.HashData(stream)).ToLowerInvariant(); }
private static string HashBytes(byte[] value) => Convert.ToHexString(SHA256.HashData(value)).ToLowerInvariant();
private static async Task WriteAtomicAsync(byte[] value, string target, CancellationToken cancellationToken)
{
var temporary = $"{target}.{Environment.ProcessId}.tmp";
try
{
await File.WriteAllBytesAsync(temporary, value, cancellationToken);
File.Move(temporary, target, true);
}
catch
{
try { File.Delete(temporary); } catch { }
throw;
}
}
}
public sealed record ObsBridgeManifest(
[property: JsonPropertyName("version")] string Version,
[property: JsonPropertyName("sha256")] string Sha256,
[property: JsonPropertyName("obs_minimum_version")] string ObsMinimumVersion);
public sealed record ObsBridgeStatus(bool PackageAvailable, bool Installed, bool Valid, string? Version, string Detail);
internal sealed record ObsBridgePackage(ObsBridgeManifest Manifest, byte[] Dll, byte[]? Locale);

View File

@ -0,0 +1,46 @@
using Avalonia;
namespace Lumi.Companion.App;
internal static class Program
{
internal static SingleInstanceCoordinator? InstanceCoordinator { get; private set; }
internal static bool LaunchInBackground { get; private set; }
[STAThread]
public static int Main(string[] args)
{
if (!OperatingSystem.IsWindows())
{
Console.Error.WriteLine("The first Lumi Companion release supports Windows x64 only.");
return 2;
}
if (UpdateApplier.IsApplyRequest(args)) return UpdateApplier.Apply(args);
if (ObsBridgeManager.IsDiagnosticRequest(args)) return ObsBridgeManager.RunDiagnostic(args);
if (ObsBridgeManager.IsMaintenanceRequest(args)) return ObsBridgeManager.RunMaintenance(args);
LaunchInBackground = args.Contains("--background", StringComparer.OrdinalIgnoreCase);
InstanceCoordinator = new SingleInstanceCoordinator();
if (!InstanceCoordinator.IsPrimary)
{
InstanceCoordinator.SignalPrimary();
InstanceCoordinator.Dispose();
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,51 @@
using Lumi.Companion.Abstractions;
namespace Lumi.Companion.App;
internal sealed class TranscriptionPluginContribution : ICompanionPluginContribution
{
private readonly CompanionRuntime _runtime;
public TranscriptionPluginContribution(CompanionRuntime runtime)
{
_runtime = runtime;
Actions =
[
new CompanionPluginAction(
"test",
() => _runtime.State.TestRunning || _runtime.State.BenchmarkRunning ? "Transcription test running…" : "Run transcription test",
cancellationToken => _runtime.RunTestAsync(cancellationToken),
() => !_runtime.State.TestRunning && !_runtime.State.BenchmarkRunning,
10)
];
_runtime.StateChanged += _ => Changed?.Invoke();
}
public CompanionPluginDescriptor Descriptor { get; } = new(
"transcription",
"Transcription",
new Version(0, 1, 0),
"Captures selected OBS sources and delivers closed captions through Lumi.",
100);
public IReadOnlyList<CompanionPluginPage> Pages { get; } =
[
new CompanionPluginPage("Transcription", "Capture", 10),
new CompanionPluginPage("Test", "Test & benchmark", 20)
];
public IReadOnlyList<CompanionPluginAction> Actions { get; }
public CompanionPluginStatus Status => new(
_runtime.State.Health switch
{
TrayHealth.Ready => CompanionPluginHealth.Healthy,
TrayHealth.Operating => CompanionPluginHealth.Healthy,
TrayHealth.Failed => CompanionPluginHealth.Error,
_ => CompanionPluginHealth.Warning
},
_runtime.State.Summary,
_runtime.State.Detail);
public event Action? Changed;
}

View File

@ -0,0 +1,63 @@
using System.Diagnostics;
namespace Lumi.Companion.App;
internal static class UpdateApplier
{
public static bool IsApplyRequest(string[] args) => args.Length > 0 && args[0].Equals("--apply-update", StringComparison.OrdinalIgnoreCase);
public static int Apply(string[] args)
{
if (args.Length != 7 || !int.TryParse(args[1], out var processId)) return 3;
var stageRoot = Path.GetFullPath(args[2]);
var staged = Path.GetFullPath(args[3]);
var target = Path.GetFullPath(args[4]);
var expectedHash = args[5];
var helper = Path.GetFullPath(args[6]);
try
{
try { Process.GetProcessById(processId).WaitForExit(30000); } catch (ArgumentException) { }
if (!File.Exists(staged) || !staged.StartsWith(stageRoot + Path.DirectorySeparatorChar, StringComparison.OrdinalIgnoreCase) ||
!UpdateService.HashFile(staged).Equals(expectedHash, StringComparison.OrdinalIgnoreCase)) return 4;
var targetRoot = Path.GetDirectoryName(target)!;
var stagedComponents = Path.Combine(stageRoot, "components");
if (Directory.Exists(stagedComponents)) ReplaceDirectory(stagedComponents, Path.Combine(targetRoot, "components"));
var stagedLegal = Path.Combine(stageRoot, "legal");
if (Directory.Exists(stagedLegal)) ReplaceDirectory(stagedLegal, Path.Combine(targetRoot, "legal"));
var stagedDevelopmentManifest = Path.Combine(stageRoot, ".lumi-dev-build.json");
var targetDevelopmentManifest = Path.Combine(targetRoot, ".lumi-dev-build.json");
if (File.Exists(stagedDevelopmentManifest)) File.Copy(stagedDevelopmentManifest, targetDevelopmentManifest, true);
else try { File.Delete(targetDevelopmentManifest); } catch { }
var temporary = $"{target}.{Environment.ProcessId}.update";
var backup = $"{target}.previous";
File.Copy(staged, temporary, true);
if (File.Exists(target)) File.Copy(target, backup, true);
File.Move(temporary, target, true);
Process.Start(new ProcessStartInfo(target) { UseShellExecute = true });
try { Directory.Delete(stageRoot, true); } catch { }
try { File.Delete(helper); } catch { }
return 0;
}
catch { return 5; }
}
private static void ReplaceDirectory(string source, string target)
{
var temporary = $"{target}.{Environment.ProcessId}.update";
var backup = $"{target}.previous";
if (Directory.Exists(temporary)) Directory.Delete(temporary, true);
CopyDirectory(source, temporary);
if (Directory.Exists(backup)) Directory.Delete(backup, true);
if (Directory.Exists(target)) Directory.Move(target, backup);
Directory.Move(temporary, target);
}
private static void CopyDirectory(string source, string target)
{
Directory.CreateDirectory(target);
foreach (var directory in Directory.EnumerateDirectories(source, "*", SearchOption.AllDirectories))
Directory.CreateDirectory(Path.Combine(target, Path.GetRelativePath(source, directory)));
foreach (var file in Directory.EnumerateFiles(source, "*", SearchOption.AllDirectories))
File.Copy(file, Path.Combine(target, Path.GetRelativePath(source, file)), true);
}
}

View File

@ -0,0 +1,181 @@
using System.Diagnostics;
using System.IO.Compression;
using System.Net.Http.Headers;
using System.Net.Http.Json;
using System.Security.Cryptography;
using System.Text.Json;
using System.Text.Json.Serialization;
using Lumi.Companion.Core;
using Lumi.Companion.Protocol;
namespace Lumi.Companion.App;
public sealed class UpdateService(HttpClient http, CompanionPaths paths)
{
public async Task<CompanionUpdate?> CheckAsync(DeviceCredential credential, string currentVersion, CancellationToken cancellationToken = default)
{
var host = new Uri(credential.Host);
var endpoint = new Uri(host, "/plugins/lumi_transcription/api/companion/update");
var localBuild = DevelopmentBuildManifest.Load();
var deadline = DateTimeOffset.UtcNow.AddMinutes(10);
while (true)
{
using var request = new HttpRequestMessage(HttpMethod.Post, endpoint);
request.Headers.Authorization = new AuthenticationHeaderValue("LumiDevice", $"{credential.DeviceId}.{credential.DeviceSecret}");
request.Content = JsonContent.Create(new CompanionUpdateCheck(
currentVersion,
localBuild?.BuildChecksum,
localBuild?.Components ?? new Dictionary<string, string>(),
true), options: ProtocolV1.JsonOptions);
using var response = await http.SendAsync(request, HttpCompletionOption.ResponseHeadersRead, cancellationToken);
if (response.StatusCode is System.Net.HttpStatusCode.NotFound or System.Net.HttpStatusCode.MethodNotAllowed)
return await CheckLegacyAsync(credential, currentVersion, host, cancellationToken);
response.EnsureSuccessStatusCode();
var update = await JsonSerializer.DeserializeAsync<CompanionUpdate>(
await response.Content.ReadAsStreamAsync(cancellationToken),
ProtocolV1.JsonOptions,
cancellationToken);
if (update is not { Ok: true, UpdateAvailable: true }) return null;
if (update.Artifact is not null) return update;
if (!update.Development || !update.BuildPending)
throw new InvalidDataException("Lumi announced a Companion update without a downloadable artifact.");
if (DateTimeOffset.UtcNow >= deadline)
throw new TimeoutException("Lumi did not finish preparing the local Companion update within 10 minutes.");
await Task.Delay(TimeSpan.FromMilliseconds(Math.Clamp(update.RetryAfterMilliseconds ?? 2000, 500, 10000)), cancellationToken);
}
}
private async Task<CompanionUpdate?> CheckLegacyAsync(DeviceCredential credential, string currentVersion, Uri host, CancellationToken cancellationToken)
{
var endpoint = new Uri(host, $"/plugins/lumi_transcription/api/companion/update?current_version={Uri.EscapeDataString(currentVersion)}");
using var request = new HttpRequestMessage(HttpMethod.Get, endpoint);
request.Headers.Authorization = new AuthenticationHeaderValue("LumiDevice", $"{credential.DeviceId}.{credential.DeviceSecret}");
using var response = await http.SendAsync(request, HttpCompletionOption.ResponseHeadersRead, cancellationToken);
response.EnsureSuccessStatusCode();
var legacy = await JsonSerializer.DeserializeAsync<LegacyCompanionUpdate>(await response.Content.ReadAsStreamAsync(cancellationToken), ProtocolV1.JsonOptions, cancellationToken);
if (legacy is not { Ok: true, UpdateAvailable: true }) return null;
return new CompanionUpdate(legacy.Ok, legacy.Version, legacy.UpdateAvailable, legacy.Artifact, legacy.Signed, legacy.ReleaseNotes,
false, null, null, null, false, null);
}
public async Task<StagedUpdate> StageAsync(CompanionUpdate update, DeviceCredential? credential = null, CancellationToken cancellationToken = default)
{
var artifact = update.Artifact ?? throw new InvalidDataException("The update manifest does not contain an artifact.");
if (artifact.Bytes is <= 0 or > 300 * 1024 * 1024 || !IsSha256(artifact.Sha256)) throw new InvalidDataException("The update manifest is invalid.");
var pairedHost = credential is null ? null : new Uri(credential.Host);
if (!Uri.TryCreate(artifact.Url, UriKind.Absolute, out var artifactUri) ||
!IsAllowedArtifactUri(artifactUri, update.Development, pairedHost))
throw new InvalidDataException("The update manifest does not use an allowed artifact URL.");
var safeVersion = SafeVersion(update.Version);
if (string.IsNullOrWhiteSpace(safeVersion)) throw new InvalidDataException("The update manifest has an invalid version.");
Directory.CreateDirectory(paths.UpdatesDirectory);
var buildSuffix = update.Development && IsSha256(update.BuildChecksum ?? string.Empty) ? $"-{update.BuildChecksum![..12]}" : string.Empty;
var archivePath = Path.Combine(paths.UpdatesDirectory, $"companion-{safeVersion}{buildSuffix}.zip.partial");
var stageRoot = Path.Combine(paths.UpdatesDirectory, $"{safeVersion}{buildSuffix}");
try
{
using var download = new HttpRequestMessage(HttpMethod.Get, artifactUri);
if (update.Development && credential is not null && CompanionTransportPolicy.IsSameOrigin(artifactUri, pairedHost!))
download.Headers.Authorization = new AuthenticationHeaderValue("LumiDevice", $"{credential.DeviceId}.{credential.DeviceSecret}");
using var response = await http.SendAsync(download, HttpCompletionOption.ResponseHeadersRead, cancellationToken);
response.EnsureSuccessStatusCode();
await using (var source = await response.Content.ReadAsStreamAsync(cancellationToken))
await using (var target = new FileStream(archivePath, FileMode.Create, FileAccess.Write, FileShare.None, 81920, true))
{
var buffer = new byte[81920];
long total = 0;
while (true)
{
var read = await source.ReadAsync(buffer, cancellationToken);
if (read == 0) break;
total = checked(total + read);
if (total > artifact.Bytes) throw new InvalidDataException("The Companion update download exceeded its declared size.");
await target.WriteAsync(buffer.AsMemory(0, read), cancellationToken);
}
}
if (new FileInfo(archivePath).Length != artifact.Bytes || !HashFile(archivePath).Equals(artifact.Sha256, StringComparison.OrdinalIgnoreCase))
throw new InvalidDataException("The downloaded Companion update did not match its trusted checksum.");
if (Directory.Exists(stageRoot)) Directory.Delete(stageRoot, true);
Directory.CreateDirectory(stageRoot);
using var archive = ZipFile.OpenRead(archivePath);
if (archive.Entries.Count is 0 or > 256) throw new InvalidDataException("The Companion update contains an invalid number of files.");
long extractedBytes = 0;
foreach (var entry in archive.Entries)
{
var relative = entry.FullName.Replace('\\', '/').TrimStart('/');
if (string.IsNullOrEmpty(relative) || relative.EndsWith('/')) continue;
if (relative.Split('/').Any(segment => segment is "" or "." or "..")) throw new InvalidDataException("The Companion update contains an unsafe path.");
extractedBytes = checked(extractedBytes + entry.Length);
if (extractedBytes > 500 * 1024 * 1024) throw new InvalidDataException("The Companion update expands beyond its safety limit.");
var destination = Path.GetFullPath(Path.Combine(stageRoot, relative.Replace('/', Path.DirectorySeparatorChar)));
if (!destination.StartsWith(stageRoot + Path.DirectorySeparatorChar, StringComparison.OrdinalIgnoreCase)) throw new InvalidDataException("The Companion update contains an unsafe path.");
Directory.CreateDirectory(Path.GetDirectoryName(destination)!);
entry.ExtractToFile(destination, true);
}
var staged = Path.GetFullPath(Path.Combine(stageRoot, artifact.Entrypoint.Replace('/', Path.DirectorySeparatorChar)));
if (!File.Exists(staged) || !staged.StartsWith(stageRoot + Path.DirectorySeparatorChar, StringComparison.OrdinalIgnoreCase))
throw new InvalidDataException("The Companion update is missing its application entrypoint.");
return new StagedUpdate(stageRoot, staged, HashFile(staged));
}
finally { try { File.Delete(archivePath); } catch { } }
}
public void LaunchApplier(StagedUpdate update)
{
if (!OperatingSystem.IsWindows() || string.IsNullOrWhiteSpace(Environment.ProcessPath)) throw new PlatformNotSupportedException("In-place updates require the packaged Windows application.");
var target = Path.GetFullPath(Environment.ProcessPath);
var helper = Path.Combine(paths.UpdatesDirectory, $"Lumi.Companion.UpdateHelper.{Environment.ProcessId}.exe");
File.Copy(target, helper, true);
var start = new ProcessStartInfo(helper) { UseShellExecute = false, CreateNoWindow = true };
foreach (var argument in new[] { "--apply-update", Environment.ProcessId.ToString(), update.StageRoot, update.ExecutablePath, target, update.Sha256, helper }) start.ArgumentList.Add(argument);
Process.Start(start)?.Dispose();
}
private static bool IsAllowedArtifactUri(Uri uri, bool development, Uri? pairedHost)
{
if (CompanionTransportPolicy.IsSecureOrigin(uri)) return true;
return development && CompanionTransportPolicy.IsEndpointAllowed(uri, pairedHost);
}
internal static string HashFile(string path) { using var stream = File.OpenRead(path); return Convert.ToHexString(SHA256.HashData(stream)).ToLowerInvariant(); }
private static bool IsSha256(string value) => value.Length == 64 && value.All(Uri.IsHexDigit);
private static string SafeVersion(string value) => string.Concat(value.Where(character => char.IsLetterOrDigit(character) || character is '.' or '-')).Trim('.');
}
public sealed record LegacyCompanionUpdate(
[property: JsonPropertyName("ok")] bool Ok,
[property: JsonPropertyName("version")] string Version,
[property: JsonPropertyName("update_available")] bool UpdateAvailable,
[property: JsonPropertyName("artifact")] CompanionUpdateArtifact Artifact,
[property: JsonPropertyName("signed")] bool Signed,
[property: JsonPropertyName("release_notes")] string ReleaseNotes);
public sealed record CompanionUpdateCheck(
[property: JsonPropertyName("current_version")] string CurrentVersion,
[property: JsonPropertyName("build_checksum")] string? BuildChecksum,
[property: JsonPropertyName("component_checksums")] IReadOnlyDictionary<string, string> ComponentChecksums,
[property: JsonPropertyName("supports_pending_build")] bool SupportsPendingBuild);
public sealed record CompanionUpdate(
[property: JsonPropertyName("ok")] bool Ok,
[property: JsonPropertyName("version")] string Version,
[property: JsonPropertyName("update_available")] bool UpdateAvailable,
[property: JsonPropertyName("artifact")] CompanionUpdateArtifact? Artifact,
[property: JsonPropertyName("signed")] bool Signed,
[property: JsonPropertyName("release_notes")] string ReleaseNotes,
[property: JsonPropertyName("development")] bool Development,
[property: JsonPropertyName("build_checksum")] string? BuildChecksum,
[property: JsonPropertyName("component_checksums")] IReadOnlyDictionary<string, string>? ComponentChecksums,
[property: JsonPropertyName("changed_components")] IReadOnlyList<CompanionUpdateComponent>? ChangedComponents,
[property: JsonPropertyName("build_pending")] bool BuildPending,
[property: JsonPropertyName("retry_after_ms")] int? RetryAfterMilliseconds);
public sealed record CompanionUpdateComponent(
[property: JsonPropertyName("id")] string Id,
[property: JsonPropertyName("kind")] string Kind,
[property: JsonPropertyName("checksum")] string Checksum);
public sealed record CompanionUpdateArtifact(
[property: JsonPropertyName("url")] string Url,
[property: JsonPropertyName("sha256")] string Sha256,
[property: JsonPropertyName("bytes")] long Bytes,
[property: JsonPropertyName("entrypoint")] string Entrypoint);
public sealed record StagedUpdate(string StageRoot, string ExecutablePath, string Sha256);

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

@ -0,0 +1,76 @@
using System.Net.Http.Headers;
using System.Net.Http.Json;
using Lumi.Companion.Abstractions;
using Lumi.Companion.Protocol;
namespace Lumi.Companion.Core;
public sealed class CompanionPluginTransport(
HttpClient http,
Func<DeviceCredential?> credentialProvider,
string pluginId) : ICompanionPluginTransport
{
private readonly string _pluginId = ValidatePluginId(pluginId);
public Uri? LumiBaseUri
{
get
{
try
{
var credential = credentialProvider();
return credential is null ? null : new Uri(credential.Host);
}
catch
{
return null;
}
}
}
public bool IsAuthenticated => LumiBaseUri is not null;
public async Task<CompanionPluginHttpResponse> PostJsonAsync(
string relativePath,
object payload,
CancellationToken cancellationToken = default)
{
var credential = credentialProvider()
?? throw new InvalidOperationException("Pair Companion with Lumi before using this plugin.");
var host = new Uri(credential.Host);
if (!CompanionTransportPolicy.IsPairedHostAllowed(host))
throw new InvalidDataException("Companion transport requires HTTPS unless this device was paired with localhost.");
var expectedPrefix = $"/plugins/{_pluginId}/";
if (!relativePath.StartsWith(expectedPrefix, StringComparison.Ordinal) ||
!Uri.TryCreate(relativePath, UriKind.Relative, out var relative))
throw new InvalidDataException($"Plugin '{_pluginId}' attempted to use an endpoint outside its Lumi plugin scope.");
var endpoint = new Uri(host, relative);
if (!CompanionTransportPolicy.IsEndpointAllowed(endpoint, host))
throw new InvalidDataException("The plugin endpoint does not match the paired Lumi host.");
using var request = new HttpRequestMessage(HttpMethod.Post, endpoint)
{
Content = JsonContent.Create(payload, options: ProtocolV1.JsonOptions)
};
request.Headers.Authorization = new AuthenticationHeaderValue(
"LumiDevice",
$"{credential.DeviceId}.{credential.DeviceSecret}");
request.Headers.UserAgent.ParseAdd("Lumi-Companion/1.0");
request.Headers.Add("X-Lumi-Companion-Plugin", _pluginId);
using var response = await http.SendAsync(request, cancellationToken).ConfigureAwait(false);
var body = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false);
return new CompanionPluginHttpResponse((int)response.StatusCode, body);
}
private static string ValidatePluginId(string value)
{
var clean = (value ?? string.Empty).Trim();
if (clean.Length is < 1 or > 100 ||
clean.Any(character => !char.IsAsciiLetterOrDigit(character) && character is not '_' and not '-' and not '.'))
throw new ArgumentException("A valid Companion plugin identifier is required.", nameof(value));
return clean;
}
}

View File

@ -0,0 +1,171 @@
using System.Buffers.Binary;
using System.Net.WebSockets;
using System.Threading.Channels;
using Lumi.Companion.Protocol;
namespace Lumi.Companion.Core;
public sealed class CompanionSocket : IAsyncDisposable
{
private const int NetworkAudioPayloadBytes = 640; // 20 ms of 16 kHz mono PCM16.
private readonly Channel<ReadOnlyMemory<byte>> _audio = Channel.CreateBounded<ReadOnlyMemory<byte>>(new BoundedChannelOptions(250) { FullMode = BoundedChannelFullMode.Wait, SingleReader = true });
private readonly object _bridgeAudioGate = new();
private readonly byte[] _bridgeAudioBuffer = new byte[ProtocolV1.MaxAudioPayloadBytes + NetworkAudioPayloadBytes];
private readonly byte[] _bridgeAudioHeader = new byte[ProtocolV1.AudioHeaderBytes];
private int _bridgeAudioBuffered;
private uint _bridgeAudioSequence;
private ClientWebSocket? _socket;
private CancellationTokenSource? _lifetime;
private readonly SemaphoreSlim _sendLock = new(1, 1);
private readonly List<Task> _backgroundTasks = [];
private long _droppedFrames;
private int _disconnectNotified;
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)
{
_disconnectNotified = 0;
_socket = new ClientWebSocket();
_socket.Options.SetRequestHeader("Authorization", $"LumiDevice {credential.DeviceId}.{credential.DeviceSecret}");
var host = new Uri(credential.Host);
if (!CompanionTransportPolicy.IsPairedHostAllowed(host))
throw new InvalidDataException("Companion transport requires HTTPS unless this credential was issued for localhost.");
var uri = new UriBuilder(host) { Scheme = host.Scheme == "https" ? "wss" : "ws", Path = "/plugins/lumi_transcription/live" }.Uri;
await _socket.ConnectAsync(uri, cancellationToken);
var hello = ProtocolV1.EncodeEnvelope("hello", new { companion_version = companionVersion, plugin_version = pluginVersion, obs_version = obsVersion, capabilities = credential.Capabilities, audio = new { codec = "pcm_s16le", sample_rate = 16000, channels = 1, bits = 16 } });
await _socket.SendAsync(hello, WebSocketMessageType.Text, true, cancellationToken);
var acknowledgement = await ReceiveMessageAsync(_socket, cancellationToken);
if (acknowledgement.Type != "hello_ack" || acknowledgement.Version != 1 || acknowledgement.SessionId is null)
throw new InvalidDataException("Lumi returned an incompatible companion handshake.");
SessionId = acknowledgement.SessionId.Value;
_lifetime = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
_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)
{
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)
{
var input = encoded.Span;
if (input.Length < ProtocolV1.AudioHeaderBytes || input.Length > ProtocolV1.AudioHeaderBytes + ProtocolV1.MaxAudioPayloadBytes ||
!input[..4].SequenceEqual("LACP"u8) || BinaryPrimitives.ReadUInt16LittleEndian(input[6..8]) != ProtocolV1.AudioHeaderBytes) return false;
var payloadBytes = (int)BinaryPrimitives.ReadUInt32LittleEndian(input[60..64]);
if (payloadBytes < 0 || payloadBytes % 2 != 0 || input.Length != ProtocolV1.AudioHeaderBytes + payloadBytes) return false;
lock (_bridgeAudioGate)
{
var sourceChanged = !_bridgeAudioHeader.AsSpan(36, 16).SequenceEqual(input[36..52]);
var stateChanged = _bridgeAudioHeader[5] != input[5];
if (_bridgeAudioBuffered > 0 && (sourceChanged || stateChanged)) _bridgeAudioBuffered = 0;
input[..ProtocolV1.AudioHeaderBytes].CopyTo(_bridgeAudioHeader);
input[ProtocolV1.AudioHeaderBytes..].CopyTo(_bridgeAudioBuffer.AsSpan(_bridgeAudioBuffered));
_bridgeAudioBuffered += payloadBytes;
var accepted = true;
while (_bridgeAudioBuffered >= NetworkAudioPayloadBytes)
{
var normalized = new byte[ProtocolV1.AudioHeaderBytes + NetworkAudioPayloadBytes];
_bridgeAudioHeader.CopyTo(normalized, 0);
BinaryPrimitives.WriteUInt32LittleEndian(normalized.AsSpan(8, 4), _bridgeAudioSequence++);
BinaryPrimitives.WriteUInt64LittleEndian(normalized.AsSpan(12, 8), (ulong)DateTimeOffset.UtcNow.ToUnixTimeMilliseconds() * 1000UL);
SessionId.TryWriteBytes(normalized.AsSpan(20, 16), bigEndian: true, out _);
BinaryPrimitives.WriteUInt32LittleEndian(normalized.AsSpan(60, 4), NetworkAudioPayloadBytes);
_bridgeAudioBuffer.AsSpan(0, NetworkAudioPayloadBytes).CopyTo(normalized.AsSpan(ProtocolV1.AudioHeaderBytes));
_bridgeAudioBuffered -= NetworkAudioPayloadBytes;
if (_bridgeAudioBuffered > 0)
_bridgeAudioBuffer.AsSpan(NetworkAudioPayloadBytes, _bridgeAudioBuffered).CopyTo(_bridgeAudioBuffer);
accepted &= QueueEncodedAudio(normalized);
}
return accepted;
}
}
public async Task SendAsync(string type, object payload, Guid? sessionId, CancellationToken cancellationToken)
{
if (_socket?.State != WebSocketState.Open) throw new InvalidOperationException("Lumi is not connected.");
await SendLockedAsync(ProtocolV1.EncodeEnvelope(type, payload, sessionId), WebSocketMessageType.Text, cancellationToken);
}
private async Task SendAudioAsync(CancellationToken cancellationToken)
{
await foreach (var frame in _audio.Reader.ReadAllAsync(cancellationToken))
{
if (_socket?.State != WebSocketState.Open) continue;
await SendLockedAsync(frame, WebSocketMessageType.Binary, cancellationToken);
}
}
private async Task SendLockedAsync(ReadOnlyMemory<byte> body, WebSocketMessageType type, CancellationToken cancellationToken)
{
await _sendLock.WaitAsync(cancellationToken);
try { if (_socket?.State == WebSocketState.Open) await _socket.SendAsync(body, type, true, cancellationToken); }
finally { _sendLock.Release(); }
}
private async Task ReceiveAsync(CancellationToken cancellationToken)
{
while (_socket?.State == WebSocketState.Open && !cancellationToken.IsCancellationRequested)
{
var message = await ReceiveMessageAsync(_socket, cancellationToken);
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();
if (Interlocked.Exchange(ref _disconnectNotified, 1) == 0) Disconnected?.Invoke(error);
}
}
private static async Task<ServerEnvelope> ReceiveMessageAsync(ClientWebSocket socket, CancellationToken cancellationToken)
{
using var body = new MemoryStream();
var buffer = new byte[8192];
WebSocketReceiveResult result;
do
{
result = await socket.ReceiveAsync(new ArraySegment<byte>(buffer), cancellationToken);
if (result.MessageType == WebSocketMessageType.Close)
{
if (socket.State == WebSocketState.CloseReceived)
try { await socket.CloseOutputAsync(WebSocketCloseStatus.NormalClosure, "close_ack", CancellationToken.None); } catch (WebSocketException) { }
throw new EndOfStreamException($"Lumi closed the companion connection ({result.CloseStatus?.ToString() ?? "no status"}: {result.CloseStatusDescription ?? "no reason"}).");
}
body.Write(buffer, 0, result.Count);
if (body.Length > ProtocolV1.MaxJsonBytes) throw new InvalidDataException("Lumi message exceeds the protocol limit.");
} while (!result.EndOfMessage);
if (result.MessageType != WebSocketMessageType.Text) throw new InvalidDataException("Unexpected binary message from Lumi.");
return System.Text.Json.JsonSerializer.Deserialize<ServerEnvelope>(body.ToArray(), ProtocolV1.JsonOptions)
?? throw new InvalidDataException("Lumi message is invalid.");
}
public async ValueTask DisposeAsync()
{
_lifetime?.Cancel();
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

@ -0,0 +1,37 @@
namespace Lumi.Companion.Core;
/// <summary>
/// Canonical transport boundary for every Companion feature that sends
/// credentials or private stream data to Lumi.
/// </summary>
public static class CompanionTransportPolicy
{
public static bool IsSecureOrigin(Uri uri) =>
uri.Scheme.Equals(Uri.UriSchemeHttps, StringComparison.OrdinalIgnoreCase);
public static bool IsLoopbackHttpOrigin(Uri uri) =>
uri.Scheme.Equals(Uri.UriSchemeHttp, StringComparison.OrdinalIgnoreCase) &&
uri.IsLoopback;
public static bool IsSameOrigin(Uri left, Uri right) =>
left.Scheme.Equals(right.Scheme, StringComparison.OrdinalIgnoreCase) &&
left.Host.Equals(right.Host, StringComparison.OrdinalIgnoreCase) &&
left.Port == right.Port;
public static bool IsPairingExchangeAllowed(Uri exchangeUri, Uri hostUri) =>
IsSameOrigin(exchangeUri, hostUri) &&
((IsSecureOrigin(exchangeUri) && IsSecureOrigin(hostUri)) ||
(IsLoopbackHttpOrigin(exchangeUri) && IsLoopbackHttpOrigin(hostUri)));
public static bool IsPairedHostAllowed(Uri hostUri) =>
IsSecureOrigin(hostUri) || IsLoopbackHttpOrigin(hostUri);
public static bool IsEndpointAllowed(Uri endpoint, Uri? pairedHost)
{
if (IsSecureOrigin(endpoint)) return true;
return pairedHost is not null &&
IsLoopbackHttpOrigin(endpoint) &&
IsLoopbackHttpOrigin(pairedHost) &&
IsSameOrigin(endpoint, pairedHost);
}
}

View File

@ -0,0 +1 @@
<Project Sdk="Microsoft.NET.Sdk"><PropertyGroup><TargetFramework>net8.0-windows</TargetFramework><Nullable>enable</Nullable><ImplicitUsings>enable</ImplicitUsings></PropertyGroup><ItemGroup><ProjectReference Include="../Lumi.Companion.Abstractions/Lumi.Companion.Abstractions.csproj" /><ProjectReference Include="../Lumi.Companion.Protocol/Lumi.Companion.Protocol.csproj" /><PackageReference Include="System.Security.Cryptography.ProtectedData" Version="8.0.0" /></ItemGroup></Project>

View File

@ -0,0 +1,30 @@
using System.Net.Http.Json;
using System.Text.Json;
using Lumi.Companion.Protocol;
namespace Lumi.Companion.Core;
public sealed class PairingClient(HttpClient http)
{
public async Task<DeviceCredential> PairAsync(string packagePath, object device, CancellationToken cancellationToken)
{
var bootstrap = JsonSerializer.Deserialize<PairingBootstrap>(await File.ReadAllBytesAsync(packagePath, cancellationToken), ProtocolV1.JsonOptions)
?? throw new InvalidDataException("Pairing package is invalid.");
if (bootstrap.Format != "lumi-companion-bootstrap-v1" || bootstrap.ProtocolVersion != 1)
throw new InvalidDataException("Pairing package is incompatible with this companion.");
if (DateTimeOffset.UtcNow.ToUnixTimeMilliseconds() >= bootstrap.ExpiresAt)
throw new InvalidDataException("Pairing package has expired. Download a new package from Lumi.");
var exchangeUri = new Uri(bootstrap.ExchangeUrl);
var hostUri = new Uri(bootstrap.Host);
if (!CompanionTransportPolicy.IsPairingExchangeAllowed(exchangeUri, hostUri))
throw new InvalidDataException("Pairing requires HTTPS unless the package explicitly uses one matching localhost origin.");
using var response = await http.PostAsJsonAsync(bootstrap.ExchangeUrl, new { token = bootstrap.Token, device }, ProtocolV1.JsonOptions, cancellationToken);
var body = await response.Content.ReadAsStringAsync(cancellationToken);
if (!response.IsSuccessStatusCode) throw new InvalidOperationException($"Pairing failed: {body}");
using var json = JsonDocument.Parse(body);
return new DeviceCredential(
json.RootElement.GetProperty("device_id").GetString()!, json.RootElement.GetProperty("device_secret").GetString()!,
json.RootElement.GetProperty("host").GetString()!, json.RootElement.GetProperty("capabilities").EnumerateArray().Select(item => item.GetString()!).ToArray(),
json.RootElement.GetProperty("protocol_version").GetInt32());
}
}

View File

@ -0,0 +1,33 @@
using System.Security.Cryptography;
using System.Text.Json;
using Lumi.Companion.Protocol;
namespace Lumi.Companion.Core;
public sealed class SecureCredentialStore(string root)
{
private readonly string _path = Path.Combine(root, "device.credential");
private readonly string _installIdPath = Path.Combine(root, "install.id");
public void Save(DeviceCredential credential)
{
Directory.CreateDirectory(Path.GetDirectoryName(_path)!);
var clear = JsonSerializer.SerializeToUtf8Bytes(credential, ProtocolV1.JsonOptions);
var protectedBytes = ProtectedData.Protect(clear, "Lumi.Companion.Device.v1"u8.ToArray(), DataProtectionScope.CurrentUser);
var temporary = $"{_path}.{Environment.ProcessId}.tmp";
try { File.WriteAllBytes(temporary, protectedBytes); File.Move(temporary, _path, true); }
finally { File.Delete(temporary); }
}
public DeviceCredential? Load()
{
if (!File.Exists(_path)) return null;
var clear = ProtectedData.Unprotect(File.ReadAllBytes(_path), "Lumi.Companion.Device.v1"u8.ToArray(), DataProtectionScope.CurrentUser);
return JsonSerializer.Deserialize<DeviceCredential>(clear, ProtocolV1.JsonOptions);
}
public void Remove() => File.Delete(_path);
public string GetOrCreateInstallId()
{
Directory.CreateDirectory(Path.GetDirectoryName(_installIdPath)!);
if (File.Exists(_installIdPath) && Guid.TryParse(File.ReadAllText(_installIdPath).Trim(), out var existing)) return existing.ToString();
var created = Guid.NewGuid().ToString(); File.WriteAllText(_installIdPath, created); return created;
}
}

View File

@ -0,0 +1 @@
<Project Sdk="Microsoft.NET.Sdk"><PropertyGroup><TargetFramework>net8.0-windows</TargetFramework><Nullable>enable</Nullable><ImplicitUsings>enable</ImplicitUsings></PropertyGroup></Project>

View File

@ -0,0 +1,23 @@
using System.Diagnostics;
namespace Lumi.Companion.PluginHost;
public sealed class PluginWorker(string executable, string arguments) : IAsyncDisposable
{
private Process? _process;
public void Start()
{
if (_process is { HasExited: false }) return;
_process = Process.Start(new ProcessStartInfo(executable, arguments) { UseShellExecute = false, CreateNoWindow = true, RedirectStandardError = true, RedirectStandardOutput = true })
?? throw new InvalidOperationException("Companion plugin worker could not start.");
}
public bool Healthy => _process is { HasExited: false };
public async ValueTask DisposeAsync()
{
if (_process is not { HasExited: false }) return;
_process.CloseMainWindow();
using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(2));
try { await _process.WaitForExitAsync(timeout.Token); } catch (OperationCanceledException) { _process.Kill(entireProcessTree: true); }
_process.Dispose();
}
}

View File

@ -0,0 +1 @@
<Project Sdk="Microsoft.NET.Sdk"><PropertyGroup><TargetFramework>net8.0</TargetFramework><Nullable>enable</Nullable><ImplicitUsings>enable</ImplicitUsings></PropertyGroup></Project>

View File

@ -0,0 +1,66 @@
using System.Buffers.Binary;
using System.Text.Json;
using System.Text.Json.Serialization;
namespace Lumi.Companion.Protocol;
public static class ProtocolV1
{
public const int Version = 1;
public const int AudioHeaderBytes = 64;
public const int MaxAudioPayloadBytes = 6400;
public const int MaxJsonBytes = 64 * 1024;
public static byte[] EncodeAudio(AudioFrame frame)
{
if (frame.Pcm.Length > MaxAudioPayloadBytes || frame.Pcm.Length % 2 != 0)
throw new ArgumentOutOfRangeException(nameof(frame), "PCM payload must be even and at most 6,400 bytes.");
var output = new byte[AudioHeaderBytes + frame.Pcm.Length];
"LACP"u8.CopyTo(output);
output[4] = Version;
output[5] = (byte)((frame.Active ? 1 : 0) | (frame.Muted ? 2 : 0));
BinaryPrimitives.WriteUInt16LittleEndian(output.AsSpan(6), AudioHeaderBytes);
BinaryPrimitives.WriteUInt32LittleEndian(output.AsSpan(8), frame.Sequence);
BinaryPrimitives.WriteUInt64LittleEndian(output.AsSpan(12), frame.CaptureTimestampUs);
frame.SessionId.TryWriteBytes(output.AsSpan(20), bigEndian: true, out _);
frame.SourceUuid.TryWriteBytes(output.AsSpan(36), bigEndian: true, out _);
BinaryPrimitives.WriteUInt32LittleEndian(output.AsSpan(52), 16000);
BinaryPrimitives.WriteUInt16LittleEndian(output.AsSpan(56), 1);
BinaryPrimitives.WriteUInt16LittleEndian(output.AsSpan(58), 16);
BinaryPrimitives.WriteUInt32LittleEndian(output.AsSpan(60), (uint)frame.Pcm.Length);
frame.Pcm.Span.CopyTo(output.AsSpan(AudioHeaderBytes));
return output;
}
public static byte[] EncodeEnvelope(string type, object payload, Guid? sessionId = null) =>
JsonSerializer.SerializeToUtf8Bytes(new Envelope(Version, type, Guid.NewGuid(), DateTimeOffset.UtcNow, sessionId, payload), JsonOptions);
public static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web)
{
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull
};
}
public sealed record AudioFrame(Guid SessionId, Guid SourceUuid, uint Sequence, ulong CaptureTimestampUs, bool Active, bool Muted, ReadOnlyMemory<byte> Pcm);
public sealed record Envelope(
[property: JsonPropertyName("version")] int Version,
[property: JsonPropertyName("type")] string Type,
[property: JsonPropertyName("id")] Guid Id,
[property: JsonPropertyName("sent_at")] DateTimeOffset SentAt,
[property: JsonPropertyName("session_id")] Guid? SessionId,
[property: JsonPropertyName("payload")] object Payload);
public sealed record ServerEnvelope(
[property: JsonPropertyName("version")] int Version,
[property: JsonPropertyName("type")] string Type,
[property: JsonPropertyName("id")] Guid Id,
[property: JsonPropertyName("sent_at")] DateTimeOffset SentAt,
[property: JsonPropertyName("session_id")] Guid? SessionId,
[property: JsonPropertyName("payload")] JsonElement Payload);
public sealed record PairingBootstrap(
[property: JsonPropertyName("format")] string Format,
[property: JsonPropertyName("token")] string Token,
[property: JsonPropertyName("host")] string Host,
[property: JsonPropertyName("exchange_url")] string ExchangeUrl,
[property: JsonPropertyName("expires_at")] long ExpiresAt,
[property: JsonPropertyName("protocol_version")] int ProtocolVersion);
public sealed record DeviceCredential(string DeviceId, string DeviceSecret, string Host, string[] Capabilities, int ProtocolVersion);

View File

@ -0,0 +1,13 @@
# ADR 0001: Companion transcription boundaries
Status: accepted for the first stable Companion release.
The MVP uses server-hosted whisper.cpp behind `TranscriptionProvider` and returns captions through `CaptionDeliveryAdapter`. The first concrete boundaries are `WhisperCppServerProvider` and companion-managed OBS native delivery. Session and UI code depend on those interfaces rather than engine, location, or platform details.
Deferred provider implementations are local companion inference and remote providers such as Qwen ASR. Qwen will be a separate provider; it will not impersonate whisper.cpp. Deferred delivery implementations include other streaming platforms and optional open-caption rendering. Open captions are never an implicit fallback for failed Twitch closed captions.
OBS source identity is UUID-based. Multiple tracks, independent internal caption events, primary-track overlap policy, and delivery-enabled fields exist in the server model even though the first usable configuration targets one microphone. Protocol codec negotiation exists even though v1 accepts only PCM.
The companion plugin host is process-oriented so one future integration can fail independently. Official package signature enforcement and Dev Mode unsigned-package handling are deferred with the installer/plugin packaging work. Offline authorization retains the low/normal/severe setting boundary; server inference still stops when Lumi is unreachable.
Core owns only a reusable WebSocket upgrade registration mechanism. Removing or disabling `lumi_transcription` must not leave transcription routes, timers, sockets, workers, or global capabilities active.

View File

@ -29,9 +29,10 @@ new branch.
## Test an experimental branch ## Test an experimental branch
First update production on `main` to stable `0.2.25` through the existing First update production on `main` to the latest stable release through
**Update from repository** action. This installs the exact-branch controls on **Update from repository**. Stable `0.2.26` and newer synchronize core and
stable, so future switches in either direction use the same verified workflow. bundled plugins in one snapshot-backed transaction, so future switches in either
direction use the same verified workflow.
If a `0.2.24` host enters Experimental before taking the stable update, the If a `0.2.24` host enters Experimental before taking the stable update, the
guarded compatibility bootstrap completes bundled-plugin synchronization after guarded compatibility bootstrap completes bundled-plugin synchronization after
the older updater installs the newest `experimental-*` branch; expect a second the older updater installs the newest `experimental-*` branch; expect a second

View File

@ -0,0 +1,107 @@
# Localhost development updates
Lumi exposes one reusable runtime environment object:
```js
global.lumiRuntime
global.lumiEnvironment
global.lumiFrameworks.runtime
global.lumiFrameworks.environment
```
Useful fields include:
```js
{
mode: "development" | "production",
isDevelopment: boolean,
isProduction: boolean,
devMode: boolean,
reason: string,
allowsLocalDevelopmentUpdates(req): boolean
}
```
Runtime detection uses explicit `LUMI_DEV_MODE` first, followed by `NODE_ENV`, an explicitly loopback-bound `LUMI_HOST`, and whether Lumi is running from a source checkout. Production operators can always force production mode with `LUMI_DEV_MODE=false` or `NODE_ENV=production`.
The development update endpoint has a stricter boundary than the global mode flag. It is active only when all of the following are true:
1. Lumi is in development mode.
2. The request hostname is `localhost`, `127.0.0.1`, or `::1`.
3. The request originates from a loopback address.
4. The Companion device was paired from that exact localhost origin.
A source checkout exposed on a LAN therefore does not expose development artifacts to LAN clients.
## Checksum flow
The update service hashes source contents directly and never calls Git. Generated and mutable directories such as `bin`, `obj`, `node_modules`, `data`, logs, tests, documentation folders, build output, and update caches are excluded. README and changelog edits do not trigger binary updates.
Checksums are tracked independently for:
- Companion core;
- every directory under `companion/plugins`;
- Lumi server plugins related to Companion functionality.
Related server plugins are discovered through a matching Companion `plugin.json` ID or a server-side `companion_manifest.json`. This currently links Song Overlay and Lumi Transcription to their Companion components.
The aggregate build checksum combines the individual tracked component checksums. The installed Companion stores the checksums from its last localhost development update in `.lumi-dev-build.json` beside the executable.
When Companion checks for updates it sends only:
- its normal version;
- the aggregate development checksum;
- the per-component checksums.
Lumi responds with the changed component IDs. No source code or file inventory crosses the connection.
## Same-version update flow
When checksums differ on localhost, Lumi:
1. builds a self-contained Windows x64 Companion package from the current local source;
2. adds `.lumi-dev-build.json` to that package;
3. computes the package SHA-256 and exact size;
4. exposes the package through an authenticated localhost-only route;
5. returns a normal Companion update response even when the semantic version is unchanged.
Companion then uses its existing confirmation, download, checksum verification, staging, OBS-idle safety check, replacement, and restart flow. The build checksum is appended to the local staging directory so repeated same-version builds do not collide.
After restart, the installed checksum manifest prevents the same source state from being offered again.
## Requirements
The machine running Lumi must have a usable Windows .NET 8 SDK when a localhost development package needs to be built. The build runs lazily only after Companion asks for updates and its checksums differ.
The build cache is stored under:
```text
data/development-updates/companion/
```
Only the five newest checksum-addressed builds are retained by default.
## Configuration
Example local-only development setup:
```dotenv
LUMI_DEV_MODE=true
LUMI_HOST=127.0.0.1
```
`LUMI_HOST` is optional, but explicitly binding to `127.0.0.1` is recommended for a development instance that should never be reachable from the LAN.
Run the focused verification with:
```bash
npm run verify:dev-updates
```
## First adoption
An already installed Companion that predates this feature still uses the legacy
GET-only update check and cannot apply a localhost HTTP development artifact.
Build or install the patched Companion once through the normal development
workflow. After that first adoption, later same-version source changes use the
checksum update path automatically.

View File

@ -0,0 +1,88 @@
# Lumi Companion transcription
`lumi_transcription` is a separately disableable Lumi plugin. It owns paired
devices, session state, model/runtime artifacts, worker supervision, caption
stabilization, delivery, diagnostics, tests, and WebUI routes. Core supplies the
shared Companion authentication and versioned WebSocket boundary.
## Runtime path
```text
selected OBS source -> same-user Companion IPC -> bounded audio queue
-> authenticated TLS WebSocket -> bounded Lumi session
-> supervised server-hosted whisper.cpp worker -> stabilized caption revisions
-> Companion -> OBS native caption boundary
```
Speech recognition never runs on the streaming computer. Raw audio is kept in
bounded memory and is not logged or written to disk. Lumi prefers the published
CUDA worker on an NVIDIA RTX 3060 or newer and retains the CPU worker as a
portable fallback.
## Install and pair
1. Enable **Lumi Transcription** under **Admin > Plugins**.
2. Use **Download Companion** from the Admin dashboard or Transcription page.
3. Extract the private ZIP and run `Lumi.Companion-Setup.exe` within 15 minutes.
4. Complete the per-user setup and install or repair the managed OBS integration
while OBS is closed.
5. Select the OBS microphone in Companion.
6. Run the short voice-free full-path check. A successful result remains valid
until a relevant source, integration, model, or host setting changes.
7. Use the dedicated transcription test when measuring real speech accuracy,
confidence, latency, and input level.
Production device HTTP and WebSocket traffic requires HTTPS/WSS. Plain
HTTP/WebSocket is allowed only for a device paired from the exact matching
loopback Lumi origin and only while both sides remain on loopback.
## Models and workers
The Admin page installs checksum-pinned model and worker artifacts only after
explicit confirmation. `small.en` is the recommended model;
`small.en-q5_1` and `base.en` remain available as lower-resource alternatives.
The worker is supervised, restarts are bounded, and an explicit
`LUMI_TRANSCRIPTION_WORKER` override remains available for nonstandard
installations.
The native worker source is under
`plugins/lumi_transcription/backend/transcription/worker-native`. Release
workers are built with `plugins/lumi_transcription/scripts/build-worker.ps1`.
## Operation and recovery
- Live sessions require an active OBS stream. Tests use simulated delivery and
never publish captions to Twitch.
- Disconnects pause delivery and retain the session/model for a bounded grace
period so a short reconnect can resume safely.
- Manual benchmark completion waits for the final confidence-bearing caption;
silence ends a test after ten seconds.
- Device revocation takes effect on the next authenticated request or
connection.
- Disabling the plugin unregisters routes, closes clients and sessions, stops
the worker, and leaves unrelated plugins running.
The Admin summary reports Companion, device, inference, and session health.
Detailed benchmark records remain inspectable for one hour and include
word/phrase latency and confidence views.
## Verification
```powershell
npm run verify:transcription
dotnet build companion/Lumi.Companion.sln -c Release -p:EnableWindowsTargeting=true
```
Target-machine acceptance can be recorded with
`companion/docs/performance-acceptance-template.md`.
## Remaining release limitation
The Windows installer is checksum-pinned but not yet code-signed, so Windows may
show an unknown-publisher warning. The release includes its licence, privacy
notice, third-party notices, and corresponding source for the GPL-licensed OBS
bridge.
See `docs/adr/0001-companion-transcription-boundaries.md`,
`protocol/companion-protocol-v1.md`, and
`companion/docs/obs-native-caption-compatibility-spike.md`.

View File

@ -137,18 +137,23 @@ in place:
- `knowledge/community/` and `knowledge/corrections/`; - `knowledge/community/` and `knowledge/corrections/`;
- files under generated knowledge folders unless they explicitly declare both - files under generated knowledge folders unless they explicitly declare both
`generated: true` and `editable: false`; `generated: true` and `editable: false`;
- local configuration, storage, uploads, logs, secrets, environment files, and - local configuration, storage, uploads, logs, secrets, and environment files;
the plugin directory during core-only updates. - plugin data and local-only plugin code. Bundled plugin code follows the exact
stable release or managed branch being installed.
Full core updates remove stale replaceable code before copying the new version. Full core updates remove stale replaceable code before copying the new version.
Plugin code is prepared in a staging directory and swapped only after the new Plugin code is prepared in a staging directory and swapped only after the new
files are ready; plugin data is moved into the replacement as part of that files are ready; plugin data is moved into the replacement as part of that
transaction. transaction.
Core updates intentionally do not copy or delete the `plugins/` directory. Stable core updates and managed branch deployments synchronize the bundled
Core code therefore accesses optional plugin capabilities through the registered plugins from the exact selected release. Existing plugin data directories remain
framework API instead of importing files from a plugin directory. Plugins can in place, locally installed plugins absent from that release are retained, and
be installed or changed independently from their version picker. new bundled plugins are registered automatically after the restart. The same
snapshot contains both core and bundled-plugin code so an automatic restore
cannot leave those layers on different releases. Core code still accesses
optional plugin capabilities through registered framework APIs, and plugins can
be repaired, upgraded, or downgraded independently from their version picker.
## Runtime Dependencies ## Runtime Dependencies
@ -199,3 +204,10 @@ When a problem occurs only on production, the optional **Admin > Diagnostics**
page can expose the redacted `update_state`, `system_health`, and page can expose the redacted `update_state`, `system_health`, and
`recent_errors` checks to a trusted maintainer without shell or write access. `recent_errors` checks to a trusted maintainer without shell or write access.
See [Production diagnostics](production-diagnostics.md). See [Production diagnostics](production-diagnostics.md).
## Localhost Companion development updates
A source checkout can also offer checksum-addressed, same-version Companion
updates without publishing a Git tag. This path is strictly limited to a
localhost-paired Companion and never replaces the production repository update
flow. See [Localhost development updates](local-development-updates.md).

View File

@ -14,7 +14,7 @@ editable: false
Lumi is the core web UI and bot runtime. Lumi is the core web UI and bot runtime.
## Runtime ## Runtime
Package: lumi-bot Package: lumi-bot
Version: 0.2.25 Version: 0.2.26
## Routes ## Routes
- POST /api/diagnostics/v1/run - POST /api/diagnostics/v1/run
- GET /api/events - GET /api/events

View File

@ -0,0 +1,223 @@
---
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
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/companion/download
- GET /plugins/lumi_transcription/api/companion/update
- POST /plugins/lumi_transcription/api/companion/update
- GET /plugins/lumi_transcription/api/companion/dev-artifact/:buildId
- 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/tests
- GET /plugins/lumi_transcription/api/tests/:id
- 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: plain or HTML 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.
### 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/companion/download
- Purpose: Processes the lumi_transcription plugin action for api companion download.
- 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.
### GET /plugins/lumi_transcription/api/companion/update
- Purpose: Renders or serves the lumi_transcription plugin page for api companion update.
- Inputs: No request parameters detected by static analysis.
- Response format: HTML or data response; exact format was not detected statically.
- 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.
### POST /plugins/lumi_transcription/api/companion/update
- Purpose: Processes the lumi_transcription plugin action for api companion update.
- Inputs: No request parameters detected by static analysis.
- 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. 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/companion/dev-artifact/:buildId
- Purpose: Renders or serves the lumi_transcription plugin page for api companion dev artifact buildId.
- Inputs: path params: `buildId`
- Response format: file download
- 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.
### 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: query: `status`
- 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/tests
- Purpose: Renders or serves the lumi_transcription plugin page for api tests.
- 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.
### GET /plugins/lumi_transcription/api/tests/:id
- Purpose: Renders or serves the lumi_transcription plugin page for api tests id.
- Inputs: path params: `id`
- 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.
### 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

@ -0,0 +1,143 @@
---
id: plugin.now_playing
title: Song Overlay
scope: plugins
status: active
priority: 10
visibility: user
category: Plugin
tags: plugin, now_playing
generated: true
editable: false
---
# Song Overlay
Provider-neutral song state, chat announcements, and a customizable Lumi Overlay source.
## Metadata
Plugin ID: now_playing
Version: 0.1.2
Default state: enabled
## Web Routes
- /plugins/now_playing
- GET /plugins/now_playing
- POST /plugins/now_playing/settings
- POST /plugins/now_playing/overlay/install
- POST /plugins/now_playing/overlay/remove
- POST /plugins/now_playing/test-announcement
- GET /plugins/now_playing/api/companion/ping
- POST /plugins/now_playing/api/companion/state
- GET /plugins/now_playing/render/:token
- GET /plugins/now_playing/render/:token/state
- GET /plugins/now_playing/render/:token/events
- GET /plugins/now_playing/render/:token/cover/:hash
## Route Reference
### MOUNT /plugins/now_playing
- 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/now_playing
- Purpose: Renders or serves the now_playing plugin page.
- Inputs: No request parameters detected by static analysis.
- Response format: HTML page rendered from an EJS view
- 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.
### POST /plugins/now_playing/settings
- Purpose: Processes the now_playing plugin action for settings.
- Inputs: body: full submitted body is passed to a helper; exact fields are defined by the matching form/service
- Response format: HTTP redirect after handling the request
- Access: No explicit access guard detected in the route handler; check surrounding router/mount middleware.
- 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.
### POST /plugins/now_playing/overlay/install
- Purpose: Processes the now_playing plugin action for overlay install.
- Inputs: body: `scene_id`
- Response format: HTTP redirect after handling the request
- Access: No explicit access guard detected in the route handler; check surrounding router/mount middleware.
- 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.
### POST /plugins/now_playing/overlay/remove
- Purpose: Processes the now_playing plugin action for overlay remove.
- Inputs: No request parameters detected by static analysis.
- Response format: HTTP redirect after handling the request
- Access: No explicit access guard detected in the route handler; check surrounding router/mount middleware.
- 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.
### POST /plugins/now_playing/test-announcement
- Purpose: Processes the now_playing plugin action for test announcement.
- Inputs: No request parameters detected by static analysis.
- Response format: HTTP redirect after handling the request
- Access: 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.
### GET /plugins/now_playing/api/companion/ping
- Purpose: Renders or serves the now_playing plugin page for api companion ping.
- 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.
### POST /plugins/now_playing/api/companion/state
- Purpose: Processes the now_playing plugin action for api companion state.
- Inputs: body: full submitted body is passed to a helper; exact fields are defined by the matching form/service
- Response format: JSON response
- Access: No explicit access guard detected in the route handler; check surrounding router/mount middleware.
- 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/now_playing/render/:token
- Purpose: Renders or serves the now_playing plugin page for render token.
- Inputs: path params: `token`
- Response format: HTML page rendered from an EJS view
- 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.
### GET /plugins/now_playing/render/:token/state
- Purpose: Renders or serves the now_playing plugin page for render token state.
- Inputs: path params: `token`
- 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.
### GET /plugins/now_playing/render/:token/events
- Purpose: Renders or serves the now_playing plugin page for render token events.
- Inputs: path params: `token`
- Response format: streaming event response
- Access: No explicit access guard detected in the route handler; check surrounding router/mount middleware.
- 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.
### GET /plugins/now_playing/render/:token/cover/:hash
- Purpose: Renders or serves the now_playing plugin page for render token cover hash.
- Inputs: path params: `hash`, `token`
- Response format: static file 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.
## Commands
- No plugin command triggers detected.
## Source
Plugin folder: plugins/now_playing

4
package-lock.json generated
View File

@ -1,12 +1,12 @@
{ {
"name": "lumi-bot", "name": "lumi-bot",
"version": "0.2.25", "version": "0.2.26",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "lumi-bot", "name": "lumi-bot",
"version": "0.2.25", "version": "0.2.26",
"dependencies": { "dependencies": {
"adm-zip": "^0.6.0", "adm-zip": "^0.6.0",
"better-sqlite3": "^11.5.0", "better-sqlite3": "^11.5.0",

View File

@ -1,6 +1,6 @@
{ {
"name": "lumi-bot", "name": "lumi-bot",
"version": "0.2.25", "version": "0.2.26",
"private": true, "private": true,
"type": "commonjs", "type": "commonjs",
"scripts": { "scripts": {
@ -21,7 +21,9 @@
"test:ui:update": "playwright test --update-snapshots", "test:ui:update": "playwright test --update-snapshots",
"verify:webui": "node scripts/verify-webui.js && node scripts/verify-destructive-actions.js", "verify:webui": "node scripts/verify-webui.js && node scripts/verify-destructive-actions.js",
"benchmark:okf": "node scripts/benchmark-okf-search.js", "benchmark:okf": "node scripts/benchmark-okf-search.js",
"verify:content": "node scripts/verify-content-library.js" "verify:content": "node scripts/verify-content-library.js",
"verify:transcription": "node plugins/lumi_transcription/tests/verify.js",
"verify:dev-updates": "node scripts/verify-local-development-updates.js"
}, },
"engines": { "engines": {
"node": ">=18" "node": ">=18"

View File

@ -0,0 +1,8 @@
# Lumi Transcription changelog
## 0.1.0
- Added server-hosted whisper.cpp transcription with bounded Companion audio transport and no local speech inference.
- Added paired-device authentication, localhost-only development packages, revocation, diagnostics, model management, and recoverable worker supervision.
- Added voice-free full-path readiness checks and dedicated accuracy, confidence, latency, and live audio-level testing.
- Added a durable per-user Companion installer, managed OBS integration, user-approved updates, and preserved device identity.

View File

@ -0,0 +1,123 @@
const crypto = require("crypto");
const DEFAULT_CAPABILITIES = Object.freeze(["transcription.capture.v1", "transcription.settings.v1", "obs.caption.native.v1"]);
class DeviceStore {
constructor(db, options = {}) {
this.db = db;
this.now = options.now || Date.now;
this.randomBytes = options.randomBytes || crypto.randomBytes;
this.migrate();
this.cleanup();
}
migrate() {
this.db.exec(`
CREATE TABLE IF NOT EXISTS transcription_pairing_tokens (
id TEXT PRIMARY KEY, token_hash TEXT NOT NULL UNIQUE, lumi_user_id TEXT NOT NULL,
host TEXT NOT NULL, expires_at INTEGER NOT NULL, activated_at INTEGER, created_at INTEGER NOT NULL
);
CREATE TABLE IF NOT EXISTS transcription_devices (
id TEXT PRIMARY KEY, install_id TEXT, name TEXT NOT NULL, lumi_user_id TEXT NOT NULL,
credential_hash TEXT NOT NULL, capabilities_json TEXT NOT NULL, metadata_json TEXT NOT NULL,
first_connected_at INTEGER NOT NULL, last_connected_at INTEGER NOT NULL, revoked_at INTEGER,
pairing_host TEXT
);
CREATE INDEX IF NOT EXISTS transcription_devices_user_idx ON transcription_devices(lumi_user_id);
`);
ensureColumn(this.db, "transcription_devices", "pairing_host", "TEXT");
}
issuePairing({ userId, host, ttlMs = 15 * 60 * 1000 }) {
if (!userId || !host) throw new Error("A Lumi user and host are required.");
const id = crypto.randomUUID();
const token = tokenValue(this.randomBytes(32));
const now = this.now();
this.db.prepare("INSERT INTO transcription_pairing_tokens (id, token_hash, lumi_user_id, host, expires_at, activated_at, created_at) VALUES (?, ?, ?, ?, ?, NULL, ?)")
.run(id, digest(token), String(userId), normalizeHost(host), now + ttlMs, now);
return { pairing_id: id, token, host: normalizeHost(host), expires_at: now + ttlMs, protocol_version: 1 };
}
exchange({ token, device = {} }) {
const hash = digest(token);
const row = this.db.prepare("SELECT * FROM transcription_pairing_tokens WHERE token_hash = ?").get(hash);
if (!row || row.activated_at || row.expires_at < this.now()) {
const error = new Error(row?.activated_at ? "This pairing package was already activated. Download a new companion package." : "This pairing package is invalid or expired. Download a new companion package.");
error.code = row?.activated_at ? "PAIRING_ALREADY_USED" : "PAIRING_INVALID";
throw error;
}
const deviceId = crypto.randomUUID();
const secret = tokenValue(this.randomBytes(32));
const now = this.now();
const capabilities = DEFAULT_CAPABILITIES.slice();
const transaction = this.db.transaction(() => {
const consumed = this.db.prepare("UPDATE transcription_pairing_tokens SET activated_at = ? WHERE id = ? AND activated_at IS NULL").run(now, row.id);
if (consumed.changes !== 1) { const error = new Error("This pairing package was already activated. Download a new companion package."); error.code = "PAIRING_ALREADY_USED"; throw error; }
this.db.prepare("INSERT INTO transcription_devices (id, install_id, name, lumi_user_id, credential_hash, capabilities_json, metadata_json, first_connected_at, last_connected_at, revoked_at, pairing_host) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, NULL, ?)")
.run(deviceId, clean(device.install_id, 128) || null, clean(device.name, 160) || "Lumi Companion", row.lumi_user_id, digest(secret), JSON.stringify(capabilities), JSON.stringify(safeMetadata(device)), now, now, row.host);
});
transaction();
return { device_id: deviceId, device_secret: secret, host: row.host, capabilities, protocol_version: 1 };
}
authenticate(header, requiredCapability = null) {
const match = /^LumiDevice\s+([0-9a-f-]{36})\.([A-Za-z0-9_-]{40,})$/i.exec(String(header || ""));
if (!match) return { allowed: false, reason: "missing_credentials" };
const row = this.db.prepare("SELECT * FROM transcription_devices WHERE id = ?").get(match[1]);
if (!row || row.revoked_at || !safeEqual(row.credential_hash, digest(match[2]))) return { allowed: false, reason: row?.revoked_at ? "device_revoked" : "invalid_credentials" };
const capabilities = parseArray(row.capabilities_json);
if (requiredCapability && !capabilities.includes(requiredCapability)) return { allowed: false, reason: "capability_revoked" };
this.db.prepare("UPDATE transcription_devices SET last_connected_at = ? WHERE id = ?").run(this.now(), row.id);
return { allowed: true, device: serialize(row, capabilities) };
}
list(options = {}) {
const status = options.status === "revoked" ? "revoked" : options.status === "all" ? "all" : "active";
const where = status === "revoked" ? "WHERE revoked_at IS NOT NULL" : status === "active" ? "WHERE revoked_at IS NULL" : "";
return this.db.prepare(`SELECT * FROM transcription_devices ${where} ORDER BY last_connected_at DESC`).all().map((row) => serialize(row, parseArray(row.capabilities_json)));
}
revoke(deviceId) { return this.db.prepare("UPDATE transcription_devices SET revoked_at = ? WHERE id = ? AND revoked_at IS NULL").run(this.now(), deviceId).changes === 1; }
cleanup(now = this.now()) { return this.db.prepare("DELETE FROM transcription_devices WHERE revoked_at IS NOT NULL AND revoked_at <= ?").run(now - 30 * 86400000).changes; }
setCapabilities(deviceId, capabilities) {
const allowed = DEFAULT_CAPABILITIES.filter((capability) => new Set(capabilities || []).has(capability));
const changed = this.db.prepare("UPDATE transcription_devices SET capabilities_json = ? WHERE id = ? AND revoked_at IS NULL").run(JSON.stringify(allowed), deviceId).changes;
return changed ? allowed : null;
}
updateRuntime(deviceId, input = {}) {
const row = this.db.prepare("SELECT metadata_json FROM transcription_devices WHERE id = ? AND revoked_at IS NULL").get(deviceId);
if (!row) return false;
let metadata = {};
try { metadata = JSON.parse(row.metadata_json || "{}"); } catch { }
if (typeof input.bridge_installed === "boolean") metadata.bridge_installed = input.bridge_installed;
if (typeof input.bridge_connected === "boolean") metadata.bridge_connected = input.bridge_connected;
if (input.bridge_version !== undefined) metadata.bridge_version = clean(input.bridge_version, 32) || null;
if (typeof input.path_test_valid === "boolean") metadata.path_test_valid = input.path_test_valid;
if (Number.isFinite(Number(input.path_test_at))) metadata.path_test_at = Math.max(0, Number(input.path_test_at));
if (input.companion_version !== undefined) metadata.companion_version = clean(input.companion_version, 32);
if (input.companion_plugin_version !== undefined) metadata.companion_plugin_version = clean(input.companion_plugin_version, 32);
metadata.runtime_seen_at = this.now();
return this.db.prepare("UPDATE transcription_devices SET metadata_json = ?, last_connected_at = ? WHERE id = ? AND revoked_at IS NULL")
.run(JSON.stringify(metadata), this.now(), deviceId).changes === 1;
}
pairingAllowsHttp(token, requestOrigin) {
const row = this.db.prepare("SELECT host, activated_at, expires_at FROM transcription_pairing_tokens WHERE token_hash = ?").get(digest(token));
return Boolean(row && !row.activated_at && row.expires_at >= this.now() && sameLoopbackOrigin(row.host, requestOrigin));
}
}
function digest(value) { return crypto.createHash("sha256").update(String(value || ""), "utf8").digest("hex"); }
function safeEqual(left, right) { const a = Buffer.from(String(left)); const b = Buffer.from(String(right)); return a.length === b.length && crypto.timingSafeEqual(a, b); }
function tokenValue(buffer) { return Buffer.from(buffer).toString("base64url"); }
function normalizeHost(value) { const url = new URL(String(value)); if (url.protocol !== "https:" && !isLoopbackHttpOrigin(url)) throw new Error("Lumi Companion requires HTTPS. HTTP is allowed only when the pairing URL explicitly uses localhost or a loopback address."); return url.origin; }
function isLoopbackOrigin(value) { try { const url = value instanceof URL ? value : new URL(String(value)); return ["http:", "https:"].includes(url.protocol) && ["localhost", "127.0.0.1", "::1", "[::1]"].includes(url.hostname); } catch { return false; } }
function isLoopbackHttpOrigin(value) { try { const url = value instanceof URL ? value : new URL(String(value)); return url.protocol === "http:" && isLoopbackOrigin(url); } catch { return false; } }
function sameLocalhostOrigin(left, right) { try { const a = new URL(String(left)); const b = new URL(String(right)); return isLoopbackOrigin(a) && isLoopbackOrigin(b) && a.origin === b.origin; } catch { return false; } }
function sameLoopbackOrigin(left, right) { try { const a = new URL(String(left)); const b = new URL(String(right)); return isLoopbackHttpOrigin(a) && isLoopbackHttpOrigin(b) && a.origin === b.origin; } catch { return false; } }
function insecureDeviceAllowed(device, requestOrigin, remoteAddress) { return ["127.0.0.1", "::1", "::ffff:127.0.0.1"].includes(String(remoteAddress || "")) && sameLoopbackOrigin(device?.pairing_host, requestOrigin); }
function ensureColumn(db, table, column, type) { if (!db.prepare(`PRAGMA table_info(${table})`).all().some((entry) => entry.name === column)) db.exec(`ALTER TABLE ${table} ADD COLUMN ${column} ${type}`); }
function clean(value, max) { return String(value || "").trim().slice(0, max); }
function parseArray(value) { try { const parsed = JSON.parse(value); return Array.isArray(parsed) ? parsed : []; } catch { return []; } }
function safeMetadata(device) { return { companion_version: clean(device.companion_version, 32), obs_version: clean(device.obs_version, 32), os: clean(device.os, 120), architecture: clean(device.architecture, 32), hardware: clean(device.hardware, 500) }; }
function serialize(row, capabilities) { return { id: row.id, install_id: row.install_id, name: row.name, lumi_user_id: row.lumi_user_id, capabilities, metadata: JSON.parse(row.metadata_json || "{}"), first_connected_at: row.first_connected_at, last_connected_at: row.last_connected_at, revoked_at: row.revoked_at, pairing_host: row.pairing_host || null }; }
module.exports = { DeviceStore, DEFAULT_CAPABILITIES, digest, normalizeHost, isLoopbackOrigin, isLoopbackHttpOrigin, sameLocalhostOrigin, sameLoopbackOrigin, insecureDeviceAllowed };

View File

@ -0,0 +1,151 @@
const { WebSocketServer, WebSocket } = require("ws");
const { MAX_JSON_BYTES, MAX_AUDIO_PAYLOAD_BYTES, AUDIO_HEADER_BYTES, parseEnvelope, validateHello, parseAudioFrame, envelope } = require("./protocol");
const { insecureDeviceAllowed } = require("./device_store");
const MAX_CONTROL_MESSAGES_PER_SECOND = 120;
const MAX_AUDIO_MESSAGES_PER_SECOND = 200;
const MAX_SOURCE_MESSAGES_PER_SECOND = 1000;
class CompanionGateway {
constructor(options) {
this.devices = options.devices;
this.sessions = options.sessions;
this.log = options.log || { append() {} };
this.wss = new WebSocketServer({ noServer: true, maxPayload: Math.max(MAX_JSON_BYTES, AUDIO_HEADER_BYTES + MAX_AUDIO_PAYLOAD_BYTES), perMessageDeflate: false, clientTracking: true });
this.wss.on("connection", (socket, request, device) => this.connection(socket, request, device));
}
upgrade(request, socket, head) {
const forwardedProto = String(request.headers["x-forwarded-proto"] || "").split(",")[0].trim();
const proxyIsLocal = ["127.0.0.1", "::1", "::ffff:127.0.0.1"].includes(request.socket.remoteAddress);
const secure = Boolean(request.socket.encrypted) || (proxyIsLocal && forwardedProto === "https");
const auth = this.devices.authenticate(request.headers.authorization, "transcription.capture.v1");
if (!auth.allowed) return reject(socket, auth.reason === "capability_revoked" ? 403 : 401, auth.reason);
const requestOrigin = `http://${request.headers.host || "invalid"}`;
if (!secure && !insecureDeviceAllowed(auth.device, requestOrigin, request.socket.remoteAddress)) return reject(socket, 426, "tls_required");
const origin = request.headers.origin;
if (origin && !sameHostOrigin(origin, request.headers.host)) return reject(socket, 403, "origin_rejected");
this.wss.handleUpgrade(request, socket, head, (ws) => this.wss.emit("connection", ws, request, auth.device));
}
connection(socket, _request, device) {
let session = null;
let helloComplete = false;
let lastPong = Date.now();
let windowStarted = Date.now();
let controlMessagesInWindow = 0;
let audioMessagesInWindow = 0;
let sourceMessagesInWindow = 0;
let audioMessagesDropped = 0;
let sourceMessagesDropped = 0;
const sourceStates = new Map();
let messageChain = Promise.resolve();
const send = (type, payload, sessionId = session?.id || null) => {
if (socket.readyState === WebSocket.OPEN) socket.send(JSON.stringify(envelope(type, payload, sessionId)));
};
const helloTimer = setTimeout(() => closeWith(socket, 4408, "hello_timeout"), 5000);
const heartbeat = setInterval(() => {
if (Date.now() - lastPong > 30000) return closeWith(socket, 4408, "heartbeat_timeout");
send("status", { kind: "heartbeat", state: session?.state || "connecting" });
}, 10000);
helloTimer.unref?.(); heartbeat.unref?.();
socket.on("message", (data, isBinary) => {
const body = Buffer.from(data);
messageChain = messageChain.then(async () => {
try {
if (Date.now() - windowStarted >= 1000) {
if (audioMessagesDropped) this.log.append({ kind: "audio_rate_limited", device_id: device.id, session_id: session?.id, dropped_frames: audioMessagesDropped });
if (sourceMessagesDropped) this.log.append({ kind: "source_rate_limited", device_id: device.id, session_id: session?.id, dropped_updates: sourceMessagesDropped });
windowStarted = Date.now(); controlMessagesInWindow = 0; audioMessagesInWindow = 0; sourceMessagesInWindow = 0; audioMessagesDropped = 0; sourceMessagesDropped = 0;
}
if (isBinary) {
if (!helloComplete || !session) throw coded("HELLO_REQUIRED", "Complete the handshake before sending audio.");
audioMessagesInWindow += 1;
if (audioMessagesInWindow > MAX_AUDIO_MESSAGES_PER_SECOND) { audioMessagesDropped += 1; return; }
const result = await this.sessions.audio(session.id, parseAudioFrame(body));
if (result.gap) send("metric", { kind: "sequence_gap", missing_frames: result.gap });
return;
}
const message = parseEnvelope(body);
if (helloComplete && message.type === "source_update") {
const sourceId = String(message.payload?.source_uuid || "");
const signature = JSON.stringify(message.payload || {});
if (sourceId && sourceStates.get(sourceId) === signature) return;
if (sourceId) {
sourceStates.set(sourceId, signature);
if (sourceStates.size > 2048) sourceStates.delete(sourceStates.keys().next().value);
}
sourceMessagesInWindow += 1;
if (sourceMessagesInWindow > MAX_SOURCE_MESSAGES_PER_SECOND) { sourceMessagesDropped += 1; return; }
} else {
controlMessagesInWindow += 1;
if (controlMessagesInWindow > MAX_CONTROL_MESSAGES_PER_SECOND) throw coded("RATE_LIMIT", "Companion control message rate exceeded its limit.");
}
if (!helloComplete) {
const hello = validateHello(message);
this.devices.updateRuntime(device.id, { companion_version: hello.companion_version, companion_plugin_version: hello.plugin_version });
const created = this.sessions.create(device, send, hello.resume_session_id);
session = created.session;
helloComplete = true;
clearTimeout(helloTimer);
send("hello_ack", {
protocol_version: 1, resumed: created.resumed,
server_plugin_version: require("../../plugin.json").version,
capabilities: ["transcription.server.v1", "settings.revision.v1", "pcm_s16le"],
heartbeat_ms: 10000, recovery_window_ms: 5000
});
this.log.append({ kind: "connection", state: "authenticated", device_id: device.id, session_id: session.id });
return;
}
await this.structured(session, message, send, () => { lastPong = Date.now(); });
} catch (error) {
send("error", { code: error.code || "INVALID_MESSAGE", message: error.message, recoverable: !["INCOMPATIBLE_VERSION", "HELLO_REQUIRED"].includes(error.code) });
this.log.append({ kind: "protocol_error", device_id: device.id, session_id: session?.id, code: error.code || "INVALID_MESSAGE", message: error.message });
if (["INCOMPATIBLE_VERSION", "HELLO_REQUIRED", "RATE_LIMIT"].includes(error.code)) closeWith(socket, 4400, error.code);
}
});
});
socket.on("close", () => {
clearTimeout(helloTimer); clearInterval(heartbeat);
if (session) this.sessions.disconnect(session.id);
this.log.append({ kind: "connection", state: "closed", device_id: device.id, session_id: session?.id });
});
socket.on("error", (error) => this.log.append({ kind: "connection", state: "error", device_id: device.id, session_id: session?.id, message: error.message }));
}
async structured(session, message, send, pong) {
switch (message.type) {
case "ping":
pong();
send("pong", { received_id: message.id });
send("status", { kind: "readiness", ...(await this.sessions.readiness()), server_plugin_version: require("../../plugin.json").version });
break;
case "source_update": {
const source = this.sessions.updateSource(session.id, message.payload || {});
this.devices.updateRuntime(session.deviceId, { bridge_installed: true, bridge_connected: true });
send("status", { kind: "source", source });
break;
}
case "obs_state": {
const status = await this.sessions.updateObsState(session.id, message.payload || {});
this.devices.updateRuntime(session.deviceId, status);
send("status", { kind: "obs", ...status });
break;
}
case "readiness": send("status", { kind: "readiness", ...(await this.sessions.readiness()), server_plugin_version: require("../../plugin.json").version }); break;
case "start": send("status", { kind: "session", ...(await this.sessions.start(session.id, message.payload || {})) }); break;
case "stop": send("status", { kind: "session", ...(await this.sessions.stop(session.id, cleanReason(message.payload?.reason))) }); break;
case "ack": break;
default: throw coded("UNEXPECTED_MESSAGE", `Message ${message.type} is not valid after the handshake.`);
}
}
async close() {
for (const client of this.wss.clients) closeWith(client, 1001, "plugin_shutdown");
await new Promise((resolve) => this.wss.close(resolve));
}
}
function reject(socket, status, reason) { const labels = { 401: "Unauthorized", 403: "Forbidden", 426: "Upgrade Required" }; socket.write(`HTTP/1.1 ${status} ${labels[status] || "Rejected"}\r\nConnection: close\r\nContent-Type: application/json\r\n\r\n${JSON.stringify({ error: reason })}`); socket.destroy(); }
function closeWith(socket, code, reason) { if (socket.readyState === WebSocket.OPEN || socket.readyState === WebSocket.CONNECTING) socket.close(code, String(reason).slice(0, 120)); }
function sameHostOrigin(origin, host) { try { return new URL(origin).host === host; } catch { return false; } }
function coded(code, message) { return Object.assign(new Error(message), { code }); }
function cleanReason(value) { return ["requested", "test_complete", "benchmark_complete", "silence_timeout", "disconnect"].includes(String(value)) ? String(value) : "requested"; }
module.exports = { CompanionGateway, sameHostOrigin, MAX_CONTROL_MESSAGES_PER_SECOND, MAX_AUDIO_MESSAGES_PER_SECOND, MAX_SOURCE_MESSAGES_PER_SECOND };

View File

@ -0,0 +1,103 @@
const fs = require("fs");
const path = require("path");
const AdmZip = require("adm-zip");
const { ArtifactManager } = require("../models/artifact_manager");
class CompanionPackageService {
constructor(root, manifest, options = {}) {
this.root = root;
this.manifestPath = typeof manifest === "string" ? manifest : null;
this.manifest = typeof manifest === "string" ? null : manifest;
this.artifacts = new ArtifactManager(root, { fetch: options.fetch });
fs.mkdirSync(root, { recursive: true });
}
currentManifest() { return this.manifestPath ? JSON.parse(fs.readFileSync(this.manifestPath, "utf8")) : this.manifest; }
entry(manifest = this.currentManifest()) { return manifest?.artifacts?.find((entry) => entry.platform === "win32" && entry.architecture === "x64") || null; }
installerEntry(manifest = this.currentManifest()) { return manifest?.installer || null; }
status() {
const manifest = this.currentManifest();
const entry = this.entry(manifest);
if (!entry) return { available: false, installed: false, valid: false, reason: "No Windows Companion artifact is configured." };
const artifact = this.artifacts.status(entry);
return { available: true, version: manifest.version, artifact: entry.id, ...artifact };
}
async build(pairing) {
const manifest = this.currentManifest();
const entry = this.entry(manifest);
if (!entry) throw new Error("No Windows Companion artifact is configured.");
const output = new AdmZip();
const installer = this.installerEntry(manifest);
if (installer) {
let installerStatus = this.artifacts.status(installer);
if (!installerStatus.valid) installerStatus = await this.artifacts.download(installer, { confirmed: true });
const installerName = safeArchivePath(installer.filename || "Lumi.Companion-Setup.exe");
if (!installerName.toLowerCase().endsWith(".exe")) throw new Error("The Companion installer entrypoint is invalid.");
output.addFile(installerName, fs.readFileSync(installerStatus.path));
} else {
let status = this.artifacts.status(entry);
if (!status.valid) status = await this.artifacts.download(entry, { confirmed: true });
const source = new AdmZip(status.path);
let expandedBytes = 0;
for (const item of source.getEntries()) {
const name = safeArchivePath(item.entryName);
if (!name || item.isDirectory) continue;
const body = item.getData();
expandedBytes += body.length;
if (expandedBytes > 300 * 1024 * 1024) throw new Error("The Companion artifact exceeds its expanded size limit.");
output.addFile(name, body);
}
if (!output.getEntry(entry.entrypoint)) throw new Error("The Companion artifact is missing its expected application entrypoint.");
}
const pairingName = `lumi-companion-${pairing.pairing_id}.lumi-pairing.json`;
output.addFile(pairingName, Buffer.from(`${JSON.stringify(pairing.bootstrap, null, 2)}\n`, "utf8"));
output.addFile("HOST-OPERATOR-NOTICE.txt", Buffer.from(hostOperatorNotice(pairing.bootstrap), "utf8"));
const host = safeNoticeValue(pairing.bootstrap?.host, "the Lumi host identified by the pairing package", 500);
output.addFile("START-HERE.txt", Buffer.from([
"Lumi Companion", "",
"1. Extract all files in this ZIP to a temporary folder on the Windows streaming computer.",
"2. Read HOST-OPERATOR-NOTICE.txt. The installer will also display the paired host and legal notices.",
`3. Start ${installer?.filename || entry.entrypoint} within 15 minutes.`,
"4. Setup installs Companion in your Windows account, imports the adjacent one-time pairing package, and launches the durable installed copy.",
"5. After setup completes, this extracted folder can be deleted. Use the Start menu to open Lumi Companion.", "",
`Paired Lumi host: ${host}`,
"Do not share this ZIP. Its pairing package works once and expires after 15 minutes.",
"Windows may warn because this release is not code-signed yet.", ""
].join("\r\n"), "utf8"));
return { buffer: output.toBuffer(), filename: `Lumi-Companion-${manifest.version}-paired.zip`, pairingName };
}
}
function safeNoticeValue(value, fallback, maxLength = 300) {
const clean = String(value || "").replace(/[\r\n\0]/g, " ").trim().slice(0, maxLength);
return clean || fallback;
}
function hostOperatorNotice(bootstrap = {}) {
const host = safeNoticeValue(bootstrap.host, "Not identified", 500);
const operatorName = safeNoticeValue(bootstrap.operator_name, "Not supplied by this host", 200);
const operatorContact = safeNoticeValue(bootstrap.operator_contact, `Use the Lumi WebUI or administrator at ${host}`, 300);
const privacyUrl = safeNoticeValue(bootstrap.privacy_url, "Not supplied by this host", 500);
return [
"LUMI COMPANION — HOST OPERATOR NOTICE", "",
`Paired Lumi host: ${host}`,
`Operator name: ${operatorName}`,
`Operator contact: ${operatorContact}`,
`Privacy information: ${privacyUrl}`, "",
"This package connects Companion to the Lumi installation at the host shown above. The person or organisation controlling that installation is responsible for its Hosted Service, including server configuration, user access, server-side processing, retention, integrations, notices, and compliance obligations.", "",
"OokamiKunTV is the developer of Lumi Companion and is not automatically the operator of an independently hosted Lumi installation. Responsibility follows the actual facts and applicable law. The host URL is a technical identifier and may not by itself identify the operator's legal name.", ""
].join("\r\n");
}
function safeArchivePath(value) {
const portable = String(value || "").replace(/\\/g, "/");
const normalized = path.posix.normalize(portable).replace(/^\/+/, "");
if (!normalized || normalized === ".." || normalized.startsWith("../") || /^[A-Za-z]:/.test(normalized)) throw new Error("The Companion artifact contains an unsafe path.");
return normalized;
}
module.exports = { CompanionPackageService, safeArchivePath };

View File

@ -0,0 +1,110 @@
const crypto = require("crypto");
const PROTOCOL_VERSION = 1;
const AUDIO_MAGIC = Buffer.from("LACP");
const AUDIO_HEADER_BYTES = 64;
const MAX_JSON_BYTES = 64 * 1024;
const MAX_AUDIO_PAYLOAD_BYTES = 6400;
const CLIENT_TYPES = new Set(["hello", "ping", "source_update", "obs_state", "readiness", "start", "stop", "ack"]);
function parseEnvelope(input) {
const bytes = Buffer.isBuffer(input) ? input : Buffer.from(String(input || ""), "utf8");
if (!bytes.length || bytes.length > MAX_JSON_BYTES) throw protocolError("MESSAGE_SIZE", "Structured message size is invalid.");
let value;
try { value = JSON.parse(bytes.toString("utf8")); }
catch { throw protocolError("INVALID_JSON", "Structured message is not valid JSON."); }
if (!value || Array.isArray(value) || typeof value !== "object") throw protocolError("INVALID_ENVELOPE", "Message envelope must be an object.");
if (value.version !== PROTOCOL_VERSION) throw protocolError("INCOMPATIBLE_VERSION", `Protocol version ${value.version} is unsupported.`);
if (!CLIENT_TYPES.has(value.type)) throw protocolError("UNKNOWN_TYPE", "Message type is not allowed.");
if (!isUuid(value.id)) throw protocolError("INVALID_ID", "Message id must be a UUID.");
if (!value.sent_at || !Number.isFinite(Date.parse(value.sent_at))) throw protocolError("INVALID_TIME", "Message sent_at must be an ISO timestamp.");
if (value.session_id != null && !isUuid(value.session_id)) throw protocolError("INVALID_SESSION", "Session id must be a UUID.");
if (value.payload != null && (Array.isArray(value.payload) || typeof value.payload !== "object")) throw protocolError("INVALID_PAYLOAD", "Message payload must be an object.");
return value;
}
function validateHello(envelope) {
if (envelope.type !== "hello") throw protocolError("HELLO_REQUIRED", "The first message must be hello.");
const payload = envelope.payload || {};
if (!shortVersion(payload.companion_version) || !shortVersion(payload.plugin_version)) throw protocolError("INVALID_HELLO", "Companion and plugin versions are required.");
if (!Array.isArray(payload.capabilities) || payload.capabilities.length > 32) throw protocolError("INVALID_CAPABILITIES", "Capabilities are invalid.");
if (!payload.capabilities.includes("transcription.capture.v1")) throw protocolError("MISSING_CAPABILITY", "The transcription capture capability is required.");
const audio = payload.audio || {};
if (audio.codec !== "pcm_s16le" || audio.sample_rate !== 16000 || audio.channels !== 1 || audio.bits !== 16) {
throw protocolError("UNSUPPORTED_AUDIO", "Protocol v1 requires 16 kHz mono signed 16-bit PCM.");
}
if (payload.resume_session_id != null && !isUuid(payload.resume_session_id)) throw protocolError("INVALID_RESUME", "Resume session id is invalid.");
return payload;
}
function parseAudioFrame(input) {
const frame = Buffer.from(input || []);
if (frame.length < AUDIO_HEADER_BYTES || frame.length > AUDIO_HEADER_BYTES + MAX_AUDIO_PAYLOAD_BYTES) {
throw protocolError("AUDIO_SIZE", "Audio frame size is invalid.");
}
if (!frame.subarray(0, 4).equals(AUDIO_MAGIC)) throw protocolError("AUDIO_MAGIC", "Audio frame magic is invalid.");
const version = frame.readUInt8(4);
const flags = frame.readUInt8(5);
const headerBytes = frame.readUInt16LE(6);
const payloadBytes = frame.readUInt32LE(60);
if (version !== PROTOCOL_VERSION || headerBytes !== AUDIO_HEADER_BYTES || frame.length !== headerBytes + payloadBytes) {
throw protocolError("AUDIO_HEADER", "Audio frame header is invalid.");
}
if (payloadBytes > MAX_AUDIO_PAYLOAD_BYTES || payloadBytes % 2 !== 0) throw protocolError("AUDIO_PAYLOAD", "PCM payload length is invalid.");
if (frame.readUInt32LE(52) !== 16000 || frame.readUInt16LE(56) !== 1 || frame.readUInt16LE(58) !== 16) {
throw protocolError("UNSUPPORTED_AUDIO", "Audio format is unsupported.");
}
return {
version,
active: Boolean(flags & 1),
muted: Boolean(flags & 2),
sequence: frame.readUInt32LE(8),
capture_timestamp_us: Number(frame.readBigUInt64LE(12)),
session_id: bytesToUuid(frame.subarray(20, 36)),
source_uuid: bytesToUuid(frame.subarray(36, 52)),
sample_rate: 16000,
channels: 1,
bits: 16,
pcm: frame.subarray(AUDIO_HEADER_BYTES)
};
}
function encodeAudioFrame(value) {
const pcm = Buffer.from(value.pcm || []);
if (pcm.length > MAX_AUDIO_PAYLOAD_BYTES || pcm.length % 2) throw protocolError("AUDIO_PAYLOAD", "PCM payload length is invalid.");
const frame = Buffer.alloc(AUDIO_HEADER_BYTES + pcm.length);
AUDIO_MAGIC.copy(frame, 0);
frame.writeUInt8(PROTOCOL_VERSION, 4);
frame.writeUInt8((value.active === false ? 0 : 1) | (value.muted ? 2 : 0), 5);
frame.writeUInt16LE(AUDIO_HEADER_BYTES, 6);
frame.writeUInt32LE(Number(value.sequence) >>> 0, 8);
frame.writeBigUInt64LE(BigInt(value.capture_timestamp_us || 0), 12);
uuidToBytes(value.session_id).copy(frame, 20);
uuidToBytes(value.source_uuid).copy(frame, 36);
frame.writeUInt32LE(16000, 52);
frame.writeUInt16LE(1, 56);
frame.writeUInt16LE(16, 58);
frame.writeUInt32LE(pcm.length, 60);
pcm.copy(frame, AUDIO_HEADER_BYTES);
return frame;
}
function envelope(type, payload = {}, sessionId = null) {
return { version: 1, type, id: crypto.randomUUID(), sent_at: new Date().toISOString(), session_id: sessionId, payload };
}
function protocolError(code, message) {
const error = new Error(message);
error.code = code;
return error;
}
function isUuid(value) {
return /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(String(value || ""));
}
function shortVersion(value) { return typeof value === "string" && value.length > 0 && value.length <= 32; }
function uuidToBytes(value) { if (!isUuid(value)) throw protocolError("INVALID_UUID", "UUID is invalid."); return Buffer.from(value.replace(/-/g, ""), "hex"); }
function bytesToUuid(value) { const hex = Buffer.from(value).toString("hex"); return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`; }
module.exports = { PROTOCOL_VERSION, AUDIO_HEADER_BYTES, MAX_JSON_BYTES, MAX_AUDIO_PAYLOAD_BYTES, parseEnvelope, validateHello, parseAudioFrame, encodeAudioFrame, envelope, protocolError, isUuid };

View File

@ -0,0 +1,52 @@
const ALLOWED_KEYS = new Set([
"selected_model_id", "fallback_order", "decode_interval_ms", "silence_finalize_ms",
"rolling_context_ms", "caption_max_chars", "minimum_display_ms", "auto_start_stream",
"offline_authorization_severity", "diagnostic_caption_text", "tracks"
]);
class RevisionStore {
constructor(db, options = {}) { this.db = db; this.now = options.now || Date.now; this.migrate(); }
migrate() {
this.db.exec(`CREATE TABLE IF NOT EXISTS transcription_settings (
key TEXT PRIMARY KEY, value_json TEXT NOT NULL, revision INTEGER NOT NULL,
actor_id TEXT NOT NULL, updated_at INTEGER NOT NULL
);`);
}
list() {
return Object.fromEntries(this.db.prepare("SELECT * FROM transcription_settings ORDER BY key").all().map((row) => [row.key, decode(row)]));
}
apply(changes, actorId) {
if (!Array.isArray(changes) || !changes.length || changes.length > 50) throw new Error("One to fifty field changes are required.");
const normalized = changes.map(validateChange);
const applied = [];
const conflicts = [];
this.db.transaction(() => {
for (const change of normalized) {
const current = this.db.prepare("SELECT * FROM transcription_settings WHERE key = ?").get(change.key);
const currentRevision = current?.revision || 0;
if (change.base_revision !== currentRevision) {
conflicts.push({ key: change.key, local_value: change.value, local_base_revision: change.base_revision, server: current ? decode(current) : { value: null, revision: 0, actor_id: null, updated_at: null } });
continue;
}
const revision = currentRevision + 1;
const updatedAt = this.now();
this.db.prepare("INSERT INTO transcription_settings (key, value_json, revision, actor_id, updated_at) VALUES (?, ?, ?, ?, ?) ON CONFLICT(key) DO UPDATE SET value_json = excluded.value_json, revision = excluded.revision, actor_id = excluded.actor_id, updated_at = excluded.updated_at")
.run(change.key, JSON.stringify(change.value), revision, String(actorId || "unknown"), updatedAt);
applied.push({ key: change.key, value: change.value, revision, actor_id: String(actorId || "unknown"), updated_at: updatedAt });
}
})();
return { applied, conflicts, current: this.list() };
}
}
function validateChange(change) {
if (!change || !ALLOWED_KEYS.has(change.key)) throw new Error(`Setting key ${change?.key || "(missing)"} is not allowed.`);
const baseRevision = Number(change.base_revision);
if (!Number.isInteger(baseRevision) || baseRevision < 0) throw new Error("A non-negative base revision is required for every changed field.");
const encoded = JSON.stringify(change.value);
if (encoded == null || Buffer.byteLength(encoded) > 64 * 1024) throw new Error("Setting value is too large.");
return { key: change.key, value: change.value, base_revision: baseRevision };
}
function decode(row) { return { value: JSON.parse(row.value_json), revision: row.revision, actor_id: row.actor_id, updated_at: row.updated_at }; }
module.exports = { RevisionStore, ALLOWED_KEYS };

View File

@ -0,0 +1,28 @@
const { LatestCaptionGate } = require("../transcription/stabilizer");
class CaptionDeliveryAdapter {
async test() { throw new Error("Caption delivery test is not implemented."); }
async start() { throw new Error("Caption delivery start is not implemented."); }
async deliver() { throw new Error("Caption delivery is not implemented."); }
async pause() {}
async resume() {}
async stop() {}
async health() { return { healthy: false, state: "unavailable" }; }
}
class CompanionCaptionDeliveryAdapter extends CaptionDeliveryAdapter {
constructor(send) { super(); this.send = send; this.gate = new LatestCaptionGate(); this.state = "idle"; this.testMode = false; }
async test() { return { supported: true, mode: "simulated", note: "The companion simulates the exact outgoing native-caption stream without sending it to Twitch." }; }
async start(options = {}) { this.testMode = Boolean(options.testMode); this.state = "running"; return this.health(); }
async deliver(event) {
if (this.state !== "running" || !this.gate.accept(event)) return { disposition: "obsolete_or_paused" };
this.send("caption", { ...event, delivery: { disposition: this.testMode ? "simulated" : "forwarded_to_obs" } }, event.session_id);
return { disposition: this.testMode ? "simulated" : "forwarded_to_obs" };
}
async pause() { this.state = "paused"; }
async resume() { this.state = "running"; }
async stop() { this.state = "idle"; }
async health() { return { healthy: this.state !== "idle", state: this.state, adapter: "companion_obs_native", test_mode: this.testMode }; }
}
module.exports = { CaptionDeliveryAdapter, CompanionCaptionDeliveryAdapter };

Some files were not shown because too many files have changed in this diff Show More