diff --git a/TODO.md b/TODO.md index 7d7cd5d..088abce 100644 --- a/TODO.md +++ b/TODO.md @@ -39,6 +39,12 @@ 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. + Release-blocking work remains: - Package the native whisper.cpp worker as the Windows CUDA runtime, then benchmark diff --git a/companion/scripts/publish-companion.ps1 b/companion/scripts/publish-companion.ps1 index 73359e6..c54a3fb 100644 --- a/companion/scripts/publish-companion.ps1 +++ b/companion/scripts/publish-companion.ps1 @@ -1,5 +1,5 @@ param( - [string]$Version = "0.1.0-experimental.5", + [string]$Version = "0.1.0-experimental.6", [string]$BridgeVersion = "0.1.0-experimental.5" ) diff --git a/companion/src/Lumi.Companion.App/CompanionRuntime.cs b/companion/src/Lumi.Companion.App/CompanionRuntime.cs index 101a846..4587a53 100644 --- a/companion/src/Lumi.Companion.App/CompanionRuntime.cs +++ b/companion/src/Lumi.Companion.App/CompanionRuntime.cs @@ -14,6 +14,7 @@ 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; @@ -23,19 +24,24 @@ public sealed class CompanionRuntime : IAsyncDisposable private readonly CancellationTokenSource _maintenanceLifetime = new(); private CompanionSocket? _socket; private ObsBridgePipe? _obsBridge; - private TaskCompletionSource? _audioSignal; private TaskCompletionSource? _captionSignal; private TaskCompletionSource? _sessionStartSignal; + private TaskCompletionSource? _sessionStopSignal; private TaskCompletionSource? _testFailure; private TaskCompletionSource<(string SourceUuid, bool Attached)>? _bridgeSelectionSignal; + private bool? _bridgeSelectionAttached; private readonly SemaphoreSlim _bridgeSelectionGate = new(1, 1); private readonly Dictionary _benchmarkCaptions = []; private CancellationTokenSource? _benchmarkLifetime; private TaskCompletionSource? _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) { @@ -57,6 +63,7 @@ public sealed class CompanionRuntime : IAsyncDisposable public event Action>? TestStagesChanged; public event Action>? ObsSourcesChanged; public event Action? BenchmarkChanged; + public event Action? VoiceLevelChanged; public event Action? CaptionReceived; public event Action? LogAdded; public event Func? UpdateRestartRequested; @@ -135,6 +142,9 @@ public sealed class CompanionRuntime : IAsyncDisposable 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 => @@ -161,8 +171,8 @@ public sealed class CompanionRuntime : IAsyncDisposable LastConnectedAt = DateTimeOffset.Now }); foreach (var source in ObsSources) await SendSourceUpdateAsync(source); - if (State.ObsConnected) - await _socket.SendAsync("obs_state", new { streaming = State.ObsStreaming, recording = State.ObsRecording, auto_start = _settings.Current.StartWithObs }, _socket.SessionId, cancellationToken); + await SendRuntimeStateAsync(cancellationToken); + await _socket.SendAsync("readiness", new { }, _socket.SessionId, cancellationToken); await WriteLogAsync("connected", $"Connected to {credential.Host}."); await CheckForUpdatesAsync(cancellationToken); } @@ -192,9 +202,9 @@ public sealed class CompanionRuntime : IAsyncDisposable 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; - _audioSignal = new(TaskCreationOptions.RunContinuationsAsynchronously); _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); @@ -204,34 +214,36 @@ public sealed class CompanionRuntime : IAsyncDisposable MarkStage(stages, 5, TestStageState.Blocked, sessionStart.Failure ?? "Lumi did not confirm that speech recognition started within 8 seconds."); return; } - MarkStage(stages, 3, TestStageState.Running, "Speak normally into the selected microphone…"); - var audio = await WaitForTestSignalAsync(_audioSignal.Task, TimeSpan.FromSeconds(10), cancellationToken); - if (!audio.Completed) - { - MarkStage(stages, 3, TestStageState.Blocked, audio.Failure ?? "No active microphone audio reached the companion within 10 seconds."); - return; - } - MarkStage(stages, 3, TestStageState.Passed, "Live microphone audio reached the companion."); - MarkStage(stages, 5, TestStageState.Running, "Waiting for server-hosted speech recognition…"); - var caption = await WaitForTestSignalAsync(_captionSignal.Task, TimeSpan.FromSeconds(30), cancellationToken); + 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, 5, TestStageState.Blocked, caption.Failure ?? "No caption returned within 30 seconds. Check the loaded model and host diagnostics."); + MarkStage(stages, 6, TestStageState.Blocked, caption.Failure ?? "The safe test caption did not return within 8 seconds."); return; } - MarkStage(stages, 5, TestStageState.Passed, "Speech recognition returned a stable result."); - MarkStage(stages, 6, TestStageState.Passed, "A caption returned over the secure connection."); + MarkStage(stages, 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."); - SetState(State with { Detail = "The safe end-to-end transcription test passed.", Health = TrayHealth.Ready }); + 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); } catch { } - _audioSignal = null; + 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 }); } @@ -254,6 +266,7 @@ public sealed class CompanionRuntime : IAsyncDisposable 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 { @@ -293,6 +306,7 @@ public sealed class CompanionRuntime : IAsyncDisposable } finally { + VoiceLevelChanged?.Invoke(-60); SetState(State with { BenchmarkRunning = false, BenchmarkDetail = Benchmark.Words.Count > 0 ? $"Test complete with {Benchmark.Words.Count} measured words or phrases." : "The test ended without a finalized transcript." }); _sessionStartSignal = null; _testFailure = null; @@ -388,6 +402,8 @@ public sealed class CompanionRuntime : IAsyncDisposable { 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) @@ -404,6 +420,8 @@ public sealed class CompanionRuntime : IAsyncDisposable { 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) @@ -426,6 +444,8 @@ public sealed class CompanionRuntime : IAsyncDisposable 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}."); } @@ -451,15 +471,27 @@ public sealed class CompanionRuntime : IAsyncDisposable private Task OnServerMessageAsync(ServerEnvelope message) { - if (message.Type == "status" && message.Payload.TryGetProperty("kind", out var kind) && kind.GetString() == "session" && - message.Payload.TryGetProperty("state", out var state) && state.GetString() == "running") + if (message.Type == "status" && message.Payload.TryGetProperty("kind", out var statusKind) && statusKind.GetString() == "readiness") { - _sessionStartSignal?.TrySetResult(true); - if (message.Payload.TryGetProperty("benchmark_id", out var benchmarkId) && benchmarkId.ValueKind == JsonValueKind.String) + _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") { - Benchmark = Benchmark with { Id = benchmarkId.GetString(), Status = "Running" }; - BenchmarkChanged?.Invoke(Benchmark); + _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") { @@ -499,9 +531,12 @@ public sealed class CompanionRuntime : IAsyncDisposable _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(); } @@ -514,8 +549,8 @@ public sealed class CompanionRuntime : IAsyncDisposable var streaming = ReadBoolean(message, "streaming"); var recording = ReadBoolean(message, "recording"); SetState(State with { ObsConnected = true, ObsStreaming = streaming, ObsRecording = recording, Health = streaming ? TrayHealth.Operating : TrayHealth.Ready, Detail = streaming ? "OBS is live and companion services are active." : "OBS is connected and ready." }); - if (_socket is not null && State.Connected) - await _socket.SendAsync("obs_state", new { streaming, recording, version = ReadString(message, "version"), auto_start = _settings.Current.StartWithObs }, _socket.SessionId, _lifetimeToken()); + RefreshPathReadiness(); + await SendRuntimeStateAsync(_lifetimeToken(), ReadString(message, "version")); } else if (type == "source_list" && message.TryGetProperty("sources", out var sources) && sources.ValueKind == JsonValueKind.Array) { @@ -525,8 +560,10 @@ public sealed class CompanionRuntime : IAsyncDisposable ReadBoolean(source, "program_active") || ReadBoolean(source, "active"), ReadBoolean(source, "source_missing") || ReadBoolean(source, "missing"))) .Where(source => Guid.TryParse(source.Uuid, out _)).ToArray(); + _bridgeSelectionAttached = null; ObsSourcesChanged?.Invoke(ObsSources); foreach (var source in ObsSources) await SendSourceUpdateAsync(source); + RefreshPathReadiness(); _ = SyncBridgeSelectionAsync(_lifetimeToken()); } else if (type == "source_state") @@ -537,6 +574,7 @@ public sealed class CompanionRuntime : IAsyncDisposable 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") @@ -547,9 +585,14 @@ public sealed class CompanionRuntime : IAsyncDisposable private Task OnObsAudioAsync(ReadOnlyMemory frame) { - if (HasVoice(frame.Span)) + 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) { - _audioSignal?.TrySetResult(true); if (State.BenchmarkRunning) _benchmarkLastVoiceAt = DateTimeOffset.UtcNow; } if (_socket is not null && State.Connected && !_socket.QueueBridgeAudio(frame)) @@ -575,7 +618,7 @@ public sealed class CompanionRuntime : IAsyncDisposable private async Task SyncBridgeSelectionAsync(CancellationToken cancellationToken = default) { - if (_obsBridge is null || string.IsNullOrWhiteSpace(_settings.Current.PrimarySourceUuid)) return false; + if (_obsBridge is null || string.IsNullOrWhiteSpace(_settings.Current.PrimarySourceUuid)) { _bridgeSelectionAttached = false; RefreshPathReadiness(); return false; } await _bridgeSelectionGate.WaitAsync(cancellationToken); try { @@ -589,11 +632,20 @@ public sealed class CompanionRuntime : IAsyncDisposable source_uuids = new[] { sourceUuid }, primary_source_uuid = sourceUuid }, cancellationToken); - if (!sent) return false; - var completed = await Task.WhenAny(signal.Task, Task.Delay(TimeSpan.FromSeconds(2), cancellationToken)); - if (completed != signal.Task) { cancellationToken.ThrowIfCancellationRequested(); return true; } - var result = await signal.Task; - return result.Attached && string.Equals(result.SourceUuid, sourceUuid, StringComparison.OrdinalIgnoreCase); + 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 { @@ -685,9 +737,62 @@ public sealed class CompanionRuntime : IAsyncDisposable return values[lower] + (values[upper] - values[lower]) * (position - lower); } - private static bool HasVoice(ReadOnlySpan frame) + private async Task SendRuntimeStateAsync(CancellationToken cancellationToken, string? obsVersion = null) { - if (frame.Length <= ProtocolV1.AudioHeaderBytes || (frame[5] & 1) == 0 || (frame[5] & 2) != 0) return false; + 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 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; @@ -696,7 +801,9 @@ public sealed class CompanionRuntime : IAsyncDisposable var value = BinaryPrimitives.ReadInt16LittleEndian(samples.Slice(index * 2, 2)) / 32768.0; squares += value * value; } - return count > 0 && Math.Sqrt(squares / count) >= 0.012; + 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) => @@ -783,10 +890,10 @@ public sealed class CompanionRuntime : IAsyncDisposable new("OBS integration", "Waiting to check the managed bridge.", TestStageState.Waiting), new("OBS connection", "Waiting for OBS.", TestStageState.Waiting), new("Microphone", "Waiting for a selected source.", TestStageState.Waiting), - new("Audio capture", "Waiting for live microphone audio.", TestStageState.Waiting), + new("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 real server-hosted inference.", TestStageState.Waiting), - new("Caption return", "Waiting for a stable caption.", 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) ]; diff --git a/companion/src/Lumi.Companion.App/CompanionSettingsStore.cs b/companion/src/Lumi.Companion.App/CompanionSettingsStore.cs index a34d384..5987a76 100644 --- a/companion/src/Lumi.Companion.App/CompanionSettingsStore.cs +++ b/companion/src/Lumi.Companion.App/CompanionSettingsStore.cs @@ -8,7 +8,9 @@ public sealed record CompanionSettings( bool StartWithObs = true, bool AdvancedMode = false, string? PrimarySourceUuid = null, - string? PrimarySourceName = null); + string? PrimarySourceName = null, + DateTimeOffset? PathTestPassedAt = null, + string? PathTestFingerprint = null); public sealed class CompanionSettingsStore { diff --git a/companion/src/Lumi.Companion.App/CompanionState.cs b/companion/src/Lumi.Companion.App/CompanionState.cs index 3034542..74f3786 100644 --- a/companion/src/Lumi.Companion.App/CompanionState.cs +++ b/companion/src/Lumi.Companion.App/CompanionState.cs @@ -22,6 +22,8 @@ public sealed record CompanionState( 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…") diff --git a/companion/src/Lumi.Companion.App/Lumi.Companion.App.csproj b/companion/src/Lumi.Companion.App/Lumi.Companion.App.csproj index f4bab1c..cdd3f13 100644 --- a/companion/src/Lumi.Companion.App/Lumi.Companion.App.csproj +++ b/companion/src/Lumi.Companion.App/Lumi.Companion.App.csproj @@ -5,7 +5,7 @@ enable enable app.manifest - 0.1.0-experimental.5 + 0.1.0-experimental.6 0.1.0.0 @@ -21,5 +21,8 @@ + + + diff --git a/companion/src/Lumi.Companion.App/MainWindow.axaml b/companion/src/Lumi.Companion.App/MainWindow.axaml index 299f779..255f207 100644 --- a/companion/src/Lumi.Companion.App/MainWindow.axaml +++ b/companion/src/Lumi.Companion.App/MainWindow.axaml @@ -128,7 +128,7 @@ - + @@ -58,7 +59,7 @@ <% if (devices.length) { %>
    <% devices.forEach((device) => { %> -
  • <%= device.name %>Last connected <%= new Date(device.last_connected_at).toLocaleString() %><% if (device.metadata?.companion_version) { %> · Companion <%= device.metadata.companion_version %><% } %>
    Allowed
  • +
  • <%= device.name %>Last connected <%= new Date(device.last_connected_at).toLocaleString() %><% if (device.metadata?.companion_version) { %> · Companion <%= displayCompanionVersion(device.metadata.companion_version) %><% } %>
    Allowed
  • <% }) %>
<% } %>